api: POST /api/scenario returns result plus graph topology (M3 slice 1)

One panel's whole world in one call: effective config in, echoed
config + cascade result + undirected edge list out. Edges are
[from, to] pairs with from < to in deterministic node order;
Graph.Edges() walks the adjacency once, GraphEdges(config) rebuilds
the seeded world (~25us) so Result stays lean and /api/comparison
stays untouched. This closes the topology gap the design brief
flagged; the frontend's seeded d3-force layout consumes these pairs.
Go bits: [][2]int is a slice of fixed-size arrays; [2]int is a value
type, comparable, and JSON-marshals to [a, b], exactly the wire
shape the spec asks for.
tygo regen includes a fix: engine.Strategy now maps to the generated
Strategy type instead of decaying to 'any' in ScenarioRequest.
Verified live through the dev stack: 7/120 reached, 351 edges.
This commit is contained in:
Justin Visser 2026-06-10 15:48:48 +02:00
parent 8c8d5bca11
commit 9a392b1860
12 changed files with 1790 additions and 2 deletions

View file

@ -0,0 +1,81 @@
# 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.

1
docs/mockup/data.json Normal file
View file

@ -0,0 +1 @@
{"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}]}

445
docs/mockup/index.html Normal file
View file

@ -0,0 +1,445 @@
<!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>

445
docs/mockup/rendered.html Normal file
View file

@ -0,0 +1,445 @@
<!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>

587
docs/ui-spec.md Normal file
View file

@ -0,0 +1,587 @@
# 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)
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 uint32, 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: rounds advance every 700 ms at 1x (speed pill cycles 0.5x,
1x, 2x). Within a round, nodes reached that round transition from their
resting state to rose: scale 0.6 to 1.0 plus fade, 250 ms ease-out.
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.
- 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 700 ms cadence becomes 1000 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). Layout via d3-force
(`forceLink`, `forceManyBody`, `forceCenter`, collision), 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.