feat(observatory): reduced-motion + persistent pause control for the field

Closes the launch guardrail: the field's ambient orbit + force-sim drift
run >5s, so WCAG needs a persistent pause control and prefers-reduced-
motion must be honored.

- engine.setPaused(): freezes the deterministic clock so the orbit + sim
  drift hold still, while the frame still renders and the live preFrameHook
  still runs — discrete event pulses (firewall, decay, dream) are
  information, not decoration, so they land even when motion is reduced.
- ObservatoryStage: a persistent ❚❚ PAUSE / ▶ RESUME control on the live
  field; auto-pauses under prefers-reduced-motion (user can override).

Verified live: the pause button toggles engine.isPaused (frozen drift,
pulses still land); 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:34:31 -07:00
parent da3bc5e5d7
commit 435d3f4098
2 changed files with 69 additions and 1 deletions

View file

@ -96,6 +96,32 @@
let projectionDays = $state(0);
let liveBridge: LiveBridge | null = null;
let liveDecayReady = $state(false);
// Motion control (WCAG): the field's ambient orbit/sim drift runs >5s, so it
// needs a persistent pause control AND must honor prefers-reduced-motion.
// When paused the clock freezes (drift stops) but live event pulses still
// land — they are information, not decoration. Auto-pauses under
// reduced-motion; the user can still override with the button.
let paused = $state(false);
let userSetPause = $state(false);
function initReducedMotion() {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
if (mq.matches && !userSetPause) paused = true;
const onChange = (e: MediaQueryListEvent) => {
if (!userSetPause) paused = e.matches;
};
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}
function togglePause() {
userSetPause = true;
paused = !paused;
}
// Push the pause state to the engine whenever either changes.
$effect(() => {
engine?.setPaused(paused);
});
// Live contradiction-firewall verdict — set when a real MemorySuppressed /
// contradiction event quarantines a memory on camera. Held ~7s then fades.
let liveFirewallLabel = $state('');
@ -354,6 +380,7 @@
onMount(() => {
loadGraph();
return initReducedMotion();
});
</script>
@ -409,6 +436,23 @@
</div>
{/if}
<!-- Motion pause control (WCAG: persistent, for >5s ambient motion). Live
field only. Pausing freezes ambient drift; live event pulses persist.
Auto-on under prefers-reduced-motion. -->
{#if live}
<button
onclick={togglePause}
class="absolute bottom-4 right-4 pointer-events-auto flex items-center gap-2 px-3 py-1.5
rounded-xl border border-[#22C7DE]/25 bg-[#05060a]/80 backdrop-blur-sm
font-mono text-[11px] tracking-wide text-[#22C7DE]/80 hover:text-[#22C7DE]
hover:border-[#22C7DE]/50 transition-colors"
title={paused ? 'Resume field motion' : 'Pause field motion (event pulses stay live)'}
aria-pressed={paused}
>
{paused ? '▶ RESUME' : '❚❚ PAUSE'}
</button>
{/if}
<!-- v2.3 living field — forward-projection scrubber. Real per-memory FSRS
decay drifts too slowly to watch in one session, so this projects the
field N days forward on the SAME true forgetting curve (honest, not

View file

@ -110,6 +110,15 @@ export class ObservatoryEngine {
private accumulatorMs = 0;
private static readonly FIXED_DT_MS = 1000 / 60;
/**
* Paused (prefers-reduced-motion or the on-page control): the deterministic
* clock stops advancing so the ambient orbit + force-sim drift FREEZE, but
* the frame still renders and the live preFrameHook still runs discrete
* event pulses (firewall, decay, dream) are information, not decoration, so
* they must land even when motion is reduced (WCAG-friendly).
*/
private paused = false;
constructor(opts: EngineOptions) {
this.canvas = opts.canvas;
this.demo = opts.demo;
@ -178,6 +187,19 @@ export class ObservatoryEngine {
return this.clock.state.totalFrames;
}
/**
* Freeze/unfreeze the ambient motion (prefers-reduced-motion or the on-page
* pause control). Frozen = the clock stops advancing, so the orbit + force
* sim hold still; live event pulses (via the preFrameHook) still land.
*/
setPaused(paused: boolean): void {
this.paused = paused;
}
get isPaused(): boolean {
return this.paused;
}
/**
* Wall-clock now in ms. The ONE sanctioned wall-clock read (never for sim
* state the DemoClock owns that). The live FSRS decay field legitimately
@ -317,8 +339,10 @@ export class ObservatoryEngine {
// on every display; only the scheduling reads the wall clock.
this.accumulatorMs += Math.min(deltaMs, 250);
let ticked = false;
// When paused, the clock is frozen (ambient/sim drift stops) — but we
// keep draining the accumulator so it doesn't fast-forward on resume.
while (this.accumulatorMs >= ObservatoryEngine.FIXED_DT_MS) {
this.clock.tick();
if (!this.paused) this.clock.tick();
this.accumulatorMs -= ObservatoryEngine.FIXED_DT_MS;
ticked = true;
}