feat(graph): raw-WebGPU living field is now the main dashboard renderer

The main graph page defaults to the Observatory engine — GPU force sim,
HDR bloom, and the recall wavefront sweeping the REAL memory graph on a
12s loop — instead of the flat Three.js view. Visuals to the max, per
the locked ALIVE-field direction.

- ObservatoryStage gains embedded (fills parent, not viewport) and
  chrome ('full' | 'none') props; chrome='none' is a pure living canvas
  plus loading/error text, so the page's own DOM chrome stays in charge.
- New Field | Classic renderer toggle in the control bar. Field is the
  default wherever WebGPU exists; no WebGPU forces classic. Choice
  persists in localStorage.
- Classic (Three.js) remains one click away and untouched: node picking,
  colour modes, node count, brightness, temporal scrubbing and legends
  are classic-renderer features and now only render there. Dream,
  Observatory takeover, Memory Cinema (protected) and Reload work in
  both modes.

Verified live: fresh load defaults to Field (148 real nodes, 2865 real
edges, recall ribbon igniting on camera, zero console errors); Classic
toggle restores search/colour/brightness with a single canvas; the
choice round-trips through localStorage. svelte-check 940 files 0
errors 0 warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-07-08 12:06:01 -07:00
parent 7da5f4aa97
commit c920f93b10
2 changed files with 113 additions and 24 deletions

View file

@ -44,6 +44,19 @@
ondemochange?: (demo: DemoMode) => void;
/** When provided, renders the exit control (× back to the dashboard). */
onexit?: () => void;
/**
* Embedded mode — the stage fills its PARENT (absolute) instead of the
* viewport (fixed). Used by the main graph page, where the field is the
* default renderer living under the page's own chrome.
*/
embedded?: boolean;
/**
* 'full' — telemetry strip, spine, verdicts, switcher, exit (the route
* and the takeover overlay).
* 'none' — pure living canvas + loading/error text only; the host page
* provides all chrome (the main graph's field renderer).
*/
chrome?: 'full' | 'none';
}
let {
@ -53,7 +66,9 @@
capture = false,
showSwitcher = true,
ondemochange,
onexit
onexit,
embedded = false,
chrome = 'full'
}: Props = $props();
// Human labels for the switcher chips — short, mono, uppercase (visual DNA §7.3).
@ -246,8 +261,11 @@
<svelte:window onkeydown={onKeydown} />
<!-- Full-bleed void stage: #05060a -->
<div class="fixed inset-0 overflow-hidden bg-[#05060a]" class:cursor-none={capture}>
<!-- Void stage: #05060a — full-bleed (route/takeover) or parent-filling (embedded) -->
<div
class="{embedded ? 'absolute' : 'fixed'} inset-0 overflow-hidden bg-[#05060a]"
class:cursor-none={capture}
>
<!-- Canvas layer (z-index 0) — the living memory field -->
<div class="absolute inset-0 z-0">
<ObservatoryCanvas
@ -260,9 +278,11 @@
</div>
<!-- DOM overlay layer (pointer-events:none) — instruments only.
capture hides it all (pure canvas for recording); H toggles. -->
capture hides it all (pure canvas for recording); H toggles;
chrome='none' leaves only loading/error (host page owns the chrome). -->
{#if showHud}
<div class="absolute inset-0 z-10 pointer-events-none">
{#if chrome === 'full'}
<!-- Top telemetry strip -->
<TelemetryStrip
demoMode={demo}
@ -276,9 +296,10 @@
{loading}
{error}
/>
{/if}
<!-- Exit control — back to the dashboard graph. Esc works too. -->
{#if onexit}
{#if chrome === 'full' && onexit}
<button
onclick={onexit}
class="absolute top-10 right-4 pointer-events-auto font-mono text-xs tracking-widest
@ -291,7 +312,7 @@
{/if}
<!-- Demo moment switcher — the 5 cognitive moments, one click each. -->
{#if showSwitcher}
{#if chrome === 'full' && showSwitcher}
<div class="absolute top-10 left-4 pointer-events-auto flex flex-col gap-1.5">
{#each DEMO_MODES as mode (mode)}
<button
@ -327,15 +348,17 @@
{/if}
<!-- Timeline spine: beat ticks + playhead riding the loop -->
<TimelineSpine steps={pathSteps} frame={frameCount} />
{#if chrome === 'full'}
<TimelineSpine steps={pathSteps} frame={frameCount} />
{/if}
<!-- Salience-rescue verdict card (frame-driven opacity) -->
{#if demo === 'salience-rescue' && rescuePlan?.viable}
{#if chrome === 'full' && demo === 'salience-rescue' && rescuePlan?.viable}
<RescueVerdict frame={frameCount} verdict={rescuePlan.verdict} />
{/if}
<!-- Firewall quarantine verdict card (frames 480-620, crimson tone) -->
{#if demo === 'firewall' && firewallPlan?.viable}
{#if chrome === 'full' && demo === 'firewall' && firewallPlan?.viable}
<RescueVerdict
frame={frameCount}
tone="quarantine"

View file

@ -64,6 +64,22 @@
let showObservatory = $state(false);
let obsDemo = $state<DemoMode>('recall-path');
// Main-canvas renderer: 'field' = the raw-WebGPU living memory field (the
// Observatory engine — HDR bloom, GPU force sim, recall wavefront riding
// the REAL graph; visuals to the max, the default wherever WebGPU exists).
// 'classic' = the Three.js inspector (node picking, colour modes, temporal
// scrubbing) — kept one click away until GPU picking lands in the field.
const RENDERER_KEY = 'vestige-graph-renderer';
let renderMode = $state<'field' | 'classic'>('classic');
function setRenderMode(m: 'field' | 'classic') {
renderMode = m;
try {
localStorage.setItem(RENDERER_KEY, m);
} catch {
/* storage unavailable (private mode) — session-only choice */
}
}
// Filtered graph data based on temporal mode
let displayNodes = $derived.by((): GraphNode[] => {
if (!graphData) return [];
@ -112,6 +128,17 @@
}
onMount(() => {
// Renderer choice: honor the saved preference, defaulting to the WebGPU
// field wherever the GPU exists. No WebGPU → classic, always.
const hasWebGpu = typeof navigator !== 'undefined' && 'gpu' in navigator;
let saved: string | null = null;
try {
saved = localStorage.getItem(RENDERER_KEY);
} catch {
/* storage unavailable */
}
renderMode = !hasWebGpu ? 'classic' : saved === 'classic' ? 'classic' : 'field';
const sp = new URLSearchParams(window.location.search);
const requestedMode = sp.get('colorMode');
if (isColorMode(requestedMode)) {
@ -286,16 +313,25 @@ disown</code>
</div>
</div>
{:else if graphData}
<Graph3D
nodes={displayNodes}
edges={displayEdges}
centerId={graphData.center_id}
events={$eventFeed}
{isDreaming}
{colorMode}
onSelect={onNodeSelect}
onGraphMutation={handleGraphMutation}
/>
{#if renderMode === 'field'}
<!-- The raw-WebGPU living memory field — the Observatory engine as the
MAIN renderer: GPU force sim, HDR bloom, the recall wavefront
sweeping the real graph on a 12s loop. Page chrome stays DOM. -->
<div class="absolute inset-0">
<ObservatoryStage embedded chrome="none" demo="recall-path" showSwitcher={false} />
</div>
{:else}
<Graph3D
nodes={displayNodes}
edges={displayEdges}
centerId={graphData.center_id}
events={$eventFeed}
{isDreaming}
{colorMode}
onSelect={onNodeSelect}
onGraphMutation={handleGraphMutation}
/>
{/if}
{/if}
<!-- Top controls bar -->
@ -304,7 +340,8 @@ disown</code>
[padding-top:max(0.75rem,env(safe-area-inset-top))] [padding-left:max(0.75rem,env(safe-area-inset-left))] [padding-right:max(0.75rem,env(safe-area-inset-right))]
sm:[padding-top:0] sm:[padding-left:0] sm:[padding-right:0]"
>
<!-- Search -->
<!-- Search — classic-renderer feature (centers + reloads the inspector) -->
{#if renderMode === 'classic'}
<div class="flex gap-2 w-full sm:flex-1 sm:w-auto sm:max-w-md">
<input
type="text"
@ -319,8 +356,36 @@ disown</code>
Focus
</button>
</div>
{/if}
<div class="flex flex-wrap gap-2 w-full sm:w-auto sm:ml-auto">
<!-- Renderer toggle: the WebGPU living field (default) vs the classic
Three.js inspector. The field is the banger; classic keeps node
picking / colour modes / temporal until GPU picking lands. -->
<div class="flex shrink-0 glass rounded-xl p-0.5 text-xs" role="radiogroup" aria-label="Renderer">
<button
type="button"
role="radio"
aria-checked={renderMode === 'field'}
onclick={() => setRenderMode('field')}
class="min-h-9 px-3 py-1.5 rounded-lg transition {renderMode === 'field' ? 'bg-[#5dcaa5]/25 text-[#7fe6c0]' : 'text-dim hover:text-text'}"
title="Raw-WebGPU living memory field — visuals to the max"
>
✦ Field
</button>
<button
type="button"
role="radio"
aria-checked={renderMode === 'classic'}
onclick={() => setRenderMode('classic')}
class="min-h-9 px-3 py-1.5 rounded-lg transition {renderMode === 'classic' ? 'bg-synapse/25 text-synapse-glow' : 'text-dim hover:text-text'}"
title="Classic 3D inspector — node picking, colour modes, temporal scrubbing"
>
Classic
</button>
</div>
{#if renderMode === 'classic'}
<!-- v2.0.8: colour mode toggle. Switches sphere tint between node type
(fact / concept / event / …) and FSRS memory state (active / dormant /
silent / unavailable). Legend auto-renders in state mode. -->
@ -386,6 +451,7 @@ disown</code>
{graphState.brightness.toFixed(1)}x
</span>
</label>
{/if}
<!-- Dream button -->
<button
@ -449,13 +515,13 @@ disown</code>
<!-- v2.0.8: FSRS memory-state legend. Only rendered in state mode so the
legend doesn't compete with the node-type palette in type mode. -->
{#if colorMode === 'state'}
{#if renderMode === 'classic' && colorMode === 'state'}
<div class="absolute bottom-4 right-4 z-10">
<MemoryStateLegend />
</div>
{/if}
{#if colorMode === 'ahagraph'}
{#if renderMode === 'classic' && colorMode === 'ahagraph'}
<div class="absolute bottom-4 right-4 z-10 glass rounded-xl px-4 py-3 text-xs">
<div class="text-bright font-semibold mb-2">AhaGraph</div>
<div class="space-y-1.5">
@ -469,8 +535,8 @@ disown</code>
</div>
{/if}
<!-- Temporal playback slider -->
{#if graphData}
<!-- Temporal playback slider — scrubs the classic inspector only -->
{#if graphData && renderMode === 'classic'}
<TimeSlider
nodes={graphData.nodes}
onDateChange={(date) => { temporalDate = date; }}