web: the dashboard page, slice 3 of M3

The milestone 2 parity page becomes the spec's dashboard: app bar with
wordmark, disclaimer badge and About popover, theme toggle (persisted,
defaults to prefers-color-scheme, applied pre-mount to avoid a flash),
hero with the preset narrative and live toned percentages, scenario
toolbar, panel cards with skeleton loading and dim-while-refreshing,
legend, and footer. ComparisonTable evolves into the collapsed
ResultsTable (test moved along, plus a formatPct rounding case).

NetworkView renders the real topology with the spec's shape encodings.
The layout is seeded d3-force (mulberry32 random source, seeded initial
positions, 300 synchronous ticks, cached per graph hash); anisotropic
forceX/forceY pulls settle the cloud into the wide card shape so the
fit stays uniform-scale and the spacing organic. Justin asked for a
taller network area (380x230) and a reading caption under the legend;
the caption is study copy, so StudyPreset gains a readingCaption field.

Slices still to come keep their controls visibly parked: Export and the
panel kebab render disabled until slices 11 and 7.
This commit is contained in:
Justin Visser 2026-06-10 16:55:14 +02:00
parent 2f21248c20
commit 71baf10e73
28 changed files with 1766 additions and 300 deletions

1
web/.gitignore vendored
View file

@ -37,3 +37,4 @@ __screenshots__/
# Vite
*.timestamp-*-*.mjs
public/__*.html

View file

@ -1,10 +1,25 @@
<!DOCTYPE html>
<html lang="">
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>spreadlab</title>
<script>
// Apply the persisted (or preferred) theme before the app mounts so
// dark mode never flashes light. Kept in sync with useTheme.ts.
try {
var storedTheme = localStorage.getItem('spreadlab-theme')
if (
storedTheme === 'dark' ||
(storedTheme !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark')
}
} catch (storageError) {
/* default to light */
}
</script>
</head>
<body>
<div id="app"></div>

126
web/package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -1,40 +1,51 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchDefaultConfig, runComparison } from '@/lib/api'
import type { ComparisonResponse } from '@/types/api'
import ComparisonTable from '@/components/ComparisonTable.vue'
import { onMounted } from 'vue'
import AppBar from '@/components/AppBar.vue'
import ErrorBanner from '@/components/ErrorBanner.vue'
import FooterDisclaimer from '@/components/FooterDisclaimer.vue'
import HeroHeadline from '@/components/HeroHeadline.vue'
import LegendRow from '@/components/LegendRow.vue'
import PanelGrid from '@/components/PanelGrid.vue'
import ResultsTable from '@/components/ResultsTable.vue'
import ScenarioToolbar from '@/components/ScenarioToolbar.vue'
import { useSimStore } from '@/composables/useSimStore'
import { useTheme } from '@/composables/useTheme'
const comparison = ref<ComparisonResponse | null>(null)
const error = ref<string | null>(null)
const store = useSimStore()
useTheme() // resolve and apply the theme before first paint of the app
onMounted(async () => {
try {
const config = await fetchDefaultConfig()
comparison.value = await runComparison(config)
} catch (cause) {
error.value = cause instanceof Error ? cause.message : String(cause)
}
onMounted(() => {
void store.initialize()
})
</script>
<template>
<main>
<h1>spreadlab</h1>
<p>
How a non-consensual deepfake spreads through a simulated school year
group, and what an education program changes. Illustrative, not
validated.
</p>
<p v-if="error" role="alert">Could not reach the spreadlab API: {{ error }}</p>
<ComparisonTable v-else-if="comparison" :comparison="comparison" />
<p v-else>Running scenarios&hellip;</p>
<AppBar />
<main class="page">
<HeroHeadline />
<ScenarioToolbar />
<ErrorBanner />
<PanelGrid />
<LegendRow />
<ResultsTable
:panels="store.state.panels"
:results-by-panel-id="store.state.resultsByPanelId"
:base="store.state.base"
/>
</main>
<FooterDisclaimer />
</template>
<style scoped>
main {
max-width: 48rem;
.page {
max-width: 1240px;
margin: 0 auto;
padding: 2rem 1rem;
padding: 36px 28px 30px;
}
@media (max-width: 760px) {
.page {
padding: 22px 16px;
}
}
</style>

View file

@ -1,86 +0,0 @@
/* color palette from <https://github.com/vuejs/theme> */
: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;
}

View file

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

View file

@ -0,0 +1,111 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useSimStore } from '@/composables/useSimStore'
// Small dialog under the disclaimer badge (spec 5.12): what the model is,
// what it is not, and a link to the source. Esc, outside click, or the
// close button dismiss it; the opener restores focus on close.
const props = defineProps<{ anchor: HTMLElement | null }>()
const emit = defineEmits<{ close: [] }>()
const { preset } = useSimStore()
const popover = ref<HTMLElement | null>(null)
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') emit('close')
}
function onPointerDown(event: PointerEvent) {
const target = event.target as Node
if (popover.value?.contains(target)) return
if (props.anchor?.contains(target)) return // the badge itself toggles
emit('close')
}
onMounted(() => {
popover.value?.focus()
document.addEventListener('keydown', onKeydown)
document.addEventListener('pointerdown', onPointerDown, true)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown)
document.removeEventListener('pointerdown', onPointerDown, true)
})
</script>
<template>
<div ref="popover" class="about" role="dialog" aria-label="About this model" tabindex="-1">
<h3>About this model</h3>
<p>{{ preset.disclaimerLong }}</p>
<p>
<a href="https://github.com/JustinZeus/spreadlab" target="_blank" rel="noopener">
Proposal and source on GitHub
</a>
</p>
<button class="close" type="button" aria-label="Close" @click="emit('close')">
<svg class="ic" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
</template>
<style scoped>
.about {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 30;
width: min(420px, calc(100vw - 32px));
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-lift);
padding: 16px 40px 16px 18px;
}
h3 {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-4);
}
p {
margin-top: 8px;
font-size: 13.5px;
line-height: 1.55;
color: var(--ink-2);
}
a {
color: var(--edu);
font-weight: 550;
}
.close {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
border: none;
background: none;
border-radius: 8px;
color: var(--ink-3);
display: grid;
place-items: center;
cursor: pointer;
}
.close:hover {
background: var(--bg);
color: var(--ink);
}
.close svg.ic {
width: 14px;
height: 14px;
}
</style>

