Repository navigation
Description
Split out of #535 (slice T7) by owner decision at its design signoff. #535 namespaces cluckwork.lastFlockId per account and adds the farm-code cache; the farm brand was deliberately left alone, because the pre-paint constraint makes it a different problem with a real regression risk.
The shipped code already CLAIMS this isolation and does not have it
web/src/lib/brand.ts:5-8 states:
The API is the source of truth. localStorage exists ONLY so the pre-paint script (public/theme-init.js) can apply the palette before first paint; it is cleared on every path that ends a session, so farm A's colour never bleeds into farm B's login screen.
Nothing clears it. web/src/auth/AuthContext.tsx:118-121 says the opposite in as many words:
The farm palette (cluckwork.brand) is deliberately NOT cleared here: this is a single-farm deployment, so the login screen should keep showing the farm's palette rather than reverting to the default (user choice, #149).
So the tree carries two comments that contradict each other, one of them asserting a safety property no line enforces — and its stated premise, "this is a single-farm deployment", is exactly what epic #530 falsifies. #535 corrects both comments to state the truth and leaves the behaviour to this issue.
Why the obvious fix does not work
cluckwork.brand is read by web/public/theme-init.js, a render-blocking <head> script that runs before React and before any token exists. So the account GUID that #535 uses as its namespace key — getBoundAccountId(), decoded from the access token — is structurally unavailable at the moment the brand must be resolved.
Namespacing the brand by GUID anyway was the original #535 design and was rejected in review after the cost was measured: a fresh tab with a live refresh cookie has no boundAccountId at pre-paint, so it would paint the default palette and then snap to the farm's colour once restoreSession() and /account complete. On the rural-3G phones this app targets that is a multi-second wrong-colour shell on every cold start, and today no such flash exists. It would regress #149's no-flash guarantee for the single-farm case, which is every current deployment.
The shape that does work
Key the brand by slug, and drive pre-paint from what is actually available before a session exists:
?farm=<slug>on the URL, validated inline against the slug shape → readcluckwork.brand:<slug>. This pre-paints the farm's palette on the login screen, which is strictly better than today and impossible under a GUID key. It is also what makes SPA: prefill the farm code from ?farm= and remember it #535's branded-URL story complete.- otherwise, if
cluckwork.farmCodes(added by SPA: prefill the farm code from ?farm= and remember it #535) holds exactly one code, that is this device's farm → pre-paint its brand. This is the single-farm majority, and it closes the cold-start flash. - otherwise — several farms and no
?farm=— the farm is genuinely ambiguous, so set no brand attribute and take the default. Correct rather than merely safe: at/loginthe app does not know which farm, so it must not assert one.
The slug is acceptable as a key here even though it is user input: it is written only after a server-authenticated login, it passes the same anchored regex the #535 cache applies, and epic #530 decision 10 makes it immutable for this epic. A wrong slug yields a cache miss and the default — identical to no cache.
Write-side cost, stated honestly: the Account DTO (web/src/api/cluckwork.ts:391) has no slug field, so applyBrand cannot learn the slug from /account. The login-time farm code has to be carried through (a cluckwork.boundFarmCode in sessionStorage beside cluckwork.boundAccountId is the natural parallel). On a cold restore there is no typed farm code — fall back to the single-entry cache, and where the farm is ambiguous apply the attribute but skip the cache write, since the cached value is already correct from the prior login.
Scope
- Slug-keyed brand cache, the three-branch pre-paint resolution above, and the write-side slug binding.
theme-init.jsgains aJSON.parseand a shape check. It is render-blocking, so keep it small, and it has no allowlist by design (see its own comment) — do not add one.- Purge the un-namespaced
cluckwork.brandon startup, the way SPA: prefill the farm code from ?farm= and remember it #535 purgescluckwork.lastFlockId. - Rewrite the
brand.tsandAuthContext.tsxcomments again, to describe the mechanism that now exists.
Tests
web/src/lib/themeInit.test.tsalready evaluatestheme-init.js's raw source vianew Function(SRC)()under jsdom, which supplies both storages — extend that harness rather than inventing one.src/lib/**is locked at 100% on all four coverage metrics (web/vite.config.ts:212-219), and that lock may only move up.- Cover all three pre-paint branches, plus a malformed
?farm=, a malformed cached list, and storage throwing.
Acceptance
- A cold start on a single-farm device pre-paints that farm's palette — no flash, matching today.
/login?farm=sunny-acrespre-paints sunny-acres' palette.- A device holding two farms never paints farm A's palette for farm B.
brand.tsno longer claims a property the code does not provide.
Activity
fix(web): never read the un-namespaced farm palette (#586)
test(web): re-point the palette-keeps-across-teardown tests at the pe…
docs(web): document the per-farm palette in Help, glossary and GLOSSA…
test(web): pin the palette guarantees against the key the flow derives (
fix(web): never apply a farm palette from a superseded response (#586)
fix(web): reset the live palette when farm attribution is lost (#586)
mforce commentedon Aug 25, 2026
Acceptance criteria as shipped (PR #600, head
c1030a68)Recorded here because the issue body is the spec, and two criteria did not survive contact unchanged.
/login?farm=<code>pre-paints that farm's palette?farm=renders the default rather than inheriting another farm's colour.brand.tsno longer claims a property the code does not provideWhy that last one was given up
This issue's original text said to purge the un-namespaced
cluckwork.brand. The design kept it as apre-paint fallback instead, to avoid exactly that flash. Reviewers then broke the fallback four times —
as
"[]", as a shrinking roster, and finally through history: a device that used Forget on the oldbuild shrank its roster while leaving the key untouched, so the fallback would paint the forgotten farm's
colour. Reproduced directly:
No lifetime rule reaches that, because the key's provenance predates every mechanism available. The
issue was right and the design was wrong; the key is purged and never read.
The flash is therefore an accepted cost, not an oversight. If it needs closing later, the route is
exposing
Account.Slugon/accountso a cookie-restored session can repopulate its own cache — thatwas scoped out here as an API+SPA change and is not implemented. It is a follow-up someone must file
deliberately, not a bullet quietly left to rot.
feat(web): key the farm brand palette per farm (#586) (#600)
chore(main): release 0.1.0 (#542)
feat(web): adopt MUI, themed from the farm palette tokens (#674) (#860)
feat(web): redesign Login onto AuthShell (Concept B), cache the farm …
feat(web): redesign Login onto AuthShell (Concept B), cache the farm …
feat(web): convert and redesign Settings, Help, Login, Audit, Export,…