From 0e3a14a0b353ad5329bf9f6ad7fe3147236e8c97 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Wed, 10 Jun 2026 17:15:38 +0200 Subject: [PATCH] web: playback, slice 4 of M3 usePlayback drives one global round on a rAF timer: 700 ms per round at 1x, speed pill cycling 0.5/1/2, play-once autoplay after the first load, no looping; at the final round the play button becomes replay. Under prefers-reduced-motion there is no autoplay, the page settles on the final round, and the cadence slows to 1000 ms. The PlayerBar is the mockup's centered pill with a native range scrubber (one tick per round, aria-valuetext, seeking pauses) and the global keyboard map: Space toggles, arrows step, Home/End jump; text inputs and native control handling are left alone. A single polite live region announces run updates, pauses, scrub releases, and the final state. Nodes reached in the current round pop in (scale 0.6 to 1, 250 ms ease-out) with a small deterministic stagger so each round reads as a wave. Per Justin's feedback the panel percentage and reached count now follow the playhead instead of sitting on the final outcome; the hero keeps the final numbers, since its sentence claims outcomes. --- web/src/App.vue | 10 +- web/src/components/NetworkView.vue | 45 ++- web/src/components/PlayerBar.vue | 331 ++++++++++++++++++ web/src/components/ScenarioPanel.vue | 24 +- .../composables/__tests__/usePlayback.spec.ts | 171 +++++++++ web/src/composables/usePlayback.ts | 150 ++++++++ web/src/composables/useSimStore.ts | 18 + 7 files changed, 736 insertions(+), 13 deletions(-) create mode 100644 web/src/components/PlayerBar.vue create mode 100644 web/src/composables/__tests__/usePlayback.spec.ts create mode 100644 web/src/composables/usePlayback.ts diff --git a/web/src/App.vue b/web/src/App.vue index be6b3cb..b229fa1 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -6,16 +6,20 @@ import FooterDisclaimer from '@/components/FooterDisclaimer.vue' import HeroHeadline from '@/components/HeroHeadline.vue' import LegendRow from '@/components/LegendRow.vue' import PanelGrid from '@/components/PanelGrid.vue' +import PlayerBar from '@/components/PlayerBar.vue' import ResultsTable from '@/components/ResultsTable.vue' import ScenarioToolbar from '@/components/ScenarioToolbar.vue' +import { usePlayback } from '@/composables/usePlayback' import { useSimStore } from '@/composables/useSimStore' import { useTheme } from '@/composables/useTheme' const store = useSimStore() +const playback = usePlayback() useTheme() // resolve and apply the theme before first paint of the app -onMounted(() => { - void store.initialize() +onMounted(async () => { + await store.initialize() + playback.autoplayOnce() }) @@ -27,6 +31,7 @@ onMounted(() => { + { /> +
{{ store.state.announcement }}
diff --git a/web/src/components/PlayerBar.vue b/web/src/components/PlayerBar.vue new file mode 100644 index 0000000..b895e23 --- /dev/null +++ b/web/src/components/PlayerBar.vue @@ -0,0 +1,331 @@ + + + + + diff --git a/web/src/components/ScenarioPanel.vue b/web/src/components/ScenarioPanel.vue index f91fbb9..9f25190 100644 --- a/web/src/components/ScenarioPanel.vue +++ b/web/src/components/ScenarioPanel.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import NetworkView from './NetworkView.vue' import { useSimStore } from '@/composables/useSimStore' -import { formatPct, roundPct } from '@/lib/format' +import { roundPct } from '@/lib/format' import type { PanelSpec } from '@/presets/types' const props = defineProps<{ panel: PanelSpec; accent: string }>() @@ -11,10 +11,19 @@ const store = useSimStore() const result = computed(() => store.state.resultsByPanelId[props.panel.id]) const numStudents = computed(() => store.effectiveConfig(props.panel).numStudents) + +// The numbers follow the playhead: they count what the network picture +// shows at the current round, reaching the final outcome on the last one. +const reachedCount = computed(() => { + const panelResult = result.value + if (!panelResult) return 0 + return panelResult.reachedAtRound.filter( + (reachedAt) => reachedAt >= 0 && reachedAt <= store.state.round, + ).length +}) +const reachedPctNow = computed(() => (reachedCount.value / numStudents.value) * 100) const tone = computed(() => - result.value && roundPct(result.value.reachedPct) <= store.preset.toneThresholdPct - ? 'good' - : 'bad', + roundPct(reachedPctNow.value) <= store.preset.toneThresholdPct ? 'good' : 'bad', ) interface PanelChip { @@ -48,12 +57,9 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value