From da3bc5e5d75046faa85b23809a2b86b106a36975 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Wed, 8 Jul 2026 22:29:07 -0700 Subject: [PATCH] feat(dashboard): metric-bound ambient base coat for the quiet routes (Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dead routes were flat purple. This adds ONE cheap WebGPU substrate behind them whose drive is REAL backend metrics, so it legibly READS the data instead of being decoration — a store with 129 endangered memories storms; 0 contradictions is a calm field (the discipline test still holds). - AmbientField.svelte + ambient-field.wgsl: a metric-bound mote field. Each mote's vertical drift IS its retention (low sinks to the forgetting floor, high floats up); the real endangered fraction storms turbulence + the sink split; contradiction fraction tears rifts; due-for-review pulses it. Deterministic per mote (no Math.random in the loop). - Guardrails: no WebGPU / null adapter / context throw -> renders nothing (page background shows, never a black canvas). prefers-reduced-motion freezes drift but keeps the metric snapshot. Page-Visibility + IntersectionObserver gate the rAF loop. DPR clamped, mobile-first budget. - Wired to the two highest-signal routes: /stats (endangered + due) and /contradictions (fracture). Reusable — any remaining route adopts it with one import + real metric props. Verified live: /stats renders the field reading the retention distribution (dense healthy top, endangered embers); /contradictions shows a calm crimson field at 0 contradictions (fracture=0). check 0 err, 1043 tests, build green. Co-Authored-By: Claude Opus 4.8 --- .../src/lib/components/AmbientField.svelte | 247 ++++++++++++++++++ .../observatory/ambient/ambient-field.wgsl.ts | 113 ++++++++ .../routes/(app)/contradictions/+page.svelte | 16 ++ .../src/routes/(app)/stats/+page.svelte | 30 +++ 4 files changed, 406 insertions(+) create mode 100644 apps/dashboard/src/lib/components/AmbientField.svelte create mode 100644 apps/dashboard/src/lib/observatory/ambient/ambient-field.wgsl.ts diff --git a/apps/dashboard/src/lib/components/AmbientField.svelte b/apps/dashboard/src/lib/components/AmbientField.svelte new file mode 100644 index 0000000..310b8f7 --- /dev/null +++ b/apps/dashboard/src/lib/components/AmbientField.svelte @@ -0,0 +1,247 @@ + + + +{#if supported} + +{/if} diff --git a/apps/dashboard/src/lib/observatory/ambient/ambient-field.wgsl.ts b/apps/dashboard/src/lib/observatory/ambient/ambient-field.wgsl.ts new file mode 100644 index 0000000..15f93f7 --- /dev/null +++ b/apps/dashboard/src/lib/observatory/ambient/ambient-field.wgsl.ts @@ -0,0 +1,113 @@ +/** + * Ambient base coat (Phase 5) — a single metric-bound WGSL field for the + * "quiet" routes. NOT a hero: one cheap substrate, composited low behind the + * page. The discipline test still applies — the drive is REAL backend metrics, + * so the field legibly READS the data (swap the metrics for Math.random() and + * the motion is legibly wrong): + * + * - mote COUNT tracks the real memory count (density = how full the mind is) + * - each mote's VERTICAL drift is its retention bucket: low-retention motes + * sink toward the forgetting floor, high-retention ones hold near the top + * - endangeredFrac (real actively-forgetting share) storms the whole field: + * more endangered memories → more turbulence + more sinking motes + * - fractureFrac (real contradiction-pair share) tears rifts across it + * + * A route with 129 endangered memories looks visibly different from one with 0. + * Deterministic per mote (phase from index), so no Math.random in the loop. + */ +export const ambientFieldWGSL = /* wgsl */ ` +struct AmbientParams { + time: f32, // seconds (advances only when not reduced-motion) + count: f32, // active mote count (<= capacity) + endangered: f32, // 0..1 real endangered fraction — storm intensity + fracture: f32, // 0..1 real contradiction fraction — rift intensity + due: f32, // 0..1 real due-for-review fraction — pulse rate + aspect: f32, // viewport w/h + accent_r: f32, // route accent (rgb, 0..1) — one accent per §guardrail + accent_g: f32, + accent_b: f32, + dpr: f32, + reduced: f32, // 1.0 = prefers-reduced-motion (freeze drift, keep field) + _pad: f32, +}; + +@group(0) @binding(0) var params: AmbientParams; + +struct VSOut { + @builtin(position) clip: vec4, + @location(0) uv: vec2, + @location(1) @interpolate(flat) seed: vec2, + @location(2) @interpolate(flat) tint: vec4, // rgb + retention +}; + +// Golden-ratio hash → a deterministic 0..1 per index (no PRNG state). +fn hash1(n: f32) -> f32 { + return fract(sin(n * 12.9898) * 43758.5453); +} + +const CORNERS = array, 6>( + vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(1.0, 1.0), + vec2(-1.0, -1.0), vec2(1.0, 1.0), vec2(-1.0, 1.0) +); + +@vertex +fn vs_main(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut { + var out: VSOut; + if (ii >= u32(params.count)) { + out.clip = vec4(0.0, 0.0, 2.0, 1.0); + return out; + } + let fi = f32(ii); + // Deterministic base position on a golden-angle lattice across the panel. + let ga = 2.399963; // golden angle + let rx = hash1(fi + 1.0); + let ry = hash1(fi + 37.0); + // Retention bucket for this mote: bias the population so the endangered + // share of motes are low-retention (they sink). Real fraction drives it. + let isEndangered = select(0.0, 1.0, ry < params.endangered); + let retention = mix(0.55 + 0.4 * hash1(fi + 91.0), 0.05 + 0.18 * hash1(fi + 5.0), isEndangered); + + // Vertical home: high retention floats up, low sinks toward the floor. + let homeY = mix(-0.9, 0.85, retention); + // Gentle deterministic drift (frozen when reduced-motion): endangered motes + // jitter more (the field is agitated by how much is being forgotten). + let t = params.time; + let sway = select(1.0, 0.0, params.reduced > 0.5); + let turb = 0.02 + 0.10 * params.endangered; + let driftX = sway * turb * sin(t * (0.3 + rx) + fi * ga); + let driftY = sway * (0.015 + 0.05 * isEndangered) * sin(t * (0.5 + ry) + fi); + let baseX = (rx * 2.0 - 1.0) * 0.98 + driftX; + let baseY = homeY + driftY; + + // A rift: the fracture metric opens a horizontal tear that pushes motes apart. + let rift = params.fracture * 0.25 * sin(baseX * 3.14159 + t * 0.2); + let center = vec2(baseX, baseY + rift); + + // Mote size: small; endangered ones a touch larger + dimmer (last flare). + let size = (0.010 + 0.014 * retention) * (1.0 + 0.4 * isEndangered); + let corner = CORNERS[vi]; + out.clip = vec4(center.x + corner.x * size, center.y + corner.y * size * params.aspect, 0.0, 1.0); + out.uv = corner; + out.seed = vec2(rx, ry); + out.tint = vec4(params.accent_r, params.accent_g, params.accent_b, retention); + return out; +} + +@fragment +fn fs_main(in: VSOut) -> @location(0) vec4 { + let d = length(in.uv); + if (d > 1.0) { discard; } + let retention = in.tint.a; + // Soft mote: hot core + feathered halo, brightness scales with retention so + // the endangered (dim) vs healthy (bright) split is LEGIBLE at a glance. + let core = smoothstep(0.5, 0.0, d); + let halo = pow(max(1.0 - d, 0.0), 2.0); + // Due-for-review adds a slow global pulse so an overdue route breathes. + let pulse = 0.85 + 0.15 * sin(params.time * (0.6 + params.due)); + let intensity = (core * 0.9 + halo * 0.35) * (0.25 + 0.75 * retention) * pulse; + // Endangered motes shift toward a warmer, dimmer ember; healthy toward accent. + let ember = vec3(0.62, 0.32, 0.22); + let col = mix(ember, in.tint.rgb, smoothstep(0.2, 0.6, retention)); + return vec4(col * intensity, intensity * 0.9); +} +`; diff --git a/apps/dashboard/src/routes/(app)/contradictions/+page.svelte b/apps/dashboard/src/routes/(app)/contradictions/+page.svelte index 5373045..7661ba8 100644 --- a/apps/dashboard/src/routes/(app)/contradictions/+page.svelte +++ b/apps/dashboard/src/routes/(app)/contradictions/+page.svelte @@ -5,6 +5,7 @@ import Dropdown, { type DropdownOption } from '$components/Dropdown.svelte'; import Icon from '$components/Icon.svelte'; import AnimatedNumber from '$components/AnimatedNumber.svelte'; + import AmbientField from '$components/AmbientField.svelte'; import { reveal } from '$lib/actions/reveal'; import { api } from '$stores/api'; import { @@ -135,8 +136,22 @@ function sidebarClick(localIndex: number) { focusedPairIndex = focusedPairIndex === localIndex ? null : localIndex; } + + // Phase 5 base coat — the field behind this page fractures with the REAL + // contradiction load: no contradictions = a calm field, many = visible rifts. + let fractureFrac = $derived.by(() => Math.min(1, totalDetected / 24)); +
+
{/if}
+
diff --git a/apps/dashboard/src/routes/(app)/stats/+page.svelte b/apps/dashboard/src/routes/(app)/stats/+page.svelte index eae40a0..0fb0a81 100644 --- a/apps/dashboard/src/routes/(app)/stats/+page.svelte +++ b/apps/dashboard/src/routes/(app)/stats/+page.svelte @@ -4,6 +4,7 @@ import type { SystemStats, HealthCheck, RetentionDistribution } from '$types'; import PageHeader from '$lib/components/PageHeader.svelte'; import AnimatedNumber from '$lib/components/AnimatedNumber.svelte'; + import AmbientField from '$lib/components/AmbientField.svelte'; import { reveal } from '$lib/actions/reveal'; import { spotlight } from '$lib/actions/interactions'; import { NODE_TYPE_COLORS } from '$types'; @@ -13,6 +14,20 @@ let retention: RetentionDistribution | null = $state(null); let loading = $state(true); + // Phase 5 ambient base coat — the field behind this page READS these real + // metrics: the endangered share storms it, due-for-review pulses it. A store + // with 129 actively-forgetting memories looks visibly different from a calm one. + let endangeredFrac = $derived.by(() => { + const r = retention; + if (!r || r.total <= 0) return 0; + return (r.endangered?.length ?? 0) / r.total; + }); + let dueFrac = $derived.by(() => { + const s = stats; + if (!s || s.totalMemories <= 0) return 0; + return s.dueForReview / s.totalMemories; + }); + onMount(async () => { try { [stats, health, retention] = await Promise.all([ @@ -41,6 +56,20 @@ } + +
+
{/if}
+