docs: amend ui-spec to the implemented design; handoff for M4

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.
This commit is contained in:
Justin Visser 2026-06-10 21:52:25 +02:00
parent 2b47158b01
commit bbba450c6e
2 changed files with 132 additions and 15 deletions

103
docs/m4-handoff.md Normal file
View file

@ -0,0 +1,103 @@
# 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.

View file

@ -39,6 +39,8 @@ interface StudyPreset {
// reached% (same formatter as the panels) // reached% (same formatter as the panels)
disclaimerShort: string // badge text: "Illustrative model, not validated" disclaimerShort: string // badge text: "Illustrative model, not validated"
disclaimerLong: string // About popover body (2-4 sentences) 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), toneThresholdPct: number // reached% <= threshold renders "good" (teal),
// above renders "bad" (rose); 30 for this study // above renders "bad" (rose); 30 for this study
base: Config // from generated types, the default world base: Config // from generated types, the default world
@ -243,7 +245,9 @@ Every interaction lists trigger, behavior, and states.
### 5.3 Seed rerolls ### 5.3 Seed rerolls
- Trigger: dice button next to a seed field (graph seed "Friendship - Trigger: dice button next to a seed field (graph seed "Friendship
network", threshold seed "Who resists", education seed "Random picks"). network", threshold seed "Who resists", education seed "Random picks").
- Behavior: set that seed to a fresh random uint32, then as 5.2. Rerolling - 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 the graph seed invalidates the layout cache for affected panels; the new
layout fades in (200 ms opacity crossfade, skipped under reduced layout fades in (200 ms opacity crossfade, skipped under reduced
motion). A text button "Reroll world" rerolls all three seeds at once. motion). A text button "Reroll world" rerolls all three seeds at once.
@ -252,13 +256,20 @@ Every interaction lists trigger, behavior, and states.
### 5.4 Playback ### 5.4 Playback
- Trigger: autoplay once after first successful load; afterwards only via - Trigger: autoplay once after first successful load; afterwards only via
player controls. player controls.
- Behavior: rounds advance every 700 ms at 1x (speed pill cycles 0.5x, - Behavior (amended 2026-06-10 after live review): rounds advance every
1x, 2x). Within a round, nodes reached that round transition from their 1000 ms at 1x (speed pill cycles 0.5x, 1x, 2x). Within a round, each
resting state to rose: scale 0.6 to 1.0 plus fade, 250 ms ease-out. node reached that round gets its own deterministic appearance moment
Educated rings are visible from round 0 (they exist before the spread). (full-avalanche hash of the node index) spread across the first 95% of
Panels whose run ended before the global max round hold their final the interval; its unreached dot stays put until that exact frame, then
state. At the end, playback stops on the final round (no loop), play the rose dot fades and scales in (0.5 to 1.0) over 30% of the interval.
button becomes replay affordance. 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 / - Player controls: replay (jump to round 0 and play), step back, play /
pause toggle, step forward, scrubber (one tick per round, drag or click pause toggle, step forward, scrubber (one tick per round, drag or click
to seek; seeking pauses), round counter "Round 4 of 9" (global max), to seek; seeking pauses), round counter "Round 4 of 9" (global max),
@ -270,7 +281,7 @@ Every interaction lists trigger, behavior, and states.
`aria-valuetext="Round 4 of 9"`. `aria-valuetext="Round 4 of 9"`.
- Reduced motion (`prefers-reduced-motion: reduce`): no autoplay, page - Reduced motion (`prefers-reduced-motion: reduce`): no autoplay, page
loads at the final round; play still works but rounds swap discretely loads at the final round; play still works but rounds swap discretely
(no tween, no scale); the 700 ms cadence becomes 1000 ms at 1x. (no tween, no scale); the 1000 ms cadence becomes 1200 ms at 1x.
- States: round state is global and drives every panel, the chart - States: round state is global and drives every panel, the chart
playhead, and the modal simultaneously. Round is not stored in the URL. playhead, and the modal simultaneously. Round is not stored in the URL.
@ -419,12 +430,15 @@ mockup. Spacing on an 8 px-ish scale as in the mockup; page max-width
Number formatting: one shared `formatPct` (round to whole percent) used by Number formatting: one shared `formatPct` (round to whole percent) used by
hero, panels, strip, chart labels, and table. No other rounding anywhere. hero, panels, strip, chart labels, and table. No other rounding anywhere.
Network rendering: inline SVG (not canvas). Layout via d3-force Network rendering: inline SVG (not canvas), viewBox 380x230 (amended
(`forceLink`, `forceManyBody`, `forceCenter`, collision), run 2026-06-10: taller than the mockup's 190 for breathing room). Layout via
synchronously for a fixed 300 ticks with `simulation.randomSource( d3-force (`forceLink`, `forceManyBody`, collision, plus anisotropic
mulberry32(graphSeed))` and seeded initial positions, so identical configs `forceX`/`forceY` pulls so the cloud settles into the wide card shape
give identical pictures on every machine (the shared-URL guarantee). organically and the fit stays uniform-scale; axis stretching reads as
Layout results cached per graph hash. 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.
--- ---