diff --git a/apps/browser-demos/index.html b/apps/browser-demos/index.html index 2fec1d726..0bb98bb9b 100644 --- a/apps/browser-demos/index.html +++ b/apps/browser-demos/index.html @@ -4,10 +4,38 @@ Kandelo + + diff --git a/apps/browser-demos/lib/init/wordpress-mariadb-readiness.ts b/apps/browser-demos/lib/init/wordpress-mariadb-readiness.ts new file mode 100644 index 000000000..8d2f450a3 --- /dev/null +++ b/apps/browser-demos/lib/init/wordpress-mariadb-readiness.ts @@ -0,0 +1,62 @@ +export const WORDPRESS_MARIADB_SOCKET_PATH = "/tmp/mysql.sock"; +export const WORDPRESS_MARIADB_READY_PATH = "/kandelo-ready.php"; +export const WORDPRESS_MARIADB_READY_FILE = `/var/www/html${WORDPRESS_MARIADB_READY_PATH}`; + +export const WORDPRESS_MARIADB_READY_PHP = ` = [ + { family: "kandelo", label: "Kandelo", description: "Candlelit Kandelo surfaces with warm highlights and ink-dark contrast." }, + { family: "wordpress", label: "WordPress", description: "WordPress design-system grays with the modern blueberry accent." }, + { family: "ubuntu", label: "Ubuntu", description: "Yaru light and dark colors with Ubuntu terminal palettes." }, +]; +const THEME_MODES: Array<{ mode: ThemeMode; label: string }> = [ + { mode: "auto", label: "Auto" }, + { mode: "light", label: "Light" }, + { mode: "dark", label: "Dark" }, +]; + +const PANE_META: Record = { + gallery: { + title: "Launch New Machine", + subtitle: "Choose a published Kandelo machine or local demo image to boot.", + }, +}; export const App: React.FC = () => { const host = useKernelHost(); - const status = useStatus(); + const demoGuide = useDemoGuide(); + const lazyDownloads = useLazyDownloads(); + const surface = useMachineSurfaceController(); - const [view, setView] = React.useState("machine"); + 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 [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); - /** - * 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 autoOpenedDemoGuideKey = React.useRef(null); const desc = host.getBootDescriptor(); + const resolvedThemeMode = theme.mode === "auto" ? systemThemeMode : theme.mode; - const onNav = (id: ViewId) => { - if (id === "share") { - setShareTarget(true); - return; + 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. } - setView(id); - }; + }, [resolvedThemeMode, theme]); + + React.useEffect(() => { + const key = `${desc.id}:${demoGuide?.title ?? "no-guide"}`; + if (autoOpenedDemoGuideKey.current === key) return; + autoOpenedDemoGuideKey.current = key; + setDemoGuideOpen(dockPane === null && demoGuide !== null); + }, [demoGuide?.title, desc.id, dockPane]); + + React.useEffect(() => { + setDemoDockControls(null); + setDemoGuidePopup(null); + setInternalsOpen(false); + setThemeOpen(false); + }, [desc.id]); + + const closeDockPane = React.useCallback(() => { + setDockPane(null); + }, []); + + const selectDockPane = React.useCallback((pane: DockPaneId | null) => { + setInternalsOpen(false); + setDemoGuideOpen(false); + setThemeOpen(false); + setDockPane((current) => current === pane ? null : pane); + }, []); + + const selectMachineView = React.useCallback((view: DockViewId) => { + 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); + }); + }, [host, closeDockPane]); const onLaunchGalleryItem = React.useCallback((item: GalleryItem) => { if (item.vfsImageUrl) { @@ -50,15 +168,10 @@ 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]); - - const onShareGalleryItem = React.useCallback((item: GalleryItem) => { - setShareTarget(descriptorFromGalleryItem(item, host.getBootDescriptor())); - }, [host]); + }, [host, closeDockPane]); const onAddTerminal = React.useCallback(() => { const terminal = createShellTerminal(nextTerminalIndex.current++); @@ -66,90 +179,419 @@ 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]); - - return ( -
- + ) + : 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 ? ( 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 && ( + <> + ); }; -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 }) => ( +
+
+ {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 ( -
-
{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 ( +
+
+ {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 && + 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 && ( + <> +