mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
fix(os): close 4 shell launch blockers — dup nav, capture leak, palette a11y, Cinema keyboard collision
Audit BLOCK: (1) DUPLICATE MOBILE NAV — removed the legacy MobileNav (mounted in (app)/+layout); the registry-driven os-mobilebar is now the ONE mobile nav. (2) CAPTURE LEAK — showShell now also hides on ?frame=N (was capture only), and removing MobileNav kills its un-gated leak; ?frame=1 and ?capture=1 both show 0 chrome. (3) PALETTE A11Y — role=dialog + aria-modal + a paletteDialog action that traps Tab focus, marks [data-app-root] inert while open, and restores focus to the opener on close. (4) CINEMA KEYBOARD COLLISION (most serious) — the shell's ⌘K/Escape now no-op while a full-screen takeover owns the keyboard, detected via a READ-ONLY .cinema-overlay DOM probe (Cinema stays protected/untouched) plus a new shell.svelte store for non-DOM takeovers. Verified live: mobileBars=1, frame/capture chrome=0, dialog role+inert set/cleared, palette blocked during Cinema. check 0/0, 980 files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4168925099
commit
b0b69a1edf
3 changed files with 100 additions and 9 deletions
28
apps/dashboard/src/lib/stores/shell.svelte.ts
Normal file
28
apps/dashboard/src/lib/stores/shell.svelte.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
<script lang="ts">
|
||||
import MobileNav from '$lib/components/MobileNav.svelte';
|
||||
// Mobile navigation is now the ONE registry-driven os-mobilebar in the root
|
||||
// +layout.svelte (which also handles ?frame=/?capture= hiding). The old
|
||||
// MobileNav is removed to avoid a duplicate mobile bar + a capture-mode leak.
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
|
|
@ -13,5 +15,4 @@
|
|||
-->
|
||||
<div class="relative h-[100dvh] w-full overflow-hidden">
|
||||
{@render children()}
|
||||
<MobileNav />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>(
|
||||
'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?.();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Organs always render FULL-BLEED (they own the viewport as fixed-inset
|
||||
WebGPU canvases). The OS shell floats OVER them, so it never fights the
|
||||
canvas layout the way the old flex-sidebar did. -->
|
||||
{@render children()}
|
||||
<div data-app-root class="contents">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
{#if showShell}
|
||||
<!-- ── Persistent desktop dock (floating, left edge) ──────────────────────
|
||||
|
|
@ -224,14 +280,20 @@
|
|||
{/if}
|
||||
|
||||
<!-- ── Command palette — ALL 20 organs, grouped, searchable, everywhere ──── -->
|
||||
{#if showCommandPalette && showShell}
|
||||
{#if showCommandPalette && showShell && !shell.overlayActive}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-start justify-center pt-[10vh] md:pt-[14vh] px-4 bg-void/70 backdrop-blur-md"
|
||||
onkeydown={(e) => { if (e.key === 'Escape') showCommandPalette = false; }}
|
||||
onclick={(e) => { if (e.target === e.currentTarget) showCommandPalette = false; }}
|
||||
>
|
||||
<div class="w-full max-w-xl glass-panel rounded-xl shadow-2xl shadow-synapse/10 overflow-hidden">
|
||||
<div
|
||||
class="w-full max-w-xl glass-panel rounded-xl shadow-2xl shadow-synapse/10 overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette — jump to any organ"
|
||||
use:paletteDialog
|
||||
>
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-synapse/10">
|
||||
<span class="text-synapse"><Icon name="search" size={16} /></span>
|
||||
<input
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue