untrack docs/: specs and handoffs are local working material

This commit is contained in:
Justin Visser 2026-06-11 16:43:17 +02:00
parent 268575ed6c
commit 77ca195952
9 changed files with 3 additions and 1849 deletions

3
.gitignore vendored
View file

@ -8,3 +8,6 @@
# stays tracked; `git restore internal/webdist/dist` after local testing) # stays tracked; `git restore internal/webdist/dist` after local testing)
/internal/webdist/dist/* /internal/webdist/dist/*
!/internal/webdist/dist/index.html !/internal/webdist/dist/index.html
# internal working docs (specs, handoffs, mockups) stay local-only
/docs/

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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}]}

View file

@ -1,445 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>spreadlab mockup v2 (design artifact, not app code)</title>
<style>
:root {
--bg: #F8FAFC;
--surface: #FFFFFF;
--border: #E2E8F0;
--border-soft: #EEF2F7;
--edge: #E6E9EF;
--unreached: #D8DFE9;
--spread: #F43F5E;
--spread-soft: #FFF1F2;
--edu: #14B8A6;
--edu-soft: #F0FDFA;
--ink: #0F172A;
--ink-2: #334155;
--ink-3: #64748B;
--ink-4: #94A3B8;
--shadow: 0 1px 2px rgba(15,23,42,.05), 0 4px 16px -8px rgba(15,23,42,.08);
--shadow-lift: 0 2px 4px rgba(15,23,42,.06), 0 12px 32px -12px rgba(15,23,42,.16);
--radius: 16px;
}
body.dark {
--bg: #0B1120;
--surface: #111A2E;
--border: #1E293B;
--border-soft: #1A2438;
--edge: #243349;
--unreached: #3B4A61;
--spread: #FB7185;
--spread-soft: rgba(251,113,133,.08);
--edu: #2DD4BF;
--edu-soft: rgba(45,212,191,.08);
--ink: #F1F5F9;
--ink-2: #CBD5E1;
--ink-3: #94A3B8;
--ink-4: #64748B;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.5);
--shadow-lift: 0 2px 4px rgba(0,0,0,.5), 0 16px 40px -16px rgba(0,0,0,.6);
}
* { box-sizing: border-box; margin: 0; }
html { -webkit-font-smoothing: antialiased; }
body {
background: var(--bg); color: var(--ink);
font: 15px/1.5 "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
}
svg.ic { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; display: block; }
/* ---------- app bar ---------- */
.appbar {
background: var(--surface); border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 20;
}
.appbar .in {
max-width: 1240px; margin: 0 auto; padding: 0 28px; height: 56px;
display: flex; align-items: center; gap: 14px;
}
.wordmark { display: flex; align-items: center; gap: 9px; font-weight: 650; font-size: 15.5px; letter-spacing: -0.01em; }
.wordmark .glyph {
width: 26px; height: 26px; border-radius: 8px;
background: linear-gradient(135deg, var(--edu), #0D9488);
display: grid; place-items: center;
}
.wordmark .glyph svg { width: 15px; height: 15px; stroke: #fff; stroke-width: 2.2; }
.badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12.5px; font-weight: 500; color: var(--ink-3);
background: var(--bg); border: 1px solid var(--border);
border-radius: 999px; padding: 4px 12px 4px 9px; cursor: pointer;
}
.badge svg.ic { width: 14px; height: 14px; }
.appbar .grow { flex: 1; }
.iconbtn {
width: 34px; height: 34px; border-radius: 10px; border: none; background: none;
color: var(--ink-3); display: grid; place-items: center; cursor: pointer;
}
.iconbtn:hover { background: var(--bg); color: var(--ink); }
.page { max-width: 1240px; margin: 0 auto; padding: 36px 28px 48px; }
/* ---------- hero ---------- */
.hero { max-width: 760px; }
.hero h1 {
font-size: 30px; line-height: 1.18; font-weight: 700; letter-spacing: -0.022em;
}
.hero p {
margin-top: 10px; font-size: 16.5px; color: var(--ink-3); line-height: 1.55;
}
.hero p b { font-weight: 650; font-variant-numeric: tabular-nums; }
.hero p b.bad { color: var(--spread); }
.hero p b.good { color: var(--edu); }
/* ---------- toolbar ---------- */
.toolbar { display: flex; align-items: center; margin: 30px 0 14px; gap: 10px; }
.toolbar h2 {
font-size: 13px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--ink-4);
}
.toolbar .grow { flex: 1; }
.btn {
display: inline-flex; align-items: center; gap: 7px;
font: 600 13.5px/1 inherit; font-family: inherit; color: var(--ink-2);
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 8px 14px; cursor: pointer; box-shadow: 0 1px 2px rgba(15,23,42,.04);
}
.btn:hover { border-color: var(--ink-4); }
.btn.primary { background: var(--ink); color: var(--surface); border-color: var(--ink); }
.btn svg.ic { width: 15px; height: 15px; }
/* ---------- panels ---------- */
.panels { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.panel {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 18px 20px 10px; position: relative; transition: box-shadow .15s;
}
.panel:hover { box-shadow: var(--shadow-lift); }
.panel .head { display: flex; align-items: center; gap: 8px; }
.panel .swatch { width: 9px; height: 9px; border-radius: 3px; flex: none; }
.panel .label { font-size: 13.5px; font-weight: 600; color: var(--ink-2); letter-spacing: -0.005em; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}
.panel .kebab { color: var(--ink-4); margin-right: -6px; }
.panel .pct {
margin-top: 10px; font-size: 40px; font-weight: 700; letter-spacing: -0.03em;
line-height: 1; font-variant-numeric: tabular-nums;
}
.panel .pct small { font-size: 22px; font-weight: 600; color: var(--ink-4); letter-spacing: 0; }
.panel .pct.bad { color: var(--spread); } .panel .pct.good { color: var(--edu); }
.panel .meta { margin-top: 7px; font-size: 12.5px; color: var(--ink-4); display: flex; gap: 5px; flex-wrap: wrap; align-items: center;}
.panel .meta span { white-space: nowrap; }
.panel .meta .sep { color: var(--border); }
.panel .chips { display: flex; gap: 6px; margin-top: 9px; min-height: 22px; flex-wrap: wrap; }
.chip {
font-size: 11.5px; font-weight: 550; color: var(--ink-3);
background: var(--bg); border: 1px solid var(--border-soft);
border-radius: 7px; padding: 2px 8px; font-variant-numeric: tabular-nums;
}
.panel svg.net { width: 100%; height: auto; display: block; margin-top: 4px; }
/* ---------- legend ---------- */
.legend { display: flex; gap: 22px; margin: 14px 2px 0; font-size: 13px; color: var(--ink-3); flex-wrap: wrap; }
.legend .k { display: inline-flex; align-items: center; gap: 7px; }
.lg { width: 11px; height: 11px; border-radius: 50%; flex: none; }
/* ---------- player ---------- */
.playerwrap { display: flex; justify-content: center; margin-top: 26px; }
.player {
display: flex; align-items: center; gap: 6px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 999px; box-shadow: var(--shadow);
padding: 8px 18px 8px 10px; width: min(680px, 100%);
}
.player .iconbtn { width: 36px; height: 36px; border-radius: 999px; flex: none; }
.player .iconbtn.play {
width: 42px; height: 42px; background: var(--ink); color: var(--surface);
}
.player .iconbtn.play:hover { background: var(--ink); opacity: .9; }
.scrub { flex: 1; position: relative; height: 28px; margin: 0 10px; }
.scrub .track { position: absolute; top: 13px; left: 0; right: 0; height: 3px; border-radius: 3px; background: var(--border); }
.scrub .fill { position: absolute; top: 13px; left: 0; width: 100%; height: 3px; border-radius: 3px; background: var(--ink); }
.scrub .dot { position: absolute; top: 12.5px; width: 4px; height: 4px; border-radius: 50%; background: var(--surface); border: 1px solid var(--ink-4); transform: translateX(-2px); }
.scrub .thumb {
position: absolute; top: 6px; right: -2px; width: 17px; height: 17px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--ink); box-shadow: 0 1px 3px rgba(15,23,42,.25);
}
.player .meta { font-size: 13px; font-weight: 550; color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap; }
.player .pill {
font-size: 12.5px; font-weight: 600; color: var(--ink-2);
border: 1px solid var(--border); border-radius: 999px; padding: 4px 11px; margin-left: 10px; cursor: pointer;
}
/* ---------- lower grid ---------- */
.lower { display: grid; grid-template-columns: 7fr 5fr; gap: 18px; margin-top: 26px; align-items: stretch; }
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px 22px;
}
.card h3 { font-size: 13px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-4); }
.chart svg { width: 100%; height: auto; margin-top: 10px; }
.ctl .row { display: grid; grid-template-columns: 150px 1fr 78px; align-items: center; gap: 14px; margin-top: 18px; }
.ctl .row label { font-size: 14px; font-weight: 550; color: var(--ink-2); }
.ctl .row .hint { display: block; font-size: 12px; font-weight: 400; color: var(--ink-4); margin-top: 1px; }
.ctl .val {
justify-self: end; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums;
color: var(--ink-2); background: var(--bg); border: 1px solid var(--border-soft);
border-radius: 8px; padding: 3px 10px;
}
.slider { position: relative; height: 20px; }
.slider .track { position: absolute; top: 8.5px; left: 0; right: 0; height: 3px; border-radius: 3px; background: var(--border); }
.slider .fill { position: absolute; top: 8.5px; left: 0; height: 3px; border-radius: 3px; background: var(--edu); }
.slider .thumb {
position: absolute; top: 2.5px; width: 15px; height: 15px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--edu); box-shadow: 0 1px 3px rgba(15,23,42,.2);
}
.adv { margin-top: 20px; border-top: 1px solid var(--border-soft); padding-top: 14px; }
.adv .toggle { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: var(--ink-3); cursor: pointer; }
.adv .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; margin-top: 12px; }
.field {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
border: 1px solid var(--border-soft); background: var(--bg);
border-radius: 10px; padding: 7px 11px; font-size: 13px;
}
.field .fl { color: var(--ink-3); font-weight: 500; }
.field .fv { font-weight: 600; color: var(--ink-2); font-variant-numeric: tabular-nums; display: flex; align-items: center; gap: 7px; }
.field .fv svg.ic { width: 14px; height: 14px; color: var(--ink-4); cursor: pointer; }
footer { margin-top: 30px; display: flex; justify-content: space-between; font-size: 12.5px; color: var(--ink-4); }
footer em { font-style: italic; }
/* ---------- mobile ---------- */
.strip { display: none; }
.hero .mobile-note { display: none; }
@media (max-width: 760px) {
.page { padding: 22px 16px 110px; }
.appbar .badge { display: none; }
.hero h1 { font-size: 22px; }
.hero p { font-size: 15px; }
.hero .mobile-note { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 12.5px; color: var(--ink-4); }
.strip {
display: flex; gap: 8px; overflow-x: auto; margin-top: 18px;
position: sticky; top: 56px; z-index: 10; background: var(--bg);
padding: 10px 0; scrollbar-width: none;
}
.s-chip {
flex: none; display: flex; align-items: baseline; gap: 8px;
background: var(--surface); border: 1px solid var(--border); border-radius: 999px;
padding: 6px 14px; font-size: 12.5px; font-weight: 550; color: var(--ink-3);
box-shadow: var(--shadow);
}
.s-chip b { font-size: 15px; font-variant-numeric: tabular-nums; }
.toolbar { margin-top: 16px; }
.panels { grid-template-columns: 1fr; gap: 14px; }
.lower { grid-template-columns: 1fr; }
.playerwrap { position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 15; margin: 0; }
.player { box-shadow: var(--shadow-lift); }
.player .pill, .player .iconbtn.step { display: none; }
.legend { gap: 14px; }
footer { flex-direction: column; gap: 4px; }
}
</style>
</head>
<body>
<div class="appbar"><div class="in">
<span class="wordmark">
<span class="glyph"><svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.2"/><circle cx="18" cy="9" r="2.2"/><circle cx="10" cy="18" r="2.2"/><path d="M8 7l8 1.6M16.5 10.8l-5 5.4M7 8l2.4 8"/></svg></span>
spreadlab
</span>
<span class="badge"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8v.01"/></svg> Illustrative model, not validated</span>
<span class="grow"></span>
<button class="iconbtn" title="Toggle theme"><svg class="ic" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 1 1 9.5 4 6.5 6.5 0 0 0 20 14.5z"/></svg></button>
<button class="iconbtn" title="Source"><svg class="ic" viewBox="0 0 24 24"><path d="M9 19c-4.6 1.4-4.6-2.5-6.5-3m13 5v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0C7.1 2.8 6 3.1 6 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4.6 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21"/></svg></button>
</div></div>
<div class="page">
<div class="hero">
<h1>How a non&#8209;consensual deepfake spreads through a school</h1>
<p>One simulated year group, 120 students. Educate the same 30% of them, but change <i>who</i>:
the fake reaches <b class="bad" id="h0">82%</b> of the school with no program,
<b class="bad" id="h1">58%</b> educating at random, and
<b class="good" id="h2">6%</b> educating the best&#8209;connected students.</p>
<span class="mobile-note"><svg class="ic" style="width:14px;height:14px" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8v.01"/></svg> Illustrative model, not validated</span>
</div>
<div class="strip" id="strip"></div>
<div class="toolbar">
<h2>Scenarios</h2>
<span class="grow"></span>
<button class="btn"><svg class="ic" viewBox="0 0 24 24"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14"/></svg> Export</button>
<button class="btn primary"><svg class="ic" viewBox="0 0 24 24"><path d="M12 6v12M6 12h12"/></svg> Add scenario</button>
</div>
<div class="panels" id="panels"></div>
<div class="legend">
<span class="k"><span class="lg" style="background:var(--spread)"></span> Forwarded the fake</span>
<span class="k"><span class="lg" style="background:none;border:2.5px solid var(--edu);width:8px;height:8px"></span> Educated to refuse</span>
<span class="k"><span class="lg" style="background:var(--unreached)"></span> Not reached</span>
<span class="k"><span class="lg" style="background:none;border:2px solid var(--spread);width:9px;height:9px"></span> Origin</span>
</div>
<div class="playerwrap"><div class="player">
<button class="iconbtn" title="Replay"><svg class="ic" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 2.6-6.3M5 3v4h4"/></svg></button>
<button class="iconbtn step" title="Back one round"><svg class="ic" viewBox="0 0 24 24"><path d="M17 6l-7 6 7 6M7 6v12"/></svg></button>
<button class="iconbtn play" title="Play"><svg class="ic" viewBox="0 0 24 24" style="fill:currentColor;stroke:none"><path d="M8.5 6.2v11.6c0 .8.9 1.3 1.6.9l9-5.8a1 1 0 0 0 0-1.8l-9-5.8a1.05 1.05 0 0 0-1.6.9z"/></svg></button>
<button class="iconbtn step" title="Forward one round"><svg class="ic" viewBox="0 0 24 24"><path d="M7 6l7 6-7 6M17 6v12"/></svg></button>
<div class="scrub" id="scrub"><div class="track"></div><div class="fill"></div><div class="thumb"></div></div>
<span class="meta">Round 9 of 9</span>
<span class="pill">1&#215;</span>
</div></div>
<div class="lower">
<div class="card chart">
<h3>Reach over time</h3>
<svg id="chart" viewBox="0 0 640 230"></svg>
</div>
<div class="card ctl">
<h3>World</h3>
<div class="row">
<label>Chance to forward<span class="hint">per friendship, per round</span></label>
<div class="slider"><div class="track"></div><div class="fill" style="width:38%"></div><div class="thumb" style="left:calc(38% - 8px)"></div></div>
<span class="val">38%</span>
</div>
<div class="row">
<label>Education budget<span class="hint">students the program reaches</span></label>
<div class="slider"><div class="track"></div><div class="fill" style="width:30%"></div><div class="thumb" style="left:calc(30% - 8px)"></div></div>
<span class="val">36 &middot; 30%</span>
</div>
<div class="adv">
<span class="toggle"><svg class="ic" style="width:14px;height:14px" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg> Advanced</span>
<div class="grid">
<span class="field"><span class="fl">Students</span><span class="fv">120</span></span>
<span class="field"><span class="fl">Friends per student</span><span class="fv">3</span></span>
<span class="field"><span class="fl">Clique tendency</span><span class="fv">0.45</span></span>
<span class="field"><span class="fl">First poster</span><span class="fv">#0</span></span>
<span class="field"><span class="fl">Friendship network</span><span class="fv">17 <svg class="ic" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 9h.01M15 15h.01M15 9h.01M9 15h.01"/></svg></span></span>
<span class="field"><span class="fl">Who resists</span><span class="fv">2 <svg class="ic" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 9h.01M15 15h.01M15 9h.01M9 15h.01"/></svg></span></span>
</div>
</div>
</div>
</div>
<footer>
<span><em>Illustrative (not validated output)</em> &middot; an agent&#8209;based toy model, parameters chosen for clarity</span>
<span>spreadlab &middot; AGPL</span>
</footer>
</div>
<script>
if (location.search.includes('dark')) document.body.classList.add('dark');
const DATA = __DATA__;
const ACCENTS = ['#6366F1', '#F59E0B', '#0EA5E9', '#8B5CF6', '#EC4899', '#84CC16'];
const LABELS = ['No program', 'Educate 30% at random', 'Educate best-connected 30%'];
// seeded stand-in layout (real app: d3-force over real topology)
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
const rnd = mulberry32(DATA.config.graphSeed);
const N = DATA.config.numStudents;
const pos = [];
while (pos.length < N) {
const a = rnd()*Math.PI*2, r = Math.sqrt(rnd());
const x = 190 + Math.cos(a)*r*172, y = 95 + Math.sin(a)*r*82;
if (pos.every(p => (p[0]-x)**2 + (p[1]-y)**2 > 14.5**2)) pos.push([x,y]);
}
const edges = [];
for (let i=0;i<N;i++){
const d = pos.map((p,j)=>[(p[0]-pos[i][0])**2 + (p[1]-pos[i][1])**2, j]).sort((a,b)=>a[0]-b[0]);
for (let k=1;k<=2;k++) if (d[k]) edges.push([i, d[k][1]]);
}
function netSVG(result){
const edu = new Set(result.educated);
let s = `<svg class="net" viewBox="0 0 380 190">`;
for (const [a,b] of edges) s += `<line x1="${pos[a][0].toFixed(1)}" y1="${pos[a][1].toFixed(1)}" x2="${pos[b][0].toFixed(1)}" y2="${pos[b][1].toFixed(1)}" stroke="var(--edge)" stroke-width="1"/>`;
for (let i=0;i<N;i++){
const [x,y] = pos[i];
const reached = result.reachedAtRound[i] >= 0;
if (i === DATA.config.origin) {
s += `<circle cx="${x}" cy="${y}" r="7.5" fill="none" stroke="var(--spread)" stroke-width="1.4" opacity=".75"/><circle cx="${x}" cy="${y}" r="4" fill="var(--spread)"/>`;
} else if (edu.has(i)) {
s += `<circle cx="${x}" cy="${y}" r="3.8" fill="${reached ? 'var(--spread)' : 'var(--surface)'}" stroke="var(--edu)" stroke-width="2.2"/>`;
} else if (reached) {
s += `<circle cx="${x}" cy="${y}" r="4" fill="var(--spread)"/>`;
} else {
s += `<circle cx="${x}" cy="${y}" r="3" fill="var(--unreached)"/>`;
}
}
return s + `</svg>`;
}
const fmt = p => (Math.round(p*10)/10).toFixed(0);
const panelsEl = document.getElementById('panels');
const stripEl = document.getElementById('strip');
DATA.results.forEach((res, i) => {
const pct = fmt(res.reachedPct);
const tone = res.reachedPct > 30 ? 'bad' : 'good';
const el = document.createElement('div');
el.className = 'panel';
el.innerHTML = `
<div class="head">
<span class="swatch" style="background:${ACCENTS[i]}"></span>
<span class="label">${LABELS[i]}</span>
<span class="kebab"><svg class="ic" viewBox="0 0 24 24"><path d="M12 6h.01M12 12h.01M12 18h.01"/></svg></span>
</div>
<div class="pct ${tone}">${pct}<small>%</small></div>
<div class="meta">
<span>${res.numReached} of ${N} reached</span><span class="sep">&bull;</span>
<span>${res.numRounds} rounds</span><span class="sep">&bull;</span>
<span>${res.educated.length} educated</span>
</div>
<div class="chips">${i===2 ? '<span class="chip">strategy &middot; most-connected</span>' : i===1 ? '<span class="chip">strategy &middot; random</span>' : ''}</div>
${netSVG(res)}`;
panelsEl.appendChild(el);
document.getElementById('h'+i).textContent = pct + '%';
const chip = document.createElement('span');
chip.className = 's-chip';
chip.innerHTML = `${LABELS[i].replace('Educate 30% at random','Random 30%').replace('Educate best-connected 30%','Connected 30%')} <b style="color:${tone==='bad'?'var(--spread)':'var(--edu)'}">${pct}%</b>`;
stripEl.appendChild(chip);
});
// scrub ticks
const maxR = Math.max(...DATA.results.map(r=>r.numRounds));
const scrub = document.getElementById('scrub');
for (let r=1; r<maxR; r++){
const d = document.createElement('div'); d.className = 'dot'; d.style.left = (r/maxR*100)+'%';
scrub.appendChild(d);
}
// chart
const chart = document.getElementById('chart');
const CW = 640, CH = 230, L = 40, R = 56, T = 14, B = 30;
let svg = '';
for (let g=0; g<=4; g++){
const y = T + g*(CH-T-B)/4;
svg += `<line x1="${L}" y1="${y}" x2="${CW-R}" y2="${y}" stroke="var(--border-soft)" stroke-width="1" ${g<4?'stroke-dasharray="2 4"':''}/>`;
svg += `<text x="${L-9}" y="${y+3.5}" text-anchor="end" font-size="11" fill="var(--ink-4)">${100-g*25}</text>`;
}
DATA.results.forEach((res, i) => {
const pts = [];
for (let r=0; r<=maxR; r++){
const c = res.reachedAtRound.filter(v => v>=0 && v<=r).length;
pts.push([L + r*(CW-L-R)/maxR, T + (1-c/N)*(CH-T-B)]);
}
svg += `<polyline points="${pts.map(p=>p.map(v=>v.toFixed(1)).join(',')).join(' ')}" fill="none" stroke="${ACCENTS[i]}" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/>`;
const last = pts[pts.length-1];
svg += `<circle cx="${last[0]}" cy="${last[1]}" r="3.5" fill="${ACCENTS[i]}"/>`;
svg += `<text x="${last[0]+9}" y="${last[1]+4}" font-size="12" font-weight="650" fill="${ACCENTS[i]}">${fmt(res.reachedPct)}%</text>`;
});
for (let r=0; r<=maxR; r++){
svg += `<text x="${L + r*(CW-L-R)/maxR}" y="${CH-8}" text-anchor="middle" font-size="11" fill="var(--ink-4)">${r}</text>`;
}
svg += `<text x="${L}" y="${CH-8}" text-anchor="start" font-size="0"> </text>`;
chart.innerHTML = svg;
</script>
</body>
</html>

View file

@ -1,445 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>spreadlab mockup v2 (design artifact, not app code)</title>
<style>
:root {
--bg: #F8FAFC;
--surface: #FFFFFF;
--border: #E2E8F0;
--border-soft: #EEF2F7;
--edge: #E6E9EF;
--unreached: #D8DFE9;
--spread: #F43F5E;
--spread-soft: #FFF1F2;
--edu: #14B8A6;
--edu-soft: #F0FDFA;
--ink: #0F172A;
--ink-2: #334155;
--ink-3: #64748B;
--ink-4: #94A3B8;
--shadow: 0 1px 2px rgba(15,23,42,.05), 0 4px 16px -8px rgba(15,23,42,.08);
--shadow-lift: 0 2px 4px rgba(15,23,42,.06), 0 12px 32px -12px rgba(15,23,42,.16);
--radius: 16px;
}
body.dark {
--bg: #0B1120;
--surface: #111A2E;
--border: #1E293B;
--border-soft: #1A2438;
--edge: #243349;
--unreached: #3B4A61;
--spread: #FB7185;
--spread-soft: rgba(251,113,133,.08);
--edu: #2DD4BF;
--edu-soft: rgba(45,212,191,.08);
--ink: #F1F5F9;
--ink-2: #CBD5E1;
--ink-3: #94A3B8;
--ink-4: #64748B;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.5);
--shadow-lift: 0 2px 4px rgba(0,0,0,.5), 0 16px 40px -16px rgba(0,0,0,.6);
}
* { box-sizing: border-box; margin: 0; }
html { -webkit-font-smoothing: antialiased; }
body {
background: var(--bg); color: var(--ink);
font: 15px/1.5 "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
}
svg.ic { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; display: block; }
/* ---------- app bar ---------- */
.appbar {
background: var(--surface); border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 20;
}
.appbar .in {
max-width: 1240px; margin: 0 auto; padding: 0 28px; height: 56px;
display: flex; align-items: center; gap: 14px;
}
.wordmark { display: flex; align-items: center; gap: 9px; font-weight: 650; font-size: 15.5px; letter-spacing: -0.01em; }
.wordmark .glyph {
width: 26px; height: 26px; border-radius: 8px;
background: linear-gradient(135deg, var(--edu), #0D9488);
display: grid; place-items: center;
}
.wordmark .glyph svg { width: 15px; height: 15px; stroke: #fff; stroke-width: 2.2; }
.badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12.5px; font-weight: 500; color: var(--ink-3);
background: var(--bg); border: 1px solid var(--border);
border-radius: 999px; padding: 4px 12px 4px 9px; cursor: pointer;
}
.badge svg.ic { width: 14px; height: 14px; }
.appbar .grow { flex: 1; }
.iconbtn {
width: 34px; height: 34px; border-radius: 10px; border: none; background: none;
color: var(--ink-3); display: grid; place-items: center; cursor: pointer;
}
.iconbtn:hover { background: var(--bg); color: var(--ink); }
.page { max-width: 1240px; margin: 0 auto; padding: 36px 28px 48px; }
/* ---------- hero ---------- */
.hero { max-width: 760px; }
.hero h1 {
font-size: 30px; line-height: 1.18; font-weight: 700; letter-spacing: -0.022em;
}
.hero p {
margin-top: 10px; font-size: 16.5px; color: var(--ink-3); line-height: 1.55;
}
.hero p b { font-weight: 650; font-variant-numeric: tabular-nums; }
.hero p b.bad { color: var(--spread); }
.hero p b.good { color: var(--edu); }
/* ---------- toolbar ---------- */
.toolbar { display: flex; align-items: center; margin: 30px 0 14px; gap: 10px; }
.toolbar h2 {
font-size: 13px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--ink-4);
}
.toolbar .grow { flex: 1; }
.btn {
display: inline-flex; align-items: center; gap: 7px;
font: 600 13.5px/1 inherit; font-family: inherit; color: var(--ink-2);
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 8px 14px; cursor: pointer; box-shadow: 0 1px 2px rgba(15,23,42,.04);
}
.btn:hover { border-color: var(--ink-4); }
.btn.primary { background: var(--ink); color: var(--surface); border-color: var(--ink); }
.btn svg.ic { width: 15px; height: 15px; }
/* ---------- panels ---------- */
.panels { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.panel {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 18px 20px 10px; position: relative; transition: box-shadow .15s;
}
.panel:hover { box-shadow: var(--shadow-lift); }
.panel .head { display: flex; align-items: center; gap: 8px; }
.panel .swatch { width: 9px; height: 9px; border-radius: 3px; flex: none; }
.panel .label { font-size: 13.5px; font-weight: 600; color: var(--ink-2); letter-spacing: -0.005em; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}
.panel .kebab { color: var(--ink-4); margin-right: -6px; }
.panel .pct {
margin-top: 10px; font-size: 40px; font-weight: 700; letter-spacing: -0.03em;
line-height: 1; font-variant-numeric: tabular-nums;
}
.panel .pct small { font-size: 22px; font-weight: 600; color: var(--ink-4); letter-spacing: 0; }
.panel .pct.bad { color: var(--spread); } .panel .pct.good { color: var(--edu); }
.panel .meta { margin-top: 7px; font-size: 12.5px; color: var(--ink-4); display: flex; gap: 5px; flex-wrap: wrap; align-items: center;}
.panel .meta span { white-space: nowrap; }
.panel .meta .sep { color: var(--border); }
.panel .chips { display: flex; gap: 6px; margin-top: 9px; min-height: 22px; flex-wrap: wrap; }
.chip {
font-size: 11.5px; font-weight: 550; color: var(--ink-3);
background: var(--bg); border: 1px solid var(--border-soft);
border-radius: 7px; padding: 2px 8px; font-variant-numeric: tabular-nums;
}
.panel svg.net { width: 100%; height: auto; display: block; margin-top: 4px; }
/* ---------- legend ---------- */
.legend { display: flex; gap: 22px; margin: 14px 2px 0; font-size: 13px; color: var(--ink-3); flex-wrap: wrap; }
.legend .k { display: inline-flex; align-items: center; gap: 7px; }
.lg { width: 11px; height: 11px; border-radius: 50%; flex: none; }
/* ---------- player ---------- */
.playerwrap { display: flex; justify-content: center; margin-top: 26px; }
.player {
display: flex; align-items: center; gap: 6px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 999px; box-shadow: var(--shadow);
padding: 8px 18px 8px 10px; width: min(680px, 100%);
}
.player .iconbtn { width: 36px; height: 36px; border-radius: 999px; flex: none; }
.player .iconbtn.play {
width: 42px; height: 42px; background: var(--ink); color: var(--surface);
}
.player .iconbtn.play:hover { background: var(--ink); opacity: .9; }
.scrub { flex: 1; position: relative; height: 28px; margin: 0 10px; }
.scrub .track { position: absolute; top: 13px; left: 0; right: 0; height: 3px; border-radius: 3px; background: var(--border); }
.scrub .fill { position: absolute; top: 13px; left: 0; width: 100%; height: 3px; border-radius: 3px; background: var(--ink); }
.scrub .dot { position: absolute; top: 12.5px; width: 4px; height: 4px; border-radius: 50%; background: var(--surface); border: 1px solid var(--ink-4); transform: translateX(-2px); }
.scrub .thumb {
position: absolute; top: 6px; right: -2px; width: 17px; height: 17px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--ink); box-shadow: 0 1px 3px rgba(15,23,42,.25);
}
.player .meta { font-size: 13px; font-weight: 550; color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap; }
.player .pill {
font-size: 12.5px; font-weight: 600; color: var(--ink-2);
border: 1px solid var(--border); border-radius: 999px; padding: 4px 11px; margin-left: 10px; cursor: pointer;
}
/* ---------- lower grid ---------- */
.lower { display: grid; grid-template-columns: 7fr 5fr; gap: 18px; margin-top: 26px; align-items: stretch; }
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px 22px;
}
.card h3 { font-size: 13px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-4); }
.chart svg { width: 100%; height: auto; margin-top: 10px; }
.ctl .row { display: grid; grid-template-columns: 150px 1fr 78px; align-items: center; gap: 14px; margin-top: 18px; }
.ctl .row label { font-size: 14px; font-weight: 550; color: var(--ink-2); }
.ctl .row .hint { display: block; font-size: 12px; font-weight: 400; color: var(--ink-4); margin-top: 1px; }
.ctl .val {
justify-self: end; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums;
color: var(--ink-2); background: var(--bg); border: 1px solid var(--border-soft);
border-radius: 8px; padding: 3px 10px;
}
.slider { position: relative; height: 20px; }
.slider .track { position: absolute; top: 8.5px; left: 0; right: 0; height: 3px; border-radius: 3px; background: var(--border); }
.slider .fill { position: absolute; top: 8.5px; left: 0; height: 3px; border-radius: 3px; background: var(--edu); }
.slider .thumb {
position: absolute; top: 2.5px; width: 15px; height: 15px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--edu); box-shadow: 0 1px 3px rgba(15,23,42,.2);
}
.adv { margin-top: 20px; border-top: 1px solid var(--border-soft); padding-top: 14px; }
.adv .toggle { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: var(--ink-3); cursor: pointer; }
.adv .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; margin-top: 12px; }
.field {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
border: 1px solid var(--border-soft); background: var(--bg);
border-radius: 10px; padding: 7px 11px; font-size: 13px;
}
.field .fl { color: var(--ink-3); font-weight: 500; }
.field .fv { font-weight: 600; color: var(--ink-2); font-variant-numeric: tabular-nums; display: flex; align-items: center; gap: 7px; }
.field .fv svg.ic { width: 14px; height: 14px; color: var(--ink-4); cursor: pointer; }
footer { margin-top: 30px; display: flex; justify-content: space-between; font-size: 12.5px; color: var(--ink-4); }
footer em { font-style: italic; }
/* ---------- mobile ---------- */
.strip { display: none; }
.hero .mobile-note { display: none; }
@media (max-width: 760px) {
.page { padding: 22px 16px 110px; }
.appbar .badge { display: none; }
.hero h1 { font-size: 22px; }
.hero p { font-size: 15px; }
.hero .mobile-note { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 12.5px; color: var(--ink-4); }
.strip {
display: flex; gap: 8px; overflow-x: auto; margin-top: 18px;
position: sticky; top: 56px; z-index: 10; background: var(--bg);
padding: 10px 0; scrollbar-width: none;
}
.s-chip {
flex: none; display: flex; align-items: baseline; gap: 8px;
background: var(--surface); border: 1px solid var(--border); border-radius: 999px;
padding: 6px 14px; font-size: 12.5px; font-weight: 550; color: var(--ink-3);
box-shadow: var(--shadow);
}
.s-chip b { font-size: 15px; font-variant-numeric: tabular-nums; }
.toolbar { margin-top: 16px; }
.panels { grid-template-columns: 1fr; gap: 14px; }
.lower { grid-template-columns: 1fr; }
.playerwrap { position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 15; margin: 0; }
.player { box-shadow: var(--shadow-lift); }
.player .pill, .player .iconbtn.step { display: none; }
.legend { gap: 14px; }
footer { flex-direction: column; gap: 4px; }
}
</style>
</head>
<body>
<div class="appbar"><div class="in">
<span class="wordmark">
<span class="glyph"><svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.2"/><circle cx="18" cy="9" r="2.2"/><circle cx="10" cy="18" r="2.2"/><path d="M8 7l8 1.6M16.5 10.8l-5 5.4M7 8l2.4 8"/></svg></span>
spreadlab
</span>
<span class="badge"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8v.01"/></svg> Illustrative model, not validated</span>
<span class="grow"></span>
<button class="iconbtn" title="Toggle theme"><svg class="ic" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 1 1 9.5 4 6.5 6.5 0 0 0 20 14.5z"/></svg></button>
<button class="iconbtn" title="Source"><svg class="ic" viewBox="0 0 24 24"><path d="M9 19c-4.6 1.4-4.6-2.5-6.5-3m13 5v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0C7.1 2.8 6 3.1 6 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4.6 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21"/></svg></button>
</div></div>
<div class="page">
<div class="hero">
<h1>How a non&#8209;consensual deepfake spreads through a school</h1>
<p>One simulated year group, 120 students. Educate the same 30% of them, but change <i>who</i>:
the fake reaches <b class="bad" id="h0">82%</b> of the school with no program,
<b class="bad" id="h1">58%</b> educating at random, and
<b class="good" id="h2">6%</b> educating the best&#8209;connected students.</p>
<span class="mobile-note"><svg class="ic" style="width:14px;height:14px" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8v.01"/></svg> Illustrative model, not validated</span>
</div>
<div class="strip" id="strip"></div>
<div class="toolbar">
<h2>Scenarios</h2>
<span class="grow"></span>
<button class="btn"><svg class="ic" viewBox="0 0 24 24"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14"/></svg> Export</button>
<button class="btn primary"><svg class="ic" viewBox="0 0 24 24"><path d="M12 6v12M6 12h12"/></svg> Add scenario</button>
</div>
<div class="panels" id="panels"></div>
<div class="legend">
<span class="k"><span class="lg" style="background:var(--spread)"></span> Forwarded the fake</span>
<span class="k"><span class="lg" style="background:none;border:2.5px solid var(--edu);width:8px;height:8px"></span> Educated to refuse</span>
<span class="k"><span class="lg" style="background:var(--unreached)"></span> Not reached</span>
<span class="k"><span class="lg" style="background:none;border:2px solid var(--spread);width:9px;height:9px"></span> Origin</span>
</div>
<div class="playerwrap"><div class="player">
<button class="iconbtn" title="Replay"><svg class="ic" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 2.6-6.3M5 3v4h4"/></svg></button>
<button class="iconbtn step" title="Back one round"><svg class="ic" viewBox="0 0 24 24"><path d="M17 6l-7 6 7 6M7 6v12"/></svg></button>
<button class="iconbtn play" title="Play"><svg class="ic" viewBox="0 0 24 24" style="fill:currentColor;stroke:none"><path d="M8.5 6.2v11.6c0 .8.9 1.3 1.6.9l9-5.8a1 1 0 0 0 0-1.8l-9-5.8a1.05 1.05 0 0 0-1.6.9z"/></svg></button>
<button class="iconbtn step" title="Forward one round"><svg class="ic" viewBox="0 0 24 24"><path d="M7 6l7 6-7 6M17 6v12"/></svg></button>
<div class="scrub" id="scrub"><div class="track"></div><div class="fill"></div><div class="thumb"></div></div>
<span class="meta">Round 9 of 9</span>
<span class="pill">1&#215;</span>
</div></div>
<div class="lower">
<div class="card chart">
<h3>Reach over time</h3>
<svg id="chart" viewBox="0 0 640 230"></svg>
</div>
<div class="card ctl">
<h3>World</h3>
<div class="row">
<label>Chance to forward<span class="hint">per friendship, per round</span></label>
<div class="slider"><div class="track"></div><div class="fill" style="width:38%"></div><div class="thumb" style="left:calc(38% - 8px)"></div></div>
<span class="val">38%</span>
</div>
<div class="row">
<label>Education budget<span class="hint">students the program reaches</span></label>
<div class="slider"><div class="track"></div><div class="fill" style="width:30%"></div><div class="thumb" style="left:calc(30% - 8px)"></div></div>
<span class="val">36 &middot; 30%</span>
</div>
<div class="adv">
<span class="toggle"><svg class="ic" style="width:14px;height:14px" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg> Advanced</span>
<div class="grid">
<span class="field"><span class="fl">Students</span><span class="fv">120</span></span>
<span class="field"><span class="fl">Friends per student</span><span class="fv">3</span></span>
<span class="field"><span class="fl">Clique tendency</span><span class="fv">0.45</span></span>
<span class="field"><span class="fl">First poster</span><span class="fv">#0</span></span>
<span class="field"><span class="fl">Friendship network</span><span class="fv">17 <svg class="ic" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 9h.01M15 15h.01M15 9h.01M9 15h.01"/></svg></span></span>
<span class="field"><span class="fl">Who resists</span><span class="fv">2 <svg class="ic" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 9h.01M15 15h.01M15 9h.01M9 15h.01"/></svg></span></span>
</div>
</div>
</div>
</div>
<footer>
<span><em>Illustrative (not validated output)</em> &middot; an agent&#8209;based toy model, parameters chosen for clarity</span>
<span>spreadlab &middot; AGPL</span>
</footer>
</div>
<script>
if (location.search.includes('dark')) document.body.classList.add('dark');
const DATA = {"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}]};
const ACCENTS = ['#6366F1', '#F59E0B', '#0EA5E9', '#8B5CF6', '#EC4899', '#84CC16'];
const LABELS = ['No program', 'Educate 30% at random', 'Educate best-connected 30%'];
// seeded stand-in layout (real app: d3-force over real topology)
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
const rnd = mulberry32(DATA.config.graphSeed);
const N = DATA.config.numStudents;
const pos = [];
while (pos.length < N) {
const a = rnd()*Math.PI*2, r = Math.sqrt(rnd());
const x = 190 + Math.cos(a)*r*172, y = 95 + Math.sin(a)*r*82;
if (pos.every(p => (p[0]-x)**2 + (p[1]-y)**2 > 14.5**2)) pos.push([x,y]);
}
const edges = [];
for (let i=0;i<N;i++){
const d = pos.map((p,j)=>[(p[0]-pos[i][0])**2 + (p[1]-pos[i][1])**2, j]).sort((a,b)=>a[0]-b[0]);
for (let k=1;k<=2;k++) if (d[k]) edges.push([i, d[k][1]]);
}
function netSVG(result){
const edu = new Set(result.educated);
let s = `<svg class="net" viewBox="0 0 380 190">`;
for (const [a,b] of edges) s += `<line x1="${pos[a][0].toFixed(1)}" y1="${pos[a][1].toFixed(1)}" x2="${pos[b][0].toFixed(1)}" y2="${pos[b][1].toFixed(1)}" stroke="var(--edge)" stroke-width="1"/>`;
for (let i=0;i<N;i++){
const [x,y] = pos[i];
const reached = result.reachedAtRound[i] >= 0;
if (i === DATA.config.origin) {
s += `<circle cx="${x}" cy="${y}" r="7.5" fill="none" stroke="var(--spread)" stroke-width="1.4" opacity=".75"/><circle cx="${x}" cy="${y}" r="4" fill="var(--spread)"/>`;
} else if (edu.has(i)) {
s += `<circle cx="${x}" cy="${y}" r="3.8" fill="${reached ? 'var(--spread)' : 'var(--surface)'}" stroke="var(--edu)" stroke-width="2.2"/>`;
} else if (reached) {
s += `<circle cx="${x}" cy="${y}" r="4" fill="var(--spread)"/>`;
} else {
s += `<circle cx="${x}" cy="${y}" r="3" fill="var(--unreached)"/>`;
}
}
return s + `</svg>`;
}
const fmt = p => (Math.round(p*10)/10).toFixed(0);
const panelsEl = document.getElementById('panels');
const stripEl = document.getElementById('strip');
DATA.results.forEach((res, i) => {
const pct = fmt(res.reachedPct);
const tone = res.reachedPct > 30 ? 'bad' : 'good';
const el = document.createElement('div');
el.className = 'panel';
el.innerHTML = `
<div class="head">
<span class="swatch" style="background:${ACCENTS[i]}"></span>
<span class="label">${LABELS[i]}</span>
<span class="kebab"><svg class="ic" viewBox="0 0 24 24"><path d="M12 6h.01M12 12h.01M12 18h.01"/></svg></span>
</div>
<div class="pct ${tone}">${pct}<small>%</small></div>
<div class="meta">
<span>${res.numReached} of ${N} reached</span><span class="sep">&bull;</span>
<span>${res.numRounds} rounds</span><span class="sep">&bull;</span>
<span>${res.educated.length} educated</span>
</div>
<div class="chips">${i===2 ? '<span class="chip">strategy &middot; most-connected</span>' : i===1 ? '<span class="chip">strategy &middot; random</span>' : ''}</div>
${netSVG(res)}`;
panelsEl.appendChild(el);
document.getElementById('h'+i).textContent = pct + '%';
const chip = document.createElement('span');
chip.className = 's-chip';
chip.innerHTML = `${LABELS[i].replace('Educate 30% at random','Random 30%').replace('Educate best-connected 30%','Connected 30%')} <b style="color:${tone==='bad'?'var(--spread)':'var(--edu)'}">${pct}%</b>`;
stripEl.appendChild(chip);
});
// scrub ticks
const maxR = Math.max(...DATA.results.map(r=>r.numRounds));
const scrub = document.getElementById('scrub');
for (let r=1; r<maxR; r++){
const d = document.createElement('div'); d.className = 'dot'; d.style.left = (r/maxR*100)+'%';
scrub.appendChild(d);
}
// chart
const chart = document.getElementById('chart');
const CW = 640, CH = 230, L = 40, R = 56, T = 14, B = 30;
let svg = '';
for (let g=0; g<=4; g++){
const y = T + g*(CH-T-B)/4;
svg += `<line x1="${L}" y1="${y}" x2="${CW-R}" y2="${y}" stroke="var(--border-soft)" stroke-width="1" ${g<4?'stroke-dasharray="2 4"':''}/>`;
svg += `<text x="${L-9}" y="${y+3.5}" text-anchor="end" font-size="11" fill="var(--ink-4)">${100-g*25}</text>`;
}
DATA.results.forEach((res, i) => {
const pts = [];
for (let r=0; r<=maxR; r++){
const c = res.reachedAtRound.filter(v => v>=0 && v<=r).length;
pts.push([L + r*(CW-L-R)/maxR, T + (1-c/N)*(CH-T-B)]);
}
svg += `<polyline points="${pts.map(p=>p.map(v=>v.toFixed(1)).join(',')).join(' ')}" fill="none" stroke="${ACCENTS[i]}" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/>`;
const last = pts[pts.length-1];
svg += `<circle cx="${last[0]}" cy="${last[1]}" r="3.5" fill="${ACCENTS[i]}"/>`;
svg += `<text x="${last[0]+9}" y="${last[1]+4}" font-size="12" font-weight="650" fill="${ACCENTS[i]}">${fmt(res.reachedPct)}%</text>`;
});
for (let r=0; r<=maxR; r++){
svg += `<text x="${L + r*(CW-L-R)/maxR}" y="${CH-8}" text-anchor="middle" font-size="11" fill="var(--ink-4)">${r}</text>`;
}
svg += `<text x="${L}" y="${CH-8}" text-anchor="start" font-size="0"> </text>`;
chart.innerHTML = svg;
</script>
</body>
</html>

View file

@ -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.

View file

@ -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<Config> // 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 <origin>/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: <message>" 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=<panel index>` 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 `<table>`
with `<th scope>` 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.
- [ ] `<html lang="en">`, 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.