Skip to content

SPA: per-farm brand palette, keyed by slug so it survives pre-paint #586

Closed

Description

@mforce

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 → read cluckwork.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 /login the 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.js gains a JSON.parse and 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.brand on startup, the way SPA: prefill the farm code from ?farm= and remember it #535 purges cluckwork.lastFlockId.
  • Rewrite the brand.ts and AuthContext.tsx comments again, to describe the mechanism that now exists.

Tests

  • web/src/lib/themeInit.test.ts already evaluates theme-init.js's raw source via new 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-acres pre-paints sunny-acres' palette.
  • A device holding two farms never paints farm A's palette for farm B.
  • brand.ts no longer claims a property the code does not provide.

Activity

  1. added
    sliceThin vertical work item
    epic-1.6Phase 1.6 — Multi-farm tenancy
    on Aug 23, 2026
  2. mforce commented on Aug 25, 2026

    @mforce
    OwnerAuthor

    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.

    Criterion Status
    /login?farm=<code> pre-paints that farm's palette Met. An unknown ?farm= renders the default rather than inheriting another farm's colour.
    brand.ts no longer claims a property the code does not provide Met.
    A device holding two farms never paints farm A's palette for farm B Amended. #587's Forget means a device can hold two farms while remembering one, so the original wording is unachievable without a farm identifier in the URL. Shipped guarantee: never paints a palette for a farm the device still remembers.
    A cold start on a single-farm device pre-paints that farm's palette — no flash, matching today Not met on upgrade day, deliberately. An upgraded device paints the default until its next explicit login.

    Why that last one was given up

    This issue's original text said to purge the un-namespaced cluckwork.brand. The design kept it as a
    pre-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 old
    build shrank its roster while leaving the key untouched, so the fallback would paint the forgotten farm's
    colour. Reproduced directly:

    cluckwork.farmCodes = ["farm-b"]   cluckwork.brand = "terracotta"
    → data-brand = terracotta          (farm-A's colour, on farm-B's login screen)
    

    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.Slug on /account so a cookie-restored session can repopulate its own cache — that
    was 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:frontendReact/Vite web clientepic-1.6Phase 1.6 — Multi-farm tenancysliceThin vertical work item

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions