From dd4ced1f93fbfd3f99897f272ec02b4ea4e6536c Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sun, 16 Aug 2026 16:59:22 +0200 Subject: [PATCH] feat(import): the constellation breathes between beats (#1622) The theater canvas froze visually between spawn events; long holds like "Skriver till journalen..." read as stale. Add continuous ambient life inside the existing rAF loop, derived entirely from the clock (no extra timers), without inventing progress: motion means the system is alive, not that work completed. - Per-node breathing: radius +-10% (about 1px on the hub) plus up to 4% alpha, on two slow incommensurate clocks offset by each node's own position/wave phase so the field shimmers organically, not in sync. - Quiet ripple: every 7s a luminance wave travels hub to rim over 2.6s, brightening the hairline year rings (+0.18 alpha peak) and edges (+0.12) it passes. Alpha only: no color change, so it cannot be mistaken for the sage event pulse. - Settled mode (result reveals) rests at half breathing amplitude and gets no ripple; the reveal is a verdict. - prefers-reduced-motion: ambient scale is zero and the frame stays frozen as before. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- components/import/TheaterCanvas.tsx | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/components/import/TheaterCanvas.tsx b/components/import/TheaterCanvas.tsx index 7e60b014..6773cb01 100644 --- a/components/import/TheaterCanvas.tsx +++ b/components/import/TheaterCanvas.tsx @@ -215,12 +215,26 @@ const TheaterCanvas = forwardRef 1 - Math.pow(1 - v, 3) const byId = new Map(engine.nodes.map((n) => [n.id, n])) + // Ambient life (Living Paper): the constellation moves because it is + // alive right now, never to fake progress. Build mode gets full + // amplitude, settled reveals rest at half (the reveal is a verdict), + // reduced motion gets none (the loop is frozen anyway). Everything is + // derived from the clock inside this one rAF loop; no extra timers. + const ambient = reduced ? 0 : settled ? 0.5 : 1 + // Every 7s a quiet luminance wave travels hub -> rim over 2.6s, + // brightening the hairline rings and edges it passes (alpha only, no + // color, no geometry). Build mode only; -1 means resting. + const ripple = + ambient === 1 && now % 7000 < 2600 ? ((now % 7000) / 2600) * 340 : -1 + const rippleGlow = (radius: number, width: number) => + ripple < 0 ? 0 : Math.max(0, 1 - Math.abs(radius - ripple) / width) + for (const n of engine.nodes) { if (n.kind !== 'ring' || n.born == null) continue const p = ease(age(n)) const rad = (n.ring ?? 0) * k ctx.strokeStyle = colors.hair - ctx.globalAlpha = 0.75 * p + ctx.globalAlpha = (0.75 + 0.18 * rippleGlow(n.ring ?? 0, 30)) * p ctx.lineWidth = 0.8 ctx.beginPath() ctx.arc(cx, cy, rad, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * p) @@ -244,9 +258,11 @@ const TheaterCanvas = forwardRef