Landing page at / with animated concept GIFs; owners list to /explore #187
Labels
No labels
actions
bug
cli
duplicate
enhancement
fork
forum
git storage
help wanted
insights
invalid
issues
moderation
oidc
ownership transfer
packages
pr/merge protection rules
projects
pull requests
question
releases
sponsorships
tags
webhooks
wiki
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crueber/walhub#187
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Landing page:
/becomes marketing, owners list moves to/explore, animated concept GIFsRoutes
/stops being the owners list. It becomes a marketing/landing page: what walhub is, how it works at a superficially technical level. This page draws people in — design bar is high./explore(for now). All existing owners-page behavior (caps, order, star counts, activity stamps, intro card — the intro card should probably slim down or move since/now carries the message; decide in the change) keeps working at the new path. Old/links (nav "owners" entry?) point at/explore.Animated concept GIFs (procedurally generated, in-repo)
image/gif(zero new deps) plus embedded font/raster text handling decided in planning; GIFs served as static assets (or generated at build — decide).Acceptance criteria
/marketing page (copy + GIFs + CTAs),/exploreowners list unchanged in behavior.alttext describing each animation;prefers-reduced-motionrespected — static first frame fallback).node --testgreen; browser pass both themes, zero console errors; no new npm deps; Go gates if a generator is added.Landing page plan — Forgejo issue crueber/walhub#187
Scope:
/becomes a marketing/landing page with 3–4 procedurally generatedanimated concept GIFs; the owners list moves to
/explorewith behaviorunchanged. Planning only — no production code written.
Laws binding this change (AGENTS.md): dependency budget (no new Go modules,
no new npm runtime deps — generator is stdlib-only, page uses existing
solid-js/@solidjs/router/Tailwind), bucket-is-repo (landing page adds zerostore round trips; it makes zero API calls), seams (no new backend routes
beyond the SPA shell + static asset entries; no new API endpoints, no new SDK
methods), coverage (generator lives in
internal/devtools/, whichmake coveralready excludes — unit tests still required, golden determinism test included),
docs-with-code (06 + 12 updated in the same change).
Canonical language reused verbatim or near-verbatim (README.md L3–5, Owners
intro card, footer): "git over smart HTTP (v0/v2), LFS, bundle-uri, a JSON
API with SSE, and a web UI — where the object store is the only database";
"every repository's refs, packs, config, and policy live as objects in a bucket
(filesystem, S3, or GCS)"; "Instances are disposable; wipe one and you lose
nothing but warmth"; "the bucket is the repository" (footer); walgit
object-protocol compliance note (README "Inspired by walgit").
Text-rendering risk — RESOLVED in planning by spike (see §3.5): two
/tmp-only programs proved stdlibimage/gif+ a hand-authored 5x7 bitmapfont at 2x scale on 640×360 is legible and tiny (3 frames = 6.3 KB animated,
2.2 KB static still). Nothing from the spike was written to the repo.
1. Route changes
1.1 Frontend (
web/src/index.jsx)/OwnersLanding(newweb/src/pages/Landing.jsx)/explore/:owner)Owners(moved, component unchanged)*fallbackOwnersLanding(safe default: no API calls, no confusing empty state for unmatched deep paths)Owners.jsxheader comment (route "/") updated toroute "/explore". Nologic change inside
Owners.jsx,lib/owners.js, or any data path.1.2 Backend (
internal/server/router.go,health.go)r.Get("/explore", s.gated(s.explorePage))next to the existingr.Get("/", ...)/r.Get("/setup", ...)lines.explorePage=serveSPA(same as
ownerPage). Must be explicit: otherwise/explorefalls intothe
/*wildcard →repoDispatch→ treated as owner"explore".spaHome(GET /) keeps serving the shell for browsers. Its?format=text/text-Accept branch (plain one-per-line owner list, documentedin
docs/go/06_server_http.mdL122/L212) is kept unchanged at/— itis machine surface, not the marketing page. Extract the branch into a shared
helper so
/exploreanswers it too (scripts can move; old scripts keepworking at
/).mountSetupOnly): no/exploreregistration — it 503slike every non-setup route. Nothing to do.
uiAsset(health.go~L346) with aconcepts/prefixmapping to
dist/concepts/, and addimage/gifMIME (hasSuffixFold(name, ".gif")→image/gif). Caching class: no-cache + ETag (same asindex.html), NOT immutable — filenames are stable (push.gif,push-still.gif, …) and content changes on regeneration, soimmutablewould be wrong.
serveUIAssetsalready ETags everything; only the allowlist1.3 Nav (
web/src/App.jsx)Nav entry
owners → /becomesexplore → /explore(label "explore"). Brandlink stays
/. Footer unchanged ("walhub — the bucket is the repository").1.4 Name collision: owner literally named
explorePrecedent already exists:
/import,/api,/keys,/setupall shadow thoseowner names via explicit routes.
/explorejoins that set — an owner namedexploreloses its/:ownerUI page (git/API paths for that owner areunaffected; only the single-segment UI page is shadowed). Document the
reservation in
docs/go/06_server_http.md§3.3 alongside the existing list.No code beyond the explicit route.
1.5 Old anchors / redirects
None needed: the owners page has no
#iddeep-link scheme (unlike Settings),and in-app links are updated in the same change. External bookmarks to
/land on the landing page, which carries a prominent "Browse repositories →
/explore" CTA — that is the migration path, no server redirect table.
Concurrency
No new concurrency. The generator is a single-threaded CLI; the landing page
makes zero API calls (fully static marketing + static GIFs), so no new fetch,
SSE, or invalidation paths. (
AGENTS.mdlaw 3: nothing concurrent, nothingto lock.)
2. Landing page structure (
web/src/pages/Landing.jsx)Static page, zero API calls, zero Solid data hooks. Works in both themes (dark
default). New component
web/src/components/ConceptGif.jsx(see §4) used4×. New
node --testfile pins CTA hrefs + alt texts (source pins, no DOM).2.1 Hero
README/intro-card language, tightened to one line.)
and policy live as objects in a bucket — filesystem, S3, or GCS. Instances
are disposable; wipe one and you lose nothing but warmth."
.btn.primary/.btn):/explore#quickstart(same-page push snippet)/setupbucket layout, protobuf wire encoding, and git wire behavior follow walgit's
formats." (README "Inspired by walgit" compressed.)
2.2 Concept sections (alternating layout, each: GIF + caption + 2–3 sentences)
push.gif) — "Push over smart HTTP; objects land in the bucket."Copy: smart HTTP v0/v2, auto-create on push, manifest CAS is the only commit
point, never ACKs before the bucket ACKs (law 4, superficial).
bucket.gif) — "No SQL, no Redis. Kill theinstance; the bucket doesn't notice." Copy: refs/packs/config/policy as
objects; new instance serves immediately; disk and memory are caches.
fetch.gif) — "Clone reads objects back." Copy: warmrefs in one round trip, stock-git fetch path, bundle-uri for large hosts.
collab.gif) — "Issues, PRs, reviews, andchecks live next to git data." Copy:
docs/features/README.mdP1 family(
meta/,issues/,checks/,releases/, shared issue/PR numbering)with the one architectural law stated plainly: the WAL stays git-only;
collaboration is a parallel object family that never gates a push.
2.3 Quickstart strip (
#quickstart)The README push block verbatim (zero-config
./walhub,git push -u origin main, browse at/you/demo), rendered as a code block. Plus the auth-modesone-liner: "
nonefor dev,tokenfor static bearers,oidcfor realusers — then
/setup." Plus SSH one-liner (port 2222,/keys).2.4 Owners page at
/explore— what changesBehavior preserved exactly: caps (
MAX_OWNERS50,MAX_REPOS_PER_OWNER10),newest-first ordering, star counts, activity stamps,
+N more →overflow,import button, empty states. Only change: the intro card slims to one line
since
/now carries the message:(one sentence + link; the full paragraph moves to the landing page.)
3. GIF generator design (the hard part)
3.1 Exact technique (stdlib only — zero new deps, both ecosystems)
image,image/color,image/gif,image/png(stills) only. No
golang.org/x/image(not in the Go budget — ruling C-1spirit, AGENTS.md law 1). No npm involvement — GIFs are opaque bytes to vite.
all drawn by hand loops (spike-proved, ~30 lines).
printable ASCII (32–126), stored as Go source (~95 glyphs × 7 rows of
5-bit strings, ≈ 2–3 KB), rendered at 2× scale (10×14 px per glyph).
golang.org/x/image/font/basicfont: x/image isoutside the dependency budget and would need a written amendment; a
5x7 font table is trivially small to own and has zero license surface
(shapes typed from scratch, verified by rendering).
640×360 is comfortably readable (verified by viewing decoded frames).
#09090b, fgzinc-200
#e4e4e7, accent emerald-500#10b981, in-flight amber#f59e0b,dim zinc-500
#71717a. GIFs ship in dark-framed rounded cards in BOTHthemes (README screenshot precedent) — one asset set, no light variants.
layout shift;
width/heightattributes set).encode path, fixed frame order; golden test regenerates into a buffer and
byte-compares against checked-in files (
bytes.Equal, fail on any drift —regeneration is then always intentional + reviewed).
<name>-still.gif(frame 0) for thereduced-motion fallback (§4).
3.2 Storyboards with timings (acceptance: slow enough to follow)
Timing convention:
Delayunits are 100ths of a second. Key frames hold180–250 (1.8–2.5 s); motion steps 40–60 (0.4–0.6 s); total loop 6–9 s; ≤ 3
moving elements per frame; every frame carries a numbered caption
("1. YOU PUSH." …) baked at bottom-center + mirrored in adjacent text.
GIF 1 —
push.gif: push → objects land in the bucket (7 frames, ≈ 8 s)GIF 2 —
bucket.gif: the bucket is the only database (6 frames, ≈ 8 s)GIF 3 —
fetch.gif: fetch/clone reads objects back (6 frames, ≈ 7 s)GIF 4 —
collab.gif: collaboration as objects alongside git data (7 frames, ≈ 9 s)3.3 Target file sizes (measured basis, not estimates)
Spike measurements (640×360, 2× type, 5-color palette): 3-frame animated =
6,343 bytes; single-frame still = 2,187 bytes. Per-frame marginal cost
≈ 1.5–2 KB (flat-color LZW). Budgets:
push.gifbucket.giffetch.gifcollab.gifFor scale: the shipped JS bundle delta for D-WEB-7 alone was +22.2 KB gzip —
all four GIFs + stills cost roughly 3× that, one time, lazy-loaded below the
fold (
loading="lazy"on all concept images). Sizes recorded in EVIDENCE.md(§5).
3.4 Make target + checked-in artifacts vs build-time generation
Decision: generator checked in + artifacts checked in +
maketarget toregenerate. No build-time generation.
internal/devtools/landinggif/—main.go(scene definitions),font.go(full ASCII 5x7 table),draw.go(boxes/arrows/text/blits),main_test.go(font coverage: every caption char used by every scene has aglyph — fail otherwise; determinism golden: encode-to-buffer twice →
identical; asset freshness: regenerate → byte-equal to checked-in files).
Placement in
internal/devtools/is deliberate:make coveralreadyexcludes
devtools(ande2e) from the ≥ 95% gate, so the pixel-pushingcode doesn't distort package coverage; it still gets real unit tests.
web/public/concepts/{push,bucket,fetch,collab}.gif+{...}-still.gif— 8 files checked in.web/public/is new (vitecopies
public/→dist/verbatim, survivingemptyOutDir: true; theSDK bundle step runs after and is unaffected).
make landing-gifs→go run ./internal/devtools/landinggif -out web/public/concepts/;make webgains it as a prerequisite (order: gifsfirst, then
pnpm build, so vite copies them intodist/).go:embed all:distpicks them up with no embed changes;make clean(rm -rf web/dist) stays safe because the sources of truth live inweb/public/.review, makes
make webdepend on Go, complicates the Docker web stage;checked-in bytes are diffable and the golden test makes staleness loud.
3.5 Spike results (the resolved risk, evidence)
/tmp/opencode/gifspike/main.go(stdlibimage/gifonly, 30-glyph 5x7subset, boxes + arrow + captions, 480×270): 3 frames = 4,111 bytes,
still = 1,387 bytes.
legible at both body and caption sizes.
3("3. BUCKET ACKS. DONE." rendered as ". BUCKET ACKS. DONE."). Hence thefull-ASCII requirement + the font-coverage test that fails the build on any
missing glyph. Never a silent space.
4. Accessibility
ConceptGif.jsxcall sites, pinned by test):bucket writes them, then acknowledges. The client only finishes after the
bucket's acknowledgement."
a server instance above it disappears and a fresh instance connects to the
same bucket with nothing lost."
data, from the bucket until it holds a working copy."
links to it, a green check result stamps the PR — while the write-ahead
log stays git-only."
ConceptGifrenders the-stillimage by default(also the
<noscript>output); JS swaps in the animated.gifonly whenmatchMedia("(prefers-reduced-motion: reduce)").matches === false. Listensfor change events. No autoplay for users who asked for none.
point — no information is GIF-only. Focus/contrast: captions are real text
(not images); keyboard: nothing interactive inside figures (
role="img"aria-labelon the figure, or plainimg alt)..mutedtokens (already contrast-checked in both themes).
5. EVIDENCE / perf
the "measured, not modeled" standard with the generator as harness):
per-file byte sizes, total GIF weight, landing page weight (shell HTML +
hashed JS/CSS + 4 GIFs lazy + 4 stills),
make landing-gifswall time,backend named (filesystem store irrelevant — static assets; note embed).
/explorekeeps the documented1 + MAX_OWNERSGET shape (owners.listrepos(owner)per shown owner) — unchanged code, no new budget testneeded, existing
owners.test.jsuntouched.package.jsonruntime stays exactly the four amendedpackages); no new Go modules (
go.moduntouched — generator is stdlib).make vet/make buildunaffected beyond the newuiAssetprefix + MIMElines (covered by existing
x_health_test.go-class table tests, extendedwith a gif row).
6. Acceptance criteria
/renders the landing page (hero + 4 concept sections + quickstart),/explorerenders the owners list with behavior unchanged (caps, order,stars, activity, slimmed intro card).
/explore; brand still/;*fallback renderslanding;
?format=textstill answers the owner list at/(and nowalso at
/explore).description; key-frame holds ≥ 1.8 s, motion steps ≥ 0.4 s, ≤ 3 moving
elements per frame, numbered captions on every frame.
make landing-gifsoutput byte-identical to checked-infiles (golden test); full-ASCII font + coverage test (the missing-
3class can never recur silently).
prefers-reduced-motionswap,
<noscript>still, adjacent text captions.node --test web/test/unit/*.test.jsgreen (incl. new landing +ConceptGif source-pin tests);
make vet,make test-go,make covergreen; no new npm/Go deps (
git diff package.json go.modempty).canonical host per AGENTS.md field lesson):
/+/explorein darkAND light, zero console errors; GIFs animate, stills show under
emulated reduced-motion; screenshots attached to the PR.
docs/go/06_server_http.md(§3 routetree +
?format=text+explorename reservation),docs/go/12_*frontend doc (route table row,
web/public/concepts/pipeline,make landing-gifs), EVIDENCE.md asset entry. No DEVIATIONS amendment needed(no budget change). Commit message names the sections.
7. Open questions / risks
5x7 at 2× is legible and tiny. Residual risk is aesthetic, not technical:
bitmap type reads deliberately lo-fi/diagrammatic. Mitigation: strict
palette discipline, generous whitespace, numbered captions; the design bar
comes from composition, not font fidelity. If reviewers judge it cheap,
the fallback is NOT a library (budget) but larger 3× display type for
headlines inside frames — same technique, no new decision.
exploreloses its/:ownerUI page (§1.4). Precedent(
import,api,keys,setup) makes this acceptable; documented, notcoded around. Alternative (content-negotiate the single segment) rejected:
wire-contract smell for one name.
?format=textat/— keep or move? Plan keeps it at/(compat) andmirrors at
/explore. If maintainers prefer a clean break, the alternativeis 308
/ → /explore?format=textfor text-Accept requests — one line, butit changes documented behavior; keep is the conservative default.
both themes (screenshot precedent). Risk: a light-theme purist objects.
Alternative (dual light/dark GIF sets) doubles weight and generator scenes
for zero information gain — not recommended.
non-stdlib encoders (out of budget) or browser-side SMIL (not a GIF).
GIF it is — LZW on flat colors is near-optimal for this content anyway
(~2 KB/frame measured).
LoopCount: 0(infinite) is standard forconcept loops; reduced-motion users never see it (§4). A finite 3-loop
alternative strands sighted users mid-explanation on long dwell — not
recommended.
store, no WAL touch). Any urge to make the landing page "live" (repo
counts, activity feeds) is out of scope — it would spend store round trips
on the front door and violate the static-page decision.
/exploreis thelive page.
Review: Landing page plan (issue #187)
Verdict: proceed-with-fixes — 3 BLOCKING items (all small, all must be resolved in the implementation change, none requires re-planning). Design is sound, law analysis is honest, spike de-risked the hard part.
What I verified against code (all plan claims hold unless noted)
/explorebackend route: NEEDED — but the stated reason is slightly off. Without it,GET /explorehitsr.HandleFunc("/*", s.repoDispatch)(router.go L127),len(segs)<2→s.gated(s.ownerPage)→ still serves the SPA shell (200). So the shell would load either way; the explicit route is needed to (a) serve the?format=textbranch at/explore(that branch lives only inspaHome, health.go L148-166 —ownerPageL169 never checks it), and (b) document the name reservation. Fix the justification; the route itself is correct, and chi static-beats-wildcard ordering is safe placed next tor.Get("/").?format=textlocation cited correctly (doc 06 L122 route tree + L212 gated list; code health.go L148-166). Extract-to-shared-helper plan is right.uiAssetallowlist + MIME: correct. Onlyindex.html+assets/*pass (health.go L346-352);.gifwould fall toapplication/octet-stream(L285). Both planned lines are needed. Caching classno-cache + ETag(not immutable) is the right call for stable filenames.go:embed all:dist(web/embed.go L12) picks updist/concepts/with zero embed changes. Confirmed.public/handling: correct. vite.config.mjs hasemptyOutDir: trueand defaultpublicDir; vite emptiesdist/at start ofbuild:ui, then copiespublic/→dist/, thenbuild:sdkappendsdist/repos.js. Order is safe..gitignoreignoresweb/dist/*but NOTweb/public/— checked-inweb/public/concepts/*.gifsurvivemake clean. Correct.COPY web/ ./carriespublic/;pnpm run buildcopies it). Correct.make coverexcludes devtools: correct (Makefile L36:grep -v devtools).internal/devtools/landinggifkeeps the 95% gate clean while its tests still run undergo test ./.... Correct.MAX_OWNERS50 /MAX_REPOS_PER_OWNER10 (lib/owners.js), newest-first proxy,+N moreoverflow, import button, stars, activity stamps (commits?n=1 source), two-columnRepoRowgrid. All present in Owners.jsx.meta/,issues/,checks/,releases/, shared numbering). Correct.*→ Owners today (index.jsx L83), navowners → /(App.jsx L52), footer text (App.jsx L81), Owners header commentroute "/"(Owners.jsx L1). All citations check out.internal/server-only backend diff.BLOCKING
B1 — Pin the served GIF URL prefix:
/_ui/concepts/<name>.gif. The plan says uiAsset mapsconcepts/→dist/concepts/but never states whatsrcConceptGif renders. Withbase: "/_ui/", vite copiespublic/concepts/push.gif→dist/concepts/push.gif, served at/_ui/concepts/push.gif. Asrc="/concepts/push.gif"implementation 404s (no such backend route; repoDispatch would try ownerconcepts). The change must pinsrc="/_ui/concepts/..."(and the test must assert the/_ui/prefix).B2 —
compressmiddleware will gzip the GIFs; bypassimage/*. Doc 12 section 3 claims gzip applies to text assets, but the CODE compresses everything except SSE/precompressed (compressWriter.WriteHeader, middleware.go L596-607 — no content-type gate). Since/_uiis wrapped ins.compress(router.go L112-114), every GIF response would pay a pointless gzip pass (LZW bytes; CPU per request, Vary/Content-Length churn) unless bypassed. Addimage/to the bypass condition and note the pre-existing doc/code drift in the same change. One-line fix, must not be forgotten.B3 — Do NOT make
make webdepend onmake landing-gifs. The plan (section 3.4) wires gif regeneration as a prerequisite ofmake web, which directly contradicts its own No build-time generation decision two paragraphs up — andmake vet,test-go, andraceALL depend onweb, so every contributor build would pay a Go-run regen step (and risk dirty-tree diffs if the generator ever drifts). Keeplanding-gifsa MANUAL target run only when scenes change; vite copies the checked-inpublic/files on every normal build, and the golden freshness test (regenerate → byte-equal) already makes staleness loud in CI. Decouple.Should-fix
S1 —
?format=textat BOTH/and/exploreviolates the pre-1.0 no-alias law (AGENTS.md: no aliases, shims, deprecated flags, or still accepted for branches). The text list is the machine twin of the owners page, which is moving to/explore— its canonical home is/explore. Either move it or record a written exception in 06 Decisions. Keeping both silently is the one place the plan bends a law without saying so. Section 7.3 already frames the question — make the call before code lands.S2 — Verify
/:owner/:name/<badsub>still renders the repo shell, not Landing. Server-side, most unmatched deep paths 404 as plain text via repoDispatch (never reach the SPA), so the frontend*→ Landing fallback fires far less often than the plan implies. The real risk is Solid nested-router behavior for/o/r/<unknown>: parentRepomatches with no child — confirm it renders the shell with empty outlet (current behavior) rather than falling through to*. Add to acceptance; Landing-as-fallback is otherwise safe (zero API calls — agreed).S3 — Collab GIF is the tightest scene: review it first. 7 frames with the most distinct elements (GIT lane + collab lane + issue/PR/check cards + WAL badge) against a 40KB budget and the 3-moving-elements rule. If any scene breaks the followability budget, it is this one. Suggest implementing/reviewing
collab.gifframes first; fallback is splitting into two GIFs, not enlarging.Nits
role="img" + aria-labelon<figure>ANDalton<img>double-announces. Plan says or — enforce exactly one in code review (prefer plainimg alt; drop the figure role)./exploreself-link loop for an owner literally namedexplore. Its section link points at/explore, which now renders the list, not its repos. Harmless, but name it in the 06 reservation note alongside the shadowing.decoding="async"on concept images alongsideloading="lazy"+ explicitwidth/height(already planned).Scope judgment
Nothing missing that matters; nothing to cut except process weight. The open questions (section 7) are all resolved in the recommended direction except 7.3 (S1 above). EVIDENCE.md asset entry, 06 section 3.3 reservation doc, 12 route-table row,
x_health_test-style gif row, and source-pinnode --testfor CTAs/alts are all proportionate. The?format=text308 alternative should stay rejected (it changes documented behavior for zero benefit while S1 is pending).Plan revision R1 (review findings — R1 wins on conflict)
Blocking (normative)
/_ui/concepts/<name>.gif. ConceptGif renderssrc="/_ui/concepts/…", never/concepts/…; tests assert the prefix.image/*in the compress middleware (it currently gzips everything except SSE/precompressed). One-line fix + note the pre-existing doc/code drift in the same change.make landing-gifsstays MANUAL (run only when scenes change); NOT a prerequisite ofmake web. Freshness enforced by the golden byte-equality test in CI.Should-fix adoptions
?format=textMOVES to/explore(canonical home of the owners list; the machine twin follows the page)./keeps no text branch. No 308, no silent alias (pre-1.0 no-alias law); record the move in 06 §Decisions./o/r/<badsub>behavior (repo shell with empty outlet, not Landing); add to acceptance.collab.gifframes first (tightest scene); fallback is splitting, not enlarging.Nits adopted
N1 exactly one of
img alt(preferred) vs figure role; N2 name theexplore-owner self-link loop in the reservation note; N3 quote README exactly (full set incl. events + web UI); N4 no step below 40 units; N5 glyphs drawn from scratch (reviewer eyeball); N6decoding="async"+ lazy + explicit dimensions.Implemented by PR #188 (branch feat/issue-187): #188 — landing page at / with 4 animated concept GIFs (82,618 bytes total, all budgets ~45% headroom), owners list moved to /explore with ?format=text, compress bypasses image/*, make landing-gifs manual. All gates green: gofmt/vet, go test -race (server), make cover (48 pkgs ≥95%), node --test 386 pass, real-Chromium 4/4 clean with zero console errors (screenshots verified locally — attachment upload is not available from this session, happy to attach on request). Review notes: R1 adopted throughout (B1/B2/B3/S1/S2/S3/N1–N6); one nit-level call — bucket.gif ships 7 frames (inside the 6–7 budget row). Do NOT merge from my side.
Review: PR #188 vs issue #187 plan + R1 (verified in scratch worktree at origin/feat/issue-187, web built, dist/concepts/ confirmed copied by vite).
VERDICT: ready to merge. One factual nit found and fixed by me (pushed
a0dc425to feat/issue-187): loop-time annotations understated the delay sums. No delay/byte changes — GIFs untouched.R1 compliance, point by point (all hold):
Route correctness: * -> Landing is safe (zero API calls, test-pinned: no useData/SDK/fetch/EventSource); /explore explicit next to / with the shadowing rationale in situ; setup-only 503s /explore (mountSetupOnly registers no /explore route -> 503 NotFound). Owners.jsx diff is header comment + slimmed intro only; caps/order/stars/activity/overflow logic untouched.
GIF delays decoded by me (GCE walk, stdlib python): push [200 50 50 60 180 180 200]=9.2s; bucket [200 60 200 60 50 50 220]=8.4s; fetch [200 60 150 50 200 200]=8.6s; collab [200 60 150 150 150 200 200]=11.1s. Frame counts match storyboard; every GIF has holds >=180; min step 50 >= 40 floor. Corrected totals (my commit): EVIDENCE E12 said 8.0/7.1/9.1 for push/fetch/collab; scene comments said ~8/~7/~9s. The delays were always right; only the derived totals were off. PR description timing line still carries the old totals — worth editing to 9.2/8.4/8.6/11.1.
Budgets (file bytes): push 16,773 / bucket 17,793 / fetch 15,874 / collab 22,912 / stills 2227+2569+2493+1977 / total 82,618 — all inside ceilings. Determinism genuine: TestDeterministic (re-encode byte-equal) + TestFreshness (regen byte-equal to checked-in files) both pass. Generator imports are stdlib-only (bytes/flag/fmt/image(+color/gif)/os/path/filepath/time); git diff on go.mod/go.sum/package.json/pnpm-lock.yaml is empty.
Tests (scratch, after my fix): go test -race ./internal/server/ ok; go test ./internal/devtools/landinggif/ 6/6 pass; node --test web/test/unit/*.test.js 386 pass 0 fail; gofmt clean; go vet clean; server coverage 95.6% (>=95% gate). No browser from my side per review instructions (node+go+reasoning); author reports Chromium 4/4 clean — build output matches EVIDENCE sizes (JS 430.37kB/CSS 71.79kB), so the weighed page is as documented.
MERGE RECOMMENDATION: ready to merge (do not merge from review side per author note).
Implemented in PR #188 (review: all 12 R1 rulings verified + GIF timings independently decoded; 386/386 + 95.6%), merged. Closing.