Commit graph

34 commits

Author SHA1 Message Date
8e1964c0dd web: keep network graphs from collapsing on shorter desktops; clarify the educated 30% is a share, not the same students 2026-06-27 18:03:22 +02:00
7ad1e0f3bf web: prose spans the full width, flush with the sources table 2026-06-27 17:18:44 +02:00
6a1e08aa74 web: widen Method page and clear its table scrollbar
Two visual fixes to MethodPage.vue, from the deployed page.

Width: the content container was 820px and felt cramped. Raise it to
1080px so the page uses more of the viewport and the sources table has
room, while capping the running prose (lede, ODD blocks, section intros,
strategies, references, closing) at a 760px reading measure so line length
stays comfortable. The table and section rules span the full container.

Scrollbar: the sources table's horizontal scrollbar overlapped the last
row's text. Reserve a strip with padding-bottom: 14px inside the
overflow-x:auto container so the scrollbar sits clear of the content, and
style it thin and themed (scrollbar-width / -color, plus a webkit fallback)
in both themes. Verified on a local prod build at desktop width and 390px.
2026-06-27 17:00:04 +02:00
8a2a28864e web: Method and Sources page on the Explainer tab
Turn the Explainer placeholder into the real method + sources page the
proposal's Proof of Concept promises, so the demo's results can be examined
and reproduced rather than taken on trust.

Two parts in MethodPage.vue:
- How the model works, structured loosely on the ODD protocol for
  agent-based models (purpose; entities, state, scale; the Holme-Kim
  friendship network; the independent-cascade spread; the additive
  forwarding composite; the education lever and the three strategies;
  determinism from seeds; parameters as low/medium/high sensitivity ranges
  rather than fitted values). Cites Holme & Kim 2002 and Kempe, Kleinberg
  & Tardos 2003.
- Sources behind each parameter: a table mapping every lever, live and
  reserved, to the project team's open-access literature review (S1-S11),
  with the full reference list below it.

App.vue routes explainer to MethodPage; Findings and Guides stay
placeholders. Matches the existing visual tokens and card system, carries
the "illustrative, not validated" disclaimer, scrolls its table inside an
overflow container at 390px, and stays keyboard reachable and
reduced-motion friendly. Verified in light, dark, and at 390px against a
local prod build with the embedded frontend. No Go types changed, so the
type-drift guard stays clean.
2026-06-27 16:13:08 +02:00
318dca3423 web: UI polish pass (layout, seeds, reroll, fit)
- Graph layout: fix the reroll-vs-reload determinism bug (a reroll requested
  the layout before the new edges arrived, caching a link-less uniform circle;
  only cache once edges are present). Retune to a slightly-wide organic blob
  in a 340x270 viewBox so it fills the square-ish cells without flat ovals.
- Reroll world: moved out of Advanced into a button in the WORLD card header;
  any seed reroll (header or per-seed dice) now replays the spread from round 0
  (pendingReplay flag in the store, watched by playback). freshSeed extracted
  to lib/seed.ts.
