-
- {{ roundPct(result.reachedPct) }}%
- {{ formatPct(result.reachedPct) }} reached
-
+ {{ roundPct(reachedPctNow) }}%
- {{ result.numReached }} of {{ numStudents }} reached
+ {{ reachedCount }} of {{ numStudents }} reached
•
{{ result.numRounds }} rounds
•
diff --git a/web/src/composables/__tests__/usePlayback.spec.ts b/web/src/composables/__tests__/usePlayback.spec.ts
new file mode 100644
index 0000000..8ccc9da
--- /dev/null
+++ b/web/src/composables/__tests__/usePlayback.spec.ts
@@ -0,0 +1,171 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createPlayback, REDUCED_MOTION_ROUND_MS, ROUND_MS } from '../usePlayback'
+import { createSimStore } from '../useSimStore'
+import { runScenario } from '@/lib/api'
+import type { ScenarioRequest, ScenarioResponse } from '@/types/api'
+
+vi.mock('@/lib/api', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, runScenario: vi.fn() }
+})
+
+const runScenarioMock = vi.mocked(runScenario)
+
+// Every panel runs 5 rounds in this fake engine.
+const FINAL_ROUND = 5
+
+function fakeResponse(request: ScenarioRequest): ScenarioResponse {
+ const { numStudents } = request.config
+ return {
+ config: request.config,
+ result: {
+ strategy: request.strategy,
+ educated: [],
+ reachedAtRound: Array.from({ length: numStudents }, (_, node) =>
+ node < FINAL_ROUND ? node : -1,
+ ),
+ numReached: FINAL_ROUND,
+ numRounds: FINAL_ROUND,
+ reachedPct: (FINAL_ROUND / numStudents) * 100,
+ },
+ edges: [[0, 1]],
+ }
+}
+
+let prefersReducedMotion = false
+
+beforeEach(() => {
+ vi.useFakeTimers({
+ toFake: [
+ 'setTimeout',
+ 'clearTimeout',
+ 'requestAnimationFrame',
+ 'cancelAnimationFrame',
+ 'performance',
+ ],
+ })
+ runScenarioMock.mockReset()
+ runScenarioMock.mockImplementation(async (request) => fakeResponse(request))
+ prefersReducedMotion = false
+ vi.stubGlobal('matchMedia', (query: string) => ({
+ matches: prefersReducedMotion && query.includes('prefers-reduced-motion'),
+ media: query,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }))
+ window.history.replaceState(null, '', '/')
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+})
+
+async function readyStore() {
+ const store = createSimStore()
+ await store.initialize('')
+ return store
+}
+
+describe('usePlayback', () => {
+ it('autoplays once from round 0 and advances on the 700 ms cadence', async () => {
+ const store = await readyStore()
+ const playback = createPlayback(store)
+
+ playback.autoplayOnce()
+ expect(store.state.round).toBe(0)
+ expect(store.state.playing).toBe(true)
+
+ await vi.advanceTimersByTimeAsync(ROUND_MS + 20)
+ expect(store.state.round).toBe(1)
+ await vi.advanceTimersByTimeAsync(ROUND_MS)
+ expect(store.state.round).toBe(2)
+ })
+
+ it('stops on the final round, announces, and replays via play', async () => {
+ const store = await readyStore()
+ const playback = createPlayback(store)
+
+ playback.autoplayOnce()
+ await vi.advanceTimersByTimeAsync(ROUND_MS * (FINAL_ROUND + 3))
+
+ expect(store.state.round).toBe(FINAL_ROUND)
+ expect(store.state.playing).toBe(false) // no loop
+ expect(store.state.announcement).toContain('Final:')
+
+ playback.play() // at the end, play is the replay affordance
+ expect(store.state.round).toBe(0)
+ expect(store.state.playing).toBe(true)
+ })
+
+ it('autoplays only once', async () => {
+ const store = await readyStore()
+ const playback = createPlayback(store)
+
+ playback.autoplayOnce()
+ await vi.advanceTimersByTimeAsync(ROUND_MS * (FINAL_ROUND + 3))
+ playback.autoplayOnce()
+ expect(store.state.playing).toBe(false)
+ })
+
+ it('doubles the cadence at 2x speed', async () => {
+ const store = await readyStore()
+ const playback = createPlayback(store)
+
+ playback.cycleSpeed() // 1x -> 2x
+ expect(store.state.speed).toBe(2)
+ playback.play()
+ await vi.advanceTimersByTimeAsync(ROUND_MS / 2 + 20)
+ expect(store.state.round).toBe(1)
+ })
+
+ it('steps and seeks pause playback and clamp to the round range', async () => {
+ const store = await readyStore()
+ const playback = createPlayback(store)
+
+ playback.play()
+ playback.stepForward()
+ expect(store.state.playing).toBe(false)
+ expect(store.state.round).toBe(1)
+
+ playback.seekTo(99)
+ expect(store.state.round).toBe(FINAL_ROUND)
+ playback.stepForward()
+ expect(store.state.round).toBe(FINAL_ROUND)
+
+ playback.seekTo(-3)
+ expect(store.state.round).toBe(0)
+ playback.stepBack()
+ expect(store.state.round).toBe(0)
+
+ playback.announceRound()
+ expect(store.state.announcement).toBe(`Round 0 of ${FINAL_ROUND}`)
+ })
+
+ it('pausing announces the current round', async () => {
+ const store = await readyStore()
+ const playback = createPlayback(store)
+
+ playback.play()
+ await vi.advanceTimersByTimeAsync(ROUND_MS + 20)
+ playback.pause()
+ expect(store.state.announcement).toBe(`Paused at round 1 of ${FINAL_ROUND}`)
+ })
+
+ it('under reduced motion: no autoplay, page settles on the final round, slower cadence', async () => {
+ prefersReducedMotion = true
+ const store = await readyStore()
+ const playback = createPlayback(store)
+
+ playback.autoplayOnce()
+ expect(store.state.playing).toBe(false)
+ expect(store.state.round).toBe(FINAL_ROUND)
+
+ playback.replay()
+ expect(store.state.playing).toBe(true)
+ await vi.advanceTimersByTimeAsync(ROUND_MS + 20)
+ expect(store.state.round).toBe(0) // 700 ms is not enough at reduced motion
+ await vi.advanceTimersByTimeAsync(REDUCED_MOTION_ROUND_MS - ROUND_MS + 20)
+ expect(store.state.round).toBe(1)
+ })
+})
diff --git a/web/src/composables/usePlayback.ts b/web/src/composables/usePlayback.ts
new file mode 100644
index 0000000..1a3012a
--- /dev/null
+++ b/web/src/composables/usePlayback.ts
@@ -0,0 +1,150 @@
+import { useSimStore, type SimStore } from './useSimStore'
+
+// Playback (spec 5.4): one global round drives every panel, the chart
+// playhead, and the focus modal. Rounds advance on a rAF timer, 700 ms per
+// round at 1x (1000 ms under reduced motion, which also disables the
+// one-time autoplay). Playback never loops; at the final round the play
+// button becomes a replay affordance.
+
+export const ROUND_MS = 700
+export const REDUCED_MOTION_ROUND_MS = 1000
+
+const SPEED_CYCLE: Record = { 0.5: 1, 1: 2, 2: 0.5 }
+
+export function createPlayback(store: SimStore = useSimStore()) {
+ const { state } = store
+ let frameId: number | null = null
+ let lastAdvanceAt = 0
+ let autoplayDone = false
+
+ function prefersReducedMotion(): boolean {
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ }
+
+ function roundIntervalMs(): number {
+ return (prefersReducedMotion() ? REDUCED_MOTION_ROUND_MS : ROUND_MS) / state.speed
+ }
+
+ function stopFrameLoop() {
+ if (frameId !== null) cancelAnimationFrame(frameId)
+ frameId = null
+ }
+
+ function finishPlayback() {
+ state.playing = false
+ stopFrameLoop()
+ state.announcement = `Final: ${store.resultsSummary()}`
+ }
+
+ function onFrame(timestamp: number) {
+ if (!state.playing) {
+ frameId = null
+ return
+ }
+ if (timestamp - lastAdvanceAt >= roundIntervalMs()) {
+ lastAdvanceAt = timestamp
+ state.round += 1
+ if (state.round >= store.maxRounds()) {
+ state.round = Math.min(state.round, store.maxRounds())
+ finishPlayback()
+ return
+ }
+ }
+ frameId = requestAnimationFrame(onFrame)
+ }
+
+ function play() {
+ if (store.maxRounds() === 0 || state.playing) return
+ if (state.round >= store.maxRounds()) state.round = 0 // replay affordance
+ state.playing = true
+ lastAdvanceAt = performance.now()
+ frameId = requestAnimationFrame(onFrame)
+ }
+
+ function pause() {
+ if (!state.playing) return
+ state.playing = false
+ stopFrameLoop()
+ state.announcement = `Paused at round ${state.round} of ${store.maxRounds()}`
+ }
+
+ function toggle() {
+ if (state.playing) {
+ pause()
+ } else {
+ play()
+ }
+ }
+
+ // Stepping and seeking pause playback without a "paused" announcement;
+ // the seek announcement happens on scrub release (announceRound).
+ function quietPause() {
+ state.playing = false
+ stopFrameLoop()
+ }
+
+ function stepBack() {
+ quietPause()
+ state.round = Math.max(0, state.round - 1)
+ }
+
+ function stepForward() {
+ quietPause()
+ state.round = Math.min(store.maxRounds(), state.round + 1)
+ }
+
+ function seekTo(round: number) {
+ quietPause()
+ state.round = Math.min(store.maxRounds(), Math.max(0, Math.round(round)))
+ }
+
+ function announceRound() {
+ state.announcement = `Round ${state.round} of ${store.maxRounds()}`
+ }
+
+ function replay() {
+ quietPause()
+ state.round = 0
+ play()
+ }
+
+ function cycleSpeed() {
+ state.speed = SPEED_CYCLE[state.speed] ?? 1
+ }
+
+ // Once, after the first successful load (spec 5.1): render round 0, then
+ // play. Under reduced motion the page settles on the final round instead.
+ function autoplayOnce() {
+ if (autoplayDone) return
+ autoplayDone = true
+ if (store.maxRounds() === 0) return
+ if (prefersReducedMotion()) {
+ state.round = store.maxRounds()
+ return
+ }
+ state.round = 0
+ play()
+ }
+
+ return {
+ play,
+ pause,
+ toggle,
+ stepBack,
+ stepForward,
+ seekTo,
+ announceRound,
+ replay,
+ cycleSpeed,
+ autoplayOnce,
+ }
+}
+
+export type Playback = ReturnType
+
+let activePlayback: Playback | null = null
+
+export function usePlayback(): Playback {
+ activePlayback ??= createPlayback()
+ return activePlayback
+}
diff --git a/web/src/composables/useSimStore.ts b/web/src/composables/useSimStore.ts
index 18ece8c..47168d2 100644
--- a/web/src/composables/useSimStore.ts
+++ b/web/src/composables/useSimStore.ts
@@ -1,5 +1,6 @@
import { reactive } from 'vue'
import { ApiError, runScenario } from '@/lib/api'
+import { roundPct } from '@/lib/format'
import { graphKey } from '@/lib/graph'
import { parseUrlState, serializeUrlState } from '@/lib/urlState'
import { deepfakeSchoolPreset } from '@/presets/deepfake-school'
@@ -32,6 +33,7 @@ export interface SimState {
errorMessage: string | null // network/server failure, shown in the ErrorBanner
validationError: string | null // 400 from the engine, shown inline under the controls
urlStateInvalid: boolean // the opened link held malformed state; preset shown instead
+ announcement: string // text for the single polite live region (spec section 8)
}
function createPanelId(): string {
@@ -57,6 +59,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
errorMessage: null,
validationError: null,
urlStateInvalid: false,
+ announcement: '',
})
let debounceTimer: ReturnType | null = null
@@ -82,6 +85,19 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
}, 0)
}
+ // One spoken line per state of the world, e.g. "No program 83 percent,
+ // Random picks 58 percent". Used by the live region for run updates and
+ // by playback for the final announcement.
+ function resultsSummary(): string {
+ return state.panels
+ .map((panel) => {
+ const result = state.resultsByPanelId[panel.id]
+ return result ? `${panel.label} ${roundPct(result.reachedPct)} percent` : null
+ })
+ .filter(Boolean)
+ .join(', ')
+ }
+
function scheduleRun(panelId?: string) {
if (panelId === undefined) {
pendingPanelIds = null
@@ -144,6 +160,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
state.errorMessage = null
state.validationError = null
state.round = maxRounds()
+ state.announcement = `Updated: ${resultsSummary()}`
syncToUrl()
}
@@ -274,6 +291,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
preset,
effectiveConfig,
maxRounds,
+ resultsSummary,
initialize,
retry,
setBaseField,