Skip to content

Commit e3b15f5

Browse files
CyberSecDefclaude
andcommitted
feat(M13 chunk 13): reduced-motion + WebGL gate tri-state
Closes chunk 13. Replaces the chunk-1 placeholder gate copy ("once Chunk 13 lands…") with the spec's tri-state behaviour: full / 2D-svg / debug-text. Reduced-motion (PowerPoint mode): - Autoplay disabled (useSceneRunner gets autoplay: false). - Each scene renders at t = 1 (completion frame). - 4-second setTimeout auto-advances to the next scene; Step in PlaybackControls cancels the pending advance and arms a new one for the next scene. - No auto-advance past the last scene (guarded). - All logic in CinematicViz; useSceneRunner stays untouched. - canvas root gets data-reduced-motion="true|false" for tests. WebGL 2 unavailable: - Informational notice only — every M13 primitive is already SVG (chunk-1 locked "no Three.js" decision), so there's no actual 3D rendering to fall back from. - Copy matches the spec literal: "3D camera moves are unavailable; the visualization is rendering in 2D mode." - The viz continues rendering normally (no blocking like the M12 chunk-8 binary disable). Tri-state precedence: - WebGL gate notice wins when both apply (more critical info). - The reduced-motion BEHAVIOR (t=1 pin + auto-advance) still runs underneath, even when the WebGL notice covers the message. - data-gate-mode attribute ("webgl" | "reduced-motion") on the notice for test assertions. 9 new Vitest tests in CinematicViz-gates.test.tsx: - No notice when both gates pass. - Reduced-motion: notice copy + t=1 pin + canvas data attribute. - WebGL-missing: notice copy + viz still renders + data-reduced- motion=false (WebGL alone doesn't freeze t). - Both active: WebGL precedence + reduced-motion behavior still applies. Uses vi.resetModules() + vi.doMock + dynamic import to override the global useWebGL2Support / useReducedMotion mocks per-suite. The chunk-1 a11y test (webgl-fallback.test.tsx) was updated to match the new spec copy — its comment block explicitly anticipated this change. Full suite: 93 files / 933 tests pass; tsc --noEmit clean. phase1.md: chunk-13 checkbox [x], status row "chunks 1-13 done", decisions block (10 bullets) landed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d770257 commit e3b15f5

4 files changed

Lines changed: 206 additions & 15 deletions

File tree

docs/phase1.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ This document breaks Phase 1 into 15 milestones (M1–M15). Each milestone lists
2727
| M10 | GIF Export | M8 | 6 days | ✅ Complete | |
2828
| M11 | Thread Sharing | M9 | 3 days | ✅ Done | |
2929
| M12 | Accessibility + Polish | M11 | 5 days | ✅ Done | |
30-
| M13 | Cinematic Inference Visualization | M12 | 12 days | 🟡 In progress (chunks 1–12 done) | Replaces the M8 live viz with a 20-scene narrative |
30+
| M13 | Cinematic Inference Visualization | M12 | 12 days | 🟡 In progress (chunks 1–13 done) | Replaces the M8 live viz with a 20-scene narrative |
3131
| M14 | Deployment | M13 | 5 days | ⚪ Not started | |
3232
| M15 | Launch Prep | M14 | 5 days | ⚪ Not started | |
3333
| | **Total** | | **~80 engineer-days** | | |
@@ -1042,7 +1042,7 @@ Bonus M12 work beyond the SPEC's two exit criteria:
10421042
- VectorStrip cell count clamped to 64 (down from 128) on the degraded path
10431043
Document the degrade triggers + restoration condition (`< 18 FPS for 2s → degrade; > 24 FPS for 5s → restore`) inline + in the M13 retrospective.
10441044

1045-
- [ ] **Chunk 13 — Reduced-motion + WebGL gate adaptation.** The M12 chunk-8 gate disabled the M8 Viz + Embeddings tabs entirely when WebGL 2 is missing or reduced-motion is set. The new cinematic viz can degrade more gracefully:
1045+
- [x] **Chunk 13 — Reduced-motion + WebGL gate adaptation.** The M12 chunk-8 gate disabled the M8 Viz + Embeddings tabs entirely when WebGL 2 is missing or reduced-motion is set. The new cinematic viz can degrade more gracefully:
10461046
- **Reduced-motion**: scenes still display, but as static frames. The user advances scene-by-scene via the chunk-11 Step button (or auto-advances on a slow 4-second-per-scene timer). No camera moves, no particle trails, no continuous animations. Equivalent of "PowerPoint mode."
10471047
- **WebGL 2 unavailable**: fall back to the SVG variants of every primitive. The 2D Debug-tab fallback from M12 chunk 8 remains as the absolute-last-resort path. New `webgl-unsupported-notice` copy explains: "3D camera moves are unavailable; the visualization is rendering in 2D mode."
10481048
Replaces the M12 chunk-8 binary disable/fallback with a tri-state (full / 2D-svg / debug-text).
@@ -1066,6 +1066,19 @@ Bonus M12 work beyond the SPEC's two exit criteria:
10661066
- **Real run continues independently.** The M6 streaming pipeline + the M8 `useRunStream` hook keep doing exactly what they do. The chat bubble subscribes to the same WebSocket events and displays tokens as they arrive. Scenes 18-20 use the same event timeline to drive the autoregressive-loop pacing, but the viz lagging behind is expected + acceptable.
10671067
- **WebSocket → Scene coupling.** Scenes 5-17 fire once per *generated* token. The current `RunEvent` schema (M6) emits one `token.received` per token; the SceneRunner subscribes + advances. For the input tokens (the prompt), the runner enters Scenes 0-4 at submit time without waiting for the WebSocket.
10681068

1069+
**Decisions (chunk 13):** (Reduced-motion + WebGL gate tri-state.)
1070+
- **Reduced-motion = PowerPoint mode.** Each scene renders at `t = 1` (completion frame). Autoplay disabled, scene runner doesn't tick through t. 4-second `setTimeout` auto-advances to the next scene; Step button in PlaybackControls always works as a manual override. Once the runner reaches the last scene, no auto-advance (the timer guards `sceneIndex >= totalScenes - 1`).
1071+
- **`t = 1` is the chosen pin.** Each scene's last frame is its most informative static view — Scene 5 shows the embedding strips (post-detach), Scene 16 shows the chosen bar pulse, Scene 20 shows the EOS flourish. `t = 0.5` was considered but most scenes have peak content mid-window only briefly; the completion frame stays maximally informative.
1072+
- **All reduced-motion logic lives in `CinematicViz`, not in `useSceneRunner`.** The hook is untouched: we pass `autoplay: false`, override the rendered `t`, and run a sibling `setTimeout` for the 4s advance. Keeps the runner pure (it still drives t for non-reduced-motion paths), and the reduced-motion behaviour is one cohesive 15-line block in CinematicViz with clear comments.
1073+
- **WebGL 2 gate = informational only.** The chunk-1 decisions block locked "All 5 primitives are SVG/HTML/CSS — no Three.js" — every M13 scene already renders via SVG. There's no 3D scene to fall back from. The notice exists because the spec calls for it, but functionally the viz behaves identically with WebGL2 missing. The copy explains this honestly: "3D camera moves are unavailable; the visualization is rendering in 2D mode."
1074+
- **Tri-state precedence: WebGL gate wins over reduced-motion when both apply.** The WebGL notice is the more critical info (different rendering pipeline), reduced-motion is a comfort/accessibility mode. Only one notice surface; the more informative one shows. The reduced-motion *behaviour* (t = 1 pin + auto-advance) still applies underneath even when the WebGL notice covers the visible message.
1075+
- **`data-gate-mode` attribute on the notice (`webgl` / `reduced-motion`).** Lets tests assert which gate fired without parsing the human-readable copy. Pattern matches the chunk-12 FpsCounter's `data-degraded`.
1076+
- **`data-reduced-motion="true|false"` on the canvas root.** Sister assertion to the gate notice — confirms the t-pin behaviour is active regardless of which notice (if any) is visible. Used by the chunk-13 tests to verify the "both gates" case.
1077+
- **Gate-notice tests use dynamic import + `vi.doMock`.** The global setup defaults `useWebGL2Support` to true and `useReducedMotion` to false (via the matchMedia stub). Chunk-13 suites override per-suite via `vi.doMock + vi.resetModules() + dynamic await import('@/Components/Viz/CinematicViz')`. The `vi.resetModules()` call in beforeEach is required — without it the first test in each suite inherits the already-loaded module from before the mock applied. Documented inline.
1078+
- **The chunk-1 a11y test (`webgl-fallback.test.tsx`) was updated to match the chunk-13 copy.** Its comment block explicitly anticipated this change ("Chunk 13 evolves the gate into the tri-state… for now we just assert that the notice renders"). Updated to assert the new spec literals + `data-gate-mode="webgl"` rather than the chunk-1 "WebGL 2.0" placeholder copy.
1079+
- **No behavioural change for ParticleTrail/etc.** ParticleTrail already gates its animation via `motion-safe:` (chunk 2) which respects `prefers-reduced-motion` natively. The chunk-12 degraded-mode hook handles FPS-driven trims. Chunk 13's contribution is the scene-runner-level t-pin + auto-advance, not per-component animation overrides — those existed already and continue to fire.
1080+
- **Auto-advance is per scene, not per token.** Spec says "auto-advances on a slow 4-second-per-scene timer" — chunk 13 uses a fresh `setTimeout(controls.nextScene, 4000)` per scene-change, cleared on unmount or sceneIndex change. Any manual `setScene()` call (PipelineProgressBar click, PlaybackControls Step) cancels the pending advance and arms a new one for the new scene.
1081+
10691082
**Decisions (chunk 12):** (Performance hardening + degraded-mode state machine.)
10701083
- **State machine in `useFpsTracker`, transport via `PerformanceModeContext`.** Hook drives the RAF loop + hysteresis; context flows `{ fps, degraded }` to ~13 scene/component callsites. Same pattern as chunk 11b's `VectorInspection`. Mounting the hook in `CinematicViz` keeps a single global tracker; per-component subscribers stay declarative.
10711084
- **Hysteresis thresholds match the spec literal:** `< 18 FPS for 2s → degrade; > 24 FPS for 5s → restore`. Asymmetric on purpose — degrade fast, restore slow. The 6-point gap between thresholds (18 vs 24) prevents oscillation when the actual FPS sits around the boundary. Exported as `FPS_TRACKER_CONFIG` for tests + decision-block citation.

resources/js/Components/Viz/CinematicViz.tsx

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,16 +116,48 @@ export default function CinematicViz({
116116
[prompt, model?.architecture_type, model?.layers, model?.vocab_size],
117117
);
118118

119+
// M13 chunk 13: in reduced-motion mode the scene runner does NOT
120+
// autoplay through the t-axis. Scenes render at t=1 (completion
121+
// frame) and a slow 4-second timer auto-advances to the next
122+
// scene. PlaybackControls' Step button still works on top of
123+
// this — manual advance is always available.
119124
const { state, controls } = useSceneRunner({
120125
scenes,
121126
initialState,
122-
autoplay: prompt !== null && prompt !== undefined,
127+
autoplay: !reducedMotion && prompt !== null && prompt !== undefined,
123128
});
124129

130+
// 4-second auto-advance for reduced-motion. Step in the chunk-11
131+
// PlaybackControls (or any other setScene call) resets the timer.
132+
useEffect(() => {
133+
if (!reducedMotion || !prompt) return;
134+
if (state.sceneIndex >= state.totalScenes - 1) return; // pinned at last scene
135+
const timer = setTimeout(() => controls.nextScene(), 4000);
136+
return () => clearTimeout(timer);
137+
}, [reducedMotion, prompt, state.sceneIndex, state.totalScenes, controls]);
138+
139+
// Effective t for scene render: reduced-motion freezes at 1
140+
// (the completion frame); the runner's actual t still ticks
141+
// (used for the auto-advance scheduling) but the scene's
142+
// render function never sees it.
143+
const renderT = reducedMotion ? 1 : state.t;
144+
145+
// M13 chunk 13: tri-state gate notice.
146+
// webgl2Supported=false → SVG fallback notice (the M13 viz is
147+
// already all-SVG, so this is purely
148+
// informational: 3D camera moves
149+
// wouldn't have been visible anyway).
150+
// reducedMotion=true → PowerPoint mode notice.
151+
// Both true → WebGL notice wins (more critical).
125152
const gateMessage = !webgl2Supported
126-
? 'WebGL 2.0 is unavailable in this browser. The visualization will render in 2D-only mode once Chunk 13 lands.'
153+
? '3D camera moves are unavailable; the visualization is rendering in 2D mode.'
154+
: reducedMotion
155+
? 'Reduced-motion is set. Scenes display as static completion frames; advance via Step or wait 4 seconds.'
156+
: null;
157+
const gateTestId = !webgl2Supported
158+
? 'cinematic-viz-gate-webgl'
127159
: reducedMotion
128-
? 'Reduced-motion is set. The visualization will play scene-by-scene without continuous animation once Chunk 13 lands.'
160+
? 'cinematic-viz-gate-reduced-motion'
129161
: null;
130162

131163
const idle = !prompt;
@@ -279,11 +311,15 @@ export default function CinematicViz({
279311

280312
<NumericalValuesPanel />
281313

282-
{gateMessage && (
314+
{gateMessage && gateTestId && (
283315
<div
284316
className="absolute top-2 left-2 z-10 max-w-md rounded-md border border-amber-900/50 bg-amber-950/40 px-3 py-2 text-[11px] text-amber-200"
285317
role="note"
286318
data-testid="cinematic-viz-gate-notice"
319+
data-gate-mode={gateTestId.replace(
320+
'cinematic-viz-gate-',
321+
'',
322+
)}
287323
>
288324
{gateMessage}
289325
</div>
@@ -310,9 +346,10 @@ export default function CinematicViz({
310346
className="flex h-full min-h-[400px] flex-col"
311347
data-testid="cinematic-viz-canvas"
312348
data-scene-id={state.sceneId}
313-
data-scene-t={state.t.toFixed(3)}
349+
data-scene-t={renderT.toFixed(3)}
350+
data-reduced-motion={reducedMotion ? 'true' : 'false'}
314351
>
315-
{activeScene.render(state.t, state.pipelineState)}
352+
{activeScene.render(renderT, state.pipelineState)}
316353
</div>
317354
) : (
318355
// Prompt present but the scene at this index isn't
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import CinematicViz from '@/Components/Viz/CinematicViz';
4+
import type { PipelineState, Scene } from '@/Components/Viz/Scene';
5+
6+
/*
7+
* Tests for the M13 chunk-13 tri-state gate notices:
8+
* - full mode (no gate notice)
9+
* - reduced-motion (PowerPoint mode + 4s auto-advance)
10+
* - WebGL 2 unavailable (informational notice; viz still renders)
11+
*
12+
* Hook mocks are scoped per-suite via vi.mock so other CinematicViz
13+
* tests (which default to webgl=true / reduced=false) stay unaffected.
14+
*/
15+
16+
const tCaptureScene: Scene<PipelineState, PipelineState> = {
17+
id: 'prompt-entry',
18+
durationMs: 500,
19+
render: (t, input) => (
20+
<div data-testid="captured-render" data-t={t.toFixed(3)}>
21+
Got prompt: {input.promptText ?? '(none)'}
22+
</div>
23+
),
24+
transform: (input) => input,
25+
};
26+
27+
describe('<CinematicViz /> chunk-13 gates — defaults', () => {
28+
it('renders no gate notice when both gates pass', () => {
29+
render(<CinematicViz events={[]} prompt="hello" />);
30+
expect(screen.queryByTestId('cinematic-viz-gate-notice')).not.toBeInTheDocument();
31+
});
32+
});
33+
34+
describe('<CinematicViz /> chunk-13 gates — reduced-motion', () => {
35+
beforeEach(() => {
36+
vi.resetModules();
37+
vi.doMock('@/hooks/useReducedMotion', () => ({
38+
useReducedMotion: () => true,
39+
}));
40+
});
41+
42+
afterEach(() => {
43+
vi.doUnmock('@/hooks/useReducedMotion');
44+
vi.resetModules();
45+
});
46+
47+
it('shows the reduced-motion notice with the chunk-13 copy', async () => {
48+
const { default: CinematicVizReloaded } = await import('@/Components/Viz/CinematicViz');
49+
render(<CinematicVizReloaded events={[]} prompt="hello" />);
50+
const notice = screen.getByTestId('cinematic-viz-gate-notice');
51+
expect(notice.getAttribute('data-gate-mode')).toBe('reduced-motion');
52+
expect(notice.textContent).toMatch(/Reduced-motion is set/i);
53+
expect(notice.textContent).toMatch(/static completion frames/i);
54+
expect(notice.textContent).toMatch(/advance via Step or wait 4 seconds/i);
55+
});
56+
57+
it('renders the scene at t=1 (completion frame)', async () => {
58+
const { default: CinematicVizReloaded } = await import('@/Components/Viz/CinematicViz');
59+
render(<CinematicVizReloaded events={[]} prompt="hello" scenes={[tCaptureScene]} />);
60+
const rendered = screen.getByTestId('captured-render');
61+
expect(rendered.getAttribute('data-t')).toBe('1.000');
62+
});
63+
64+
it('canvas carries data-reduced-motion="true"', async () => {
65+
const { default: CinematicVizReloaded } = await import('@/Components/Viz/CinematicViz');
66+
render(<CinematicVizReloaded events={[]} prompt="hello" scenes={[tCaptureScene]} />);
67+
const canvas = screen.getByTestId('cinematic-viz-canvas');
68+
expect(canvas.getAttribute('data-reduced-motion')).toBe('true');
69+
});
70+
});
71+
72+
describe('<CinematicViz /> chunk-13 gates — WebGL 2 unavailable', () => {
73+
beforeEach(() => {
74+
vi.resetModules();
75+
vi.doMock('@/hooks/useWebGL2Support', () => ({
76+
useWebGL2Support: () => false,
77+
}));
78+
});
79+
80+
afterEach(() => {
81+
vi.doUnmock('@/hooks/useWebGL2Support');
82+
vi.resetModules();
83+
});
84+
85+
it('shows the WebGL-unsupported notice with the chunk-13 spec copy', async () => {
86+
const { default: CinematicVizReloaded } = await import('@/Components/Viz/CinematicViz');
87+
render(<CinematicVizReloaded events={[]} prompt="hello" />);
88+
const notice = screen.getByTestId('cinematic-viz-gate-notice');
89+
expect(notice.getAttribute('data-gate-mode')).toBe('webgl');
90+
expect(notice.textContent).toMatch(/3D camera moves are unavailable/i);
91+
expect(notice.textContent).toMatch(/rendering in 2D mode/i);
92+
});
93+
94+
it('viz still renders (does NOT block like the M12 binary disable)', async () => {
95+
const { default: CinematicVizReloaded } = await import('@/Components/Viz/CinematicViz');
96+
render(<CinematicVizReloaded events={[]} prompt="hello" scenes={[tCaptureScene]} />);
97+
expect(screen.getByTestId('captured-render')).toBeInTheDocument();
98+
});
99+
100+
it('canvas carries data-reduced-motion="false" (WebGL gate alone does not freeze t)', async () => {
101+
const { default: CinematicVizReloaded } = await import('@/Components/Viz/CinematicViz');
102+
render(<CinematicVizReloaded events={[]} prompt="hello" scenes={[tCaptureScene]} />);
103+
const canvas = screen.getByTestId('cinematic-viz-canvas');
104+
expect(canvas.getAttribute('data-reduced-motion')).toBe('false');
105+
});
106+
});
107+
108+
describe('<CinematicViz /> chunk-13 gates — both gates active', () => {
109+
beforeEach(() => {
110+
vi.resetModules();
111+
vi.doMock('@/hooks/useReducedMotion', () => ({
112+
useReducedMotion: () => true,
113+
}));
114+
vi.doMock('@/hooks/useWebGL2Support', () => ({
115+
useWebGL2Support: () => false,
116+
}));
117+
});
118+
119+
afterEach(() => {
120+
vi.doUnmock('@/hooks/useReducedMotion');
121+
vi.doUnmock('@/hooks/useWebGL2Support');
122+
vi.resetModules();
123+
});
124+
125+
it('WebGL notice takes precedence over the reduced-motion notice', async () => {
126+
const { default: CinematicVizReloaded } = await import('@/Components/Viz/CinematicViz');
127+
render(<CinematicVizReloaded events={[]} prompt="hello" />);
128+
const notice = screen.getByTestId('cinematic-viz-gate-notice');
129+
expect(notice.getAttribute('data-gate-mode')).toBe('webgl');
130+
});
131+
132+
it('reduced-motion behavior (t=1 pin) still applies regardless of which notice shows', async () => {
133+
const { default: CinematicVizReloaded } = await import('@/Components/Viz/CinematicViz');
134+
render(<CinematicVizReloaded events={[]} prompt="hello" scenes={[tCaptureScene]} />);
135+
const rendered = screen.getByTestId('captured-render');
136+
expect(rendered.getAttribute('data-t')).toBe('1.000');
137+
});
138+
});

0 commit comments

Comments
 (0)