From 65595b9ffcfff2cbc775bb0257a24e9fa0bbc499 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 13 Jul 2026 10:32:22 +0700 Subject: [PATCH] checkpoint(dashboard): mobile reflow + field richness + fake-data fixes + predict FSRS urgency Verified-green pre-OS-shell checkpoint. Mobile portrait reflow (portraitAdapt + MobileNav), desktop field-richness on 7 text organs, 4 fake-data fixes (settings version, TRIGGER BIRTH removed, predict real per-memory urgency, palace DIAG), timeline dim-backdrop, stats depth-floor, em-dash cleanup, per-tier launch-gate fill floors. check 0/0, 977 files. Cinema WIP isolated to stash. Co-Authored-By: Claude Opus 4.8 --- apps/dashboard/e2e/palace-launch-gate.spec.ts | 55 ++++- .../src/lib/components/MobileNav.svelte | 207 ++++++++++++++++ .../lib/components/ObservatoryCanvas.svelte | 39 ++- .../src/lib/observatory/RouteStage.svelte | 86 ++++--- .../observatory/duplicates/duplicates-pass.ts | 171 ++++++++++++- .../observatory/field/living-field-pass.ts | 149 +++++++++++- .../observatory/field/living-field.wgsl.ts | 85 +++++-- .../src/lib/observatory/nav/nav-layer.ts | 33 +++ .../src/lib/observatory/palace-labels.ts | 31 ++- .../src/lib/observatory/palace-node-pass.ts | 12 +- .../src/lib/observatory/text/text-layer.ts | 176 +++++++++++++- .../lib/observatory/timeline/timeline-pass.ts | 26 +- .../dashboard/src/routes/(app)/+layout.svelte | 2 + .../src/routes/(app)/activation/+page.svelte | 118 +++++++-- .../src/routes/(app)/blackbox/+page.svelte | 69 +++++- .../routes/(app)/contradictions/+page.svelte | 42 +++- .../src/routes/(app)/dreams/+page.svelte | 194 ++++++++++++++- .../src/routes/(app)/duplicates/+page.svelte | 145 +++++++---- .../src/routes/(app)/explore/+page.svelte | 109 ++++++++- .../src/routes/(app)/feed/+page.svelte | 206 ++++++++++++++-- .../src/routes/(app)/graph/+page.svelte | 56 ++++- .../src/routes/(app)/importance/+page.svelte | 92 ++++++- .../src/routes/(app)/intentions/+page.svelte | 202 +++++++++++++++- .../src/routes/(app)/memories/+page.svelte | 99 +++++++- .../src/routes/(app)/memory-prs/+page.svelte | 228 +++++++++++++++++- .../src/routes/(app)/observatory/+page.svelte | 146 +++++++++++ .../src/routes/(app)/palace/+page.svelte | 18 +- .../src/routes/(app)/patterns/+page.svelte | 101 +++++++- .../src/routes/(app)/reasoning/+page.svelte | 192 ++++++++++++++- .../src/routes/(app)/schedule/+page.svelte | 152 +++++++++++- .../src/routes/(app)/settings/+page.svelte | 205 +++++++++++++--- .../src/routes/(app)/stats/+page.svelte | 18 +- .../src/routes/(app)/timeline/+page.svelte | 104 ++++++-- crates/vestige-mcp/src/dashboard/handlers.rs | 35 ++- 34 files changed, 3312 insertions(+), 291 deletions(-) create mode 100644 apps/dashboard/src/lib/components/MobileNav.svelte diff --git a/apps/dashboard/e2e/palace-launch-gate.spec.ts b/apps/dashboard/e2e/palace-launch-gate.spec.ts index 29ae97d..5be6077 100644 --- a/apps/dashboard/e2e/palace-launch-gate.spec.ts +++ b/apps/dashboard/e2e/palace-launch-gate.spec.ts @@ -65,12 +65,47 @@ const SETTLE_MS = 3200; // than a static field, so it gets an extended settle before we sample. const SLOW_ORGANS: Record = { reasoning: 12_000 }; -// The liveness bar: the mission requires every organ to be a full-bleed MOVING -// field (fill >= 35% at full res). The gate samples a 96x96 decode of a real -// compositor screenshot; a genuinely-filled field (60-89% at full res) reads -// >= 35% here, while a sparse text-on-black organ reads < 18%. 30% cleanly -// separates alive from sparse with headroom for settle/downsample variance. -const MIN_FILL_PCT = 30; +// The liveness bar. TWO tiers, matching the SHIPPED design direction (Sam's +// Jul 2026 correction: "the field is COMPLETELY OVERPOWERING THE TEXT" — the +// field must be a DIM, legible BACKDROP on text-heavy organs, and the hero +// only on the visual organs). +// +// VISUAL organs (palace, graph, observatory, memories, activation, explore, +// timeline, blackbox): the field IS the hero — it fills the screen. High bar. +// +// TEXT-HEAVY organs (stats, settings, reasoning, feed, schedule, importance, +// contradictions, patterns, intentions, dreams, memory-prs, duplicates): the +// field is a DIM breathing backdrop behind a reading well so the MSDF text +// wins the contrast fight (each carries setIntensity ~0.18-0.26 + a reading +// well). fill% legitimately reads LOW here BY DESIGN — a high fill would mean +// the blinding-blob bug is back. The bar is still "lit + structured + moving, +// not black", just a lower fill floor. +// +// The gate samples a 96x96 decode of a real compositor screenshot. +const MIN_FILL_PCT_VISUAL = 30; // field is the hero → must be full-bleed +const MIN_FILL_PCT_TEXT = 6; // dim backdrop by design → lit but not blob + +// Organs where the field is the HERO (full-bleed expected). Everything else is +// a text-heavy instrument with a dim backdrop. +const VISUAL_ORGANS = new Set([ + 'palace', + 'graph', + 'observatory', + 'memories', + 'activation', + 'explore', + 'timeline', + 'blackbox' +]); + +function minFillFor(label: string): number { + // label is like "/stats" or "dived organ .../stats" or "palace hub" — match + // the organ name loosely so both smoke and tour callers resolve correctly. + for (const organ of VISUAL_ORGANS) { + if (label.includes(organ)) return MIN_FILL_PCT_VISUAL; + } + return MIN_FILL_PCT_TEXT; +} /** Assert a genuinely-lit, MOVING, error-free field on the current route. */ async function assertLiveField( @@ -85,9 +120,11 @@ async function assertLiveField( const s = await sampleCanvas(page); expect(s.avgLum, `${label} avgLum (field must not be black)`).toBeGreaterThan(3); expect(s.variance, `${label} variance (field must have structure)`).toBeGreaterThan(6); - expect(s.fillPct, `${label} fill% (field must be full-bleed, not text-on-black)`).toBeGreaterThan( - MIN_FILL_PCT - ); + const minFill = minFillFor(label); + expect( + s.fillPct, + `${label} fill% (field must be lit + structured; visual organs full-bleed, text organs a dim backdrop)` + ).toBeGreaterThan(minFill); return { avgLum: s.avgLum, variance: s.variance, fillPct: s.fillPct }; } diff --git a/apps/dashboard/src/lib/components/MobileNav.svelte b/apps/dashboard/src/lib/components/MobileNav.svelte new file mode 100644 index 0000000..a497bdc --- /dev/null +++ b/apps/dashboard/src/lib/components/MobileNav.svelte @@ -0,0 +1,207 @@ + + +{#if show} + + +{/if} + + diff --git a/apps/dashboard/src/lib/components/ObservatoryCanvas.svelte b/apps/dashboard/src/lib/components/ObservatoryCanvas.svelte index 9eb8754..bbcff12 100644 --- a/apps/dashboard/src/lib/components/ObservatoryCanvas.svelte +++ b/apps/dashboard/src/lib/components/ObservatoryCanvas.svelte @@ -7,6 +7,7 @@ * (Increment 3 gate, spec §4). */ import { onMount, onDestroy } from 'svelte'; + import { base } from '$app/paths'; import { ObservatoryEngine, type EngineStatus } from '$lib/observatory/engine'; import type { DemoMode } from '$lib/observatory/types'; @@ -65,13 +66,19 @@ > {#if status.state === 'unsupported' || status.state === 'error'} - + {/if} @@ -116,16 +123,26 @@ opacity: 0.85; } + .fallback-cta { + margin-top: 0.35rem; + padding: 0.7rem 1.2rem; + border-radius: 999px; + border: 1px solid rgba(0, 245, 212, 0.45); + background: rgba(0, 245, 212, 0.1); + color: #7fe6c0; + font-size: 0.82rem; + letter-spacing: 0.14em; + text-decoration: none; + text-shadow: 0 0 18px rgba(0, 245, 212, 0.35); + } + .fallback-cta:active { + background: rgba(0, 245, 212, 0.2); + } + .fallback-hint { color: #7c8a97; font-size: 0.75rem; max-width: 34rem; line-height: 1.6; } - - .fallback-hint a { - color: #cfe9ff; - text-decoration: underline; - text-underline-offset: 3px; - } diff --git a/apps/dashboard/src/lib/observatory/RouteStage.svelte b/apps/dashboard/src/lib/observatory/RouteStage.svelte index 494e218..256c82f 100644 --- a/apps/dashboard/src/lib/observatory/RouteStage.svelte +++ b/apps/dashboard/src/lib/observatory/RouteStage.svelte @@ -171,48 +171,72 @@ .replace(/[^\x20-\x7E]/g, '?'); } + // Portrait/phone check — same live-aspect signal the text + field layers use + // (engine.params[6]/[7], window fallback). On a phone the in-canvas HUD chrome + // (dev telemetry + the floating PAUSE) is SUPPRESSED: it has fixed landscape + // NDC anchors that overprint the reflowed content, the telemetry is debug-only + // noise a real user shouldn't see, and the DOM MobileNav already owns the + // bottom-thumb zone. Desktop keeps the full chrome unchanged. + function isPortrait(): boolean { + let vw = engine?.params[6] || 0; + let vh = engine?.params[7] || 0; + if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') { + vw = window.innerWidth; + vh = window.innerHeight; + } + if (vw <= 0 || vh <= 0) return false; + return vw / vh < 0.85; + } + function makeChromeItems(frame = frameCount, fps = fpsEstimate): TextLayerItem[] { - const items: TextLayerItem[] = [ - { - id: 'route-chrome:pause', - kind: 'route-chrome', - text: paused ? '> RESUME' : '|| PAUSE', - x: 0.66, - y: -0.86, - size: 0.034, - color: paused ? AMBER : CYAN, - revealSpan: 1 - }, - { - id: 'route-chrome:telemetry', - kind: 'route-telemetry', - text: `${organ.toUpperCase()} - ${frame}F - ${fps}FPS`, - x: 0.44, - y: 0.88, - size: 0.022, - color: DIM_GREEN, - revealSpan: 1 - } - ]; + const portrait = isPortrait(); + // Desktop: floating PAUSE + dev telemetry. Phone: neither (see isPortrait). + const items: TextLayerItem[] = portrait + ? [] + : [ + { + id: 'route-chrome:pause', + kind: 'route-chrome', + text: paused ? '> RESUME' : '|| PAUSE', + x: 0.66, + y: -0.86, + size: 0.034, + color: paused ? AMBER : CYAN, + revealSpan: 1 + }, + { + id: 'route-chrome:telemetry', + kind: 'route-telemetry', + text: `${organ.toUpperCase()} - ${frame}F - ${fps}FPS`, + x: 0.44, + y: 0.88, + size: 0.022, + color: DIM_GREEN, + revealSpan: 1 + } + ]; if (loading) { items.push({ id: 'route-chrome:loading', kind: 'route-status', text: 'REPLAYING COGNITIVE RECEIPT...', - x: -0.23, + x: portrait ? -0.82 : -0.23, y: 0.02, - size: 0.046, + size: portrait ? 0.03 : 0.046, color: OXYGEN, startFrame: Math.max(0, frame - 90), - revealSpan: 72 + revealSpan: 72, + maxWidthEm: portrait ? 26 : undefined }); } else if (error) { items.push( { id: 'route-chrome:error-pulse', kind: 'route-status-pulse', - text: '!!!!!!!!!!!!!!!!!!!!!!!!', + // The '!!!' pulse bar is a landscape flourish; drop it on a phone + // (it overprints the error text once the field reflows narrow). + text: portrait ? '' : '!!!!!!!!!!!!!!!!!!!!!!!!', x: -0.36, y: -0.035, size: 0.025, @@ -223,12 +247,12 @@ id: 'route-chrome:error', kind: 'route-status', text: asciiSafe(`ERROR - ${error}`).slice(0, 72), - x: -0.54, + x: portrait ? -0.82 : -0.54, y: 0.025, - size: 0.032, + size: portrait ? 0.028 : 0.032, color: SCARLET, revealSpan: 14, - maxWidthEm: 48 + maxWidthEm: portrait ? 24 : 48 } ); } else if (!currentScene.alive) { @@ -236,9 +260,9 @@ id: 'route-chrome:empty', kind: 'route-status', text: asciiSafe(emptyLabel), - x: -0.36, + x: portrait ? -0.82 : -0.36, y: 0.02, - size: 0.034, + size: portrait ? 0.03 : 0.034, color: DIM_GREEN, revealSpan: 24, maxWidthEm: 48 diff --git a/apps/dashboard/src/lib/observatory/duplicates/duplicates-pass.ts b/apps/dashboard/src/lib/observatory/duplicates/duplicates-pass.ts index a5dc0df..9429c93 100644 --- a/apps/dashboard/src/lib/observatory/duplicates/duplicates-pass.ts +++ b/apps/dashboard/src/lib/observatory/duplicates/duplicates-pass.ts @@ -57,9 +57,18 @@ struct FusionNeck { const SPLAT_WGSL = /* wgsl */ ` ${COMMON_WGSL} +// FieldOpts mirrors the membrane's: x=intensity, yz=well center NDC, w=well +// half-w; then well half-h, floor, soft, pad. Cells/necks dim by the same amount +// so nothing blows out under the centered text overlay. +struct FieldOpts { + intensity_wx_wy_hw: vec4f, + hh_floor_soft_pad: vec4f, +}; + @group(0) @binding(0) var params: Params; @group(0) @binding(1) var cells: array; @group(0) @binding(2) var necks: array; +@group(0) @binding(5) var opts: FieldOpts; const QUAD = array( vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0), @@ -70,12 +79,30 @@ struct VSOut { @builtin(position) clip: vec4f, @location(0) uv: vec2f, @location(1) @interpolate(flat) misc: vec4f, + @location(2) @interpolate(flat) home: vec2f, }; fn similarity_neck(similarity: f32) -> f32 { return smoothstep(0.78, 0.98, similarity); } +// Reading-well multiplier at an NDC point (1.0 outside, →floor inside). hw<=0 off. +fn field_dim(ndc: vec2f) -> f32 { + let intensity = clamp(opts.intensity_wx_wy_hw.x, 0.0, 1.0); + let hw = opts.intensity_wx_wy_hw.w; + if (hw <= 0.0) { return intensity; } + let center = opts.intensity_wx_wy_hw.yz; + let hh = opts.hh_floor_soft_pad.x; + let floor_v = opts.hh_floor_soft_pad.y; + let soft = max(0.02, opts.hh_floor_soft_pad.z); + let d = abs(ndc - center) - vec2f(hw, hh); + let outside = length(max(d, vec2f(0.0))); + let inside = min(max(d.x, d.y), 0.0); + let sd = outside + inside; + let t = smoothstep(-soft, 0.0, sd); + return intensity * mix(floor_v, 1.0, t); +} + @vertex fn vs_splat(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut { var out: VSOut; @@ -88,6 +115,7 @@ fn vs_splat(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> out.clip = vec4f(c.pos_retention.xy + corner * radius, 0.0, 1.0); out.uv = corner; out.misc = vec4f(c.pos_retention.z, c.cluster_meta.x, c.visual_meta.x, merge_gate); + out.home = c.pos_retention.xy; } else { let n = necks[ii - cell_count]; let a = n.a.xy; @@ -102,6 +130,7 @@ fn vs_splat(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> out.clip = vec4f(pos, 0.0, 1.0); out.uv = vec2f(corner.x, corner.y / max(0.001, thickness)); out.misc = vec4f(n.signals.x, fused, n.signals.z, n.signals.w); + out.home = center; } return out; } @@ -119,7 +148,9 @@ fn fs_splat(frag: VSOut) -> @location(0) vec4f { let cell_rim = smoothstep(0.24, 0.02, abs(d - (0.58 + retention * 0.16))) * (0.2 + similarity * 0.55); let neck_body = exp(-frag.uv.y * frag.uv.y * 4.0) * smoothstep(1.05, 0.82, abs(frag.uv.x)) * (0.35 + similarity * 0.9); let density = max(cell_body + cell_rim, neck_body * (0.4 + similarity)); - // r=density, g=retention/luciferin, b=mismatch amber, a reserved. No storage textures. + // The splat writes the density FIELD (blurred into the membrane). It must NOT + // be dimmed here — the membrane fragment applies intensity + reading well once, + // so dimming both would double-darken. r=density, g=retention, b=mismatch amber. return vec4f(density, density * (0.35 + retention * 0.65), mismatch * (0.18 + merge_gate * 0.12), 1.0); } @@ -133,6 +164,7 @@ fn vs_cell(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> out.clip = vec4f(c.pos_retention.xy + corner * radius, 0.0, 1.0); out.uv = corner; out.misc = vec4f(c.pos_retention.z, c.cluster_meta.x, c.visual_meta.x, winner); + out.home = c.pos_retention.xy; return out; } @@ -153,7 +185,10 @@ fn fs_cell(frag: VSOut) -> @location(0) vec4f { let rim = smoothstep(0.98, 0.72, d) * (1.0 - smoothstep(0.72, 0.22, d)); let body = exp(-d*d*3.2) * (0.20 + retention * 0.44 + winner * 0.16); let mismatch_ring = smoothstep(0.16, 0.0, abs(d - 0.80)) * mismatch; - return vec4f(core * body + ivory * rim * (0.16 + similarity * 0.52) + amber * mismatch_ring * 0.34, 1.0); + let color = core * body + ivory * rim * (0.16 + similarity * 0.52) + amber * mismatch_ring * 0.34; + // Sharp cells draw on TOP of the membrane, so dim them by the same field + // intensity + reading well or they'd punch through the centered text. + return vec4f(color * field_dim(frag.home), 1.0); } @vertex @@ -175,6 +210,7 @@ fn vs_neck(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> out.clip = vec4f(pos + normal * side * thickness, 0.0, 1.0); out.uv = vec2f(t, side); out.misc = vec4f(n.signals.x, n.signals.y, n.signals.z, distance(pos, midpoint)); + out.home = midpoint; return out; } @@ -189,16 +225,27 @@ fn fs_neck(frag: VSOut) -> @location(0) vec4f { let amber = vec3f(1.0, 0.69, 0.08); let pull = smoothstep(-0.08, 0.20, similarity - threshold); let color = mix(bridge, luciferin, pull) + amber * mismatch * pulse * 0.34; - return vec4f(color * (0.14 + similarity * 0.55 + mismatch * 0.18), 1.0); + // Necks draw on TOP of the membrane too — dim by field intensity + reading well. + return vec4f(color * (0.14 + similarity * 0.55 + mismatch * 0.18) * field_dim(frag.home), 1.0); } `; const MEMBRANE_WGSL = /* wgsl */ ` ${COMMON_WGSL} +// FieldOpts: x=intensity (0..1 overall dim), yz=well center NDC, w=well half-w, +// then well half-h, floor (min emission inside well), soft (edge falloff), pad. +// Lets a text-heavy organ dim the whole field AND carve a reading well under the +// centered DOM overlay so the labels/values read. hw<=0 disables the well. +struct FieldOpts { + intensity_wx_wy_hw: vec4f, + hh_floor_soft_pad: vec4f, +}; + @group(0) @binding(0) var params: Params; @group(0) @binding(3) var field_sampler: sampler; @group(0) @binding(4) var field_tex: texture_2d; +@group(0) @binding(5) var opts: FieldOpts; const QUAD = array( vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0), @@ -216,6 +263,25 @@ fn vs_fullscreen(@builtin(vertex_index) vi: u32) -> VSOut { return out; } +// Reading-well multiplier at an NDC point: 1.0 outside the well, falling toward +// the floor value inside it (smooth edge of width soft). Disabled when hw<=0. +fn reading_well(ndc: vec2f) -> f32 { + let hw = opts.intensity_wx_wy_hw.w; + if (hw <= 0.0) { return 1.0; } + let center = opts.intensity_wx_wy_hw.yz; + let hh = opts.hh_floor_soft_pad.x; + let floor_v = opts.hh_floor_soft_pad.y; + let soft = max(0.02, opts.hh_floor_soft_pad.z); + let d = abs(ndc - center) - vec2f(hw, hh); + // signed distance to rect edge: <0 inside, >0 outside + let outside = length(max(d, vec2f(0.0))); + let inside = min(max(d.x, d.y), 0.0); + let sd = outside + inside; + // sd<=-soft → fully inside (floor); sd>=0 → outside (1.0) + let t = smoothstep(-soft, 0.0, sd); + return mix(floor_v, 1.0, t); +} + @fragment fn fs_membrane(frag: VSOut) -> @location(0) vec4f { let f = textureSample(field_tex, field_sampler, frag.uv); @@ -232,7 +298,9 @@ fn fs_membrane(frag: VSOut) -> @location(0) vec4f { color = color + bridge * density * 0.055 + luciferin * retention * 0.080; color = color + ivory * membrane * 0.22 + amber * mismatch * (0.20 + 0.08 * params.pulse); let vignette = smoothstep(0.96, 0.18, distance(frag.uv, vec2f(0.5))); - return vec4f(color * (0.35 + 0.65 * vignette) * params.brightness, 1.0); + let ndc = frag.uv * 2.0 - vec2f(1.0); + let dim = clamp(opts.intensity_wx_wy_hw.x, 0.0, 1.0) * reading_well(ndc); + return vec4f(color * (0.35 + 0.65 * vignette) * params.brightness * dim, 1.0); } `; @@ -272,6 +340,7 @@ type GpuResources = { neckBuffer: GPUBuffer; blurHBuffer: GPUBuffer; blurVBuffer: GPUBuffer; + optsBuffer: GPUBuffer; splatBindGroup: GPUBindGroup; blurHBindGroup: GPUBindGroup; blurVBindGroup: GPUBindGroup; @@ -328,12 +397,67 @@ export class DuplicatesPass implements FramePass { private neckCount = 0; private cellGeometry: CellGeometry[] = []; private neckGeometry: NeckGeometry[] = []; + // 0..1 overall field intensity — text-heavy /duplicates dims to a calm backdrop + // so the centered DOM overlay (cluster cards, threshold, counts) stays legible. + private intensity = 0.22; + // Reading well (NDC rect): the field emits LESS inside it so the centered text + // column reads. hw<=0 disables it. + private well = { x: 0, y: 0, hw: -1, hh: 0, floor: 0.1, soft: 0.22 }; constructor(engine: ObservatoryEngine, scene: RouteSceneModel) { this.engine = engine; this.uploadScene(scene); } + /** + * Set the overall field intensity (0..1). LOW (~0.22) = dim backdrop for this + * text-heavy organ; HIGH = the field is the hero. Picked up immediately by the + * membrane + on-top cell/neck shaders via the shared FieldOpts uniform. + */ + setIntensity(v: number): void { + this.intensity = Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0.22)); + const d = this.engine.gpuDevice; + if (d) this.writeOpts(d); + } + + /** + * Set a "reading well": the field emits LESS inside this NDC rectangle so the + * DOM text on top reads. hw<=0 disables it. /duplicates renders its overlay in a + * centered `mx-auto max-w-5xl` column, so the well is centered, not a left rail. + */ + setReadingWell(r: { x: number; y: number; hw: number; hh: number; floor?: number; soft?: number }): void { + const finiteN = (v: number, fb = 0) => (Number.isFinite(v) ? v : fb); + this.well = { + x: finiteN(r.x), + y: finiteN(r.y), + hw: finiteN(r.hw, -1), + hh: finiteN(r.hh), + floor: Math.min(1, Math.max(0, finiteN(r.floor ?? 0.1, 0.1))), + soft: Math.max(0.02, finiteN(r.soft ?? 0.22, 0.22)) + }; + const d = this.engine.gpuDevice; + if (d) this.writeOpts(d); + } + + /** Write the FieldOpts uniform (intensity + reading-well rect). 8 floats. */ + private writeOpts(device: GPUDevice): void { + if (!this.resources) return; + device.queue.writeBuffer( + this.resources.optsBuffer, + 0, + new Float32Array([ + this.intensity, + this.well.x, + this.well.y, + this.well.hw, + this.well.hh, + this.well.floor, + this.well.soft, + 0 + ]) + ); + } + uploadScene(scene: RouteSceneModel): void { this.scene = scene as DuplicatesScene; this.buildGeometry(); @@ -354,7 +478,9 @@ export class DuplicatesPass implements FramePass { 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.VERTEX, buffer: { type: 'read-only-storage' } } + { binding: 2, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } }, + // FieldOpts (intensity + reading well) — read in fs_cell/fs_neck only. + { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } } ] }); this.blurBindLayout = device.createBindGroupLayout({ @@ -370,7 +496,9 @@ export class DuplicatesPass implements FramePass { entries: [ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }, { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } }, - { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } } + { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }, + // FieldOpts (intensity + reading well) — dims the full-bleed membrane. + { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } } ] }); const splatLayout = device.createPipelineLayout({ label: 'duplicates-fusion-splat-layout', bindGroupLayouts: [this.splatBindLayout] }); @@ -424,6 +552,7 @@ export class DuplicatesPass implements FramePass { let neckBuffer = this.resources?.neckBuffer; let blurHBuffer = this.resources?.blurHBuffer; let blurVBuffer = this.resources?.blurVBuffer; + let optsBuffer = this.resources?.optsBuffer; if (!cellBuffer) cellBuffer = device.createBuffer({ label: 'duplicates-cells', size: MAX_CELLS * CELL_FLOATS * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); if (!neckBuffer) neckBuffer = device.createBuffer({ label: 'duplicates-necks', size: MAX_NECKS * NECK_FLOATS * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); if (!blurHBuffer) { @@ -434,7 +563,16 @@ export class DuplicatesPass implements FramePass { blurVBuffer = device.createBuffer({ label: 'duplicates-blur-v-dir', size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); device.queue.writeBuffer(blurVBuffer, 0, new Float32Array([0, 1, 0, 0])); } - if (!needsTextures && this.resources) return; + if (!optsBuffer) { + // FieldOpts: intensity + reading-well rect. 8 floats = 32 bytes. + optsBuffer = device.createBuffer({ label: 'duplicates-field-opts', size: 32, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + } + if (!needsTextures && this.resources) { + // Buffers already exist; the caller may have just changed intensity/well. + this.resources.optsBuffer = optsBuffer; + this.writeOpts(device); + return; + } this.resources?.fieldA.destroy(); this.resources?.fieldB.destroy(); const usage = GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING; @@ -448,7 +586,8 @@ export class DuplicatesPass implements FramePass { entries: [ { binding: 0, resource: { buffer: this.engine.paramsBuffer } }, { binding: 1, resource: { buffer: cellBuffer } }, - { binding: 2, resource: { buffer: neckBuffer } } + { binding: 2, resource: { buffer: neckBuffer } }, + { binding: 5, resource: { buffer: optsBuffer } } ] }); const blurHBindGroup = device.createBindGroup({ @@ -475,10 +614,12 @@ export class DuplicatesPass implements FramePass { entries: [ { binding: 0, resource: { buffer: this.engine.paramsBuffer } }, { binding: 3, resource: this.sampler }, - { binding: 4, resource: fieldAView } + { binding: 4, resource: fieldAView }, + { binding: 5, resource: { buffer: optsBuffer } } ] }); - this.resources = { cellBuffer, neckBuffer, blurHBuffer, blurVBuffer, splatBindGroup, blurHBindGroup, blurVBindGroup, membraneBindGroup, fieldA, fieldB, fieldAView, fieldBView, fieldSize: [w, h] }; + this.resources = { cellBuffer, neckBuffer, blurHBuffer, blurVBuffer, optsBuffer, splatBindGroup, blurHBindGroup, blurVBindGroup, membraneBindGroup, fieldA, fieldB, fieldAView, fieldBView, fieldSize: [w, h] }; + this.writeOpts(device); } private buildGeometry(): void { @@ -719,6 +860,7 @@ export class DuplicatesPass implements FramePass { this.resources?.neckBuffer.destroy(); this.resources?.blurHBuffer.destroy(); this.resources?.blurVBuffer.destroy(); + this.resources?.optsBuffer.destroy(); this.resources?.fieldA.destroy(); this.resources?.fieldB.destroy(); this.resources = null; @@ -757,5 +899,12 @@ export function createDuplicatesPasses(engine: ObservatoryEngine, scene: RouteSc void rgb01(RETENTION.recall); void rgb01(RETENTION.luciferin); void rgb01(IMMUNE.trustMembrane); - return [new DuplicatesPass(engine, scene)]; + const pass = new DuplicatesPass(engine, scene); + // /duplicates is a TEXT-HEAVY organ: the DOM overlay (cluster cards, threshold, + // counts) is the content, the synaptic-fusion field is a DIM backdrop. Drop the + // field to 0.22 and carve a centered reading well under the `mx-auto max-w-5xl` + // column so every label/value reads (mirrors observatory's setIntensity+well). + pass.setIntensity(0.22); + pass.setReadingWell({ x: 0, y: 0, hw: 0.6, hh: 0.85, floor: 0.08, soft: 0.25 }); + return [pass]; } diff --git a/apps/dashboard/src/lib/observatory/field/living-field-pass.ts b/apps/dashboard/src/lib/observatory/field/living-field-pass.ts index 5a3eaeb..08a8045 100644 --- a/apps/dashboard/src/lib/observatory/field/living-field-pass.ts +++ b/apps/dashboard/src/lib/observatory/field/living-field-pass.ts @@ -83,6 +83,7 @@ type GpuResources = { cellBuffer: GPUBuffer; blurHBuffer: GPUBuffer; blurVBuffer: GPUBuffer; + optsBuffer: GPUBuffer; splatBindGroup: GPUBindGroup; cellBindGroup: GPUBindGroup; blurHBindGroup: GPUBindGroup; @@ -99,6 +100,15 @@ export class LivingFieldPass implements FramePass { private engine: ObservatoryEngine; private cells: LivingCell[] = []; private scalars: LivingFieldScalars = {}; + // 0..1 field intensity — how bright the whole field renders. Text-heavy organs + // set this LOW (~0.2) so the field is a dim backdrop the labels read over; + // pure-visual organs (graph/timeline) keep it high. Default is a calm backdrop, + // NOT a full-brightness blast, because most organs carry readable text. + private intensity = 0.28; + // Reading well (NDC rect) — the field dims inside it so text reads. hw<=0 = off. + private well = { x: 0, y: 0, hw: -1, hh: 0, floor: 0.1, soft: 0.22 }; + // Portrait well-reflow: last aspect bucket, so compute() only re-writes opts on change. + private lastAspectBucket = -999; private resources: GpuResources | null = null; private sampler: GPUSampler | null = null; private splatBindLayout: GPUBindGroupLayout | null = null; @@ -114,6 +124,19 @@ export class LivingFieldPass implements FramePass { this.engine = engine; } + /** + * Set the field intensity (0..1). LOW (~0.15-0.28) = dim backdrop for text + * organs; HIGH (~0.6-1.0) = the field is the hero (graph/timeline). Call + * before setCells (it's baked into the uploaded cells). + */ + setIntensity(v: number): void { + this.intensity = Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0.28)); + const d = this.engine.gpuDevice; + if (d) this.writeOpts(d); // membrane picks it up immediately + // cells read intensity from extra.z, so re-bake the cell buffer too + if (this.cells.length) this.setCells(this.cells, this.scalars); + } + /** Upload a fresh set of cells (a data change). Rebuilds the GPU buffer. */ setCells(cells: LivingCell[], scalars: LivingFieldScalars = {}): void { this.cells = cells.slice(0, MAX_CELLS); @@ -132,12 +155,14 @@ export class LivingFieldPass implements FramePass { const membraneModule = diagShader(device, 'living-field-membrane', FIELD_MEMBRANE_WGSL); const cellModule = diagShader(device, 'living-field-cell', FIELD_CELL_WGSL); - // splat + cell share: uniform params (0) + storage cells (1) + // splat + cell share: uniform params (0) + storage cells (1) + FieldOpts (2). + // (fs_splat ignores binding 2; the cell shader reads it for intensity+well.) this.splatBindLayout = device.createBindGroupLayout({ label: 'living-field-splat-layout', entries: [ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }, - { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } } + { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } } ] }); this.blurBindLayout = device.createBindGroupLayout({ @@ -152,6 +177,7 @@ export class LivingFieldPass implements FramePass { label: 'living-field-membrane-layout', entries: [ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }, { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } }, { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } } ] @@ -203,6 +229,7 @@ export class LivingFieldPass implements FramePass { let cellBuffer = this.resources?.cellBuffer; let blurHBuffer = this.resources?.blurHBuffer; let blurVBuffer = this.resources?.blurVBuffer; + let optsBuffer = this.resources?.optsBuffer; if (!cellBuffer) cellBuffer = device.createBuffer({ label: 'living-field-cells', size: MAX_CELLS * CELL_FLOATS * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST }); if (!blurHBuffer) { blurHBuffer = device.createBuffer({ label: 'living-field-blur-h', size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); @@ -212,6 +239,10 @@ export class LivingFieldPass implements FramePass { blurVBuffer = device.createBuffer({ label: 'living-field-blur-v', size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); device.queue.writeBuffer(blurVBuffer, 0, new Float32Array([0, 1, 0, 0])); } + if (!optsBuffer) { + // FieldOpts: intensity + reading-well rect. 8 floats = 32 bytes. + optsBuffer = device.createBuffer({ label: 'living-field-opts', size: 32, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); + } if (!needsTextures && this.resources) return; this.resources?.fieldA.destroy(); this.resources?.fieldB.destroy(); @@ -225,7 +256,8 @@ export class LivingFieldPass implements FramePass { layout: this.splatBindLayout, entries: [ { binding: 0, resource: { buffer: this.engine.paramsBuffer } }, - { binding: 1, resource: { buffer: cellBuffer } } + { binding: 1, resource: { buffer: cellBuffer } }, + { binding: 2, resource: { buffer: optsBuffer } } ] }); const cellBindGroup = device.createBindGroup({ @@ -233,13 +265,103 @@ export class LivingFieldPass implements FramePass { layout: this.splatBindLayout, entries: [ { binding: 0, resource: { buffer: this.engine.paramsBuffer } }, - { binding: 1, resource: { buffer: cellBuffer } } + { binding: 1, resource: { buffer: cellBuffer } }, + { binding: 2, resource: { buffer: optsBuffer } } ] }); const blurHBindGroup = device.createBindGroup({ label: 'living-field-blur-h-bind', layout: this.blurBindLayout, entries: [{ binding: 0, resource: this.sampler }, { binding: 1, resource: fieldAView }, { binding: 2, resource: { buffer: blurHBuffer } }] }); const blurVBindGroup = device.createBindGroup({ label: 'living-field-blur-v-bind', layout: this.blurBindLayout, entries: [{ binding: 0, resource: this.sampler }, { binding: 1, resource: fieldBView }, { binding: 2, resource: { buffer: blurVBuffer } }] }); - const membraneBindGroup = device.createBindGroup({ label: 'living-field-membrane-bind', layout: this.membraneBindLayout, entries: [{ binding: 0, resource: { buffer: this.engine.paramsBuffer } }, { binding: 3, resource: this.sampler }, { binding: 4, resource: fieldAView }] }); - this.resources = { cellBuffer, blurHBuffer, blurVBuffer, splatBindGroup, cellBindGroup, blurHBindGroup, blurVBindGroup, membraneBindGroup, fieldA, fieldB, fieldAView, fieldBView, fieldSize: [w, h] }; + const membraneBindGroup = device.createBindGroup({ label: 'living-field-membrane-bind', layout: this.membraneBindLayout, entries: [{ binding: 0, resource: { buffer: this.engine.paramsBuffer } }, { binding: 2, resource: { buffer: optsBuffer } }, { binding: 3, resource: this.sampler }, { binding: 4, resource: fieldAView }] }); + this.resources = { cellBuffer, blurHBuffer, blurVBuffer, optsBuffer, splatBindGroup, cellBindGroup, blurHBindGroup, blurVBindGroup, membraneBindGroup, fieldA, fieldB, fieldAView, fieldBView, fieldSize: [w, h] }; + this.writeOpts(device); + } + + /** Write the FieldOpts uniform (intensity + reading-well rect). */ + private writeOpts(device: GPUDevice): void { + if (!this.resources) return; + // Portrait: the text layer recentres x and reclaims vertical spread on a + // phone (TextLayerPass.portraitAdapt). Apply the SAME transform to the + // reading well so the dimmed region still sits under the reflowed text, + // driven entirely by the live viewport aspect — nothing hardcoded per page. + const well = this.portraitWell(); + device.queue.writeBuffer( + this.resources.optsBuffer, + 0, + new Float32Array([ + this.intensity, + well.x, + well.y, + well.hw, + well.hh, + well.floor, + well.soft, + 0 + ]) + ); + } + + /** Coarse aspect bucket (matches TextLayerPass) so we only re-write on change. */ + private aspectBucket(): number { + let vw = this.engine.params[6] || 0; + let vh = this.engine.params[7] || 0; + if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') { + vw = window.innerWidth; + vh = window.innerHeight; + } + if (vw <= 0 || vh <= 0) return -999; + return Math.round((vw / vh) * 8); + } + + /** Mirror TextLayerPass.portraitAdapt's x-recentre + y-reclaim on the well. */ + private portraitWell(): { + x: number; + y: number; + hw: number; + hh: number; + floor: number; + soft: number; + } { + if (this.well.hw <= 0) return this.well; + let vw = this.engine.params[6] || 0; + let vh = this.engine.params[7] || 0; + if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') { + vw = window.innerWidth; + vh = window.innerHeight; + } + if (vw <= 0 || vh <= 0) return this.well; + const aspect = vw / vh; + if (aspect >= 0.85) return this.well; + const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.46)); + const xPull = 0.42 * portraitness; + const yReclaim = 1 + (1 / Math.max(aspect, 0.2) - 1) * (0.72 * portraitness); + // Widen the well to cover the whole narrow column + a little slack. + const widen = 1 + 0.25 * portraitness; + return { + x: this.well.x * (1 - xPull), + y: clamp(this.well.y * yReclaim, -0.98, 0.98), + hw: Math.min(1.1, this.well.hw * widen), + hh: Math.min(1.1, this.well.hh * yReclaim), + floor: this.well.floor, + soft: this.well.soft + }; + } + + /** + * Set a "reading well": the field emits LESS inside this NDC rectangle so text + * on top reads clearly. hw<=0 (default) disables it → field renders full. Call + * with the region your MSDF text occupies (e.g. the left instrument column). + */ + setReadingWell(r: { x: number; y: number; hw: number; hh: number; floor?: number; soft?: number }): void { + this.well = { + x: finite(r.x), + y: finite(r.y), + hw: finite(r.hw, -1), + hh: finite(r.hh), + floor: clamp01(r.floor ?? 0.1), + soft: Math.max(0.02, finite(r.soft ?? 0.22, 0.22)) + }; + const d = this.engine.gpuDevice; + if (d) this.writeOpts(d); } private uploadBuffers(device: GPUDevice): void { @@ -276,7 +398,7 @@ export class LivingFieldPass implements FramePass { data[o + 11] = finite(c.spin ?? 1, 1); data[o + 12] = i; data[o + 13] = finite(c.seed ?? phase * 97.13); - data[o + 14] = 0; + data[o + 14] = this.intensity; // extra.z — per-field intensity the shader dims by data[o + 15] = 0; } // NOTE: do NOT write engine.params[2] (node_count) here. That lane is SHARED @@ -291,6 +413,14 @@ export class LivingFieldPass implements FramePass { const device = this.engine.gpuDevice; if (!device || !this.resources || !this.splatPipeline || !this.blurPipeline) return; this.ensureResources(device); + // Re-write the reading-well opts when the viewport aspect crosses a bucket + // (phone rotate / window resize) so the portrait-adapted well tracks the + // reflowed text. Cheap (one 32-byte write) and only fires on real changes. + const bucket = this.aspectBucket(); + if (bucket !== this.lastAspectBucket) { + this.lastAspectBucket = bucket; + this.writeOpts(device); + } const res = this.resources; const splat = encoder.beginRenderPass({ label: 'living-field-splat-pass', colorAttachments: [{ view: res.fieldAView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: 'clear', storeOp: 'store' }] }); splat.setPipeline(this.splatPipeline); @@ -354,6 +484,7 @@ export class LivingFieldPass implements FramePass { this.resources?.cellBuffer.destroy(); this.resources?.blurHBuffer.destroy(); this.resources?.blurVBuffer.destroy(); + this.resources?.optsBuffer.destroy(); this.resources?.fieldA.destroy(); this.resources?.fieldB.destroy(); this.resources = null; @@ -364,6 +495,10 @@ function clamp01(v: number): number { return Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0)); } +function clamp(v: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : lo)); +} + /** Coerce a value to a finite number so no NaN/Infinity reaches the GPU buffer. */ function finite(v: number, fallback = 0): number { return Number.isFinite(v) ? v : fallback; diff --git a/apps/dashboard/src/lib/observatory/field/living-field.wgsl.ts b/apps/dashboard/src/lib/observatory/field/living-field.wgsl.ts index 58b3581..2b07355 100644 --- a/apps/dashboard/src/lib/observatory/field/living-field.wgsl.ts +++ b/apps/dashboard/src/lib/observatory/field/living-field.wgsl.ts @@ -79,6 +79,29 @@ fn orbit(base: vec2f, phase01: f32, spin_scale: f32) -> vec2f { let rr = radius * (1.0 + 0.016 * sin(params.time * 1.1 + phase01 * TAU)); return vec2f(cos(ang), sin(ang)) * rr; } + +// Per-field options (a small field-local uniform, NOT the shared Params). Carries +// the global intensity + a "reading well" rectangle: the field emits LESS inside +// the well so text renders on a dim, readable substrate. hw<=0 disables the well. +struct FieldOpts { + intensity: f32, // 0..1 global field scale (membrane); cells also honor extra.z + well_x: f32, // reading-well rect center, NDC x + well_y: f32, // reading-well rect center, NDC y + well_hw: f32, // half-width NDC (<=0 disables -> factor 1.0) + well_hh: f32, // half-height NDC + well_floor: f32, // min multiplier inside the well (e.g. 0.10) + well_soft: f32, // edge softness NDC (e.g. 0.22) + _pad: f32, +}; + +// 1.0 outside the well, ramping down to well_floor inside it (a soft rectangle). +fn reading_well(uv_ndc: vec2f, o: FieldOpts) -> f32 { + if (o.well_hw <= 0.0) { return 1.0; } + let dx = max(0.0, abs(uv_ndc.x - o.well_x) - o.well_hw); + let dy = max(0.0, abs(uv_ndc.y - o.well_y) - o.well_hh); + let outside = length(vec2f(dx, dy)) / max(o.well_soft, 0.001); + return mix(o.well_floor, 1.0, clamp(outside, 0.0, 1.0)); +} `; // Pass 1 — additive splat of every cell into the low-res density field @@ -172,6 +195,7 @@ export const FIELD_MEMBRANE_WGSL = /* wgsl */ ` ${FIELD_COMMON_WGSL} @group(0) @binding(0) var params: Params; +@group(0) @binding(2) var fopts: FieldOpts; @group(0) @binding(3) var field_sampler: sampler; @group(0) @binding(4) var field_tex: texture_2d; @@ -205,16 +229,22 @@ fn fs_membrane(in: VSOut) -> @location(0) vec4f { let membrane = smoothstep(0.05, 0.62, density) * (1.0 - smoothstep(2.0, 4.0, density)); let edge = smoothstep(0.008, 0.11, grad) * membrane; let breath = 0.72 + 0.55 * params.pulse; + // Cold blue-black substrate: the fullscreen plasma is desaturated + dimmed hard + // so it reads as a deep breathing floor, NOT a neon-green wash over text. let blackwater = vec3f(0.006, 0.012, 0.015); - let amber = vec3f(0.86, 0.42, 0.12); - let oxygen_col = vec3f(0.66, 1.0, 0.37); - let scarlet = vec3f(1.0, 0.23, 0.18); - var color = blackwater * (0.35 + density * 0.14); - color = color + mix(amber, oxygen_col, clamp(oxygen / max(density, 0.001), 0.0, 1.0)) * density * 0.34 * breath; - color = color + vec3f(0.91, 1.0, 0.72) * edge * (0.85 + 0.5 * params.pulse); - color = color + scarlet * scar * (0.6 + 0.4 * params.pulse); + let amber = vec3f(0.70, 0.38, 0.14); + let oxygen_col = vec3f(0.42, 0.70, 0.40); // desaturated (was neon 0.66,1.0,0.37) + let scarlet = vec3f(0.85, 0.22, 0.18); + var color = blackwater * (0.30 + density * 0.10); + color = color + mix(amber, oxygen_col, clamp(oxygen / max(density, 0.001), 0.0, 1.0)) * density * 0.10 * breath; + color = color + vec3f(0.55, 0.62, 0.50) * edge * (0.28 + 0.18 * params.pulse); + color = color + scarlet * scar * (0.28 + 0.18 * params.pulse); let vignette = smoothstep(1.02, 0.10, distance(in.uv, vec2f(0.5))); - return vec4f(color * (0.5 + 0.5 * vignette) * params.brightness, 1.0); + // Reading well: the field emits LESS where text lives, so labels read. Plus the + // per-page intensity. Deeper vignette floor pushes frame edges toward void. + let uv_ndc = vec2f(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0); + let well = reading_well(uv_ndc, fopts); + return vec4f(color * (0.35 + 0.65 * vignette) * params.brightness * fopts.intensity * well, 1.0); } `; @@ -225,6 +255,7 @@ ${FIELD_COMMON_WGSL} @group(0) @binding(0) var params: Params; @group(0) @binding(1) var cells: array; +@group(0) @binding(2) var fopts: FieldOpts; const QUAD = array( vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0), @@ -236,6 +267,10 @@ struct VSOut { @location(0) uv: vec2f, @location(1) @interpolate(flat) hue_energy: vec4f, @location(2) @interpolate(flat) info: vec4f, + // x = field intensity (0..1, extra.z), y = twinkle seed (extra.y) + @location(3) @interpolate(flat) extra: vec4f, + // the cell's orbited center in NDC, so the reading well is evaluated per cell + @location(4) @interpolate(flat) center_ndc: vec2f, }; @vertex @@ -253,6 +288,8 @@ fn vs_cell(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> out.uv = corner; out.hue_energy = c.hue_energy; out.info = c.phase_flags; + out.extra = c.extra; + out.center_ndc = center; return out; } @@ -260,21 +297,35 @@ fn vs_cell(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> fn fs_cell(in: VSOut) -> @location(0) vec4f { let d = length(in.uv); if (d > 1.0) { discard; } + // Field intensity (extra.z) dims the WHOLE cell so the field is a backdrop the + // text reads over. Text organs set this low; visual organs keep it high. + let intensity = clamp(in.extra.x, 0.05, 1.0); let hue = in.hue_energy.rgb; let energy = clamp(in.hue_energy.w, 0.0, 1.0); let flags = in.info.y; - let metric2 = clamp(in.info.z, 0.0, 1.0); let selected = select(0.0, 1.0, flags == 1.0 || flags == 3.0 || flags == 5.0 || flags == 7.0); let scar = select(0.0, 1.0, (flags >= 2.0 && flags < 4.0) || flags >= 6.0); let phase = in.info.x; - let twinkle = 0.6 + 0.8 * (0.5 + 0.5 * sin(params.time * 2.1 + phase * 26.0)); - let body = exp(-d * d * 2.7) * (0.55 + energy * 1.7) * twinkle; + // twinkle tamed to 0.85..1.05 (amp 0.10) — cells breathe, never strobe over text. + let twinkle = 0.85 + 0.10 * (0.5 + 0.5 * sin(params.time * 2.1 + phase * 26.0)); + // tighter core (d*d*3.2), low amplitude, then a soft-knee ceiling so even a + // max-energy selected cell + bloom cannot spike into the text luminance range. + var body = exp(-d * d * 3.2) * (0.10 + energy * 0.42) * twinkle; + body = body / (1.0 + body * 0.9); + // cool the hue toward a cold substrate so raw green/amber can't run away and + // beat cyan/ivory text (green channel near-white after threshold-free bloom). + let cool = vec3f(0.16, 0.22, 0.30); + let tinted = mix(cool, hue, 0.55); let rim = smoothstep(0.98, 0.72, d) * (1.0 - smoothstep(0.72, 0.40, d)); - let scarlet = vec3f(1.0, 0.23, 0.18); - let ivory = vec3f(0.95, 1.0, 0.86); - var color = hue * body; - color = color + ivory * rim * (0.9 + selected * 1.6); - color = color + scarlet * scar * smoothstep(0.16, 0.0, abs(d - 0.74)) * 1.5; - return vec4f(color, 1.0); + let scarlet = vec3f(0.85, 0.22, 0.18); + let ivory = vec3f(0.90, 0.96, 0.86); + var color = tinted * body; + // rim is a SELECTED-ONLY affordance now (no global white shimmer over text). + color = color + ivory * rim * selected * 0.5; + color = color + scarlet * scar * smoothstep(0.16, 0.0, abs(d - 0.74)) * 0.35; + // selected/scar stay a touch brighter than the dimmed backdrop so meaning survives. + let keep = max(intensity, (selected + scar) * 0.7); + let well = reading_well(in.center_ndc, fopts); + return vec4f(color * keep * well, 1.0); } `; diff --git a/apps/dashboard/src/lib/observatory/nav/nav-layer.ts b/apps/dashboard/src/lib/observatory/nav/nav-layer.ts index 06df6d0..e7d38de 100644 --- a/apps/dashboard/src/lib/observatory/nav/nav-layer.ts +++ b/apps/dashboard/src/lib/observatory/nav/nav-layer.ts @@ -86,13 +86,33 @@ class TextNavLayer implements NavLayerPass { private hoverHref: string | null = null; private expanded = false; private ready = false; + private readonly engine: ObservatoryEngine; constructor(engine: ObservatoryEngine, opts: NavLayerOptions) { + this.engine = engine; this.text = new TextLayerPass(engine); this.routes = opts.routes ?? COGNITIVE_OS_ROUTES; this.activePath = opts.activePath ?? ''; } + /** + * Touch/portrait phones have NO hover, so the desktop hover-to-expand rail can + * never open and the organ becomes un-navigable. On a narrow viewport we switch + * to a directly-TAPPABLE dock: always-visible shortcut letters at the true left + * edge, each its own pick target so one tap navigates. Derived from the live + * viewport aspect — nothing hardcoded, and desktop is untouched. + */ + private isMobile(): boolean { + let vw = this.engine.params[6] || 0; + let vh = this.engine.params[7] || 0; + if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') { + vw = window.innerWidth; + vh = window.innerHeight; + } + if (vw <= 0 || vh <= 0) return false; + return vw / vh < 0.85; + } + async init(): Promise { await this.text.init(); this.ready = true; @@ -106,6 +126,10 @@ class TextNavLayer implements NavLayerPass { } setHoverFromNdc(ndcX: number, ndcY: number): NavPick | null { + // Mobile dock is always-visible + directly tappable — there is no hover-to- + // expand. Just report whether a marker is under the point (for cursor state) + // without any rebuild. + if (this.isMobile()) return this.pickAt(ndcX, ndcY); // 1. Decide expand/collapse from the cursor ZONE (with hysteresis) BEFORE // picking, so that on the first move into the edge the labels are built // and immediately hoverable in this same call (no dead pointermove). @@ -159,6 +183,15 @@ class TextNavLayer implements NavLayerPass { private rebuild(): void { if (!this.ready) return; + // On mobile the in-canvas rail is SUPPRESSED entirely: a global DOM MobileNav + // (rendered by the (app) shell) is the single, reliable tap-to-navigate + // surface for phones — it works on every stage type AND on devices with no + // WebGPU, which this in-canvas rail cannot. Rendering nothing here avoids a + // confusing double nav. Desktop keeps the full hover-to-expand rail. + if (this.isMobile()) { + this.text.setText([]); + return; + } this.text.setText(this.expanded ? this.buildExpanded() : this.buildCollapsed()); } diff --git a/apps/dashboard/src/lib/observatory/palace-labels.ts b/apps/dashboard/src/lib/observatory/palace-labels.ts index 7ccd99a..8b10e25 100644 --- a/apps/dashboard/src/lib/observatory/palace-labels.ts +++ b/apps/dashboard/src/lib/observatory/palace-labels.ts @@ -123,11 +123,31 @@ function clamp01(v: number): number { */ export function buildOrganLabels( positions: OrganScreenPos[], - opts: { hoveredHref?: string | null; dimUnhovered?: boolean } = {} + opts: { hoveredHref?: string | null; dimUnhovered?: boolean; aspect?: number } = {} ): TextLayerItem[] { const hovered = opts.hoveredHref ?? null; const dim = opts.dimUnhovered ?? true; + // ── Portrait title-guard. On a phone the fixed title HUD ("THE MEMORY PALACE" + // + subtitle) lives in the top screen band (authored screen-y ≈ 0.78..0.90), + // while the orbital projection lands the top-arc organs (TIMELINE / GRAPH / + // MEMORIES) in that SAME band — the labels pile onto the title and it becomes + // unreadable. portraitAdapt preserves authored screen-y, so a label projected + // into that band renders on top of the title. The cure is to pull every + // orbital label DOWN out of the reserved title band. Derived entirely from the + // live aspect: the narrower/taller the viewport, the more the top band must be + // protected. Landscape/desktop (aspect ≥ 0.85) keeps the authored projection + // byte-for-byte, so this is purely a portrait reflow. + const aspect = opts.aspect ?? 1; + const portrait = aspect < 0.85; + // Portraitness 0→1 as aspect narrows from 0.85 to ~0.46 (tall phones). The + // title band's lower edge in authored screen-y: on a wide-ish portrait the + // subtitle sits at ~0.79, on a tall phone the title needs a touch more room. + const portraitness = portrait ? clamp01((0.85 - aspect) / (0.85 - 0.46)) : 0; + // Any label whose projected screen-y is above this ceiling gets folded down + // below it, keeping the top band clear for the title HUD. + const titleCeil = 0.7 - 0.06 * portraitness; + let n = 0; // Per-frame placed-label rects for greedy de-confliction (deterministic order // → capture-stable). Reused scratch to keep the hot path allocation-light. @@ -149,6 +169,15 @@ export function buildOrganLabels( let x = p.ndcX + clear * OFFSET_RIGHT; let y = p.ndcY + clear * OFFSET_UP; + // Portrait title-guard: fold any label out of the reserved title band so + // it never overprints "THE MEMORY PALACE" / the subtitle. Reflect it down + // below the ceiling (mirror across titleCeil) so a top-arc orb's caption + // still reads, just underneath the title instead of on top of it. The + // de-confliction pass below then keeps folded labels from stacking. + if (portrait && y > titleCeil) { + y = titleCeil - (y - titleCeil); + } + // Size + brightness ride depth; hover overrides to a hot readout. let size = lerp(SIZE_FAR, SIZE_NEAR, depth); let alpha = lerp(ALPHA_FAR, ALPHA_NEAR, depth); diff --git a/apps/dashboard/src/lib/observatory/palace-node-pass.ts b/apps/dashboard/src/lib/observatory/palace-node-pass.ts index 9232db2..89be851 100644 --- a/apps/dashboard/src/lib/observatory/palace-node-pass.ts +++ b/apps/dashboard/src/lib/observatory/palace-node-pass.ts @@ -247,7 +247,17 @@ fn fs_main(in: VSOut) -> @location(0) vec4 { color = color + vec3(1.0, 1.0, 1.0) * core * 0.6; } - return vec4(color * params.brightness, 1.0); + // Portrait dim: on a phone the packed additive halos bloom into one blinding + // white blob that swallows the lower field and steals contrast from the STATS/ + // SETTINGS labels. Fold the whole field down to a DIM backdrop when the live + // viewport is portrait (aspect < 0.85), scaling with how narrow it is. Derived + // purely from viewport_w/viewport_h — landscape/desktop (aspect >= 0.85) gets + // an exact 1.0 multiplier, so the desktop render is byte-identical. + let aspect = params.viewport_w / max(params.viewport_h, 1.0); + let portraitness = clamp((0.85 - aspect) / (0.85 - 0.46), 0.0, 1.0); + let portrait_dim = 1.0 - 0.62 * portraitness; + + return vec4(color * params.brightness * portrait_dim, 1.0); } `; diff --git a/apps/dashboard/src/lib/observatory/text/text-layer.ts b/apps/dashboard/src/lib/observatory/text/text-layer.ts index 7257bcd..b089183 100644 --- a/apps/dashboard/src/lib/observatory/text/text-layer.ts +++ b/apps/dashboard/src/lib/observatory/text/text-layer.ts @@ -65,9 +65,48 @@ export class TextLayerPass implements FramePass { private runs: TextRunRect[] = []; private runDepths = new Map(); private initPromise: Promise | null = null; + // Portrait reflow re-lays text when the viewport aspect changes (rotate/resize). + private onResize: (() => void) | null = null; + private resizeRaf = 0; + private lastAspectBucket = -1; constructor(engine: ObservatoryEngine) { this.engine = engine; + this.installResizeReflow(); + } + + /** + * Re-lay the text when the viewport aspect crosses a portrait bucket (e.g. the + * user rotates the phone, or the window is resized narrow). Bucketed + rAF- + * debounced so a continuous drag doesn't thrash the glyph buffer. + */ + private installResizeReflow(): void { + if (typeof window === 'undefined') return; + this.onResize = () => { + if (this.resizeRaf) return; + this.resizeRaf = requestAnimationFrame(() => { + this.resizeRaf = 0; + const bucket = this.aspectBucket(); + if (bucket !== this.lastAspectBucket && this.pendingItems.length) { + this.lastAspectBucket = bucket; + this.uploadItems(this.pendingItems); + } + }); + }; + window.addEventListener('resize', this.onResize); + window.addEventListener('orientationchange', this.onResize); + } + + /** Coarse aspect bucket so tiny resizes don't trigger a re-layout. */ + private aspectBucket(): number { + let vw = this.engine.params[6] || 0; + let vh = this.engine.params[7] || 0; + if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') { + vw = window.innerWidth; + vh = window.innerHeight; + } + if (vw <= 0 || vh <= 0) return -1; + return Math.round((vw / vh) * 8); } async init(): Promise { @@ -116,10 +155,138 @@ export class TextLayerPass implements FramePass { }); } - private uploadItems(items: TextLayerItem[]): void { + /** + * Portrait reflow — the ONE place mobile layout is handled, driven entirely by + * the live viewport aspect (params[6]/[7]); NOTHING is hardcoded per page. + * + * The MSDF shader keeps glyphs square by doing `pos.y *= min(aspect,1)` in + * portrait (aspect<1), which crushes a full-height text column into a thin + * central band and leaves glyphs phone-tiny. Layouts are authored in landscape + * NDC (x≈-0.9 left column, y spanning ±0.86, size 0.02–0.05). On a phone we: + * 1. reclaim vertical spread: pre-divide y by aspect so `y/a * a == y` net, + * then gently compress so the tallest item still fits the safe band; + * 2. pull wide left-anchored columns toward centre so they don't overflow the + * narrow width after the size boost; + * 3. boost glyph size (capped) so small labels are readable at 375px, and + * tighten maxWidthEm so long lines wrap instead of running off-screen. + * Landscape (aspect>=~0.85) passes through untouched — desktop is unchanged. + */ + private portraitAdapt(items: TextLayerItem[]): TextLayerItem[] { + // Live viewport from the engine params (canvas px). params[6]/[7] are written + // inside the frame loop, so on the very first setText (during handleReady, + // before frame 0) they can be 0 — fall back to the window, which for these + // fixed-inset fullscreen organs has the same aspect as the canvas. + let vw = this.engine.params[6] || 0; + let vh = this.engine.params[7] || 0; + if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') { + vw = window.innerWidth; + vh = window.innerHeight; + } + if (vw <= 0 || vh <= 0) return items; + const aspect = vw / vh; + // Only reflow genuinely portrait / narrow viewports. Desktop + landscape + // tablet are left byte-for-byte identical. + if (aspect >= 0.85) return items; + + // The MSDF shader keeps glyphs square by `pos.y *= aspect` (portrait) and + // scales on-screen glyph height by the same aspect. So BOTH the vertical + // layout AND the visual size get crushed by `aspect` before hitting the + // screen. To place a row at a chosen SCREEN y (and render it at a chosen + // SCREEN size), the layout value must be pre-divided by aspect — that's the + // exact inverse of what the shader does, so the net screen result is the + // value we chose. Everything here is derived from the live aspect; nothing + // is hardcoded per page. + const inv = 1 / Math.max(aspect, 0.2); // shader-inverse (portrait: >1) + + // Vertical: reclaim the FULL authored spread. y_layout = y_screen / aspect, + // and we want y_screen == the authored y (it already fits the safe band). + const yReclaim = inv; + + // Size: a label authored at size s renders at on-screen height s*aspect in + // portrait — phone-tiny. Target ~1.25x the desktop on-screen height for + // touch legibility, i.e. screen size = s*1.25, so layout size = s*1.25*inv. + const sizeWant = 1.25 * inv; + + // Horizontal: width is now the TIGHT axis (full ±1, no aspect help). Pull + // wide left-anchored columns (x≈-0.9) toward centre and give a small left + // margin so the bigger glyphs don't run off either edge. + const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.46)); + const xPull = 0.5 * portraitness; + // Usable NDC width for a line starting at the (pulled) left anchor. In + // portrait the shader does NOT compress x, so this is full-scale NDC. + const LEFT_MARGIN = -0.9; + const RIGHT_MARGIN = 0.96; + // Empirical NDC advance per em of layout size. The MSDF atlas is roughly + // monospace; measured against real vitals lines the effective advance is + // ~0.62 (a touch wider than the nominal 0.55), so use that to keep long + // lines from bleeding off the right edge. + const EM_ADVANCE = 0.62; + + // Navigation + fixed HUD chrome own their OWN mobile layout (nav-layer builds + // a touch dock at the true edge; RouteStage pins the pause/telemetry corners). + // Recentering/reclaiming them like body content would drag the edge dock into + // the page or push the corners off-screen — so pass all chrome through with + // only a modest size bump for touch legibility. + const isChrome = (k?: string) => + !!k && + (k.startsWith('route-nav') || k === 'route-chrome' || k === 'route-telemetry' || k === 'route-status' || k === 'route-status-pulse'); + + return items.map((item) => { + if (isChrome(item.kind)) { + // Only grow slightly (touch legibility) — keep the authored x/y so the + // nav dock and HUD corners stay exactly where nav-layer/RouteStage put + // them for mobile. Clamp size growth so corners don't overlap. + const s = (item.size ?? 0.03) * Math.min(1.5, 1.1 * inv); + return { ...item, size: s }; + } + const baseSize = item.size ?? 0.075; + // Start from the desired boosted size, then cap it so the WHOLE line fits + // the usable width without needing per-character wrapping. A line of N + // chars at layout size s spans ~N*s*EM_ADVANCE in NDC-x. + const chars = Math.max(1, item.text.length); + const usableW = RIGHT_MARGIN - Math.max(LEFT_MARGIN, item.x * (1 - xPull)); + const fitSize = usableW / (chars * EM_ADVANCE); + // Never shrink below the authored size (desktop was already legible); never + // grow past what fits. This keeps long vitals on ONE line and readable. + const size = Math.max(baseSize, Math.min(baseSize * sizeWant, fitSize)); + // Keep the left anchor inside the margin after the centre-pull. + const x = Math.max(LEFT_MARGIN, item.x * (1 - xPull)); + // Clamp the reclaimed y so a row near the authored edge (±0.9) still maps + // to an on-screen y inside the safe band (±0.92 screen → ±0.92*inv layout). + const maxYLayout = 0.92 * inv; + let y = item.y * yReclaim; + if (y > maxYLayout) y = maxYLayout; + else if (y < -maxYLayout) y = -maxYLayout; + // Wrap paragraph-style items (those that opted into maxWidthEm) at a width + // that fits the screen; leave single-line vitals alone (fitSize keeps them + // on one line). Convert the fit width into an em cap for the chosen size. + // Floor at 14 em so we NEVER wrap down toward one-character-per-line (the + // stray vertical letter column bug) — better to slightly overflow a long + // unbreakable token than to stack it vertically. + const emThatFit = Math.floor(usableW / (size * EM_ADVANCE)); + const maxWidthEm = + item.maxWidthEm != null ? Math.max(14, Math.min(item.maxWidthEm, emThatFit)) : item.maxWidthEm; + const adapted = { ...item, x, y, size, maxWidthEm }; + if (typeof window !== 'undefined' && window.location?.search.includes('dbg=1')) { + (window as unknown as { __adaptDbg?: unknown[] }).__adaptDbg ??= []; + (window as unknown as { __adaptDbg: unknown[] }).__adaptDbg.push({ + id: item.id, + text: item.text?.slice(0, 24), + x: +x.toFixed(3), + y: +y.toFixed(3), + size: +size.toFixed(4), + maxWidthEm + }); + } + return adapted; + }); + } + + private uploadItems(rawItems: TextLayerItem[]): void { const device = this.engine.gpuDevice; if (!device || !this.engine.paramsBuffer || !this.atlas) return; this.ensurePipeline(device); + const items = this.portraitAdapt(rawItems); const packed: number[] = []; const runs: TextRunRect[] = []; let glyphIndex = 0; @@ -242,6 +409,13 @@ export class TextLayerPass implements FramePass { } dispose(): void { + if (this.onResize && typeof window !== 'undefined') { + window.removeEventListener('resize', this.onResize); + window.removeEventListener('orientationchange', this.onResize); + } + this.onResize = null; + if (this.resizeRaf) cancelAnimationFrame(this.resizeRaf); + this.resizeRaf = 0; this.glyphBuffer?.destroy(); this.glyphBuffer = null; this.atlas?.dispose(); diff --git a/apps/dashboard/src/lib/observatory/timeline/timeline-pass.ts b/apps/dashboard/src/lib/observatory/timeline/timeline-pass.ts index 8da7b81..11b09b4 100644 --- a/apps/dashboard/src/lib/observatory/timeline/timeline-pass.ts +++ b/apps/dashboard/src/lib/observatory/timeline/timeline-pass.ts @@ -51,6 +51,26 @@ struct TimelineRingGpu { // ('meta' is a WGSL reserved keyword — see GOD-TIER §9 / it broke Blackbox too) stats: vec4f, }; + +// Portrait legibility: on a phone the growth-ring field is the whole screen and +// its HDR bloom becomes a BLINDING blob that drowns the MSDF HUD/receipt text. +// Derive a dim factor from the LIVE viewport aspect (viewport_w/viewport_h) — +// nothing is hardcoded per device. Landscape/desktop (aspect >= 0.85) is left at +// full brightness (1.0); portrait scales down toward ~0.34 as it narrows so the +// field becomes a DIM backdrop and the overlay text wins the contrast fight. +fn portrait_field_dim() -> f32 { + let a = params.viewport_w / max(params.viewport_h, 1.0); + // portraitness: 0 at aspect 0.85 (landscape edge) -> 1 at aspect 0.46 (tall phone) + let p = clamp((0.85 - a) / (0.85 - 0.46), 0.0, 1.0); + // The ring/membrane colors are pushed HARD into HDR (peak accumulated ~5-8x via + // additive blend) specifically so the post-chain bloom flares them. A 0.2 dim + // still leaves ~1.0-1.6 — above the bloom knee, so it stayed a blinding blob on + // a phone. Pull it down to ~0.07 at full portrait so even the accumulated HDR + // peak lands well below the bloom threshold and the field reads as a true DIM + // backdrop the MSDF HUD/receipt text can win against. Aspect-derived, no per- + // device constant; landscape/desktop (aspect>=0.85) stays untouched at 1.0. + return mix(1.0, 0.07, p); +} `; const SPLAT_WGSL = /* wgsl */ ` @@ -161,7 +181,7 @@ fn fs_cell(in: VSOut) -> @location(0) vec4f { let rim = smoothstep(0.98, 0.74, d) * (1.0 - smoothstep(0.74, 0.42, d)); let seam = smoothstep(0.12, 0.0, abs(d - 0.48)) * rewritten; let scar = smoothstep(0.16, 0.0, abs(d - 0.76)) * suppressed; - return vec4f(core * body + vec3f(0.91, 1.0, 0.72) * rim * 1.1 + indigo * seam * 1.3 + scarlet * scar * 1.5, 1.0); + return vec4f((core * body + vec3f(0.91, 1.0, 0.72) * rim * 1.1 + indigo * seam * 1.3 + scarlet * scar * 1.5) * portrait_field_dim(), 1.0); } @vertex @@ -212,7 +232,7 @@ fn fs_ring(in: VSOut) -> @location(0) vec4f { color = color + scarlet * suppressed * 1.4; // Bright engraved date ticks flare on selection. color = color + vec3f(0.91, 1.0, 0.72) * tick * (0.14 + selected * 0.6); - return vec4f(color, 1.0); + return vec4f(color * portrait_field_dim(), 1.0); } `; @@ -269,7 +289,7 @@ fn fs_membrane(in: VSOut) -> @location(0) vec4f { // Indigo transaction-time seams shimmer with the breath. color = color + indigo * seam * (0.55 + 0.35 * params.pulse); let vignette = smoothstep(0.98, 0.12, distance(in.uv, vec2f(0.5))); - return vec4f(color * (0.55 + 0.45 * vignette) * params.brightness, 1.0); + return vec4f(color * (0.55 + 0.45 * vignette) * params.brightness * portrait_field_dim(), 1.0); } `; diff --git a/apps/dashboard/src/routes/(app)/+layout.svelte b/apps/dashboard/src/routes/(app)/+layout.svelte index f1a725d..de14111 100644 --- a/apps/dashboard/src/routes/(app)/+layout.svelte +++ b/apps/dashboard/src/routes/(app)/+layout.svelte @@ -1,4 +1,5 @@ @@ -12,4 +13,5 @@ -->
{@render children()} +
diff --git a/apps/dashboard/src/routes/(app)/activation/+page.svelte b/apps/dashboard/src/routes/(app)/activation/+page.svelte index a70e87e..5902d68 100644 --- a/apps/dashboard/src/routes/(app)/activation/+page.svelte +++ b/apps/dashboard/src/routes/(app)/activation/+page.svelte @@ -15,8 +15,32 @@ type ActivationTextItem = TextLayerItem & { memoryId?: string }; const CYAN = [...rgb01('#22C7DE'), 1] satisfies [number, number, number, number]; + const TITLE = [...rgb01('#EAF3FF'), 0.96] satisfies [number, number, number, number]; + const HEADER = [...rgb01('#7FA0B8'), 0.72] satisfies [number, number, number, number]; const ROW_LIMIT = 36; + // Phone shows fewer result rows so each gets real breathing room. + const ROW_LIMIT_PORTRAIT = 16; const SEARCH_LIMIT = 40; + // Pre-revealed anchor: the shared reveal ages every glyph by a GLOBAL index, so a + // long list ages past the ~720-frame wrapped clock and all but the first rows + // never appear. Anchor deeply negative so every row is revealed on frame 0. + const REVEAL_ANCHOR = -100000; + + let engineRef: ObservatoryEngine | null = null; + + // Portrait / narrow viewport from the LIVE engine aspect (params[6]/[7]) with a + // window fallback — the same signal TextLayerPass.portraitAdapt uses. Nothing is + // hardcoded to a phone width; desktop (aspect>=0.85) is untouched. + function isPortrait(): boolean { + let vw = engineRef?.params[6] || 0; + let vh = engineRef?.params[7] || 0; + if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') { + vw = window.innerWidth; + vh = window.innerHeight; + } + if (vw <= 0 || vh <= 0) return false; + return vw / vh < 0.85; + } let results = $state([]); let total = $state(0); @@ -84,33 +108,78 @@ return clamp01(memory.retentionStrength ?? relevance(memory)); } - function activationLine(memory: Memory): string { + function activationLine(memory: Memory, portrait: boolean): string { + if (portrait) { + // Phone: drop the id column and keep the primary content + the ONE headline + // metric (relevance %). 'title 92%' reads at a glance; full detail on tap. + const snippet = sanitizeAscii(memory.content).replace(/\s+/g, ' ').trim().slice(0, 34); + return sanitizeAscii(`${snippet} ${Math.round(relevance(memory) * 100)}%`); + } const snippet = sanitizeAscii(memory.content).replace(/\s+/g, ' ').trim().slice(0, 54); const score = Math.round(relevance(memory) * 100); return sanitizeAscii(`${snippet} | ${memory.id.slice(0, 8)} | ${score}%`); } function buildTextItems(): ActivationTextItem[] { - const rows = results.slice(0, ROW_LIMIT); - const top = 0.72; - const rowStep = 1.5 / Math.max(1, ROW_LIMIT - 1); - return rows.map((memory, i) => ({ - id: `activation:${memory.id}`, - kind: 'activation-result', - memoryId: memory.id, - text: activationLine(memory), - x: -0.88, - y: top - i * rowStep, - size: 0.026, - color: CYAN, - depth: relevance(memory), - weight: retentionOrScore(memory), - startFrame: i * 2, - revealSpan: 20, - maxWidthEm: 46, + const portrait = isPortrait(); + const rowLimit = portrait ? ROW_LIMIT_PORTRAIT : ROW_LIMIT; + const rows = results.slice(0, rowLimit); + // Phone gets a title + column header and a shorter, generously spaced list; + // desktop keeps the dense edge-to-edge result log exactly as authored. + const top = portrait ? 0.6 : 0.72; + const span = portrait ? 1.28 : 1.5; + const rowStep = span / Math.max(1, rowLimit - 1); + const items: ActivationTextItem[] = []; + + if (portrait) { + items.push({ + id: 'activation:title', + kind: 'activation-title', + text: 'ACTIVATION', + x: -0.62, + y: 0.86, + size: 0.06, + color: TITLE, + depth: 1, + weight: 0.9, + startFrame: REVEAL_ANCHOR, + revealSpan: 1 + }); + items.push({ + id: 'activation:header', + kind: 'activation-header', + text: `MEMORY / RELEVANCE (${Math.min(results.length, rowLimit)})`, + x: -0.62, + y: 0.74, + size: 0.03, + color: HEADER, + depth: 0.9, + weight: 0.5, + startFrame: REVEAL_ANCHOR, + revealSpan: 1 + }); + } + + rows.forEach((memory, i) => { + items.push({ + id: `activation:${memory.id}`, + kind: 'activation-result', + memoryId: memory.id, + text: activationLine(memory, portrait), + x: portrait ? -0.62 : -0.88, + y: top - i * rowStep, + size: portrait ? 0.03 : 0.026, + color: CYAN, + depth: portrait ? Math.max(0.7, relevance(memory)) : relevance(memory), + weight: retentionOrScore(memory), + startFrame: portrait ? REVEAL_ANCHOR + i * 2 : i * 2, + revealSpan: portrait ? 1 : 20, + maxWidthEm: 46, hitPadX: 0.03, hitPadY: 0.015 - })); + }); + }); + return items; } let activationScene: RouteSceneModel = $derived({ @@ -149,6 +218,16 @@ private field: LivingFieldPass; constructor(engine: ObservatoryEngine) { this.field = new LivingFieldPass(engine); + // Landscape/desktop: the field is the hero (bright spread). Portrait: the + // same 0.75 field becomes a blinding wall of blobs the result rows can't be + // read over, so DIM it and open a reading well behind the label column so + // the text sits on a calm backdrop. Both derive from the live aspect only. + if (isPortrait()) { + this.field.setIntensity(0.24); + this.field.setReadingWell({ x: -0.3, y: -0.03, hw: 0.7, hh: 0.9, floor: 0.08, soft: 0.25 }); + } else { + this.field.setIntensity(0.75); + } } uploadScene(scene: RouteSceneModel): void { const nodes = scene.nodes as RouteNode[]; @@ -182,6 +261,7 @@ } function createActivationPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] { + engineRef = engine; // Field FIRST (renders behind the MSDF labels), then the readable text rows. const field = new ActivationFieldPass(engine); field.uploadScene(scene); diff --git a/apps/dashboard/src/routes/(app)/blackbox/+page.svelte b/apps/dashboard/src/routes/(app)/blackbox/+page.svelte index df4ba89..9714907 100644 --- a/apps/dashboard/src/routes/(app)/blackbox/+page.svelte +++ b/apps/dashboard/src/routes/(app)/blackbox/+page.svelte @@ -177,6 +177,10 @@ function createRecorderPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] { const field = new LivingFieldPass(engine); runsField = field; + // Text-heavy organ: the field is a DIM backdrop, and the centered content + // column (max-w-6xl) needs a wide reading well so labels/values stay legible. + field.setIntensity(0.24); + field.setReadingWell({ x: 0, y: 0, hw: 0.66, hh: 0.85, floor: 0.08, soft: 0.25 }); field.setCells(buildRunCells()); const fieldWrapper: RouteFramePass = { compute: (encoder) => field.compute(encoder), @@ -210,7 +214,7 @@ onpick={handleRoutePick} /> -
+

- Memory pulse — touched this run + Memory pulse · touched this run

{#if pulsedIds.length === 0}

No memories touched yet.

@@ -409,7 +413,7 @@
-

Event producers — this run

+

Event producers · this run

  • mcp.call · memory.write · memory.retrieve · memory.suppress @@ -440,7 +444,7 @@ {#if receipts.length}

    - Receipts — proof behind retrievals + Receipts · proof behind retrievals

    {#each receipts.slice(0, 2) as r (r.receipt_id)} @@ -501,6 +505,63 @@
    diff --git a/apps/dashboard/src/routes/(app)/importance/+page.svelte b/apps/dashboard/src/routes/(app)/importance/+page.svelte index 3ad2dba..b41826c 100644 --- a/apps/dashboard/src/routes/(app)/importance/+page.svelte +++ b/apps/dashboard/src/routes/(app)/importance/+page.svelte @@ -20,11 +20,30 @@ const SCARLET = [...rgb01('#FF3B30'), 0.92] satisfies [number, number, number, number]; const MUTED = [...rgb01('#29F2A9'), 0.62] satisfies [number, number, number, number]; const AMBER = [...rgb01('#FFB020'), 0.86] satisfies [number, number, number, number]; + const TITLE = [...rgb01('#EAF3FF'), 0.96] satisfies [number, number, number, number]; + const HEADER = [...rgb01('#7FA0B8'), 0.72] satisfies [number, number, number, number]; const MEMORY_LIMIT = 36; const ROW_LIMIT = 30; + // Phone shows fewer rows so each gets real vertical breathing room instead of a + // 30-deep crushed wall of tiny text. + const ROW_LIMIT_PORTRAIT = 16; const REVEAL_ANCHOR = -100000; const MIN_VISIBLE_DEPTH = 0.62; + // Portrait / narrow viewport, derived from the LIVE engine aspect (params[6]/[7]) + // with a window fallback — same signal TextLayerPass.portraitAdapt uses. Nothing + // is hardcoded to a phone width; desktop (aspect>=0.85) is untouched. + function isPortrait(): boolean { + let vw = engineRef?.params[6] || 0; + let vh = engineRef?.params[7] || 0; + if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') { + vw = window.innerWidth; + vh = window.innerHeight; + } + if (vw <= 0 || vh <= 0) return false; + return vw / vh < 0.85; + } + let hostEl: HTMLDivElement | null = $state(null); let engineRef: ObservatoryEngine | null = null; let textPass: TextLayerPass | null = null; @@ -67,6 +86,11 @@ stopReducedMotion = initReducedMotion(engine); const field = new LivingFieldPass(engine); fieldPass = field; + // Text-heavy organ: keep the field a DIM backdrop so 30 rows of MSDF text + // read cleanly. Rows anchor at x=-0.9 and run rightward (~54em), y from 0.72 + // down to ~-0.78, so the reading well covers the whole left+center column. + field.setIntensity(0.22); + field.setReadingWell({ x: -0.3, y: -0.03, hw: 0.7, hh: 0.88, floor: 0.08, soft: 0.25 }); field.setCells(buildFieldCells()); engine.addPass(field); const pass = new TextLayerPass(engine); @@ -129,8 +153,19 @@ .replace(/[^\x20-\x7E]/g, '?'); } - function importanceLine(record: ImportanceRecord): string { + function importanceLine(record: ImportanceRecord, portrait: boolean): string { const { memory, score } = record; + if (portrait) { + // Phone: drop the id/retention/recommendation/channel columns that turn the + // row into an unreadable wall. Keep the primary content (a longer title + // budget) and the ONE headline metric — composite score — right-aligned by + // the header. `title 92%` reads at a glance; the full detail lives on tap. + const snippet = sanitizeAscii(memory.content ?? '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 34); + return sanitizeAscii(`${snippet} ${Math.round(score.composite * 100)}%`); + } const snippet = sanitizeAscii(memory.content ?? '').replace(/\s+/g, ' ').trim().slice(0, 44); const strongest = strongestChannel(score); return sanitizeAscii( @@ -168,22 +203,58 @@ if (error) return [statusItem(`ERROR - ${error}`.slice(0, 72), SCARLET)]; if (records.length === 0) return [statusItem('EMPTY IMPORTANCE FIELD', MUTED)]; - const rows = records.slice(0, ROW_LIMIT); - const top = 0.72; - const rowStep = 1.5 / Math.max(1, ROW_LIMIT - 1); - return rows.map((record, i) => { + const portrait = isPortrait(); + const rowLimit = portrait ? ROW_LIMIT_PORTRAIT : ROW_LIMIT; + const rows = records.slice(0, rowLimit); + // Phone gets a title + column header and a shorter, more generously spaced + // list; desktop keeps the dense edge-to-edge log exactly as authored. + const top = portrait ? 0.6 : 0.72; + const span = portrait ? 1.28 : 1.5; + const rowStep = span / Math.max(1, rowLimit - 1); + const items: ImportanceTextItem[] = []; + + if (portrait) { + items.push({ + id: 'importance:title', + kind: 'importance-title', + text: 'IMPORTANCE', + x: -0.62, + y: 0.86, + size: 0.06, + color: TITLE, + depth: 1, + weight: 0.9, + startFrame: REVEAL_ANCHOR, + revealSpan: 1 + }); + items.push({ + id: 'importance:header', + kind: 'importance-header', + text: `MEMORY / SCORE (${records.length})`, + x: -0.62, + y: 0.74, + size: 0.03, + color: HEADER, + depth: 0.9, + weight: 0.5, + startFrame: REVEAL_ANCHOR, + revealSpan: 1 + }); + } + + rows.forEach((record, i) => { // depth = crispness/forward channel — floor it or the DOF blur + dim glow // makes low-composite rows invisible (composite is low for most memories). const depth = Math.max(MIN_VISIBLE_DEPTH, clamp01(record.score.composite)); const weight = clamp01(record.memory.retentionStrength); - return { + items.push({ id: `importance:${record.memory.id}`, kind: 'importance', memoryId: record.memory.id, - text: importanceLine(record), - x: -0.9, + text: importanceLine(record, portrait), + x: portrait ? -0.62 : -0.9, y: top - i * rowStep, - size: 0.025, + size: portrait ? 0.03 : 0.025, color: record.score.recommendation === 'save' ? CYAN : AMBER, depth, weight, @@ -196,8 +267,9 @@ maxWidthEm: 54, hitPadX: 0.03, hitPadY: 0.018 - }; + }); }); + return items; } function pointerToNdc(e: PointerEvent | MouseEvent): { x: number; y: number } | null { diff --git a/apps/dashboard/src/routes/(app)/intentions/+page.svelte b/apps/dashboard/src/routes/(app)/intentions/+page.svelte index 6b3c29c..775b463 100644 --- a/apps/dashboard/src/routes/(app)/intentions/+page.svelte +++ b/apps/dashboard/src/routes/(app)/intentions/+page.svelte @@ -19,7 +19,14 @@ related_memories?: string[]; }; type IntentionTextItem = TextLayerItem & { intentionId?: string }; - type PredictedIntent = { id: string; content: string; nodeType: string; predictedNeed: string; retention: number }; + type PredictedIntent = { + id: string; + content: string; + nodeType: string; + predictedNeed: string; + retention: number; + urgency?: number; + }; const CYAN = [...rgb01(CAUSAL.forward), 1] satisfies [number, number, number, number]; const LUCIFERIN = [...rgb01(RETENTION.luciferin), 0.88] satisfies [number, number, number, number]; @@ -38,6 +45,30 @@ let error: string | null = $state(null); let selectedIntentionId: string | null = $state(null); let textPass: TextLayerPass | null = null; + let engineRef: ObservatoryEngine | null = null; + + // Live viewport aspect (canvas px) — same signal TextLayerPass.portraitAdapt + // reads (engine.params[6]/[7]), with a window fallback for the pre-frame-0 + // pass. NEVER a hardcoded phone width; desktop (aspect>=0.85) is untouched. + function viewportAspect(): number { + let vw = engineRef?.params[6] || 0; + let vh = engineRef?.params[7] || 0; + if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') { + vw = window.innerWidth; + vh = window.innerHeight; + } + if (vw <= 0 || vh <= 0) return 1; + return vw / vh; + } + + // Trim to a cap on a word boundary so a portrait row never ends mid-token. + function trimSnippet(text: string, cap: number): string { + const s = sanitizeAscii(text).replace(/\s+/g, ' ').trim(); + if (s.length <= cap) return s; + const hard = s.slice(0, cap); + const lastSpace = hard.lastIndexOf(' '); + return lastSpace > cap * 0.6 ? hard.slice(0, lastSpace) : hard; + } onMount(() => { void loadIntentions(ACTIVE_FILTER); @@ -46,6 +77,7 @@ onDestroy(() => { textPass?.dispose(); textPass = null; + engineRef = null; }); async function loadIntentions(nextFilter = filter) { @@ -70,6 +102,7 @@ } function createIntentionsPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] { + engineRef = engine; const field = new IntentionsFieldPass(engine); field.uploadScene(scene); const pass = new TextLayerPass(engine); @@ -81,13 +114,33 @@ class IntentionsFieldPass implements RouteFramePass { private field: LivingFieldPass; - constructor(engine: ObservatoryEngine) { this.field = new LivingFieldPass(engine); } + private desktop: boolean; + constructor(engine: ObservatoryEngine) { + this.field = new LivingFieldPass(engine); + this.desktop = viewportAspect() >= 0.85; + // Portrait keeps its verified dim backdrop exactly. Desktop can carry a much + // richer field because the reading well protects the intention rows. + this.field.setIntensity(this.desktop ? 1.6 : 0.24); + // On desktop, leave more living field around the reading column while keeping + // the complete row span inside a soft, low-luminance well. + this.field.setReadingWell( + this.desktop + ? { x: -0.2, y: 0.05, hw: 0.58, hh: 0.62, floor: 0.08, soft: 0.18 } + : { x: -0.2, y: 0.05, hw: 0.85, hh: 0.92, floor: 0.08, soft: 0.25 } + ); + } uploadScene(scene: RouteSceneModel): void { const data: FieldDatum[] = scene.nodes.map((node) => ({ id: node.source.id, score: node.activation ?? node.retention, hue: FIELD_HUE.forward, energy: node.activation, metric2: node.retention, selected: node.source.id === selectedIntentionId, kind: 'intention', payload: node })); // RouteStage now picks text chrome (front) before field cells (behind), // so the galaxy can fill without stealing the filter toggle's click. const sparse = data.length < 4; - this.field.setCells(layoutGalaxy(data, { maxRadius: 0.82, minCellR: sparse ? 0.22 : 0.025, maxCellR: sparse ? 0.3 : 0.075 })); + this.field.setCells( + layoutGalaxy(data, { + maxRadius: this.desktop ? 0.9 : 0.82, + minCellR: sparse ? (this.desktop ? 0.56 : 0.22) : 0.025, + maxCellR: sparse ? (this.desktop ? 0.7 : 0.3) : 0.075 + }) + ); } compute(encoder: GPUCommandEncoder): void { this.field.compute(encoder); } render(pass: GPURenderPassEncoder): void { this.field.render(pass); } @@ -138,7 +191,18 @@ } } - function intentionLine(intention: RichIntention): string { + function intentionLine(intention: RichIntention, portrait = false): string { + // Portrait: drop the id + trigger columns and shorten the snippet so the row + // fits the narrow width on one readable line — never edge-to-edge, never + // truncated mid-word. Desktop keeps the full, byte-identical row. + if (portrait) { + // Word-boundary trim so the row never ends mid-token. Cap sized so the + // snippet + the trailing " pN status" tag still fits the narrow portrait + // width on one line (verified live at 360 and 390). + const content = trimSnippet(intention.content, 34); + const status = sanitizeAscii(intention.status).slice(0, 8); + return sanitizeAscii(`${content} p${intention.priority} ${status}`); + } const content = sanitizeAscii(intention.content).replace(/\s+/g, ' ').trim().slice(0, 48); const status = sanitizeAscii(intention.status).slice(0, 12); const trigger = summarizeTrigger(intention); @@ -165,7 +229,31 @@ // A dedicated, explicit filter toggle (active <-> all). Selecting an intention // must NOT silently flip the global filter — that lives here as its own target. - function filterToggleItem(): IntentionTextItem { + function filterToggleItem(portrait = false): IntentionTextItem { + if (portrait) { + // Portrait: a readable, centred heading pinned to the top band. It owns its + // OWN mobile layout (portraitAdapt treats intention-filter as body text, so + // author in the reclaimed-y space; the near-centre x keeps it on-screen after + // the size boost). Drop the "SHOWING:" prefix — the shorter label leaves a + // real right-edge margin at 360 so the closing bracket never clips. + const portraitText = + filter === ACTIVE_FILTER ? '[ ACTIVE - TAP FOR ALL ]' : '[ ALL - TAP FOR ACTIVE ]'; + return { + id: 'intentions:filter', + kind: 'intention-filter', + text: portraitText, + x: -0.5, + y: 0.86, + size: 0.03, + color: AMBER, + depth: 1, + weight: 0.7, + revealSpan: 12, + maxWidthEm: 34, + hitPadX: 0.06, + hitPadY: 0.04 + }; + } return { id: 'intentions:filter', kind: 'intention-filter', @@ -189,6 +277,99 @@ function buildTextItems(): IntentionTextItem[] { if (loading) return [statusItem('LOADING INTENTION FIELD...', CYAN)]; if (error) return [statusItem(`ERROR - ${error}`.slice(0, 72), SCARLET)]; + + const portrait = viewportAspect() < 0.85; + + if (portrait) { + // Phone plan: ONE focal heading + a short, well-spaced column with real + // negative space. Row count derives from the LIVE aspect (taller/narrower + // -> fewer rows), never a fixed phone number. When the field is empty or + // tiny, a content-first focal message tells a first-timer what they're + // looking at instead of leaving a black void. + // Instant reveal on portrait: the MSDF reveal gate bumps ageFrame by + // (globalGlyphIndex*2) and discards glyphs whose ageFrame exceeds the frame + // loop, so a per-row startFrame leaves long portrait rows half-drawn ("...is + // li"). A large negative startFrame + revealSpan 1 saturates reveal to 1 on + // frame 0 so EVERY row renders in full immediately. Same trick as schedule. + const REVEAL = -100000; + const items: IntentionTextItem[] = [filterToggleItem(true)]; + if (intentions.length === 0) { + items.push({ + id: 'intentions:empty', + kind: 'intention-status', + text: 'NO ACTIVE INTENTIONS', + x: -0.5, + y: 0.18, + size: 0.04, + color: MUTED, + depth: 0.9, + weight: 0.6, + startFrame: REVEAL, + revealSpan: 1, + maxWidthEm: 22 + }); + items.push({ + id: 'intentions:empty-sub', + kind: 'intention-status', + text: 'Vestige is watching for triggers. Tap the heading to see all.', + x: -0.72, + y: 0.02, + size: 0.026, + color: MUTED, + depth: 0.78, + weight: 0.5, + startFrame: REVEAL, + revealSpan: 1, + maxWidthEm: 30 + }); + return items; + } + const aspect = viewportAspect(); + const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.42)); + const rowCount = Math.max(6, Math.round(10 - 4 * portraitness)); + const rows = intentions.slice(0, rowCount); + // A count line so a single-row field reads as "1 of 1", not a lone stray. + items.push({ + id: 'intentions:count', + kind: 'intention-status', + text: `${intentions.length} ACTIVE FOCUS${intentions.length === 1 ? '' : 'ES'}`, + x: -0.62, + y: 0.66, + size: 0.026, + color: MUTED, + depth: 0.8, + weight: 0.5, + startFrame: REVEAL, + revealSpan: 1, + maxWidthEm: 28 + }); + const top = 0.5; + const bottom = -0.7; + const rowStep = rows.length > 1 ? (top - bottom) / (rows.length - 1) : 0; + for (let i = 0; i < rows.length; i++) { + const intention = rows[i]; + const active = selectedIntentionId === intention.id; + items.push({ + id: `intent:${intention.id}`, + kind: 'intention', + intentionId: intention.id, + text: intentionLine(intention, true), + x: -0.82, + y: top - i * rowStep, + size: active ? 0.032 : 0.03, + color: active ? LUCIFERIN : CYAN, + depth: active ? 1 : Math.max(0.7, intentionDepth(intention)), + weight: statusWeight(intention), + startFrame: REVEAL, + revealSpan: 1, + maxWidthEm: 34, + hitPadX: 0.05, + hitPadY: 0.03 + }); + } + return items; + } + if (intentions.length === 0) return [filterToggleItem(), statusItem(`EMPTY ${filter.toUpperCase()} INTENTION FIELD`, MUTED)]; const items: IntentionTextItem[] = [filterToggleItem()]; @@ -238,7 +419,16 @@ index: intentions.length + offset, label: sanitizeAscii(prediction.content).slice(0, 48), retention: clamp01(prediction.retention), - activation: prediction.predictedNeed === 'high' ? 1 : prediction.predictedNeed === 'medium' ? 0.65 : 0.35, + // Real continuous urgency (FSRS decay + review schedule) drives brightness. + // Fall back to the high/medium/low band only if an older backend omits it. + activation: + typeof prediction.urgency === 'number' + ? clamp01(prediction.urgency) + : prediction.predictedNeed === 'high' + ? 1 + : prediction.predictedNeed === 'medium' + ? 0.65 + : 0.35, trust: clamp01(prediction.retention), tags: [prediction.predictedNeed], type: prediction.nodeType diff --git a/apps/dashboard/src/routes/(app)/memories/+page.svelte b/apps/dashboard/src/routes/(app)/memories/+page.svelte index 16a9b75..db47e7b 100644 --- a/apps/dashboard/src/routes/(app)/memories/+page.svelte +++ b/apps/dashboard/src/routes/(app)/memories/+page.svelte @@ -71,6 +71,7 @@ // glowing cell, retention = oxygen. The cursor-parallax text field rides on top. const field = new LivingFieldPass(engine); fieldPass = field; + field.setIntensity(0.8); field.setCells(buildFieldCells()); engine.addPass(field); const pass = new TextLayerPass(engine); @@ -127,11 +128,39 @@ .replace(/[^\x20-\x7E]/g, '?'); } - function memoryLine(memory: Memory): string { - const snippet = sanitizeAscii(memory.content).replace(/\s+/g, ' ').trim().slice(0, 52); - return sanitizeAscii( - `${snippet} | ${memory.id.slice(0, 8)} | ${Math.round(memory.retentionStrength * 100)}%` - ); + // Live viewport aspect (canvas px) — same source portraitAdapt reads, never a + // hardcoded phone width. Falls back to the window before frame 0, then 1. + function viewportAspect(): number { + const vw = engineRef?.params[6] || 0; + const vh = engineRef?.params[7] || 0; + if (vw > 0 && vh > 0) return vw / vh; + if (typeof window !== 'undefined' && window.innerHeight > 0) { + return window.innerWidth / window.innerHeight; + } + return 1; + } + + // Portrait rows must READ, not dump. A phone can only fit ~10-12 rows with the + // generous spacing the taste bar demands, and long lines would shrink to + // illegible to fit width. So on portrait we shorten each line (drop the id + // column, tighten the snippet) so it never runs edge-to-edge, and the caller + // caps the row count. Everything is gated on the LIVE aspect — desktop keeps + // the full id column and long snippet, byte-identical. + // Trim a snippet to a cap, breaking on the last word boundary near the cap so a + // portrait row never ends mid-token ("Jul 202"); falls back to a hard slice for + // a single unbroken token. + function trimSnippet(text: string, cap: number): string { + const s = sanitizeAscii(text).replace(/\s+/g, ' ').trim(); + if (s.length <= cap) return s; + const hard = s.slice(0, cap); + const lastSpace = hard.lastIndexOf(' '); + return lastSpace > cap * 0.6 ? hard.slice(0, lastSpace) : hard; + } + + function memoryLine(memory: Memory, portrait: boolean): string { + const pct = `${Math.round(memory.retentionStrength * 100)}%`; + if (portrait) return sanitizeAscii(`${trimSnippet(memory.content, 28)} ${pct}`); + return sanitizeAscii(`${trimSnippet(memory.content, 52)} | ${memory.id.slice(0, 8)} | ${pct}`); } function clamp01(value: number): number { @@ -159,6 +188,64 @@ if (error) return [statusItem(`ERROR - ${error}`.slice(0, 72), SCARLET)]; if (memories.length === 0) return [statusItem('EMPTY MEMORY FIELD', MUTED)]; + const aspect = viewportAspect(); + const portrait = aspect < 0.85; + + if (portrait) { + // Phone plan: ONE focal header + a short, well-spaced column that fits the + // screen band with real negative space. Row count and spacing derive from + // the live aspect (taller/narrower → fewer rows), never a fixed phone + // number. portraitAdapt maps authored-y straight to screen-y (its inv + // reclaim and the shader's aspect crush cancel), so we author in screen NDC. + const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.42)); + // 12 rows at the wide-portrait edge, down to 9 on the tallest phones — + // enough to be a real list, few enough to breathe and never collide. + const rowCount = Math.max(9, Math.round(12 - 3 * portraitness)); + const rows = memories.slice(0, rowCount); + const headerY = 0.8; + const top = 0.62; + const bottom = -0.72; + const rowStep = rows.length > 1 ? (top - bottom) / (rows.length - 1) : 0; + const header: MemoryTextItem = { + id: 'memories:header', + kind: 'memory-header', + text: `MEMORY FIELD ${total} TRACES`, + x: -0.82, + y: headerY, + size: 0.03, + color: MUTED, + depth: 0.85, + weight: 0.6, + startFrame: REVEAL_ANCHOR, + revealSpan: 24, + maxWidthEm: 30 + }; + return [ + header, + ...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, true), + x: -0.82, + y: top - i * rowStep, + size: 0.03, + color: CYAN, + depth: Math.max(MIN_VISIBLE_DEPTH, retrieval), + weight: retention, + startFrame: REVEAL_ANCHOR + i * 2, + revealSpan: 20, + maxWidthEm: 34, + hitPadX: 0.04, + hitPadY: 0.03 + } satisfies MemoryTextItem; + }) + ]; + } + const rows = memories.slice(0, ROW_LIMIT); const top = 0.72; const rowStep = 1.5 / Math.max(1, ROW_LIMIT - 1); @@ -169,7 +256,7 @@ id: `mem:${memory.id}`, kind: 'memory', memoryId: memory.id, - text: memoryLine(memory), + text: memoryLine(memory, false), x: -0.88, y: top - i * rowStep, size: 0.026, diff --git a/apps/dashboard/src/routes/(app)/memory-prs/+page.svelte b/apps/dashboard/src/routes/(app)/memory-prs/+page.svelte index fb0c941..9a01c5f 100644 --- a/apps/dashboard/src/routes/(app)/memory-prs/+page.svelte +++ b/apps/dashboard/src/routes/(app)/memory-prs/+page.svelte @@ -9,6 +9,11 @@ import { TextLayerPass, type TextLayerItem } from '$lib/observatory/text/text-layer'; import { LivingFieldPass } from '$lib/observatory/field/living-field-pass'; import { layoutGalaxy, FIELD_HUE, type FieldDatum } from '$lib/observatory/field/cell-layout'; + import PageHeader from '$lib/components/PageHeader.svelte'; + import Icon from '$lib/components/Icon.svelte'; + import AnimatedNumber from '$lib/components/AnimatedNumber.svelte'; + import { reveal } from '$lib/actions/reveal'; + import { spotlight } from '$lib/actions/interactions'; type MemoryPrTextItem = TextLayerItem & { prId?: string }; type WhySignal = { code: string; detail: string }; @@ -31,6 +36,36 @@ let whySignals: WhySignal[] = $state([]); let loading = $state(true); let error: string | null = $state(null); + // The DOM row the user last asked "why" about — so the returned risk signals + // render against a concrete PR, not a floating panel with no anchor. + let whyForPrId: string | null = $state(null); + + // PORTRAIT GATE — everything below is gated on the LIVE viewport aspect so the + // desktop (landscape, aspect>=0.85) render stays byte-identical zero-DOM: no DOM + // overlay, full-strength in-canvas PR field. On a phone (portrait, aspect<0.85) + // the in-canvas log wall is illegible, so we surface a readable DOM overlay AND + // dim the field to a pure backdrop. Threshold matches TextLayerPass.portraitAdapt. + let isPortrait = $state(false); + onMount(() => { + const update = () => { + isPortrait = window.innerWidth / Math.max(1, window.innerHeight) < 0.85; + }; + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }); + + const pendingCount = $derived(prs.filter((pr) => pr.status === 'pending').length); + // Cap the readable DOM list to the same window the field renders so the two + // stay in sync and the scroll stays bounded on a phone. + const domRows = $derived(prs.slice(0, ROW_LIMIT)); + + function prStatusTone(status: string): string { + if (status === 'pending') return 'text-warning border-warning/30 bg-warning/10'; + if (status === 'approved' || status === 'promoted') return 'text-recall border-recall/25 bg-recall/10'; + if (status === 'rejected' || status === 'forgotten') return 'text-decay border-decay/25 bg-decay/10'; + return 'text-dim border-white/10 bg-white/[0.04]'; + } onMount(() => { void loadPrs(); @@ -97,6 +132,15 @@ .slice(0, 96); } + // In PORTRAIT the readable DOM overlay is the hero, so the in-canvas PR rows must + // recede to a faint ambient substrate (they'd otherwise be an illegible wall of + // log text competing with the DOM copy on top). In LANDSCAPE (desktop) the + // in-canvas text IS the content, so it keeps full strength — desktop unchanged. + function dim(color: [number, number, number, number]): [number, number, number, number] { + if (!isPortrait) return color; + return [color[0], color[1], color[2], color[3] * 0.34]; + } + function buildTextItems(): MemoryPrTextItem[] { const rows = prs.slice(0, ROW_LIMIT); const top = 0.74; @@ -109,7 +153,7 @@ x: -0.9, y: top - i * rowStep, size: 0.025, - color: pr.status === 'pending' ? CYAN : MUTED, + color: dim(pr.status === 'pending' ? CYAN : MUTED), depth: confidenceDepth(pr), weight: 1, startFrame: REVEAL_ANCHOR + i * 2, @@ -126,7 +170,7 @@ x: -0.82, y: -0.76 - i * 0.052, size: 0.02, - color: AMBER, + color: dim(AMBER), depth: 0.72, weight: 0.8, startFrame: REVEAL_ANCHOR + (rows.length + i) * 2, @@ -159,24 +203,57 @@ alive: prs.length > 0 }); + // Live handles so the portrait $effect can re-dim the field and re-push the + // (portrait-dimmed) in-canvas text when the viewport aspect crosses the gate. + let fieldPass: MemoryPrFieldPass | null = null; + let textPass: TextLayerPass | null = null; + + $effect(() => { + // Re-apply the portrait/landscape backdrop treatment whenever the gate flips. + const portrait = isPortrait; + fieldPass?.applyBackdrop(portrait); + textPass?.setText(buildTextItems()); + }); + function createMemoryPrPasses(engine: ObservatoryEngine, scene: RouteSceneModel): RouteFramePass[] { const field = new MemoryPrFieldPass(engine); + field.applyBackdrop(isPortrait); field.uploadScene(scene); + fieldPass = field; const text = new TextLayerPass(engine); + textPass = text; void text.init().then(() => text.setText(buildTextItems())); return [field, { render: (pass) => text.render(pass), uploadScene: () => text.setText(buildTextItems()), pickAt: (x, y) => text.pickAt(x, y), - dispose: () => text.dispose() + dispose: () => { + if (textPass === text) textPass = null; + text.dispose(); + } } ]; } class MemoryPrFieldPass implements RouteFramePass { private field: LivingFieldPass; - constructor(engine: ObservatoryEngine) { this.field = new LivingFieldPass(engine); } + constructor(engine: ObservatoryEngine) { + this.field = new LivingFieldPass(engine); + } + // PORTRAIT: the readable hero is the DOM overlay, so the field recedes to a + // faint full-frame ambient substrate behind the cards. LANDSCAPE (desktop): + // the in-canvas PR queue IS the content, so keep the original intensity + the + // left-column reading well that kept those rows legible — desktop unchanged. + applyBackdrop(portrait: boolean): void { + if (portrait) { + this.field.setIntensity(0.14); + this.field.setReadingWell({ x: 0, y: 0, hw: 1.0, hh: 1.0, floor: 0.05, soft: 0.35 }); + } else { + this.field.setIntensity(0.22); + this.field.setReadingWell({ x: -0.2, y: -0.1, hw: 0.78, hh: 0.9, floor: 0.06, soft: 0.25 }); + } + } uploadScene(scene: RouteSceneModel): void { const data: FieldDatum[] = scene.nodes.map((node) => ({ id: node.source.id, score: node.activation ?? 0.5, hue: FIELD_HUE.caution, energy: node.activation, metric2: node.trust, scar: (node.tags?.length ?? 0) > 1, kind: 'memory-pr', payload: node })); this.field.setCells(layoutGalaxy(data, { maxRadius: 0.9, minCellR: 0.035, maxCellR: 0.09 })); @@ -184,7 +261,10 @@ compute(encoder: GPUCommandEncoder): void { this.field.compute(encoder); } render(pass: GPURenderPassEncoder): void { this.field.render(pass); } pickAt(x: number, y: number): RoutePick | null { return this.field.pickAt(x, y); } - dispose(): void { this.field.dispose(); } + dispose(): void { + if (fieldPass === this) fieldPass = null; + this.field.dispose(); + } } async function handleRoutePick(pick: RoutePick) { @@ -195,6 +275,11 @@ const payload = pick.payload as Partial & { source?: { id?: string } }; const prId = payload.prId ?? payload.source?.id; if (!prId) return; + await askWhy(prId); + } + + async function askWhy(prId: string) { + whyForPrId = prId; try { const res = (await api.memoryPrs.act(prId, 'ask_agent_why')) as { why?: WhySignal[] }; whySignals = res.why ?? []; @@ -218,3 +303,136 @@ emptyLabel="NO MEMORY PRS" onpick={handleRoutePick} /> + + +{#if isPortrait} +
    + +
    + + + Live + +
    + + +
    + {#if loading} + + Loading review queue… + {:else if error} + + Queue unavailable + {:else} + + + + {prs.length === 1 ? 'PR' : 'PRs'} + · pending review + + {/if} +
    + + + {#if error} +
    +
    Couldn't load memory PRs
    +
    {error}
    + +
    + {:else if loading} +
    + {#each Array(4) as _} +
    + {/each} +
    + {:else if domRows.length === 0} +
    +
    + +
    +
    No pending memory PRs.
    +
    + Every proposed change has been reviewed. New facts and supersessions will queue here + for your approval before they touch the graph. +
    +
    + {:else} +
    + {#each domRows as pr, i (pr.id)} + + {/each} +
    + {/if} +
    +{/if} diff --git a/apps/dashboard/src/routes/(app)/observatory/+page.svelte b/apps/dashboard/src/routes/(app)/observatory/+page.svelte index 1ff73d7..bd475a9 100644 --- a/apps/dashboard/src/routes/(app)/observatory/+page.svelte +++ b/apps/dashboard/src/routes/(app)/observatory/+page.svelte @@ -58,6 +58,28 @@ // of the recall-path scene. Then the MSDF text HUD on top of that. const field = new LivingFieldPass(engine); fieldPass = field; + // The "living nervous system" home: the field stays ALIVE (0.60) but a reading + // well dims it under the instrument overlay so the nav labels + HUD read. The + // well covers the left nav column (RECALL/ENGRAM/... at x=-0.91) and the + // right telemetry (NODES/EDGES at x~0.4), the two text regions. + // + // Portrait phones: the HUD collapses to ONE centred vertical stack + // (buildPortraitItems), so the desktop left-side well would miss it and the + // bright bloom core would sit right behind the text. On portrait, dim the + // whole field harder (backdrop, not a blob) and centre a tall well over the + // stack. Everything derives from the live aspect — desktop is untouched. + const aspect = portraitAspect(); + if (aspect !== null) { + const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.46)); + field.setIntensity(0.32 - 0.1 * portraitness); + // The centred stack's labels run left-anchored to the right, so bias the + // well slightly right and widen it to cover the full label extent — the + // recall wavefront sweeping the right side must stay UNDER the text. + field.setReadingWell({ x: 0.05, y: 0.05, hw: 0.82, hh: 0.95, floor: 0.04, soft: 0.3 }); + } else { + field.setIntensity(0.6); + field.setReadingWell({ x: -0.55, y: 0.1, hw: 0.5, hh: 0.75, floor: 0.08, soft: 0.25 }); + } field.setCells(buildFieldCells()); engine.addPass(field); const pass = new TextLayerPass(engine); @@ -151,7 +173,131 @@ }; } + /** + * Live viewport aspect from the engine params (canvas px) with a window + * fallback — the SAME source TextLayerPass.portraitAdapt reads. Returns the + * aspect only when genuinely portrait/narrow (aspect < 0.85); null otherwise. + * Nothing is hardcoded per phone width; the whole portrait layout scales off + * this one live number, so desktop (>=0.85) is byte-identical. + */ + function portraitAspect(): number | null { + let vw = engineRef?.params[6] || 0; + let vh = engineRef?.params[7] || 0; + if ((vw <= 0 || vh <= 0) && typeof window !== 'undefined') { + vw = window.innerWidth; + vh = window.innerHeight; + } + if (vw <= 0 || vh <= 0) return null; + const aspect = vw / vh; + return aspect < 0.85 ? aspect : null; + } + + /** + * Portrait HUD: the desktop layout is FOUR competing columns (nav at x=-0.91, + * receipts at x=-0.67, telemetry at x=0.39, exit at x=0.75). On a phone the + * shared portraitAdapt pulls them all toward centre, so they overprint (the + * "DENTER"/FIREWALL collisions). Instead, author ONE readable vertical stack: + * a centred demo-mode list, a compact telemetry block below it, and EXIT + * pinned top — no side-by-side columns, no dense receipt lines running off the + * right edge. Everything is authored centred (x~0) so portraitAdapt's x-pull + * barely moves it; the y bands are spaced so nothing shares a row. + */ + function buildPortraitItems(aspect: number): ObservatoryTextItem[] { + const items: ObservatoryTextItem[] = []; + const graph = graphData; + const nodeDepth = graph ? graphMetric(graph.nodeCount, Math.max(1, graph.nodes.length, 200)) : 0.5; + const edgeWeight = graph ? graphMetric(graph.edgeCount, Math.max(1, graph.nodeCount * 8)) : 0.5; + + // MSDF labels are LEFT-anchored at their x, so an x of 0 sits the column + // right-of-centre. Left-shift the whole stack by a portraitness-scaled + // amount (bigger shift on the narrowest phones) so the longest label + // ("CENTER c5a42e31-c5f") reads visually centred. portraitAdapt scales x by + // (1-xPull); pre-divide so the on-screen anchor lands where we want. + const portraitness = clamp01((0.85 - aspect) / (0.85 - 0.46)); + const anchorX = -0.34 - 0.06 * portraitness; + + // EXIT — top of the stack. + items.push({ + id: 'observatory:exit', + kind: 'observatory-exit', + action: 'exit', + text: sanitizeAscii('EXIT'), + x: anchorX, + y: 0.86, + size: 0.03, + color: AMBER, + depth: 1, + weight: edgeWeight, + revealSpan: 10, + maxWidthEm: 12, + hitPadX: 0.08, + hitPadY: 0.05 + }); + + // Demo-mode list — the primary interactive column, centred, generously + // spaced so touch targets don't crowd. This is the ONE focal point. + const navTop = 0.66; + const navStep = 0.15; + DEMO_MODES.forEach((mode, i) => { + items.push({ + id: `observatory:demo:${mode}`, + kind: 'observatory-demo', + action: 'demo', + demo: mode, + text: demoLabel(mode), + x: anchorX, + y: navTop - i * navStep, + size: 0.032, + color: mode === demo ? OXYGEN : GREEN, + depth: mode === demo ? 1 : nodeDepth, + weight: mode === demo ? 0.9 : edgeWeight, + startFrame: i * 2, + revealSpan: 14, + maxWidthEm: 18, + hitPadX: 0.14, + hitPadY: 0.05 + }); + }); + + if (loading) return [...items, statusItem('LOADING MEMORY FIELD...', CYAN)]; + if (error) return [...items, statusItem(`ERROR - ${error}`.slice(0, 60), SCARLET)]; + if (!graph || graph.nodeCount === 0) return [...items, statusItem('NO MEMORIES IN FIELD', GREEN)]; + + // Telemetry — a compact block BELOW the nav list (not a right-hand column), + // so it never shares a row with anything. Short labels only; the long + // receipt lines that overflowed the right edge on desktop are dropped on + // portrait (desktop density a phone can't read). + const telemetry = [ + `NODES ${graph.nodeCount}`, + `EDGES ${graph.edgeCount}`, + `DEPTH ${graph.depth}`, + `CENTER ${graph.center_id.slice(0, 12)}` + ]; + const telTop = navTop - DEMO_MODES.length * navStep - 0.06; + const telStep = 0.075; + telemetry.forEach((text, i) => { + items.push({ + id: `observatory:telemetry:${i}`, + kind: 'observatory-telemetry', + text: sanitizeAscii(text), + x: anchorX, + y: telTop - i * telStep, + size: 0.024, + color: CYAN, + depth: 0.9, + weight: edgeWeight, + startFrame: 8 + i * 2, + revealSpan: 10, + maxWidthEm: 24 + }); + }); + return items; + } + function buildTextItems(): ObservatoryTextItem[] { + const aspect = portraitAspect(); + if (aspect !== null) return buildPortraitItems(aspect); + const items: ObservatoryTextItem[] = []; const graph = graphData; const nodeDepth = graph ? graphMetric(graph.nodeCount, Math.max(1, graph.nodes.length, 200)) : 0.5; diff --git a/apps/dashboard/src/routes/(app)/palace/+page.svelte b/apps/dashboard/src/routes/(app)/palace/+page.svelte index dd850f5..2bda75c 100644 --- a/apps/dashboard/src/routes/(app)/palace/+page.svelte +++ b/apps/dashboard/src/routes/(app)/palace/+page.svelte @@ -118,8 +118,24 @@ ]; } + // Live viewport aspect (canvas px), same source portraitAdapt reads — never a + // hardcoded phone width. Falls back to the window before frame 0, then 1. + function viewportAspect(): number { + const vw = engineRef?.params[6] || 0; + const vh = engineRef?.params[7] || 0; + if (vw > 0 && vh > 0) return vw / vh; + if (typeof window !== 'undefined' && window.innerHeight > 0) { + return window.innerWidth / window.innerHeight; + } + return 1; + } + function buildAllText(): TextLayerItem[] { - const labels = buildOrganLabels(toOrganPositions(), { hoveredHref, dimUnhovered: !!hoveredHref }); + const labels = buildOrganLabels(toOrganPositions(), { + hoveredHref, + dimUnhovered: !!hoveredHref, + aspect: viewportAspect() + }); return [...hudLines(), ...labels]; } diff --git a/apps/dashboard/src/routes/(app)/patterns/+page.svelte b/apps/dashboard/src/routes/(app)/patterns/+page.svelte index 497b2f2..80a7cc9 100644 --- a/apps/dashboard/src/routes/(app)/patterns/+page.svelte +++ b/apps/dashboard/src/routes/(app)/patterns/+page.svelte @@ -1,6 +1,8 @@ @@ -188,10 +227,59 @@ passes={createReasoningPasses} loading={loading} error={error} - emptyLabel="ASK A QUESTION - PRESS CMD+K - WATCH THE DECISION FORM" + {emptyLabel} onpick={handleRoutePick} /> + +{#if portrait && !reasoningScene && !loading && !error} + +{/if} +