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