View file

@ -0,0 +1,184 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import AboutPopover from './AboutPopover.vue'
import { useSimStore } from '@/composables/useSimStore'
import { useTheme } from '@/composables/useTheme'
const { preset } = useSimStore()
const { theme, toggleTheme } = useTheme()
const aboutOpen = ref(false)
const badgeButton = ref<HTMLButtonElement | null>(null)
function closeAbout() {
aboutOpen.value = false
void nextTick(() => badgeButton.value?.focus())
}
</script>
<template>
<header class="appbar">
<div class="in">
<span class="wordmark">
<span class="glyph" aria-hidden="true">
<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-anchor">
<button
ref="badgeButton"
class="badge"
type="button"
:aria-expanded="aboutOpen"
@click="aboutOpen = !aboutOpen"
>
<svg class="ic" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 8v.01" />
</svg>
{{ preset.disclaimerShort }}
</button>
<AboutPopover v-if="aboutOpen" :anchor="badgeButton" @close="closeAbout" />
</span>
<span class="grow" />
<button
class="iconbtn"
type="button"
:aria-label="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
@click="toggleTheme"
>
<svg v-if="theme === 'dark'" class="ic" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4" />
<path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.5 1.5M16.9 16.9l1.5 1.5M5.6 18.4l1.5-1.5M16.9 7.1l1.5-1.5"
/>
</svg>
<svg v-else 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>
<a
class="iconbtn"
href="https://github.com/JustinZeus/spreadlab"
target="_blank"
rel="noopener"
aria-label="Source code on GitHub"
>
<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>
</a>
</div>
</header>
</template>
<style scoped>
.appbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 20;
}
.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;
}
.glyph {
width: 26px;
height: 26px;
border-radius: 8px;
background: linear-gradient(135deg, var(--edu), #0d9488);
display: grid;
place-items: center;
}
.glyph svg {
width: 15px;
height: 15px;
stroke: #fff;
fill: none;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.badge-anchor {
position: relative;
display: inline-flex;
}
.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;
}
.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);
}
@media (max-width: 760px) {
.in {
padding: 0 16px;
}
.badge-anchor {
display: none;
}
}
</style>

View file

@ -1,63 +0,0 @@
<script setup lang="ts">
import type { ComparisonResponse } from '@/types/api'
defineProps<{ comparison: ComparisonResponse }>()
const strategyLabels: Record<string, string> = {
none: 'No program',
random: 'Educate at random',
'most-connected': 'Educate the most connected',
}
</script>
<template>
<table>
<caption>
Same education budget ({{ comparison.config.numEducated }} of
{{ comparison.config.numStudents }} students), different targeting
</caption>
<thead>
<tr>
<th scope="col">Strategy</th>
<th scope="col">Educated</th>
<th scope="col">Reached</th>
<th scope="col">Share of school</th>
<th scope="col">Rounds</th>
</tr>
</thead>
<tbody>
<tr v-for="result in comparison.results" :key="result.strategy">
<th scope="row">{{ strategyLabels[result.strategy] ?? result.strategy }}</th>
<td>{{ result.educated.length }}</td>
<td>{{ result.numReached }} / {{ comparison.config.numStudents }}</td>
<td>{{ Math.round(result.reachedPct) }}%</td>
<td>{{ result.numRounds }}</td>
</tr>
</tbody>
</table>
</template>
<style scoped>
table {
border-collapse: collapse;
margin-top: 1.5rem;
}
caption {
caption-side: top;
text-align: left;
padding-bottom: 0.75rem;
color: var(--color-text);
}
th,
td {
border: 1px solid var(--color-border);
padding: 0.5rem 0.9rem;
text-align: left;
}
thead th {
background: var(--color-background-soft);
}
</style>

View file

@ -0,0 +1,61 @@
<script setup lang="ts">
import { useSimStore } from '@/composables/useSimStore'
// Failed refreshes and invalid shared links (spec 5.1/5.2). Engine 400s do
// not land here; they render inline under the controls card. The last good
// results stay on screen behind this banner.
const store = useSimStore()
</script>
<template>
<div v-if="store.state.urlStateInvalid" class="banner" role="alert">
<svg class="ic" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 8v.01" />
</svg>
Link contained an invalid configuration, showing defaults
</div>
<div v-if="store.state.errorMessage" class="banner" role="alert">
<svg class="ic" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 8v.01" />
</svg>
Could not update: {{ store.state.errorMessage }}
<button class="retry" type="button" @click="store.retry()">Retry</button>
</div>
</template>
<style scoped>
.banner {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 14px;
padding: 10px 14px;
font-size: 13.5px;
font-weight: 500;
color: var(--spread);
background: var(--spread-soft);
border: 1px solid color-mix(in srgb, var(--spread) 25%, transparent);
border-radius: 10px;
}
svg.ic {
width: 16px;
height: 16px;
flex: none;
}
.retry {
margin-left: auto;
font-size: 12.5px;
font-weight: 600;
color: var(--spread);
background: none;
border: 1px solid currentColor;
border-radius: 8px;
padding: 3px 10px;
cursor: pointer;
}
</style>

View file