- Seeds: separated from the real quantities under a 'Seeds' header ('labels for
  a random world, not counts') with a # prefix so a value reads as an id.
- Fit: grid rows subtract the container padding so the 2x2 fits exactly and the
  bottom card shadows are no longer clipped; the settings card absolutely fills
  the sidebar so the graph grid defines the height and the card scrolls
  internally instead of growing taller than the graphs.
2026-06-18 23:01:47 +02:00
6b2cbd5728 docs: README and About copy for the tuned additive model
The idea table now shows 83/69/18 (the soft-program default) with a 'who you
teach matters more than how many' framing; the model section describes the
forwarding composite (baseline, raised by novelty, lowered by harm awareness)
and the soft education lever instead of the old fixed-rate / never-forward
wording. Background acknowledges the team's literature sourcing of the
parameters and frames this demo as the accessible layer of the funded
instrument (calibration, full parameters, optimisation). Preset About copy
updated to match.
2026-06-18 22:24:10 +02:00
792c636354 web: center and widen the hero
The hero was a 760px block anchored left of a much wider workbench, which read
as lopsided. Center it (margin auto, text-align center) at a wider 880px.
2026-06-18 22:19:44 +02:00
c1201caf11 engine: tune the default world to a soft program
DefaultConfig now describes a more realistic world: a moderately novel fake
(novelty 0.3), some ambient harm awareness (0.2), and a strong but imperfect
education program (programEffect 0.8) so educated students mostly, not always,
refuse. The headline shifts from 99/70/7 (a perfect program) to 100/83/21 out
of 120 (83/69/18 percent): no program >> random >> most-connected still holds,
targeting still wins by ~4x, but the program is no longer a perfect wall.
Golden values re-pinned in the engine and API tests; the preset base matches.
The forward-chance formula test now neutralises its baseline so it pins the
formula, not the tuned defaults.
2026-06-18 22:19:44 +02:00
4ac7ba1624 web: single-screen redesign with the literature levers
The dashboard is now a single-screen app shell:
- app-bar page tabs (Model + placeholder Explainer/Findings/Guides) via
  lightweight hash routing (usePage); the wordmark links home
- a 2x2 graph grid that fills the viewport, rows sized to half its height so
  the four cells always show; a + Add tile is the 4th cell, a 5th scenario
  scrolls (with a reserved scrollbar gutter)
- a left settings sidebar exposing the forwarding composite as grouped levers
  (novelty, harm awareness, program strength, plus baseline forwarding and
  education budget); the card is a CSS container so the sliders stack to width
- a full-width dock (legend, full-width player, Export / Reach over time /
  Data table); the reach chart and data table open as overlays
- the illustrative note moved under the hero

ScenarioToolbar is removed (the + tile and the dock Export replace it).
Deviates from the original ui-spec, which is amended with a pointer.
2026-06-18 22:01:30 +02:00
430d59a2e8 web: regenerate types and sync for the new forwarding levers
go generate brings novelty, harmAwareness, programEffect into the
generated Config and Bounds; preset base, field labels, the clamp switch,
and the test fixtures pick them up. No UI for them yet (that is the next
slice); behaviour and the demo are unchanged. type-check, unit tests and
lint all green, so CI's type-drift guard is satisfied.
2026-06-18 20:10:27 +02:00
268575ed6c web: drive control limits from the served bounds and clamp, not 400
The store fetches {config, bounds} at startup and snaps values into
range everywhere they enter: typed fields, sliders, panel overrides,
and shared links (a ?edgesPerNode=150 link now runs at 8 instead of
showing a validation error). Shrinking numStudents pulls numEducated
and origin down with it. If the bounds fetch fails the app still
starts and the engine's validation backstops. Native min/max
attributes follow the same numbers, so spinners stop at the bounds.
2026-06-11 16:16:27 +02:00
bd890384e5 engine: realistic config bounds, enforced and served
ConfigBounds (students 10-500, edgesPerNode 1-8, forwardProb 0-0.9,
triangleProb 0-1) caps what RunScenario accepts: a student does not
keep 150 close friendships and a guaranteed forward is not a real
base rate. numEducated gains its missing upper limit (numStudents).
Bounds-side validation also protects the public server from absurd
numStudents values. GET /api/config/default now returns
{config, bounds} so the frontend can drive its controls from the
same numbers; the frontend does not call it yet, so this commit
changes no UI behaviour.
2026-06-11 15:54:07 +02:00
2b47158b01 web: kill the round-switch flash, dots swap exactly at their moment
The flash came from branch switching at the round tick: every node due
that round dropped its gray unreached dot at once while its rose dot
waited invisibly for an animation delay. The unreached/reached branch is
now gated per frame by the same appearance predicate the live counter
uses (nodeDisplayedAsReached), so each gray dot stays put until the very
frame its rose dot starts fading in. Animation delays are gone; the
fade starts on the branch flip.
2026-06-10 18:28:49 +02:00
3a4e21b701 web: hide native number spinners; slower, fully staggered rounds
Per Justin: the native number-input spinners rendered as light boxes on
the dark theme, so they are gone app-wide (typing and arrow keys keep
working). Playback pacing: rounds now take 1000 ms at 1x, appearance
moments spread across 95% of the interval via a murmur3-style
full-avalanche hash, and each dot fades over 30% of the interval, so a
round reads as a continuous trickle instead of a flash.
2026-06-10 18:26:28 +02:00
d9b3812306 web: accessibility audit fixes, slice 12 of M3
Walked the spec section 8 checklist against the running app. Two gaps
fixed: the menus now close on Tab instead of leaking focus behind the
open menu (the opener restores focus), and the hero's mobile About note
restores focus to its trigger on close.

Audit results, item by item:
- Keyboard: all controls reachable and operable; menus arrow-cycle,
  Esc/Tab close with focus restore; the only trap is the focus modal.
- Focus indicator: global 2 px ink outline with 2 px offset.
- Player keys: Space/arrows/Home/End global except text inputs; the
  scrubber is a native range with aria-valuetext "Round x of y".
- Reduced motion: no autoplay, page settles on the final round,
  discrete swaps, 1000 ms cadence, smooth scrolling and crossfades off.
- Contrast: informative text uses --ink-3 or stronger; --ink-4 appears
  only on text duplicated in the data table or decorative numerals.
- Node state is shape-encoded (disc / donut / small dot / halo ring).
- Network SVGs aria-hidden; each panel is a labelled region whose text
  carries the result; the collapsed ResultsTable is a real table with
  th scope headers behind a keyboard disclosure.
- One polite live region announces runs, pauses, scrub releases, and
  the final state; rounds are not announced during playback.
- FocusModal: dialog semantics, trap, Esc, focus restore. ErrorBanner
  and inline 400 notes use role=alert. lang=en, header/main/footer.
- Touch targets 44 px on mobile; node hit halos 24 px.
- 200% zoom at 1366 px (683 px viewport) reflows to the mobile stack
  with nothing lost (verified by screenshot).
2026-06-10 18:22:11 +02:00
5c4a92b5a0 web: seed rerolls draw from a readable range
Per Justin: a full uint32 made rerolled seeds ten digits long. Rerolls
now draw 0-9999; still thousands of distinct worlds, but the advanced
fields and shared URLs stay legible.
2026-06-10 18:22:11 +02:00
c5cbfd3eea web: export menu, slice 11 of M3
The toolbar's Export button becomes a real menu: Data (JSON) downloads
{ base, panels, results } exactly as held in the store, Per-node CSV
writes one row per panel and node (educated flag, reachedAtRound with
-1 kept), and Picture (PNG) rasterizes each panel's live SVG with the
current theme's token values resolved, drawn side by side with labels
and the live percentage at the current round, plus legend and the
disclaimer footer line, at 2x. Items disable while a run is in flight
so exports always capture settled state.
2026-06-10 18:19:32 +02:00
3cd48ca7ea web: dots trickle in randomly per round, the share counts live
Per Justin: a round's dots no longer start fading together. Every node
gets a deterministic random appearance moment inside the first 70% of
the round interval (the rest is its fade), and a new roundProgress
value, driven by the playback frame loop, feeds the same jitter into
the panel stats: the big percentage and reached count now tick up
exactly as dots start appearing, in the cards, the mobile strip, and
the focus modal. Pausing, stepping, and seeking hold the full round;
reduced motion keeps discrete swaps.
2026-06-10 18:14:35 +02:00
609ba6f0dd web: mobile pass, slice 10 of M3
The MobileScoreStrip lands: sticky under the app bar, one pill per
panel with the round-synced colored share, horizontally scrollable (the
only horizontal scroller, verified at 320 px), tapping scrolls to the
panel (instant under reduced motion, scroll-margin clears the sticky
stack).

Touch targets reach 44 px on mobile: player buttons, panel kebabs, and
toolbar buttons; Export collapses to its icon so the toolbar stays one
row down to 320 px.
2026-06-10 18:09:51 +02:00
e0ecc5c6ba web: node tooltip with cross-panel echo, slice 9 of M3
Each node carries a transparent 12 px hit halo. Hovering shows the
single tooltip instance near the pointer: student number, state in that
panel at the current round (origin / forwarded in round n / educated,
refused / not reached), and the student's friend count from the real
edges. The same student simultaneously gets a 2 px ink echo ring in
every panel: the same kid, reached in one world, safe in another.

Dismissal via pointerleave, tap elsewhere, or Esc. A touch tap on a
node shows the tooltip without opening the focus modal; a mouse click
still falls through to the zoom button.
2026-06-10 18:03:42 +02:00
8929eeddb0 web: focus modal, slice 8 of M3
Clicking a panel's network (cursor zoom-in, a real button for keyboard
users) or its menu's Focus item opens the modal: the same SVG at the
same global round rendered up to 90vw/85vh over the spec scrim, with a
second PlayerBar docked in the footer sharing the store. Only the page
PlayerBar owns the global keyboard map, so keys are never handled
twice.

