diff --git a/apps/dashboard/src/lib/stores/shell.svelte.ts b/apps/dashboard/src/lib/stores/shell.svelte.ts new file mode 100644 index 0000000..f76f698 --- /dev/null +++ b/apps/dashboard/src/lib/stores/shell.svelte.ts @@ -0,0 +1,28 @@ +// ───────────────────────────────────────────────────────────────────────────── +// shell.svelte.ts — global OS-shell coordination state. +// +// When a full-screen takeover owns the keyboard (Memory Cinema, an Observatory +// demo takeover), the OS command palette must NOT respond to ⌘K/Escape — otherwise +// ⌘K opens the palette *behind* the takeover and steals Escape, so the takeover +// can no longer be closed (the launch audit's most serious interaction bug). +// +// A takeover raises overlayActive; the shell's ⌘K + palette are suppressed while +// it is set. Reference-counted so nested/overlapping takeovers behave. +// ───────────────────────────────────────────────────────────────────────────── + +let overlayCount = $state(0); + +export const shell = { + /** True while ANY full-screen takeover (Cinema, demo) owns the keyboard. */ + get overlayActive(): boolean { + return overlayCount > 0; + }, + /** A takeover calls this on open (e.g. in an $effect/onMount). */ + pushOverlay(): void { + overlayCount += 1; + }, + /** …and this on close/teardown. Guarded so it never goes negative. */ + popOverlay(): void { + overlayCount = Math.max(0, overlayCount - 1); + } +}; diff --git a/apps/dashboard/src/routes/(app)/+layout.svelte b/apps/dashboard/src/routes/(app)/+layout.svelte index de14111..cc93ede 100644 --- a/apps/dashboard/src/routes/(app)/+layout.svelte +++ b/apps/dashboard/src/routes/(app)/+layout.svelte @@ -1,5 +1,7 @@ @@ -13,5 +15,4 @@ -->
{@render children()} -
diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index a0756d4..e9fa11a 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -21,6 +21,7 @@ import Icon from '$lib/components/Icon.svelte'; import { initTheme } from '$stores/theme'; import { OS_ROUTES, DOCK_ROUTES, routesByGroup, HOME_ROUTE } from '$lib/os-routes'; + import { shell } from '$lib/stores/shell.svelte'; let { children } = $props(); let showCommandPalette = $state(false); @@ -36,10 +37,12 @@ // every dashboard route so no canvas page is a navigation island (the launch // audit: 6 organs had no desktop nav, Palace had none at all). // - // Recording cleanliness is preserved by ?capture=1 (or ?capture): the shell - // hides so hero footage / ?frame=N captures stay pure canvas. + // Recording cleanliness: ?capture=1 (or ?capture) AND ?frame=N (deterministic + // still capture) both hide ALL chrome so hero footage / ?frame captures stay + // pure canvas. The old MobileNav had no such gate and leaked into captures. let isCaptureMode = $derived( - $page.url.searchParams.has('capture') && $page.url.searchParams.get('capture') !== '0' + ($page.url.searchParams.has('capture') && $page.url.searchParams.get('capture') !== '0') || + $page.url.searchParams.has('frame') ); // Show the floating OS shell on real dashboard routes (not marketing, not capture). let showShell = $derived(!isMarketingRoute && !isCaptureMode); @@ -58,7 +61,12 @@ const teardownTheme = initTheme(); function onKeyDown(e: KeyboardEvent) { - if (isMarketingRoute || isCaptureMode) return; + // While a full-screen takeover (Memory Cinema, a demo) owns the keyboard, + // the OS shell must NOT react to ⌘K/Escape — otherwise ⌘K opens the + // palette behind the takeover and steals Escape so it can't be closed. + // Cinema is PROTECTED, so we detect its active overlay read-only via the + // DOM (.cinema-overlay mounts only while open) rather than editing it. + if (isMarketingRoute || isCaptureMode || takeoverActive()) return; if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); showCommandPalette = !showCommandPalette; @@ -137,12 +145,60 @@ cmdQuery = ''; goto(`${base}${href}`); } + + // A full-screen takeover is active if either the shell store was raised OR a + // protected Cinema overlay is mounted (read-only DOM probe — we never touch + // the protected MemoryCinema component). + function takeoverActive(): boolean { + if (shell.overlayActive) return true; + return typeof document !== 'undefined' && document.querySelector('.cinema-overlay') !== null; + } + + // Dialog a11y: trap focus inside the palette while open, mark the rest of the + // page inert, and restore focus to the element that opened it on close. A + // Svelte action so it wires/tears-down with the palette's mount lifecycle. + function paletteDialog(node: HTMLElement) { + const opener = document.activeElement as HTMLElement | null; + const appRoot = document.querySelector('[data-app-root]') as HTMLElement | null; + appRoot?.setAttribute('inert', ''); + function focusables(): HTMLElement[] { + return Array.from( + node.querySelectorAll( + 'a[href], button:not([disabled]), input, [tabindex]:not([tabindex="-1"])' + ) + ).filter((el) => el.offsetParent !== null); + } + function onKey(e: KeyboardEvent) { + if (e.key !== 'Tab') return; + const items = focusables(); + if (items.length === 0) return; + const first = items[0]; + const last = items[items.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + node.addEventListener('keydown', onKey); + return { + destroy() { + node.removeEventListener('keydown', onKey); + appRoot?.removeAttribute('inert'); + opener?.focus?.(); + } + }; + } -{@render children()} +
+ {@render children()} +
{#if showShell} -{#if showCommandPalette && showShell} +{#if showCommandPalette && showShell && !shell.overlayActive}
{ if (e.key === 'Escape') showCommandPalette = false; }} onclick={(e) => { if (e.target === e.currentTarget) showCommandPalette = false; }} > -
+