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)
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue