feat(dashboard): MSDF text engine — the all-WebGPU text linchpin

Day-1 linchpin of the zero-DOM Cognitive OS. Every label, receipt id, and
number in the dashboard now renders as glowing in-canvas MSDF glyphs instead
of DOM text. Checked-in JetBrains Mono mtsdf atlas (512x512, 95 glyphs) +
loader + pure layout helper + FramePass + trap-checked WGSL.

Built by a GPT-5.5 swarm against an adversarially-verified spec (5 verifiers
+ 3 skeptics caught 6 silent-fail traps before a line was written: V-flip,
release-binary atlas embed, base-path fetch, linear rgba8unorm, premultiplied
blend, ASCII-only charset). Conductor live-GPU audit on the real dashboard
found one more at runtime: the aspect transform pushed text off-screen in
portrait; fixed to keep glyphs square in both orientations.

Renders cyan, crisp, upright, glowing, glyph-by-glyph, landscape + portrait.
check 0 errors, build green, golden V-flip test 2/2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-07-09 12:43:58 -07:00
parent f167be2663
commit 389ddfa294
11 changed files with 1314 additions and 112 deletions

View file

@ -0,0 +1,374 @@
# GOD-TIER PLAN — The All-WebGPU Cognitive OS Dashboard
### Vestige · HN launch July 14 2026 · written July 9 (T-5 days)
> **THE ONE RULE THAT OVERRIDES EVERYTHING:** The ENTIRE dashboard renders in
> WebGPU. **Text and all. Zero DOM chrome.** Every pixel a user sees — labels,
> memory content, receipt IDs, numbers, buttons, the cursor's effect — is drawn
> by the GPU into one canvas. This is not negotiable and it is the whole point:
> **a full-screen WebGPU canvas is the only artifact that survives a screen
> recording, an OS screenshot, or a phone camera pointed at the monitor — which
> is the only thing that makes people share it.** A DOM dashboard is a website.
> An all-WebGPU dashboard is footage.
>
> Sam, verbatim: *"EVERYTHING needs to be WebGPU/RawGPU so the visuals can be to
> the ABSOLUTE MAX"* and *"I WANT THIS WHOLE DASHBOARD AS WEBGPU BECAUSE THAT'S
> THE ONLY THING THAT WILL GET USERS TO SHARE THIS."*
DOM's ONLY remaining job: a single invisible accessibility mirror (offscreen
`aria-live` + focusable proxies) so screen-readers/keyboard work. It is never
seen. It carries zero visual styling. `app.css` becomes ~empty.
Branch: `feat/dashboard-live-max`. Repo: `/Users/entity002/vestige`. Reuse the
raw-WebGPU `ObservatoryEngine` + `PostChain`. Never touch Memory Cinema or the
Graph field.
---
## 0. WHY THIS IS THE SHAREABLE MOVE (the launch thesis)
Verified precedents (Vestige memory, HN Algolia, July 2026):
- ghostty-blackhole (raytraced black hole whose SIZE = context fill) went viral
because **the visual IS a live gauge** and it's pure canvas — remixable.
- XorDev singularity shader (381 chars) → ~60M views, remixed into shadcn/godot.
- pilot flight-globes 1539pts, neal.fun, wplace.live 0→1M users in 4 days.
- **The 5 transferable mechanics:** (1) personalization — render THEIR data;
(2) one absurd verifiable number in the title; (3) receipts — every flourish
is a gauge of real state; (4) deterministic 12s perfect loop = remixable clip;
(5) chain moments into one journey ending in a personal payoff.
A DOM dashboard fails all five the moment someone screen-records it (the DOM
chrome screams "web app"). An all-WebGPU organism passes all five: it looks like
a living brain, it's a single shareable surface, every glow is a real gauge, and
the 720-frame loop is already deterministic.
**The moat is the conjunction:** raw-WebGPU engine (ours) × real Vestige-only
data (FSRS decay, RSB causal receipts, contradiction pairs, suppression, live
events) × the discipline test (swap real data for Math.random and the viewer can
tell). Nobody else has all three. Rendering the TEXT in-canvas too is what makes
it uncopyable *and* unscreenshottable-as-a-webpage.
---
## 1. WHAT ALREADY EXISTS (verified in-repo, reuse — do not rebuild)
- `lib/observatory/engine.ts``ObservatoryEngine`: FramePass plugin system
(`addPass`, `compute(encoder)` + `render(pass)`), params uniform buffer,
deterministic `DemoClock` (720-frame loop), DPR clamp, `setPaused`,
`preFrameHook`, `wallNowMs`, `totalFrames`, GPU picking.
- `post/post-chain.ts` — HDR chain: `SCENE_FORMAT='rgba16float'` scene → mip
bloom → Khronos PBR neutral tonemap → grain → vignette. **Text drawn into the
scene texture gets bloom for free** — that's how labels "glow."
- `live-bridge.ts` — real WebSocket `VestigeEvent` → GPU mutation, with the live
lanes (liveKind/liveFrame/liveEnergy/projectionDays) + the metaball/firewall/
dream/causal-recall machinery.
- `cognitive-palette.ts` — the invented color language (blackwater base, FSRS
oxygen ramp, magenta=RSB-only, scarlet=immune, indigo=bitemporal, cyan accent).
- `route-scene.ts``RouteSceneModel` + `Provenance` + `assertProvenance` (the
discipline test as a type constraint).
- Working organ passes to copy: `reasoning/reasoning-theater-pass.ts`,
`contradictions/contradictions-pass.ts`, `blackbox/blackbox-pass.ts`.
- `RouteStage.svelte` — the organ shell (engine lifecycle, WebGPU fallback,
pause, reduced-motion). **Must be rebuilt to host the text layer + input layer
and remove all DOM panels.**
**THE ONE MISSING PIECE (the linchpin):** there is NO text pipeline in the
observatory. All-WebGPU text = we build an MSDF glyph renderer. This is the
critical path. Everything else is variation on proven passes.
---
## 2. WEBGPU PLATFORM WE CAN RELY ON (verified July 5 2026, Chrome stable)
SHIPPED (use freely): HDR **extended-range canvas** (`toneMapping:{mode:'extended'}`
+ rgba16float + display-p3 — ignitions brighter than page-white on M1 Max XDR —
the "gasp" nobody else has), subgroups (134/144/145), shader-f16 (120),
dual-source-blending (130), timestamp-query (121, dev perf HUD only),
texture-formats tier1/tier2, IMMEDIATES/push-constants (149-150).
NOT shipped (do NOT depend on): multi-draw-indirect, bindless.
Cross-browser: Safari 26 all Apple, Firefox 141+ Windows/145+ AS-Mac. **WebGPU
absent → the accessibility DOM mirror shows a static "open in Chrome/Safari for
the live view" + the last server-rendered OG still. Never a black screen.**
**Capture/share gotchas (verified, launch-critical):** WebGPU canvases IGNORE
`preserveDrawingBuffer` — snapshot must copy `getCurrentTexture()` IN THE SAME
TASK as the render (gpuweb#1781). Clip export: `mediabunny` (WebCodecs
canvas→MP4). Safari clipboard: pass `Promise<Blob>` inside `ClipboardItem`. Bake
the wordmark into the POST CHAIN (not DOM) so it survives OS screen recordings
(the Loom/tldraw viral-watermark move).
---
## 3. THE ARCHITECTURE — one canvas, four GPU layers, zero DOM chrome
Every route = `ObservatoryEngine` rendering into the HDR scene texture, composed
in this Z order, all in WebGPU:
```
L0 BLACKWATER FIELD full-bleed metaball/organism substrate (the "tissue")
L1 ORGAN PASS(es) the route's bespoke hero (chambers/synapse/rings/…)
L2 MSDF TEXT LAYER every label/number/content, drawn in-scene (gets bloom)
L3 INTERACTION LAYER GPU cursor field, click shockwaves, hover ignition
→ POST CHAIN bloom + tonemap + grain + vignette + baked wordmark
```
Input: ONE transparent full-canvas hit-layer. Pointer/keyboard events →
CPU hit-test against a GPU-picking ID buffer (already have picking) OR a CPU
rect/quadtree of the laid-out text/controls → drive `live-bridge` + API calls.
No `<button>`, no `<div>` panel. A "button" is an MSDF label + a pickable quad +
a click-shockwave. Selection/focus is a GPU highlight, not a CSS outline.
Accessibility mirror (offscreen, visually `sr-only`, never rendered): mirror the
scene model into `aria-live` regions + focusable proxies so keyboard/SR users
get the data and can trigger the same actions. This is the ONLY DOM.
---
## 4. THE UNIFYING VISUAL LANGUAGE (locked — "Causal Bioluminescent Cortex")
A dark local brain in a jar; routes are organs of one organism. From
`cognitive-palette.ts` (source of truth):
- **Base:** blackwater `#020307`. Never purple. Ever.
- **Retention = oxygen:** luciferin `#E9FFB7` (fresh) → healthy `#A8FF5E`
amber-debt `#8A4B18` → sediment `#2A160B` (forgotten). Radius = √stability.
- **Cyan `#22C7DE`** = the single interactive accent (focus, hover, selection).
- **Scarlet `#FF3B30`/`#B90D2B`** = immune (contradiction/veto/suppression scar).
- **Magenta `#FF2DF7`** = RSB retrograde causal axon ONLY. Never chrome.
- **Indigo `#7C6CFF`** = bitemporal transaction-time parallax ONLY.
- **Text is bioluminescent, not chrome:** labels are **etched glowing scars**
they materialize glyph-by-glyph when backed by a real memory/event/receipt,
colored by their semantic (a memory's label glows at its retention color; a
veto reason glows scarlet; a causal receipt id glows magenta). Text drawn into
the HDR scene BEFORE bloom, so it glows like the tissue.
Motion grammar: chemotaxis, elastic axons, immune clamping, retrograde firing,
metabolic breathing, sedimentation, scar persistence, **click-as-incision**
(every click cuts/probes real tissue → receipt-backed wave).
---
## 5. THE CRITICAL PATH — MSDF Text Engine (build FIRST, everything needs it)
Reference: Red Blob "SDF Fonts" + WebGPU-samples `textRenderingMsdf`
(`msdfText.wgsl`, median-of-RGB distance, screen-space width via `fwidth`/
derivatives, smoothstep alpha). Verified real, spec-current.
Deliverables:
1. **Checked-in MSDF atlas** for ONE crisp mono font (JetBrains Mono / IBM Plex
Mono). Generate offline with `msdf-atlas-gen` → a PNG atlas + JSON
(`planeBounds`/`atlasBounds` per glyph). Commit both to
`apps/dashboard/static/msdf/`. NEVER generate at runtime (toolchain risk).
2. `lib/observatory/text/msdf-atlas.ts` — loads the atlas PNG as a `texture_2d`
+ parses the JSON into a glyph metrics map.
3. `lib/observatory/text/text-layer.ts` — a `FramePass`. CPU lays out strings →
per-glyph instance buffer: `{glyphRect, atlasRect, worldAnchor,
semanticColorRGBA, ageFrame, confidence}`. GPU draws one instanced quad per
glyph, samples the MSDF atlas, applies `smoothstep` AA, tints by
`semanticColor`, and (signature move) reveals glyph-by-glyph over `ageFrame`
for the "etched scar materializes" effect. Renders into the scene texture
(pre-bloom) so text glows.
4. `shaders/msdf-text.wgsl.ts` — the vertex (place glyph quad at worldAnchor +
glyphRect) + fragment (median MSDF distance → alpha → semantic color * glow).
Single module; render-only; storage buffers `var<storage, read>` (trap T6).
No reserved-word struct fields (traps: not `meta`, not `active`).
5. A `layout` helper: wrap/truncate/align strings to a world-space box, return
glyph instances. This replaces every DOM `<p>`/`<span>`/label.
Acceptance: a test route renders "hello · 5de3e41f · trust 51%" as glowing
in-canvas text, selectable via pick, materializing glyph-by-glyph, tinted by
semantic color. Once this lands, all text everywhere is this layer.
---
## 6. THE INTERACTION ENGINE (every click means something, in-canvas)
`lib/observatory/interaction/` — reuse the existing GPU picking + click-shockwave:
- **Cursor field:** the pointer is a soft light in the tissue; nearby cells lean
toward it (chemotaxis) — the field *flinches* on movement. Pure GPU, cursor
pos as a uniform.
- **Hover = ignition:** hovering a pickable object (memory/event/receipt) ignites
it + its real neighbors (spreading activation), and its MSDF label
materializes. Drives off the pick ID buffer.
- **Click = incision + receipt:** click fires a shockwave from the exact object;
for a real action (suppress/promote/merge/appeal) the wave + state change play
AFTER the API succeeds; for inspect, a receipt panel (MSDF text) etches in.
No optimistic fake pulses.
- **Controls are organisms:** a "button" = MSDF label + pickable quad + hover
ignition + click shockwave. A slider (e.g. duplicate threshold, forgetting
horizon) = a draggable pickable node that physically moves the field.
- Keyboard: the accessibility mirror's focusable proxies map 1:1 to pickable
objects; Tab moves a GPU focus-ring (cyan) through them; Enter = click.
---
## 7. PER-ROUTE ORGANS (all-WebGPU, real data, discipline test governs)
Each = `RouteStage` mounting: L0 field + L1 organ pass + L2 text + L3 interaction.
The route's `*-scene.ts` adapter turns real API/events → `RouteSceneModel`
(`assertProvenance`). DONE organs need their DOM panels replaced by the text
layer.
**Flagships (highest share value, do first):**
1. **Reasoning — Eight-Stage Thought Organ.** `/deep_reference` replays as 8
glowing chambers; evidence cells flow chamber→chamber on compute Bezier
splines; a stage lights only if it has real output; contradiction/supersession
interrupts cut the path; the answer + evidence render as MSDF scars in-scene.
Click a chamber → its receipt etches in. *(pass exists — swap DOM answer panel
for MSDF text.)*
2. **Blackbox — Agent Flight Recorder.** Real `/traces` as a nervous-system
trace: tool calls = impulses on lanes, retrievals = green branches,
suppressions = red clamps, writes = cell births, vetoes = immune gates,
receipt IDs = MSDF beads. Run list + event log rendered in-canvas. *(pass
exists — the run list + event log must become MSDF text, not DOM.)*
3. **Contradictions — Immune Synapse Arena.** Dual-channel signed metaball field;
higher-trust membrane thickens; unresolved pairs spark scarlet arcs; the pair
text + verdict etch in scarlet. *(pass exists.)*
4. **Duplicates — Synaptic Fusion Chamber.** Similarity slider physically pushes
twin cells together (neck = smoothstep(0.78,0.98,sim)); mismatch tokens =
glowing filaments (MSDF); real merge fuses nuclei + receipt ring. *(pass
exists.)*
5. **Timeline — Bitemporal Growth Rings.** Cut cross-section of the brain:
valid-time rings + indigo transaction-time shadows; supersessions cut seams;
MSDF date ticks engraved in the rings. *(pass exists.)*
**Shared effect:** RSB retrograde magenta axon (research D3) — reusable pass,
used by Reasoning + Blackbox; target→cause backward wavefront + permanent
cause-latch brightening. Magenta lives only here.
**Sweep organs (same L0 field + text + real-metric adapter, not bespoke heroes):**
feed (event bloodstream), schedule (forgetting-debt orrery), patterns
(cross-project mycelium/physarum), memories (cellular atlas), explore
(chemoattractant probe), importance (salience furnace), dreams (REM forge over
the live dream storm), intentions (future-tense germline seeds), memory-prs
(immune review capsules), stats (metabolic vitals). Each: `*-scene.ts` adapter +
the shared field + MSDF readouts. Nothing static, nothing purple, nothing DOM.
**The graph route + Memory Cinema:** UNTOUCHED (already all-WebGPU + protected).
---
## 8. THE SHARE LOOP — "PLANT A THOUGHT" (the viral wedge, all-WebGPU)
One public URL, no install. Type a thought/name/handle. Your text is
simultaneously the **seed** (phantomBrain: deterministic one-of-a-kind
constellation from your text) AND the **label** (MSDF-rendered — your words
glowing in the void). The birth engine fires: dust converges → your thought
ignites in thin-film iridescence brighter than page-white on XDR → edges engrave
to a phantom constellation → settles to your mind-signature. 12s seamless loop,
footage-grade through the post chain. Share mints
`vestige.dev/mind?t=your-thought` (deterministic replay forever + OG still from
`getCurrentTexture` same-task copy). Every share visually unique by construction.
End-card (MSDF): *"This is a toy mind. Vestige renders your agent's REAL one."*
install → the real Observatory. This reuses the birth choreography + seeded
determinism that already exist; new work = text→seed+label + share/OG mechanics.
---
## 9. THE HARD TRAPS (all found live — every worker card carries these)
- **T-DOM:** ZERO visible DOM. If a route renders a styled `<div>`/`<p>`/`<button>`
the user can see, it's a RED card. Text = MSDF layer. Controls = pickable
quads. The only DOM is the invisible a11y mirror.
- **T3 (rgba16float):** field ping-pong = `rgba16float` render-attachment
textures; separable blur = FULLSCREEN RENDER PASSES; NEVER
`texture_storage_2d<...,write>`+textureStore (not portable). Copy the proven
field pipeline from reasoning-theater-pass.ts.
- **T6 (module split):** a render pipeline's WGSL module must not declare
`read_write` storage or write storage textures (compute-only). Render-stage
storage buffers are `var<storage, read>`. Split modules per pipeline.
- **WGSL reserved words:** never a var OR struct field named `active`, `meta`,
`filter`, `sample`, `texture`, `binding`, `common`, `override`. (`active` broke
Organ 1; `meta` broke Blackbox.) Pick names like `beat`/`info`.
- **Struct-field match:** every `struct.field` referenced in WGSL must EXIST in
the struct. TS build does not catch this.
- **Pipeline-layout scoping (web-verified vs W3C spec):** a pipeline's layout
need only cover the SELECTED entry point's statically-reached bindings; a
shared multi-entry-point module is fine. Watch texture sampleType `float` vs a
filtering sampler (creation error).
- **FramePass order:** engine calls ALL `compute(encoder)` then ONE main scene
pass calls ALL `render(pass)`. Field passes encode their own offscreen
splat+blur inside `compute(encoder)`; draw membrane + text in `render()`.
- **One-shot events = labeled deterministic replay,** never fake streaming.
- **Capture:** snapshot copies `getCurrentTexture()` in the same task (WebGPU
ignores preserveDrawingBuffer). Wordmark baked in the post chain.
## 9b. LIFECYCLE CONTRACT (every organ, all in-canvas)
loading / ready / stale / empty / error / reduced-motion / paused — all rendered
as GPU states (a "loading" organ breathes dim; "empty" = calm blackwater +
honest MSDF line; "error" = scarlet pulse + MSDF message). WebGPU absent → the
a11y DOM mirror + a static message + last OG still. Reduced-motion freezes
ambient drift but KEEPS discrete event pulses. Persistent GPU pause control.
## 9c. PROTECTED (never touch)
`MemoryCinema.svelte`, `graph/cinema/*`, the Graph field's 64-byte NodeState +
the 8 existing observatory shaders. Reuse the engine; don't fork it.
## 9d. GATE (every card, run — don't self-assess)
`pnpm --filter @vestige/dashboard check` = 0 errors; `build` succeeds; **AND the
CONDUCTOR does a live-GPU audit** (Preview console + getCompilationInfo +
screenshot) — because check/build cannot catch invalid-pipeline runtime errors
or "still looks like slop." "Renders with real data" ≠ "looks category-of-one" —
hold BOTH bars.
---
## 10. BUILD ORDER — 5 days to launch (T-5)
**Day 1 (today) — THE FOUNDATION (blocks everything):**
1. MSDF text engine (§5): atlas + msdf-atlas.ts + text-layer.ts + msdf-text.wgsl
+ layout helper. This is the critical path — nothing all-WebGPU works without
it. De-risk it FIRST with a one-line test route.
2. Rebuild `RouteStage.svelte`: remove ALL DOM panels; host L0 field + L1 organ +
L2 text + L3 interaction + the invisible a11y mirror. One canvas.
3. Interaction engine (§6): cursor field + hover ignition + click shockwave +
pickable-quad "controls", reusing GPU picking.
**Day 2 — CONVERT THE 3 DONE FLAGSHIPS TO ALL-WEBGPU:**
Reasoning, Contradictions, Blackbox already have organ passes — strip their DOM
panels, render all their text/controls via the MSDF + interaction layers. These
become the proof the all-WebGPU pattern works end-to-end. Live-GPU audit each.
**Day 3 — THE OTHER 2 FLAGSHIPS + RSB axon:**
Duplicates + Timeline organs, all-WebGPU. Build the shared RSB retrograde-axon
pass; wire into Reasoning + Blackbox.
**Day 4 — THE SWEEP ORGANS (parallel swarm, all-WebGPU):**
The 10 remaining routes through the same shell: L0 field + MSDF readouts + real
adapter. GPT-5.5 swarm (gateway dispatcher, verifier gate) — cards MUST say "all
WebGPU, zero DOM, spawn subagents, build the files, gate." Conductor live-audits
each.
**Day 5 — "PLANT A THOUGHT" + SHARE + POLISH:**
The public share page (§8), OG-still capture, mediabunny clip export, baked
wordmark, the extended-range HDR "gasp", the mega-PR, final live-audit sweep of
every route (no DOM, no purple, gates green, looks insane). Launch July 14.
---
## 11. HOW WE EXECUTE (Opus conducts, GPT-5.5 builds, Vestige remembers)
- **Opus = conductor/auditor:** recall Vestige FIRST before every non-trivial
move (the failure this session was substituting faded memory for the record);
set precise cards; live-GPU audit every organ; protect Cinema/Graph field;
hold the visual bar; commit only what's verified.
- **GPT-5.5 = builder** on `--yolo`, xhigh, spawning its own `delegate_task`
subagents; for design/visual work give it the `claude-design` skill +
computer-use so it iterates by LOOKING at the real page. Swarm (kanban +
gateway dispatcher) for parallel organs; verifier profile gates on real exit
codes.
- **Vestige = memory:** every decision/correction/finding saved the moment it
lands; recall before diagnosing. The dashboard IS the demo of this — it must
never again be built against a forgotten decision.
- **ONE THING AT A TIME.** Stop the swarm before switching workstreams. No
parallel contradictory builds.
---
## 12. THE ACCEPTANCE TEST (are we done?)
Open any route. Screen-record it on a phone. Does the recording look like a
living brain nobody else has — or like a web dashboard? If any DOM chrome, any
purple, any static panel is visible, it's not done. Every glyph glows, every
click cuts tissue and returns a receipt, every color means a real Vestige-only
fact, and the whole thing is one shareable WebGPU surface. That — and only that —
is the launch.
```
```

126
.council/MSDF-BUILD-SPEC.md Normal file
View file

@ -0,0 +1,126 @@
# MSDF TEXT ENGINE — VERIFIED BUILD SPEC (trap-free, first-try-correct)
Produced by an adversarial verification workflow (5 verifiers read real source + 3 skeptics
attacked the silent-fail risks + WGSL validated under naga 30.0.0 / MSL 2.1). Every fix below
is verified against the real repo, atlas JSON, and the shipped reasoning-theater-pass.ts.
The swarm worker builds against THIS, not raw council files.
Repo: /Users/entity002/vestige · branch feat/dashboard-live-max · cd there, work in the real repo.
---
## THE 6 SILENT-FAIL TRAPS (each passes `pnpm check` and fails at runtime — resolve ALL)
1. **V-FLIP (upside-down glyphs).** atlas `yOrigin:"bottom"`; GPU texture V is top-down.
On the CPU when packing instances: `u0=left/512, u1=right/512, v0=1-top/512, v1=1-bottom/512`.
Verified for 'A' (atlasBounds top=373.5): `v0 = 1 - 373.5/512 = 0.2705`. Omit → every glyph
upside down, passes check silently. GOLDEN TEST: assert the top-most vertex samples
`1 - atlasBounds.top/512` (0.2705 for 'A') so inverted wiring fails LOUD.
Shader mapping that matches this pack order (corner.y=0 is quad BOTTOM in +Y-up NDC):
`let v = mix(uvMax.y, uvMin.y, corner.y);` where uvMin=(u0,v0)=glyph-top, uvMax=(u1,v1)=glyph-bottom.
DO NOT use `out.uv = tex_off + corner*tex_ext` with tex_off=(u0,v0=glyph-top) + positive
extent on a +Y-up quad — that double-negates the flip → upside down. (If you keep the
additive form, set tex_off=(u0,v1=bottom) and tex_ext.y=v0-v1 NEGATIVE.)
2. **ATLAS NEVER LOADS IN THE RELEASE BINARY.** Production embeds apps/dashboard/build via
include_dir! at compile time. The committed build/ tree is STALE (Jun 21, no msdf/). At
runtime GET /dashboard/msdf/jetbrains-mono.png hits the SPA fallback → returns index.html
with HTTP 200 (not 404) → `fetch().json()` throws parsing HTML, texture never creates, the
ENTIRE text layer silently no-ops. FIX: run `pnpm --filter @vestige/dashboard build` BEFORE
any cargo build (adapter-static copies static/msdf/ → build/msdf/). CI already does this
(release.yml, pages.yml). Never test the embedded/release build against the stale committed
build/ without rebuilding the dashboard first. HARDEN (separate small fix): make
serve_dashboard_asset return a real 404 for .png/.json/.woff2 paths instead of the 200 SPA
fallback, so a missing atlas fails loud.
3. **BASE PATH.** Fetch via `import { base } from '$app/paths'``${base}/msdf/jetbrains-mono.{png,json}`.
NOT `import.meta.env.BASE_URL`, NOT hardcoded `/msdf/...`. Default base `/dashboard`
(svelte.config.js VESTIGE_BASE_PATH ?? '/dashboard'; CI → `/vestige`). Codebase standard is
$app/paths base everywhere. Hardcoded 404s in prod.
4. **ATLAS TEXTURE FORMAT = `rgba8unorm`, NOT `rgba8unorm-srgb`.** MTSDF channels are LINEAR
distance values; an sRGB view corrupts the median → fuzzy/blown edges. Upload via
fetch→blob→createImageBitmap→copyExternalImageToTexture (net-new; no existing example in repo).
Usage: TEXTURE_BINDING | COPY_DST.
5. **BLEND = premultiplied over-blend, NOT additive.** Text is INK, not glowing energy —
additive AA fringes go over-bright/crunchy. Use `color/alpha: {srcFactor:'one',
dstFactor:'one-minus-src-alpha', operation:'add'}` and output premultiplied `vec4f(rgb*alpha, alpha)`.
It STILL glows because it's drawn into the pre-bloom rgba16float HDR scene; the glow comes
from the post chain, not the blend mode. (The reference pass uses one/one additive because
its marks ARE energy — text is the exception.)
6. **ASPECT + ASCII + ROUTE NAME.**
- Aspect: NDC is [-1,1] both axes but viewport isn't square. Size glyphs in NDC-Y units,
divide X by `aspect = viewport_w/viewport_h` (params[6]/params[7]) IN THE VERTEX SHADER,
and apply the SAME divide in pickAt or clicks miss.
- ASCII-only atlas (95 glyphs, U+0020U+007E). The demo `·` (U+00B7) and `…` (U+2026) are
NOT present → they silently drop. Use `|` as separator and `...` for truncation. Filter
any input string to 0x200x7E; substitute `?` (0x3F) for out-of-range codepoints.
- `_msdftest` is a PUBLIC route at /dashboard/_msdftest (SvelteKit does NOT hide leading-
underscore dirs — that's Remix/React-Router). Fine as a scratch name; just know it's routable.
CONFIRMED CLEAN (adversarial verdict, not a trap): the WGSL validates under naga 30.0.0 +
MSL 2.1 (the M1 Max Metal path Dawn uses), ZERO errors/warnings. Params struct = 16 f32 = 64
bytes = engine.paramsBuffer.byteLength. Single rgba16float color attachment, no depthStencil/
multisample — matches engine.ts main render pass. `in`/`out` are legal WGSL (only `input`/`output`
are reserved). Bind layout b0 uniform(V|F) / b1 read-only-storage(V) / b2 filtering-sampler(F) /
b3 float-texture(F) is a valid pairing with a filterable rgba8unorm atlas.
---
## DELIVERABLE FILES (5)
### 1. layout.ts — pure (text, opts) → GlyphInstance[]
GlyphInstance = 8 floats, 32-byte stride (matches webgpu-samples Char{texOffset,texExtent,size,offset}):
`{ x,y,w,h (quad in NDC/em from planeBounds), u,v,uw,vh (atlas UV, V ALREADY FLIPPED) }`.
Placement (verified vs real metrics, monospace advance=0.6 for ALL 95 glyphs incl space):
```
penX=0, penY=0 (baseline, +Y up)
per char: '\n' → penX=0, penY-=1.32(lineHeight); else
g=glyphMap[cp] (fallback '?' if missing)
if g.planeBounds: emit {x:penX+pb.left, y:pb.bottom, w:pb.right-pb.left, h:pb.top-pb.bottom, u,v,uw,vh}
penX += 0.6 (ALWAYS, even for space/no-bounds)
```
Wrap/truncate in em units: maxChars=floor(maxWidth_em/0.6). Truncate with `...` (ASCII).
A full line box spans em [-0.30, +1.02] around baseline (descender..ascender) for pick-rect sizing.
### 2. msdf-atlas.ts — loader
fetch `${base}/msdf/jetbrains-mono.json` → glyph Map<unicode, {advance, planeBounds?, atlasBounds?}>
+ atlas{distanceRange:4,size:48,width:512,height:512} + metrics{lineHeight:1.32,ascender:1.02,descender:-0.3}.
fetch `${base}/msdf/jetbrains-mono.png` → blob → createImageBitmap → device.createTexture(rgba8unorm,
TEXTURE_BINDING|COPY_DST) → copyExternalImageToTexture. Sampler: linear/linear, clamp-to-edge.
### 3. text-layer.ts — FramePass (render-only, no compute)
Copy the reasoning-theater-pass.ts pattern: own createBindGroupLayout + createPipelineLayout
(NOT auto — paramsBuffer is engine-shared), ensurePipeline/ensureResources guards. Instance data
= flat Float32Array in a STORAGE|COPY_DST buffer read `var<storage, read>` indexed by
instance_index (NOT vertex attributes — the shipped code has zero vertex buffers). init() is
async (atlas fetch); render() no-ops until pipeline+bindGroup+glyphCount ready. setText(items)
builds the buffer + a runs[] list of NDC AABBs for picking. pickAt(ndcX,ndcY) AABB-tests runs
(apply the aspect divide). draw(6, glyphCount). Target engine.sceneFormat, premultiplied over-blend.
### 4. shaders/msdf-text.wgsl.ts — single render-only module (VERIFIED, validates clean)
struct Params (16 f32, exact engine layout) @group0@binding0 uniform;
struct Glyph {anchor_size:vec4f, quad_offset:vec4f, uv_rect:vec4f, info:vec4f, color:vec4f}
(info.x=age_frame, info.y=reveal_span for glyph-by-glyph typewriter; NO reserved field names —
uses info/beat/data2, avoids meta/active/filter/sample/texture/binding/common/override);
@binding1 var<storage,read> glyphs; @binding2 filtering sampler; @binding3 texture_2d<f32> atlas.
QUAD = 6-vert unit quad [0,1]². vertex: pos = anchor + quad_offset + corner*size, x/=aspect;
uv: u=mix(uvMin.x,uvMax.x,corner.x), v=mix(uvMax.y,uvMin.y,corner.y). fragment:
`fn median(c)=max(min(c.r,c.g),min(max(c.r,c.g),c.b))`; screen-space AA = fwidth(uv)*texDims →
texels/px, *distanceRange(4), px_dist=range*(dist-0.5), cov=clamp(px_dist+0.5,0,1)
[≡ smoothstep(-.5,.5,px_dist)]; reveal=clamp((frame-age_frame)/span,0,1); alpha=cov*base_a*reveal;
discard if <0.001; return vec4f(rgb*alpha, alpha).
### 5. routes/(app)/_msdftest/+page.svelte — test route (zero DOM beyond the canvas)
mounts ObservatoryCanvas demo="recall-path" seed="msdf-test-v1"; onready(engine)→ new TextLayerPass,
await init(), setText("hello | 5de3e41f | trust 51%") [note `|` not `·`], engine.addPass. Pointer
host div (transparent, paints nothing) forwards pointerdown → NDC → pass.pickAt. ObservatoryCanvas
renders exactly ONE <canvas>. (Flag: ObservatoryCanvas still has a DOM .fallback panel — later card.)
## ACCEPTANCE
Test route renders "hello | 5de3e41f | trust 51%" as glowing in-canvas MSDF text, RIGHT-SIDE UP,
crisp at any zoom, materializing glyph-by-glyph, pickable. Gate: `pnpm --filter @vestige/dashboard
check` (0 errors) + `build`. Conductor live-GPU audit: getCompilationInfo clean + screenshot shows
upright crisp glowing text (the V-flip golden test must pass).

View file

@ -1,124 +1,195 @@
# ORGAN BUILD BRIEF — swarm worker cards read this FIRST
# ORGAN BUILD BRIEF v2 — ALL-WEBGPU, ZERO DOM (swarm workers read this FIRST)
You are a GPT-5.5 worker building ONE Cognitive OS dashboard organ. Branch
`feat/dashboard-live-max` at `/Users/entity002/vestige` (cd there; the kanban
scratch workspace is just your home — do the real work in the repo).
You are a GPT-5.5 worker building ONE piece of the Vestige **Cognitive OS** dashboard.
Branch `feat/dashboard-live-max` at `/Users/entity002/vestige` (cd there; the kanban
scratch home is not the repo — do the real work in the repo).
## 🔴 THIS IS A BUILD TASK. YOU MUST WRITE + SAVE REAL FILES. (read twice)
A "design", "plan", "recommendation", "handoff", or "reconnaissance" is a
FAILURE. You are NOT done until three NEW files exist on disk and the gate is
green. If you finish without writing files, you have failed the card.
Source of truth for architecture: `.council/GOD-TIER-ALLWEBGPU-PLAN.md`. Read it.
MANDATORY DEFINITION OF DONE (all must be TRUE, verify each yourself):
1. `apps/dashboard/src/lib/observatory/<organ>/<organ>-scene.ts` EXISTS on disk
(you wrote it — `ls` it to confirm).
2. `apps/dashboard/src/lib/observatory/<organ>/<organ>-pass.ts` EXISTS on disk
(you wrote the FramePass + WGSL — `ls` it to confirm).
3. `apps/dashboard/src/routes/(app)/<organ>/+page.svelte` is MODIFIED to mount
`<RouteStage organ="<organ>" passes={...} scene={...} .../>` full-bleed
(`git diff` it to confirm your edit is there).
4. `pnpm --filter @vestige/dashboard check` prints `0 ERRORS`. You RAN it and
pasted the real output.
5. `pnpm --filter @vestige/dashboard build` succeeded. You RAN it.
In your completion summary you MUST paste: the `ls -la` of your organ dir, the
`git diff --stat` showing your changed files, and the tail of the real check +
build output. NO files + no gate output = the verifier bounces you as RED.
---
## HOW TO BUILD IT (do the work, don't just plan it)
1. FIRST copy the working reference to steal its exact structure:
`cp apps/dashboard/src/lib/observatory/reasoning/reasoning-theater-pass.ts
apps/dashboard/src/lib/observatory/<organ>/<organ>-pass.ts` then rewrite it
for your organ's hero + real data. Same for the scene adapter (copy
reasoning-scene.ts). This guarantees the WebGPU-safe pipeline (all render
passes, no storage textures, split modules, no reserved words, real struct
fields) instead of reinventing it and re-hitting the traps.
2. You MAY use `delegate_task` to PARALLELIZE the WRITING — but each subagent
must WRITE ITS FILE, not return prose. E.g. delegate: "write the full
contents of <organ>-scene.ts to disk and confirm with ls" — not "recommend a
design." If a subagent returns a design instead of a written file, YOU write
the file from its design. The card is not done until the files are on disk.
3. Then YOU integrate, wire the route, and RUN the gate. Paste the output.
The bar is "people lose their minds" AND it renders — but step 0 is the files
must exist. A brilliant design with no files is a RED card. GO WRITE CODE.
## 🚫 THE ONE RULE THAT OVERRIDES EVERYTHING — ZERO DOM. EVERY LINE BREATHES WebGPU.
READ FIRST, in order:
1. `/Users/entity002/vestige/.council/ROUND3-FINAL-PLAN.md` — the full plan +
§6b traps T1-T6 (BINDING).
2. `/Users/entity002/vestige/.council/gpt55-round2-research.md` — the WGSL
recipes (metaball field D1, spline advection D2, retrograde axon D3).
3. WORKING REFERENCE CODE — copy the proven patterns, don't reinvent:
- `apps/dashboard/src/lib/observatory/reasoning/reasoning-theater-pass.ts`
(the metaball field: additive splat -> render-pass blur -> membrane, all
render passes, no storage textures; compute spline advection).
- `apps/dashboard/src/lib/observatory/contradictions/contradictions-pass.ts`
(dual-channel signed field, scarlet seam).
- `apps/dashboard/src/lib/observatory/RouteStage.svelte` (the organ shell).
- `apps/dashboard/src/lib/observatory/route-scene.ts` (RouteSceneModel +
assertProvenance) and `cognitive-palette.ts` (the color language).
The ENTIRE dashboard renders in WebGPU into a **single `<canvas>`**. Text, labels,
memory content, receipt IDs, numbers, buttons, controls, cursor, focus, selection,
loading / empty / error / paused states — **100% drawn by the GPU into that one
canvas.** The ONLY non-WebGPU element allowed on the page is the `<canvas>` itself.
**FORBIDDEN (any one = RED card, no exceptions):**
- ANY `<div>`, `<p>`, `<span>`, `<button>`, `<ul>`, list panel, detail panel, label,
tooltip, overlay, or badge that a user can see OR that is present in the DOM tree.
- An "invisible" / `sr-only` / `aria-live` accessibility mirror. **Sam rejected it
explicitly** ("FUCK TO THE NO"). Do NOT add one to "solve" accessibility.
- A DOM/SVG fallback panel for "WebGPU absent". The absent-WebGPU state is drawn on
the canvas too (a static honest message via the text layer + last frame), never a
DOM node. Never a black screen either.
- `app.css` styling of any visual element. It is effectively empty.
Accessibility is handled canvas-side (pickable quads + GPU focus ring) or DEFERRED —
never by reintroducing DOM. The shareable-canvas thesis wins: a full-screen WebGPU
canvas survives a screen-recording / OS screenshot / phone camera; a DOM dashboard
reads as a generic web app and dies as a shareable. That is the whole point.
**ACCEPTANCE TEST (the verifier + the human conductor both run it):** screen-record the
route. If a single HTML element is visible OR present in the DOM tree beyond `<canvas>`,
the card is WRONG. Text = MSDF glyphs. A "button" = MSDF label + pickable quad +
click-shockwave. Focus/selection = a GPU cyan highlight, not a CSS outline.
---
## 🔴 THIS IS A BUILD TASK — YOU MUST WRITE + SAVE REAL FILES (read twice)
A "design", "plan", "recommendation", "handoff", or "reconnaissance" is a FAILURE.
You are NOT done until real files exist on disk AND the gate is green AND there is
zero DOM. If you finish without writing files, you failed the card.
You MUST paste, in your completion summary:
- `ls -la` of the files you created,
- `git diff --stat` of what you changed,
- the real tail of `pnpm --filter @vestige/dashboard check` (**0 errors**) and `build`,
- a one-line confirmation you did NOT add any DOM element and did NOT touch protected files.
No files + no gate output = the verifier bounces you RED.
---
## 🔥 WORK TO THE ABSOLUTE MAX — SPAWN YOUR SUBAGENTS (non-negotiable)
Do NOT build solo. Use `delegate_task(tasks=[...])` to fan out PARALLEL subagents.
Delegation is configured for **5 concurrent children, spawn depth 2** — use it.
Recommended fan-out per organ card (each subagent WRITES ITS OWN FILE — a subagent that
returns prose instead of a written file is wasted; if that happens, YOU write the file):
- **subagent A** — research the REAL API/event payload for this organ (curl the running
brain on `http://127.0.0.1:3931`, read the Rust handler) and write findings to
`.council/notes/<organ>-data.md`. Confirms the discipline-test signal EXISTS before any WGSL.
- **subagent B** — write `<organ>/<organ>-scene.ts` (adapter: real payload → RouteSceneModel).
- **subagent C** — write `<organ>/<organ>-pass.ts` (the FramePass + WGSL hero).
- **subagent D** — write the MSDF text/label layout for this organ (uses the shared text layer).
- then **YOU** integrate, wire the route `+page.svelte`, run the gate, paste output.
**⚠️ CONTAINER-COLLISION TRAP (verified from Hermes docs):** parallel subagents SHARE
ONE container/filesystem. Concurrent `cd`, env mutations, and writes to the SAME path
COLLIDE. So: give each subagent a DISTINCT target file path, tell each to use absolute
paths (no `cd`), and never have two subagents write the same file. Integration (one
writer) is YOUR job after they return.
The bar is **"people lose their minds"** AND it renders AND it is zero-DOM — not "it compiles".
---
## THE VERIFIED CONTRACTS (read from real source — conform exactly)
**FramePass** (`$lib/observatory/engine.ts`): `interface FramePass { compute?(encoder:
GPUCommandEncoder, frame: number): void; render?(pass: GPURenderPassEncoder, frame:
number): void }`. The engine runs **ALL** passes' `compute(encoder)` first, THEN opens
**ONE** main HDR scene render pass (clears to `VOID_CLEAR_HDR`) and runs **ALL** passes'
`render(pass)`. So: a field/offscreen pass encodes its own offscreen splat+blur render
passes INSIDE `compute(encoder)`; it draws its membrane/quads/text in `render(pass)`.
**Render target format**: draw into `engine.sceneFormat` (= `'rgba16float'`, the offscreen
HDR scene texture — NOT the swapchain). PostChain composites scene→swapchain afterward
(mip bloom → Khronos tonemap → grain → vignette). **Text drawn into the scene gets bloom
for free = it glows.** Render your MSDF text BEFORE post, into the scene texture.
**Params uniform**: `engine.paramsBuffer`, 16 f32, bound `@group(0) @binding(0)`. Struct
layout (COMMON_WGSL): frame, loop_phase, node_count, edge_count, path_count, pulse,
viewport_w, viewport_h, brightness, demo_id, time, capture_mode, live_kind, live_frame,
live_energy, projection_days.
**Pick / interaction**: implement `pickAt(ndcX, ndcY): RoutePick | null` (CPU rect hit-test
against your laid-out quads/glyph boxes). RouteStage converts pointer clientXY → NDC
(y flipped) and calls each pass's `pickAt`. A control is a pickable quad, not a `<button>`.
**RouteSceneModel** (`$lib/observatory/route-scene.ts`): nodes/edges/events/receipts each
carry a `Provenance {kind, id, scalar?}`. `assertProvenance(scene)` (call in `import.meta.env.DEV`)
THROWS if any primitive lacks a real source. `scalars: Record<string, number>` for route
metrics. `emptyScene(organ)``alive:false` honest empty (drawn on canvas, not DOM).
**Copy the proven field pipeline** from `reasoning/reasoning-theater-pass.ts`: additive splat
→ fullscreen blur-H → blur-V → membrane, ALL render passes, rgba16float textures with usage
`RENDER_ATTACHMENT | TEXTURE_BINDING` (NO STORAGE_BINDING). Own bind-group layouts +
pipeline layouts in `ensurePipelines`; recreate field textures on resize in `ensureResources`.
---
## DAY-1 CRITICAL PATH (dependency chain — these are SEQUENTIAL, not parallel organs)
Nothing all-WebGPU renders text until the MSDF engine exists. Build in THIS order; each is
its own card, live-GPU-audited by the conductor before the next starts:
1. **MSDF text engine** (`$lib/observatory/text/`): a checked-in MSDF atlas
(JetBrains Mono, `static/msdf/` PNG + JSON — generate offline, never at runtime) +
`msdf-atlas.ts` (loads atlas texture + glyph metrics) + `text-layer.ts` (a FramePass:
CPU lays out strings → per-glyph instance buffer {glyphRect, atlasRect, worldAnchor,
semanticColorRGBA, ageFrame, confidence}; GPU draws instanced quads, median-of-RGB MSDF
distance → smoothstep AA, tints by semantic color, reveals glyph-by-glyph over ageFrame,
renders into the scene pre-bloom) + `msdf-text.wgsl` (single render-only module; storage
buffers `var<storage, read>`; no reserved-word fields) + a `layout` helper. ACCEPTANCE:
a test route renders `hello · 5de3e41f · trust 51%` as glowing in-canvas text, pickable,
materializing glyph-by-glyph, tinted by semantic color.
2. **Zero-DOM RouteStage rebuild** (`RouteStage.svelte`): strip EVERY DOM node — the
`<slot name="chrome">`, the `<button>` pause control, the telemetry/loading/error/empty
`<div>`s. Result = one `<canvas>` (via ObservatoryCanvas) hosting L0 field + L1 organ +
L2 MSDF text + L3 interaction. Pause control, telemetry, loading/error/empty are all MSDF
text + pickable quads on the canvas. Pointer/keyboard events on the canvas → pickAt.
3. **Interaction engine** (`$lib/observatory/interaction/`): cursor field (pointer = soft
light, chemotaxis flinch), hover ignition (hover a pickable → it + real neighbors ignite,
MSDF label materializes), click = incision + receipt (real actions play the wave AFTER the
API succeeds — no optimistic fake pulses), pickable-quad controls, GPU cyan focus ring.
Only AFTER Day-1 lands do the flagship conversions (Day 2) and the sweep organs (Day 4,
parallel swarm) run — each reusing this text + interaction layer.
---
## HARD RULES (a card is RED if it violates any)
- NEVER touch: `MemoryCinema.svelte`, `src/lib/graph/cinema/*`,
`observatory/types.ts` NodeState (64 bytes), the 8 existing observatory
shaders, the Graph field. REUSE the engine, don't fork it.
- Every visual primitive carries real `Provenance` (memory/event/receipt/pair/
trace/pr/pattern/scalar). Run `assertProvenance(scene)` in dev. NO
Math.random() as a semantic input. The Math.random() discipline test governs.
- Reuse `cognitive-palette.ts`. magenta `#FF2DF7` = RSB retrograde causality
ONLY. indigo `#7C6CFF` = bitemporal ONLY. scarlet/immune reds for
contradiction/veto/suppression. blackwater `#020307` base, never purple.
## WEBGPU TRAPS (all found live in Organ 1/3 — DO NOT repeat)
- T3: field ping-pong textures are `rgba16float` with usage `RENDER_ATTACHMENT
| TEXTURE_BINDING` (NO STORAGE_BINDING). Do separable blur as FULLSCREEN
RENDER PASSES, never `texture_storage_2d<...,write>` + textureStore (not
portable). The whole field pipeline = render passes: splat(additive) ->
blur-H -> blur-V -> membrane. Copy reasoning-theater-pass.ts.
- T6: split WGSL into per-pipeline modules. A render pipeline's module must NOT
declare `var<storage, read_write>` or write storage textures (compute-only).
Render-stage storage buffers are `var<storage, read>` only.
- WGSL reserved words: NEVER name a var OR STRUCT FIELD `active`, `meta`,
`filter`, `common`, `sample`, `override`, `enable`, `const`, `handle`,
`input`, `output`, `texture`, `binding`, `access`, `layout`. (`active` broke
Organ 1, `meta` broke Blackbox — both found live.) The WGSL reserved list is
long — pick descriptive safe names like `beat`/`info`/`data2`.
- STRUCT-FIELD MATCH: every `struct.field` you reference in WGSL must EXIST in
the struct (Organ 3's bug was `p.signals` vs a struct declaring `meta`). TS
build does NOT catch this.
- FramePass sequencing: the engine calls ALL `compute(encoder, frame)` then
opens ONE main HDR scene pass and calls ALL `render(pass, frame)`. So a field
pass encodes its own offscreen splat + blur render passes INSIDE
`compute(encoder)` (it gets a `GPUCommandEncoder`), then draws the membrane in
`render()`. Route field textures need their own `ensure(w,h)` on resize.
- Pipeline-layout scoping (web-verified vs W3C spec): a pipeline's explicit
layout need only cover the bindings the SELECTED entry point statically
reaches — a shared multi-entry-point module is fine. Layout must be a SUPERSET
of the used bindings. Watch texture sampleType `float` vs a filtering sampler
(common creation error).
- One-shot backend events (e.g. DeepReferenceCompleted) become LABELED
deterministic replays, never fake streaming. A stage/element lights only if
it has real output.
- **NEVER touch**: `MemoryCinema.svelte`, `src/lib/graph/cinema/*`, `observatory/types.ts`
NodeState (64 bytes), the 8 existing observatory shaders, the Graph field. REUSE the
engine, don't fork it.
- Every visual primitive carries real `Provenance`. Run `assertProvenance(scene)` in dev.
NO `Math.random()` as a semantic input. The Math.random() discipline test governs — swap
real data for random and the viewer can tell.
- Reuse `cognitive-palette.ts`. blackwater `#020307` base, **never purple, ever**. cyan
`#22C7DE` = the single interactive accent. magenta `#FF2DF7` = RSB retrograde causality
ONLY. indigo `#7C6CFF` = bitemporal ONLY. scarlet reds = immune (contradiction/veto/suppression).
## LIFECYCLE CONTRACT (every organ ships all of it)
loading / ready / stale / empty / error / reduced-motion / paused. WebGPU
absent -> DOM/SVG fallback, NEVER a black canvas. Adapter-null -> honest metric
snapshot. RouteStage already inherits pause + reduced-motion — reuse it.
## WEBGPU TRAPS (all found live — DO NOT repeat)
## PER-CARD DELIVERABLE
1. `<organ>/<organ>-scene.ts` — adapter: real API/event -> RouteSceneModel,
run assertProvenance. Handle the honest empty state (scene.alive=false).
2. `<organ>/<organ>-pass.ts` — the FramePass(es) implementing the hero (see the
organ's spec in ROUND3-FINAL-PLAN §3/§4).
3. Wire `src/routes/(app)/<organ>/+page.svelte`: mount RouteStage full-bleed
with the pass; keep DOM detail/list panels for accessibility ON TOP; wire the
primary click to a real receipt/action.
- **T3 (field textures)**: `rgba16float`, usage `RENDER_ATTACHMENT | TEXTURE_BINDING` (NO
STORAGE_BINDING). Separable blur = FULLSCREEN RENDER PASSES; NEVER `texture_storage_2d<...,
write>` + textureStore (not portable on M-series). Copy reasoning-theater-pass.ts.
- **T6 (module split)**: a render pipeline's WGSL module must NOT declare `var<storage,
read_write>` or write storage textures (compute-only). Render-stage storage = `var<storage,
read>`. Split modules per pipeline.
- **Reserved words** — NEVER a var OR struct field named: `active`, `meta`, `filter`,
`sample`, `texture`, `binding`, `common`, `override`, `enable`, `const`, `handle`, `input`,
`output`, `access`, `layout`. (`active` broke Organ 1, `meta` broke Blackbox.) Use `beat`/
`info`/`data2`.
- **Struct-field match**: every `struct.field` referenced in WGSL must EXIST in the struct.
TS build does NOT catch this.
- **Pipeline-layout scoping**: a pipeline's explicit layout need only cover the SELECTED
entry point's statically-reached bindings; a shared multi-entry module is fine. Watch
texture `sampleType:'float'` vs a filtering sampler (creation error).
- **One-shot events = labeled deterministic replay**, never fake streaming. An element lights
only if it has real output.
- **Capture**: snapshot copies `getCurrentTexture()` in the SAME task (WebGPU ignores
`preserveDrawingBuffer`). Wordmark baked into the post chain, not DOM.
## GATE (do NOT self-assess — RUN these; the verifier will re-run them)
- `pnpm --filter @vestige/dashboard check` -> 0 errors
- `pnpm --filter @vestige/dashboard build` -> success
Both must pass before you mark the card done. Note in your completion metadata:
the files you created, and confirm you did NOT touch protected files.
## LIFECYCLE (every organ, all in-canvas — NO DOM)
## NOTE ON LIVE-GPU BUGS
check+build CANNOT catch invalid-pipeline runtime errors. After building, if you
can, sanity-check your WGSL modules with `getCompilationInfo()`. The human
conductor (Claude) will do the final live-GPU audit against the running app.
loading (organ breathes dim + MSDF "replaying…") / ready / stale / empty (calm blackwater +
honest MSDF line) / error (scarlet pulse + MSDF message) / reduced-motion (freeze ambient
drift, KEEP discrete event pulses) / paused (MSDF pause glyph, pickable). WebGPU absent →
canvas-drawn static message + last frame, never DOM, never black.
## GATE (do NOT self-assess — RUN these; the verifier re-runs them)
- `pnpm --filter @vestige/dashboard check` → **0 errors**
- `pnpm --filter @vestige/dashboard build` → success
- If you can, sanity-check WGSL with `getCompilationInfo()` before marking done.
The human conductor (Claude/Opus) does the final live-GPU audit (Preview console +
getCompilationInfo + screenshot on the running app) — check/build cannot catch invalid-
pipeline runtime errors or "still DOM / still slop." Hold BOTH bars: renders-with-real-data
AND looks-category-of-one AND zero-DOM.

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import atlas from '../../../../static/msdf/jetbrains-mono.json';
import { layoutText, type MsdfAtlasJson } from '../text/layout';
describe('MSDF text layout', () => {
it('packs the atlas V flip for the top of A loudly', () => {
const glyphs = layoutText('A', atlas as MsdfAtlasJson);
expect(glyphs).toHaveLength(1);
// atlasBounds.top for A is 373.5 in the checked-in 512px atlas.
// WebGPU texture V is top-down, so the glyph top must sample 1 - top/512.
expect(glyphs[0].v).toBeCloseTo(1 - 373.5 / 512, 4);
expect(glyphs[0].v).toBeCloseTo(0.2705, 4);
});
it('falls back to ASCII question mark for non-atlas characters', () => {
const [fallback] = layoutText('·', atlas as MsdfAtlasJson);
const [question] = layoutText('?', atlas as MsdfAtlasJson);
expect(fallback).toEqual(question);
});
});

View file

@ -0,0 +1,146 @@
export interface MsdfBounds {
left: number;
bottom: number;
right: number;
top: number;
}
export interface MsdfGlyph {
unicode: number;
advance?: number;
planeBounds?: MsdfBounds;
atlasBounds?: MsdfBounds;
}
export interface MsdfAtlasJson {
atlas: {
width: number;
height: number;
};
metrics?: {
lineHeight?: number;
};
glyphs: MsdfGlyph[];
}
export interface GlyphInstance {
/** Quad origin in em/NDC-y units, baseline-relative, +Y up. */
x: number;
y: number;
/** Quad size in em/NDC-y units. */
w: number;
h: number;
/** Atlas UV origin. v is already flipped from atlas yOrigin: bottom to GPU top-down V. */
u: number;
v: number;
/** Atlas UV extent. vh is positive from flipped glyph top to flipped glyph bottom. */
uw: number;
vh: number;
}
export interface LayoutTextOptions {
/** Maximum line width in em units. Overlong lines are truncated with ASCII dots. */
maxWidthEm?: number;
/** Monospace advance in em units. The checked-in atlas uses 0.6 for every ASCII glyph. */
advance?: number;
/** Baseline-to-baseline distance in em units. Defaults to the atlas metric, then 1.32. */
lineHeight?: number;
}
const ASCII_MIN = 0x20;
const ASCII_MAX = 0x7e;
const FALLBACK_CODEPOINT = 0x3f; // '?'
const DEFAULT_ADVANCE = 0.6;
const DEFAULT_LINE_HEIGHT = 1.32;
const ELLIPSIS = '...';
function glyphMapFor(atlas: MsdfAtlasJson): Map<number, MsdfGlyph> {
return new Map(atlas.glyphs.map((glyph) => [glyph.unicode, glyph]));
}
function asciiFallback(char: string): string {
const codepoint = char.codePointAt(0) ?? FALLBACK_CODEPOINT;
if (codepoint >= ASCII_MIN && codepoint <= ASCII_MAX) {
return char;
}
return '?';
}
function truncateLine(line: string, maxChars: number | undefined): string {
if (maxChars === undefined || maxChars < 0) {
return line;
}
if (maxChars === 0) {
return '';
}
const chars = Array.from(line, asciiFallback);
if (chars.length <= maxChars) {
return chars.join('');
}
if (maxChars <= ELLIPSIS.length) {
return '.'.repeat(maxChars);
}
return `${chars.slice(0, maxChars - ELLIPSIS.length).join('')}${ELLIPSIS}`;
}
function preparedLines(text: string, maxChars: number | undefined): string[] {
return text.split('\n').map((line) => truncateLine(line, maxChars));
}
/**
* Layout ASCII-only MSDF glyph instances for the checked-in JetBrains Mono atlas.
*
* Coordinates are baseline-relative with +Y up. Atlas UVs are packed with the required
* V flip: atlas yOrigin is bottom, while GPU texture V is top-down. Newlines reset penX
* and move the baseline down by lineHeight. Advance is always 0.6em by default, even
* for spaces and glyphs without plane bounds.
*/
export function layoutText(text: string, atlas: MsdfAtlasJson, options: LayoutTextOptions = {}): GlyphInstance[] {
const glyphs = glyphMapFor(atlas);
const fallback = glyphs.get(FALLBACK_CODEPOINT);
const atlasWidth = atlas.atlas.width;
const atlasHeight = atlas.atlas.height;
const advance = options.advance ?? DEFAULT_ADVANCE;
const lineHeight = options.lineHeight ?? atlas.metrics?.lineHeight ?? DEFAULT_LINE_HEIGHT;
const maxChars = options.maxWidthEm === undefined ? undefined : Math.max(0, Math.floor(options.maxWidthEm / advance));
const instances: GlyphInstance[] = [];
let penY = 0;
for (const line of preparedLines(text, maxChars)) {
let penX = 0;
for (const rawChar of Array.from(line)) {
const char = asciiFallback(rawChar);
const codepoint = char.codePointAt(0) ?? FALLBACK_CODEPOINT;
const glyph = glyphs.get(codepoint) ?? fallback;
if (glyph?.planeBounds && glyph.atlasBounds) {
const plane = glyph.planeBounds;
const atlasBounds = glyph.atlasBounds;
const u = atlasBounds.left / atlasWidth;
const v = 1 - atlasBounds.top / atlasHeight;
const uw = (atlasBounds.right - atlasBounds.left) / atlasWidth;
const vh = 1 - atlasBounds.bottom / atlasHeight - v;
instances.push({
x: penX + plane.left,
y: penY + plane.bottom,
w: plane.right - plane.left,
h: plane.top - plane.bottom,
u,
v,
uw,
vh
});
}
penX += advance;
}
penY -= lineHeight;
}
return instances;
}
export const layoutMsdfText = layoutText;

View file

@ -0,0 +1,80 @@
import { base } from '$app/paths';
import type { MsdfGlyph } from './layout';
export type MsdfAtlasMetrics = {
emSize?: number;
lineHeight: number;
ascender?: number;
descender?: number;
underlineY?: number;
underlineThickness?: number;
};
export type LoadedMsdfAtlas = {
atlas: {
type?: string;
distanceRange: number;
distanceRangeMiddle?: number;
size: number;
width: number;
height: number;
yOrigin?: string;
};
metrics: MsdfAtlasMetrics;
glyphs: MsdfGlyph[];
glyphMap: Map<number, MsdfGlyph>;
texture: GPUTexture;
textureView: GPUTextureView;
sampler: GPUSampler;
dispose: () => void;
};
type RawAtlas = Omit<LoadedMsdfAtlas, 'glyphMap' | 'texture' | 'textureView' | 'sampler' | 'dispose'>;
export async function loadMsdfAtlas(device: GPUDevice): Promise<LoadedMsdfAtlas> {
const jsonUrl = `${base}/msdf/jetbrains-mono.json`;
const pngUrl = `${base}/msdf/jetbrains-mono.png`;
const jsonResponse = await fetch(jsonUrl);
if (!jsonResponse.ok) throw new Error(`MSDF atlas JSON failed: ${jsonResponse.status} ${jsonUrl}`);
const raw = (await jsonResponse.json()) as RawAtlas;
if (raw.atlas?.yOrigin !== 'bottom') {
throw new Error(`MSDF atlas yOrigin must be bottom, got ${raw.atlas?.yOrigin ?? 'missing'}`);
}
const pngResponse = await fetch(pngUrl);
if (!pngResponse.ok) throw new Error(`MSDF atlas PNG failed: ${pngResponse.status} ${pngUrl}`);
const blob = await pngResponse.blob();
const bitmap = await createImageBitmap(blob);
const texture = device.createTexture({
label: 'msdf-jetbrains-mono-rgba8unorm',
size: [bitmap.width, bitmap.height, 1],
format: 'rgba8unorm',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
});
device.queue.copyExternalImageToTexture(
{ source: bitmap },
{ texture },
{ width: bitmap.width, height: bitmap.height }
);
bitmap.close?.();
const sampler = device.createSampler({
label: 'msdf-jetbrains-mono-linear-sampler',
magFilter: 'linear',
minFilter: 'linear',
mipmapFilter: 'linear',
addressModeU: 'clamp-to-edge',
addressModeV: 'clamp-to-edge'
});
const textureView = texture.createView({ label: 'msdf-jetbrains-mono-view' });
const glyphMap = new Map(raw.glyphs.map((glyph) => [glyph.unicode, glyph]));
return {
...raw,
glyphMap,
texture,
textureView,
sampler,
dispose: () => texture.destroy()
};
}

View file

@ -0,0 +1,91 @@
export const MSDF_TEXT_WGSL = /* wgsl */ `
struct Params {
frame: f32,
loop_phase: f32,
node_count: f32,
edge_count: f32,
path_count: f32,
pulse: f32,
viewport_w: f32,
viewport_h: f32,
brightness: f32,
demo_id: f32,
time: f32,
capture_mode: f32,
live_kind: f32,
live_frame: f32,
live_energy: f32,
projection_days: f32,
};
struct Glyph {
anchor_size: vec4f,
quad_offset: vec4f,
uv_rect: vec4f,
info: vec4f,
color: vec4f,
};
struct VSOut {
@builtin(position) clip: vec4f,
@location(0) uv: vec2f,
@location(1) @interpolate(flat) info: vec4f,
@location(2) @interpolate(flat) color: vec4f,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read> glyphs: array<Glyph>;
@group(0) @binding(2) var atlas_sampler: sampler;
@group(0) @binding(3) var atlas_tex: texture_2d<f32>;
const QUAD = array<vec2f, 6>(
vec2f(0.0, 0.0), vec2f(1.0, 0.0), vec2f(1.0, 1.0),
vec2f(0.0, 0.0), vec2f(1.0, 1.0), vec2f(0.0, 1.0)
);
fn median3(c: vec3f) -> f32 {
return max(min(c.r, c.g), min(max(c.r, c.g), c.b));
}
@vertex
fn vs_text(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
let glyph = glyphs[ii];
let corner = QUAD[vi];
let anchor = glyph.anchor_size.xy;
let size = glyph.anchor_size.zw;
let quad_offset = glyph.quad_offset.xy;
let uv_min = glyph.uv_rect.xy;
let uv_max = glyph.uv_rect.zw;
let aspect = max(0.0001, params.viewport_w / max(1.0, params.viewport_h));
var pos = anchor + quad_offset + corner * size;
// Keep glyphs square in BOTH orientations: normalize by the longer axis.
// Landscape (aspect>1): narrow x. Portrait (aspect<1): shrink y instead —
// dividing x by aspect<1 would WIDEN x and push text off-screen.
pos.x = pos.x / max(aspect, 1.0);
pos.y = pos.y * min(aspect, 1.0);
var out: VSOut;
out.clip = vec4f(pos, 0.0, 1.0);
out.uv = vec2f(mix(uv_min.x, uv_max.x, corner.x), mix(uv_max.y, uv_min.y, corner.y));
out.info = glyph.info;
out.color = glyph.color;
return out;
}
@fragment
fn fs_text(in: VSOut) -> @location(0) vec4f {
let atlas_px = vec2f(textureDimensions(atlas_tex, 0));
let msdf = textureSample(atlas_tex, atlas_sampler, in.uv).rgb;
let dist = median3(msdf);
let uv_width = max(fwidth(in.uv), vec2f(1.0 / max(atlas_px.x, 1.0), 1.0 / max(atlas_px.y, 1.0)));
let texels_per_px = max(length(uv_width * atlas_px), 0.0001);
let screen_range = max(0.5, 4.0 / texels_per_px);
let px_dist = screen_range * (dist - 0.5);
let coverage = clamp(px_dist + 0.5, 0.0, 1.0);
let reveal_span = max(1.0, in.info.y);
let reveal = clamp((params.frame - in.info.x) / reveal_span, 0.0, 1.0);
let alpha = coverage * in.color.a * reveal;
if (alpha < 0.001) { discard; }
let rgb = in.color.rgb * params.brightness;
return vec4f(rgb * alpha, alpha);
}
`;

View file

@ -0,0 +1,243 @@
import type { ObservatoryEngine, FramePass } from '$lib/observatory/engine';
import { rgb01 } from '$lib/observatory/cognitive-palette';
import { layoutText, type GlyphInstance } from './layout';
import { loadMsdfAtlas, type LoadedMsdfAtlas } from './msdf-atlas';
import { MSDF_TEXT_WGSL } from './shaders/msdf-text.wgsl';
type RoutePick = { id: string; kind: string; index?: number; payload?: unknown };
export type TextLayerItem = {
id?: string;
kind?: string;
text: string;
/** Logical NDC anchor, before shader's aspect divide. y is baseline, +Y up. */
x: number;
y: number;
/** NDC-Y units per em. */
size?: number;
color?: [number, number, number, number];
startFrame?: number;
revealSpan?: number;
maxWidthEm?: number;
maxLines?: number;
};
type TextRunRect = {
id: string;
kind: string;
text: string;
x0: number;
x1: number;
y0: number;
y1: number;
payload: TextLayerItem;
};
const GLYPH_FLOATS = 20;
const DEFAULT_COLOR: [number, number, number, number] = [...rgb01('#22C7DE'), 1];
export class TextLayerPass implements FramePass {
private engine: ObservatoryEngine;
private atlas: LoadedMsdfAtlas | null = null;
private bindLayout: GPUBindGroupLayout | null = null;
private pipeline: GPURenderPipeline | null = null;
private glyphBuffer: GPUBuffer | null = null;
private bindGroup: GPUBindGroup | null = null;
private glyphCapacity = 0;
private glyphCount = 0;
private pendingItems: TextLayerItem[] = [];
private runs: TextRunRect[] = [];
private initPromise: Promise<void> | null = null;
constructor(engine: ObservatoryEngine) {
this.engine = engine;
}
async init(): Promise<void> {
if (this.initPromise) return this.initPromise;
this.initPromise = this.initInner();
return this.initPromise;
}
private async initInner(): Promise<void> {
const device = this.engine.gpuDevice;
if (!device || !this.engine.paramsBuffer) return;
this.atlas = await loadMsdfAtlas(device);
this.ensurePipeline(device);
if (this.pendingItems.length) this.uploadItems(this.pendingItems);
}
setText(items: string | TextLayerItem | TextLayerItem[]): void {
const normalized = typeof items === 'string' ? [{ text: items, x: -0.62, y: 0, size: 0.075 }] : Array.isArray(items) ? items : [items];
this.pendingItems = normalized;
this.uploadItems(normalized);
}
private ensurePipeline(device: GPUDevice): void {
if (this.pipeline || !this.engine.paramsBuffer) return;
const module = device.createShaderModule({ label: 'msdf-text-wgsl', code: MSDF_TEXT_WGSL });
this.bindLayout = device.createBindGroupLayout({
label: 'msdf-text-bind-layout',
entries: [
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
{ binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }
]
});
const layout = device.createPipelineLayout({ label: 'msdf-text-pipeline-layout', bindGroupLayouts: [this.bindLayout] });
const overBlend: GPUBlendState = {
color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' }
};
this.pipeline = device.createRenderPipeline({
label: 'msdf-text-pipeline',
layout,
vertex: { module, entryPoint: 'vs_text' },
fragment: { module, entryPoint: 'fs_text', targets: [{ format: this.engine.sceneFormat, blend: overBlend }] },
primitive: { topology: 'triangle-list' }
});
}
private uploadItems(items: TextLayerItem[]): void {
const device = this.engine.gpuDevice;
if (!device || !this.engine.paramsBuffer || !this.atlas) return;
this.ensurePipeline(device);
const packed: number[] = [];
const runs: TextRunRect[] = [];
let glyphIndex = 0;
items.forEach((item, itemIndex) => {
const size = item.size ?? 0.075;
const laid = layoutText(item.text, this.atlas!, {
maxWidthEm: item.maxWidthEm
});
const color = item.color ?? DEFAULT_COLOR;
const runGlyphs = laid;
let x0 = Number.POSITIVE_INFINITY;
let x1 = Number.NEGATIVE_INFINITY;
let y0 = Number.POSITIVE_INFINITY;
let y1 = Number.NEGATIVE_INFINITY;
for (const glyph of runGlyphs) {
const gx0 = item.x + glyph.x * size;
const gx1 = item.x + (glyph.x + glyph.w) * size;
const gy0 = item.y + glyph.y * size;
const gy1 = item.y + (glyph.y + glyph.h) * size;
x0 = Math.min(x0, gx0);
x1 = Math.max(x1, gx1);
y0 = Math.min(y0, gy0);
y1 = Math.max(y1, gy1);
packGlyph(packed, item, glyph, color, size, glyphIndex++);
}
if (runGlyphs.length > 0) {
runs.push({
id: item.id ?? `msdf-text:${itemIndex}`,
kind: item.kind ?? 'text',
text: item.text,
x0,
x1,
y0,
y1,
payload: item
});
}
});
this.runs = runs;
this.glyphCount = packed.length / GLYPH_FLOATS;
const floats = new Float32Array(packed.length || GLYPH_FLOATS);
floats.set(packed);
this.ensureGlyphBuffer(device, Math.max(1, this.glyphCount));
if (!this.glyphBuffer || !this.bindLayout) return;
device.queue.writeBuffer(this.glyphBuffer, 0, floats);
this.bindGroup = device.createBindGroup({
label: 'msdf-text-bind-group',
layout: this.bindLayout,
entries: [
{ binding: 0, resource: { buffer: this.engine.paramsBuffer } },
{ binding: 1, resource: { buffer: this.glyphBuffer } },
{ binding: 2, resource: this.atlas.sampler },
{ binding: 3, resource: this.atlas.textureView }
]
});
}
private ensureGlyphBuffer(device: GPUDevice, glyphCount: number): void {
if (this.glyphBuffer && this.glyphCapacity >= glyphCount) return;
this.glyphBuffer?.destroy();
this.glyphCapacity = Math.max(glyphCount, Math.ceil(this.glyphCapacity * 1.5), 32);
this.glyphBuffer = device.createBuffer({
label: 'msdf-text-glyphs',
size: this.glyphCapacity * GLYPH_FLOATS * 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
});
}
render(pass: GPURenderPassEncoder): void {
if (!this.pipeline || !this.bindGroup || this.glyphCount <= 0) return;
pass.setPipeline(this.pipeline);
pass.setBindGroup(0, this.bindGroup);
pass.draw(6, this.glyphCount);
}
pickAt(ndcX: number, ndcY: number): RoutePick | null {
const aspect = Math.max(0.0001, (this.engine.params[6] || 1) / Math.max(1, this.engine.params[7] || 1));
// Mirror the shader's square-in-both-orientations transform (msdf-text.wgsl):
// x /= max(aspect,1); y *= min(aspect,1).
const xScale = Math.max(aspect, 1);
const yScale = Math.min(aspect, 1);
for (const run of this.runs) {
const x0 = run.x0 / xScale;
const x1 = run.x1 / xScale;
const y0 = run.y0 * yScale;
const y1 = run.y1 * yScale;
if (ndcX >= x0 && ndcX <= x1 && ndcY >= y0 && ndcY <= y1) {
return { id: run.id, kind: run.kind, payload: run.payload };
}
}
return null;
}
dispose(): void {
this.glyphBuffer?.destroy();
this.glyphBuffer = null;
this.atlas?.dispose();
this.atlas = null;
this.bindGroup = null;
this.pipeline = null;
}
}
function packGlyph(
out: number[],
item: TextLayerItem,
glyph: GlyphInstance,
color: [number, number, number, number],
size: number,
glyphIndex: number
): void {
const ageFrame = (item.startFrame ?? 0) + glyphIndex * 2;
const revealSpan = item.revealSpan ?? 18;
out.push(
item.x,
item.y,
glyph.w * size,
glyph.h * size,
glyph.x * size,
glyph.y * size,
0,
0,
glyph.u,
glyph.v,
glyph.u + glyph.uw,
glyph.v + glyph.vh,
ageFrame,
revealSpan,
0,
0,
color[0],
color[1],
color[2],
color[3]
);
}

View file

@ -0,0 +1,50 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import ObservatoryCanvas from '$lib/components/ObservatoryCanvas.svelte';
import type { ObservatoryEngine } from '$lib/observatory/engine';
import { TextLayerPass } from '$lib/observatory/text/text-layer';
let hostEl: HTMLDivElement | null = $state(null);
let textPass: TextLayerPass | null = null;
async function handleReady(engine: ObservatoryEngine) {
const pass = new TextLayerPass(engine);
textPass = pass;
await pass.init();
pass.setText({
id: 'msdf-test-line',
kind: 'msdf-test',
text: 'hello | 5de3e41f | trust 51%',
x: -0.62,
y: 0.03,
size: 0.105,
color: [0.14, 0.78, 0.87, 1],
startFrame: 0,
revealSpan: 20,
maxWidthEm: 18
});
engine.addPass(pass);
engine.demoClock.reset();
}
function handlePointerDown(e: PointerEvent) {
if (!hostEl || !textPass) return;
const rect = hostEl.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const ndcY = -(((e.clientY - rect.top) / rect.height) * 2 - 1);
const hit = textPass.pickAt(ndcX, ndcY);
if (hit) console.info('[msdf-test] picked', hit.id);
}
onDestroy(() => {
textPass?.dispose();
textPass = null;
});
</script>
<!-- Transparent pointer host only; ObservatoryCanvas owns the single visible canvas. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div bind:this={hostEl} class="fixed inset-0 bg-[#020307]" onpointerdown={handlePointerDown}>
<ObservatoryCanvas demo="recall-path" seed="msdf-test-v1" onready={handleReady} />
</div>

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB