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));
+
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 stats}
+
+ {/if}
+
+