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; }}
>
-