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:
parent
2b47158b01
commit
bbba450c6e
2 changed files with 132 additions and 15 deletions
103
docs/m4-handoff.md
Normal file
103
docs/m4-handoff.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue