diff --git a/web/.gitignore b/web/.gitignore
index cd68f14..b5cef9b 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -37,3 +37,4 @@ __screenshots__/
# Vite
*.timestamp-*-*.mjs
+public/__*.html
diff --git a/web/index.html b/web/index.html
index 32253a8..29ca3d4 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1,10 +1,25 @@
-
+
spreadlab
+
diff --git a/web/package-lock.json b/web/package-lock.json
index 4caba8f..a804009 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -8,10 +8,14 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
+ "@fontsource-variable/inter": "^5.2.8",
+ "d3-force": "^3.0.0",
+ "d3-quadtree": "^3.0.1",
"vue": "^3.5.32"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
+ "@types/d3-force": "^3.0.10",
"@types/jsdom": "^28.0.1",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
@@ -740,35 +744,10 @@
"node": ">=20.19.0"
}
},
- "node_modules/@emnapi/core": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
- "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.1",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
- "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@emnapi/wasi-threads": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
- "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -888,6 +867,15 @@
}
}
},
+ "node_modules/@fontsource-variable/inter": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
+ "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1659,6 +1647,40 @@
"node": "^20.19.0 || >=22.12.0"
}
},
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
@@ -1736,6 +1758,13 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -2888,6 +2917,47 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-force": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-quadtree": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-quadtree": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
diff --git a/web/package.json b/web/package.json
index 496a26d..01d0409 100644
--- a/web/package.json
+++ b/web/package.json
@@ -16,10 +16,14 @@
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
+ "@fontsource-variable/inter": "^5.2.8",
+ "d3-force": "^3.0.0",
+ "d3-quadtree": "^3.0.1",
"vue": "^3.5.32"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
+ "@types/d3-force": "^3.0.10",
"@types/jsdom": "^28.0.1",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
diff --git a/web/src/App.vue b/web/src/App.vue
index 342b1f1..be6b3cb 100644
--- a/web/src/App.vue
+++ b/web/src/App.vue
@@ -1,40 +1,51 @@
-
- spreadlab
-
- How a non-consensual deepfake spreads through a simulated school year
- group, and what an education program changes. Illustrative, not
- validated.
-
- Could not reach the spreadlab API: {{ error }}
-
- Running scenarios…
+
+
+
+
+
+
+
+
+
diff --git a/web/src/assets/base.css b/web/src/assets/base.css
deleted file mode 100644
index 8816868..0000000
--- a/web/src/assets/base.css
+++ /dev/null
@@ -1,86 +0,0 @@
-/* color palette from */
-:root {
- --vt-c-white: #ffffff;
- --vt-c-white-soft: #f8f8f8;
- --vt-c-white-mute: #f2f2f2;
-
- --vt-c-black: #181818;
- --vt-c-black-soft: #222222;
- --vt-c-black-mute: #282828;
-
- --vt-c-indigo: #2c3e50;
-
- --vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
- --vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
- --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
- --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
-
- --vt-c-text-light-1: var(--vt-c-indigo);
- --vt-c-text-light-2: rgba(60, 60, 60, 0.66);
- --vt-c-text-dark-1: var(--vt-c-white);
- --vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
-}
-
-/* semantic color variables for this project */
-:root {
- --color-background: var(--vt-c-white);
- --color-background-soft: var(--vt-c-white-soft);
- --color-background-mute: var(--vt-c-white-mute);
-
- --color-border: var(--vt-c-divider-light-2);
- --color-border-hover: var(--vt-c-divider-light-1);
-
- --color-heading: var(--vt-c-text-light-1);
- --color-text: var(--vt-c-text-light-1);
-
- --section-gap: 160px;
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --color-background: var(--vt-c-black);
- --color-background-soft: var(--vt-c-black-soft);
- --color-background-mute: var(--vt-c-black-mute);
-
- --color-border: var(--vt-c-divider-dark-2);
- --color-border-hover: var(--vt-c-divider-dark-1);
-
- --color-heading: var(--vt-c-text-dark-1);
- --color-text: var(--vt-c-text-dark-2);
- }
-}
-
-*,
-*::before,
-*::after {
- box-sizing: border-box;
- margin: 0;
- font-weight: normal;
-}
-
-body {
- min-height: 100vh;
- color: var(--color-text);
- background: var(--color-background);
- transition:
- color 0.5s,
- background-color 0.5s;
- line-height: 1.6;
- font-family:
- Inter,
- -apple-system,
- BlinkMacSystemFont,
- 'Segoe UI',
- Roboto,
- Oxygen,
- Ubuntu,
- Cantarell,
- 'Fira Sans',
- 'Droid Sans',
- 'Helvetica Neue',
- sans-serif;
- font-size: 15px;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
diff --git a/web/src/assets/main.css b/web/src/assets/main.css
index 36fb845..ca27bfc 100644
--- a/web/src/assets/main.css
+++ b/web/src/assets/main.css
@@ -1,35 +1,106 @@
-@import './base.css';
+@import '@fontsource-variable/inter';
-#app {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
- font-weight: normal;
+/* Visual tokens (spec section 6), one set per theme. Node semantics are
+ identical in both themes. */
+: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, 0.05), 0 4px 16px -8px rgba(15, 23, 42, 0.08);
+ --shadow-lift: 0 2px 4px rgba(15, 23, 42, 0.06), 0 12px 32px -12px rgba(15, 23, 42, 0.16);
+ --radius: 16px;
}
-a,
-.green {
- text-decoration: none;
- color: hsla(160, 100%, 37%, 1);
- transition: 0.4s;
- padding: 3px;
+:root.dark {
+ --bg: #0b1120;
+ --surface: #111a2e;
+ --border: #1e293b;
+ --border-soft: #1a2438;
+ --edge: #243349;
+ --unreached: #3b4a61;
+ --spread: #fb7185;
+ --spread-soft: rgba(251, 113, 133, 0.08);
+ --edu: #2dd4bf;
+ --edu-soft: rgba(45, 212, 191, 0.08);
+ --ink: #f1f5f9;
+ --ink-2: #cbd5e1;
+ --ink-3: #94a3b8;
+ --ink-4: #64748b;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px -12px rgba(0, 0, 0, 0.5);
+ --shadow-lift: 0 2px 4px rgba(0, 0, 0, 0.5), 0 16px 40px -16px rgba(0, 0, 0, 0.6);
}
-@media (hover: hover) {
- a:hover {
- background-color: hsla(160, 100%, 37%, 0.2);
- }
-}
-
-@media (min-width: 1024px) {
- body {
- display: flex;
- place-items: center;
- }
-
- #app {
- display: grid;
- grid-template-columns: 1fr 1fr;
- padding: 0 2rem;
+* {
+ box-sizing: border-box;
+ margin: 0;
+}
+
+html {
+ -webkit-font-smoothing: antialiased;
+}
+
+body {
+ background: var(--bg);
+ color: var(--ink);
+ font:
+ 15px/1.5 'Inter Variable',
+ Inter,
+ system-ui,
+ sans-serif;
+ font-variant-numeric: tabular-nums;
+}
+
+/* Accessibility acceptance criterion: a visible 2 px ink outline with 2 px
+ offset on every focusable element, both themes. */
+:focus-visible {
+ outline: 2px solid var(--ink);
+ outline-offset: 2px;
+}
+
+button {
+ font-family: inherit;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* Shared icon sizing for the inline stroke icons. */
+svg.ic {
+ width: 18px;
+ height: 18px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.8;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ display: block;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
}
}
diff --git a/web/src/components/AboutPopover.vue b/web/src/components/AboutPopover.vue
new file mode 100644
index 0000000..f7974a9
--- /dev/null
+++ b/web/src/components/AboutPopover.vue
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
diff --git a/web/src/components/AppBar.vue b/web/src/components/AppBar.vue
new file mode 100644
index 0000000..c5da0c1
--- /dev/null
+++ b/web/src/components/AppBar.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ spreadlab
+
+
+
+
+
+
+
+ {{ preset.disclaimerShort }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/ComparisonTable.vue b/web/src/components/ComparisonTable.vue
deleted file mode 100644
index eb88996..0000000
--- a/web/src/components/ComparisonTable.vue
+++ /dev/null
@@ -1,63 +0,0 @@
-
-
-
-
-
- Same education budget ({{ comparison.config.numEducated }} of
- {{ comparison.config.numStudents }} students), different targeting
-
-
-
- Strategy
- Educated
- Reached
- Share of school
- Rounds
-
-
-
-
- {{ strategyLabels[result.strategy] ?? result.strategy }}
- {{ result.educated.length }}
- {{ result.numReached }} / {{ comparison.config.numStudents }}
- {{ Math.round(result.reachedPct) }}%
- {{ result.numRounds }}
-
-
-
-
-
-
diff --git a/web/src/components/ErrorBanner.vue b/web/src/components/ErrorBanner.vue
new file mode 100644
index 0000000..75a2611
--- /dev/null
+++ b/web/src/components/ErrorBanner.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+ Link contained an invalid configuration, showing defaults
+
+
+
+
+
+
+ Could not update: {{ store.state.errorMessage }}
+
Retry
+
+
+
+
diff --git a/web/src/components/FooterDisclaimer.vue b/web/src/components/FooterDisclaimer.vue
new file mode 100644
index 0000000..9990312
--- /dev/null
+++ b/web/src/components/FooterDisclaimer.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
diff --git a/web/src/components/HeroHeadline.vue b/web/src/components/HeroHeadline.vue
new file mode 100644
index 0000000..f0220d7
--- /dev/null
+++ b/web/src/components/HeroHeadline.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
{{ preset.headline }}
+
+
+ {{ segment.text }}
+ {{ segment.text }}
+
+
+
+
+
+
+
+
+ {{ preset.disclaimerShort }}
+
+
+
+
+
+
+
diff --git a/web/src/components/LegendRow.vue b/web/src/components/LegendRow.vue
new file mode 100644
index 0000000..21b5350
--- /dev/null
+++ b/web/src/components/LegendRow.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
+ Forwarded the fake
+ Educated to refuse
+ Not reached
+ Origin
+
+
{{ preset.readingCaption }}
+
+
+
+
diff --git a/web/src/components/NetworkView.vue b/web/src/components/NetworkView.vue
new file mode 100644
index 0000000..6e9b5e8
--- /dev/null
+++ b/web/src/components/NetworkView.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/PanelGrid.vue b/web/src/components/PanelGrid.vue
new file mode 100644
index 0000000..16ff991
--- /dev/null
+++ b/web/src/components/PanelGrid.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+ Could not reach the spreadlab API at {{ apiBaseUrl }}
+
+
Retry
+
+
+
+
+
+
+
diff --git a/web/src/components/ResultsTable.vue b/web/src/components/ResultsTable.vue
new file mode 100644
index 0000000..19bc416
--- /dev/null
+++ b/web/src/components/ResultsTable.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+ Data table
+
+
+
+ Per-scenario results for the current configuration
+
+
+
+ Scenario
+ Strategy
+ Educated
+ Reached
+ Share of school
+ Rounds
+
+
+
+
+ {{ panel.label }}
+
+ {{ strategyLabels[panel.strategy] ?? panel.strategy }}
+ {{ resultsByPanelId[panel.id]!.educated.length }}
+ {{ resultsByPanelId[panel.id]!.numReached }} / {{ numStudentsFor(panel) }}
+ {{ formatPct(resultsByPanelId[panel.id]!.reachedPct) }}
+ {{ resultsByPanelId[panel.id]!.numRounds }}
+
+ Running…
+
+
+
+
+
+
+
diff --git a/web/src/components/ScenarioPanel.vue b/web/src/components/ScenarioPanel.vue
new file mode 100644
index 0000000..f91fbb9
--- /dev/null
+++ b/web/src/components/ScenarioPanel.vue
@@ -0,0 +1,234 @@
+
+
+
+
+
+
+
{{ panel.label }}
+
+
+
+
+
+
+ {{ roundPct(result.reachedPct) }}%
+ {{ formatPct(result.reachedPct) }} reached
+
+
+ {{ result.numReached }} of {{ numStudents }} reached
+ •
+ {{ result.numRounds }} rounds
+ •
+ {{ result.educated.length }} educated
+
+
+ {{ chip.text }}
+
+
+
+
+
+ Running scenario…
+
+
+
+
+
diff --git a/web/src/components/ScenarioToolbar.vue b/web/src/components/ScenarioToolbar.vue
new file mode 100644
index 0000000..3d34ef9
--- /dev/null
+++ b/web/src/components/ScenarioToolbar.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
diff --git a/web/src/components/__tests__/ComparisonTable.spec.ts b/web/src/components/__tests__/ComparisonTable.spec.ts
deleted file mode 100644
index 810c3b4..0000000
--- a/web/src/components/__tests__/ComparisonTable.spec.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { mount } from '@vue/test-utils'
-import ComparisonTable from '../ComparisonTable.vue'
-import type { ComparisonResponse } from '@/types/api'
-
-// A small hand-made comparison; the real numbers come from the API and are
-// pinned by the Go tests. Here we only care that the table renders them.
-const comparison: ComparisonResponse = {
- config: {
- numStudents: 10,
- edgesPerNode: 2,
- triangleProb: 0.4,
- forwardProb: 0.5,
- numEducated: 3,
- origin: 0,
- graphSeed: 1,
- thresholdSeed: 2,
- educationSeed: 3,
- },
- results: [
- {
- strategy: 'none',
- educated: [],
- reachedAtRound: [0, 1, 1, 2, 2, 2, 3, -1, -1, -1],
- numReached: 7,
- numRounds: 4,
- reachedPct: 70,
- },
- {
- strategy: 'random',
- educated: [2, 5, 8],
- reachedAtRound: [0, 1, 1, 2, -1, -1, -1, -1, -1, -1],
- numReached: 4,
- numRounds: 3,
- reachedPct: 40,
- },
- {
- strategy: 'most-connected',
- educated: [1, 2, 3],
- reachedAtRound: [0, 1, -1, -1, -1, -1, -1, -1, -1, -1],
- numReached: 2,
- numRounds: 2,
- reachedPct: 20,
- },
- ],
-}
-
-describe('ComparisonTable', () => {
- it('renders one row per strategy with its reach', () => {
- const wrapper = mount(ComparisonTable, { props: { comparison } })
-
- const rows = wrapper.findAll('tbody tr')
- expect(rows).toHaveLength(3)
- expect(rows[0]!.text()).toContain('No program')
- expect(rows[0]!.text()).toContain('7 / 10')
- expect(rows[2]!.text()).toContain('Educate the most connected')
- expect(rows[2]!.text()).toContain('20%')
- })
-
- it('falls back to the raw strategy name for unknown strategies', () => {
- const unknown: ComparisonResponse = {
- config: comparison.config,
- results: [{ ...comparison.results[0]!, strategy: 'telepathy' }],
- }
- const wrapper = mount(ComparisonTable, { props: { comparison: unknown } })
- expect(wrapper.find('tbody th').text()).toBe('telepathy')
- })
-})
diff --git a/web/src/components/__tests__/ResultsTable.spec.ts b/web/src/components/__tests__/ResultsTable.spec.ts
new file mode 100644
index 0000000..17d3a6f
--- /dev/null
+++ b/web/src/components/__tests__/ResultsTable.spec.ts
@@ -0,0 +1,89 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ResultsTable from '../ResultsTable.vue'
+import type { PanelSpec } from '@/presets/types'
+import type { Config, Result } from '@/types/engine'
+
+// Hand-made data; the real numbers come from the API and are pinned by the
+// Go tests. Here we only care that the table renders them accessibly.
+
+const base: Config = {
+ numStudents: 10,
+ edgesPerNode: 2,
+ triangleProb: 0.4,
+ forwardProb: 0.5,
+ numEducated: 3,
+ origin: 0,
+ graphSeed: 1,
+ thresholdSeed: 2,
+ educationSeed: 3,
+}
+
+const panels: PanelSpec[] = [
+ { id: 'panel-none', label: 'No program', strategy: 'none', overrides: {} },
+ { id: 'panel-random', label: 'Random picks', strategy: 'random', overrides: {} },
+ {
+ id: 'panel-big',
+ label: 'Bigger school',
+ strategy: 'most-connected',
+ overrides: { numStudents: 40 },
+ },
+]
+
+const resultsByPanelId: Record = {
+ 'panel-none': {
+ strategy: 'none',
+ educated: [],
+ reachedAtRound: [0, 1, 1, 2, 2, 2, 3, -1, -1, -1],
+ numReached: 7,
+ numRounds: 4,
+ reachedPct: 70,
+ },
+ 'panel-random': {
+ strategy: 'random',
+ educated: [2, 5, 8],
+ reachedAtRound: [0, 1, 1, 2, -1, -1, -1, -1, -1, -1],
+ numReached: 4,
+ numRounds: 3,
+ reachedPct: 40,
+ },
+}
+
+describe('ResultsTable', () => {
+ it('renders one row per panel with its reach, honoring overrides', () => {
+ const wrapper = mount(ResultsTable, { props: { panels, resultsByPanelId, base } })
+
+ const rows = wrapper.findAll('tbody tr')
+ expect(rows).toHaveLength(3)
+ expect(rows[0]!.text()).toContain('No program')
+ expect(rows[0]!.text()).toContain('7 / 10')
+ expect(rows[0]!.text()).toContain('70%')
+ expect(rows[1]!.text()).toContain('Random')
+ // The third panel has no result yet and overrides numStudents.
+ expect(rows[2]!.text()).toContain('Running')
+ })
+
+ it('rounds the share with the shared formatPct', () => {
+ const halfPctResults = {
+ 'panel-none': { ...resultsByPanelId['panel-none']!, reachedPct: 82.5 },
+ }
+ const wrapper = mount(ResultsTable, {
+ props: { panels: panels.slice(0, 1), resultsByPanelId: halfPctResults, base },
+ })
+ expect(wrapper.find('tbody tr').text()).toContain('83%')
+ })
+
+ it('falls back to the raw strategy name for unknown strategies', () => {
+ const oddPanels: PanelSpec[] = [
+ { id: 'panel-odd', label: 'Odd', strategy: 'telepathy', overrides: {} },
+ ]
+ const wrapper = mount(ResultsTable, {
+ props: {
+ panels: oddPanels,
+ resultsByPanelId: { 'panel-odd': resultsByPanelId['panel-none']! },
+ base,
+ },
+ })
+ expect(wrapper.findAll('tbody td')[0]!.text()).toBe('telepathy')
+ })
+})
diff --git a/web/src/composables/useLayout.ts b/web/src/composables/useLayout.ts
new file mode 100644
index 0000000..9954f40
--- /dev/null
+++ b/web/src/composables/useLayout.ts
@@ -0,0 +1,87 @@
+import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY } from 'd3-force'
+import type { SimulationLinkDatum, SimulationNodeDatum } from 'd3-force'
+import { graphKey } from '@/lib/graph'
+import { mulberry32 } from '@/lib/mulberry32'
+import type { Config } from '@/types/engine'
+
+// Seeded force layout (spec section 6): d3-force run synchronously for a
+// fixed number of ticks with a mulberry32 random source and seeded initial
+// positions, so identical configs give identical pictures on every machine
+// (the shared-URL guarantee). Layouts are cached per graph hash; panels
+// that share the base graph share one layout and their differences read as
+// the experiment, not as noise.
+
+export interface LayoutPoint {
+ x: number
+ y: number
+}
+
+export const NETWORK_VIEW_WIDTH = 380
+export const NETWORK_VIEW_HEIGHT = 230
+const LAYOUT_PADDING = 14
+const LAYOUT_TICKS = 300
+
+interface LayoutNode extends SimulationNodeDatum {
+ index: number
+}
+
+const layoutCache = new Map()
+
+export function clearLayoutCache() {
+ layoutCache.clear()
+}
+
+export function layoutForGraph(config: Config, edges: number[][]): LayoutPoint[] {
+ const cacheKey = graphKey(config)
+ const cached = layoutCache.get(cacheKey)
+ if (cached) return cached
+
+ const random = mulberry32(Number(config.graphSeed) >>> 0)
+ const nodes: LayoutNode[] = Array.from({ length: config.numStudents }, (_, index) => {
+ // Seeded start positions in a wide disc; the forces do the rest.
+ const angle = random() * Math.PI * 2
+ const radius = Math.sqrt(random())
+ return { index, x: Math.cos(angle) * radius * 150, y: Math.sin(angle) * radius * 60 }
+ })
+ const links: SimulationLinkDatum[] = edges.map((edge) => ({
+ source: edge[0] ?? 0,
+ target: edge[1] ?? 0,
+ }))
+
+ // The vertical pull is stronger than the horizontal one, so the cloud
+ // settles into the panel's wide ellipse by itself; fitting then scales
+ // both axes uniformly, which keeps the spacing organic (anisotropic
+ // stretching reads as line patterns).
+ forceSimulation(nodes)
+ .randomSource(random)
+ .force(
+ 'link',
+ forceLink>(links).distance(16).strength(0.3),
+ )
+ .force('charge', forceManyBody().strength(-15))
+ .force('x', forceX(0).strength(0.028))
+ .force('y', forceY(0).strength(0.22))
+ .force('collide', forceCollide(5.5))
+ .stop()
+ .tick(LAYOUT_TICKS)
+
+ const xs = nodes.map((node) => node.x ?? 0)
+ const ys = nodes.map((node) => node.y ?? 0)
+ const minX = Math.min(...xs)
+ const maxX = Math.max(...xs)
+ const minY = Math.min(...ys)
+ const maxY = Math.max(...ys)
+ const scale = Math.min(
+ (NETWORK_VIEW_WIDTH - 2 * LAYOUT_PADDING) / Math.max(maxX - minX, 1),
+ (NETWORK_VIEW_HEIGHT - 2 * LAYOUT_PADDING) / Math.max(maxY - minY, 1),
+ )
+ const offsetX = (NETWORK_VIEW_WIDTH - (maxX - minX) * scale) / 2
+ const offsetY = (NETWORK_VIEW_HEIGHT - (maxY - minY) * scale) / 2
+
+ const points = nodes.map((node) => ({
+ x: offsetX + ((node.x ?? 0) - minX) * scale,
+ y: offsetY + ((node.y ?? 0) - minY) * scale,
+ }))
+ layoutCache.set(cacheKey, points)
+ return points
+}
diff --git a/web/src/composables/useTheme.ts b/web/src/composables/useTheme.ts
new file mode 100644
index 0000000..2c90c72
--- /dev/null
+++ b/web/src/composables/useTheme.ts
@@ -0,0 +1,35 @@
+import { ref } from 'vue'
+
+// Theme handling (spec 5.11): default follows prefers-color-scheme, the
+// toggle overrides and persists. Theme is never URL state. index.html
+// applies the class before the app mounts to avoid a flash; this module is
+// the runtime source of truth.
+
+export type ThemeName = 'light' | 'dark'
+
+const THEME_STORAGE_KEY = 'spreadlab-theme'
+
+const theme = ref('light')
+let initialized = false
+
+function applyTheme(nextTheme: ThemeName) {
+ theme.value = nextTheme
+ document.documentElement.classList.toggle('dark', nextTheme === 'dark')
+}
+
+export function useTheme() {
+ if (!initialized) {
+ initialized = true
+ const stored = localStorage.getItem(THEME_STORAGE_KEY)
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
+ applyTheme(stored === 'dark' || stored === 'light' ? stored : prefersDark ? 'dark' : 'light')
+ }
+
+ function toggleTheme() {
+ const nextTheme: ThemeName = theme.value === 'dark' ? 'light' : 'dark'
+ localStorage.setItem(THEME_STORAGE_KEY, nextTheme)
+ applyTheme(nextTheme)
+ }
+
+ return { theme, toggleTheme }
+}
diff --git a/web/src/lib/__tests__/urlState.spec.ts b/web/src/lib/__tests__/urlState.spec.ts
index dae83ce..ef2af93 100644
--- a/web/src/lib/__tests__/urlState.spec.ts
+++ b/web/src/lib/__tests__/urlState.spec.ts
@@ -9,6 +9,7 @@ const preset: StudyPreset = {
narrative: 'n',
disclaimerShort: 'short',
disclaimerLong: 'long',
+ readingCaption: 'caption',
toneThresholdPct: 30,
base: {
numStudents: 120,
diff --git a/web/src/lib/accents.ts b/web/src/lib/accents.ts
new file mode 100644
index 0000000..d034d5b
--- /dev/null
+++ b/web/src/lib/accents.ts
@@ -0,0 +1,15 @@
+// Panel accent palette (spec section 6): categorical and deliberately
+// distinct from the rose/teal node semantics. Assigned by panel position,
+// recycled on remove.
+export const PANEL_ACCENTS = [
+ '#6366F1',
+ '#F59E0B',
+ '#0EA5E9',
+ '#8B5CF6',
+ '#EC4899',
+ '#84CC16',
+] as const
+
+export function accentForPanel(panelIndex: number): string {
+ return PANEL_ACCENTS[panelIndex % PANEL_ACCENTS.length] ?? PANEL_ACCENTS[0]
+}
diff --git a/web/src/lib/mulberry32.ts b/web/src/lib/mulberry32.ts
new file mode 100644
index 0000000..8ca4850
--- /dev/null
+++ b/web/src/lib/mulberry32.ts
@@ -0,0 +1,12 @@
+// Deterministic 32-bit PRNG (spec section 6). The force layout must come
+// out identical on every machine so a shared URL reproduces the exact
+// picture; Math.random would break that guarantee.
+export function mulberry32(seed: number): () => number {
+ let stateWord = seed | 0
+ return () => {
+ stateWord = (stateWord + 0x6d2b79f5) | 0
+ let mixed = Math.imul(stateWord ^ (stateWord >>> 15), 1 | stateWord)
+ mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed
+ return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296
+ }
+}
diff --git a/web/src/presets/deepfake-school.ts b/web/src/presets/deepfake-school.ts
index 9eea8dc..3cb5386 100644
--- a/web/src/presets/deepfake-school.ts
+++ b/web/src/presets/deepfake-school.ts
@@ -19,6 +19,9 @@ export const deepfakeSchoolPreset: StudyPreset = {
'illustration, not fitted to data, so it is not a validated prediction ' +
'of any real school. Use it to build intuition about who to educate, ' +
'not to forecast outcomes.',
+ readingCaption:
+ 'Each dot is one student in the same simulated year group. Every scenario ' +
+ 'runs the identical school; only the education program differs.',
toneThresholdPct: 30,
base: {
numStudents: 120,
diff --git a/web/src/presets/types.ts b/web/src/presets/types.ts
index a21e953..3dc1182 100644
--- a/web/src/presets/types.ts
+++ b/web/src/presets/types.ts
@@ -18,6 +18,7 @@ export interface StudyPreset {
narrative: string
disclaimerShort: string // badge text
disclaimerLong: string // About popover body
+ readingCaption: string // one quiet line under the legend: how to read the dots
toneThresholdPct: number // reached% <= threshold renders "good" (teal)
base: Config // the default world
panels: PanelSpec[] // initial panels