From d10e080f3685598f5a1c7b18ccaba1a61397f224 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 26 Jun 2026 17:44:06 -0400 Subject: [PATCH 1/9] Derive Kandelo dock UI from Playground prototype --- apps/browser-demos/pages/kandelo/app/App.tsx | 203 ++-- apps/browser-demos/pages/kandelo/app/Dock.tsx | 330 +++++++ .../pages/kandelo/app/NewMachinePane.tsx | 155 +++ .../pages/kandelo/dialogs/ShareDialog.tsx | 64 +- apps/browser-demos/pages/kandelo/styles.css | 893 ++++++++++++++++++ .../pages/kandelo/views/Gallery.tsx | 5 +- .../pages/kandelo/views/MachineView.tsx | 230 +++-- docs/browser-support.md | 17 + 8 files changed, 1693 insertions(+), 204 deletions(-) create mode 100644 apps/browser-demos/pages/kandelo/app/Dock.tsx create mode 100644 apps/browser-demos/pages/kandelo/app/NewMachinePane.tsx diff --git a/apps/browser-demos/pages/kandelo/app/App.tsx b/apps/browser-demos/pages/kandelo/app/App.tsx index ad42f181f..922f1eeed 100644 --- a/apps/browser-demos/pages/kandelo/app/App.tsx +++ b/apps/browser-demos/pages/kandelo/app/App.tsx @@ -1,47 +1,75 @@ -// Top-level Kandelo app. View router (sidebar item → main panel content); -// holds the per-session UI state (current view, inspector tab, share dialog -// open). -// -// Today only the 'machine' surface is wired. Other views show a placeholder -// while their components are built. +// Top-level Kandelo app. The machine remains the primary canvas; the dock +// switches machine views and opens exploratory panes for gallery, config, +// sharing, and new machine setup. import * as React from "react"; -import { useKernelHost, useStatus } from "../kernel-host/react"; -import { Sidebar, type ViewId, type InternalsTab } from "./Sidebar"; -import { MachineView } from "../views/MachineView"; +import { useKernelHost } from "../kernel-host/react"; +import { Dock, DockPane, type DockPaneId, type DockViewId } from "./Dock"; +import { NewMachinePane } from "./NewMachinePane"; +import { MachineView, useMachineSurfaceController } from "../views/MachineView"; import { Gallery, descriptorFromGalleryItem } from "../views/Gallery"; import { Config } from "../views/Config"; import { EmptyState } from "../views/EmptyState"; -import { ShareDialog } from "../dialogs/ShareDialog"; +import { SharePanel } from "../dialogs/ShareDialog"; import { createShellTerminal, type ShellTerminal } from "../panes/Shell"; import { navigateToGalleryItemUrl } from "../url-state"; import type { BootDescriptor, GalleryItem } from "../../../../../web-libs/kandelo-session/src/kernel-host"; +type InternalsTab = "syslog" | "procs" | "vfs" | "lazy-load" | "config" | "syscalls"; + +const PANE_META: Record = { + new: { + title: "New machine", + subtitle: "Start from a preset, open a Kandelo URL, or review image import boundaries.", + }, + gallery: { + title: "Gallery", + subtitle: "Published Kandelo systems and local demo images.", + }, + config: { + title: "This machine", + subtitle: "Edit the current boot descriptor through KernelHost.", + }, + share: { + title: "Share and export", + subtitle: "Create a Kandelo URL and check what state it can carry.", + }, +}; + export const App: React.FC = () => { const host = useKernelHost(); - const status = useStatus(); + const surface = useMachineSurfaceController(); - const [view, setView] = React.useState("machine"); + const [dockPane, setDockPane] = React.useState(null); const [internalsTab, setInternalsTab] = React.useState("syslog"); + const [shareTarget, setShareTarget] = React.useState(null); const [terminals, setTerminals] = React.useState(() => [createShellTerminal(1)]); const [activeTerminalId, setActiveTerminalId] = React.useState("tty-1"); const nextTerminalIndex = React.useRef(2); - /** - * Share dialog state. `null` = closed; `true` = sharing the running - * machine; a BootDescriptor = sharing a gallery preset that hasn't been - * applied yet (e.g. user clicked the share icon on a gallery card). - */ - const [shareTarget, setShareTarget] = React.useState(null); const desc = host.getBootDescriptor(); - const onNav = (id: ViewId) => { - if (id === "share") { - setShareTarget(true); - return; - } - setView(id); - }; + const closeDockPane = React.useCallback(() => { + setDockPane(null); + setShareTarget(null); + }, []); + + const selectDockPane = React.useCallback((pane: DockPaneId | null) => { + setShareTarget(null); + setDockPane((current) => current === pane ? null : pane); + }, []); + + const selectMachineView = React.useCallback((view: DockViewId) => { + setShareTarget(null); + setDockPane(null); + surface.chooseView(view); + }, [surface]); + + const applyDescriptor = React.useCallback((d: BootDescriptor) => { + void host.applyBootDescriptor(d).then(closeDockPane).catch((err) => { + console.warn("applyBootDescriptor failed:", err); + }); + }, [host, closeDockPane]); const onLaunchGalleryItem = React.useCallback((item: GalleryItem) => { if (item.vfsImageUrl) { @@ -50,14 +78,14 @@ export const App: React.FC = () => { } const next = descriptorFromGalleryItem(item, host.getBootDescriptor()); - setView("machine"); - void host.applyBootDescriptor(next).catch((err) => { + void host.applyBootDescriptor(next).then(closeDockPane).catch((err) => { console.warn("applyBootDescriptor failed:", err); }); - }, [host]); + }, [host, closeDockPane]); const onShareGalleryItem = React.useCallback((item: GalleryItem) => { setShareTarget(descriptorFromGalleryItem(item, host.getBootDescriptor())); + setDockPane("share"); }, [host]); const onAddTerminal = React.useCallback(() => { @@ -66,33 +94,21 @@ export const App: React.FC = () => { setActiveTerminalId(terminal.id); }, []); - const isMachineView = view === "machine" || view === "internals"; - const isEmpty = isMachineView && status === "idle"; - const flushMain = view === "gallery" || view === "browse" || view === "export" || view === "config" || isEmpty; - - const onApplyPastedDescriptor = React.useCallback((d: BootDescriptor) => { - void host.applyBootDescriptor(d).catch((err) => { - console.warn("applyBootDescriptor failed:", err); - }); - }, [host]); + const isEmpty = surface.status === "idle"; + const meta = dockPane ? PANE_META[dockPane] : null; return ( -
- - -
+
+
{isEmpty ? ( setView("gallery")} - onApplyDescriptor={onApplyPastedDescriptor} + onBrowseAll={() => setDockPane("gallery")} + onApplyDescriptor={applyDescriptor} /> - ) : isMachineView ? ( + ) : ( setInternalsTab(t as InternalsTab)} terminals={terminals} @@ -100,56 +116,57 @@ export const App: React.FC = () => { onActiveTerminalId={setActiveTerminalId} onAddTerminal={onAddTerminal} /> - ) : view === "gallery" ? ( - - ) : view === "config" ? ( - setView("machine")} /> - ) : ( - )}
- {shareTarget && ( - setShareTarget(null)} - /> + {dockPane && meta && ( + + {dockPane === "new" && ( + setDockPane("gallery")} + onApplyDescriptor={applyDescriptor} + /> + )} + {dockPane === "gallery" && ( + + )} + {dockPane === "config" && ( + + )} + {dockPane === "share" && ( + + )} + )} -
- ); -}; -const PlaceholderView: React.FC<{ view: ViewId }> = ({ view }) => { - const label = - view === "gallery" ? "Gallery" - : view === "browse" ? "Browse Systems" - : view === "config" ? "System Config" - : view === "export" ? "Export VFS" - : "View"; - return ( -
-
{label}
-
- This surface is in the implementation order but not built yet. The - Sidebar/LiveURLBar/MachineView chassis comes first; this view is - wired once the chassis is signed off. -
+
); }; diff --git a/apps/browser-demos/pages/kandelo/app/Dock.tsx b/apps/browser-demos/pages/kandelo/app/Dock.tsx new file mode 100644 index 000000000..6c43e3c7c --- /dev/null +++ b/apps/browser-demos/pages/kandelo/app/Dock.tsx @@ -0,0 +1,330 @@ +import * as React from "react"; +import markUrl from "../assets/kandelo-mark.png"; +import type { MachineStatus } from "../../../../../web-libs/kandelo-session/src/kernel-host"; + +export type DockPaneId = "new" | "gallery" | "config" | "share"; +export type DockViewId = "demo" | "terminal" | "internals"; + +interface DockItem { + id: T; + label: string; + title: string; + icon: React.ReactNode; +} + +const VIEW_ITEMS: DockItem[] = [ + { + id: "demo", + label: "Demo", + title: "Demo surface", + icon: , + }, + { + id: "terminal", + label: "Terminal", + title: "Terminal", + icon: , + }, + { + id: "internals", + label: "Internals", + title: "Internals", + icon: , + }, +]; + +const PANE_ITEMS: DockItem[] = [ + { + id: "new", + label: "New", + title: "New machine", + icon: , + }, + { + id: "gallery", + label: "Gallery", + title: "Gallery", + icon: , + }, + { + id: "config", + label: "Config", + title: "System config", + icon: , + }, + { + id: "share", + label: "Share", + title: "Share machine", + icon: , + }, +]; + +type DockCssProperties = React.CSSProperties & { + "--kdock-body-h"?: string; + "--kdock-center"?: string; +}; + +export const Dock: React.FC<{ + activePane: DockPaneId | null; + activeView: DockViewId | null; + status: MachineStatus; + machineTitle?: string; + viewDisabled?: Partial>; + onSelectPane: (pane: DockPaneId | null) => void; + onSelectView: (view: DockViewId) => void; +}> = ({ activePane, activeView, status, machineTitle, viewDisabled = {}, onSelectPane, onSelectView }) => { + const shellRef = React.useRef(null); + const bodyRef = React.useRef(null); + const dragRef = React.useRef<{ + pointerId: number; + startX: number; + startCenter: number; + width: number; + moved: boolean; + } | null>(null); + const suppressHeaderClickRef = React.useRef(false); + const [collapsed, setCollapsed] = React.useState(false); + const [bodyHeight, setBodyHeight] = React.useState(72); + const [dockCenter, setDockCenter] = React.useState(null); + const [dragging, setDragging] = React.useState(false); + const statusLabel = formatMachineStatus(status); + const title = machineTitle || "Kandelo machine"; + + const clampCenter = React.useCallback((center: number, width?: number): number => { + const viewportWidth = window.innerWidth; + const margin = 8; + const dockWidth = width ?? shellRef.current?.getBoundingClientRect().width ?? 0; + + if (dockWidth + margin * 2 >= viewportWidth) { + return viewportWidth / 2; + } + + const half = dockWidth / 2; + return Math.min(viewportWidth - half - margin, Math.max(half + margin, center)); + }, []); + + React.useLayoutEffect(() => { + const body = bodyRef.current; + if (!body) { + return; + } + + const updateBodyHeight = () => { + setBodyHeight(Math.ceil(body.getBoundingClientRect().height)); + }; + updateBodyHeight(); + + const observer = new ResizeObserver(updateBodyHeight); + observer.observe(body); + return () => observer.disconnect(); + }, []); + + React.useEffect(() => { + const onResize = () => { + setDockCenter((center) => center === null ? null : clampCenter(center)); + }; + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [clampCenter]); + + const onHeaderPointerDown = React.useCallback((event: React.PointerEvent) => { + if (event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey) { + return; + } + + const shell = shellRef.current; + if (!shell || window.matchMedia("(max-width: 860px)").matches) { + return; + } + + const rect = shell.getBoundingClientRect(); + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startCenter: dockCenter ?? rect.left + rect.width / 2, + width: rect.width, + moved: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, [dockCenter]); + + const onHeaderPointerMove = React.useCallback((event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + + const deltaX = event.clientX - drag.startX; + if (Math.abs(deltaX) > 3) { + drag.moved = true; + setDragging(true); + } + + if (drag.moved) { + event.preventDefault(); + setDockCenter(clampCenter(drag.startCenter + deltaX, drag.width)); + } + }, [clampCenter]); + + const onHeaderPointerUp = React.useCallback((event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (drag.moved) { + suppressHeaderClickRef.current = true; + window.setTimeout(() => { + suppressHeaderClickRef.current = false; + }, 0); + } + dragRef.current = null; + setDragging(false); + }, []); + + const onHeaderClick = React.useCallback((event: React.MouseEvent) => { + if (suppressHeaderClickRef.current) { + suppressHeaderClickRef.current = false; + event.preventDefault(); + return; + } + setCollapsed((value) => !value); + }, []); + + const dockStyle: DockCssProperties = { + "--kdock-body-h": `${bodyHeight}px`, + }; + if (dockCenter !== null) { + dockStyle["--kdock-center"] = `${Math.round(dockCenter)}px`; + } + + return ( + + ); +}; + +function formatMachineStatus(status: MachineStatus): string { + switch (status) { + case "idle": + return "No machine"; + case "booting": + return "Booting"; + case "running": + return "Running"; + case "halted": + return "Halted"; + case "error": + return "Error"; + default: + return status; + } +} + +export const DockPane: React.FC<{ + pane: DockPaneId; + title: string; + subtitle?: string; + onClose: () => void; + children: React.ReactNode; +}> = ({ pane, title, subtitle, onClose, children }) => ( +
+
+
+

{title}

+ +
+ {subtitle &&

{subtitle}

} +
+
{children}
+
+); diff --git a/apps/browser-demos/pages/kandelo/app/NewMachinePane.tsx b/apps/browser-demos/pages/kandelo/app/NewMachinePane.tsx new file mode 100644 index 000000000..01d840a21 --- /dev/null +++ b/apps/browser-demos/pages/kandelo/app/NewMachinePane.tsx @@ -0,0 +1,155 @@ +import * as React from "react"; +import { useGalleryItems } from "../kernel-host/react"; +import { decodeBootDescriptor } from "../../../../../web-libs/kandelo-session/src/boot-descriptor"; +import type { + BootDescriptor, + GalleryItem, +} from "../../../../../web-libs/kandelo-session/src/kernel-host"; + +type NewMachineSource = "presets" | "url" | "image"; + +const SOURCE_TABS: Array<{ id: NewMachineSource; label: string }> = [ + { id: "presets", label: "Presets" }, + { id: "url", label: "Kandelo URL" }, + { id: "image", label: "VFS image" }, +]; + +export const NewMachinePane: React.FC<{ + onLaunchItem: (item: GalleryItem) => void; + onBrowseAll: () => void; + onApplyDescriptor: (desc: BootDescriptor) => void; +}> = ({ onLaunchItem, onBrowseAll, onApplyDescriptor }) => { + const { items, loading } = useGalleryItems("presets"); + const featured = items.slice(0, 6); + const [source, setSource] = React.useState("presets"); + const [url, setUrl] = React.useState(""); + const [error, setError] = React.useState(null); + + const bootUrl = async () => { + if (!url.trim()) return; + setError(null); + const hashIndex = url.indexOf("#"); + const fragment = hashIndex === -1 ? url : url.slice(hashIndex + 1); + try { + const descriptor = await decodeBootDescriptor(fragment); + if (!descriptor) { + setError("Paste a Kandelo URL or k1= fragment."); + return; + } + onApplyDescriptor(descriptor); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + return ( +
+
+ {SOURCE_TABS.map((tab) => ( + + ))} +
+ + {source === "presets" && ( +
+
+
+

Start from a preset

+

Boot a published Kandelo VFS image through the normal gallery descriptor path.

+
+ +
+ + {loading ? ( +
Loading presets...
+ ) : featured.length === 0 ? ( +
No presets are available.
+ ) : ( +
+ {featured.map((item) => ( + + ))} +
+ )} +
+ )} + + {source === "url" && ( +
+
+
+

Open a Kandelo URL

+

Paste a share link or raw k1 fragment. Decoding and booting stay inside KernelHost.

+
+
+
+ setUrl(event.target.value)} + placeholder="https://kandelo.dev/c/shell#k1=..." + /> + +
+ {error &&
{error}
} +
+ Malformed, oversized, or unsupported descriptors fail visibly instead of + being patched into a demo-only boot path. +
+
+ )} + + {source === "image" && ( +
+
+
+

Bring a VFS image

+

Direct image import remains a platform boundary until the host has a durable upload/mount contract.

+
+
+
+
+ Available now + Gallery images and Kandelo URLs +
+
+ Not yet available + Local image upload, durable browser mount, or trusted archive import +
+
+
+ This pane does not fake success for local files. Image import needs a real + KernelHost/VFS contract with size caps, path validation, and persistence semantics. +
+
+ )} +
+ ); +}; diff --git a/apps/browser-demos/pages/kandelo/dialogs/ShareDialog.tsx b/apps/browser-demos/pages/kandelo/dialogs/ShareDialog.tsx index aa560687f..80efdbac9 100644 --- a/apps/browser-demos/pages/kandelo/dialogs/ShareDialog.tsx +++ b/apps/browser-demos/pages/kandelo/dialogs/ShareDialog.tsx @@ -29,8 +29,29 @@ export interface ShareDialogProps { onClose: () => void; } -export const ShareDialog: React.FC = ({ - descriptor: presetDesc, presetId, onClose, +export interface SharePanelProps extends ShareDialogProps { + embedded?: boolean; +} + +export const ShareDialog: React.FC = (props) => { + const onBackdropClick: React.MouseEventHandler = (event) => { + if (event.target === event.currentTarget) props.onClose(); + }; + + const onKeyDown: React.KeyboardEventHandler = (event) => { + if (event.key === "Escape") props.onClose(); + }; + + return createPortal( +
+ +
, + document.body, + ); +}; + +export const SharePanel: React.FC = ({ + descriptor: presetDesc, presetId, onClose, embedded = false, }) => { const host = useKernelHost(); const [mode, setMode] = React.useState("auto"); @@ -90,6 +111,10 @@ export const ShareDialog: React.FC = ({ const tier = classifyTier(url.length); const tierPct = url.length === 0 ? 0 : Math.min(100, (url.length / (8 * 1024)) * 100); + const shareTargetLabel = presetDesc ? "selected preset" : "current machine"; + const overlaySummary = includeOverlay + ? "Overlay-capable modes may include current descriptor edits when KernelHost can encode them." + : "The link is limited to the base preset/descriptor; local edits stay in this browser."; const copy = () => { if (!url) return; @@ -98,14 +123,6 @@ export const ShareDialog: React.FC = ({ window.setTimeout(() => setCopied(false), 1400); }; - const onBackdropClick: React.MouseEventHandler = (e) => { - if (e.target === e.currentTarget) onClose(); - }; - - const onKeyDown: React.KeyboardEventHandler = (e) => { - if (e.key === "Escape") onClose(); - }; - const renderUrl = () => { if (!url) return computing…; const m = url.match(/^(https:\/\/)([^/]+)(\/[^#]*)(#.*)?$/); @@ -120,9 +137,14 @@ export const ShareDialog: React.FC = ({ ); }; - return createPortal( -
-
e.stopPropagation()} role="dialog" aria-modal="true"> + return ( +
e.stopPropagation()} + role={embedded ? undefined : "dialog"} + aria-modal={embedded ? undefined : true} + > + {!embedded && (
@@ -133,8 +155,22 @@ export const ShareDialog: React.FC = ({
Share this machine
+ )}
+
+
+
Share target
+
{shareTargetLabel}
+

{overlaySummary}

+
+
+
Export boundary
+
No VFS archive export
+

Use this URL flow for descriptor sharing. Full image export is not simulated here.

+
+
+ {/* URL + tier */}
Link
@@ -274,8 +310,6 @@ export const ShareDialog: React.FC = ({
-
, - document.body, ); }; diff --git a/apps/browser-demos/pages/kandelo/styles.css b/apps/browser-demos/pages/kandelo/styles.css index 2e4c0b021..355da943e 100644 --- a/apps/browser-demos/pages/kandelo/styles.css +++ b/apps/browser-demos/pages/kandelo/styles.css @@ -267,6 +267,704 @@ padding: 0; gap: 0; } +.kdocked-main { + padding-bottom: 98px; +} +.kdocked-main.kmain-flush { + padding-bottom: 98px; +} + +/* ── Exploratory dock ──────────────────────────────────────────────────── */ + +.kdock-shell { + --kdock-notch-w: 84px; + position: fixed; + left: var(--kdock-center, 50%); + bottom: 0; + z-index: 1000; + transform: translateX(-50%); + display: flex; + flex-direction: column; + width: max-content; + max-width: calc(100vw - 24px); + border: 1px solid color-mix(in oklch, var(--k-border) 78%, transparent); + border-bottom: 0; + border-radius: 14px 14px 0 0; + background: color-mix(in oklch, var(--k-surface) 92%, transparent); + box-shadow: + 0 1px 0 color-mix(in oklch, white 70%, transparent) inset, + 0 -16px 45px rgba(55, 42, 19, 0.18), + 0 -4px 12px rgba(55, 42, 19, 0.12); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + clip-path: inset(0 round 14px 14px 0 0); + transition: + transform 0.24s cubic-bezier(0.22, 1, 0.36, 1), + clip-path 0.24s cubic-bezier(0.22, 1, 0.36, 1); +} + +.kdock-shell.kdock-collapsed { + clip-path: inset(0 calc(50% - var(--kdock-notch-w) / 2) 0 calc(50% - var(--kdock-notch-w) / 2) round 14px 14px 0 0); + transform: translateX(-50%) translateY(var(--kdock-body-h, 72px)); +} + +.kdock-shell.kdock-dragging { + transition: clip-path 0.24s cubic-bezier(0.22, 1, 0.36, 1); +} + +.kdock-header { + width: 100%; + height: 24px; + border: 0; + border-radius: 14px 14px 0 0; + background: transparent; + color: var(--k-text-faint); + cursor: grab; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: color 0.12s; +} + +.kdock-header:hover, +.kdock-header:focus-visible { + color: var(--k-text); +} + +.kdock-header:active, +.kdock-shell.kdock-dragging .kdock-header { + cursor: grabbing; +} + +.kdock-header:focus-visible { + outline: 2px solid var(--k-accent); + outline-offset: -3px; +} + +.kdock-header svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; + transition: transform 0.24s cubic-bezier(0.22, 1, 0.36, 1); +} + +.kdock-shell.kdock-collapsed .kdock-header svg { + transform: rotate(180deg); +} + +.kdock-body { + display: flex; + align-items: center; + gap: 8px; + max-width: 100%; + min-width: 0; + padding: 6px; + box-sizing: border-box; +} + +.kdock-body[aria-hidden="true"] { + pointer-events: none; +} + +.kdock-status { + min-width: 172px; + max-width: 240px; + height: 48px; + border: 1px solid var(--k-border); + border-radius: 10px; + background: color-mix(in oklch, var(--k-surface-sunk) 70%, transparent); + color: var(--k-text); + cursor: pointer; + display: flex; + align-items: center; + gap: 9px; + padding: 0 12px; + font: inherit; + text-align: left; +} + +.kdock-status img { + width: 24px; + height: auto; + display: block; +} + +.kdock-status-text { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + color: var(--k-text-muted); + font-size: 10px; + font-weight: 750; + letter-spacing: 0.06em; + line-height: 1.1; + text-transform: uppercase; + white-space: nowrap; +} + +.kdock-status-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.kdock-status-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--k-text); + font-size: 12px; + font-weight: 760; + letter-spacing: 0; + line-height: 1.15; +} + +.kdock-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +.kdock-status-text[data-status="running"] { + color: var(--k-ok); +} + +.kdock-status-text[data-status="booting"] { + color: var(--k-warn); +} + +.kdock-status-text[data-status="error"] { + color: var(--k-err); +} + +.kdock-status-text[data-status="idle"], +.kdock-status-text[data-status="halted"] { + color: var(--k-text-faint); +} + +.kdock { + display: flex; + align-items: stretch; + gap: 4px; + min-width: 0; +} + +.kdock-section { + display: flex; + align-items: stretch; + gap: 4px; + min-width: max-content; + flex: 0 0 auto; +} + +.kdock-separator { + width: 1px; + align-self: stretch; + margin: 8px 8px; + border-radius: 999px; + background: color-mix(in oklch, var(--k-border-strong) 72%, transparent); + flex: 0 0 auto; +} + +.kdock-item { + min-width: 66px; + height: 48px; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--k-text-muted); + cursor: pointer; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + padding: 6px 9px; + font: inherit; + transition: background 0.12s, color 0.12s, transform 0.12s; +} + +.kdock-item:hover:not(:disabled) { + background: color-mix(in oklch, var(--k-text) 7%, transparent); + color: var(--k-text); + transform: translateY(-1px); +} + +.kdock-item:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.kdock-item[aria-current="true"] { + background: color-mix(in oklch, var(--k-accent) 16%, var(--k-surface)); + color: var(--k-accent); +} + +.kdock-item[aria-current="true"]:disabled { + opacity: 0.62; +} + +.kdock-icon { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.kdock-icon svg { + width: 18px; + height: 18px; +} + +.kdock-label { + width: 100%; + max-width: 64px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; + font-size: 10.5px; + font-weight: 720; + letter-spacing: 0; +} + +.kdock-pane { + position: fixed; + left: 50%; + bottom: 100px; + z-index: 990; + width: min(980px, calc(100vw - 28px)); + height: min(720px, calc(100dvh - 128px)); + transform: translateX(-50%); + display: flex; + flex-direction: column; + border: 1px solid var(--k-border); + border-radius: 14px; + background: var(--k-surface); + box-shadow: + 0 1px 0 color-mix(in oklch, white 68%, transparent) inset, + 0 24px 70px rgba(55, 42, 19, 0.28), + 0 4px 14px rgba(55, 42, 19, 0.16); + overflow: hidden; + animation: kdock-pane-in 0.16s ease-out; +} + +.kdock-pane-new { + width: min(860px, calc(100vw - 28px)); + height: min(620px, calc(100dvh - 116px)); +} + +.kdock-pane-share { + width: min(720px, calc(100vw - 28px)); + height: min(640px, calc(100dvh - 116px)); +} + +.kdock-pane-internals { + width: min(1060px, calc(100vw - 28px)); + height: min(680px, calc(100dvh - 116px)); +} + +.kdock-pane-header { + flex-shrink: 0; + padding: 18px 22px 12px; + border-bottom: 1px solid var(--k-border); + background: + linear-gradient(180deg, color-mix(in oklch, var(--k-surface) 92%, white), var(--k-surface)); +} + +.kdock-pane-title-row { + display: flex; + align-items: center; + gap: 14px; +} + +.kdock-pane-title-row h2 { + flex: 1; + min-width: 0; + margin: 0; + color: var(--k-text); + font-size: 26px; + font-weight: 780; + letter-spacing: 0; + line-height: 1.1; +} + +.kdock-pane-header p { + max-width: 760px; + margin: 6px 0 0; + color: var(--k-text-muted); + font-size: 13px; + line-height: 1.45; +} + +.kdock-pane-close { + width: 30px; + height: 30px; + border: 1px solid var(--k-border); + border-radius: 8px; + background: var(--k-surface); + color: var(--k-text-muted); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.kdock-pane-close:hover { + color: var(--k-text); + border-color: var(--k-border-strong); + background: var(--k-surface-alt); +} + +.kdock-pane-body { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + overflow: hidden; +} + +.kdock-pane .kgallery, +.kdock-pane .kcfg { + flex: 1; + background: var(--k-surface); +} + +.kdock-pane .kgallery { + padding-top: 18px; +} + +.kdock-pane .kcfg { + padding: 18px 24px 0; +} + +.kdock-pane .kcfg-title, +.kdock-pane .kcfg-sub { + display: none; +} + +.kdock-pane .kcfg-foot { + margin-left: -24px; + margin-right: -24px; + padding-left: 24px; + padding-right: 24px; +} + +.kdock-pane .kpane { + flex: 1; + border-radius: 0; + box-shadow: none; +} + +.kshare-embedded { + width: 100%; + max-height: none; + flex: 1; + border-radius: 0; + box-shadow: none; +} + +.kshare-embedded .kshare-body { + padding: 18px 24px; +} + +.kshare-embedded .kshare-actions { + padding: 12px 24px; +} + +/* ── New machine pane ──────────────────────────────────────────────────── */ + +.knew { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: 14px; + overflow: auto; + padding: 20px 24px 24px; + background: var(--k-surface); +} + +.knew-tabs { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 4px; + padding: 4px; + border: 1px solid var(--k-border); + border-radius: var(--k-radius); + background: var(--k-surface-sunk); +} + +.knew-tab { + min-height: 34px; + flex: 1 1 0; + min-width: 0; + border: 0; + border-radius: var(--k-radius-sm); + background: transparent; + color: var(--k-text-muted); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 740; + letter-spacing: 0; +} + +.knew-tab:hover { + background: color-mix(in oklch, var(--k-text) 6%, transparent); + color: var(--k-text); +} + +.knew-tab[aria-selected="true"] { + background: var(--k-surface); + color: var(--k-accent); + box-shadow: 0 1px 0 color-mix(in oklch, white 70%, transparent) inset; +} + +.knew-panel, +.knew-section { + display: flex; + flex-direction: column; + gap: 12px; + border: 1px solid var(--k-border); + border-radius: var(--k-radius); + background: color-mix(in oklch, var(--k-bg) 64%, var(--k-surface)); + padding: 14px; +} + +.knew-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.knew-panel h3, +.knew-section h3 { + margin: 0; + color: var(--k-text); + font-size: 14px; + font-weight: 720; + letter-spacing: 0; + line-height: 1.2; +} + +.knew-panel p, +.knew-section p { + max-width: 620px; + margin: 4px 0 0; + color: var(--k-text-muted); + font-size: 12.5px; + line-height: 1.45; +} + +.knew-link { + flex-shrink: 0; + border: 1px solid var(--k-border); + border-radius: var(--k-radius-sm); + background: var(--k-surface); + color: var(--k-accent); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 700; + padding: 6px 10px; +} + +.knew-link:hover { + border-color: var(--k-border-strong); + background: var(--k-surface-alt); +} + +.knew-presets { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 8px; +} + +.knew-preset { + min-width: 0; + min-height: 68px; + display: grid; + grid-template-columns: 38px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + border: 1px solid var(--k-border); + border-radius: var(--k-radius-sm); + background: var(--k-surface); + color: var(--k-text); + cursor: pointer; + padding: 9px; + text-align: left; + font: inherit; +} + +.knew-preset:hover { + border-color: var(--k-border-strong); + background: var(--k-surface-alt); +} + +.knew-preset-glyph { + width: 34px; + height: 34px; + border-radius: var(--k-radius-sm); + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + font-family: var(--k-font-mono); + font-size: 13px; + font-weight: 800; + letter-spacing: 0; +} + +.knew-preset-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.knew-preset-title { + overflow: hidden; + color: var(--k-text); + font-size: 13px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.knew-preset-sub { + overflow: hidden; + color: var(--k-text-muted); + font-size: 11.5px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.knew-preset-meta { + border-left: 1px solid var(--k-border); + color: var(--k-text-faint); + font-family: var(--k-font-mono); + font-size: 10px; + padding-left: 9px; + white-space: nowrap; +} + +.knew-empty { + padding: 16px; + color: var(--k-text-faint); + font-family: var(--k-font-mono); + font-size: 12px; +} + +.knew-url-row { + display: flex; + gap: 8px; +} + +.knew-input { + flex: 1; + min-width: 0; + border: 1px solid var(--k-border); + border-radius: var(--k-radius-sm); + background: var(--k-surface-sunk); + color: var(--k-text); + font: inherit; + font-family: var(--k-font-mono); + font-size: 12px; + outline: none; + padding: 8px 10px; +} + +.knew-input:focus { + border-color: var(--k-accent); + background: var(--k-surface); +} + +.knew-primary { + flex-shrink: 0; + border: 1px solid var(--k-accent); + border-radius: var(--k-radius-sm); + background: var(--k-accent); + color: #fff; + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 700; + padding: 0 12px; +} + +.knew-primary:hover { + border-color: var(--k-accent-fire); + background: var(--k-accent-fire); +} + +.knew-error { + color: var(--k-err); + font-family: var(--k-font-mono); + font-size: 11.5px; +} + +.knew-boundary { + border-style: dashed; + background: color-mix(in oklch, var(--k-info) 7%, var(--k-surface)); +} + +.knew-note { + border: 1px solid color-mix(in oklch, var(--k-info) 28%, var(--k-border)); + border-radius: var(--k-radius-sm); + background: color-mix(in oklch, var(--k-info) 8%, var(--k-surface)); + color: var(--k-text-muted); + font-size: 12px; + line-height: 1.5; + padding: 10px 12px; +} + +.knew-boundary-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.knew-boundary-list > div { + min-width: 0; + border: 1px solid var(--k-border); + border-radius: var(--k-radius-sm); + background: var(--k-surface); + padding: 10px 12px; +} + +.knew-boundary-list span { + display: block; + margin-bottom: 4px; + color: var(--k-text-faint); + font-size: 10px; + font-weight: 750; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.knew-boundary-list strong { + color: var(--k-text); + font-size: 12.5px; + font-weight: 700; + line-height: 1.35; +} .kurl { display: flex; @@ -404,6 +1102,15 @@ gap: 8px; } +.kmachine-statusline { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-height: 24px; + flex-shrink: 0; +} + .kmachine-toolbar { display: flex; align-items: center; @@ -1902,6 +2609,43 @@ overflow: auto; min-height: 0; } + +.kshare-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.kshare-summary-card { + min-width: 0; + border: 1px solid var(--k-border); + border-radius: var(--k-radius-sm); + background: color-mix(in oklch, var(--k-bg) 60%, var(--k-surface)); + padding: 10px 12px; +} + +.kshare-summary-k { + color: var(--k-text-faint); + font-size: 10px; + font-weight: 760; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.kshare-summary-v { + margin-top: 4px; + color: var(--k-text); + font-size: 13px; + font-weight: 720; +} + +.kshare-summary-card p { + margin: 5px 0 0; + color: var(--k-text-muted); + font-size: 11.5px; + line-height: 1.4; +} + .kshare-sect-lbl { font-size: 10.5px; font-weight: 700; @@ -2607,8 +3351,157 @@ line-height: 1.4; } +@media (max-width: 860px) { + .kdock-shell { + left: 0; + right: 0; + bottom: 0; + width: auto; + max-width: none; + border-radius: 14px 14px 0 0; + transform: none; + clip-path: none; + } + + .kdock-shell.kdock-collapsed { + clip-path: none; + transform: translateY(var(--kdock-body-h, 72px)); + } + + .kdock-header { + border-radius: 14px 14px 0 0; + cursor: pointer; + } + + .kdock-shell.kdock-dragging .kdock-header, + .kdock-header:active { + cursor: pointer; + } + + .kdock-body { + width: 100%; + padding: 6px 10px calc(6px + env(safe-area-inset-bottom, 0px)); + } + + .kdock-status { + min-width: 58px; + max-width: 58px; + justify-content: center; + padding: 8px; + } + + .kdock-status-copy { + display: none; + } + + .kdock { + flex: 1; + overflow-x: auto; + scrollbar-width: none; + } + + .kdock::-webkit-scrollbar { + display: none; + } + + .kdock-item { + flex: 0 0 62px; + } + + .kdock-pane { + bottom: 96px; + width: calc(100vw - 20px); + height: min(720px, calc(100dvh - 118px)); + } + + .kdock-pane-header { + padding: 20px 20px 12px; + } + + .kdock-pane-title-row h2 { + font-size: 24px; + } + + .knew, + .kdock-pane .kgallery, + .kdock-pane .kcfg, + .kshare-embedded .kshare-body { + padding-left: 16px; + padding-right: 16px; + } + + .kdock-pane .kcfg-foot, + .kshare-embedded .kshare-actions { + padding-left: 16px; + padding-right: 16px; + } + + .kdock-pane .kcfg-foot { + margin-left: -16px; + margin-right: -16px; + } + + .knew-url-row { + flex-direction: column; + } + + .knew-primary { + min-height: 34px; + } + + .kshare-modes { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .kshare-summary, + .knew-boundary-list { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .kdock { + padding: 5px; + } + + .kdock-item { + flex-basis: 62px; + padding-left: 6px; + padding-right: 6px; + } + + .kdock-label { + font-size: 10px; + } + + .kdock-pane { + bottom: 86px; + width: calc(100vw - 16px); + height: calc(100dvh - 94px); + } + + .knew-presets { + grid-template-columns: 1fr; + } + + .kshare-modes { + grid-template-columns: 1fr; + } +} + /* ── Animations ─────────────────────────────────────────────────────────── */ +@keyframes kdock-pane-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(12px) scale(0.985); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + @keyframes k-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } diff --git a/apps/browser-demos/pages/kandelo/views/Gallery.tsx b/apps/browser-demos/pages/kandelo/views/Gallery.tsx index 85760593b..3e4fa6ef0 100644 --- a/apps/browser-demos/pages/kandelo/views/Gallery.tsx +++ b/apps/browser-demos/pages/kandelo/views/Gallery.tsx @@ -14,9 +14,10 @@ import type { export interface GalleryProps { onLaunch: (item: GalleryItem) => void; onShare?: (item: GalleryItem) => void; + compact?: boolean; } -export const Gallery: React.FC = ({ onLaunch, onShare }) => { +export const Gallery: React.FC = ({ onLaunch, onShare, compact = false }) => { const [q, setQ] = React.useState(""); const { items, loading } = useGalleryItems("presets"); @@ -27,7 +28,7 @@ export const Gallery: React.FC = ({ onLaunch, onShare }) => { return (
-

Gallery

+ {!compact &&

Gallery

}
diff --git a/apps/browser-demos/pages/kandelo/views/MachineView.tsx b/apps/browser-demos/pages/kandelo/views/MachineView.tsx index f1a294e3a..7d2c60568 100644 --- a/apps/browser-demos/pages/kandelo/views/MachineView.tsx +++ b/apps/browser-demos/pages/kandelo/views/MachineView.tsx @@ -19,32 +19,40 @@ import { Display, type DisplayHandle, type WordPressLoginOptions } from "../pane import { Shell, type ShellTerminal } from "../panes/Shell"; import { DemoGuide } from "../panes/DemoGuide"; import type { DemoActionConfig } from "../../../../../web-libs/kandelo-session/src/demo-config"; -import type { LazyDownloadEvent, PrimarySurface, SurfaceAvailability } from "../../../../../web-libs/kandelo-session/src/kernel-host"; +import type { + DemoPresentation, + LazyDownloadEvent, + MachineStatus, + PrimarySurface, + SurfaceAvailability, +} from "../../../../../web-libs/kandelo-session/src/kernel-host"; const DEMO_GUIDE_DEFAULT_WIDTH = 300; const DEMO_GUIDE_MIN_WIDTH = 220; const DEMO_GUIDE_MAX_WIDTH = 480; const DEMO_GUIDE_PRIMARY_MIN_WIDTH = 480; -export interface MachineViewProps { - focusInternals?: boolean; - internalsTab: string; - onInternalsTab: (id: string) => void; - terminals: ShellTerminal[]; - activeTerminalId: string; - onActiveTerminalId: (id: string) => void; - onAddTerminal: () => void; +export type MachineSurfaceView = "demo" | "terminal" | "internals"; + +export interface MachineSurfaceController { + status: MachineStatus; + presentation: DemoPresentation; + availability: SurfaceAvailability; + activePrimary: PrimarySurface; + activeView: MachineSurfaceView; + primaryLabel: string; + demoSurface: PrimarySurface | null; + canOpenDemo: boolean; + canUseTerminal: boolean; + canUseInternals: boolean; + shouldMountDemoSurface: boolean; + choosePrimary: (surface: PrimarySurface) => void; + chooseView: (view: MachineSurfaceView) => void; + followDemoSurface: () => void; + focusInternals: () => void; } -export const MachineView: React.FC = ({ - focusInternals = false, - internalsTab, - onInternalsTab, - terminals, - activeTerminalId, - onActiveTerminalId, - onAddTerminal, -}) => { +export function useMachineSurfaceController(): MachineSurfaceController { const status = useStatus(); const presentation = usePresentation(); const rawAvailability = useSurfaceAvailability(); @@ -53,17 +61,8 @@ export const MachineView: React.FC = ({ ...rawAvailability, web: rawAvailability.web && webPreview?.status === "running", }), [rawAvailability, webPreview?.status]); - const demoGuide = useDemoGuide(); - const lazyDownloads = useLazyDownloads(); - const rootRef = React.useRef(null); - const displayRef = React.useRef(null); const [activePrimary, setActivePrimary] = React.useState(presentation.bootPrimary); const [primaryMode, setPrimaryMode] = React.useState<"following-demo" | "pinned">("following-demo"); - const [terminalOpen, setTerminalOpen] = React.useState(false); - const [internalsOpen, setInternalsOpen] = React.useState(false); - const [terminalDrawerHeight, setTerminalDrawerHeight] = React.useState(320); - const [internalsDrawerHeight, setInternalsDrawerHeight] = React.useState(320); - const [demoGuideWidth, setDemoGuideWidth] = React.useState(DEMO_GUIDE_DEFAULT_WIDTH); const previousAvailability = React.useRef(availability); const canUseTerminal = status === "running" && availability.terminal; @@ -96,13 +95,115 @@ export const MachineView: React.FC = ({ }, [activePrimary, availability, presentation.runningPrimary, status]); React.useEffect(() => { - if (!focusInternals) return; + setPrimaryMode("following-demo"); + }, [presentation.runningPrimary, presentation.autoCommand]); + + const choosePrimary = React.useCallback((surface: PrimarySurface) => { + if (status !== "running" && surface !== "syslog") return; + if (!isSurfaceAvailable(surface, availability)) return; + setActivePrimary(surface); + setPrimaryMode(surface === defaultPrimary ? "following-demo" : "pinned"); + }, [availability, defaultPrimary, status]); + + const demoSurface = React.useMemo( + () => resolveDemoSurface(presentation.runningPrimary), + [presentation.runningPrimary], + ); + const canOpenDemo = + demoSurface !== null && + isSurfaceAvailable(demoSurface, availability) && + status === "running"; + const shouldMountDemoSurface = + demoSurface !== null && + status === "running" && + isSurfaceAvailable(demoSurface, availability); + const canUseInternals = status !== "idle" && isSurfaceAvailable("syslog", availability); + + const chooseView = React.useCallback((view: MachineSurfaceView) => { + if (view === "demo") { + if (demoSurface) choosePrimary(demoSurface); + return; + } + choosePrimary(view === "terminal" ? "terminal" : "syslog"); + }, [choosePrimary, demoSurface]); + + const followDemoSurface = React.useCallback(() => { + if (!demoSurface) return; + setActivePrimary(demoSurface); + setPrimaryMode("following-demo"); + }, [demoSurface]); + + const focusInternals = React.useCallback(() => { setActivePrimary("syslog"); setPrimaryMode("pinned"); - }, [focusInternals, internalsTab]); + }, []); + + const activeView: MachineSurfaceView = + activePrimary === "terminal" + ? "terminal" + : activePrimary === "syslog" + ? "internals" + : "demo"; + + return { + status, + presentation, + availability, + activePrimary, + activeView, + primaryLabel: surfaceLabel(activePrimary), + demoSurface, + canOpenDemo, + canUseTerminal, + canUseInternals, + shouldMountDemoSurface, + choosePrimary, + chooseView, + followDemoSurface, + focusInternals, + }; +} + +export interface MachineViewProps { + surface: MachineSurfaceController; + internalsTab: string; + onInternalsTab: (id: string) => void; + terminals: ShellTerminal[]; + activeTerminalId: string; + onActiveTerminalId: (id: string) => void; + onAddTerminal: () => void; +} + +export const MachineView: React.FC = ({ + surface, + internalsTab, + onInternalsTab, + terminals, + activeTerminalId, + onActiveTerminalId, + onAddTerminal, +}) => { + const demoGuide = useDemoGuide(); + const lazyDownloads = useLazyDownloads(); + const rootRef = React.useRef(null); + const displayRef = React.useRef(null); + const [terminalOpen, setTerminalOpen] = React.useState(false); + const [internalsOpen, setInternalsOpen] = React.useState(false); + const [terminalDrawerHeight, setTerminalDrawerHeight] = React.useState(320); + const [internalsDrawerHeight, setInternalsDrawerHeight] = React.useState(320); + const [demoGuideWidth, setDemoGuideWidth] = React.useState(DEMO_GUIDE_DEFAULT_WIDTH); + const { + status, + presentation, + activePrimary, + primaryLabel, + demoSurface, + canUseTerminal, + shouldMountDemoSurface, + followDemoSurface, + } = surface; React.useEffect(() => { - setPrimaryMode("following-demo"); setTerminalOpen(false); setInternalsOpen(false); }, [presentation.runningPrimary, presentation.autoCommand]); @@ -111,29 +212,16 @@ export const MachineView: React.FC = ({ if (!canUseTerminal) setTerminalOpen(false); }, [canUseTerminal]); - const choosePrimary = (surface: PrimarySurface) => { - if (status !== "running" && surface !== "syslog") return; - if (!isSurfaceAvailable(surface, availability)) return; - setActivePrimary(surface); - setPrimaryMode(surface === defaultPrimary ? "following-demo" : "pinned"); - }; - - const primaryLabel = surfaceLabel(activePrimary); - const demoSurface = resolveDemoSurface(presentation.runningPrimary); - const runWebAction = React.useCallback(async (action: DemoActionConfig): Promise => { if (action.kind === "web.wordpressLogin") { - if (demoSurface) { - setActivePrimary(demoSurface); - setPrimaryMode("following-demo"); - } + followDemoSurface(); const preview = displayRef.current; if (!preview) throw new Error("Web preview is not available"); await preview.loginToWordPress(parseWordPressLoginPayload(action.payload)); return "Logged into WordPress"; } throw new Error(`Unsupported web action: ${action.kind}`); - }, [demoSurface]); + }, [followDemoSurface]); const shellProps = { terminals, @@ -142,14 +230,6 @@ export const MachineView: React.FC = ({ onAddTerminal, }; - const canOpenDemo = - demoSurface !== null && - isSurfaceAvailable(demoSurface, availability) && - status === "running"; - const shouldMountDemoSurface = - demoSurface !== null && - status === "running" && - isSurfaceAvailable(demoSurface, availability); const showDemoGuide = demoGuide !== null; const beginDrawerResize = ( @@ -241,33 +321,12 @@ export const MachineView: React.FC = ({ return (
-
-
- { - if (demoSurface) choosePrimary(demoSurface); - }} - label="Demo" - /> - choosePrimary("terminal")} - label="Terminal" - /> - choosePrimary("syslog")} - label="Internals" - /> -
-
+ {lazyDownloads.length > 0 && ( +
{primaryLabel}
-
+ )}
void; -}> = ({ label, active, disabled, onClick }) => ( - -); - const PrimarySurfaceSlot: React.FC<{ active: boolean; children: React.ReactNode; diff --git a/docs/browser-support.md b/docs/browser-support.md index 01bd75a9e..709a78cf9 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -164,6 +164,23 @@ The "Boot pattern" column reflects how the demo enters the kernel: Run the browser app: `cd apps/browser-demos && npm run dev`, then open `http://127.0.0.1:5401/`. +### Kandelo session UI + +The Kandelo app at `/pages/kandelo/` keeps the running machine as the primary +browser canvas and exposes related tools through a bottom dock. Dock controls +switch between Demo, Terminal, and Internals views, while dock panes open for +new-machine setup, gallery browsing, system config, and sharing. These controls +consume `KernelHost` state and actions rather than replacing the runtime path. + +The dock may be collapsed or moved horizontally within the browser viewport; +that placement is UI-only presentation state and does not alter the running +machine, boot descriptor, VFS image, or share/export data. + +Image-declared demo guides from `/etc/kandelo/demo.json` remain part of the +machine presentation owned by the demo image. Guide actions may run terminal or +web actions through `KernelHost`, but they do not replace process supervision, +VFS state, networking, or runtime behavior. + Cross-origin browser fetches are routed through `public/service-worker.js`, which defaults to `https://wordpress-playground-cors-proxy.net/?`. Override it with `VITE_CORS_PROXY_URL` when testing another proxy: From d9827386274606e17a586b939ba4c26785afb551 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 1 Jul 2026 18:15:55 -0400 Subject: [PATCH 2/9] Polish Kandelo dock UI interactions Refine the browser demo dock, theme defaults, gallery table, terminal focus behavior, display fitting, and Kandelo favicon so the UI matches the Playground-inspired direction while keeping demos on the normal KernelHost/runtime path. --- apps/browser-demos/index.html | 25 + apps/browser-demos/pages/kandelo/app/App.tsx | 533 ++++- apps/browser-demos/pages/kandelo/app/Dock.tsx | 658 ++++-- .../pages/kandelo/assets/favicon.png | Bin 0 -> 3936 bytes apps/browser-demos/pages/kandelo/index.html | 1 + .../pages/kandelo/kernel-host/live-setup.ts | 4 +- .../pages/kandelo/kernel-host/react.tsx | 4 +- .../pages/kandelo/panes/DemoGuide.tsx | 21 +- .../pages/kandelo/panes/Display.tsx | 225 +- .../pages/kandelo/panes/Framebuffer.tsx | 166 +- .../pages/kandelo/panes/Inspector.tsx | 32 +- .../pages/kandelo/panes/Modeset.tsx | 101 +- .../pages/kandelo/panes/Shell.tsx | 205 +- .../pages/kandelo/panes/canvasFit.ts | 62 + apps/browser-demos/pages/kandelo/presets.ts | 2 +- apps/browser-demos/pages/kandelo/styles.css | 1912 +++++++++++------ apps/browser-demos/pages/kandelo/tokens.css | 400 +++- .../pages/kandelo/views/Gallery.tsx | 292 ++- .../pages/kandelo/views/MachineView.tsx | 328 +-- .../test/kandelo-modeset.spec.ts | 12 +- 20 files changed, 3376 insertions(+), 1607 deletions(-) create mode 100644 apps/browser-demos/pages/kandelo/assets/favicon.png create mode 100644 apps/browser-demos/pages/kandelo/panes/canvasFit.ts diff --git a/apps/browser-demos/index.html b/apps/browser-demos/index.html index 2fec1d726..fc6133c04 100644 --- a/apps/browser-demos/index.html +++ b/apps/browser-demos/index.html @@ -4,10 +4,35 @@ Kandelo + + diff --git a/apps/browser-demos/pages/kandelo/app/App.tsx b/apps/browser-demos/pages/kandelo/app/App.tsx index 922f1eeed..8effc1e78 100644 --- a/apps/browser-demos/pages/kandelo/app/App.tsx +++ b/apps/browser-demos/pages/kandelo/app/App.tsx @@ -1,70 +1,156 @@ // Top-level Kandelo app. The machine remains the primary canvas; the dock -// switches machine views and opens exploratory panes for gallery, config, -// sharing, and new machine setup. +// switches machine views and opens exploratory panes for gallery and overlays. import * as React from "react"; -import { useKernelHost } from "../kernel-host/react"; -import { Dock, DockPane, type DockPaneId, type DockViewId } from "./Dock"; -import { NewMachinePane } from "./NewMachinePane"; +import { useDemoGuide, useKernelHost, useLazyDownloads } from "../kernel-host/react"; +import { Dock, DockPane, type DockLayoutState, type DockPaneId, type DockViewId } from "./Dock"; import { MachineView, useMachineSurfaceController } from "../views/MachineView"; import { Gallery, descriptorFromGalleryItem } from "../views/Gallery"; -import { Config } from "../views/Config"; import { EmptyState } from "../views/EmptyState"; -import { SharePanel } from "../dialogs/ShareDialog"; import { createShellTerminal, type ShellTerminal } from "../panes/Shell"; +import { Inspector, INSPECTOR_TABS } from "../panes/Inspector"; import { navigateToGalleryItemUrl } from "../url-state"; -import type { BootDescriptor, GalleryItem } from "../../../../../web-libs/kandelo-session/src/kernel-host"; +import type { + BootDescriptor, + GalleryItem, + LazyDownloadEvent, +} from "../../../../../web-libs/kandelo-session/src/kernel-host"; type InternalsTab = "syslog" | "procs" | "vfs" | "lazy-load" | "config" | "syscalls"; +type ThemeFamily = "playground" | "balanced" | "terminal"; +type ResolvedThemeMode = "light" | "dark"; +type ThemeMode = ResolvedThemeMode | "auto"; +type ThemePreference = { + family: ThemeFamily; + mode: ThemeMode; +}; + +const THEME_STORAGE_KEY = "kandelo.theme"; +const THEME_STORAGE_VERSION = 1; + +type StoredThemePreference = ThemePreference & { + version: typeof THEME_STORAGE_VERSION; +}; + +const DEFAULT_THEME: ThemePreference = { family: "playground", mode: "auto" }; +const THEME_FAMILIES: Array<{ family: ThemeFamily; label: string; description: string }> = [ + { family: "playground", label: "Playground", description: "WordPress-style product neutrals and blue accents." }, + { family: "balanced", label: "Kandelo Ember", description: "Warm parchment and orange from the public Kandelo UI." }, + { family: "terminal", label: "Kandelo Terminal", description: "Primer-style neutrals with amber terminal cues." }, +]; +const THEME_MODES: Array<{ mode: ThemeMode; label: string }> = [ + { mode: "auto", label: "Auto" }, + { mode: "light", label: "Light" }, + { mode: "dark", label: "Dark" }, +]; const PANE_META: Record = { - new: { - title: "New machine", - subtitle: "Start from a preset, open a Kandelo URL, or review image import boundaries.", - }, gallery: { title: "Gallery", subtitle: "Published Kandelo systems and local demo images.", }, - config: { - title: "This machine", - subtitle: "Edit the current boot descriptor through KernelHost.", - }, - share: { - title: "Share and export", - subtitle: "Create a Kandelo URL and check what state it can carry.", - }, }; export const App: React.FC = () => { const host = useKernelHost(); + const demoGuide = useDemoGuide(); + const lazyDownloads = useLazyDownloads(); const surface = useMachineSurfaceController(); const [dockPane, setDockPane] = React.useState(null); + const [dockHeight, setDockHeight] = React.useState(0); + const [dockLayout, setDockLayout] = React.useState({ collapsed: false, fullWidth: false }); + const [demoGuideOpen, setDemoGuideOpen] = React.useState(demoGuide !== null); + const [demoDockControls, setDemoDockControls] = React.useState(null); + const [demoGuidePopup, setDemoGuidePopup] = React.useState(null); + const [internalsOpen, setInternalsOpen] = React.useState(false); const [internalsTab, setInternalsTab] = React.useState("syslog"); - const [shareTarget, setShareTarget] = React.useState(null); + const [theme, setTheme] = React.useState(() => readThemePreference()); + const [systemThemeMode, setSystemThemeMode] = React.useState(() => getSystemThemeMode()); + const [themeOpen, setThemeOpen] = React.useState(false); const [terminals, setTerminals] = React.useState(() => [createShellTerminal(1)]); const [activeTerminalId, setActiveTerminalId] = React.useState("tty-1"); const nextTerminalIndex = React.useRef(2); const desc = host.getBootDescriptor(); + const resolvedThemeMode = theme.mode === "auto" ? systemThemeMode : theme.mode; + + React.useEffect(() => { + const query = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = () => setSystemThemeMode(query.matches ? "dark" : "light"); + onChange(); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }, []); + + React.useEffect(() => { + const root = document.documentElement; + root.dataset.kTheme = theme.family; + root.dataset.kMode = resolvedThemeMode; + root.dataset.kModePreference = theme.mode; + root.style.colorScheme = resolvedThemeMode; + try { + window.localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ + ...theme, + version: THEME_STORAGE_VERSION, + } satisfies StoredThemePreference)); + } catch { + // User preference storage can be unavailable in private or restricted contexts. + } + }, [resolvedThemeMode, theme]); + + React.useEffect(() => { + setDemoGuideOpen(demoGuide !== null); + }, [demoGuide?.title, desc.id]); + + React.useEffect(() => { + setDemoDockControls(null); + setDemoGuidePopup(null); + setInternalsOpen(false); + setThemeOpen(false); + }, [desc.id]); const closeDockPane = React.useCallback(() => { setDockPane(null); - setShareTarget(null); }, []); const selectDockPane = React.useCallback((pane: DockPaneId | null) => { - setShareTarget(null); + setInternalsOpen(false); + setDemoGuideOpen(false); + setThemeOpen(false); setDockPane((current) => current === pane ? null : pane); }, []); const selectMachineView = React.useCallback((view: DockViewId) => { - setShareTarget(null); setDockPane(null); + setInternalsOpen(false); + setThemeOpen(false); surface.chooseView(view); }, [surface]); + const toggleDemoGuide = React.useCallback(() => { + if (!demoGuide) return; + setDockPane(null); + setInternalsOpen(false); + setThemeOpen(false); + setDemoGuideOpen((open) => !open); + }, [demoGuide]); + + const toggleInternals = React.useCallback(() => { + if (!surface.canUseInternals) return; + setDockPane(null); + setDemoGuideOpen(false); + setThemeOpen(false); + setInternalsOpen((open) => !open); + }, [surface.canUseInternals]); + + const toggleTheme = React.useCallback(() => { + setDockPane(null); + setDemoGuideOpen(false); + setInternalsOpen(false); + setThemeOpen((open) => !open); + }, []); + const applyDescriptor = React.useCallback((d: BootDescriptor) => { void host.applyBootDescriptor(d).then(closeDockPane).catch((err) => { console.warn("applyBootDescriptor failed:", err); @@ -83,11 +169,6 @@ export const App: React.FC = () => { }); }, [host, closeDockPane]); - const onShareGalleryItem = React.useCallback((item: GalleryItem) => { - setShareTarget(descriptorFromGalleryItem(item, host.getBootDescriptor())); - setDockPane("share"); - }, [host]); - const onAddTerminal = React.useCallback(() => { const terminal = createShellTerminal(nextTerminalIndex.current++); setTerminals((prev) => [...prev, terminal]); @@ -95,10 +176,55 @@ export const App: React.FC = () => { }, []); const isEmpty = surface.status === "idle"; + const dockActiveView: DockViewId | null = !isEmpty && surface.activeView !== "internals" + ? surface.activeView + : null; + const viewControls = !isEmpty + ? surface.activeView === "demo" + ? demoDockControls + : surface.activeView === "terminal" + ? ( + + ) + : null + : null; + const internalsPopup = !isEmpty && internalsOpen && surface.canUseInternals + ? ( + setInternalsTab(tab as InternalsTab)} + /> + ) + : null; const meta = dockPane ? PANE_META[dockPane] : null; + const appStyle = { + "--kdock-height": `${dockHeight}px`, + } as React.CSSProperties; + const isTerminalView = !isEmpty && surface.activeView === "terminal"; + const reserveDockSpace = isTerminalView || dockLayout.fullWidth; + const appClassName = [ + "kapp", + "kdocked-app", + isTerminalView ? "is-terminal-view" : "", + dockLayout.fullWidth ? "is-dock-full-width" : "is-dock-sliding", + dockLayout.collapsed ? "is-dock-collapsed" : "", + reserveDockSpace ? "is-dock-space-reserved" : "is-dock-overlay", + ].filter(Boolean).join(" "); + const onDockLayoutChange = React.useCallback((layout: DockLayoutState) => { + setDockLayout((current) => ( + current.collapsed === layout.collapsed && current.fullWidth === layout.fullWidth + ? current + : layout + )); + }, []); return ( -
+
{isEmpty ? ( { ) : ( setInternalsTab(t as InternalsTab)} terminals={terminals} activeTerminalId={activeTerminalId} onActiveTerminalId={setActiveTerminalId} @@ -120,53 +249,327 @@ export const App: React.FC = () => {
{dockPane && meta && ( - - {dockPane === "new" && ( - setDockPane("gallery")} - onApplyDescriptor={applyDescriptor} - /> - )} - {dockPane === "gallery" && ( - - )} - {dockPane === "config" && ( - - )} - {dockPane === "share" && ( - - )} - + <> + ); }; + +const TerminalDockControls: React.FC<{ + terminals: ShellTerminal[]; + activeTerminalId: string; + onActiveTerminalId: (id: string) => void; + onAddTerminal: () => void; +}> = ({ terminals, activeTerminalId, onActiveTerminalId, onAddTerminal }) => ( +
+ {terminals.map((terminal) => ( + + ))} + +
+); + +const InternalsPopup: React.FC<{ + activeTab: string; + onTab: (id: string) => void; +}> = ({ activeTab, onTab }) => ( +
+
+ {INSPECTOR_TABS.map((tab) => ( + + ))} +
+ +
+); + +const ThemePopup: React.FC<{ + theme: ThemePreference; + resolvedMode: ResolvedThemeMode; + onThemeChange: React.Dispatch>; +}> = ({ theme, resolvedMode, onThemeChange }) => ( +
+
+
Palette
+
+ {THEME_FAMILIES.map((item) => ( + + ))} +
+
+
+
Mode
+
+ {THEME_MODES.map((item) => { + const autoResolved = theme.mode === "auto" && item.mode === resolvedMode; + return ( + + ); + })} +
+
+
+); + +const LazyDownloadToasts: React.FC<{ + downloads: LazyDownloadEvent[]; +}> = ({ downloads }) => { + const [dismissed, setDismissed] = React.useState>(() => new Set()); + const visibleDownloads = React.useMemo( + () => downloads.filter((download) => !dismissed.has(download.id)), + [dismissed, downloads], + ); + + React.useEffect(() => { + setDismissed((current) => { + if (current.size === 0) return current; + const activeIds = new Set(downloads.map((download) => download.id)); + let changed = false; + const next = new Set(); + for (const id of current) { + if (activeIds.has(id)) { + next.add(id); + } else { + changed = true; + } + } + return changed ? next : current; + }); + }, [downloads]); + + const dismiss = React.useCallback((id: string) => { + setDismissed((current) => new Set(current).add(id)); + }, []); + + if (visibleDownloads.length === 0) return null; + + return ( + + ); +}; + +const LazyDownloadToast: React.FC<{ + download: LazyDownloadEvent; + onDismiss: (id: string) => void; +}> = ({ download, onDismiss }) => { + const pct = download.totalBytes && download.totalBytes > 0 + ? Math.min(100, Math.max(0, (download.loadedBytes / download.totalBytes) * 100)) + : null; + const label = downloadLabel(download); + const progressLabel = downloadProgressLabel(download, pct); + const title = `${downloadStatusVerb(download)} ${label}`; + const detail = `${humanBytes(download.loadedBytes)}${ + download.totalBytes ? ` / ${humanBytes(download.totalBytes)}` : "" + }`; + + return ( +
+
+ {title} + {progressLabel} + +
+
+ {download.error ?? detail} +
+ +
+ ); +}; + +function downloadStatusVerb(event: LazyDownloadEvent): string { + switch (event.status) { + case "complete": return "Downloaded"; + case "error": return "Failed"; + default: return "Downloading"; + } +} + +function downloadLabel(event: LazyDownloadEvent): string { + const raw = event.kind === "archive" + ? event.url + : event.path ?? event.mountPrefix ?? event.url; + const clean = raw.split(/[?#]/, 1)[0].replace(/\/+$/, ""); + return clean.split("/").pop() || event.kind; +} + +function downloadProgressLabel(event: LazyDownloadEvent, pct: number | null): string { + if (event.status === "complete") return "OK"; + if (event.status === "error") return "ERR"; + return pct === null ? "..." : `${Math.round(pct)}%`; +} + +function humanBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kib = bytes / 1024; + if (kib < 1024) return `${kib.toFixed(kib < 10 ? 1 : 0)} KiB`; + const mib = kib / 1024; + return `${mib.toFixed(mib < 10 ? 1 : 0)} MiB`; +} + +function readThemePreference(): ThemePreference { + if (typeof window === "undefined") return DEFAULT_THEME; + + try { + const raw = window.localStorage.getItem(THEME_STORAGE_KEY); + if (!raw) return DEFAULT_THEME; + const parsed = JSON.parse(raw) as Partial; + if (parsed.version !== THEME_STORAGE_VERSION) return DEFAULT_THEME; + const family = parsed.family; + const mode = parsed.mode; + if (!isThemeFamily(family) || !isThemeMode(mode)) return DEFAULT_THEME; + return { family, mode }; + } catch { + return DEFAULT_THEME; + } +} + +function isThemeFamily(value: unknown): value is ThemeFamily { + return value === "playground" || value === "balanced" || value === "terminal"; +} + +function isThemeMode(value: unknown): value is ThemeMode { + return value === "auto" || value === "light" || value === "dark"; +} + +function getSystemThemeMode(): ResolvedThemeMode { + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} diff --git a/apps/browser-demos/pages/kandelo/app/Dock.tsx b/apps/browser-demos/pages/kandelo/app/Dock.tsx index 6c43e3c7c..52e054ebb 100644 --- a/apps/browser-demos/pages/kandelo/app/Dock.tsx +++ b/apps/browser-demos/pages/kandelo/app/Dock.tsx @@ -2,8 +2,8 @@ import * as React from "react"; import markUrl from "../assets/kandelo-mark.png"; import type { MachineStatus } from "../../../../../web-libs/kandelo-session/src/kernel-host"; -export type DockPaneId = "new" | "gallery" | "config" | "share"; -export type DockViewId = "demo" | "terminal" | "internals"; +export type DockPaneId = "gallery"; +export type DockViewId = "demo" | "terminal"; interface DockItem { id: T; @@ -25,57 +25,123 @@ const VIEW_ITEMS: DockItem[] = [ title: "Terminal", icon: , }, - { - id: "internals", - label: "Internals", - title: "Internals", - icon: , - }, ]; +const INTERNALS_ITEM: DockItem<"internals"> = { + id: "internals", + label: "Internals", + title: "Internals", + icon: , +}; + +const GUIDE_ITEM: DockItem<"guide"> = { + id: "guide", + label: "Guide", + title: "Demo guide", + icon: , +}; + +const THEME_ITEM: DockItem<"theme"> = { + id: "theme", + label: "Theme", + title: "Theme", + icon: , +}; + const PANE_ITEMS: DockItem[] = [ - { - id: "new", - label: "New", - title: "New machine", - icon: , - }, { id: "gallery", label: "Gallery", title: "Gallery", icon: , }, - { - id: "config", - label: "Config", - title: "System config", - icon: , - }, - { - id: "share", - label: "Share", - title: "Share machine", - icon: , - }, ]; -type DockCssProperties = React.CSSProperties & { - "--kdock-body-h"?: string; - "--kdock-center"?: string; +type DockPopoverAnchor = { + x: number; + bottom: number; + originX: number; +}; + +type DockPopoverProperties = React.CSSProperties & { + "--kdock-popover-x"?: string; + "--kdock-popover-bottom"?: string; + "--kdock-popover-origin-x"?: string; + "--kdock-popover-width"?: string; +}; + +type DockShellProperties = React.CSSProperties & { + "--kdock-center-x"?: string; + "--kdock-expanded-width"?: string; + "--kdock-viewport-offset"?: string; + "--kdock-viewport-trailing"?: string; +}; + +export type DockLayoutState = { + collapsed: boolean; + fullWidth: boolean; }; export const Dock: React.FC<{ activePane: DockPaneId | null; activeView: DockViewId | null; + viewControls?: React.ReactNode; + guidePopup?: React.ReactNode; + internalsPopup?: React.ReactNode; + themePopup?: React.ReactNode; + guideAvailable: boolean; + guideOpen: boolean; + internalsAvailable: boolean; + internalsOpen: boolean; + themeOpen: boolean; status: MachineStatus; machineTitle?: string; viewDisabled?: Partial>; onSelectPane: (pane: DockPaneId | null) => void; onSelectView: (view: DockViewId) => void; -}> = ({ activePane, activeView, status, machineTitle, viewDisabled = {}, onSelectPane, onSelectView }) => { + onToggleGuide: () => void; + onToggleInternals: () => void; + onToggleTheme: () => void; + onCloseGuide: () => void; + onCloseInternals: () => void; + onCloseTheme: () => void; + onHeightChange: (height: number) => void; + onLayoutChange?: (layout: DockLayoutState) => void; +}> = ({ + activePane, + activeView, + viewControls, + guidePopup, + internalsPopup, + themePopup, + guideAvailable, + guideOpen, + internalsAvailable, + internalsOpen, + themeOpen, + status, + machineTitle, + viewDisabled = {}, + onSelectPane, + onSelectView, + onToggleGuide, + onToggleInternals, + onToggleTheme, + onCloseGuide, + onCloseInternals, + onCloseTheme, + onHeightChange, + onLayoutChange, +}) => { const shellRef = React.useRef(null); const bodyRef = React.useRef(null); + const guideButtonRef = React.useRef(null); + const internalsButtonRef = React.useRef(null); + const themeButtonRef = React.useRef(null); + const guidePopoverRef = React.useRef(null); + const internalsPopoverRef = React.useRef(null); + const themePopoverRef = React.useRef(null); + const compactClampPausedUntilRef = React.useRef(0); const dragRef = React.useRef<{ pointerId: number; startX: number; @@ -83,61 +149,147 @@ export const Dock: React.FC<{ width: number; moved: boolean; } | null>(null); - const suppressHeaderClickRef = React.useRef(false); + const suppressCenterClickRef = React.useRef(false); const [collapsed, setCollapsed] = React.useState(false); - const [bodyHeight, setBodyHeight] = React.useState(72); + const [fullWidth, setFullWidth] = React.useState(false); const [dockCenter, setDockCenter] = React.useState(null); const [dragging, setDragging] = React.useState(false); + const [viewportWidth, setViewportWidth] = React.useState(() => window.innerWidth); + const guideAnchor = useDockPopoverAnchor(guideOpen, guidePopup, shellRef, guideButtonRef, 380); + const internalsAnchor = useDockPopoverAnchor(internalsOpen, internalsPopup, shellRef, internalsButtonRef, 980); + const themeAnchor = useDockPopoverAnchor(themeOpen, themePopup, shellRef, themeButtonRef, 360); const statusLabel = formatMachineStatus(status); const title = machineTitle || "Kandelo machine"; - const clampCenter = React.useCallback((center: number, width?: number): number => { + const clampDockCenter = React.useCallback((center: number, width?: number): number => { const viewportWidth = window.innerWidth; - const margin = 8; + const margin = 12; const dockWidth = width ?? shellRef.current?.getBoundingClientRect().width ?? 0; - if (dockWidth + margin * 2 >= viewportWidth) { return viewportWidth / 2; } - const half = dockWidth / 2; return Math.min(viewportWidth - half - margin, Math.max(half + margin, center)); }, []); React.useLayoutEffect(() => { - const body = bodyRef.current; - if (!body) { + const shell = shellRef.current; + if (!shell) { + onHeightChange(0); return; } - const updateBodyHeight = () => { - setBodyHeight(Math.ceil(body.getBoundingClientRect().height)); + const updateHeight = () => { + const rect = shell.getBoundingClientRect(); + onHeightChange(Math.ceil(rect.height)); + if (!fullWidth && performance.now() >= compactClampPausedUntilRef.current) { + setDockCenter((center) => center === null ? null : clampDockCenter(center, rect.width)); + } }; - updateBodyHeight(); + updateHeight(); - const observer = new ResizeObserver(updateBodyHeight); - observer.observe(body); - return () => observer.disconnect(); - }, []); + const observer = new ResizeObserver(updateHeight); + observer.observe(shell); + window.addEventListener("resize", updateHeight); + return () => { + observer.disconnect(); + window.removeEventListener("resize", updateHeight); + onHeightChange(0); + }; + }, [clampDockCenter, fullWidth, onHeightChange]); React.useEffect(() => { - const onResize = () => { - setDockCenter((center) => center === null ? null : clampCenter(center)); + const handleResize = () => { + setViewportWidth(window.innerWidth); + setDockCenter((center) => center === null ? null : clampDockCenter(center)); }; - window.addEventListener("resize", onResize); - return () => window.removeEventListener("resize", onResize); - }, [clampCenter]); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [clampDockCenter]); - const onHeaderPointerDown = React.useCallback((event: React.PointerEvent) => { - if (event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey) { - return; + React.useEffect(() => { + onLayoutChange?.({ collapsed, fullWidth }); + }, [collapsed, fullWidth, onLayoutChange]); + + React.useEffect(() => { + const body = bodyRef.current; + if (!body) return; + if (collapsed) { + body.setAttribute("inert", ""); + body.setAttribute("aria-hidden", "true"); + } else { + body.removeAttribute("inert"); + body.removeAttribute("aria-hidden"); } + }, [collapsed]); - const shell = shellRef.current; - if (!shell || window.matchMedia("(max-width: 860px)").matches) { + React.useEffect(() => { + if (!collapsed) return; + onCloseGuide(); + onCloseInternals(); + onCloseTheme(); + }, [collapsed, onCloseGuide, onCloseInternals, onCloseTheme]); + + React.useEffect(() => { + if (!guideOpen && !internalsOpen && !themeOpen) return; + + const handleOutsidePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + + if (guideOpen) { + if (!guidePopoverRef.current?.contains(target) && !guideButtonRef.current?.contains(target)) { + onCloseGuide(); + } + } + + if (internalsOpen) { + if (!internalsPopoverRef.current?.contains(target) && !internalsButtonRef.current?.contains(target)) { + onCloseInternals(); + } + } + + if (themeOpen) { + if (!themePopoverRef.current?.contains(target) && !themeButtonRef.current?.contains(target)) { + onCloseTheme(); + } + } + }; + + document.addEventListener("pointerdown", handleOutsidePointerDown, true); + return () => document.removeEventListener("pointerdown", handleOutsidePointerDown, true); + }, [guideOpen, internalsOpen, themeOpen, onCloseGuide, onCloseInternals, onCloseTheme]); + + const guidePopoverStyle = popoverStyle(guideAnchor, 380); + const internalsPopoverStyle = popoverStyle(internalsAnchor, 980); + const themePopoverStyle = popoverStyle(themeAnchor, 360); + const expandedDockWidth = dockCenter !== null + ? Math.ceil(2 * Math.max(dockCenter, viewportWidth - dockCenter)) + : viewportWidth; + const viewportOffset = dockCenter !== null + ? Math.max(0, Math.round(expandedDockWidth / 2 - dockCenter)) + : 0; + const viewportTrailing = dockCenter !== null + ? Math.max(0, Math.round(expandedDockWidth - viewportWidth - viewportOffset)) + : 0; + const dockStyle: DockShellProperties | undefined = dockCenter !== null + ? { + left: `${Math.round(dockCenter)}px`, + "--kdock-center-x": `${Math.round(dockCenter)}px`, + "--kdock-expanded-width": `${expandedDockWidth}px`, + "--kdock-viewport-offset": `${viewportOffset}px`, + "--kdock-viewport-trailing": `${viewportTrailing}px`, + } + : undefined; + + const onCenterPointerDown = React.useCallback((event: React.PointerEvent) => { + if (event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || fullWidth) { return; } + const shell = shellRef.current; + if (!shell) return; + const rect = shell.getBoundingClientRect(); dragRef.current = { pointerId: event.pointerId, @@ -146,10 +298,15 @@ export const Dock: React.FC<{ width: rect.width, moved: false, }; - event.currentTarget.setPointerCapture(event.pointerId); - }, [dockCenter]); - const onHeaderPointerMove = React.useCallback((event: React.PointerEvent) => { + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // Synthetic pointer events used by tests may not have an active pointer. + } + }, [dockCenter, fullWidth]); + + const onCenterPointerMove = React.useCallback((event: React.PointerEvent) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) { return; @@ -163,11 +320,11 @@ export const Dock: React.FC<{ if (drag.moved) { event.preventDefault(); - setDockCenter(clampCenter(drag.startCenter + deltaX, drag.width)); + setDockCenter(clampDockCenter(drag.startCenter + deltaX, drag.width)); } - }, [clampCenter]); + }, [clampDockCenter]); - const onHeaderPointerUp = React.useCallback((event: React.PointerEvent) => { + const onCenterPointerUp = React.useCallback((event: React.PointerEvent) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) { return; @@ -177,118 +334,323 @@ export const Dock: React.FC<{ event.currentTarget.releasePointerCapture(event.pointerId); } if (drag.moved) { - suppressHeaderClickRef.current = true; + suppressCenterClickRef.current = true; window.setTimeout(() => { - suppressHeaderClickRef.current = false; + suppressCenterClickRef.current = false; }, 0); } dragRef.current = null; setDragging(false); }, []); - const onHeaderClick = React.useCallback((event: React.MouseEvent) => { - if (suppressHeaderClickRef.current) { - suppressHeaderClickRef.current = false; + const onCenterClick = React.useCallback((event: React.MouseEvent) => { + if (suppressCenterClickRef.current) { + suppressCenterClickRef.current = false; event.preventDefault(); return; } setCollapsed((value) => !value); }, []); - const dockStyle: DockCssProperties = { - "--kdock-body-h": `${bodyHeight}px`, - }; - if (dockCenter !== null) { - dockStyle["--kdock-center"] = `${Math.round(dockCenter)}px`; - } + const onToggleFullWidth = React.useCallback(() => { + setFullWidth((value) => { + if (value) { + compactClampPausedUntilRef.current = performance.now() + 260; + } + return !value; + }); + }, []); return ( - + + + {internalsOpen && internalsPopup && internalsPopoverStyle && ( + <> +