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.
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.
- 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.
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.
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.
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.
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.
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.
Novelty, HarmAwareness and ProgramEffect each get a 0..1 realism bound,
served in ConfigBounds and enforced in ValidateConfig with field-named
errors (the frontend matches on the JSON name to flag the right control).
Reject/boundary test cases added.
Forwarding was a single global ForwardProb; make it a per-student
composite (Config.ForwardChance): baseline propensity raised by the
fake's Novelty, lowered by ambient HarmAwareness, and scaled down for an
educated student by ProgramEffect (1 = today's hard block). RunCascade
now takes a precomputed per-student []float64 chance and has no education
special case. Defaults are behaviour-neutral, so the 82/58/6 golden
tests are unchanged; the model is tuned in a later slice.
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.
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.
The README deploy section documents the GHCR image, the compose stack
that joins Caddy's external network, and that the Caddyfile entry is
managed by hand on the server. distroless has no shell, so the
healthcheck is the binary itself: a new -check flag probes /healthz
and exits 0 or 1, and the Dockerfile bakes it in as HEALTHCHECK.
Verified locally: the container reports healthy.
Tags :latest and the commit SHA, authenticated with the built-in
GITHUB_TOKEN via the packages:write permission. Making the package
public is a one-time manual step in the package settings after the
first push.
Three stages: npm build, Go build with the real dist copied over the
placeholder before compiling, distroless/static (nonroot) runtime.
The container listens on :8080 (all interfaces, unlike the localhost
dev default). Verified locally: 16.6 MB image, /, /healthz and /api
all answer. Dev loop unchanged, Docker stays out of it.
//go:embed needs internal/webdist/dist to exist at compile time, so a
one-line placeholder index.html is committed there and the real build
(Docker, or a manual copy of web/dist) overwrites it. One mux serves
/, /api, and a /healthz for the compose healthcheck to come.
The spec stays normative: 5.4 now describes the slower trickle playback
(1000 ms rounds, per-node appearance moments, live counting) that Justin
converged on during implementation, 5.3 the readable seed range, section
6 the taller viewBox and anisotropic layout forces, and StudyPreset its
readingCaption field.
The M4 handoff covers the single-binary embed (with the go:embed vs
gitignored-dist decision called out), Dockerfile, GHCR workflow, and the
Portainer/Caddy stack, plus the traps M3 paid for: lockfile
regeneration, the prettier ignore on generated types, screenshot
helpers, and reviewing perceptual work live.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
writeJSON's last-resort branch (json.Marshal failing, a programmer
error: unsupported type or cyclic data) previously fell back to
http.Error, which answers text/plain and was the one corner where the
API broke its own {"error": ...} contract. It now writes a
hand-written constant JSON literal: still strictly simpler than the
encoder that just failed (the reason writeError, which is built ON
writeJSON, cannot be used there: it would be circular), but contract
consistent. Tested by forcing the branch with a channel payload,
which json.Marshal cannot encode.
Self-contained brief for the frontend implementation session: current
state (slice 1 done, /api/scenario live, parity page to be replaced),
the normative pointers (ui-spec.md, approved mockup), method (drive
Vue fast, the design bar and how to self-check against it with
headless screenshots, tmux dev stack, per-slice commits, a11y as you
go), the four remaining micro-choices to batch into one question
round, and first steps.
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.
Before starting, each port (8080, 5173) is checked via ss; an existing
listener is reported with PID and command, and an interactive run asks
permission to kill it (default yes). Non-interactive runs abort
instead: never kill processes silently. Born from a real incident: an
orphaned smoke-test server held 8080 and dev.sh only said
'address already in use'.
The design session runs in the VS Code extension from the course
workspace, one level above this repo; spell every referenced path from
there so nothing depends on the session's working directory.
Self-contained setup for a dedicated design session: product goal and
audience, the inherited visual language from the Python prototype,
what exists today (including the API's missing graph-topology
endpoint, a known gap), the locked decisions the spec must respect,
the questions it must answer (accessibility criteria among them, still
undefined), and the required shape of the deliverable, docs/ui-spec.md.
Design before build; the implementation session executes the spec.
README rewritten for a public audience: what the tool is and is not
(the not-validated disclaimer up front), the model with literature
references (Holme & Kim 2002; Kempe, Kleinberg & Tardos 2003), honest
status checklist, quick start, layout, the generated-types rule, and
the prevention-only framing of the subject.
CI mirrors the local checks (go test, gofmt, golangci-lint, vue
type-check/lint/test/build) and adds a drift guard: go generate must
leave web/src/types/ unchanged, so the Go structs stay the single
source of truth in fact, not just in principle.
Starts the Go API and the Vite dev server as background jobs; 'wait -n'
returns when the first one exits and the EXIT trap kills the rest, so
one Ctrl-C (or either server crashing) stops everything. Installs
web/node_modules on first run. Verified: both ports respond, SIGINT
leaves no orphan processes.
Two-terminal dev loop (go run + vite proxy), the full check matrix,
and the regenerate-types rule: web/src/types/ is generated from the
Go structs and must never be edited by hand.
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.