diff --git a/.gitignore b/.gitignore
index 4d4680f..69917bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,6 @@
# stays tracked; `git restore internal/webdist/dist` after local testing)
/internal/webdist/dist/*
!/internal/webdist/dist/index.html
+
+# internal working docs (specs, handoffs, mockups) stay local-only
+/docs/
diff --git a/docs/m3-frontend-handoff.md b/docs/m3-frontend-handoff.md
deleted file mode 100644
index 842cd1f..0000000
--- a/docs/m3-frontend-handoff.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# Handoff: milestone 3 frontend (slices 2-12)
-
-You are implementing the spreadlab dashboard frontend. The design is fully
-decided and the backend is ready; this segment is Vue/TypeScript only.
-Repo root: grant-proposal/tool/ (paths below are relative to it).
-
-## State as of 2026-06-10
-
-- `docs/ui-spec.md` is the NORMATIVE spec: read it end to end before any
- code. Page layout, component tree, every interaction with
- trigger/behavior/states, visual tokens for both themes, URL scheme,
- accessibility checklist, responsive rules, and the slice order
- (section 10). Slice 1 is done; you start at slice 2.
-- `docs/mockup/rendered.html` is the approved visual reference. View it in
- a browser (or headless, see Method) in light, dark (`?dark`), and at
- phone width. Match its look; do not port its code.
-- `POST /api/scenario` is live (slice 1): `{config, strategy}` in,
- `{config, result, edges}` out, edges as deterministic `[from, to]`
- node-index pairs. 400 with `{"error": "..."}` on invalid input.
- Generated types in `web/src/types/` already include
- `ScenarioRequest`/`ScenarioResponse` (edges arrive as `number[][]`).
-- The current `web/src/App.vue` is the milestone 2 parity page. Slice 3
- replaces it; `ComparisonTable.vue` evolves into the collapsed
- ResultsTable (spec 4 and 8). Keep its test current, do not orphan it.
-- The Go side needs no changes in this segment. If one becomes necessary,
- raise it first; remember `go generate ./...` + commit the regenerated
- types (CI has a drift guard).
-
-## Method
-
-- Vue/TS is Justin's home turf: drive fast and mostly autonomously. No
- micro-briefs needed; keep commits per slice with clear messages, push
- after each slice (CI must stay green: type-check, lint, vitest, build,
- plus the Go jobs).
-- THE DESIGN BAR IS THE HARD PART. Justin rejected the first mockup as "a
- very weak mishmash of elements"; the approved one is the bar: one card
- system, real type scale, consistent spacing, both themes, mobile
- first-class. Never show boxes-on-a-page, not even as an intermediate
- state. Slice 3 is the make-or-break visual slice.
-- Self-check visually before claiming a visual slice done: screenshot the
- running app headlessly and compare against the mockup, e.g.
- `chromium --headless --disable-gpu --screenshot=/tmp/app.png
- --window-size=1366,900 http://localhost:5173` (also dark theme and
- `--window-size=390,1400`). Read the PNGs; if it does not look like the
- mockup, it is not done.
-- Dev stack runs in the shared tmux session "spreadlab" (window 1 runs
- ./dev.sh: API :8080, Vite :5173). Restart it there with C-c +
- `./dev.sh` via tmux send-keys; never spawn invisible background tasks.
-- Work through the accessibility checklist (spec section 8) as you build
- each piece; slice 12 is the audit, not the first time you think about it.
-- One shared `formatPct` (Math.round to whole percent) used everywhere;
- the mockup says 83% where Go's printf said 82% for 99/120, and that
- class of mismatch must not reach the UI.
-- Descriptive variable names; no em dashes anywhere.
-
-## Small open choices (decide with Justin via AskUserQuestion, batched)
-
-- d3-force dependency: `d3-force` + `d3-quadtree` only (recommended) vs
- full d3. The seeded random source (mulberry32, spec section 6) is
- hand-written either way.
-- Inter font: `@fontsource-variable/inter` (recommended) vs static weights.
-- Panel ids: `crypto.randomUUID()` (recommended, zero deps) vs nanoid.
-- Reach chart: hand-rolled SVG paths (recommended at this scale) vs a
- chart library.
-
-Everything else is decided in the spec; do not re-open decided items
-(rejected alternatives are listed in spec section 11).
-
-## First steps
-
-1. Read `docs/ui-spec.md` fully, view the mockup in both themes and at
- phone width.
-2. Batch the four open choices above into one AskUserQuestion round.
-3. Slice 2 (store + URL + debounced parallel runs) with Vitest coverage:
- it is pure logic, test it well; it carries every later slice.
-4. Slice 3, compared pixel-wise against the mockup before you call it done.
-5. Continue down spec section 10; each slice leaves the app shippable.
diff --git a/docs/m3-implementation-handoff.md b/docs/m3-implementation-handoff.md
deleted file mode 100644
index 52a8eef..0000000
--- a/docs/m3-implementation-handoff.md
+++ /dev/null
@@ -1,81 +0,0 @@
-# Handoff: milestone 3 implementation (from the UI design session)
-
-You wrote `docs/ui-design-brief.md` and asked for a design session. It ran on
-2026-06-10, every decision made with Justin via AskUserQuestion, and it is
-done: nothing UI-shaped is left open. This doc tells you what exists now,
-what changed relative to your milestone 3 assumptions, and where to start.
-
-## What exists now
-
-- `docs/ui-spec.md` is the normative spec. Page layout with wireframe,
- component tree, every interaction with trigger/behavior/states, API
- requirements, visual tokens for both themes, a testable accessibility
- checklist, 12 ordered implementation slices, and one-line rejected
- alternatives. Build from it without re-deciding; if you disagree with
- something, raise it with Justin, do not silently deviate.
-- `docs/mockup/index.html` is the approved visual reference (v2; Justin
- rejected v1 outright). It is a design artifact with stand-in layout and
- edges, not app code. To view: bake a live `/api/comparison` response into
- it (replace `__DATA__`, see `data.json` + `rendered.html` already there),
- open in a browser, `?dark` for the dark theme, narrow window for mobile.
- Match its look; do not port its code.
-
-## Deltas against what you probably assumed for milestone 3
-
-- The dashboard is not the fixed three-strategy comparison. Panels are a
- dynamic list (cap 6) of scenarios: one shared base `Config` plus sparse
- per-panel overrides and a strategy. The classic trio is just the default,
- loaded from a study preset module (`web/src/presets/deepfake-school.ts`)
- that owns all study copy, so no research question is hardcoded.
-- New endpoint needed, and it is slice 1, your Go territory:
- `POST /api/scenario` takes `{config, strategy}`, returns
- `{config, result, edges}` where `edges` is the deterministic topology as
- node-index pairs. This closes the topology gap flagged in the brief. The
- engine already has the graph; expose it. Same validation and 400-string
- behavior as comparison. tygo regen, determinism test on edges.
- `/api/comparison` stays as-is (parity table and external consumers).
-- The frontend fires one `/api/scenario` per panel in parallel, debounced
- 400 ms, atomic swap, keep-last-good on errors. No batch endpoint
- (rejected, one-liner in the spec).
-- The optimisation feature (your milestone 5) got its API shape reserved:
- `POST /api/optimize` goal-seek, request/response sketched in spec
- section 2. M3 ships no optimizer UI, only a layout that can host it.
-- In scope for M3 beyond what the brief listed: export (JSON, per-node CSV,
- PNG snapshot), both light and dark themes with a toggle, and mobile as a
- first-class target (Justin expects first contact by phone). Animated
- GIF/WebM export is explicitly post-M3.
-- The accessibility criteria your locked decisions called UNDEFINED are now
- defined: spec section 8 is the checklist, and slice 12 is the audit.
-- Pinia and Vue Router: decided not needed. Module-scope composable store,
- modal focus, one page.
-
-## Calls Justin made explicitly (do not relitigate)
-
-- Focus view is a modal overlay (recommendation was expand-in-place).
-- Sharing is address-bar-only, no Share button, no toast
- (`history.replaceState` after every successful run).
-- Both themes ship, not light-only.
-- Dynamic panels, export, and the optimizer hook all came from him
- unprompted; they are wanted, not gold-plating.
-
-## Method notes
-
-- Your working agreements stand: teaching-paced TDD on Go (slice 1 gets a
- micro-brief, failing test first, small annotated diffs, learning-log
- commit message); Vue/TS driven fast and mostly autonomously.
-- Justin's design bar is high. His words on the first mockup: "a very weak
- mishmash of elements"; he wants a clean, responsive SPA that "looks
- professional and feels amazing". The approved mockup is the bar: app
- shell, type scale, one card system, real SVG icons, consistent spacing.
- Never show him boxes-on-a-page, not even as an intermediate state demo.
-- Dev stack runs in the shared tmux session "spreadlab" (`./dev.sh`, API on
- :8080, Vite on :5173). Use it, do not spawn background tasks.
-- One shared percent formatter everywhere; the 82-vs-83 rounding mismatch
- already bit the mockup once (spec section 6).
-
-## First steps
-
-Read `docs/ui-spec.md` end to end, view the mockup in both themes and at
-phone width, then propose the slice 1 plan (the `/api/scenario` endpoint)
-and start the usual loop. Spec section 10 is the full M3 slice order; each
-slice leaves the app shippable.
diff --git a/docs/m4-handoff.md b/docs/m4-handoff.md
deleted file mode 100644
index 4179dfe..0000000
--- a/docs/m4-handoff.md
+++ /dev/null
@@ -1,103 +0,0 @@
-# Handoff: milestone 4 (single binary + deploy)
-
-You are shipping spreadlab to Justin's server. Milestone 3 is done; this
-segment turns the repo into one deployable artifact: a Go binary with the
-built frontend embedded, a public container image, and a Portainer stack
-behind Caddy. Repo root: grant-proposal/tool/ (paths below relative to it).
-
-## State as of 2026-06-10
-
-- All 12 M3 slices are merged and CI is green: the dashboard, playback,
- chart, controls, panel management, focus modal, tooltips, mobile pass,
- export, and the accessibility audit (results in commit d9b3812).
-- `docs/ui-spec.md` was amended in place where the implementation
- deliberately deviates (playback pacing and trickle in 5.4, seed range in
- 5.3, viewBox and layout forces in 6, `readingCaption` in 1). The spec is
- normative again; do not "fix" the app back toward old numbers.
-- Deploy shape is DECIDED in `grant-proposal/tool-handoff.md` (read it):
- multi-stage Dockerfile (node build, Go build with go:embed, minimal
- final image), a compose snippet for a Portainer stack joining the
- existing Caddy network, and a GitHub Actions job pushing a public image
- to ghcr.io on merge to main. No Docker in the dev loop: native
- `./dev.sh` (tmux session "spreadlab", API :8080 + Vite :5173) stays.
-- The UI already carries the "illustrative, not validated" framing
- everywhere, including PNG exports; deploy adds no copy.
-
-## Method
-
-- This is Go/infra territory: Justin is learning Go from the diffs, so
- the M1/M2 working agreements apply again (unlike the fast autonomous
- Vue pace of M3). Micro-brief each new concept before the code:
- go:embed, http.FileServer over an embedded FS, multi-stage builds,
- GHCR auth and the packages:write permission. Small annotated diffs,
- one slice per commit, push after each, CI stays green.
-- Verify each slice for real: run the binary and curl both / and /api;
- build the image once locally and run it; after the GHCR job lands,
- pull the public image and run it cold.
-- `go generate ./...` + commit if generated types ever change (CI drift
- guard); nothing in this segment should need it.
-
-## The one design decision to settle first
-
-`//go:embed` requires the embedded directory to exist at compile time,
-but `web/dist/` is gitignored, so a naive embed breaks `go test ./...`
-for anyone (and CI) without a frontend build. Decide with Justin via
-AskUserQuestion, batched with the open choices below. Recommended: keep
-the embed in its own small package (e.g. `internal/webdist`) with a
-committed one-line placeholder `dist/index.html` ("run `npm run build`");
-the Dockerfile overwrites it with the real build. Alternatives: build
-tags (dev binary without embed) or making CI build the frontend before
-every Go job.
-
-## Suggested slices (each leaves main shippable)
-
-1. **Serve the SPA from Go**: embed web/dist, serve it on / alongside
- /api (single page, no client routes; unknown paths can 404). Local
- proof: `npm run build`, `go run ./cmd/spreadlab`, one origin serving
- both. The vite dev proxy keeps working unchanged.
-2. **Dockerfile**: multi-stage (node build → Go build → minimal final
- image), plus .dockerignore. Build and run locally once to verify;
- Docker still stays out of the daily dev loop.
-3. **GHCR workflow**: build and push ghcr.io/justinzeus/spreadlab on
- push to main (permissions: packages: write), then make the package
- public. Verify with a cold pull.
-4. **Portainer stack**: compose snippet joining the external Caddy
- network plus the Caddyfile entry, documented in the README (deploy
- section). Justin applies it in Portainer; verify the public URL,
- both themes, a shared link with panels and focus, and a PNG export
- from the hosted app.
-
-## Small open choices (decide with Justin via AskUserQuestion, batched)
-
-- Embed strategy for web/dist (see above; placeholder file recommended).
-- Final image base: scratch vs distroless vs alpine (distroless static
- recommended: CA certs and tzdata without a shell).
-- Image tags: latest + commit SHA on main (recommended) vs semver tags.
-- Listen port and a /healthz endpoint for compose healthchecks
- (recommended: keep :8080, add the trivial healthz handler).
-- The public hostname, and whether the Caddyfile entry is managed by
- hand on the server or checked into the repo as documentation.
-
-## Traps learned in M3 (do not rediscover)
-
-- npm ci on CI rejects a lockfile that was updated incrementally after
- installs (missing optional deps like @emnapi/*); regenerate
- package-lock.json from scratch (`rm -rf node_modules package-lock.json
- && npm install`) whenever dependencies change.
-- Prettier must never touch `web/src/types/` (generated; CI drift guard).
- `web/.prettierignore` already enforces this; keep it.
-- Headless theme screenshots: `web/public/__light.html` and
- `__dark.html` (gitignored one-liners that set localStorage and
- redirect; recreate if missing). Screenshot light, dark, and 390 px
- wide before calling visual work done.
-- Compound shell commands need every pipe segment allowlisted, not just
- the chromium part.
-- Playback feel is perceptual: if any slice touches animation, have
- Justin look at it running in his browser early, not after polish.
-
-## First steps
-
-1. Read `grant-proposal/tool-handoff.md` (deploy shape, working
- agreements) and skim README + .github/workflows/ci.yml.
-2. Batch the open choices above into one AskUserQuestion round.
-3. Slice 1 with its micro-brief on go:embed; continue down the list.
diff --git a/docs/mockup/data.json b/docs/mockup/data.json
deleted file mode 100644
index cbed859..0000000
--- a/docs/mockup/data.json
+++ /dev/null
@@ -1 +0,0 @@
-{"config":{"numStudents":120,"edgesPerNode":3,"triangleProb":0.45,"forwardProb":0.38,"numEducated":36,"origin":0,"graphSeed":17,"thresholdSeed":2,"educationSeed":1},"results":[{"strategy":"none","educated":[],"reachedAtRound":[0,4,4,2,3,3,5,2,3,4,4,4,1,7,5,1,5,6,5,5,5,2,3,7,3,4,2,4,4,3,4,4,5,6,2,5,1,5,-1,2,6,5,3,4,-1,5,4,5,4,4,3,5,3,-1,6,5,3,-1,4,5,-1,5,-1,5,2,-1,1,4,3,4,3,5,2,4,4,3,3,-1,5,3,-1,5,-1,-1,5,3,3,-1,6,3,5,5,-1,-1,6,2,5,5,8,5,-1,5,6,6,4,7,-1,4,4,-1,-1,5,3,-1,1,-1,3,3,-1,5],"numReached":99,"numRounds":9,"reachedPct":82.5},{"strategy":"random","educated":[2,4,6,7,8,10,21,23,24,27,28,33,37,39,40,43,48,50,53,56,61,62,63,68,74,79,80,85,87,89,90,97,101,102,111,117],"reachedAtRound":[0,4,4,2,3,3,5,2,3,-1,7,4,1,7,5,1,5,6,-1,6,5,2,-1,7,3,7,2,5,5,-1,-1,6,6,6,2,5,1,5,-1,2,6,-1,3,4,-1,-1,4,-1,4,-1,-1,5,-1,-1,6,5,3,-1,-1,-1,-1,-1,-1,-1,2,-1,1,-1,5,4,-1,5,2,-1,-1,-1,3,-1,-1,3,-1,5,-1,-1,6,3,-1,-1,6,3,7,-1,-1,-1,7,2,-1,7,8,5,-1,5,6,-1,-1,7,-1,4,4,-1,-1,5,-1,-1,1,-1,3,-1,-1,-1],"numReached":70,"numRounds":9,"reachedPct":58.333333333333336},{"strategy":"most-connected","educated":[1,2,3,4,5,6,7,8,9,10,11,12,13,15,16,19,20,21,22,23,24,25,26,27,28,30,31,32,33,35,37,38,39,45,47,77],"reachedAtRound":[0,-1,-1,-1,-1,-1,-1,2,-1,-1,-1,-1,1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1],"numReached":7,"numRounds":3,"reachedPct":5.833333333333333}]}
\ No newline at end of file
diff --git a/docs/mockup/index.html b/docs/mockup/index.html
deleted file mode 100644
index 9b21301..0000000
--- a/docs/mockup/index.html
+++ /dev/null
@@ -1,445 +0,0 @@
-
-
-
How a non‑consensual deepfake spreads through a school
-
One simulated year group, 120 students. Educate the same 30% of them, but change who:
- the fake reaches 82% of the school with no program,
- 58% educating at random, and
- 6% educating the best‑connected students.
- Illustrative model, not validated
-
-
-
-
-
-
Scenarios
-
-
-
-
-
-
-
-
- Forwarded the fake
- Educated to refuse
- Not reached
- Origin
-
-
-
-
-
-
-
-
- Round 9 of 9
- 1×
-
-
-
-
-
Reach over time
-
-
-
-
World
-
-
-
- 38%
-
-
-
-
- 36 · 30%
-
-
- Advanced
-
- Students120
- Friends per student3
- Clique tendency0.45
- First poster#0
- Friendship network17
- Who resists2
-
How a non‑consensual deepfake spreads through a school
-
One simulated year group, 120 students. Educate the same 30% of them, but change who:
- the fake reaches 82% of the school with no program,
- 58% educating at random, and
- 6% educating the best‑connected students.
- Illustrative model, not validated
-
-
-
-
-
-
Scenarios
-
-
-
-
-
-
-
-
- Forwarded the fake
- Educated to refuse
- Not reached
- Origin
-
-
-
-
-
-
-
-
- Round 9 of 9
- 1×
-
-
-
-
-
Reach over time
-
-
-
-
World
-
-
-
- 38%
-
-
-
-
- 36 · 30%
-
-
- Advanced
-
- Students120
- Friends per student3
- Clique tendency0.45
- First poster#0
- Friendship network17
- Who resists2
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/ui-design-brief.md b/docs/ui-design-brief.md
deleted file mode 100644
index dd0ad3e..0000000
--- a/docs/ui-design-brief.md
+++ /dev/null
@@ -1,96 +0,0 @@
-# UI/UX design session brief
-
-You are running a design session for spreadlab's milestone 3 dashboard. The
-deliverable is a written spec, `docs/ui-spec.md`, that an implementation
-session can execute without re-deciding anything. Do NOT write application
-code in this session.
-
-## How to run this session
-
-- Walk through the UX with the user top-down: audience and core message
- first, then page layout, then each interaction, then states and edge
- cases. One topic at a time.
-- Use AskUserQuestion aggressively for every meaningful choice, with a
- recommended option first. Use ASCII mockups in option previews when
- comparing layouts.
-- The user is a developer (Vue/TS background), not a designer; explain
- design trade-offs the way you would explain code trade-offs.
-- No em dashes in anything you write.
-
-## What spreadlab is
-
-A self-hosted dashboard running an agent-based model live: a non-consensual
-deepfake spreads through a simulated school year group (120 students), and
-an education program changes who forwards it. The headline result: the same
-education budget reaches 82% / 58% / 6% of the school depending on who gets
-educated (no program / random 30% / best-connected 30%). The product goal
-is making that lever visceral. Always framed "illustrative, not validated".
-
-Audience, in order: grant-pitch audience and course graders (first contact,
-must land in seconds), curious public on the hosted demo, the author.
-
-## What already exists (look at it)
-
-Paths below are given from the course workspace root
-(/home/jv/src/uni/ai-in-open-society); the repo itself is
-grant-proposal/tool/.
-
-- Run ./dev.sh from grant-proposal/tool/ and open http://localhost:5173 to
- see the current parity page (a static table of the three scenarios).
-- The Python prototype's rendered output defines the inherited visual
- language: grant-proposal/pitch/figure/out/spread.png and spread.gif.
- Read the PNG; it is the look to inherit. Teal donut ring = educated,
- rose = forwarded the fake, haloed node = origin, slate = unreached;
- colourblind-safe via shape, not colour alone. Modern-dashboard look,
- Tailwind-ish palette (see constants in
- grant-proposal/pitch/figure/spread_figure.py).
-- API: GET /api/config/default, POST /api/comparison (full Config in, all
- three strategies' Results out). Result.reachedAtRound gives per-node
- activation rounds, exactly what an animation needs. Types in
- web/src/types/ are generated from Go structs.
-- KNOWN GAP: the API does not yet expose graph topology (edges), which any
- network view needs. The spec should state what the frontend needs from a
- new/extended endpoint; the implementation session will add it.
-
-## Locked decisions the spec must respect (from grant-proposal/tool-handoff.md)
-
-- Layout is computed in the frontend with a force simulation (d3-force or
- equivalent) seeded from the config: a shared URL must render the same
- picture.
-- Config is the whole truth and goes in the URL: shareable, reproducible.
-- Vue 3 + TypeScript, lean and conventional; Pinia/Router only when the
- design actually requires them (note it in the spec if so).
-- No shape redefinition in the frontend: all data types come generated
- from Go.
-- Quality bar includes accessibility, and the concrete criteria are
- UNDEFINED so far. Defining them is part of this design session (keyboard
- operation, reduced-motion behaviour for the animation, contrast,
- screen-reader treatment of results at minimum).
-
-## Questions the spec must answer
-
-1. Page composition: prototype-style three panels side by side, or one
- network view with a strategy switcher, or something better? What is
- above the fold at first contact?
-2. Which config levers get visible controls first, which live in an
- "advanced" area (seeds?), and what control types (sliders, steppers)?
-3. Animation: autoplay or user-initiated? Play/pause/scrub/speed? What does
- round-by-round progression look like; what happens at reduced motion?
-4. Metrics: what numbers/curves accompany the picture, and how do they
- update while animating?
-5. Sharing UX: how does config-in-URL surface to the user (copy button,
- live-updating address bar)?
-6. States: loading, API unreachable, invalid config (the API returns 400
- with an error string), tiny screens.
-7. How is "illustrative, not validated" communicated without undermining
- the demo?
-8. Accessibility acceptance criteria (the concrete, testable list).
-
-## Output format for docs/ui-spec.md
-
-Page layout (with a final ASCII wireframe), component tree with
-responsibilities, every interaction specced (trigger, behaviour, states),
-data requirements from the API (including the topology gap), visual tokens
-(palette, shape encodings), accessibility criteria as a checklist, and an
-ordered implementation slice list for milestone 3. Decisions only, no
-options left open; record rejected alternatives in one line each at the end.
diff --git a/docs/ui-spec.md b/docs/ui-spec.md
deleted file mode 100644
index 6fef963..0000000
--- a/docs/ui-spec.md
+++ /dev/null
@@ -1,601 +0,0 @@
-# spreadlab UI specification (milestone 3)
-
-Status: decided. This spec is the output of the design session run from
-`docs/ui-design-brief.md`. An implementation session should be able to build
-the dashboard from this document without re-deciding anything. Rejected
-alternatives are listed at the end, one line each.
-
-The normative visual reference is the approved mockup in `docs/mockup/`
-(`index.html` is the source; build `rendered.html` by replacing `__DATA__`
-with a live `/api/comparison` response). The mockup is a design artifact:
-its network layout and edges are stand-ins, its components are static. Match
-its look (spacing, type scale, card system, both themes), not its code.
-
----
-
-## 1. Product shape
-
-A single-page Vue 3 + TypeScript dashboard. One page, no routes. The page
-shows a dynamic list of simulation scenarios ("panels") that share a base
-config; each panel may override any config field. The default experience is
-the grant story: three panels (no program, educate 30% at random, educate
-the best-connected 30%) over the default config, animated once on load.
-
-Audience order: grant-pitch audience and graders (message must land in
-seconds, without interaction), curious public on the hosted demo (often on
-a phone), the author.
-
-### Study presets, no hardcoded research question
-
-All study-specific copy and the initial scenario set live in a preset
-module, not in components: `web/src/presets/deepfake-school.ts`, typed as
-`StudyPreset`:
-
-```ts
-interface StudyPreset {
- headline: string // hero h1
- narrative: string // hero paragraph, may contain {0} {1} {2}...
- // placeholders, replaced by each panel's
- // reached% (same formatter as the panels)
- disclaimerShort: string // badge text: "Illustrative model, not validated"
- disclaimerLong: string // About popover body (2-4 sentences)
- readingCaption: string // one quiet line under the legend explaining
- // how to read the dots (added 2026-06-10)
- toneThresholdPct: number // reached% <= threshold renders "good" (teal),
- // above renders "bad" (rose); 30 for this study
- base: Config // from generated types, the default world
- panels: PanelSpec[] // initial panels (labels + overrides)
-}
-```
-
-The app imports exactly one preset. A different research question is a new
-preset file plus a changed import, zero component changes. `StudyPreset`
-and `PanelSpec` are frontend-only types (they describe presentation, not
-simulation data, so they do not violate the no-shape-redefinition rule;
-`Config`, `Result`, `Strategy` stay imported from the generated types).
-
-### Panel model
-
-```ts
-interface PanelSpec {
- id: string // stable client id (nanoid-style)
- label: string // user editable
- strategy: Strategy // from generated types
- overrides: Partial // sparse diff against base
-}
-```
-
-Effective config for a panel = `{ ...base, ...overrides }`. Panels that do
-not override `numStudents`, `edgesPerNode`, `triangleProb`, or `graphSeed`
-share the base graph and therefore the identical layout; differences then
-read as the experiment, not as noise. Maximum 6 panels; the Add button
-disables at the cap with a tooltip "Maximum 6 scenarios".
-
----
-
-## 2. Data requirements from the API
-
-The API currently exposes `GET /api/config/default` and
-`POST /api/comparison`. Neither returns graph topology, and comparison is
-locked to the three fixed strategies of one config. Milestone 3 needs one
-new endpoint; the implementation session adds it Go-side and regenerates
-types with tygo.
-
-### New: `POST /api/scenario`
-
-Request body (Go struct, tygo-generated for the frontend):
-
-```jsonc
-{ "config": { /* Config */ }, "strategy": "random" }
-```
-
-Response:
-
-```jsonc
-{
- "config": { /* echoed effective Config */ },
- "result": { /* Result, as today */ },
- "edges": [[0, 5], [0, 12], ...] // undirected, node-index pairs,
- // deterministic from the config's
- // graph fields
-}
-```
-
-- Validation and 400-with-error-string behavior identical to comparison.
-- `edges` is small (roughly `numStudents * edgesPerNode` pairs) and is
- always included; the frontend deduplicates layout work by hashing the
- graph-shaping fields (`numStudents`, `edgesPerNode`, `triangleProb`,
- `graphSeed`), so panels sharing a graph compute one layout.
-- The frontend issues one request per panel, in parallel
- (`Promise.all`), debounced 400 ms after the last input change, and swaps
- all results in atomically when every request has settled.
-- `GET /api/config/default` keeps seeding the preset's `base` fallback.
-- `POST /api/comparison` remains for the parity table and API consumers.
-
-### Reserved: `POST /api/optimize` (post-M3, design hook only)
-
-Goal-seek endpoint: body `{ base: Config, strategy, goal: { maxReachedPct,
-optimize: "numEducated" } }`, response: best config plus the candidate
-sweep (config + reachedPct per candidate) so the UI can show its work as a
-curve. Milestone 3 ships no UI for this; the controls card layout simply
-leaves room for one extra button row, nothing more.
-
----
-
-## 3. Page layout
-
-Desktop wireframe (mobile differences in section 9):
-
-```
-┌──────────────────────────────────────────────────────────────────────┐
-│ ◆ spreadlab (ⓘ Illustrative model, not validated) ☾ ⌥src │ app bar, sticky
-├──────────────────────────────────────────────────────────────────────┤
-│ How a non-consensual deepfake spreads through a school │ hero (from preset)
-│ One simulated year group, 120 students. Educate the same 30%, │
-│ but change who: 83% ... 58% ... 6% ... │ live numbers
-│ │
-│ SCENARIOS [↓ Export] [+ Add] │ toolbar
-│ ┌──────────────────┬──────────────────┬──────────────────┐ │
-│ │ ▪ No program ⋮ │ ▪ Educate rand ⋮ │ ▪ Educate conn ⋮ │ │ panel cards
-│ │ 83% │ 58% │ 6% │ │
-│ │ 99/120 · 9r · 0e │ 70/120 · 9r · 36e│ 7/120 · 3r · 36e │ │
-│ │ [override chips] │ [override chips] │ [override chips] │ │
-│ │ (network SVG) │ (network SVG) │ (network SVG) │ │
-│ └──────────────────┴──────────────────┴──────────────────┘ │
-│ ● Forwarded ◎ Educated · Not reached ◉ Origin │ legend
-│ │
-│ ( ↻ ⏮ ▶ ⏭ ──●──·──·──·── Round 9 of 9 1× ) │ player pill, centered
-│ │
-│ ┌─ REACH OVER TIME ────────────────┐ ┌─ WORLD ───────────────────┐ │
-│ │ (overlaid curves, playhead) │ │ Chance to forward ──● │ │
-│ │ │ │ Education budget ──● │ │
-│ │ │ │ ▾ Advanced (6 fields) │ │
-│ └──────────────────────────────────┘ └───────────────────────────┘ │
-│ ▸ Data table (collapsed) │
-│ Illustrative (not validated output) · toy model spreadlab · │ footer
-└──────────────────────────────────────────────────────────────────────┘
-```
-
-Above the fold at 1366x768: app bar, hero, toolbar, the full panel row,
-legend, player. Chart and controls may start below the fold; the story
-must not.
-
----
-
-## 4. Component tree
-
-```
-App
-├── AppBar
-│ ├── Wordmark (logo glyph + "spreadlab")
-│ ├── DisclaimerBadge (opens AboutPopover; hidden on mobile)
-│ ├── ThemeToggle
-│ └── SourceLink (repo icon link)
-├── HeroHeadline (preset headline + narrative with live %)
-│ └── DisclaimerNote (mobile only: same popover trigger)
-├── MobileScoreStrip (mobile only, sticky)
-├── ScenarioToolbar ("Scenarios", ExportMenu, AddScenarioButton)
-├── ErrorBanner (failed refresh / API unreachable)
-├── PanelGrid
-│ └── ScenarioPanel (xN)
-│ ├── PanelHeader (accent swatch, label, kebab PanelMenu)
-│ ├── PanelStats (big %, meta row, override chips)
-│ └── NetworkView (SVG: edges, nodes; emits hover/tap)
-├── LegendRow
-├── PlayerBar (replay, step back, play/pause, step fwd,
-│ RoundScrubber, round counter, speed pill)
-├── ReachChart (overlaid cumulative curves + playhead)
-├── ControlsCard
-│ ├── LeverSlider (x2) (forwardProb, numEducated)
-│ └── AdvancedFields (collapsible: students, edges/node,
-│ clique tendency, origin, 3 seeds w/ dice)
-├── ResultsTable (collapsible; accessible data table,
-│ evolved from existing ComparisonTable)
-├── FocusModal (enlarged ScenarioPanel + PlayerBar dock)
-├── PanelEditorPopover (per-panel config diff editor)
-├── NodeTooltip (single instance, positioned per hover)
-└── FooterDisclaimer
-
-composables (module-scope singletons, no Pinia):
-├── useSimStore state: base, panels[], resultsByPanelId, edgesByGraphHash,
-│ round, playing, speed, focusPanelId, hoveredNode,
-│ runState (idle|running|error), errorMessage
-│ actions: setBaseField, add/update/remove/duplicatePanel,
-│ run (debounced), syncToUrl/fromUrl
-├── useLayout seeded d3-force layout per graph hash (cached)
-└── usePlayback rAF round timer honoring speed + reduced motion
-```
-
-State lives in `useSimStore` (a plain `reactive` module singleton). No
-Pinia, no Vue Router: one page, one store, modal focus is store state.
-
----
-
-## 5. Interactions, one by one
-
-Every interaction lists trigger, behavior, and states.
-
-### 5.1 Initial load
-- Trigger: page open.
-- Behavior: parse URL state (section 7); if absent, use the preset. Fetch
- `/api/scenario` per panel. While loading, panels render as skeleton
- cards (surface block, shimmer, fixed height) so layout does not jump.
- When all results land: compute layouts, render round 0, then autoplay
- (5.4). Hero percentages fill in with the results.
-- States: success (autoplay), API unreachable (centered message in the
- panel area: "Could not reach the spreadlab API at /api", retry
- button, nothing else dimmed since nothing rendered yet), invalid URL
- state (fall back to preset, show ErrorBanner "Link contained an invalid
- configuration, showing defaults").
-
-### 5.2 Changing a base lever (sliders, advanced fields)
-- Trigger: input on any ControlsCard field.
-- Behavior: update store immediately (slider thumb moves), debounce 400 ms
- after the last change, then re-run all panels in parallel. While
- running: panels dim to 60% opacity with a small spinner in the toolbar;
- interaction stays enabled. On success: results swap in atomically,
- animation resets to the final round (no autoplay re-trigger), URL
- updates via `history.replaceState`. Hero numbers update.
-- States: success; network failure (keep last good results undimmed,
- ErrorBanner "Could not update: " with a Retry button); 400
- invalid config (keep last good results, attach the API error string
- inline under the controls card header in rose, mark the offending field
- if identifiable by name match, no banner).
-
-### 5.3 Seed rerolls
-- Trigger: dice button next to a seed field (graph seed "Friendship
- network", threshold seed "Who resists", education seed "Random picks").
-- Behavior: set that seed to a fresh random value in 0-9999 (amended
- 2026-06-10: a full uint32 made fields and URLs unreadable), then as 5.2.
- Rerolling
- the graph seed invalidates the layout cache for affected panels; the new
- layout fades in (200 ms opacity crossfade, skipped under reduced
- motion). A text button "Reroll world" rerolls all three seeds at once.
-- States: as 5.2.
-
-### 5.4 Playback
-- Trigger: autoplay once after first successful load; afterwards only via
- player controls.
-- Behavior (amended 2026-06-10 after live review): rounds advance every
- 1000 ms at 1x (speed pill cycles 0.5x, 1x, 2x). Within a round, each
- node reached that round gets its own deterministic appearance moment
- (full-avalanche hash of the node index) spread across the first 95% of
- the interval; its unreached dot stays put until that exact frame, then
- the rose dot fades and scales in (0.5 to 1.0) over 30% of the interval.
- A round therefore reads as a continuous trickle, never a cohort blink.
- The panel percentage, reached count, mobile strip, and focus modal
- count via the same predicate, so the numbers tick up live exactly as
- dots appear. Educated rings are visible from round 0 (they exist before
- the spread). Panels whose run ended before the global max round hold
- their final state. At the end, playback stops on the final round (no
- loop), play button becomes replay affordance. The hero keeps the final
- outcomes (its sentence claims outcomes, not playback state).
-- Player controls: replay (jump to round 0 and play), step back, play /
- pause toggle, step forward, scrubber (one tick per round, drag or click
- to seek; seeking pauses), round counter "Round 4 of 9" (global max),
- speed pill.
-- Keyboard (when focus is inside the player, plus global except in text
- inputs): Space toggles play/pause, ArrowLeft/ArrowRight step one round,
- Home/End jump to first/final round. The scrubber is a native
- `input[type=range]` so arrow keys work on it natively;
- `aria-valuetext="Round 4 of 9"`.
-- Reduced motion (`prefers-reduced-motion: reduce`): no autoplay, page
- loads at the final round; play still works but rounds swap discretely
- (no tween, no scale); the 1000 ms cadence becomes 1200 ms at 1x.
-- States: round state is global and drives every panel, the chart
- playhead, and the modal simultaneously. Round is not stored in the URL.
-
-### 5.5 Node hover / tap
-- Trigger: pointerenter on a node (desktop), tap (touch). SVG circles get
- a transparent 12 px-radius hit halo so 4 px dots are tappable.
-- Behavior: NodeTooltip appears near the pointer: "Student 17", state in
- this panel ("Forwarded in round 3" / "Educated, refused" / "Not
- reached"; "Origin, posted it" for the origin), and "5 friends" (degree,
- computed from edges). The same student gets a 2 px ink outline ring in
- every panel simultaneously (the cross-panel echo: the same kid, reached
- in one world, safe in another).
-- Dismiss: pointerleave, tap elsewhere, Esc. No click-to-pin in M3.
-- States: tooltips are pointer-only; the equivalent information is
- available non-visually through the ResultsTable (per-panel aggregate)
- and is acceptable per the accessibility decision (8).
-
-### 5.6 Add scenario
-- Trigger: "+ Add scenario" button in the toolbar.
-- Behavior: append a panel cloned from base (`strategy: "none"`, empty
- overrides, label "Scenario N", next free accent color), run it, and
- open its PanelEditorPopover anchored to the new panel. At 6 panels the
- button disables with tooltip "Maximum 6 scenarios".
-- States: while its first run is in flight the new panel shows the
- skeleton card.
-
-### 5.7 Panel menu and editor
-- Trigger: kebab menu on a panel header: Edit, Rename, Duplicate, Remove.
-- Behavior:
- - Edit opens PanelEditorPopover: strategy select (none / random /
- most-connected) plus every Config field as a row showing the base
- value; typing a different value creates an override (row highlights,
- a reset icon appears per overridden field). Overrides render as chips
- on the panel card ("numEducated · 60"). Apply follows the same
- debounced-run path as 5.2 but re-runs only that panel.
- - Rename: inline edit of the label in the popover header.
- - Duplicate: copy panel with " copy" suffix, next accent.
- - Remove: deletes immediately (no confirm; the action is cheap to
- redo and the URL history keeps the old state one paste away). The
- last remaining panel cannot be removed (menu item disabled).
-- States: editor validation mirrors the API (400 text shown inline in the
- popover); a panel whose run failed shows its last good result plus a
- small rose dot on the kebab.
-
-### 5.8 Focus modal
-- Trigger: click anywhere on a panel's network area (cursor: zoom-in), or
- "Focus" in the panel menu.
-- Behavior: modal overlay (scrim `rgba(15,23,42,.5)`), panel rendered
- large (max 90 vw / 85 vh), same SVG and round state, with the PlayerBar
- docked at the modal footer (same store state, both bars stay in sync;
- the background one is inert under the scrim). Close via X button, Esc,
- or scrim click. `focusPanelId` is part of URL state, so a shared link
- can open pre-focused.
-- States: focus trap inside the modal; on close, focus returns to the
- panel that opened it. `aria-modal="true"`, labelled by the panel label.
-
-### 5.9 Export
-- Trigger: "Export" menu in the toolbar: "Data (JSON)", "Per-node CSV",
- "Picture (PNG)".
-- Behavior:
- - JSON: downloads `spreadlab-export.json`: `{ base, panels, results }`
- exactly as held in the store (configs echoed from the API).
- - CSV: `spreadlab-nodes.csv`, columns `panel,label,node,educated,
- reachedAtRound` (one row per panel x node; -1 kept as -1).
- - PNG: renders the current view at the current round to a canvas
- (serialize each panel SVG, draw side by side with panel labels and
- percentages, current theme colors, legend row, disclaimer footer
- line) and downloads `spreadlab.png` at 2x resolution.
-- States: menu items disable while a run is in flight (export captures
- settled state only). Animated WebM/GIF export is post-M3 (rejected
- list).
-
-### 5.10 Sharing
-- Trigger: none. The address bar is the share UI.
-- Behavior: after every successful run and on panel/focus changes, the
- full state serializes into the URL query via `history.replaceState`
- (never `pushState`, no history spam). Copying the URL at any time
- reproduces the exact view: same seeds, same layout (seeded force), same
- panels, same focus. No Share button, no toast.
-
-### 5.11 Theme
-- Trigger: ThemeToggle in the app bar.
-- Behavior: default follows `prefers-color-scheme`; toggle overrides and
- persists to `localStorage` (`spreadlab-theme`). Theme is not URL state.
- Both themes use the token table in section 6; node semantics are
- identical in both.
-
-### 5.12 About / disclaimer
-- Trigger: DisclaimerBadge (app bar, desktop) or DisclaimerNote line under
- the hero (mobile).
-- Behavior: small popover, `disclaimerLong` from the preset: what the
- model is (a seeded agent-based toy simulation with parameters chosen
- for illustration), what it is not (a validated prediction of real
- schools), and a link to the proposal/repo. The italic footer line
- ("Illustrative (not validated output)") is always present and is
- included in PNG exports. The headline itself carries no caveat.
-
----
-
-## 6. Visual tokens
-
-Inherited from the prototype figure (`grant-proposal/pitch/figure/
-spread_figure.py`) and extended for dark mode. Implement as CSS custom
-properties on `:root` / `.dark`.
-
-| Token | Light | Dark | Use |
-|---|---|---|---|
-| `--bg` | `#F8FAFC` | `#0B1120` | page background |
-| `--surface` | `#FFFFFF` | `#111A2E` | cards, app bar |
-| `--border` | `#E2E8F0` | `#1E293B` | card borders |
-| `--border-soft` | `#EEF2F7` | `#1A2438` | inner dividers, chart grid |
-| `--edge` | `#E6E9EF` | `#243349` | network edges |
-| `--unreached` | `#D8DFE9` | `#3B4A61` | unreached node fill |
-| `--spread` | `#F43F5E` | `#FB7185` | forwarded nodes, bad % |
-| `--edu` | `#14B8A6` | `#2DD4BF` | educated rings, good %, slider accents |
-| `--ink` | `#0F172A` | `#F1F5F9` | headings, primary buttons |
-| `--ink-2` | `#334155` | `#CBD5E1` | body, labels |
-| `--ink-3` | `#64748B` | `#94A3B8` | secondary text (min for informative text) |
-| `--ink-4` | `#94A3B8` | `#64748B` | decorative/duplicated text only (fails AA for body sizes; never sole carrier of information) |
-
-Panel accent palette (categorical, deliberately distinct from the rose and
-teal node semantics; used for the header swatch, chart line, and mobile
-chip): `#6366F1`, `#F59E0B`, `#0EA5E9`, `#8B5CF6`, `#EC4899`, `#84CC16`,
-assigned by panel position, recycled on remove.
-
-Node encodings (shape carries meaning, color reinforces it; colorblind-safe
-by shape alone):
-- Forwarded: filled disc, `--spread`, r 4.
-- Educated, not reached: donut, `--surface` fill, 2.2 px `--edu` stroke.
-- Educated but reached (origin educated edge case): rose fill inside the
- teal donut.
-- Not reached: small disc, `--unreached`, r 3.
-- Origin: rose disc with a 1.4 px rose halo ring at r 7.5.
-
-Type: `Inter, system-ui, sans-serif` (self-host Inter via fontsource;
-system-ui fallback). Hero h1 30/700/-0.022em (22 on mobile). Panel
-percentage 40/700 tabular. Section headings 13/600 uppercase tracked.
-Body 15, secondary 13. All numerals `font-variant-numeric: tabular-nums`.
-
-Surfaces: radius 16 (cards) / 10 (buttons, fields) / 999 (pills, player).
-Shadows: resting `0 1px 2px rgba(15,23,42,.05), 0 4px 16px -8px
-rgba(15,23,42,.08)`; lifted (hover, modal, mobile player) deeper per
-mockup. Spacing on an 8 px-ish scale as in the mockup; page max-width
-1240 px, padding 28 px.
-
-Number formatting: one shared `formatPct` (round to whole percent) used by
-hero, panels, strip, chart labels, and table. No other rounding anywhere.
-
-Network rendering: inline SVG (not canvas), viewBox 380x230 (amended
-2026-06-10: taller than the mockup's 190 for breathing room). Layout via
-d3-force (`forceLink`, `forceManyBody`, collision, plus anisotropic
-`forceX`/`forceY` pulls so the cloud settles into the wide card shape
-organically and the fit stays uniform-scale; axis stretching reads as
-line patterns), run synchronously for a fixed 300 ticks with
-`simulation.randomSource(mulberry32(graphSeed))` and seeded initial
-positions, so identical configs give identical pictures on every machine
-(the shared-URL guarantee). Layout results cached per graph hash.
-
----
-
-## 7. URL state scheme
-
-Query parameters, human-readable, no encoded blob:
-
-- Base config: each field by its JSON name, only when it differs from the
- preset base: `?forwardProb=0.5&graphSeed=99`.
-- Panels: one `panel=` param per panel (repeated params), value is
- `label~strategy~overrides` with overrides as comma-separated
- `field:value` pairs, URL-encoded:
- `panel=No%20program~none~`
- `panel=Big%20budget~random~numEducated:60`.
- When the panel set equals the preset's, all `panel=` params are omitted.
-- Focus: `focus=` when the modal is open.
-- Unknown params are ignored; malformed state falls back to the preset
- with the ErrorBanner notice (5.1).
-
----
-
-## 8. Accessibility acceptance criteria
-
-Testable checklist; "both themes" means verified in light and dark.
-
-- [ ] Every interactive control (toolbar buttons, kebab menus, popover
- fields, sliders, player controls, scrubber, collapsibles, modal,
- theme toggle) is reachable and operable with keyboard only, in a
- logical DOM order, with no trap outside the modal.
-- [ ] Visible focus indicator on every focusable element: 2 px outline in
- `--ink` with 2 px offset, both themes.
-- [ ] Player keyboard map works as specced (Space, ArrowLeft/Right,
- Home/End); scrubber is a native range input announcing
- "Round x of y" via `aria-valuetext`.
-- [ ] `prefers-reduced-motion: reduce` disables autoplay, tweening, hover
- lift, and crossfades; stepping and scrubbing remain fully usable.
-- [ ] All informative text meets WCAG 2.1 AA contrast (4.5:1, or 3:1 at
- 18 px+/bold) against its background, both themes; `--ink-4` is never
- the sole carrier of information.
-- [ ] Node state is never encoded by color alone (shape encodings of
- section 6 verified with a deuteranopia simulator on both themes).
-- [ ] Network SVGs are `aria-hidden="true"`; each panel is a `region`
- whose accessible name is its label and whose text content (big
- percentage + meta row) carries the result.
-- [ ] ResultsTable renders the live per-panel numbers as a real `
`
- with `
` headers (evolved from the existing
- ComparisonTable), collapsed behind a disclosure reachable by
- keyboard.
-- [ ] One `aria-live="polite"` region announces results after each
- successful run ("Updated: No program 83 percent, ..."), on pause,
- on scrub release, and at playback end ("Final: ..."); it does not
- announce every round during playback.
-- [ ] FocusModal: `role="dialog"` `aria-modal="true"`, labelled by the
- panel name, focus moves in on open, is trapped, Esc closes, focus
- returns to the opener.
-- [ ] Popovers (About, panel editor, export menu) follow the same
- open/trap/restore rules as menus or dialogs per their role.
-- [ ] ErrorBanner and inline 400 errors use `role="alert"`.
-- [ ] Touch targets are at least 44x44 px on mobile (player buttons,
- kebabs, chips, strip items); node hit halos at least 24 px.
-- [ ] ``, landmarks: `header` (app bar), `main`, `footer`.
-- [ ] Zoom to 200% at 1366 px wide loses no content or functionality
- (panels reflow to the mobile stack).
-
----
-
-## 9. Responsive behavior
-
-Breakpoint: 760 px (one breakpoint only).
-
-Mobile (< 760 px):
-- App bar keeps wordmark + theme + source; DisclaimerBadge hides, replaced
- by the DisclaimerNote line under the hero.
-- MobileScoreStrip appears: sticky under the app bar, horizontally
- scrollable pill chips, one per panel (short label + colored %); tapping
- a chip smooth-scrolls to its panel (instant under reduced motion).
-- PanelGrid stacks single-column; toolbar stays one row (Export collapses
- to an icon button if needed).
-- PlayerBar fixes to the bottom edge (12 px inset, lifted shadow); step
- buttons and the speed pill hide, leaving replay, play/pause, scrubber,
- round counter. Page bottom padding clears it.
-- Lower grid stacks: chart, then controls. Control rows go single-column
- (label above slider, value right-aligned).
-- FocusModal becomes a full-screen sheet.
-- No horizontal scrolling at 320 px width; the strip is the only
- horizontally scrollable element.
-
-Desktop >= 760 px matches the wireframe; the panel grid is
-`repeat(3, 1fr)` for up to 3 panels, wraps to two rows of up to 3 beyond.
-
----
-
-## 10. Implementation slices (ordered)
-
-Each slice leaves the app shippable.
-
-1. **API topology**: add `POST /api/scenario` (config + strategy in,
- result + edges out) in Go, tygo regen, tests for edge determinism.
-2. **Store + URL**: `useSimStore` with preset loading, URL parse/serialize,
- debounced parallel runs, atomic swap, keep-last-good error states.
-3. **Static dashboard**: app bar, hero with live numbers, theme system and
- tokens, panel cards with seeded d3-force NetworkView at final state,
- legend, footer. (The page now beats the parity page; ComparisonTable
- becomes the collapsed ResultsTable.)
-4. **Playback**: usePlayback, PlayerBar, round-state rendering, autoplay
- once, reduced-motion path, keyboard map.
-5. **Reach chart**: overlaid cumulative curves, accents, playhead synced
- to round.
-6. **Controls**: lever sliders, advanced fields, seed rerolls, inline 400
- handling, ErrorBanner.
-7. **Panel management**: add/duplicate/rename/remove, PanelEditorPopover
- with override chips, 6-panel cap.
-8. **Focus modal** with docked player and URL focus state.
-9. **Node tooltip** with cross-panel echo and touch hit halos.
-10. **Mobile pass**: score strip, fixed player, stacking, touch targets.
-11. **Export**: JSON, CSV, PNG snapshot.
-12. **Accessibility audit**: walk the section 8 checklist, fix, record
- results in the PR description.
-
-Post-M3 (specced hooks, not built): `POST /api/optimize` goal-seek UI,
-animated WebM/GIF export, additional study presets.
-
----
-
-## 11. Rejected alternatives
-
-- Single large network with strategy tabs: hides the comparison behind
- clicks; the side-by-side comparison is the message.
-- Fixed three-strategy panels only: cannot express budget or world
- variations the dynamic-panel model needs.
-- Fully independent config per panel: breaks layout comparability and
- bloats URL and editor; overrides express the same power against a shared
- base.
-- Hardcoded study copy: blocks reuse for other research questions; preset
- file chosen instead.
-- Expand-in-place or routed focus view: modal chosen by user; routing
- would pull in Vue Router for one interaction.
-- Share button with clipboard toast: user chose address-bar-only sharing.
-- Explicit Run button (and hybrid auto/manual): kills the
- cause-and-effect immediacy that makes the lever visceral.
-- Looping autoplay: hostile motion while reading; play-once chosen.
-- Per-panel players: panels drift out of sync and the comparison dies.
-- Per-panel sparklines and numbers-only metrics: hard to compare; one
- overlaid chart chosen.
-- Budget sweep / optimizer in M3: doubles scope; reserved as API hook.
-- Animated GIF/WebM export in M3: as much work as the whole animation
- feature; PNG + live share URL cover the need.
-- Dark-only or light-only theme: both ship, prototype renders exist for
- both and the toggle is cheap.
-- Canvas/WebGL network rendering: SVG is ample at 120 nodes x 6 panels
- and is easier to theme, hit-test, and export.
-- Batch `/api/scenarios` endpoint: per-panel `POST /api/scenario` with
- `Promise.all` is simpler and lets panels rerun independently.
-- Base64 state blob in URL: readable repeated params chosen for a FOSS
- demo; blob survives only as a fallback idea if URLs ever exceed limits.
-- Fully focusable network nodes for screen readers: 120 tab stops x N
- panels is worse than the table; aria-hidden SVG + live region + table
- chosen.
-- Pinia and Vue Router: not required by this design; module-scope
- composable store and modal focus suffice.