feat(dashboard): metric-bound ambient base coat for the quiet routes (Phase 5)

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 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-07-08 22:29:07 -07:00
parent 07921cfd52
commit da3bc5e5d7
4 changed files with 406 additions and 0 deletions

View file

@ -0,0 +1,247 @@
<script lang="ts">
/**
* AmbientField — the Phase 5 base coat. A single metric-bound WebGPU field
* mounted full-bleed BEHIND a quiet route's content, at low opacity. It is a
* base coat, not a hero: one cheap substrate, but its drive is REAL backend
* metrics so it legibly READS the data (a route with 129 endangered memories
* storms; 0 endangered is calm — the discipline test still passes).
*
* Guardrails honored:
* - WebGPU absent / adapter null / context throw → renders nothing (the
* page's own flat background shows through). Never a black canvas.
* - prefers-reduced-motion → the drift freezes (params.reduced=1) but the
* field still renders the metric snapshot (motion is not the information).
* - Page-Visibility + IntersectionObserver gate the rAF loop.
* - DPR clamped; mobile-first mote budget, scales up on desktop.
*/
import { onMount } from 'svelte';
import { ambientFieldWGSL } from '$lib/observatory/ambient/ambient-field.wgsl';
interface Props {
/** 0..1 real actively-forgetting fraction — storm intensity. */
endangered?: number;
/** 0..1 real contradiction-pair fraction — rift intensity. */
fracture?: number;
/** 0..1 real due-for-review fraction — global pulse rate. */
due?: number;
/** Real memory count — mote density (how full the mind is). */
count?: number;
/** Route accent as [r,g,b] 0..1. Defaults to the brand cyan. */
accent?: [number, number, number];
/** Backdrop opacity (kept low — it is a base coat). */
opacity?: number;
}
let {
endangered = 0,
fracture = 0,
due = 0,
count = 0,
accent = [0.13, 0.78, 0.87], // #22C7DE brand cyan
opacity = 0.5
}: Props = $props();
let canvas: HTMLCanvasElement | null = $state(null);
let supported = $state(true);
// Mote capacity: mobile-first budget, scaled up on wider viewports. Never
// exceeds the real memory count (the field can't show more mind than exists).
const CAP_MOBILE = 220;
const CAP_DESKTOP = 520;
onMount(() => {
if (!canvas) return;
let device: GPUDevice | null = null;
let context: GPUCanvasContext | null = null;
let pipeline: GPURenderPipeline | null = null;
let bindGroup: GPUBindGroup | null = null;
let uniformBuf: GPUBuffer | null = null;
let raf = 0;
let disposed = false;
let visible = true;
let onScreen = true;
let startTs = 0;
let elapsed = 0;
let lastTs = 0;
const reducedMedia = window.matchMedia('(prefers-reduced-motion: reduce)');
const params = new Float32Array(12);
const capacity = () =>
Math.min(
count > 0 ? count : CAP_DESKTOP,
window.innerWidth < 640 ? CAP_MOBILE : CAP_DESKTOP
);
function writeParams(now: number) {
const dpr = Math.min(window.devicePixelRatio || 1, window.innerWidth < 640 ? 2 : 1.5);
const w = Math.max(1, Math.floor((canvas!.clientWidth || 1) * dpr));
const h = Math.max(1, Math.floor((canvas!.clientHeight || 1) * dpr));
if (canvas!.width !== w || canvas!.height !== h) {
canvas!.width = w;
canvas!.height = h;
}
params[0] = elapsed; // seconds
params[1] = capacity();
params[2] = Math.max(0, Math.min(1, endangered));
params[3] = Math.max(0, Math.min(1, fracture));
params[4] = Math.max(0, Math.min(1, due));
params[5] = w / Math.max(1, h);
params[6] = accent[0];
params[7] = accent[1];
params[8] = accent[2];
params[9] = dpr;
params[10] = reducedMedia.matches ? 1 : 0;
params[11] = 0;
void now;
}
async function boot() {
const gpu = (navigator as Navigator & { gpu?: GPU }).gpu;
if (!gpu) {
supported = false;
return;
}
let adapter: GPUAdapter | null = null;
try {
adapter = await gpu.requestAdapter();
} catch {
supported = false;
return;
}
if (!adapter || disposed) {
supported = false;
return;
}
try {
device = await adapter.requestDevice();
} catch {
supported = false;
return;
}
if (disposed) {
device?.destroy();
return;
}
const ctx = canvas!.getContext('webgpu');
if (!ctx) {
supported = false;
return;
}
context = ctx;
const format = gpu.getPreferredCanvasFormat();
context.configure({ device, format, alphaMode: 'premultiplied' });
uniformBuf = device.createBuffer({
label: 'ambient-params',
size: params.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
});
const module = device.createShaderModule({ label: 'ambient-field', code: ambientFieldWGSL });
pipeline = device.createRenderPipeline({
label: 'ambient-field',
layout: 'auto',
vertex: { module, entryPoint: 'vs_main' },
fragment: {
module,
entryPoint: 'fs_main',
targets: [
{
format,
// Additive-ish: motes accumulate glow over the transparent void.
blend: {
color: { srcFactor: 'src-alpha', dstFactor: 'one', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' }
}
}
]
},
primitive: { topology: 'triangle-list' }
});
bindGroup = device.createBindGroup({
label: 'ambient-bind',
layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: uniformBuf } }]
});
lastTs = 0;
raf = requestAnimationFrame(frame);
}
function frame(ts: number) {
if (disposed || !device || !context || !pipeline || !bindGroup || !uniformBuf) return;
if (!visible || !onScreen) {
// Paused: don't submit GPU work, but keep the rAF cheap so we
// resume instantly when the route/tab comes back.
raf = requestAnimationFrame(frame);
return;
}
// Advance the ambient clock only when motion is allowed; a reduced-
// motion viewer still sees the metric snapshot, just frozen.
if (lastTs > 0 && !reducedMedia.matches) {
elapsed += Math.min(ts - lastTs, 100) / 1000;
}
lastTs = ts;
writeParams(ts);
device.queue.writeBuffer(uniformBuf, 0, params);
let view: GPUTextureView;
try {
view = context.getCurrentTexture().createView();
} catch {
raf = requestAnimationFrame(frame);
return;
}
const encoder = device.createCommandEncoder({ label: 'ambient-frame' });
const pass = encoder.beginRenderPass({
colorAttachments: [
{ view, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: 'clear', storeOp: 'store' }
]
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(6, Math.floor(capacity()));
pass.end();
device.queue.submit([encoder.finish()]);
raf = requestAnimationFrame(frame);
}
const onVis = () => {
visible = document.visibilityState === 'visible';
};
document.addEventListener('visibilitychange', onVis);
const io = new IntersectionObserver(
(entries) => {
onScreen = entries.some((e) => e.isIntersecting);
},
{ threshold: 0 }
);
io.observe(canvas);
void boot();
void startTs;
return () => {
disposed = true;
cancelAnimationFrame(raf);
document.removeEventListener('visibilitychange', onVis);
io.disconnect();
uniformBuf?.destroy();
device?.destroy();
};
});
</script>
<!-- The field sits behind page content (the parent positions it). When WebGPU
is unavailable the canvas simply renders nothing and the page's own
background shows through — never a black hole. -->
{#if supported}
<canvas
bind:this={canvas}
class="pointer-events-none absolute inset-0 h-full w-full"
style="opacity: {opacity}"
aria-hidden="true"
></canvas>
{/if}

View file

@ -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<uniform> params: AmbientParams;
struct VSOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) seed: vec2<f32>,
@location(2) @interpolate(flat) tint: vec4<f32>, // 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<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, 1.0), vec2<f32>(-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<f32>(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<f32>(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<f32>(center.x + corner.x * size, center.y + corner.y * size * params.aspect, 0.0, 1.0);
out.uv = corner;
out.seed = vec2<f32>(rx, ry);
out.tint = vec4<f32>(params.accent_r, params.accent_g, params.accent_b, retention);
return out;
}
@fragment
fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
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<f32>(0.62, 0.32, 0.22);
let col = mix(ember, in.tint.rgb, smoothstep(0.2, 0.6, retention));
return vec4<f32>(col * intensity, intensity * 0.9);
}
`;

View file

@ -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));
</script>
<div class="relative isolate min-h-full">
<div class="absolute inset-0 -z-10 overflow-hidden" aria-hidden="true">
<AmbientField
count={Math.max(120, uniqueMemoryCount(contradictions) * 6)}
fracture={fractureFrac}
endangered={0.15}
accent={[0.95, 0.32, 0.42]}
opacity={0.32}
/>
</div>
<div class="min-h-full p-6 space-y-6">
<!-- Header -->
<PageHeader
@ -357,3 +372,4 @@
</div>
{/if}
</div>
</div>

View file

@ -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 @@
}
</script>
<!-- Phase 5 base coat: a metric-bound WebGPU field behind the page. Degrades to
nothing (the page background) without WebGPU; freezes under reduced-motion. -->
<div class="relative isolate min-h-full">
<div class="absolute inset-0 -z-10 overflow-hidden" aria-hidden="true">
{#if stats}
<AmbientField
count={stats.totalMemories}
endangered={endangeredFrac}
due={dueFrac}
accent={[0.36, 0.92, 0.72]}
opacity={0.4}
/>
{/if}
</div>
<div class="p-6 max-w-5xl mx-auto space-y-6">
<PageHeader
icon="stats"
@ -155,6 +184,7 @@
</div>
{/if}
</div>
</div>
<style>
.metric-card {