+
{isEmpty ? (
setView("gallery")}
- onApplyDescriptor={onApplyPastedDescriptor}
+ onBrowseAll={() => setDockPane("gallery")}
+ onApplyDescriptor={applyDescriptor}
/>
- ) : isMachineView ? (
+ ) : (
setInternalsTab(t as InternalsTab)}
terminals={terminals}
activeTerminalId={activeTerminalId}
onActiveTerminalId={setActiveTerminalId}
onAddTerminal={onAddTerminal}
/>
- ) : view === "gallery" ? (
-
- ) : view === "config" ? (
- setView("machine")} />
- ) : (
-
)}
- {shareTarget && (
- setShareTarget(null)}
- />
+ {dockPane && meta && (
+ <>
+
+
+ {dockPane === "gallery" && (
+
+ )}
+
+ >
)}
+
+
+
+ }
+ guideAvailable={!isEmpty && demoGuide !== null}
+ guideOpen={!isEmpty && demoGuide !== null && demoGuideOpen}
+ internalsAvailable={!isEmpty && surface.canUseInternals}
+ internalsOpen={!isEmpty && surface.canUseInternals && internalsOpen}
+ themeOpen={themeOpen}
+ status={surface.status}
+ machineTitle={desc.title}
+ viewDisabled={{
+ demo: !surface.canOpenDemo,
+ terminal: !surface.canUseTerminal,
+ }}
+ onSelectPane={selectDockPane}
+ onSelectView={selectMachineView}
+ onToggleGuide={toggleDemoGuide}
+ onToggleInternals={toggleInternals}
+ onToggleTheme={toggleTheme}
+ onCloseGuide={() => setDemoGuideOpen(false)}
+ onCloseInternals={() => setInternalsOpen(false)}
+ onCloseTheme={() => setThemeOpen(false)}
+ onHeightChange={setDockHeight}
+ onLayoutChange={onDockLayoutChange}
+ />
);
};
-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";
+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 }) => (
+
+
+
+);
+
+const ThemePopup: React.FC<{
+ theme: ThemePreference;
+ resolvedMode: ResolvedThemeMode;
+ onThemeChange: React.Dispatch
+ {INSPECTOR_TABS.map((tab) => (
+
+ ))}
+
+
+
+
+
+
+
+);
+
+const LazyDownloadToasts: React.FC<{
+ downloads: LazyDownloadEvent[];
+}> = ({ downloads }) => {
+ const [dismissed, setDismissed] = React.useStatePalette
+
+ {THEME_FAMILIES.map((item) => (
+
+ ))}
+
+ Mode
+
+ {THEME_MODES.map((item) => {
+ const autoResolved = theme.mode === "auto" && item.mode === resolvedMode;
+ 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.
+
+ );
+};
+
+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 (
+ ;
+ if (
+ parsed.version !== THEME_STORAGE_VERSION &&
+ parsed.version !== 2 &&
+ parsed.version !== 1
+ ) {
+ return DEFAULT_THEME;
+ }
+ const family = normalizeThemeFamily(parsed.family);
+ const mode = parsed.mode;
+ if (!family || !isThemeMode(mode)) return DEFAULT_THEME;
+ return { family, mode };
+ } catch {
+ return DEFAULT_THEME;
+ }
+}
+
+function normalizeThemeFamily(value: unknown): ThemeFamily | null {
+ switch (value) {
+ case "playground":
+ return "wordpress";
+ case "wordpress":
+ case "kandelo":
+ case "ubuntu":
+ return value;
+ case "balanced":
+ case "terminal":
+ return "kandelo";
+ default:
+ return null;
+ }
+}
+
+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
new file mode 100644
index 000000000..a990087a5
--- /dev/null
+++ b/apps/browser-demos/pages/kandelo/app/Dock.tsx
@@ -0,0 +1,698 @@
+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 = "gallery";
+export type DockViewId = "demo" | "terminal";
+
+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: ,
+ },
+];
+
+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: "gallery",
+ label: "New",
+ title: "Launch new machine",
+ icon: ,
+ },
+];
+
+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;
+ 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 rowRef = 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;
+ startCenter: number;
+ width: number;
+ moved: boolean;
+ } | null>(null);
+ const suppressCenterClickRef = React.useRef(false);
+ const [collapsed, setCollapsed] = React.useState(false);
+ 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 clampDockCenter = React.useCallback((center: number, width?: number): number => {
+ const viewportWidth = window.innerWidth;
+ 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 shell = shellRef.current;
+ if (!shell) {
+ onHeightChange(0);
+ return;
+ }
+
+ 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));
+ }
+ };
+ updateHeight();
+
+ 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 handleResize = () => {
+ setViewportWidth(window.innerWidth);
+ setDockCenter((center) => center === null ? null : clampDockCenter(center));
+ };
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, [clampDockCenter]);
+
+ React.useEffect(() => {
+ onLayoutChange?.({ collapsed, fullWidth });
+ }, [collapsed, fullWidth, onLayoutChange]);
+
+ React.useEffect(() => {
+ const row = rowRef.current;
+ if (!row) return;
+ if (collapsed) {
+ row.setAttribute("inert", "");
+ row.setAttribute("aria-hidden", "true");
+ } else {
+ row.removeAttribute("inert");
+ row.removeAttribute("aria-hidden");
+ }
+ }, [collapsed]);
+
+ 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 onDockPointerDown = React.useCallback((event: React.PointerEvent) => {
+ if (event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || fullWidth) {
+ return;
+ }
+
+ const target = event.target as Element | null;
+ if (target?.closest("button,input,textarea,select,a,[role='button'],[role='textbox'],[role='combobox']")) {
+ return;
+ }
+
+ const shell = shellRef.current;
+ if (!shell) 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,
+ };
+
+ try {
+ event.currentTarget.setPointerCapture(event.pointerId);
+ } catch {
+ // Synthetic pointer events used by tests may not have an active pointer.
+ }
+ }, [dockCenter, fullWidth]);
+
+ const onDockPointerMove = 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(clampDockCenter(drag.startCenter + deltaX, drag.width));
+ }
+ }, [clampDockCenter]);
+
+ const onDockPointerUp = 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) {
+ suppressCenterClickRef.current = true;
+ window.setTimeout(() => {
+ suppressCenterClickRef.current = false;
+ }, 0);
+ }
+ dragRef.current = null;
+ setDragging(false);
+ }, []);
+
+ const onToggleCollapsed = React.useCallback((event: React.MouseEvent) => {
+ if (suppressCenterClickRef.current) {
+ suppressCenterClickRef.current = false;
+ event.preventDefault();
+ return;
+ }
+ setCollapsed((value) => !value);
+ }, []);
+
+ const onToggleFullWidth = React.useCallback(() => {
+ setFullWidth((value) => {
+ if (value) {
+ compactClampPausedUntilRef.current = performance.now() + 260;
+ }
+ return !value;
+ });
+ }, []);
+
+ return (
+ <>
+
+
+ {internalsOpen && internalsPopup && internalsPopoverStyle && (
+ <>
+
+
);
};
+
+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
+ {title}
+ {progressLabel}
+
+
+
+ {download.error ?? detail}
+
+