@ -0,0 +1,35 @@
<template>
<footer class="footer">
<div class="in">
<span>
<em>Illustrative (not validated output)</em> · an agentbased toy model, parameters chosen
for clarity
</span>
<span>spreadlab · AGPL</span>
</div>
</footer>
</template>
<style scoped>
.footer .in {
max-width: 1240px;
margin: 0 auto;
padding: 0 28px 48px;
display: flex;
justify-content: space-between;
font-size: 12.5px;
color: var(--ink-4);
}
em {
font-style: italic;
}
@media (max-width: 760px) {
.footer .in {
flex-direction: column;
gap: 4px;
padding: 0 16px 110px;
}
}
</style>

View file

@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import AboutPopover from './AboutPopover.vue'
import { useSimStore } from '@/composables/useSimStore'
import { formatPct, roundPct } from '@/lib/format'
// The grant story in one paragraph. {n} placeholders in the preset
// narrative become each panel's live reached%, toned good or bad against
// the preset threshold; the message must land without interaction.
const { preset, state } = useSimStore()
interface NarrativeSegment {
kind: 'text' | 'pct'
text: string
tone?: 'good' | 'bad' | 'pending'
}
const segments = computed<NarrativeSegment[]>(() =>
preset.narrative.split(/(\{\d+\})/).map((part) => {
const placeholder = /^\{(\d+)\}$/.exec(part)
if (!placeholder) return { kind: 'text', text: part }
const panel = state.panels[Number(placeholder[1])]
const result = panel ? state.resultsByPanelId[panel.id] : undefined
if (!result) return { kind: 'pct', text: '…', tone: 'pending' }
return {
kind: 'pct',
text: formatPct(result.reachedPct),
tone: roundPct(result.reachedPct) <= preset.toneThresholdPct ? 'good' : 'bad',
}
}),
)
const aboutOpen = ref(false)
const noteButton = ref<HTMLButtonElement | null>(null)
</script>
<template>
<div class="hero">
<h1>{{ preset.headline }}</h1>
<p>
<template v-for="(segment, index) in segments" :key="index">
<b v-if="segment.kind === 'pct'" :class="segment.tone">{{ segment.text }}</b>
<template v-else>{{ segment.text }}</template>
</template>
</p>
<span class="mobile-note">
<button
ref="noteButton"
type="button"
class="note-button"
:aria-expanded="aboutOpen"
@click="aboutOpen = !aboutOpen"
>
<svg class="ic" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 8v.01" />
</svg>
{{ preset.disclaimerShort }}
</button>
<AboutPopover v-if="aboutOpen" :anchor="noteButton" @close="aboutOpen = false" />
</span>
</div>
</template>
<style scoped>
.hero {
max-width: 760px;
}
h1 {
font-size: 30px;
line-height: 1.18;
font-weight: 700;
letter-spacing: -0.022em;
}
p {
margin-top: 10px;
font-size: 16.5px;
color: var(--ink-3);
line-height: 1.55;
}
p b {
font-weight: 650;
}
p b.bad {
color: var(--spread);
}
p b.good {
color: var(--edu);
}
p b.pending {
color: var(--ink-4);
}
.mobile-note {
display: none;
}
@media (max-width: 760px) {
h1 {
font-size: 22px;
}
p {
font-size: 15px;
}
.mobile-note {
display: block;
position: relative;
margin-top: 10px;
}
.note-button {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--ink-4);
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.note-button svg.ic {
width: 14px;
height: 14px;
}
}
</style>

View file

@ -0,0 +1,76 @@
<script setup lang="ts">
import { useSimStore } from '@/composables/useSimStore'
const { preset } = useSimStore()
</script>
<template>
<div class="legend-block">
<div class="legend">
<span class="k"><span class="lg spread" aria-hidden="true" /> Forwarded the fake</span>
<span class="k"><span class="lg educated" aria-hidden="true" /> Educated to refuse</span>
<span class="k"><span class="lg unreached" aria-hidden="true" /> Not reached</span>
<span class="k"><span class="lg origin" aria-hidden="true" /> Origin</span>
</div>
<p class="caption">{{ preset.readingCaption }}</p>
</div>
</template>
<style scoped>
.legend-block {
margin: 14px 2px 0;
}
.legend {
display: flex;
gap: 22px;
font-size: 13px;
color: var(--ink-3);
flex-wrap: wrap;
}
.k {
display: inline-flex;
align-items: center;
gap: 7px;
}
.lg {
width: 11px;
height: 11px;
border-radius: 50%;
flex: none;
}
.lg.spread {
background: var(--spread);
}
.lg.educated {
width: 8px;
height: 8px;
border: 2.5px solid var(--edu);
}
.lg.unreached {
background: var(--unreached);
}
.lg.origin {
width: 9px;
height: 9px;
border: 2px solid var(--spread);
}
.caption {
margin-top: 6px;
font-size: 13px;
color: var(--ink-3);
}
@media (max-width: 760px) {
.legend {
gap: 14px;
}
}
</style>

View file

@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed } from 'vue'
import { layoutForGraph, NETWORK_VIEW_HEIGHT, NETWORK_VIEW_WIDTH } from '@/composables/useLayout'
import { useSimStore } from '@/composables/useSimStore'
import { graphKey } from '@/lib/graph'
import type { PanelSpec } from '@/presets/types'
// One panel's network picture (spec section 6). Shape carries meaning,
// color reinforces it: forwarded discs, educated donuts, small unreached
// dots, and a halo ring on the origin. The SVG is decorative for screen
// readers; the panel text carries the result.
const props = defineProps<{ panel: PanelSpec }>()
const store = useSimStore()
const effectiveConfig = computed(() => store.effectiveConfig(props.panel))
const edges = computed(() => store.state.edgesByGraphHash[graphKey(effectiveConfig.value)] ?? [])
const result = computed(() => store.state.resultsByPanelId[props.panel.id])
const layout = computed(() => layoutForGraph(effectiveConfig.value, edges.value))
interface RenderedNode {
x: number
y: number
reached: boolean
educated: boolean
isOrigin: boolean
}
const nodes = computed<RenderedNode[]>(() => {
const panelResult = result.value
if (!panelResult) return []
const educatedNodes = new Set(panelResult.educated)
return layout.value.map((point, nodeIndex) => {
const reachedAtRound = panelResult.reachedAtRound[nodeIndex] ?? -1
return {
x: point.x,
y: point.y,
reached: reachedAtRound >= 0 && reachedAtRound <= store.state.round,
educated: educatedNodes.has(nodeIndex),
isOrigin: nodeIndex === effectiveConfig.value.origin,
}
})
})
function edgeEnd(edge: number[], side: 0 | 1) {
return layout.value[edge[side] ?? 0] ?? { x: 0, y: 0 }
}
</script>
<template>
<svg class="net" :viewBox="`0 0 ${NETWORK_VIEW_WIDTH} ${NETWORK_VIEW_HEIGHT}`" aria-hidden="true">
<line
v-for="(edge, edgeIndex) in edges"
:key="edgeIndex"
:x1="edgeEnd(edge, 0).x.toFixed(1)"
:y1="edgeEnd(edge, 0).y.toFixed(1)"
:x2="edgeEnd(edge, 1).x.toFixed(1)"
:y2="edgeEnd(edge, 1).y.toFixed(1)"
stroke="var(--edge)"
stroke-width="1"
/>
<g v-for="(node, nodeIndex) in nodes" :key="nodeIndex">
<template v-if="node.isOrigin">
<circle
:cx="node.x"
:cy="node.y"
r="7.5"
fill="none"
stroke="var(--spread)"
stroke-width="1.4"
opacity="0.75"
/>
<circle :cx="node.x" :cy="node.y" r="4" fill="var(--spread)" />
</template>
<circle
v-else-if="node.educated"
:cx="node.x"
:cy="node.y"
r="3.8"
:fill="node.reached ? 'var(--spread)' : 'var(--surface)'"
stroke="var(--edu)"
stroke-width="2.2"
/>
<circle v-else-if="node.reached" :cx="node.x" :cy="node.y" r="4" fill="var(--spread)" />
<circle v-else :cx="node.x" :cy="node.y" r="3" fill="var(--unreached)" />
</g>
</svg>
</template>
<style scoped>
.net {
width: 100%;
height: auto;
display: block;
margin-top: 4px;
}
</style>

View file

@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import ScenarioPanel from './ScenarioPanel.vue'
import { useSimStore } from '@/composables/useSimStore'
import { accentForPanel } from '@/lib/accents'
const store = useSimStore()
// Initial-load failure (spec 5.1): nothing rendered yet, so the panel area
// itself carries the message; nothing else is dimmed.
const unreachable = computed(
() =>
store.state.runState === 'error' &&
store.state.errorMessage !== null &&
Object.keys(store.state.resultsByPanelId).length === 0,
)
const apiBaseUrl = `${window.location.origin}/api`
</script>
<template>
<div v-if="unreachable" class="unreachable" role="alert">
<p>
Could not reach the spreadlab API at <code>{{ apiBaseUrl }}</code>
</p>
<button class="retry" type="button" @click="store.retry()">Retry</button>
</div>
<div v-else class="panels">
<ScenarioPanel
v-for="(panel, panelIndex) in store.state.panels"
:key="panel.id"
:panel="panel"
:accent="accentForPanel(panelIndex)"
/>
</div>
</template>
<style scoped>
.panels {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
.unreachable {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 64px 24px;
text-align: center;
color: var(--ink-3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.unreachable code {
font-size: 13px;
color: var(--ink-2);
}
.retry {
font-size: 13.5px;
font-weight: 600;
color: var(--surface);
background: var(--ink);
border: 1px solid var(--ink);
border-radius: 10px;
padding: 8px 16px;
cursor: pointer;
}
@media (max-width: 760px) {
.panels {
grid-template-columns: 1fr;
gap: 14px;
}
}
</style>

View file

@ -0,0 +1,137 @@
<script setup lang="ts">
import { formatPct } from '@/lib/format'
import type { PanelSpec } from '@/presets/types'
import type { Config, Result } from '@/types/engine'
// The collapsed, accessible counterpart of the panel cards (spec section
// 8): live per-panel numbers as a real table behind a disclosure. Evolved
// from the milestone 2 ComparisonTable.
const props = defineProps<{
panels: PanelSpec[]
resultsByPanelId: Record<string, Result>
base: Config
}>()
const strategyLabels: Record<string, string> = {
none: 'None',
random: 'Random',
'most-connected': 'Most connected',
}
function numStudentsFor(panel: PanelSpec): number {
return panel.overrides.numStudents ?? props.base.numStudents
}
</script>
<template>
<details class="results">
<summary>
<svg class="ic chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
Data table
</summary>
<table>
<caption>
Per-scenario results for the current configuration
</caption>
<thead>
<tr>
<th scope="col">Scenario</th>
<th scope="col">Strategy</th>
<th scope="col">Educated</th>
<th scope="col">Reached</th>
<th scope="col">Share of school</th>
<th scope="col">Rounds</th>
</tr>
</thead>
<tbody>
<tr v-for="panel in panels" :key="panel.id">
<th scope="row">{{ panel.label }}</th>
<template v-if="resultsByPanelId[panel.id]">
<td>{{ strategyLabels[panel.strategy] ?? panel.strategy }}</td>
<td>{{ resultsByPanelId[panel.id]!.educated.length }}</td>
<td>{{ resultsByPanelId[panel.id]!.numReached }} / {{ numStudentsFor(panel) }}</td>
<td>{{ formatPct(resultsByPanelId[panel.id]!.reachedPct) }}</td>
<td>{{ resultsByPanelId[panel.id]!.numRounds }}</td>
</template>
<td v-else colspan="5">Running</td>
</tr>
</tbody>
</table>
</details>
</template>
<style scoped>
.results {
margin-top: 26px;
}
summary {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--ink-3);
cursor: pointer;
list-style: none;
border-radius: 8px;
padding: 4px 6px;
}
summary::-webkit-details-marker {
display: none;
}
.chevron {
width: 14px;
height: 14px;
transition: transform 0.15s;
}
details[open] .chevron {
transform: rotate(90deg);
}
table {
border-collapse: collapse;
margin-top: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
font-size: 13.5px;
}
caption {
caption-side: top;
text-align: left;
padding-bottom: 8px;
font-size: 12.5px;
color: var(--ink-4);
}
th,
td {
border: 1px solid var(--border-soft);
padding: 7px 12px;
text-align: left;
}
thead th {
background: var(--bg);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-3);
}
tbody th {
font-weight: 600;
color: var(--ink-2);
}
td {
color: var(--ink-2);
}
</style>

View file

@ -0,0 +1,234 @@
<script setup lang="ts">
import { computed } from 'vue'
import NetworkView from './NetworkView.vue'
import { useSimStore } from '@/composables/useSimStore'
import { formatPct, roundPct } from '@/lib/format'
import type { PanelSpec } from '@/presets/types'
const props = defineProps<{ panel: PanelSpec; accent: string }>()
const store = useSimStore()
const result = computed(() => store.state.resultsByPanelId[props.panel.id])
const numStudents = computed(() => store.effectiveConfig(props.panel).numStudents)
const tone = computed(() =>
result.value && roundPct(result.value.reachedPct) <= store.preset.toneThresholdPct
? 'good'
: 'bad',
)
interface PanelChip {
key: string
text: string
}
const chips = computed<PanelChip[]>(() => {
const chipList: PanelChip[] = []
if (props.panel.strategy !== 'none') {
chipList.push({ key: 'strategy', text: `strategy · ${props.panel.strategy}` })
}
for (const [field, value] of Object.entries(props.panel.overrides)) {
chipList.push({ key: field, text: `${field} · ${value}` })
}
return chipList
})
// Refreshes over existing results dim the card; a panel without any result
// yet renders as a skeleton so the layout never jumps.
const dimmed = computed(() => store.state.runState === 'running' && result.value !== undefined)
</script>
<template>
<section class="panel" :class="{ dimmed }" :aria-label="panel.label">
<div class="head">
<span class="swatch" :style="{ background: accent }" aria-hidden="true" />
<span class="label">{{ panel.label }}</span>
<button class="kebab" type="button" disabled title="Scenario menu arrives in a later slice">
<svg class="ic" viewBox="0 0 24 24"><path d="M12 6h.01M12 12h.01M12 18h.01" /></svg>
</button>
</div>
<template v-if="result">
<div class="pct" :class="tone">
{{ roundPct(result.reachedPct) }}<small>%</small>
<span class="visually-hidden">{{ formatPct(result.reachedPct) }} reached</span>
</div>
<div class="meta">
<span>{{ result.numReached }} of {{ numStudents }} reached</span>
<span class="sep" aria-hidden="true"></span>
<span>{{ result.numRounds }} rounds</span>
<span class="sep" aria-hidden="true"></span>
<span>{{ result.educated.length }} educated</span>
</div>
<div class="chips">
<span v-for="chip in chips" :key="chip.key" class="chip">{{ chip.text }}</span>
</div>
<NetworkView :panel="panel" />
</template>
<template v-else>
<div class="skeleton" aria-hidden="true">
<div class="bone pct-bone" />
<div class="bone meta-bone" />
<div class="bone net-bone" />
</div>
<span class="visually-hidden">Running scenario</span>
</template>
</section>
</template>
<style scoped>
.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 0.15s,
opacity 0.15s;
}
.panel:hover {
box-shadow: var(--shadow-lift);
}
.panel.dimmed {
opacity: 0.6;
}
.head {
display: flex;
align-items: center;
gap: 8px;
}
.swatch {
width: 9px;
height: 9px;
border-radius: 3px;
flex: none;
}
.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;
}
.kebab {
color: var(--ink-4);
margin-right: -6px;
border: none;
background: none;
padding: 4px;
border-radius: 8px;
display: grid;
place-items: center;
}
.kebab:disabled {
cursor: default;
}
.pct {
margin-top: 10px;
font-size: 40px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1;
}
.pct small {
font-size: 22px;
font-weight: 600;
color: var(--ink-4);
letter-spacing: 0;
}
.pct.bad {
color: var(--spread);
}
.pct.good {
color: var(--edu);
}
.meta {
margin-top: 7px;
font-size: 12.5px;
color: var(--ink-4);
display: flex;
gap: 5px;
flex-wrap: wrap;
align-items: center;
}
.meta span {
white-space: nowrap;
}
.meta .sep {
color: var(--border);
}
.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;
}
/* Skeleton card (spec 5.1): same vertical rhythm as a loaded panel. */
.skeleton .bone {
border-radius: 8px;
background: linear-gradient(
100deg,
var(--border-soft) 40%,
var(--bg) 50%,
var(--border-soft) 60%
);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
.pct-bone {
width: 96px;
height: 40px;
margin-top: 10px;
}
.meta-bone {
width: 70%;
height: 13px;
margin-top: 9px;
}
.net-bone {
width: 100%;
aspect-ratio: 2 / 1;
margin-top: 35px;
}
@keyframes shimmer {
to {
background-position: -200% 0;
}
}
</style>

View file

@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSimStore } from '@/composables/useSimStore'
import { MAX_PANELS } from '@/presets/types'
const store = useSimStore()
const atPanelCap = computed(() => store.state.panels.length >= MAX_PANELS)
// A refresh is in flight over existing results; the initial load shows
// skeleton cards instead of this spinner.
const refreshing = computed(
() => store.state.runState === 'running' && Object.keys(store.state.resultsByPanelId).length > 0,
)
</script>
<template>
<div class="toolbar">
<h2>Scenarios</h2>
<span v-if="refreshing" class="spinner" role="status" aria-label="Updating scenarios" />
<span class="grow" />
<button class="btn" type="button" disabled title="Export arrives in a later slice">
<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"
type="button"
:disabled="atPanelCap"
:title="atPanelCap ? 'Maximum 6 scenarios' : undefined"
@click="store.addPanel()"
>
<svg class="ic" viewBox="0 0 24 24"><path d="M12 6v12M6 12h12" /></svg>
Add scenario
</button>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
align-items: center;
margin: 30px 0 14px;
gap: 10px;
}
h2 {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-4);
}
.grow {
flex: 1;
}
.spinner {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--border);
border-top-color: var(--ink-3);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13.5px;
font-weight: 600;
line-height: 1;
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, 0.04);
}
.btn:hover:not(:disabled) {
border-color: var(--ink-4);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn.primary {
background: var(--ink);
color: var(--surface);
border-color: var(--ink);
}
.btn svg.ic {
width: 15px;
height: 15px;
}
@media (max-width: 760px) {
.toolbar {
margin-top: 16px;
}
}
</style>

View file

@ -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')
})
})

View file

@ -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<string, Result> = {
'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')
})
})

View file

@ -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<string, LayoutPoint[]>()
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<LayoutNode>[] = 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<LayoutNode, SimulationLinkDatum<LayoutNode>>(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
}

View file

@ -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<ThemeName>('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 }
}

View file

@ -9,6 +9,7 @@ const preset: StudyPreset = {
narrative: 'n',
disclaimerShort: 'short',
disclaimerLong: 'long',
readingCaption: 'caption',
toneThresholdPct: 30,
base: {
numStudents: 120,

15
web/src/lib/accents.ts Normal file
View file

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

12
web/src/lib/mulberry32.ts Normal file
View file

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

View file

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

View file

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