From 41689250994519abc0b6fe412626d9ad8019800f Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 13 Jul 2026 10:42:37 +0700 Subject: [PATCH] =?UTF-8?q?feat(os):=20the=20VestigeOS=20nervous=20system?= =?UTF-8?q?=20=E2=80=94=20canonical=20registry=20+=20persistent=20shell=20?= =?UTF-8?q?+=20mutation=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE nervous system (audit P0): one os-routes.ts registry (all 20 organs w/ group/ shortcut/icon/purpose) + base-aware os-nav.ts (osHref/osGoto + URL cognitive context: memory/run/receipt/q/focus/cinema/days/filter). The root shell is now a FLOATING dock + grouped ⌘K palette that overlays every full-bleed canvas organ, so no page is a navigation island (Palace is in the nav for the first time). Hideable via ?capture=1 for clean hero footage. Verified live: dock renders + ⌘K palette opens over the canvas, 0 console errors. MUTATION SAFETY (audit dangerous-interactions, via M3 worker): a plain click now SELECTS, never silently mutates. Feed no longer wipes on click (clearEvents gone); Stats no longer runs consolidate() on a vital click (explicit button only); Memories plain-click selects instead of promoting, with a visible affordance hint for shift/alt. check 0/0, 979 files. Co-Authored-By: Claude Opus 4.8 --- apps/dashboard/src/lib/os-nav.ts | 92 +++ apps/dashboard/src/lib/os-routes.ts | 109 ++++ .../src/routes/(app)/feed/+page.svelte | 21 +- .../src/routes/(app)/memories/+page.svelte | 125 ++-- .../src/routes/(app)/stats/+page.svelte | 21 +- apps/dashboard/src/routes/+layout.svelte | 541 +++++++++++------- 6 files changed, 644 insertions(+), 265 deletions(-) create mode 100644 apps/dashboard/src/lib/os-nav.ts create mode 100644 apps/dashboard/src/lib/os-routes.ts diff --git a/apps/dashboard/src/lib/os-nav.ts b/apps/dashboard/src/lib/os-nav.ts new file mode 100644 index 0000000..023b176 --- /dev/null +++ b/apps/dashboard/src/lib/os-nav.ts @@ -0,0 +1,92 @@ +// ───────────────────────────────────────────────────────────────────────────── +// os-nav.ts — base-aware routing + the URL cognitive-context contract. +// +// Fixes the audit's launch-blocker: components navigated with unbased paths like +// goto(`/graph?center=...`) which ESCAPE the configured /dashboard base and 404 +// on the deployed site. Every cross-organ link must go through osHref()/osGoto() +// so it stays inside the base. +// +// The "cognitive context" is a small, shared set of URL params that carry +// selection/state ACROSS organs so a journey (Rescue → Graph → Reason → Black +// Box → Cinema) keeps its selections intact. `memory` is the canonical +// cross-organ contract; `center` is kept as a temporary Graph alias. +// ───────────────────────────────────────────────────────────────────────────── + +import { base } from '$app/paths'; +import { goto } from '$app/navigation'; + +/** The URL-backed cognitive context shared across every organ. */ +export interface CognitiveContext { + /** The focused memory id — the cross-organ selection contract. */ + memory?: string; + /** A Black Box run id. */ + run?: string; + /** A receipt id. */ + receipt?: string; + /** A free-text query (Explore/Activation/Reasoning). */ + q?: string; + /** Comma-separated ids to emphasize (Graph focus set). */ + focus?: string[]; + /** Launch Memory Cinema on arrival. */ + cinema?: boolean; + /** Time window in days (Timeline/Schedule). */ + days?: number; + /** A generic per-organ filter value. */ + filter?: string; + /** Temporary Graph alias for `memory` (legacy ?center=). */ + center?: string; +} + +/** Serialize a cognitive context to a query string (stable key order, base-free). */ +export function contextToQuery(ctx: CognitiveContext): string { + const p = new URLSearchParams(); + if (ctx.memory) p.set('memory', ctx.memory); + if (ctx.run) p.set('run', ctx.run); + if (ctx.receipt) p.set('receipt', ctx.receipt); + if (ctx.q) p.set('q', ctx.q); + if (ctx.focus && ctx.focus.length) p.set('focus', ctx.focus.join(',')); + if (ctx.cinema) p.set('cinema', '1'); + if (typeof ctx.days === 'number') p.set('days', String(ctx.days)); + if (ctx.filter) p.set('filter', ctx.filter); + if (ctx.center) p.set('center', ctx.center); + const s = p.toString(); + return s ? `?${s}` : ''; +} + +/** Read the cognitive context out of a URL (SvelteKit page.url or a URLSearchParams). */ +export function contextFromUrl(url: URL): CognitiveContext { + const p = url.searchParams; + const focus = p.get('focus'); + const days = p.get('days'); + return { + // `memory` is canonical; fall back to the legacy `center` alias. + memory: p.get('memory') ?? p.get('center') ?? undefined, + run: p.get('run') ?? undefined, + receipt: p.get('receipt') ?? undefined, + q: p.get('q') ?? undefined, + focus: focus ? focus.split(',').filter(Boolean) : undefined, + cinema: p.get('cinema') === '1', + days: days != null && days !== '' && Number.isFinite(Number(days)) ? Number(days) : undefined, + filter: p.get('filter') ?? undefined, + center: p.get('center') ?? undefined + }; +} + +/** + * Build a base-safe href for an organ route (path is WITHOUT base, e.g. '/graph'). + * ALWAYS use this for cross-organ links — never a bare `/graph` string. + */ +export function osHref(pathNoBase: string, ctx: CognitiveContext = {}): string { + const clean = pathNoBase.startsWith('/') ? pathNoBase : `/${pathNoBase}`; + return `${base}${clean}${contextToQuery(ctx)}`; +} + +/** Navigate to an organ route carrying cognitive context, base-safe. */ +export function osGoto(pathNoBase: string, ctx: CognitiveContext = {}): Promise { + return goto(osHref(pathNoBase, ctx)); +} + +/** The current dashboard path with the base stripped (for active-route matching). */ +export function stripBase(pathname: string): string { + return pathname.startsWith(base) ? pathname.slice(base.length) || '/' : pathname; +} diff --git a/apps/dashboard/src/lib/os-routes.ts b/apps/dashboard/src/lib/os-routes.ts new file mode 100644 index 0000000..5144a4d --- /dev/null +++ b/apps/dashboard/src/lib/os-routes.ts @@ -0,0 +1,109 @@ +// ───────────────────────────────────────────────────────────────────────────── +// os-routes.ts — THE canonical VestigeOS route registry. +// +// ONE source of truth for every navigation surface. Before this file there were +// four disagreeing inventories (nav-layer's 9-organ COGNITIVE_OS_ROUTES, +// palace-map's 19 ORGAN_REGIONS, MobileNav, and the e2e ALL_ROUTES) — so +// desktop canvas pages became navigation islands (Palace itself was in NONE of +// the visible nav surfaces). Every consumer now derives from this list: +// - the persistent shell dock (primary organs) +// - the grouped command palette (⌘K) (ALL organs, by group) +// - the mobile menu (ALL groups + organs) +// - the in-canvas WebGPU nav rail (enhancement only) +// - the Palace constellation (visible organs) +// - the e2e route smoke list (all reachable) +// +// A route is only shippable customer-facing nav if it appears here with +// visible !== 'internal'. `_msdftest` is deliberately excluded (internal only). +// ───────────────────────────────────────────────────────────────────────────── + +import type { IconName } from '$lib/components/Icon.svelte'; + +/** Which OS group an organ belongs to (drives palette + mobile grouping). */ +export type OsGroup = 'Primary' | 'Understand' | 'Maintain' | 'Reflect' | 'System'; + +/** How the route mounts its renderer (informational; used by fallback logic). */ +export type StageType = 'observatory' | 'route-stage' | 'canvas' | 'dom'; + +/** Launch visibility: dock = in the persistent dock; nav = in palette/mobile/palace; + * hidden = reachable by URL + palette only; internal = never in customer nav. */ +export type Visibility = 'dock' | 'nav' | 'hidden' | 'internal'; + +export interface OsRoute { + /** Path WITHOUT the base prefix, e.g. '/graph'. Use osHref() to render. */ + href: string; + /** Human label shown in every nav surface. */ + label: string; + /** One-line purpose, shown in the command palette + organ overlay. */ + purpose: string; + group: OsGroup; + /** Single-key shortcut (⌘K palette + in-canvas rail). Unique across the set. */ + shortcut?: string; + icon: IconName; + stage: StageType; + visibility: Visibility; + /** true once the organ is launch-ready; false surfaces a "beta" affordance. */ + ready: boolean; +} + +// ── The registry — all 20 customer organs (order = canonical display order) ── +export const OS_ROUTES: OsRoute[] = [ + // PRIMARY — the spine of the product, in the persistent dock. + { href: '/palace', label: 'Palace', purpose: 'The spatial launcher — every organ as a constellation you fly into.', group: 'Primary', shortcut: 'P', icon: 'logo', stage: 'canvas', visibility: 'dock', ready: true }, + { href: '/observatory', label: 'Observatory', purpose: 'The living memory field + the deterministic cognitive moments (Salience Rescue).', group: 'Primary', shortcut: 'O', icon: 'sparkle', stage: 'observatory', visibility: 'dock', ready: true }, + { href: '/graph', label: 'Graph', purpose: 'The causal memory graph — field + classic inspector, selection, Cinema entry.', group: 'Primary', shortcut: 'G', icon: 'graph', stage: 'canvas', visibility: 'dock', ready: true }, + { href: '/memories', label: 'Memories', purpose: 'Browse, search, and inspect individual memories with their FSRS state.', group: 'Primary', shortcut: 'M', icon: 'memories', stage: 'observatory', visibility: 'dock', ready: true }, + { href: '/timeline', label: 'Timeline', purpose: 'Bitemporal history — when memories were valid vs recorded, with audit windows.', group: 'Primary', shortcut: 'T', icon: 'timeline', stage: 'route-stage', visibility: 'dock', ready: true }, + { href: '/blackbox', label: 'Black Box', purpose: 'The receipt — what Vestige concluded, the evidence, and why, exportable.', group: 'Primary', shortcut: 'B', icon: 'blackbox', stage: 'route-stage', visibility: 'dock', ready: true }, + + // UNDERSTAND — the reasoning + exploration organs. + { href: '/reasoning', label: 'Reasoning', purpose: 'Watch a live deep_reference decision trace form from evidence.', group: 'Understand', shortcut: 'R', icon: 'reasoning', stage: 'route-stage', visibility: 'nav', ready: true }, + { href: '/explore', label: 'Explore', purpose: 'A shareable semantic walk through memory neighborhoods.', group: 'Understand', shortcut: 'E', icon: 'explore', stage: 'observatory', visibility: 'nav', ready: true }, + { href: '/contradictions', label: 'Contradictions', purpose: 'Trust-weighted conflict pairs — where your memory disagrees with itself.', group: 'Understand', shortcut: 'C', icon: 'contradictions', stage: 'route-stage', visibility: 'nav', ready: true }, + { href: '/patterns', label: 'Patterns', purpose: 'Cross-project patterns mined from the corpus.', group: 'Understand', icon: 'patterns', stage: 'route-stage', visibility: 'nav', ready: true }, + + // MAINTAIN — the memory-hygiene organs. + { href: '/duplicates', label: 'Duplicates', purpose: 'Cosine-similarity clusters quarantined for review before merge.', group: 'Maintain', shortcut: 'D', icon: 'duplicates', stage: 'route-stage', visibility: 'nav', ready: true }, + { href: '/memory-prs', label: 'Memory PRs', purpose: 'Proposed memory changes held for review before they touch the graph.', group: 'Maintain', icon: 'memorypr', stage: 'route-stage', visibility: 'nav', ready: true }, + { href: '/importance', label: 'Importance', purpose: 'Which memories rank highest by the 4-channel importance model, and why.', group: 'Maintain', icon: 'importance', stage: 'route-stage', visibility: 'nav', ready: true }, + { href: '/activation', label: 'Activation', purpose: 'The activation field — which memories light up for a query.', group: 'Maintain', shortcut: 'A', icon: 'activation', stage: 'observatory', visibility: 'nav', ready: true }, + + // REFLECT — the temporal / ambient organs. + { href: '/feed', label: 'Activity', purpose: 'The live event stream — recalls, dreams, suppressions, as they happen.', group: 'Reflect', shortcut: 'F', icon: 'feed', stage: 'route-stage', visibility: 'nav', ready: true }, + { href: '/dreams', label: 'Dreams', purpose: 'Replay consolidation cycles and the connections they discover.', group: 'Reflect', icon: 'dreams', stage: 'route-stage', visibility: 'nav', ready: true }, + { href: '/schedule', label: 'Schedule', purpose: 'Review urgency — what is due now, overdue, and coming next.', group: 'Reflect', shortcut: 'H', icon: 'schedule', stage: 'route-stage', visibility: 'nav', ready: true }, + { href: '/intentions', label: 'Intentions', purpose: 'Active and predicted intentions grouped by state.', group: 'Reflect', shortcut: 'I', icon: 'intentions', stage: 'route-stage', visibility: 'nav', ready: true }, + { href: '/stats', label: 'Stats', purpose: 'System vitals — retention distribution, coverage, throughput.', group: 'Reflect', shortcut: 'S', icon: 'stats', stage: 'route-stage', visibility: 'nav', ready: true }, + + // SYSTEM + { href: '/settings', label: 'Settings', purpose: 'Tune the cognitive engine and run the maintenance rituals.', group: 'System', shortcut: ',', icon: 'settings', stage: 'route-stage', visibility: 'nav', ready: true } +]; + +// ── Derived views (every consumer uses these, never re-lists routes) ────────── + +/** The persistent desktop dock: primary organs + a Command entry (added by shell). */ +export const DOCK_ROUTES: OsRoute[] = OS_ROUTES.filter((r) => r.visibility === 'dock'); + +/** Everything a customer can navigate to (dock + nav + hidden), for the palette. */ +export const NAV_ROUTES: OsRoute[] = OS_ROUTES.filter((r) => r.visibility !== 'internal'); + +/** Group order for the command palette + mobile menu. */ +export const OS_GROUP_ORDER: OsGroup[] = ['Primary', 'Understand', 'Maintain', 'Reflect', 'System']; + +/** Routes bucketed by group, in canonical order — drives the palette + mobile menu. */ +export function routesByGroup(): { group: OsGroup; routes: OsRoute[] }[] { + return OS_GROUP_ORDER.map((group) => ({ + group, + routes: NAV_ROUTES.filter((r) => r.group === group) + })).filter((g) => g.routes.length > 0); +} + +/** The home route — Palace is the actual VestigeOS home. */ +export const HOME_ROUTE = '/palace'; + +/** Look up a route by its href (with or without a leading base). */ +export function findRoute(pathNoBase: string): OsRoute | undefined { + const p = pathNoBase || '/'; + return OS_ROUTES.find((r) => p === r.href || p.startsWith(r.href + '/')) ?? + (p === '/' ? OS_ROUTES.find((r) => r.href === HOME_ROUTE) : undefined); +} diff --git a/apps/dashboard/src/routes/(app)/feed/+page.svelte b/apps/dashboard/src/routes/(app)/feed/+page.svelte index b18dcbf..59c0ba7 100644 --- a/apps/dashboard/src/routes/(app)/feed/+page.svelte +++ b/apps/dashboard/src/routes/(app)/feed/+page.svelte @@ -1,5 +1,5 @@ diff --git a/apps/dashboard/src/routes/(app)/memories/+page.svelte b/apps/dashboard/src/routes/(app)/memories/+page.svelte index db47e7b..44fd0f0 100644 --- a/apps/dashboard/src/routes/(app)/memories/+page.svelte +++ b/apps/dashboard/src/routes/(app)/memories/+page.svelte @@ -37,6 +37,10 @@ let loading = $state(true); let error: string | null = $state(null); let activeRun: string | null = null; + // Tracked when the user picks a memory from the canvas. Selection only — + // no API call. Mutations are gated to explicit modifier keys (shift/alt) + // whose affordances are mirrored by the on-canvas hint line. + let selectedMemoryId: string | null = $state(null); let stopReducedMotion: (() => void) | null = null; onMount(() => { @@ -188,6 +192,25 @@ if (error) return [statusItem(`ERROR - ${error}`.slice(0, 72), SCARLET)]; if (memories.length === 0) return [statusItem('EMPTY MEMORY FIELD', MUTED)]; + // Persistent on-canvas affordance: a plain click selects; shift and alt are + // the only modifiers that mutate. Anchored low so it never collides with the + // row band above, and shaped as a "memory-hint" kind so it is NEVER picked as + // a memory row by the text layer. + const hint: MemoryTextItem = { + id: 'memories:hint', + kind: 'memory-hint', + text: 'click: select · shift: suppress · alt: unsuppress', + x: -0.82, + y: -0.9, + size: 0.022, + color: MUTED, + depth: 0.7, + weight: 0.5, + startFrame: REVEAL_ANCHOR, + revealSpan: 24, + maxWidthEm: 50 + }; + const aspect = viewportAspect(); const portrait = aspect < 0.85; @@ -221,6 +244,7 @@ maxWidthEm: 30 }; return [ + hint, header, ...rows.map((memory, i) => { const retrieval = clamp01(memory.retrievalStrength); @@ -249,27 +273,30 @@ const rows = memories.slice(0, ROW_LIMIT); const top = 0.72; const rowStep = 1.5 / Math.max(1, ROW_LIMIT - 1); - return rows.map((memory, i) => { - const retrieval = clamp01(memory.retrievalStrength); - const retention = clamp01(memory.retentionStrength); - return { - id: `mem:${memory.id}`, - kind: 'memory', - memoryId: memory.id, - text: memoryLine(memory, false), - x: -0.88, - y: top - i * rowStep, - size: 0.026, - color: CYAN, - depth: Math.max(MIN_VISIBLE_DEPTH, retrieval), - weight: retention, - startFrame: REVEAL_ANCHOR + i * 2, - revealSpan: 20, - maxWidthEm: 46, - hitPadX: 0.03, - hitPadY: 0.015 - }; - }); + return [ + hint, + ...rows.map((memory, i) => { + const retrieval = clamp01(memory.retrievalStrength); + const retention = clamp01(memory.retentionStrength); + return { + id: `mem:${memory.id}`, + kind: 'memory', + memoryId: memory.id, + text: memoryLine(memory, false), + x: -0.88, + y: top - i * rowStep, + size: 0.026, + color: CYAN, + depth: Math.max(MIN_VISIBLE_DEPTH, retrieval), + weight: retention, + startFrame: REVEAL_ANCHOR + i * 2, + revealSpan: 20, + maxWidthEm: 46, + hitPadX: 0.03, + hitPadY: 0.015 + }; + }) + ]; } function pointerToNdc(e: PointerEvent | MouseEvent): { x: number; y: number } | null { @@ -321,39 +348,37 @@ if (hit?.kind !== 'memory') return; const item = hit.payload as MemoryTextItem; if (!item.memoryId) return; - // Immune actions surfaced right on the field (backend demoability): + // Read-intent click must SELECT only — no API call. Mutations are + // gated to explicit modifier keys whose affordances are mirrored by + // the on-canvas hint line ("shift: suppress · alt: unsuppress"). + // plain click -> select (no mutation) // shift-click -> suppress (macrophage engulfs; cell scars) // alt-click -> unsuppress (labile release) - // plain click -> promote (retention boost) - try { - if (e.shiftKey) { - await api.memories.suppress(item.memoryId, 'suppressed from memories field'); - suppressedIds = new Set(suppressedIds).add(item.memoryId); - } else if (e.altKey) { - // unsuppress COMPOUNDS down by one — a memory suppressed twice is still - // suppressed after one unsuppress. Only clear the scar when the backend - // says it's fully released (stillSuppressed=false), else the cell would - // lie (render healthy while retrieval is still penalized). - const res = await api.memories.unsuppress(item.memoryId); - if (!res.stillSuppressed) { - const next = new Set(suppressedIds); - next.delete(item.memoryId); - suppressedIds = next; + selectedMemoryId = item.memoryId; + if (e.shiftKey || e.altKey) { + try { + if (e.shiftKey) { + await api.memories.suppress(item.memoryId, 'suppressed from memories field'); + suppressedIds = new Set(suppressedIds).add(item.memoryId); + } else if (e.altKey) { + // unsuppress COMPOUNDS down by one — a memory suppressed twice is still + // suppressed after one unsuppress. Only clear the scar when the backend + // says it's fully released (stillSuppressed=false), else the cell would + // lie (render healthy while retrieval is still penalized). + const res = await api.memories.unsuppress(item.memoryId); + if (!res.stillSuppressed) { + const next = new Set(suppressedIds); + next.delete(item.memoryId); + suppressedIds = next; + } } - } else { - const promoted = await api.memories.promote(item.memoryId); - memories = memories.map((memory) => - memory.id === promoted.id - ? { ...memory, retentionStrength: promoted.retentionStrength } - : memory - ); + error = null; + textPass.setText(buildTextItems()); + fieldPass?.setCells(buildFieldCells()); + } catch (err) { + error = err instanceof Error ? err.message : 'UNKNOWN MEMORY ACTION ERROR'; + textPass.setText(buildTextItems()); } - error = null; - textPass.setText(buildTextItems()); - fieldPass?.setCells(buildFieldCells()); - } catch (err) { - error = err instanceof Error ? err.message : 'UNKNOWN MEMORY ACTION ERROR'; - textPass.setText(buildTextItems()); } } diff --git a/apps/dashboard/src/routes/(app)/stats/+page.svelte b/apps/dashboard/src/routes/(app)/stats/+page.svelte index 4959a71..3d17313 100644 --- a/apps/dashboard/src/routes/(app)/stats/+page.svelte +++ b/apps/dashboard/src/routes/(app)/stats/+page.svelte @@ -26,6 +26,9 @@ let loading = $state(true); let error: string | null = $state(null); let consolidation: ConsolidationResult | null = $state(null); + // Tracked when the user picks a vital. Selection only — no API call. + // Consolidation runs only from the explicit [ CONSOLIDATE ] button on settings. + let selectedVitalId: string | null = $state(null); const statsScene = $derived.by(() => { const receipts = stats ? buildReceipts(stats, consolidation) : []; @@ -58,17 +61,13 @@ } } - async function handleRoutePick(pick: RoutePick) { - if (pick.kind !== 'stats-vital') return; - loading = true; - error = null; - try { - consolidation = await api.consolidate(); - stats = await api.stats(); - } catch (err) { - error = err instanceof Error ? err.message : String(err); - } finally { - loading = false; + function handleRoutePick(pick: RoutePick) { + // Plain click on a vital must SELECT/INSPECT only — never mutate. + // Consolidation is an expensive real FSRS mutation and lives behind the + // EXPLICIT [ CONSOLIDATE ] action button on the settings page. Reading a + // number here must not silently rewrite the whole memory system. + if (pick.kind === 'stats-vital') { + selectedVitalId = pick.id; } } diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index afdf67a..a0756d4 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -18,8 +18,9 @@ import AmbientAwarenessStrip from '$lib/components/AmbientAwarenessStrip.svelte'; import VerdictBar from '$lib/components/VerdictBar.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; - import Icon, { type IconName } from '$lib/components/Icon.svelte'; + import Icon from '$lib/components/Icon.svelte'; import { initTheme } from '$stores/theme'; + import { OS_ROUTES, DOCK_ROUTES, routesByGroup, HOME_ROUTE } from '$lib/os-routes'; let { children } = $props(); let showCommandPalette = $state(false); @@ -29,12 +30,19 @@ $page.url.pathname.startsWith(base) ? $page.url.pathname.slice(base.length) || '/' : $page.url.pathname ); let isMarketingRoute = $derived(dashboardPath === '/waitlist' || dashboardPath.startsWith('/waitlist/')); - // Zero-DOM Cognitive OS (Sam, Jul 9 2026): EVERY organ is a full-bleed - // all-WebGPU surface. DOM app chrome (sidebar nav, websocket toasts, - // ambient orbs, command palette) is bypassed for all dashboard routes so - // each route is a single shareable canvas — nav itself is drawn in WebGPU. - // Only true marketing/waitlist pages keep DOM. Recordings stay clean. - let isImmersiveRoute = $derived(!isMarketingRoute); + // The organs are full-bleed WebGPU canvases. The OS shell (persistent dock + + // ⌘K palette) is NOT the old flex-sidebar — it FLOATS over the canvas as an + // overlay so it never fights the `fixed inset-0` organ layout. It shows on + // 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. + let isCaptureMode = $derived( + $page.url.searchParams.has('capture') && $page.url.searchParams.get('capture') !== '0' + ); + // Show the floating OS shell on real dashboard routes (not marketing, not capture). + let showShell = $derived(!isMarketingRoute && !isCaptureMode); onMount(() => { // Live nervous system: every immersive organ consumes the WebSocket @@ -50,7 +58,7 @@ const teardownTheme = initTheme(); function onKeyDown(e: KeyboardEvent) { - if (isMarketingRoute || isImmersiveRoute) return; + if (isMarketingRoute || isCaptureMode) return; if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); showCommandPalette = !showCommandPalette; @@ -65,21 +73,9 @@ return; } if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; - if (e.key === '/') { - e.preventDefault(); - const searchInput = document.querySelector('input[type="text"]'); - searchInput?.focus(); - return; - } - // Single-key navigation shortcuts - const shortcutMap: Record = { - g: '/graph', m: '/memories', t: '/timeline', f: '/feed', - e: '/explore', i: '/intentions', s: '/stats', - r: '/reasoning', a: '/activation', d: '/dreams', - c: '/schedule', p: '/importance', u: '/duplicates', - x: '/contradictions', n: '/patterns', - }; - const target = shortcutMap[e.key.toLowerCase()]; + // Single-key navigation shortcuts — derived from the canonical registry + // so there is ONE source of truth (no drift between dock/palette/keys). + const target = SHORTCUT_MAP[e.key.toLowerCase()]; if (target && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); goto(`${base}${target}`); @@ -107,45 +103,34 @@ }); }); - // Each nav item carries a UNIQUE semantic icon (see Icon.svelte). The old - // set reused the same Unicode glyph across multiple items; every entry here - // now has a distinct silhouette that reads instantly. - const nav: { href: string; label: string; icon: IconName; shortcut: string }[] = [ - { href: '/observatory', label: 'Observatory', icon: 'sparkle', shortcut: 'O' }, - { href: '/blackbox', label: 'Black Box', icon: 'blackbox', shortcut: 'B' }, - { href: '/memory-prs', label: 'Memory PRs', icon: 'memorypr', shortcut: 'Q' }, - { href: '/graph', label: 'Graph', icon: 'graph', shortcut: 'G' }, - { href: '/reasoning', label: 'Reasoning', icon: 'reasoning', shortcut: 'R' }, - { href: '/memories', label: 'Memories', icon: 'memories', shortcut: 'M' }, - { href: '/timeline', label: 'Timeline', icon: 'timeline', shortcut: 'T' }, - { href: '/feed', label: 'Feed', icon: 'feed', shortcut: 'F' }, - { href: '/explore', label: 'Explore', icon: 'explore', shortcut: 'E' }, - { href: '/activation', label: 'Activation', icon: 'activation', shortcut: 'A' }, - { href: '/dreams', label: 'Dreams', icon: 'dreams', shortcut: 'D' }, - { href: '/schedule', label: 'Schedule', icon: 'schedule', shortcut: 'C' }, - { href: '/importance', label: 'Importance', icon: 'importance', shortcut: 'P' }, - { href: '/duplicates', label: 'Duplicates', icon: 'duplicates', shortcut: 'U' }, - { href: '/contradictions', label: 'Contradictions', icon: 'contradictions', shortcut: 'X' }, - { href: '/patterns', label: 'Patterns', icon: 'patterns', shortcut: 'N' }, - { href: '/intentions', label: 'Intentions', icon: 'intentions', shortcut: 'I' }, - { href: '/stats', label: 'Stats', icon: 'stats', shortcut: 'S' }, - { href: '/settings', label: 'Settings', icon: 'settings', shortcut: ',' }, - ]; - - // Mobile nav shows top 5 items - const mobileNav = nav.slice(0, 5); + // ── All nav surfaces derive from the ONE canonical registry (os-routes.ts) ── + // Single-key shortcut map, built from the registry (no hand-maintained drift). + const SHORTCUT_MAP: Record = Object.fromEntries( + OS_ROUTES.filter((r) => r.shortcut).map((r) => [r.shortcut!.toLowerCase(), r.href]) + ); function isActive(href: string, currentPath: string): boolean { const path = currentPath.startsWith(base) ? currentPath.slice(base.length) || '/' : currentPath; - if (href === '/graph') return path === '/' || path === '/graph'; - return path.startsWith(href); + if (href === HOME_ROUTE) return path === '/' || path === href; + return path === href || path.startsWith(href + '/'); } - let filteredNav = $derived( - cmdQuery - ? nav.filter(n => n.label.toLowerCase().includes(cmdQuery.toLowerCase())) - : nav + // The command palette searches label + purpose across ALL 20 organs, grouped. + let paletteGroups = $derived( + routesByGroup() + .map((g) => ({ + group: g.group, + routes: cmdQuery + ? g.routes.filter( + (r) => + r.label.toLowerCase().includes(cmdQuery.toLowerCase()) || + r.purpose.toLowerCase().includes(cmdQuery.toLowerCase()) + ) + : g.routes + })) + .filter((g) => g.routes.length > 0) ); + let paletteFlat = $derived(paletteGroups.flatMap((g) => g.routes)); function cmdNavigate(href: string) { showCommandPalette = false; @@ -154,161 +139,131 @@ } -{#if isMarketingRoute || isImmersiveRoute} - {@render children()} -{:else} - - - - + +{@render children()} - - -
- -
+ {/each} + + {/if} - -{#if showCommandPalette && !isMarketingRoute && !isImmersiveRoute} + +{#if showCommandPalette && showShell}
{ if (e.key === 'Escape') showCommandPalette = false; }} onclick={(e) => { if (e.target === e.currentTarget) showCommandPalette = false; }} > -
+
{ - if (e.key === 'Enter' && filteredNav.length > 0) { - cmdNavigate(filteredNav[0].href); - } + if (e.key === 'Enter' && paletteFlat.length > 0) cmdNavigate(paletteFlat[0].href); }} /> esc
-
- {#each filteredNav as item} - +
+ {#each paletteGroups as grp} +
{grp.group}
+ {#each grp.routes as item} + + {/each} {/each} - {#if filteredNav.length === 0} + {#if paletteFlat.length === 0}
No matches
{/if}
@@ -321,24 +276,206 @@ padding-bottom: env(safe-area-inset-bottom, 0px); } - /* Logo breathes a faint synapse glow on hover — the mark feels live. */ - .logo-mark { - transition: - transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), - box-shadow 0.3s ease; + /* ── Floating desktop dock ───────────────────────────────────────────── + Overlays the full-bleed canvas at the left edge. Collapsed to icons by + default; expands to labels on hover so it never steals canvas real + estate but stays one glance away. */ + .os-dock { + position: fixed; + top: 50%; + left: 0.75rem; + transform: translateY(-50%); + z-index: 60; + flex-direction: column; + align-items: stretch; + gap: 0.35rem; + max-height: calc(100dvh - 1.5rem); + padding: 0.5rem 0.4rem; + border-radius: 1rem; + border: 1px solid rgba(129, 140, 248, 0.16); + background: rgba(6, 8, 14, 0.72); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55); + width: 3.4rem; + transition: width 0.18s ease; + overflow: hidden; } - .logo-link:hover .logo-mark { - transform: rotate(-6deg) scale(1.08); - box-shadow: - 0 0 0 1px rgba(129, 140, 248, 0.4), - 0 0 22px rgba(99, 102, 241, 0.5); + .os-dock:hover, + .os-dock:focus-within { + width: 13.5rem; + } + .os-dock-logo { + display: grid; + place-items: center; + width: 2.6rem; + height: 2.6rem; + margin: 0 auto 0.35rem; + border-radius: 0.7rem; + background: linear-gradient(135deg, var(--dream, #818cf8), var(--synapse, #6366f1)); + color: #fff; + flex-shrink: 0; + box-shadow: 0 0 18px rgba(99, 102, 241, 0.35); + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + } + .os-dock-logo:hover { + transform: rotate(-6deg) scale(1.06); + } + .os-dock-items { + display: flex; + flex-direction: column; + gap: 0.15rem; + overflow-y: auto; + min-height: 0; + flex: 1; + } + .os-dock-link { + display: flex; + align-items: center; + gap: 0.7rem; + width: 100%; + padding: 0.55rem 0.6rem; + border-radius: 0.6rem; + color: #9aa7c2; + font-size: 0.85rem; + white-space: nowrap; + border: 1px solid transparent; + background: none; + cursor: pointer; + text-align: left; + } + .os-dock-link:hover { + color: #e8ecf1; + background: rgba(255, 255, 255, 0.04); + } + .os-dock-active { + color: #a5b4fc; + background: rgba(99, 102, 241, 0.15); + border-color: rgba(99, 102, 241, 0.3); + } + .os-dock-icon { + width: 1.4rem; + display: grid; + place-items: center; + flex-shrink: 0; + } + .os-dock-active .os-dock-icon :global(svg) { + filter: drop-shadow(0 0 6px rgba(129, 140, 248, 0.6)); + } + .os-dock-label { + flex: 1; + opacity: 0; + transition: opacity 0.12s ease; + } + .os-dock:hover .os-dock-label, + .os-dock:focus-within .os-dock-label { + opacity: 1; + } + .os-dock-key { + font-family: ui-monospace, 'SF Mono', Menlo, monospace; + font-size: 0.65rem; + color: rgba(154, 167, 194, 0.5); + opacity: 0; + } + .os-dock:hover .os-dock-key, + .os-dock:focus-within .os-dock-key { + opacity: 1; + } + .os-dock-command { + margin-top: 0.25rem; + border-top: 1px solid rgba(129, 140, 248, 0.1); + border-radius: 0.6rem; + color: #7c8aa8; + } + .os-dock-footer { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.6rem 0.1rem; + margin-top: 0.3rem; + border-top: 1px solid rgba(129, 140, 248, 0.1); + } + .os-dock-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.72rem; + color: #7c8aa8; + } + .os-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + flex-shrink: 0; + } + .os-dot-live { + background: #29f2a9; + box-shadow: 0 0 8px #29f2a9; + } + .os-dot-off { + background: #ff6b6b; + } + .os-dock-status-text { + opacity: 0; + } + .os-dock:hover .os-dock-status-text, + .os-dock:focus-within .os-dock-status-text { + opacity: 1; + } + .os-dock-theme { + margin-left: auto; + opacity: 0; + } + .os-dock:hover .os-dock-theme, + .os-dock:focus-within .os-dock-theme { + opacity: 1; } - /* The active nav item's icon picks up a soft drop-shadow glow so the - current location reads at a glance even in the collapsed (icon-only) - sidebar. */ - .nav-link.text-synapse-glow .nav-icon :global(svg), - .nav-active-border .nav-icon :global(svg) { - filter: drop-shadow(0 0 6px rgba(129, 140, 248, 0.55)); + /* ── Mobile bottom bar ───────────────────────────────────────────────── */ + .os-mobilebar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: space-around; + padding: 0.3rem 0.4rem; + border-top: 1px solid rgba(129, 140, 248, 0.14); + background: rgba(6, 8, 14, 0.9); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + } + .os-mobile-link { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + min-width: 3.2rem; + min-height: 2.75rem; + padding: 0.35rem 0.5rem; + border-radius: 0.6rem; + color: #7c8aa8; + background: none; + border: none; + cursor: pointer; + } + .os-mobile-active { + color: #a5b4fc; + } + .os-mobile-label { + font-size: 0.6rem; + } + + @media (prefers-reduced-motion: reduce) { + .os-dock, + .os-dock-label, + .os-dock-key, + .os-dock-logo, + .os-dock-theme, + .os-dock-status-text { + transition: none; + } }