Skip to content

Commit 33df5b7

Browse files
CyberSecDefclaude
andcommitted
feat(M13 chunk 11a): PlaybackControls component
First half of chunk 11. Speed selector (0.25×/1×/4×) + play-pause + prev/next scene + jump-to-live. Click-to-expand affordance lands in 11b; chunk-11 checkbox stays unchecked until then. PlaybackControls component: - Pure render. Takes state + controls + optional liveSceneIndex. - Play/pause toggle (amber when playing, emerald when paused). - Prev/Next scene buttons (◂◂ / ▸▸), each disabled at the start/end boundary respectively. - Speed radiogroup: 0.25× / 1× / 4× per PLAYBACK_SPEEDS. Active speed gets cyan accent + aria-checked. - Jump-to-live (⇥ Live, violet accent). Disabled when liveSceneIndex is null OR equals state.sceneIndex. - Scene-position label at right ("Scene 5 / 20 · t = 0.42"). - role="toolbar" + aria-label="Playback controls". CinematicViz wiring: - Imports PlaybackControls; mounts between PipelineProgressBar and the canvas row. - Computes liveSceneIndex from tokenEvents: no events → null token.received but !final → 18 (autoregressive-loop) is_final → 20 (detokenize / EOS) 14 new Vitest tests in PlaybackControls.test.tsx: - All four control groups render. - Toggle label flips with playing state + calls controls.toggle. - Prev/next call correct controls; disabled at start/end. - Three speed buttons render; active one is aria-checked; click calls setSpeed. - Jump-to-live disabled when liveSceneIndex is null; enabled + calls setScene when distinct; disabled when equal. - Scene-position label format. - Toolbar role + aria-label. Full suite: 87 files / 881 tests pass; tsc --noEmit clean. phase1.md: chunk-11 checkbox stays unchecked (11b lands the rest). Decisions block for chunk 11a added (10 bullets). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4a33249 commit 33df5b7

4 files changed

Lines changed: 385 additions & 0 deletions

File tree

docs/phase1.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,18 @@ 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 11a):** (PlaybackControls — chunk 11 is mid-flight; checkbox stays unchecked until 11b lands the click-to-expand panel.)
1070+
- **`useSceneRunner` controls already covered the needs.** Speed (PLAYBACK_SPEEDS already `[0.25, 1, 4]`), play/pause/toggle, prev/next scene (= "step to scene-boundary" per chunk-11 spec). No runner changes needed — chunk 11a is pure UI + jump-to-live derivation.
1071+
- **`PlaybackControls` is a pure render component** taking `state + controls + liveSceneIndex`. CinematicViz computes `liveSceneIndex` from the events stream and passes it through. Same pattern as the chunk-1 `PipelineProgressBar` (state + onSelectScene). Keeps the chunk-11 component pure-render, easy to test.
1072+
- **Jump-to-live heuristic** matches the chunk-11 design decision: no events → null → button disabled; any token.received → setScene(18); is_final → setScene(20). Documented in the inline comment + tested explicitly. The button is also disabled when the live target equals the current scene — "no jump needed" UX state.
1073+
- **Step = `nextScene()` / `prevScene()` only.** Spec explicitly calls out scene-boundary step, not event-boundary step. The runner already exposes both; mapping them to `◂◂` / `▸▸` is straightforward. No event-stepping mode — would conflict with the synthetic-vs-real timing split.
1074+
- **Speed selector is `role="radiogroup"` with three `role="radio"` buttons.** Per the M12 chunk-2 a11y pattern: the active speed is `aria-checked="true"`; the inactive two are `aria-checked="false"`. Single button visually pressed via cyan background. focus-visible ring chain present.
1075+
- **Play/pause uses semantic colors** (amber when playing → "pause to interrupt", emerald when paused → "play to resume"). Picked for color-blind safety (the verb is in the label, not just the color) and to differentiate from the cyan speed buttons.
1076+
- **Jump-to-live uses violet accent.** Distinct from cyan (speed) / emerald (play) / amber (pause) — a fourth semantic slot for "fast-forward through the run." Disabled state uses the muted-foreground/30 pattern.
1077+
- **Scene-position label "Scene N / M · t = X.XX" mounted at the right of the toolbar.** Read-only, informative. Helps debug-mode users see exactly where the runner is. Could be hidden behind a flag in chunk 13; for now visible.
1078+
- **PlaybackControls mounted between PipelineProgressBar and the canvas row in CinematicViz.** Pipeline progress (overview) → playback controls (action) → canvas (content). Top-to-bottom visual hierarchy matches the spec's "PipelineProgressBar at the very top, PlaybackControls just below."
1079+
- **The click-to-expand affordance lands in chunk 11b.** Chunk-11 checkbox stays unchecked until 11b ships the VectorInspectionProvider + NumericalValuesPanel.
1080+
10691081
**Decisions (chunk 10b):** (ChatBubble + WebSocket events wiring — completing chunk 10.)
10701082
- **ChatBubble accepts `tokens: string[]` + `isFinal: boolean`** — the simplest contract that matches both data sources. CinematicViz does the events-vs-state selection upstream; ChatBubble itself doesn't know which source produced the array.
10711083
- **Events-primary, state-fallback selection in CinematicViz.** When the `events` prop contains any `token.received` entries, the bubble reads from those. When empty (chunk-10 isolated testing, replay without a stream), it falls back to `state.pipelineState.generatedTokens.map(t => t.string)`. This delivers the spec's "ahead-of-viz" property automatically: live events stream from the real run independent of which scene the viz is on; chunk-10 tests + replays without a stream still show something.