role=dialog with aria-modal, labelled by the panel name, focus moves in
and is trapped, Esc/X/scrim close, and focus returns to the opener.
focusPanelId already lives in the URL, so shared links open
pre-focused; on mobile the modal becomes a full-screen sheet.
2026-06-10 17:55:36 +02:00
eb1f7427c4 web: panel management, slice 7 of M3
The kebab menu goes live: Edit and Rename open the PanelEditorPopover
(rename focuses the label in its header), Duplicate copies with a
' copy' suffix and the next accent, Remove deletes immediately and is
disabled on the last panel. Add scenario opens the new panel's editor
right away, still capped at six.

The editor offers the strategy select plus every Config field as a row
against the base value: a differing value becomes an override
(highlighted row, per-field reset, chips on the card), typing the base
value back clears it, and runs touch only that panel through the
debounce. Engine 400s appear inline in the popover with the offending
field marked.

Per-panel failures now leave a rose dot on the kebab while the panel
keeps its last good result; the store tracks failed panel ids and
clears them on the next successful run. Field and strategy labels moved
to lib/fieldLabels so controls, editor, and table share one vocabulary.
2026-06-10 17:51:21 +02:00
fdc28ac686 web: world controls, slice 6 of M3
The lower grid completes: reach chart left, World card right. Two
headline levers (chance to forward, education budget as count plus
share) move instantly and rerun through the store's debounce; the
advanced disclosure holds students, friends per student, clique
tendency, first poster, and the three named seeds, each seed with a
dice reroll plus a Reroll world button (one coalesced run).

Engine 400s render inline under the card header in rose with the
offending field marked by name match; network failures keep using the
banner with retry. A graph-seed reroll remounts the network svg keyed
by graph hash, fading the new layout in over 200 ms.
2026-06-10 17:45:29 +02:00
da1d86aba7 web: reach-over-time chart with playhead, slice 5 of M3
Hand-rolled SVG as decided: dashed percent grid, one cumulative curve
per panel in its accent color, end labels via the shared formatPct, and
a playhead line with per-curve dots synced to the global round. The
cumulative-at-round counting moved into lib/reach.ts so the panel stats
and the chart cannot disagree.
2026-06-10 17:39:21 +02:00
e9d57b2082 web: node fade-in spans the whole round, playback never sits still
Per Justin: instead of a 250 ms pop followed by a still frame, each
newly reached node now fades and scales in across the full round
interval (scaled with playback speed, staggered per node), so the
animation reads as continuous motion like the prototype gif. Reduced
motion still collapses to discrete swaps.
2026-06-10 17:36:59 +02:00
85520ca504 web: regenerate package-lock so npm ci sees optional deps
The incrementally updated lock was missing @emnapi/core and
@emnapi/runtime (optional transitive deps skipped by the local
platform), which broke npm ci on CI. Regenerated from scratch.
2026-06-10 17:35:50 +02:00
0e3a14a0b3 web: playback, slice 4 of M3
usePlayback drives one global round on a rAF timer: 700 ms per round at
1x, speed pill cycling 0.5/1/2, play-once autoplay after the first load,
no looping; at the final round the play button becomes replay. Under
prefers-reduced-motion there is no autoplay, the page settles on the
final round, and the cadence slows to 1000 ms.

The PlayerBar is the mockup's centered pill with a native range
scrubber (one tick per round, aria-valuetext, seeking pauses) and the
global keyboard map: Space toggles, arrows step, Home/End jump; text
inputs and native control handling are left alone. A single polite live
region announces run updates, pauses, scrub releases, and the final
state.

Nodes reached in the current round pop in (scale 0.6 to 1, 250 ms
ease-out) with a small deterministic stagger so each round reads as a
wave. Per Justin's feedback the panel percentage and reached count now
follow the playhead instead of sitting on the final outcome; the hero
keeps the final numbers, since its sentence claims outcomes.
2026-06-10 17:15:38 +02:00
71baf10e73 web: the dashboard page, slice 3 of M3
The milestone 2 parity page becomes the spec's dashboard: app bar with
wordmark, disclaimer badge and About popover, theme toggle (persisted,
defaults to prefers-color-scheme, applied pre-mount to avoid a flash),
hero with the preset narrative and live toned percentages, scenario
toolbar, panel cards with skeleton loading and dim-while-refreshing,
legend, and footer. ComparisonTable evolves into the collapsed
ResultsTable (test moved along, plus a formatPct rounding case).

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

Slices still to come keep their controls visibly parked: Export and the
panel kebab render disabled until slices 11 and 7.
2026-06-10 16:55:14 +02:00
2f21248c20 web: sim store, URL state scheme, debounced parallel runs (M3 slice 2)
useSimStore is the one reactive module singleton from the spec: panels
over a shared base config, runs debounced 400 ms, one POST /api/scenario
per panel in parallel, results swapped in atomically only when every
request succeeds. Failures keep the last good results; engine 400s are
routed to an inline validationError, everything else to the banner
message. Stale in-flight responses lose to newer runs per panel.

URL state follows spec section 7 (readable params, repeated panel=,
focus index, full fallback to the preset on any malformed part) and is
covered by round-trip and rejection tests. The study copy and initial
panels live in the deepfake-school preset module; formatPct is the
single percent-rounding rule.

The vitest tsconfig now keeps lib at ES2022 like the app config, and
prettier skips the tygo-generated src/types (drift guard stays green).
2026-06-10 16:36:26 +02:00
9a392b1860 api: POST /api/scenario returns result plus graph topology (M3 slice 1)
One panel's whole world in one call: effective config in, echoed
config + cascade result + undirected edge list out. Edges are
[from, to] pairs with from < to in deterministic node order;
Graph.Edges() walks the adjacency once, GraphEdges(config) rebuilds
the seeded world (~25us) so Result stays lean and /api/comparison
stays untouched. This closes the topology gap the design brief
flagged; the frontend's seeded d3-force layout consumes these pairs.
Go bits: [][2]int is a slice of fixed-size arrays; [2]int is a value
type, comparable, and JSON-marshals to [a, b], exactly the wire
shape the spec asks for.
tygo regen includes a fix: engine.Strategy now maps to the generated
Strategy type instead of decaying to 'any' in ScenarioRequest.
Verified live through the dev stack: 7/120 reached, 351 edges.
2026-06-10 15:48:48 +02:00
c935071bd1 web: parity page renders the three-scenario comparison from live data
App.vue fetches the default config, posts it to /api/comparison, and
renders ComparisonTable; the Vite dev server proxies /api to the Go
server so the browser sees one origin. src/lib/api.ts is the only
fetch code and uses exclusively generated types: no shape is defined
on the frontend. Scaffold example components removed.
Engine fix surfaced by the smoke test: a nil Go slice marshals to
JSON null, violating the generated 'educated: number[]' contract;
RunScenario now returns an empty slice instead.
Verified end to end: curl through the Vite proxy returns the golden
99/70/7. Vitest covers the table rendering; type-check, oxlint,
eslint clean. This completes the milestone 2 parity check.
2026-06-10 13:47:01 +02:00
e9f36f509e api: JSON API over the engine; server mode in cmd/spreadlab
internal/api stays a translation layer: decode (with
DisallowUnknownFields so typos 400 instead of silently defaulting),
run the engine, encode; all validation stays in the engine. Routes use
Go 1.22 mux patterns ('POST /api/comparison'), so wrong methods get
405 from the stdlib for free. httptest runs handlers fully in memory;
the API test re-pins the 99/70/7 golden values end to end.
ComparisonResponse is added to tygo.yaml with a type mapping so the
generated web/src/types/api.ts reuses the engine's TS types.
cmd/spreadlab now serves on -addr (default localhost:8080); -table
keeps the CLI comparison as a sanity check.
2026-06-10 13:03:12 +02:00
9bae4a9ca0 types: generate TypeScript from engine structs with tygo
tygo is pinned via go.mod's tool directive (Go 1.24+): the tool's
version is locked like any dependency and runs as 'go tool tygo', no
global install. 'go generate ./...' regenerates web/src/types/engine.ts
from Config/Result and their json tags; the generated file is committed
so the frontend builds without Go installed. The go:generate directive
anchors in a root generate.go because generate runs commands from the
declaring file's directory and tygo reads tygo.yaml from the cwd.
go.mod's ignore directive (Go 1.25+) keeps ./... from crawling into
web/node_modules, where some npm packages ship stray .go files.
2026-06-10 12:58:13 +02:00
f58f33c3fd web: scaffold Vue 3 + Vite app (create-vue, lean options)
Verbatim output of 'npm create vue@latest web -- --ts --vitest
--eslint --prettier' so this commit shows exactly what the official
scaffold generates; our own changes come separately. Lean per the
agreed choices: no Router/Pinia until a second page or shared state
exists. node_modules is ignored via the scaffold's own web/.gitignore.
2026-06-10 12:55:43 +02:00