Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0e8d987
feat(web): convert Login and SetPassword to MUI (#833)
mforce Sep 17, 2026
324460d
feat(web): convert Export and Account to MUI (#833)
mforce Sep 17, 2026
7361ad4
feat(web): convert Settings to MUI (#833)
mforce Sep 17, 2026
b5aa3fd
feat(web): convert Help to MUI, scoped narrow on purpose (#833)
mforce Sep 17, 2026
26b27b9
feat(web): add the shared FilterBar component (#831)
mforce Sep 17, 2026
00df225
feat(web): convert Audit to MUI (#833)
mforce Sep 17, 2026
d5dfc48
fix(web): shrink Audit's filter-select labels so they stop overlappin…
mforce Sep 18, 2026
8bdf34d
fix(web): restore the Login hero gradient at 1280, fix stacked upload…
mforce Sep 18, 2026
d9aaa5a
fix(web): restore the Forget glyph's --error contrast, keep .help-her…
mforce Sep 18, 2026
29e5eff
fix(web): merge FilterDateField's sx as an array, not a spread (#831)
mforce Sep 18, 2026
a941e80
test(web): supplement the source-shape guards with rendered-style ass…
mforce Sep 19, 2026
0fad70b
feat(web): redesign Export to Concept C (Focus panels), add the desig…
mforce Sep 19, 2026
a2c977b
feat(web): redesign Account to Concept C (Focus panels) (#833)
mforce Sep 19, 2026
812d015
feat(web): redesign SetPassword to Concept B (Working desk), extract …
mforce Sep 19, 2026
55e654f
feat(web): redesign Login onto AuthShell (Concept B), cache the farm …
mforce Sep 19, 2026
b43ce93
feat(web): redesign Settings onto Concept C "Focus panels"
mforce Sep 19, 2026
1934968
feat(web): redesign Audit onto Concept C "Focus panels", expandable e…
mforce Sep 19, 2026
1867c7c
test(web): verify Help renders /login?farm=<code> as text, not an HTM…
mforce Sep 19, 2026
00e8107
docs(web): document the cached pre-login farm banner in GLOSSARY.md a…
mforce Sep 19, 2026
c73eccb
fix(e2e): update two specs broken by the #833 redesign, found via ful…
mforce Sep 19, 2026
e366a1f
test(web): close bannerCache.ts's coverage gap to satisfy src/lib/**'…
mforce Sep 19, 2026
9af2e03
chore(web): deslop five comments that narrated review rounds
mforce Sep 19, 2026
44b80ca
fix(web): address Codex's 7 findings on the #833 banner-cache redesign
mforce Sep 19, 2026
52313b3
fix(web): close the src/lib coverage gate and Codex's second banner-c…
mforce Sep 19, 2026
523371f
fix(web): serialize banner operations, fix a vacuous Login test, and …
mforce Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/designs/674-tail-redesign/SELECTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Settings and support — selected compositions

Owner selections for #833, applied to both desktop and phone:

| Screen | Direction |
| --- | --- |
| Farm settings | C — Focus panels |
| Help | B — Working desk |
| Login | B — Working desk |
| Audit log | C — Focus panels |
| Export | C — Focus panels |
| Account | C — Focus panels |
| Set Password | B — Working desk |

Open `tail-direction-lab.html`. Switching screens restores that screen's selected composition; A/B/C remain available for comparison.

## Requested refinements

- Help's contents navigation remains visible while scrolling the article, with its own overflow for long contents lists. On phone it remains a horizontal strip above the article.
- Highlight the section currently being read with a tinted background, bold label and `aria-current="location"`. Update on manual scrolling as well as topic clicks, keep the active contents item visible, and clear the highlight when search returns no sections.
- Phone navigation remains five direct, equally sized items with the existing icons: Daily entry, Stock, Sales, History, More.
- Render Help copy as text with explicitly supported paired emphasis. A literal `/login?farm=<code>` is not an HTML opening tag. Rendering it as raw HTML had caused the browser to wrap the subsequent phone navigation inside `code` and break the grid.

## Login banner proposal — not an authorization change

The owner approved the farm-banner placement in Login B's left panel. The mockup reserves a labelled image slot, not an actual farm image; the empty preview retains the text fallback. This approves the visual placement, not a change to authentication or image access.

Current production banners are authenticated: `web/src/farm/useLogoObjectUrl.ts` fetches `/account/banner` through the authorized API client. Their existing use is the post-login splash. Login cannot fetch them anonymously under that contract.

Before implementing the login-banner proposal, decide how pre-login imagery is intentionally made available and how the correct farm is identified. Do not remove authorization or implicitly persist private banner bytes in the browser as part of a styling change. Keep neutral Cluckwork branding when no suitable pre-login image is available. The existing post-login splash is not removed by this proposal.

This artifact is a design prototype. It does not submit credentials, upload branding, save account data, or download real exports. Selection is not a claim that the implementation or every production state is complete.
111 changes: 111 additions & 0 deletions docs/designs/674-tail-redesign/tail-direction-lab.html

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions docs/designs/822-mui-revamp.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,13 @@ Order is by dependency, then by what each slice teaches the next. **Deletions ar

Only one CSS deletion actually landed with #832: `.dialog .confirm-body` / `.dialog .confirm-body strong` (UsersPage's disable-warning paragraph was its last consumer anywhere in the repo; `useConfirm.tsx` had already dropped it in #827). Every other class family the five screens used — `table.data`, `.page-head`, `.form-grid`/`.inline-form`, `.actions`, `tr.inactive`, `.dialog-foot`, `.numfield-field`, `.named-picker-trigger`, `.hint`, `.check`, `.cell`, `td.nowrap` — is still shared with an unconverted screen, confirmed by grep before touching each one.

**Amendment (#833, landed 2026-09-18, ahead of #831 in real order).** #833 shipped while #831 was still in flight on its own branch (`feat/831-mui-ledgers`, unmerged at #833's head), which changes four things this row's own text assumed:

- **`FilterBar` was cherry-picked from #831's branch, not read from `main`.** Row 7 says "reused, never rebuilt: seven screens call one component," and by the time #833 reached Audit (the seventh, done last per D8), #831 had already built `FilterBar`/`FilterDateField` and converted six of its own seven ledger screens on its own unmerged branch. #833 cherry-picked #831's isolated `FilterBar` commit (`47fce5a`, two new files, zero other changes) onto `feat/833-mui-tail` rather than rebuild it, per the brief's own contingency for exactly this ordering. The PR names the cherry-picked SHA.
- **`.muted`/`.error`/`.success` were left unconverted on Audit, breaking with this PR's own pattern on its other six screens.** Pair 17 (`p.error`/`p.warn` → `Alert`) is scoped "each screen slice," and #833 converted it everywhere except Audit — Login, SetPassword, Export, Account and Settings all got `Alert`/`Typography color="text.secondary"`. Audit did not, because #831's own shipped ledger screens (Stock, History, Expenses, on its unmerged branch) all keep `.muted`/`.error`/`.success` as raw paragraphs, matching #832's own precedent of leaving semantic-text classes for a later, dedicated pass. Audit sits in the ledger family by D3.3's own row grouping (Audit shares a row with the ledgers' "table" shape), so #833 matched its nearest siblings' shipped look rather than pair 17's letter, on the theory that a ledger screen reading differently from the six beside it in the nav is a worse outcome than an inconsistent PR-to-PR reading of one pair. `#831`'s own PR is the one that will convert these classes on all seven ledger screens at once, Audit included, if it chooses to.
- **`.toolbar`, `table.data`, `.page-head`, `.content`, the badge rules, the semantic-text rules and the shared `.card` rule were NOT deleted.** Row 9's "last consumer AuditPage.tsx, #833" and D8's own #833 deletion list both assumed #833 would be the slice to retire these — true only if #833 merges after #831. At #833's head, six other screens in the same worktree (Feed, Water, Reports, History, Expenses, Stock — all #831's, unmerged) still render `.toolbar`, and `table.data`/`.page-head` are shared even more broadly. #833's own PR body states the check it ran against `origin/main` at open-PR time and which of the two possible outcomes applied.
- **Help was converted far more narrowly than D3.3's row describes.** "Keeps its docs layout as `Container maxWidth='md'` prose" undersold how much of Help's existing CSS-driven structure (`.help-hero`, `.help-search*`, `.help-toc*`, `.glossary-group`/`.glossary-entry`, `.mistakes`) survives: `HelpPage.test.tsx` has dozens of assertions keyed directly to that structure, and the issue's own text says "Presentation only — do not touch the prose or the glossary here." #833 converted only the outer wrapper (`Container maxWidth="md"`) and the page's own `<h2>`, plus the shared `GlossaryLink` component (pair 20) — leaving the rest of Help's CSS classes in `styles.css` undeleted, as a deliberate, recorded scope reduction rather than the fuller conversion this row implies. A follow-up issue is the owner's call, not filed by this PR.

### D9. The precache ceiling (#825). Owner review

Ceiling **1,800 KiB** of precache, enforced in CI by extending `scripts/verify-sw.mjs` (it already extracts the manifest at L150-161; it sums the listed files' sizes from `dist/` and fails above the ceiling). Derivation: MUI plus the realistic kit measured 1632.68 KiB; D7.2 adds 118.9 KiB; that is 1751.6 KiB, and 1,800 leaves 2.7% for the small controls. The alternative is a ceiling below the unoptimised kit measurement (1,600 KiB) on the argument that this epic deletes as it adds and a ceiling above the measurement is not a ceiling. Its cost is stated plainly: #825 installs the gate at order 3, before any deletion slice, and no deletion estimate exists yet (the hand-built controls are TSX, which the precache counts as part of the JS chunk, and nobody has measured what #826 and #827 remove), so a 1,600 ceiling can go red at #828 and stay red until enough hand-built code is gone; it also forces the `opsz` fallback in D7.2. #825 measures the JS delta of deleting `NamedEntityPicker` and `Dialog` on a throwaway branch before the number is chosen, so the choice is decidable. Either way: above the ceiling a slice must retire hand-built code to land, and a slice that adds more than 10 KiB of precache without deleting code names the reason in its PR body. The check is a `pull_request` check in the `web` job, so a documentation-only PR skips it (#782), which is correct: such a PR changes no bundle input. Script-time ceiling per #674: re-measure with the record's method when #826 lands, since `Autocomplete` is the component the record named as needing its own number.
Expand Down
41 changes: 38 additions & 3 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -971,9 +971,44 @@ as the logo (PNG/JPEG/WebP, a still image, no SVG, dimensions and metadata
handled the same way — see below), but its own larger size cap (5 MB by default,
since a detailed hero image is typically heavier than a small sidebar mark) and its own
Owner-only upload and everyone-sees-it read, same as the logo. The
splash is skipped entirely when no banner is set — it is never shown empty, and
never shown on the pre-login screen (that screen has no farm to show a banner
for yet).
splash is skipped entirely when no banner is set — it is never shown empty.

The pre-login screen itself has no farm to fetch a banner for — `/account/banner`
stays authenticated, and signing in is exactly what has not happened yet — but it
can still show one (#833): the device caches the banner's bytes locally the moment
the post-login splash fetches them. Not the same mechanism the cached **farm
palette** (below) uses: a colour is a few bytes of text and fits localStorage, but
production's Content-Security-Policy allows images only from this origin or a
`blob:` URL, never a `data:` one — so the banner's actual bytes live in IndexedDB
and are shown through a `blob:` object URL, the same mechanism the authenticated
logo/banner fetch already uses once signed in.

On a later visit, Login shows the cached image only when the farm-code field's
CURRENT value — typed, prefilled from a link, or picked from the remembered-farm
list — names the farm that cache entry belongs to; typing or picking a different
code hides it immediately, without waiting for the form to be submitted. A field
naming no cached farm at all (nothing typed yet, or a farm never signed in on
this device) shows plain Cluckwork branding instead. Forgetting a remembered farm
clears its cached banner alongside its cached palette.

Each cache entry also remembers WHICH account wrote it, not just the farm code:
a **farm code** can be reassigned to a different farm after a rename (#732 — see
above), so the code alone cannot tell "still the same farm" from "a different
farm now using this farm's old code". The moment a sign-in proves which account a
code belongs to today, a mismatched cache entry is cleared. Until that sign-in
happens, a device that already held one farm's banner may show it briefly under a
code since reassigned to a different farm — a stale attribution to a device that
already had those bytes, not a new disclosure to a device that never held them.

Deliberately narrower than the palette's own pre-login reach: the palette also
shows for a farm a `?farm=<code>` link merely *names*, even on a device that has
never signed in there, because a colour carries no private information. A banner
is farm-supplied imagery, so showing one to whoever holds a link — rather than
only to a device this farm has actually authenticated on before — would be a
new disclosure the owner did not approve; #833's own design note is explicit that
the banner's placement was approved, not a change to authentication or image
access. A `?farm=<code>` link therefore shows no banner, cached or otherwise,
regardless of what the device remembers.

What gets stored is never quite the file that was uploaded. The image is taken
apart and rebuilt, which drops two things on purpose: **embedded metadata** — a
Expand Down
5 changes: 4 additions & 1 deletion tools/simulation/ui/specs/owner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,10 @@ test.describe("Owner", () => {
// soon as the log holds more than one kind of event — which the assertion
// below on `distinctActions` proves it does, rather than assuming it.
const filter = page.getByLabel(tEn("audit:actionFilterLabel"));
const actionCells = table.locator("tbody tr td:nth-child(3)");
// #833 — each row grew a leading disclosure-toggle cell (When/Who/Action
// moved from columns 1/2/3 to 2/3/4), so Action is now the 4th cell, not
// the 3rd.
const actionCells = table.locator("tbody tr td:nth-child(4)");
const before = (await actionCells.allInnerTexts()).map((a) => a.trim());

// Count what is actually on screen and filter to one of THOSE actions.
Expand Down
25 changes: 25 additions & 0 deletions tools/simulation/ui/specs/phone.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,3 +659,28 @@ test.describe("Phone shell", { tag: "@phone" }, () => {
).toBe(clientWidth);
});
});

// #833 — successor to the retired `routes/Login.styles.test.ts`. That file
// read the 44px Forget-control floor out of `styles.css`'s cascade; the
// control is now an MUI `IconButton` with the floor as a literal `sx`
// (`minWidth`/`minHeight: 44`), so the cascade-walking guard has nothing left
// to parse and the geometric claim moves here, per AGENTS.md's guard-fate
// rule ("Playwright for anything geometric"). A separate `describe` because
// it is unauthenticated (the farm picker only exists before sign-in) and the
// "Phone shell" block above signs in on every test via its own `beforeEach`.
test.describe("Login farm picker at phone width", { tag: "@phone" }, () => {
test("the Forget control meets the 44px touch-target floor on both axes", async ({ page }) => {
// Seeded before any app script runs: Login reads the roster synchronously
// from its initial `useState`, so a `page.evaluate` after `goto` would
// land one render too late.
await page.addInitScript(([key, codes]) => {
window.localStorage.setItem(key as string, JSON.stringify(codes));
}, ["cluckwork.farmCodes", ["farm-a"]] as const);
await page.goto("/login");

const forget = page.getByRole("button", { name: tEn("auth:forgetFarm", { farmCode: "farm-a" }) });
const box = await rectOf(forget, "the Forget control");
expect.soft(box.width, "the Forget control is too narrow to hit").toBeGreaterThanOrEqual(MIN_TARGET_PX);
expect.soft(box.height, "the Forget control is too short to hit").toBeGreaterThanOrEqual(MIN_TARGET_PX);
});
});
7 changes: 6 additions & 1 deletion tools/simulation/ui/specs/pwa.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,13 @@ test.describe("PWA shell", () => {
// survivable on a bad connection. So it must PAINT — not show the
// browser's offline error page.
await expect(page).toHaveTitle("Cluckwork");
// #833 — Login now renders "Cluckwork" twice (AuthShell's own brand-panel
// h1, plus Login's own h2), so this needs a level to stay a single match.
// Asserting the shell's h1 specifically matches the comment below: it is
// the outer chrome, not Login's own heading, that proves the shell itself
// painted.
await expect(
page.getByRole("heading", { name: tEn("auth:title") }),
page.getByRole("heading", { name: tEn("auth:title"), level: 1 }),
"the shell did not render offline — the precache is not answering navigations",
).toBeVisible();

Expand Down
2 changes: 1 addition & 1 deletion tools/simulation/ui/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export const test = base.extend<Fixtures>({
// longer sign in at all. That is not hypothetical; it happened.
//
// The bare `main` element is not usable either: the login screen is a
// `<main class="auth">` too, so it cannot tell the shell from the form it
// bare `<main>` too (#833), so it cannot tell the shell from the form it
// replaced. The `id` is what separates them — AppLayout's is the only
// element in the app carrying `main-content`, and the login screen's has
// no id at all.
Expand Down
53 changes: 52 additions & 1 deletion web/src/auth/AuthContext.lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"
import { AuthProvider } from "./AuthContext";
import { useAuth } from "./useAuth";
import { setStoredToken } from "../test/jwt";
import { bindAccount, bindFarm, clearAccessToken, clearBoundAccount, getBoundFarmCode } from "./tokenStore";
import { bindAccount, bindFarm, clearAccessToken, clearBoundAccount, farmBindingToken, getBoundFarmCode } from "./tokenStore";
import { login as apiLogin, logout as apiLogout, restoreSession, setOnTokensChanged, setOnUnauthenticated } from "../api/client";
import { cacheBannerBytes, readCachedBannerBlob } from "../lib/bannerCache";

// Mock the transport: AuthProvider drives session STATE, the client drives the
// network. We simulate the server side (login stores a token, logout clears it)
Expand Down Expand Up @@ -86,6 +87,56 @@ describe("AuthProvider lifecycle", () => {
expect(screen.getByTestId("auth")).toHaveTextContent("true");
});

// #833 findings 2/3 — a farm code is reusable (#732), so a cached
// pre-login banner keyed by slug alone can outlive the account that wrote
// it. login() must reconcile that the moment a sign-in proves which
// account a code belongs to today.
it("clears a cached banner left by a DIFFERENT account under the same farm code", async () => {
// Simulate a stale cache: written earlier under a different account than
// the one about to sign in with this code.
bindAccount("old-acct");
bindFarm("default-farm");
await cacheBannerBytes(new Blob(["old-banner"]), farmBindingToken());
clearBoundAccount();

mockApiLogin.mockImplementation(async () => {
bindAccount("new-acct"); // what the real client.ts login does
setStoredToken({ sub: "u1", role: "Sales" });
});
await act(async () => {
renderAuth();
});

await act(async () => {
fireEvent.click(screen.getByText("login"));
});

// clearBannerIfWrongAccount runs fire-and-forget from login(), so this
// settles asynchronously rather than by the time act() above returns.
await waitFor(async () => expect(await readCachedBannerBlob("default-farm")).toBeNull());
});

it("leaves a cached banner written by the SAME account signing in again", async () => {
bindAccount("acct-A");
bindFarm("default-farm");
await cacheBannerBytes(new Blob(["same-banner"]), farmBindingToken());
clearBoundAccount();

mockApiLogin.mockImplementation(async () => {
bindAccount("acct-A"); // same account as the one that wrote the cache
setStoredToken({ sub: "u1", role: "Sales" });
});
await act(async () => {
renderAuth();
});

await act(async () => {
fireEvent.click(screen.getByText("login"));
});

expect(await readCachedBannerBlob("default-farm")).not.toBeNull();
});

it("adopts the session the load-time silent refresh restores", async () => {
// The success half of the #145 bootstrap. Every other case here starts
// either already-authenticated or with restoreSession resolving false, so
Expand Down
Loading
Loading