resources/js/Components/Viz/CinematicViz.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import VocabSidebar from '@/Components/Viz/VocabSidebar';
1515
import ChatBubble from '@/Components/Viz/ChatBubble';
1616
import LayerCounterHud from '@/Components/Viz/LayerCounterHud';
1717
import PipelineProgressBar from '@/Components/Viz/PipelineProgressBar';
18+
import PlaybackControls from '@/Components/Viz/PlaybackControls';
1819
import { Card, CardContent } from '@/Components/ui/card';
1920
import type { RunEvent } from '@/types/runs';
2021

@@ -176,6 +177,18 @@ export default function CinematicViz({
176177
? tokenEvents[tokenEvents.length - 1]?.payload.is_final === true
177178
: state.sceneId === 'detokenize' && state.t >= 0.9;
178179

180+
// M13 chunk 11a: jump-to-live target. Per chunk-11 decisions:
181+
// no token events → null (button disabled)
182+
// token.received but not final → 18 (autoregressive loop)
183+
// final token received → 20 (detokenize / EOS)
184+
// CinematicViz computes this here so PlaybackControls stays a
185+
// pure render component.
186+
const liveSceneIndex: number | null = (() => {
187+
if (tokenEvents.length === 0) return null;
188+
const lastIsFinal = tokenEvents[tokenEvents.length - 1].payload.is_final === true;
189+
return lastIsFinal ? 20 : 18;
190+
})();
191+
179192
// M13 chunk 10: LayerCounterHud visibility + values. Visible
180193
// during scenes 5-12 (the per-layer + tower scenes); hidden on
181194
// tokenization (0-4) + output (13-20). currentLayer defaults to
@@ -228,6 +241,12 @@ export default function CinematicViz({
228241
}}
229242
/>
230243

244+
<PlaybackControls
245+
state={state}
246+
controls={controls}
247+
liveSceneIndex={liveSceneIndex}
248+
/>
249+
231250
<div className="flex min-h-[400px] gap-2">
232251
<VocabSidebar
233252
tokens={vocabTokens}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { cn } from '@/lib/utils';
2+
import {
3+
PLAYBACK_SPEEDS,
4+
type SceneRunnerControls,
5+
type SceneRunnerState,
6+
type SceneSpeed,
7+
} from '@/Components/Viz/useSceneRunner';
8+
9+
/*
10+
* PlaybackControls (M13 chunk 11a) — scene-level playback control
11+
* surface mounted below the canvas. Adapts the M8 chunk-8
12+
* `PlaybackControls` component (deleted in M13 chunk 1) to the
13+
* scene-runner contract:
14+
*
15+
* - Play/pause toggle
16+
* - Prev / Next scene (advances to next scene-boundary, NOT next
17+
* event — per the chunk-11 spec literal)
18+
* - Speed selector: 0.25× / 1× / 4× (PLAYBACK_SPEEDS)
19+
* - Jump-to-live: skips to the scene matching the current
20+
* token-stream position. Heuristic per chunk-11 decisions:
21+
* no events → no-op (button disabled)
22+
* token.received only → setScene(18) (autoregressive-loop)
23+
* is_final received → setScene(20) (detokenize / EOS)
24+
*
25+
* Per `phase1.md:1036` + `docs/visualization.md`. Visual style
26+
* matches the chunk-1 PipelineProgressBar (same border, padding,
27+
* focus-visible ring pattern from M12 chunk 2).
28+
*/
29+
30+
export interface PlaybackControlsProps {
31+
state: SceneRunnerState;
32+
controls: SceneRunnerControls;
33+
/** Target scene for the "jump to live" button. Null disables
34+
* the button. Computed by the parent from the events stream. */
35+
liveSceneIndex?: number | null;
36+
}
37+
38+
export default function PlaybackControls({
39+
state,
40+
controls,
41+
liveSceneIndex = null,
42+
}: PlaybackControlsProps) {
43+
const canJumpLive = liveSceneIndex !== null && liveSceneIndex !== state.sceneIndex;
44+
45+
return (
46+
<div
47+
className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-card/40 p-2 text-[10px]"
48+
role="toolbar"
49+
aria-label="Playback controls"
50+
data-testid="viz-playback-controls"
51+
>
52+
{/* Play / pause */}
53+
<button
54+
type="button"
55+
onClick={controls.toggle}
56+
className={cn(
57+
'rounded px-2 py-1 font-medium uppercase tracking-wider transition-colors',
58+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
59+
state.playing
60+
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30'
61+
: 'bg-emerald-500/20 text-emerald-200 hover:bg-emerald-500/30',
62+
)}
63+
aria-label={state.playing ? 'Pause playback' : 'Resume playback'}
64+
aria-pressed={state.playing}
65+
data-testid="viz-playback-toggle"
66+
>
67+
{state.playing ? 'Pause' : 'Play'}
68+
</button>
69+
70+
{/* Prev / Next scene */}
71+
<div
72+
className="flex items-center gap-px rounded border border-border bg-card/60"
73+
data-testid="viz-playback-step-group"
74+
>
75+
<button
76+
type="button"
77+
onClick={controls.prevScene}
78+
disabled={state.sceneIndex === 0}
79+
className={cn(
80+
'rounded-l px-2 py-1 transition-colors',
81+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
82+
state.sceneIndex === 0
83+
? 'cursor-not-allowed text-muted-foreground/30'
84+
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
85+
)}
86+
aria-label="Previous scene"
87+
data-testid="viz-playback-prev"
88+
>
89+
◂◂
90+
</button>
91+
<button
92+
type="button"
93+
onClick={controls.nextScene}
94+
disabled={state.sceneIndex >= state.totalScenes - 1}
95+
className={cn(
96+
'rounded-r px-2 py-1 transition-colors',
97+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
98+
state.sceneIndex >= state.totalScenes - 1
99+
? 'cursor-not-allowed text-muted-foreground/30'
100+
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
101+
)}
102+
aria-label="Next scene"
103+
data-testid="viz-playback-next"
104+
>
105+
▸▸
106+
</button>
107+
</div>
108+
109+
{/* Speed selector */}
110+
<div
111+
className="flex items-center gap-px rounded border border-border bg-card/60"
112+
role="radiogroup"
113+
aria-label="Playback speed"
114+
data-testid="viz-playback-speed-group"
115+
>
116+
{PLAYBACK_SPEEDS.map((s, i) => {
117+
const active = state.speed === s;
118+
return (
119+
<button
120+
key={s}
121+
type="button"
122+
role="radio"
123+
aria-checked={active}
124+
onClick={() => controls.setSpeed(s as SceneSpeed)}
125+
className={cn(
126+
'px-2 py-1 font-mono tabular-nums transition-colors',
127+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
128+
i === 0 ? 'rounded-l' : '',
129+
i === PLAYBACK_SPEEDS.length - 1 ? 'rounded-r' : '',
130+
active
131+
? 'bg-cyan-500/30 text-cyan-100 font-semibold'
132+
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
133+
)}
134+
data-testid={`viz-playback-speed-${s}`}
135+
>
136+
{formatSpeed(s)}
137+
</button>
138+
);
139+
})}
140+
</div>
141+
142+
{/* Jump-to-live */}
143+
<button
144+
type="button"
145+
onClick={() => {
146+
if (liveSceneIndex !== null) controls.setScene(liveSceneIndex);
147+
}}
148+
disabled={!canJumpLive}
149+
className={cn(
150+
'rounded px-2 py-1 font-medium uppercase tracking-wider transition-colors',
151+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
152+
canJumpLive
153+
? 'bg-violet-500/20 text-violet-200 hover:bg-violet-500/30'
154+
: 'cursor-not-allowed text-muted-foreground/30',
155+
)}
156+
aria-label="Jump to live"
157+
aria-disabled={!canJumpLive}
158+
data-testid="viz-playback-jump-live"
159+
title={
160+
canJumpLive
161+
? 'Jump the visualization forward to the latest run scene'
162+
: 'No live data ahead of the visualization'
163+
}
164+
>
165+
⇥ Live
166+
</button>
167+
168+
{/* Scene-position label (informational) */}
169+
<span
170+
className="ml-auto font-mono tabular-nums text-muted-foreground/70"
171+
data-testid="viz-playback-scene-label"
172+
>
173+
Scene {state.sceneIndex} / {state.totalScenes - 1}
174+
{' · '}t = {state.t.toFixed(2)}
175+
</span>
176+
</div>
177+
);
178+
}
179+
180+
function formatSpeed(s: number): string {
181+
if (Number.isInteger(s)) return `${s}×`;
182+
return `${s}×`;
183+
}

0 commit comments

Comments
 (0)