From bbba450c6eea59f64bc4db9b9b4f1b13a49548fd Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Wed, 10 Jun 2026 21:52:25 +0200 Subject: [PATCH] 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. --- docs/m4-handoff.md | 103 +++++++++++++++++++++++++++++++++++++++++++++ docs/ui-spec.md | 44 ++++++++++++------- 2 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 docs/m4-handoff.md diff --git a/docs/m4-handoff.md b/docs/m4-handoff.md new file mode 100644 index 0000000..4179dfe --- /dev/null +++ b/docs/m4-handoff.md @@ -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. diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 055df3a..6fef963 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -39,6 +39,8 @@ interface StudyPreset { // 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 @@ -243,7 +245,9 @@ Every interaction lists trigger, behavior, and states. ### 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 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 layout fades in (200 ms opacity crossfade, skipped under reduced 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 - Trigger: autoplay once after first successful load; afterwards only via player controls. -- Behavior: rounds advance every 700 ms at 1x (speed pill cycles 0.5x, - 1x, 2x). Within a round, nodes reached that round transition from their - resting state to rose: scale 0.6 to 1.0 plus fade, 250 ms ease-out. - 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. +- 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), @@ -270,7 +281,7 @@ Every interaction lists trigger, behavior, and states. `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 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 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 hero, panels, strip, chart labels, and table. No other rounding anywhere. -Network rendering: inline SVG (not canvas). Layout via d3-force -(`forceLink`, `forceManyBody`, `forceCenter`, collision), 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. +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. ---