mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
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:
parent
da3bc5e5d7
commit
435d3f4098
2 changed files with 69 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue