diff --git a/docs/designs/674-tail-redesign/SELECTION.md b/docs/designs/674-tail-redesign/SELECTION.md new file mode 100644 index 000000000..56ad0a81c --- /dev/null +++ b/docs/designs/674-tail-redesign/SELECTION.md @@ -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=` 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. diff --git a/docs/designs/674-tail-redesign/tail-direction-lab.html b/docs/designs/674-tail-redesign/tail-direction-lab.html new file mode 100644 index 000000000..0c6000b04 --- /dev/null +++ b/docs/designs/674-tail-redesign/tail-direction-lab.html @@ -0,0 +1,111 @@ + +Cluckwork · Settings & support directions + + +
Settings & support · #833
+
Interactive layout study · English source copy · sample data · no API calls, credential submission, or real downloads.
+

+ + \ No newline at end of file diff --git a/docs/designs/822-mui-revamp.md b/docs/designs/822-mui-revamp.md index 430b66ede..63d76e84d 100644 --- a/docs/designs/822-mui-revamp.md +++ b/docs/designs/822-mui-revamp.md @@ -352,6 +352,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 `

`, 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. diff --git a/specs/product/GLOSSARY.md b/specs/product/GLOSSARY.md index 2e84225de..c9891562e 100644 --- a/specs/product/GLOSSARY.md +++ b/specs/product/GLOSSARY.md @@ -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=` 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=` 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 diff --git a/tools/simulation/ui/specs/owner.spec.ts b/tools/simulation/ui/specs/owner.spec.ts index 2b72d99ac..e4548fdf4 100644 --- a/tools/simulation/ui/specs/owner.spec.ts +++ b/tools/simulation/ui/specs/owner.spec.ts @@ -191,38 +191,32 @@ test.describe("Owner", () => { await expect(page.getByRole("heading", { name: tEn("audit:heading") })).toBeVisible(); await expect(page.getByRole("alert")).toBeHidden(); - const table = page - .getByRole("table") - .filter({ has: page.getByRole("columnheader", { name: tEn("audit:whenHeader") }) }); - await expect(table).toBeVisible(); - const allRows = await table.locator("tbody tr").count(); - expect(allRows, "the audit log is empty — the fixture wrote no auditable events").toBeGreaterThan(0); + const panels = page.getByRole("article"); + await expect(panels.first()).toBeVisible(); + expect(await panels.count(), "the audit log is empty — the fixture wrote no auditable events").toBeGreaterThan(0); await expect(page.getByText(tEn("audit:emptyMessage"))).toBeHidden(); - // Filtering is the screen's one interaction, and the assertion has to be - // about WHAT CAME BACK, not how much of it. - // - // An earlier version asserted `filteredRows <= allRows`. That cannot fail: - // a filter the server ignores entirely returns every row, and - // `allRows <= allRows` is true. Deleting the `action` handling server-side - // would have left it green (PR #390 review). - // - // So: pick a real action and assert that EVERY remaining row is that action. - // That is the guarantee the control claims, and a no-op filter fails it as - // 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)"); - const before = (await actionCells.allInnerTexts()).map((a) => a.trim()); + const firstPanel = panels.first(); + const firstSummary = firstPanel.locator("summary"); + const firstSummaryText = await firstSummary.innerText(); + await expect(firstSummary).toBeVisible(); + await expect(firstPanel).toHaveAccessibleName(firstSummaryText); + const summaryId = await firstSummary.getAttribute("id"); + if (!summaryId) throw new Error("the first Audit summary has no id"); + await expect(firstPanel).toHaveAttribute("aria-labelledby", summaryId); + const actorId = await firstPanel.getAttribute("aria-describedby"); + if (!actorId) throw new Error("the first Audit panel has no actor description"); + const actor = page.locator(`#${actorId}`); + const actorText = await actor.textContent(); + if (!actorText) throw new Error("the first Audit panel has no actor text"); + await expect(firstPanel).toHaveAccessibleDescription(actorText); + await firstSummary.click(); + await expect(firstPanel).toHaveAttribute("open", ""); + await expect(actor).toBeVisible(); - // Count what is actually on screen and filter to one of THOSE actions. - // - // An earlier version took `options[1]` — the first entry in the filter's own - // dropdown. That worked only because `manager.spec.ts` sorts before this file - // and performs exactly one adjust per run, so the action happened to exist: - // an unstated ordering coupling between two spec files (PR #390 review round - // 2). Deriving the target from the rows in front of us has no such - // dependency, and it cannot pick an action with zero rows. + const filter = page.getByLabel(tEn("audit:actionFilterLabel")); + const before = (await panels.locator("summary").allTextContents()) + .map((summary) => summary.split(" UTC · ")[1] ?? ""); const counts = new Map(); for (const action of before) counts.set(action, (counts.get(action) ?? 0) + 1); expect( @@ -231,31 +225,19 @@ test.describe("Owner", () => { + "from a working one — this spec cannot prove anything against this fixture", ).toBeGreaterThan(1); - // The rarest visible action: the strictest subset available, so a no-op - // filter is maximally obvious. const chosenLabel = [...counts.entries()].sort((a, b) => a[1] - b[1])[0]![0]; const option = filter.locator("option").filter({ hasText: chosenLabel }).first(); const chosenValue = await option.getAttribute("value"); expect(chosenValue, `no filter option matches the rendered label "${chosenLabel}"`).toBeTruthy(); await filter.selectOption(chosenValue!); - // POLL, do not snapshot. `allInnerTexts()` does not auto-retry, and - // AuditPage.load() clears `events` to null (unmounting the tbody entirely) - // before the filtered page arrives — so reading once, immediately, can catch - // the empty transient. The previous version's "wait" was - // `expect(getByRole("alert")).toBeHidden()`, which is vacuous here: this - // screen renders no alert on the success path, so the locator matches nothing - // and resolves on its first poll. That reintroduced exactly the intermittency - // the count-free rewrite was meant to remove, on a different axis - // (PR #390 review round 2). - // - // A no-op filter never reaches "settled", so this still fails closed. await expect .poll( async () => { - const rows = (await actionCells.allInnerTexts()).map((a) => a.trim()); - if (rows.length === 0) return "still-loading"; - return rows.every((a) => a === chosenLabel) ? "settled" : "mixed"; + const actions = (await panels.locator("summary").allTextContents()) + .map((summary) => summary.split(" UTC · ")[1] ?? ""); + if (actions.length === 0) return "still-loading"; + return actions.every((action) => action === chosenLabel) ? "settled" : "mixed"; }, { message: diff --git a/tools/simulation/ui/specs/phone.spec.ts b/tools/simulation/ui/specs/phone.spec.ts index 796f20ce3..5e06f9011 100644 --- a/tools/simulation/ui/specs/phone.spec.ts +++ b/tools/simulation/ui/specs/phone.spec.ts @@ -717,3 +717,17 @@ test.describe("Phone shell", { tag: "@phone" }, () => { ).toBe(clientWidth); }); }); + +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 }) => { + 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); + }); +}); diff --git a/tools/simulation/ui/specs/pwa.spec.ts b/tools/simulation/ui/specs/pwa.spec.ts index ac706ca8f..f56cabec1 100644 --- a/tools/simulation/ui/specs/pwa.spec.ts +++ b/tools/simulation/ui/specs/pwa.spec.ts @@ -97,7 +97,7 @@ test.describe("PWA shell", () => { // browser's offline error page. await expect(page).toHaveTitle("Cluckwork"); 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(); diff --git a/tools/simulation/ui/src/fixtures.ts b/tools/simulation/ui/src/fixtures.ts index 9a272b520..0e7cfed73 100644 --- a/tools/simulation/ui/src/fixtures.ts +++ b/tools/simulation/ui/src/fixtures.ts @@ -163,7 +163,7 @@ export const test = base.extend({ // longer sign in at all. That is not hypothetical; it happened. // // The bare `main` element is not usable either: the login screen is a - // `
` too, so it cannot tell the shell from the form it + // bare `
` 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. diff --git a/web/src/auth/AuthContext.lifecycle.test.tsx b/web/src/auth/AuthContext.lifecycle.test.tsx index fd537c40a..5d9003fc3 100644 --- a/web/src/auth/AuthContext.lifecycle.test.tsx +++ b/web/src/auth/AuthContext.lifecycle.test.tsx @@ -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) @@ -86,6 +87,48 @@ describe("AuthProvider lifecycle", () => { expect(screen.getByTestId("auth")).toHaveTextContent("true"); }); + it("clears a cached banner left by a DIFFERENT account under the same farm code", async () => { + 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")); + }); + + 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 diff --git a/web/src/auth/AuthContext.tsx b/web/src/auth/AuthContext.tsx index 159e44da6..d905bc155 100644 --- a/web/src/auth/AuthContext.tsx +++ b/web/src/auth/AuthContext.tsx @@ -6,11 +6,12 @@ import { } from "../api/client"; import { currentUserId, currentUserIsAdmin, currentUserMustChangePassword, currentUserRole } from "./claims"; import type { Role } from "./claims"; -import { bindFarm, clearAccessToken, getAccessToken, purgeLegacyTokens } from "./tokenStore"; +import { bindFarm, clearAccessToken, getAccessToken, getBoundAccountId, purgeLegacyTokens } from "./tokenStore"; import { clearSplashSeenMarker } from "../session/SessionContext"; import { purgeUnscopedAccountState } from "../lib/accountStorage"; import { canonicalFarmCode, readFarmCodes, rememberFarmCode } from "./farmCodeCache"; import { applyDeviceBrand } from "../lib/brand"; +import { clearBannerIfWrongAccount } from "../lib/bannerCache"; interface AuthState { isAuthenticated: boolean; @@ -124,6 +125,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { // one) canonicalises to null and leaves the tab unbound — the same outcome // as a cold restore, and never a wrong key. bindFarm(canonicalFarmCode(farmCode)); + const signedInSlug = canonicalFarmCode(farmCode); + const signedInAccountId = getBoundAccountId(); + if (signedInSlug !== null && signedInAccountId !== null) { + void clearBannerIfWrongAccount(signedInSlug, signedInAccountId); + } // #535 — remembered only AFTER apiLogin resolves, so a typo is never stored: // a failed sign-in throws out of apiLogin (client.ts:144 `if (!res.ok) throw`) // and never reaches this line, and neither does the superseded-session path diff --git a/web/src/auth/farmCodeCache.test.ts b/web/src/auth/farmCodeCache.test.ts index 8b51e8e0f..adfcde69b 100644 --- a/web/src/auth/farmCodeCache.test.ts +++ b/web/src/auth/farmCodeCache.test.ts @@ -6,6 +6,8 @@ import { rememberFarmCode, removeFarmCode, } from "./farmCodeCache"; +import { bindAccount, bindFarm, farmBindingToken } from "./tokenStore"; +import { cacheBannerBytes, readCachedBannerBlob } from "../lib/bannerCache"; const KEY = "cluckwork.farmCodes"; @@ -474,4 +476,18 @@ describe("farmCodeCache", () => { expect(JSON.parse(localStorage.getItem("cluckwork.farmCodes")!)).toEqual(["farm-a"]); }); + + it("forgetting a farm removes its cached banner too (#833)", async () => { + localStorage.setItem("cluckwork.farmCodes", JSON.stringify(["farm-a", "farm-b"])); + bindAccount("acct-A"); + bindFarm("farm-a"); + await cacheBannerBytes(new Blob(["AAA"]), farmBindingToken()); + bindFarm("farm-b"); + await cacheBannerBytes(new Blob(["BBB"]), farmBindingToken()); + + await removeFarmCode("farm-b"); + + await vi.waitFor(async () => expect(await readCachedBannerBlob("farm-b")).toBeNull()); + expect(await readCachedBannerBlob("farm-a")).not.toBeNull(); + }); }); diff --git a/web/src/auth/farmCodeCache.ts b/web/src/auth/farmCodeCache.ts index d88cb25ac..9f5378dfe 100644 --- a/web/src/auth/farmCodeCache.ts +++ b/web/src/auth/farmCodeCache.ts @@ -1,4 +1,5 @@ import { forgetBrandFor } from "../lib/brand"; +import { forgetBannerFor } from "../lib/bannerCache"; // #535 — the farm codes that have SUCCESSFULLY signed in on this device. // @@ -174,6 +175,7 @@ export async function removeFarmCode(value: string): Promise { // to leave no colour behind. forgetBrandFor never throws, so ordering it // first cannot cost the roster write either. forgetBrandFor(code); + forgetBannerFor(code); const raw = readRawRoster(); if (raw === null) return; // Readable-but-malformed stored JSON and non-array JSON parse to an EMPTY diff --git a/web/src/components/AuthShell.tsx b/web/src/components/AuthShell.tsx new file mode 100644 index 000000000..384c65e6b --- /dev/null +++ b/web/src/components/AuthShell.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Box, Paper, Typography } from "@mui/material"; +import { ThemeToggle } from "./ThemeToggle"; + +export function AuthShell({ + children, footerNote, bannerSlot, +}: { children: ReactNode; footerNote: string; bannerSlot?: ReactNode }) { + const { t } = useTranslation("auth"); + + return ( + + + + + + + + {t("shellEyebrow")} + + {t("title")} + + {t("shellTagline")} + + {bannerSlot} + + {footerNote} + + + + {children} + + + + ); +} diff --git a/web/src/components/BrandSplash.test.tsx b/web/src/components/BrandSplash.test.tsx index a8ce6322f..4e225b67d 100644 --- a/web/src/components/BrandSplash.test.tsx +++ b/web/src/components/BrandSplash.test.tsx @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { BrandSplash } from "./BrandSplash"; import { getFarmBanner } from "../api/cluckwork"; +import { bindAccount, bindFarm } from "../auth/tokenStore"; +import { readCachedBannerBlob } from "../lib/bannerCache"; vi.mock("../api/cluckwork", async () => { const actual = await vi.importActual("../api/cluckwork"); @@ -12,6 +14,7 @@ const mockGetFarmBanner = vi.mocked(getFarmBanner); beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); vi.stubGlobal("URL", { ...URL, createObjectURL: vi.fn(() => "blob:test/banner"), @@ -61,4 +64,31 @@ describe("BrandSplash", () => { expect(screen.getByRole("dialog", { name: "Hen House" })).toBeInTheDocument(); }); + + it("caches the fetched banner under the bound farm, once it loads", async () => { + bindAccount("acct-A"); + bindFarm("sunny-acres"); + mockGetFarmBanner.mockResolvedValue({ blob: new Blob(["png-bytes"]), filename: null }); + render(); + + await screen.findByAltText("Hen House banner"); + await waitFor(async () => expect(await readCachedBannerBlob("sunny-acres")).not.toBeNull()); + }); + + it("does not cache anything while the fetch is still pending", async () => { + bindAccount("acct-A"); + bindFarm("sunny-acres"); + mockGetFarmBanner.mockReturnValue(new Promise(() => {})); + render(); + + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + }); + + it("caches nothing on an unbound tab (a fresh tab restored from the refresh cookie)", async () => { + mockGetFarmBanner.mockResolvedValue({ blob: new Blob(["png-bytes"]), filename: null }); + render(); + + await screen.findByAltText("Hen House banner"); + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + }); }); diff --git a/web/src/components/BrandSplash.tsx b/web/src/components/BrandSplash.tsx index 0dd430acc..bff43f9a7 100644 --- a/web/src/components/BrandSplash.tsx +++ b/web/src/components/BrandSplash.tsx @@ -1,6 +1,8 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useBannerObjectUrl } from "../farm/useLogoObjectUrl"; +import { farmBindingToken } from "../auth/tokenStore"; +import { cacheBannerBytes } from "../lib/bannerCache"; interface BrandSplashProps { farmName: string; @@ -20,7 +22,12 @@ interface BrandSplashProps { // showing what it was given. export function BrandSplash({ farmName, bannerContentHash, onDismiss }: BrandSplashProps) { const { t } = useTranslation("splash"); - const { url, failed } = useBannerObjectUrl(bannerContentHash); + // Capture the binding before fetch so a farm switch cannot misattribute it. + const boundAt = useMemo(() => farmBindingToken(), [bannerContentHash]); + const { url, failed } = useBannerObjectUrl( + bannerContentHash, + (blob) => void cacheBannerBytes(blob, boundAt), + ); const continueRef = useRef(null); // The only focusable control, so a full focus trap is unnecessary — nothing diff --git a/web/src/components/FilterBar.test.tsx b/web/src/components/FilterBar.test.tsx new file mode 100644 index 000000000..5bdb8d1a2 --- /dev/null +++ b/web/src/components/FilterBar.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { FilterBar, FilterDateField } from "./FilterBar"; + +describe("FilterBar", () => { + it("renders every child control, each reachable by its own label", () => { + render( + + {}} /> + {}} /> + , + ); + expect(screen.getByLabelText("From", { exact: true })).toHaveValue("2026-01-01"); + expect(screen.getByLabelText("To", { exact: true })).toHaveValue("2026-01-31"); + }); + + it("lays the row out as a wrapping flex row, never a column, at rest", () => { + render( + + {}} /> + , + ); + const paper = screen.getByLabelText("From").closest(".MuiPaper-root"); + expect(paper).not.toBeNull(); + expect(paper).toHaveClass("MuiPaper-outlined"); + const stack = paper!.querySelector(":scope > .MuiStack-root"); + expect(stack).not.toBeNull(); + expect(stack).toHaveStyle({ flexWrap: "wrap" }); + }); + + it("a date field reports the value change the caller's onChange receives", () => { + const onChange = vi.fn(); + render( + + + , + ); + fireEvent.change(screen.getByLabelText("From", { exact: true }), { target: { value: "2026-02-01" } }); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("still applies a caller's function-form sx alongside the bounded-width default", () => { + render( + + {}} + sx={() => ({ color: "rgb(1, 2, 3)" })} + /> + , + ); + const field = screen.getByLabelText("From", { exact: true }).closest(".MuiFormControl-root"); + expect(field).not.toBeNull(); + expect(field).toHaveStyle({ color: "rgb(1, 2, 3)" }); + }); +}); diff --git a/web/src/components/FilterBar.tsx b/web/src/components/FilterBar.tsx new file mode 100644 index 000000000..c251d0bad --- /dev/null +++ b/web/src/components/FilterBar.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; +import { Paper, Stack, TextField } from "@mui/material"; +import type { TextFieldProps } from "@mui/material"; + +export function FilterBar({ children }: { children: ReactNode }) { + return ( + + *": { flex: { xs: "1 1 100%", md: "0 0 auto" } }, + }} + > + {children} + + + ); +} + +const DATE_FIELD_MAX_WIDTH = "12rem"; + +export function FilterDateField({ sx, slotProps, ...props }: TextFieldProps) { + return ( + + ); +} diff --git a/web/src/components/GlossaryLink.tsx b/web/src/components/GlossaryLink.tsx index 8583bb083..ed167e500 100644 --- a/web/src/components/GlossaryLink.tsx +++ b/web/src/components/GlossaryLink.tsx @@ -1,4 +1,5 @@ -import { Link } from "react-router"; +import { Link as MuiLink } from "@mui/material"; +import { Link as RouterLink } from "react-router"; import { useTranslation } from "react-i18next"; import { glossaryEntry } from "../routes/helpGlossary"; import type { GlossaryKey } from "../routes/helpGlossary"; @@ -7,11 +8,29 @@ import type { GlossaryKey } from "../routes/helpGlossary"; // term, linking to the term's own anchor on the Help page. The accessible // name says which term, so a screen reader hears "What does Withdrawal // restriction mean?" rather than a bare question mark. +// +// D2 pair 20: a small ring badge, not underlined text — the hover highlight +// is the ring's edge (--stat-accent), never a link underline. export function GlossaryLink({ term }: { term: GlossaryKey }) { const { t } = useTranslation(["common", "help"]); const entry = glossaryEntry(term); const label = t("common:whatDoesTermMean", { term: t(`help:${entry.termKey}`) }); return ( - ? + + ? + ); } diff --git a/web/src/components/ThemeToggle.test.tsx b/web/src/components/ThemeToggle.test.tsx index 650ac1e0d..43d5bc20f 100644 --- a/web/src/components/ThemeToggle.test.tsx +++ b/web/src/components/ThemeToggle.test.tsx @@ -25,6 +25,14 @@ describe("ThemeToggle", () => { expect(btn).toBeInTheDocument(); expect(screen.queryByText("Night")).not.toBeInTheDocument(); }); + + it("meets the 44px touch-target floor when rendered icon-only", () => { + render(); + const btn = screen.getByRole("button", { name: "Switch to night mode" }); + const style = getComputedStyle(btn); + expect(style.minWidth).toBe("44px"); + expect(style.minHeight).toBe("44px"); + }); }); // --------------------------------------------------------------------------- diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx index 182dc3767..017fef0c8 100644 --- a/web/src/components/ThemeToggle.tsx +++ b/web/src/components/ThemeToggle.tsx @@ -35,7 +35,13 @@ export function ThemeToggle({ if (!showLabel) { return ( - + {icon} ); diff --git a/web/src/farm/useLogoObjectUrl.ts b/web/src/farm/useLogoObjectUrl.ts index 664759430..63b825c1c 100644 --- a/web/src/farm/useLogoObjectUrl.ts +++ b/web/src/farm/useLogoObjectUrl.ts @@ -27,6 +27,7 @@ export interface FarmLogoImage { function useImageObjectUrl( hash: string | null, fetchImage: () => Promise<{ blob: Blob }>, + onBlob?: (blob: Blob) => void, ): FarmLogoImage { const [url, setUrl] = useState(null); // The hash whose request has finished, whichever way it went. Compared @@ -53,6 +54,7 @@ function useImageObjectUrl( objectUrl = URL.createObjectURL(blob); setUrl(objectUrl); setSettledHash(hash); + onBlob?.(blob); }) .catch(() => { if (cancelled) return; @@ -93,8 +95,6 @@ export function useLogoObjectUrl(logoHash: string | null): FarmLogoImage { return useImageObjectUrl(logoHash, getFarmLogo); } -// The farm banner as an object URL (#179) — same contract as the logo above, -// for the post-login splash. -export function useBannerObjectUrl(bannerHash: string | null): FarmLogoImage { - return useImageObjectUrl(bannerHash, getFarmBanner); +export function useBannerObjectUrl(bannerHash: string | null, onBlob?: (blob: Blob) => void): FarmLogoImage { + return useImageObjectUrl(bannerHash, getFarmBanner, onBlob); } diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 22b810525..8119701aa 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -74,6 +74,10 @@ export const en = { }, auth: { title: "Cluckwork", + shellEyebrow: "Poultry farm management", + shellTagline: "Daily entry · Stock · Sales", + loginShellFooter: "Farm code identifies the farm you are signing into.", + setPasswordShellFooter: "Set your password before entering the farm.", farmCode: "Farm code", // #535 — the device remembers every farm code that has SUCCESSFULLY signed // in here, so a phone shared between farms offers them instead of retyping. @@ -144,6 +148,7 @@ export const en = { setPasswordTooShortError: "The new password must be at least {{min}} characters.", }, account: { + eyebrow: "Personal preferences", preferences: "Preferences", language: "Language", languageHint: "The language the interface is shown in, just for you.", @@ -171,14 +176,11 @@ export const en = { changePasswordHint: "Changing your password signs you out everywhere else — this device " + "stays signed in.", - // The trailing " *" is folded into the label text itself, matching how - // UsersPage's emailFieldLabel/newPasswordFieldLabel already handle a - // required-field marker — never a standalone "*" key. - currentPasswordLabel: "Current password *", + currentPasswordLabel: "Current password", // {{min}} is MIN_LENGTH (AccountPage.tsx) — interpolated, not baked in, // so the label and the validation message below can never drift apart. - newPasswordLabel: "New password (min {{min}} chars) *", - confirmPasswordLabel: "Confirm new password *", + newPasswordLabel: "New password (min {{min}} chars)", + confirmPasswordLabel: "Confirm new password", changePasswordButton: "Change password", passwordMismatchError: "The new passwords don't match.", passwordTooShortError: "The new password must be at least {{min}} characters.", @@ -1610,12 +1612,16 @@ export const en = { // is their only render site), so their display names are flat keys here // instead — see the PALETTE_LABEL_KEYS map in SettingsPage.tsx. settings: { + eyebrow: "Farm configuration", heading: "Farm settings", intro: "How this farm names itself, and the locale, timezone and currency it " + "records and reads its work in.", loadFailedMessage: "Could not load farm settings.", + identityImagesHeading: "Identity & images", + imageGuidanceHeading: "Image guidance", + // Logo panel logoSectionHeading: "Logo", logoAlt: "Current farm logo", @@ -1675,6 +1681,8 @@ export const en = { // Localization form localizationSectionHeading: "Localization", + countingSalesSectionHeading: "Counting & sales", + dateTimeFormatsSectionHeading: "Date & time formats", farmNameLabel: "Farm name", timezoneLabel: "Timezone", timezoneUnknownWarning: @@ -1743,6 +1751,7 @@ export const en = { + "system and the format overrides are recorded against the farm and " + "will drive how amounts, dates and measurements are displayed once " + "that formatting lands.", + saveScopeNote: "Image actions are separate from Save settings.", savedMessage: "Settings saved.", // Imperative messages (event handlers — see CONTRIBUTING-i18n.md's @@ -2309,6 +2318,7 @@ export const en = { // em-dash fallback for a null reason (same convention as // customers/expenses/users/flocks/history's raw "—"). audit: { + eyebrow: "Read-only record", heading: "Audit log", intro: "Every corrective, destructive, or configuration change — who did it, " @@ -2324,6 +2334,9 @@ export const en = { // loaded, or if the entity has zero audit events — deliberately not // distinguishing those two cases (Gate 3). scopedHeadingFallback: "Record history", + utcTimestampsCaption: "UTC timestamps", + scopeRetainedCaption: "Record scope retained when clearing filters", + previewRecordHistoryLabel: "Preview one record's history", entityTypeFilterLabel: "Record type", allEntityTypesOption: "All types", actionFilterLabel: "Action", @@ -2370,6 +2383,7 @@ export const en = { // stay raw, unkeyed — they're functional identifiers, not display copy, and // changing them would be a download-mechanics change (out of scope). export: { + eyebrow: "Your farm data", heading: "Export", intro: "Download your account's data as CSV files — a manual backup you can " @@ -2379,12 +2393,12 @@ export const en = { fullBackupHeading: "Full backup", fullBackupButton: "Download full backup (zip)", fullBackupHint: "One zip with every dataset below plus a manifest of row counts.", - // Shared between the full-backup button and every dataset button (each - // uses its own `busy === ` check) — one in-flight-download label, - // not a per-button duplicate. preparingButton: "Preparing…", singleDatasetsHeading: "Single datasets", + datasetHint: "Select one dataset. The full backup remains available above.", + datasetLabel: "Dataset", + downloadCsvButton: "Download CSV", // Dataset picker labels — one flat "dataset." key per // EXPORT_DATASETS member (../api/cluckwork), text IDENTICAL to the raw @@ -2650,33 +2664,19 @@ export const en = { lead: "How Cluckwork works, screen by screen — and how to undo mistakes.", contentsAriaLabel: "Help contents", contentsEyebrow: "Contents", - // #657 — search, grouped rail and grouped glossary. + // #657 — search, the contents rail and glossary. searchLabel: "Search the guide", searchPlaceholder: "Type a term or a topic", searchClear: "Clear search", searchMatches: "Matches for “{{query}}”: {{sections}} in the guide, {{terms}} in the glossary.", searchNoMatches: "Nothing matches “{{query}}”.", searchShortcutHint: "Press / to search", - glossaryJumpAriaLabel: "Glossary groups", openScreen: "Open {{screen}}", - railGroupStartHere: "Start here", - railGroupEveryDay: "Every day", - railGroupSelling: "Selling", - railGroupSupplies: "Supplies", - railGroupFarm: "Farm & people", - railGroupApp: "The app", - glossaryGroupGettingAround: "Getting around", - glossaryGroupSigningIn: "Signing in & who can do what", - glossaryGroupFlocksEntry: "Flocks & daily entry", - glossaryGroupEggsStock: "Eggs, grades & stock", - glossaryGroupSalesMoney: "Sales & money", - glossaryGroupSupplies: "Feed, water & supplies", - glossaryGroupFarm: "Farm settings & branding", // Contents-rail labels (TOC array, 2nd element). Order mirrors the

sections below — see the KEEP comment at the top of // HelpPage.tsx. tocGlossary is the rail's link text only; the glossary - // SECTION itself (heading + 37-row table + closing note) is externalized + // section itself (heading + disclosures + closing note) is externalized // further down, near the end of this block (Task 33, B6b). tocGettingAround: "Getting around", tocDashboard: "Dashboard", @@ -3784,7 +3784,13 @@ export const en = { "A second, independent image shown full-size on a splash screen right after signing in, once per " + "sign-in. Separate from the farm logo above — a farm can have a logo, a banner, both, or neither. " + "Same PNG/JPEG/WebP still-image rules, its own larger size limit (5 MB by default), uploaded from " - + "Farm settings.", + + "Farm settings. The device that shows it caches the image after that splash, so on a later visit " + + "it can also appear on the sign-in screen before entering a password — but only while the farm " + + "code field names that same farm; typing or picking a different code hides it right away, and a " + + "link that only names a farm code never shows its banner. If a farm code is later reassigned to a " + + "different farm, a device that already cached the previous holder's banner may still show it " + + "briefly, until that device's next sign-in confirms which farm the code belongs to now and clears " + + "the mismatch — never a new disclosure, only a stale image on a device that already had it.", glossaryFarmPaletteTerm: "Farm palette", glossaryFarmPaletteDef: diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index e9212fe07..251446172 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -44,6 +44,10 @@ export const es = { }, auth: { title: "Cluckwork", + shellEyebrow: "Gestión de granjas avícolas", + shellTagline: "Registro diario · Existencias · Ventas", + loginShellFooter: "El código de granja identifica la granja en la que está iniciando sesión.", + setPasswordShellFooter: "Establezca su contraseña antes de entrar a la granja.", // machine-drafted (#532) — pending native review. farmCode: "Código de granja", recentFarms: "Granjas recientes", @@ -94,6 +98,7 @@ export const es = { setPasswordTooShortError: "La nueva contraseña debe tener al menos {{min}} caracteres.", }, account: { + eyebrow: "Preferencias personales", preferences: "Preferencias", language: "Idioma", languageHint: "El idioma en que se muestra la interfaz, solo para usted.", @@ -114,9 +119,9 @@ export const es = { changePasswordHint: "Al cambiar su contraseña se cerrará la sesión en todos los demás " + "dispositivos — este dispositivo seguirá con la sesión iniciada.", - currentPasswordLabel: "Contraseña actual *", - newPasswordLabel: "Nueva contraseña (mín. {{min}} caracteres) *", - confirmPasswordLabel: "Confirmar nueva contraseña *", + currentPasswordLabel: "Contraseña actual", + newPasswordLabel: "Nueva contraseña (mín. {{min}} caracteres)", + confirmPasswordLabel: "Confirmar nueva contraseña", changePasswordButton: "Cambiar contraseña", passwordMismatchError: "Las nuevas contraseñas no coinciden.", passwordTooShortError: "La nueva contraseña debe tener al menos {{min}} caracteres.", @@ -1182,12 +1187,16 @@ export const es = { // {{cap}}/{{actualKb}}/{{limitKb}}/{{code}} placeholders and the // tag in logoSquareHint. settings: { + eyebrow: "Configuración de la granja", heading: "Configuración de la granja", intro: "Cómo se llama esta granja, y la configuración regional, la zona " + "horaria y la moneda en que registra y lee su trabajo.", loadFailedMessage: "No se pudo cargar la configuración de la granja.", + identityImagesHeading: "Identidad e imágenes", + imageGuidanceHeading: "Guía de imágenes", + // Logo panel logoSectionHeading: "Logotipo", logoAlt: "Logotipo actual de la granja", @@ -1243,6 +1252,8 @@ export const es = { // Localization form localizationSectionHeading: "Localización", + countingSalesSectionHeading: "Conteo y ventas", + dateTimeFormatsSectionHeading: "Formatos de fecha y hora", farmNameLabel: "Nombre de la granja", timezoneLabel: "Zona horaria", timezoneUnknownWarning: @@ -1297,6 +1308,7 @@ export const es = { + "personalizados se registran contra la granja y determinarán cómo " + "se muestran los montos, las fechas y las medidas una vez que ese " + "formato esté disponible.", + saveScopeNote: "Las acciones sobre las imágenes son independientes de Guardar configuración.", savedMessage: "Configuración guardada.", // Imperative messages @@ -1684,6 +1696,7 @@ export const es = { // namespace — the action/entity table cells route through the already- // translated enums:auditAction.*/entityType.* labels, not this namespace). audit: { + eyebrow: "Registro de solo lectura", heading: "Registro de auditoría", intro: "Todo cambio correctivo, destructivo o de configuración — quién lo " @@ -1691,6 +1704,9 @@ export const es = { + "mismo y nunca se editan.", scopedHeading: "Historial de {{entityType}}", scopedHeadingFallback: "Historial del registro", + utcTimestampsCaption: "Marcas de tiempo UTC", + scopeRetainedCaption: "El alcance del registro se conserva al borrar los filtros", + previewRecordHistoryLabel: "Vista previa del historial de un registro", entityTypeFilterLabel: "Tipo de registro", allEntityTypesOption: "Todos los tipos", actionFilterLabel: "Acción", @@ -1724,6 +1740,7 @@ export const es = { // picker (the wire value stays the raw slug) — translated to natural // Spanish per the task brief. export: { + eyebrow: "Los datos de su granja", heading: "Exportar", intro: "Descargue los datos de su cuenta como archivos CSV — una copia de " @@ -1737,6 +1754,9 @@ export const es = { preparingButton: "Preparando…", singleDatasetsHeading: "Conjuntos de datos individuales", + datasetHint: "Seleccione un conjunto de datos. La copia de seguridad completa sigue disponible arriba.", + datasetLabel: "Conjunto de datos", + downloadCsvButton: "Descargar CSV", "dataset.flocks": "Parvadas", "dataset.bird-movements": "Movimientos de aves", @@ -1967,28 +1987,14 @@ export const es = { lead: "Cómo funciona Cluckwork, pantalla por pantalla — y cómo deshacer errores.", contentsAriaLabel: "Contenido de la ayuda", contentsEyebrow: "Contenido", - // #657 — búsqueda, índice agrupado y glosario agrupado. + // #657 — búsqueda, índice y glosario. searchLabel: "Buscar en la guía", searchPlaceholder: "Escriba un término o un tema", searchClear: "Borrar búsqueda", searchMatches: "Coincidencias para “{{query}}”: {{sections}} en la guía, {{terms}} en el glosario.", searchNoMatches: "Nada coincide con “{{query}}”.", searchShortcutHint: "Pulse / para buscar", - glossaryJumpAriaLabel: "Grupos del glosario", openScreen: "Abrir {{screen}}", - railGroupStartHere: "Para empezar", - railGroupEveryDay: "Cada día", - railGroupSelling: "Ventas", - railGroupSupplies: "Suministros", - railGroupFarm: "Granja y personas", - railGroupApp: "La aplicación", - glossaryGroupGettingAround: "Moverse por la aplicación", - glossaryGroupSigningIn: "Inicio de sesión y permisos", - glossaryGroupFlocksEntry: "Parvadas y registro diario", - glossaryGroupEggsStock: "Huevos, calidades y existencias", - glossaryGroupSalesMoney: "Ventas y dinero", - glossaryGroupSupplies: "Alimento, agua y suministros", - glossaryGroupFarm: "Configuración e identidad de la granja", tocGettingAround: "Cómo moverse", tocDashboard: "Panel", @@ -3150,7 +3156,15 @@ export const es = { + "justo después de iniciar sesión, una vez por sesión. Separada del logotipo de la granja anterior " + "— una granja puede tener un logotipo, un banner, ambos, o ninguno. Mismas reglas de imagen fija " + "PNG/JPEG/WebP, su propio límite de tamaño más grande (5 MB por defecto), se sube desde " - + "Configuración de la granja.", + + "Configuración de la granja. El dispositivo que la muestra guarda la imagen en caché después de esa " + + "pantalla de bienvenida, así que en una visita posterior también puede aparecer en la pantalla de " + + "inicio de sesión antes de ingresar una contraseña — pero solo mientras el campo de código de granja " + + "nombre esa misma granja; escribir o elegir un código distinto lo oculta de inmediato, y un enlace " + + "que solo nombra un código de granja nunca muestra su banner. Si un código de granja se reasigna " + + "después a otra granja, un dispositivo que ya guardó en caché el banner del titular anterior puede " + + "seguir mostrándolo brevemente, hasta que el siguiente inicio de sesión de ese dispositivo confirme " + + "a qué granja pertenece el código ahora y corrija la discrepancia — nunca una divulgación nueva, " + + "solo una imagen obsoleta en un dispositivo que ya la tenía.", glossaryFarmPaletteTerm: "Paleta de la granja", glossaryFarmPaletteDef: diff --git a/web/src/i18n/tl.ts b/web/src/i18n/tl.ts index e0f983307..031d02aeb 100644 --- a/web/src/i18n/tl.ts +++ b/web/src/i18n/tl.ts @@ -50,6 +50,10 @@ export const tl = { }, auth: { title: "Cluckwork", + shellEyebrow: "Pamamahala ng poultry farm", + shellTagline: "Araw-araw na entry · Stock · Sales", + loginShellFooter: "Kinikilala ng farm code kung aling farm ang iyong sinasabihan.", + setPasswordShellFooter: "Itakda ang iyong password bago pumasok sa farm.", // machine-drafted (#532) — pending native review. farmCode: "Code ng bukid", recentFarms: "Mga kamakailang bukid", @@ -100,6 +104,7 @@ export const tl = { setPasswordTooShortError: "Dapat hindi bababa sa {{min}} karakter ang bagong password.", }, account: { + eyebrow: "Personal na kagustuhan", preferences: "Mga Kagustuhan", language: "Wika", languageHint: "Ang wikang gagamitin sa interface, para lang sa iyo.", @@ -123,9 +128,9 @@ export const tl = { changePasswordHint: "Kapag pinalitan mo ang iyong password, ma-si-sign out ka sa lahat " + "ng ibang device — mananatiling naka-sign in ang device na ito.", - currentPasswordLabel: "Kasalukuyang password *", - newPasswordLabel: "Bagong password (min {{min}} na karakter) *", - confirmPasswordLabel: "Kumpirmahin ang bagong password *", + currentPasswordLabel: "Kasalukuyang password", + newPasswordLabel: "Bagong password (min {{min}} na karakter)", + confirmPasswordLabel: "Kumpirmahin ang bagong password", changePasswordButton: "Palitan ang password", passwordMismatchError: "Hindi magkatugma ang mga bagong password.", passwordTooShortError: "Dapat hindi bababa sa {{min}} na karakter ang bagong password.", @@ -1232,12 +1237,16 @@ export const tl = { // common use in PH tech UIs for these; same treatment as sales.reference // above. settings: { + eyebrow: "Konpigurasyon ng bukid", heading: "Mga setting ng bukid", intro: "Kung paano tinatawag ng bukid na ito ang sarili nito, at ang locale, " + "time zone, at currency na ginagamit nito sa pagtala at pagbasa ng trabaho.", loadFailedMessage: "Hindi na-load ang mga setting ng bukid.", + identityImagesHeading: "Pagkakakilanlan at mga larawan", + imageGuidanceHeading: "Gabay sa larawan", + // Logo panel logoSectionHeading: "Logo", logoAlt: "Kasalukuyang logo ng bukid", @@ -1294,6 +1303,8 @@ export const tl = { // Localization form localizationSectionHeading: "Localization", + countingSalesSectionHeading: "Pagbibilang at mga benta", + dateTimeFormatsSectionHeading: "Mga format ng petsa at oras", farmNameLabel: "Pangalan ng bukid", timezoneLabel: "Time zone", timezoneUnknownWarning: @@ -1351,6 +1362,7 @@ export const tl = { + "sa format ay naitala laban sa bukid at magtatakda kung paano " + "ipapakita ang mga halaga, petsa, at sukat kapag dumating na ang " + "format na iyon.", + saveScopeNote: "Hiwalay ang mga aksyon sa larawan mula sa I-save ang mga setting.", savedMessage: "Na-save ang mga setting.", // Imperative messages @@ -1752,6 +1764,7 @@ export const tl = { // elsewhere in this file, e.g. sales.confirmOrderButton); "Audit Log" // kept as a loanword heading, same treatment as settings.localeLabel. audit: { + eyebrow: "Rekord na read-only", heading: "Audit Log", intro: "Bawat corrective, destructive, o configuration na pagbabago — sino " @@ -1759,6 +1772,9 @@ export const tl = { + "pagbabago mismo at hindi na ito ine-edit pa.", scopedHeading: "Kasaysayan ng {{entityType}}", scopedHeadingFallback: "Kasaysayan ng record", + utcTimestampsCaption: "Mga UTC timestamp", + scopeRetainedCaption: "Nananatili ang saklaw ng record kapag nililinis ang mga filter", + previewRecordHistoryLabel: "I-preview ang kasaysayan ng isang record", entityTypeFilterLabel: "Uri ng record", allEntityTypesOption: "Lahat ng uri", actionFilterLabel: "Aksyon", @@ -1792,6 +1808,7 @@ export const tl = { // dataset picker (the wire value stays the raw slug) — translated to // natural Filipino per the task brief. export: { + eyebrow: "Ang datos ng iyong farm", heading: "Export", intro: "I-download ang datos ng iyong account bilang mga CSV file — isang " @@ -1805,6 +1822,9 @@ export const tl = { preparingButton: "Naghahanda…", singleDatasetsHeading: "Mga indibidwal na dataset", + datasetHint: "Pumili ng isang dataset. Available pa rin sa itaas ang buong backup.", + datasetLabel: "Dataset", + downloadCsvButton: "I-download ang CSV", "dataset.flocks": "Mga kawan", "dataset.bird-movements": "Mga paggalaw ng manok", @@ -2050,28 +2070,14 @@ export const tl = { lead: "Kung paano gumagana ang Cluckwork, screen by screen — at kung paano mo aayusin ang mga pagkakamali.", contentsAriaLabel: "Nilalaman ng tulong", contentsEyebrow: "Nilalaman", - // #657 — paghahanap, nakagrupong index at nakagrupong glossary. + // #657 — paghahanap, index at glossary. searchLabel: "Maghanap sa gabay", searchPlaceholder: "Mag-type ng termino o paksa", searchClear: "I-clear ang paghahanap", searchMatches: "Mga tugma para sa “{{query}}”: {{sections}} sa gabay, {{terms}} sa glossary.", searchNoMatches: "Walang tumutugma sa “{{query}}”.", searchShortcutHint: "Pindutin ang / para maghanap", - glossaryJumpAriaLabel: "Mga grupo ng glossary", openScreen: "Buksan ang {{screen}}", - railGroupStartHere: "Magsimula rito", - railGroupEveryDay: "Araw-araw", - railGroupSelling: "Pagbebenta", - railGroupSupplies: "Mga suplay", - railGroupFarm: "Farm at mga tao", - railGroupApp: "Ang app", - glossaryGroupGettingAround: "Paglilibot sa app", - glossaryGroupSigningIn: "Pag-sign in at kung sino ang may pahintulot", - glossaryGroupFlocksEntry: "Mga flock at pang-araw-araw na entry", - glossaryGroupEggsStock: "Mga itlog, grade at stock", - glossaryGroupSalesMoney: "Benta at pera", - glossaryGroupSupplies: "Feed, tubig at mga suplay", - glossaryGroupFarm: "Mga setting at branding ng farm", tocGettingAround: "Pag-navigate", tocDashboard: "Dashboard", @@ -3269,7 +3275,15 @@ export const tl = { + "pagkatapos mag-log in, isang beses kada session. Hiwalay sa logo ng bukid sa itaas — puwedeng " + "magkaroon ang isang bukid ng logo, banner, pareho, o wala. Parehong panuntunan ng still image " + "na PNG/JPEG/WebP, may sarili itong mas malaking limitasyon sa laki (5 MB bilang default), " - + "ina-upload mula sa Mga Setting ng Bukid.", + + "ina-upload mula sa Mga Setting ng Bukid. Ang device na nagpapakita nito ay nag-ka-cache ng larawan " + + "pagkatapos ng splash screen na iyon, kaya sa susunod na pagbisita puwede rin itong lumabas sa " + + "sign-in screen bago pa maglagay ng password — pero habang pinangalanan ng field na Code ng Bukid " + + "ang parehong bukid na iyon; ang pag-type o pagpili ng ibang code ay agad itong nagtatago, at ang " + + "link na pangalan lang ng Code ng Bukid ay hindi kailanman nagpapakita ng banner nito. Kapag ang " + + "isang Code ng Bukid ay na-reassign sa ibang bukid, ang device na naka-cache na ng banner ng dating may-ari nito ay " + + "puwede pa ring magpakita nito nang maikli, hanggang sa kumpirmahin ng susunod na pag-sign-in sa " + + "device na iyon kung aling bukid na ngayon ang may-ari ng code at itama ang di-pagtutugma — hindi " + + "ito bagong pagsisiwalat, isang lumang larawan lamang sa device na mayroon na rito.", glossaryFarmPaletteTerm: "Paleta ng Bukid", glossaryFarmPaletteDef: diff --git a/web/src/lib/bannerCache.test.ts b/web/src/lib/bannerCache.test.ts new file mode 100644 index 000000000..4df5bad0d --- /dev/null +++ b/web/src/lib/bannerCache.test.ts @@ -0,0 +1,423 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + bannerKeyFor, cacheBannerBytes, clearBannerIfWrongAccount, forgetBannerFor, readCachedBannerBlob, +} from "./bannerCache"; +import { bindAccount, bindFarm, clearBoundAccount, farmBindingToken } from "../auth/tokenStore"; +import * as tokenStore from "../auth/tokenStore"; + +beforeEach(() => { + bindAccount("acct-A"); + bindFarm("sunny-acres"); +}); + +const blob = (data = "fake-image-bytes") => new Blob([data], { type: "image/png" }); + +async function blobText(b: Blob | null): Promise { + return b === null ? null : b.text(); +} + +function stubFailingIndexedDbOpen(error: Error | null = new Error("blocked")) { + return vi.spyOn(indexedDB, "open").mockImplementation(() => { + const req = new EventTarget() as unknown as IDBOpenDBRequest; + Object.defineProperty(req, "error", { value: error, configurable: true }); + queueMicrotask(() => req.onerror?.(new Event("error") as unknown as never)); + return req; + }); +} + +function stubAbortingTransaction(error: Error | null = new Error("aborted")) { + return vi.spyOn(indexedDB, "open").mockImplementation(() => { + const openReq = {} as IDBOpenDBRequest; + const db = { + transaction: () => { + const tx: Record = { + error, + objectStore: () => ({ + get: () => ({}) as IDBRequest, + delete: () => ({}) as IDBRequest, + }), + }; + queueMicrotask(() => (tx.onabort as (() => void) | undefined)?.()); + return tx as unknown as IDBTransaction; + }, + close: () => {}, + } as unknown as IDBDatabase; + Object.defineProperty(openReq, "result", { value: db, configurable: true }); + queueMicrotask(() => (openReq as unknown as { onsuccess?: () => void }).onsuccess?.()); + return openReq; + }); +} + +function stubGetRequestErrors(error: Error | null) { + return vi.spyOn(indexedDB, "open").mockImplementation(() => { + const openReq = {} as IDBOpenDBRequest; + const db = { + transaction: () => { + const tx: Record = { + objectStore: () => ({ + get: () => { + const req: Record = {}; + Object.defineProperty(req, "error", { value: error, configurable: true }); + queueMicrotask(() => (req.onerror as (() => void) | undefined)?.()); + return req as unknown as IDBRequest; + }, + }), + }; + return tx as unknown as IDBTransaction; + }, + close: () => {}, + } as unknown as IDBDatabase; + Object.defineProperty(openReq, "result", { value: db, configurable: true }); + queueMicrotask(() => (openReq as unknown as { onsuccess?: () => void }).onsuccess?.()); + return openReq; + }); +} + +function stubReadSucceedsThenWriteOutcome( + record: unknown, + outcome: "error" | "abort", + error: Error | null, +) { + return vi.spyOn(indexedDB, "open").mockImplementation(() => { + const openReq = {} as IDBOpenDBRequest; + const db = { + transaction: (_name: string, mode: string) => { + if (mode === "readonly") { + const tx: Record = { + objectStore: () => ({ + get: () => { + const req: Record = { result: record }; + queueMicrotask(() => { + (req.onsuccess as (() => void) | undefined)?.(); + queueMicrotask(() => (tx.oncomplete as (() => void) | undefined)?.()); + }); + return req as unknown as IDBRequest; + }, + }), + }; + return tx as unknown as IDBTransaction; + } + const tx: Record = { + error, + objectStore: () => ({ delete: () => ({}) as IDBRequest }), + }; + queueMicrotask(() => { + const handler = outcome === "error" ? tx.onerror : tx.onabort; + (handler as (() => void) | undefined)?.(); + }); + return tx as unknown as IDBTransaction; + }, + close: () => {}, + } as unknown as IDBDatabase; + Object.defineProperty(openReq, "result", { value: db, configurable: true }); + queueMicrotask(() => (openReq as unknown as { onsuccess?: () => void }).onsuccess?.()); + return openReq; + }); +} + +function stubWriteTransactionOutcome(outcome: "error" | "abort", error: Error | null) { + return vi.spyOn(indexedDB, "open").mockImplementation(() => { + const openReq = {} as IDBOpenDBRequest; + const db = { + transaction: () => { + const tx: Record = { + error, + objectStore: () => ({ + put: () => ({}) as IDBRequest, + delete: () => ({}) as IDBRequest, + }), + }; + queueMicrotask(() => { + const handler = outcome === "error" ? tx.onerror : tx.onabort; + (handler as (() => void) | undefined)?.(); + }); + return tx as unknown as IDBTransaction; + }, + close: () => {}, + } as unknown as IDBDatabase; + Object.defineProperty(openReq, "result", { value: db, configurable: true }); + queueMicrotask(() => (openReq as unknown as { onsuccess?: () => void }).onsuccess?.()); + return openReq; + }); +} + +function stubOpenFailsAfterNCalls(n: number) { + const realOpen = indexedDB.open.bind(indexedDB); + let calls = 0; + return vi.spyOn(indexedDB, "open").mockImplementation((name: string, version?: number) => { + calls += 1; + if (calls <= n) return realOpen(name, version); + const req = new EventTarget() as unknown as IDBOpenDBRequest; + Object.defineProperty(req, "error", { value: new Error("blocked"), configurable: true }); + queueMicrotask(() => req.onerror?.(new Event("error") as unknown as never)); + return req; + }); +} + +describe("bannerCache", () => { + describe("cacheBannerBytes — the cached path", () => { + it("caches the blob under the bound farm's key, readable back byte-for-byte", async () => { + await cacheBannerBytes(blob("hello"), farmBindingToken()); + const cached = await readCachedBannerBlob("sunny-acres"); + expect(await blobText(cached)).toBe("hello"); + }); + + it("does nothing when the token is stale (the tab rebound while the write was in flight)", async () => { + const staleToken = farmBindingToken(); + bindFarm("other-farm"); // rebinding changes the token + await cacheBannerBytes(blob(), staleToken); + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + expect(await readCachedBannerBlob("other-farm")).toBeNull(); + }); + + it("caches nothing when the tab is unbound (a fresh tab restored from the refresh cookie)", async () => { + clearBoundAccount(); + await cacheBannerBytes(blob(), farmBindingToken()); + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + }); + + it("leaves the previous banner in place when a replacement write errors", async () => { + await cacheBannerBytes(blob("original"), farmBindingToken()); + const spy = stubWriteTransactionOutcome("error", new Error("quota exceeded")); + await cacheBannerBytes(blob("replacement-that-fails"), farmBindingToken()); + spy.mockRestore(); + expect(await blobText(await readCachedBannerBlob("sunny-acres"))).toBe("original"); + }); + + it("leaves the previous banner in place when a replacement write ABORTS (not just errors)", async () => { + await cacheBannerBytes(blob("original"), farmBindingToken()); + const spy = stubWriteTransactionOutcome("abort", new Error("aborted")); + await cacheBannerBytes(blob("replacement-that-aborts"), farmBindingToken()); + spy.mockRestore(); + expect(await blobText(await readCachedBannerBlob("sunny-acres"))).toBe("original"); + }); + + it("still fails cleanly when a write errors or aborts with no error object", async () => { + let spy = stubWriteTransactionOutcome("error", null); + await expect(cacheBannerBytes(blob(), farmBindingToken())).resolves.not.toThrow(); + spy.mockRestore(); + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + + spy = stubWriteTransactionOutcome("abort", null); + await expect(cacheBannerBytes(blob(), farmBindingToken())).resolves.not.toThrow(); + spy.mockRestore(); + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + }); + + it("writes nothing when the token goes stale between reading the blob and writing it", async () => { + const tokenAtStart = farmBindingToken(); + const spy = vi.spyOn(tokenStore, "farmBindingToken"); + spy.mockReturnValueOnce(tokenAtStart).mockReturnValue("a-different-binding-now"); + await cacheBannerBytes(blob("never-written"), tokenAtStart); + spy.mockRestore(); + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + }); + + it("deletes the record it just wrote once the token no longer matches by the time the write itself completes", async () => { + const tokenAtStart = farmBindingToken(); + const spy = vi.spyOn(tokenStore, "farmBindingToken"); + spy + .mockReturnValueOnce(tokenAtStart) // entry guard + .mockReturnValueOnce(tokenAtStart) // post-arrayBuffer() guard — write proceeds + .mockReturnValue("a-different-binding-entirely"); // post-write guard — triggers cleanup + await cacheBannerBytes(blob("mid-write"), tokenAtStart); + spy.mockRestore(); + await vi.waitFor(async () => expect(await readCachedBannerBlob("sunny-acres")).toBeNull()); + }); + + it("swallows a failed cleanup delete after a mid-write rebind, leaving the just-written record in place", async () => { + const tokenAtStart = farmBindingToken(); + const tokenSpy = vi.spyOn(tokenStore, "farmBindingToken"); + tokenSpy + .mockReturnValueOnce(tokenAtStart) + .mockReturnValueOnce(tokenAtStart) + .mockReturnValue("a-different-binding-entirely"); + const openSpy = stubOpenFailsAfterNCalls(1); // the write's own open() succeeds; the cleanup delete's open() fails + await expect(cacheBannerBytes(blob("mid-write"), tokenAtStart)).resolves.not.toThrow(); + tokenSpy.mockRestore(); + openSpy.mockRestore(); + expect(await blobText(await readCachedBannerBlob("sunny-acres"))).toBe("mid-write"); + }); + + it("does nothing when IndexedDB does not exist on this device at all", async () => { + const original = indexedDB; + vi.stubGlobal("indexedDB", undefined); + await expect(cacheBannerBytes(blob(), farmBindingToken())).resolves.not.toThrow(); + vi.stubGlobal("indexedDB", original); + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + }); + + it("still fails cleanly when the open request errors with no error object", async () => { + const spy = stubFailingIndexedDbOpen(null); + await expect(cacheBannerBytes(blob(), farmBindingToken())).resolves.not.toThrow(); + spy.mockRestore(); + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + }); + + it("refuses a blob over the 15 MiB ceiling", async () => { + const oversized = new Blob([new Uint8Array(15 * 1024 * 1024 + 1)]); + await cacheBannerBytes(oversized, farmBindingToken()); + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + }); + + it("does nothing when IndexedDB is unavailable", async () => { + const spy = stubFailingIndexedDbOpen(); + await expect(cacheBannerBytes(blob(), farmBindingToken())).resolves.not.toThrow(); + spy.mockRestore(); + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + }); + + it("stores the bound account id alongside the blob", async () => { + await cacheBannerBytes(blob(), farmBindingToken()); + await clearBannerIfWrongAccount("sunny-acres", "acct-A"); + expect(await readCachedBannerBlob("sunny-acres")).not.toBeNull(); // matching id: untouched + }); + }); + + describe("readCachedBannerBlob — the first-visit and cached paths", () => { + it("first visit: returns null when nothing is cached for that farm code", async () => { + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + }); + + it("cached: returns the stored blob for a matching farm code", async () => { + await cacheBannerBytes(blob(), farmBindingToken()); + expect(await readCachedBannerBlob("sunny-acres")).not.toBeNull(); + }); + + it("returns null for a code that does not match any cached entry", async () => { + await cacheBannerBytes(blob(), farmBindingToken()); + expect(await readCachedBannerBlob("a-different-farm")).toBeNull(); + }); + + it("matches case-insensitively and trims surrounding whitespace, like the server's own lookup", async () => { + await cacheBannerBytes(blob(), farmBindingToken()); + expect(await readCachedBannerBlob(" Sunny-Acres ")).not.toBeNull(); + }); + + it("returns null for a blank field", async () => { + expect(await readCachedBannerBlob("")).toBeNull(); + expect(await readCachedBannerBlob(" ")).toBeNull(); + }); + + it("returns null when IndexedDB cannot be read", async () => { + await cacheBannerBytes(blob(), farmBindingToken()); + const spy = stubFailingIndexedDbOpen(); + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + spy.mockRestore(); + }); + + it("returns null (rather than hanging) when the read transaction aborts without erroring", async () => { + const spy = stubAbortingTransaction(); + await expect(readCachedBannerBlob("sunny-acres")).resolves.toBeNull(); + spy.mockRestore(); + }); + + it("returns null when the read transaction aborts with no error object", async () => { + const spy = stubAbortingTransaction(null); + await expect(readCachedBannerBlob("sunny-acres")).resolves.toBeNull(); + spy.mockRestore(); + }); + + it("returns null when the read request itself errors", async () => { + const spy = stubGetRequestErrors(new Error("boom")); + await expect(readCachedBannerBlob("sunny-acres")).resolves.toBeNull(); + spy.mockRestore(); + }); + + it("returns null when the read request errors with no error object", async () => { + const spy = stubGetRequestErrors(null); + await expect(readCachedBannerBlob("sunny-acres")).resolves.toBeNull(); + spy.mockRestore(); + }); + }); + + describe("forgetBannerFor — the forget path", () => { + it("removes the cached banner for the given farm", async () => { + await cacheBannerBytes(blob(), farmBindingToken()); + expect(await readCachedBannerBlob("sunny-acres")).not.toBeNull(); + forgetBannerFor("sunny-acres"); + await vi.waitFor(async () => expect(await readCachedBannerBlob("sunny-acres")).toBeNull()); + }); + + it("leaves a DIFFERENT farm's cached banner untouched", async () => { + await cacheBannerBytes(blob(), farmBindingToken()); + bindFarm("other-farm"); + await cacheBannerBytes(blob("other-bytes"), farmBindingToken()); + + forgetBannerFor("sunny-acres"); + await vi.waitFor(async () => expect(await readCachedBannerBlob("sunny-acres")).toBeNull()); + expect(await readCachedBannerBlob("other-farm")).not.toBeNull(); + }); + + it("does not throw when IndexedDB is unavailable", () => { + const spy = vi.spyOn(indexedDB, "open").mockImplementation(() => { + throw new Error("blocked"); + }); + expect(() => forgetBannerFor("sunny-acres")).not.toThrow(); + spy.mockRestore(); + }); + }); + + describe("clearBannerIfWrongAccount", () => { + it("deletes the entry when the signed-in account differs from the one that wrote it", async () => { + await cacheBannerBytes(blob(), farmBindingToken()); // written under acct-A + await clearBannerIfWrongAccount("sunny-acres", "acct-B"); + expect(await readCachedBannerBlob("sunny-acres")).toBeNull(); + }); + + it("leaves the entry when the signed-in account matches", async () => { + await cacheBannerBytes(blob(), farmBindingToken()); // written under acct-A + await clearBannerIfWrongAccount("sunny-acres", "acct-A"); + expect(await readCachedBannerBlob("sunny-acres")).not.toBeNull(); + }); + + it("does nothing when there is no entry to reconcile", async () => { + await expect(clearBannerIfWrongAccount("sunny-acres", "acct-A")).resolves.not.toThrow(); + }); + + it("does not throw when IndexedDB is unavailable", async () => { + const spy = vi.spyOn(indexedDB, "open").mockImplementation(() => { + throw new Error("blocked"); + }); + await expect(clearBannerIfWrongAccount("sunny-acres", "acct-A")).resolves.not.toThrow(); + spy.mockRestore(); + }); + + it("resolves (rather than hanging) when the mismatch is confirmed but the delete transaction aborts without erroring", async () => { + const spy = stubReadSucceedsThenWriteOutcome( + { accountId: "acct-B", bytes: new ArrayBuffer(0), type: "image/png" }, + "abort", + new Error("aborted"), + ); + await expect(clearBannerIfWrongAccount("sunny-acres", "acct-A")).resolves.not.toThrow(); + spy.mockRestore(); + }); + + it("resolves (rather than hanging) when the mismatch is confirmed but the delete transaction errors", async () => { + const spy = stubReadSucceedsThenWriteOutcome( + { accountId: "acct-B", bytes: new ArrayBuffer(0), type: "image/png" }, + "error", + new Error("boom"), + ); + await expect(clearBannerIfWrongAccount("sunny-acres", "acct-A")).resolves.not.toThrow(); + spy.mockRestore(); + }); + + it("resolves cleanly when the delete errors or aborts with no error object", async () => { + const record = { accountId: "acct-B", bytes: new ArrayBuffer(0), type: "image/png" }; + let spy = stubReadSucceedsThenWriteOutcome(record, "error", null); + await expect(clearBannerIfWrongAccount("sunny-acres", "acct-A")).resolves.not.toThrow(); + spy.mockRestore(); + + spy = stubReadSucceedsThenWriteOutcome(record, "abort", null); + await expect(clearBannerIfWrongAccount("sunny-acres", "acct-A")).resolves.not.toThrow(); + spy.mockRestore(); + }); + }); + + describe("bannerKeyFor", () => { + it("is currently the identity function over the slug", () => { + expect(bannerKeyFor("sunny-acres")).toBe("sunny-acres"); + }); + }); +}); diff --git a/web/src/lib/bannerCache.ts b/web/src/lib/bannerCache.ts new file mode 100644 index 000000000..48937bdab --- /dev/null +++ b/web/src/lib/bannerCache.ts @@ -0,0 +1,148 @@ +// Pre-auth banners use IndexedDB bytes so production CSP can render blob URLs. +// Account IDs disambiguate reusable farm codes when login confirms ownership. +import { useEffect, useState } from "react"; +import { farmBindingToken, getBoundAccountId, getBoundFarmCode } from "../auth/tokenStore"; + +const DB_NAME = "cluckwork-banners"; +const STORE_NAME = "banners"; +const DB_VERSION = 1; + +const MAX_BANNER_BYTES = 15 * 1024 * 1024; + +interface BannerRecord { + accountId: string; + bytes: ArrayBuffer; + type: string; +} + +export function bannerKeyFor(slug: string): string { + return slug; +} + +function normalize(value: string): string { + return value.trim().toLowerCase(); +} + +function openDb(): Promise { + if (typeof indexedDB === "undefined") return Promise.reject(new Error("IndexedDB unavailable")); + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error("IndexedDB open failed")); + }); +} + +async function putBanner(slug: string, record: BannerRecord): Promise { + const db = await openDb(); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).put(record, bannerKeyFor(slug)); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error("IndexedDB write failed")); + tx.onabort = () => reject(tx.error ?? new Error("IndexedDB write aborted")); + }); + } finally { + db.close(); + } +} + +async function getBannerRecord(slug: string): Promise { + const db = await openDb(); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const req = tx.objectStore(STORE_NAME).get(bannerKeyFor(slug)); + req.onsuccess = () => resolve((req.result as BannerRecord | undefined) ?? null); + req.onerror = () => reject(req.error ?? new Error("IndexedDB read failed")); + tx.onabort = () => reject(tx.error ?? new Error("IndexedDB read aborted")); + }); + } finally { + db.close(); + } +} + +async function deleteBannerRecord(slug: string): Promise { + const db = await openDb(); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).delete(bannerKeyFor(slug)); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error("IndexedDB delete failed")); + tx.onabort = () => reject(tx.error ?? new Error("IndexedDB delete aborted")); + }); + } finally { + db.close(); + } +} + +export function forgetBannerFor(slug: string): void { + void deleteBannerRecord(slug).catch(() => { + // Best-effort device cache cleanup. + }); +} + +export async function cacheBannerBytes(blob: Blob, tokenAt: string): Promise { + if (tokenAt !== farmBindingToken()) return; + if (blob.size > MAX_BANNER_BYTES) return; + const slug = getBoundFarmCode(); + const accountId = getBoundAccountId(); + if (slug === null || accountId === null) return; + try { + const bytes = await blob.arrayBuffer(); + if (tokenAt !== farmBindingToken()) return; + await putBanner(slug, { accountId, bytes, type: blob.type }); + } catch { + return; + } + if (tokenAt !== farmBindingToken()) { + void deleteBannerRecord(slug).catch(() => {}); + } +} + +export async function clearBannerIfWrongAccount(slug: string, signedInAccountId: string): Promise { + try { + const record = await getBannerRecord(slug); + if (record !== null && record.accountId !== signedInAccountId) { + await deleteBannerRecord(slug); + } + } catch { + // Keep authentication independent of optional device storage. + } +} + +export async function readCachedBannerBlob(typedFarmCode: string): Promise { + const slug = normalize(typedFarmCode); + if (slug === "") return null; + try { + const record = await getBannerRecord(slug); + return record === null ? null : new Blob([record.bytes], { type: record.type }); + } catch { + return null; + } +} + +export function useCachedBannerUrl(typedFarmCode: string): string | null { + const [url, setUrl] = useState(null); + + useEffect(() => { + let cancelled = false; + let objectUrl: string | null = null; + + void readCachedBannerBlob(typedFarmCode).then((blob) => { + if (cancelled || blob === null) return; + objectUrl = URL.createObjectURL(blob); + setUrl(objectUrl); + }); + + return () => { + cancelled = true; + setUrl(null); + if (objectUrl !== null) URL.revokeObjectURL(objectUrl); + }; + }, [typedFarmCode]); + + return url; +} diff --git a/web/src/routes/AccountPage.test.tsx b/web/src/routes/AccountPage.test.tsx index 4ea195eae..19f8a28f9 100644 --- a/web/src/routes/AccountPage.test.tsx +++ b/web/src/routes/AccountPage.test.tsx @@ -27,15 +27,39 @@ const WORKER = { sub: "u1", role: "Worker" }; beforeEach(() => vi.resetAllMocks()); +function expandChangePassword() { + const summary = screen.getAllByRole("button", { name: "Change password" }) + .find((button) => button.hasAttribute("aria-expanded")); + if (summary === undefined) throw new Error("Change password summary not found"); + if (summary.getAttribute("aria-expanded") !== "true") fireEvent.click(summary); +} + function fill(current: string, next: string, confirm: string) { + expandChangePassword(); fireEvent.change(screen.getByLabelText(/Current password/), { target: { value: current } }); fireEvent.change(screen.getByLabelText(/New password/), { target: { value: next } }); fireEvent.change(screen.getByLabelText(/Confirm new password/), { target: { value: confirm } }); } -const submit = () => fireEvent.click(screen.getByRole("button", { name: "Change password" })); +const submit = () => { + const buttons = screen.getAllByRole("button", { name: "Change password" }); + const submitButton = buttons.find((b) => !b.hasAttribute("aria-expanded")); + if (!submitButton) throw new Error("Change password submit button not found — is the section expanded?"); + fireEvent.click(submitButton); +}; describe("AccountPage (#165 self-service password change)", () => { + it("uses the Focus-panel width and opens Change password initially", () => { + renderWithProviders(, { token: WORKER }); + + const section = screen.getByRole("heading", { name: "Account" }).closest("section"); + expect(section).not.toBeNull(); + expect(getComputedStyle(section!).maxWidth).toBe("760px"); + const summary = screen.getAllByRole("button", { name: "Change password" }) + .find((button) => button.hasAttribute("aria-expanded")); + expect(summary).toHaveAttribute("aria-expanded", "true"); + }); + it("changes the password and reports the other devices were signed out", async () => { mockChangePassword.mockResolvedValue(undefined); renderWithProviders(, { token: WORKER }); @@ -89,7 +113,8 @@ describe("AccountPage (#165 self-service password change)", () => { it("renders for any role — every user can change their own password", async () => { renderWithProviders(, { token: { sub: "u1", role: "ReadOnly" } }); - expect(screen.getByRole("button", { name: "Change password" })).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Change password" }) + .some((button) => button.hasAttribute("aria-expanded"))).toBe(true); }); it("shows the Preferences section with the language selector now that more than one language pack is installed (#182)", async () => { @@ -173,15 +198,17 @@ describe("AccountPage i18n wiring (#182, Task 25)", () => { it("reads the change-password hint prose from the catalog, not a hardcoded literal", async () => { await withOverride("changePasswordHint", "CHANGE-PW-HINT-MARKER", async () => { renderWithProviders(, { token: WORKER }); + expandChangePassword(); expect(screen.getByText("CHANGE-PW-HINT-MARKER")).toBeInTheDocument(); expect(screen.queryByText(/signs you out everywhere else/)).not.toBeInTheDocument(); }); }); it("reads the current-password label from the catalog, not a hardcoded literal", async () => { - await withOverride("currentPasswordLabel", "CURRENT-PW-MARKER *", async () => { + await withOverride("currentPasswordLabel", "CURRENT-PW-MARKER", async () => { renderWithProviders(, { token: WORKER }); - expect(screen.getByLabelText(/CURRENT-PW-MARKER/)).toBeInTheDocument(); + expandChangePassword(); + expect(screen.getByLabelText("CURRENT-PW-MARKER *")).toBeRequired(); expect(screen.queryByLabelText(/^Current password/)).not.toBeInTheDocument(); }); }); @@ -189,18 +216,20 @@ describe("AccountPage i18n wiring (#182, Task 25)", () => { it("interpolates {{min}} into the new-password label from the catalog", async () => { await withOverride("newPasswordLabel", "NEW-PW-MARKER {{min}} MARKER-END", async () => { renderWithProviders(, { token: WORKER }); + expandChangePassword(); // MIN_LENGTH is 12 (AccountPage.tsx) — asserting the exact number, not // just that A number appears, is what would catch a mutation that // dropped the interpolation and always rendered a literal "12". - expect(screen.getByLabelText("NEW-PW-MARKER 12 MARKER-END")).toBeInTheDocument(); + expect(screen.getByLabelText("NEW-PW-MARKER 12 MARKER-END *")).toBeInTheDocument(); expect(screen.queryByLabelText(/^New password/)).not.toBeInTheDocument(); }); }); it("reads the confirm-password label from the catalog, not a hardcoded literal", async () => { - await withOverride("confirmPasswordLabel", "CONFIRM-PW-MARKER *", async () => { + await withOverride("confirmPasswordLabel", "CONFIRM-PW-MARKER", async () => { renderWithProviders(, { token: WORKER }); - expect(screen.getByLabelText(/CONFIRM-PW-MARKER/)).toBeInTheDocument(); + expandChangePassword(); + expect(screen.getByLabelText("CONFIRM-PW-MARKER *")).toBeRequired(); expect(screen.queryByLabelText(/^Confirm new password/)).not.toBeInTheDocument(); }); }); @@ -208,8 +237,11 @@ describe("AccountPage i18n wiring (#182, Task 25)", () => { it("reads the submit button label from the catalog, not a hardcoded literal", async () => { await withOverride("changePasswordButton", "SUBMIT-MARKER", async () => { renderWithProviders(, { token: WORKER }); + expandChangePassword(); expect(screen.getByRole("button", { name: "SUBMIT-MARKER" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Change password" })).not.toBeInTheDocument(); + const remaining = screen.getAllByRole("button", { name: "Change password" }); + expect(remaining).toHaveLength(1); + expect(remaining[0]).toHaveAttribute("aria-expanded", "true"); }); }); diff --git a/web/src/routes/AccountPage.tsx b/web/src/routes/AccountPage.tsx index 2bef797ab..1fa336f46 100644 --- a/web/src/routes/AccountPage.tsx +++ b/web/src/routes/AccountPage.tsx @@ -1,6 +1,10 @@ import { useState } from "react"; import type { FormEvent } from "react"; import { Trans, useTranslation } from "react-i18next"; +import { ChevronDown } from "lucide-react"; +import { + Accordion, AccordionDetails, AccordionSummary, Alert, Box, Stack, TextField, Typography, +} from "@mui/material"; import { changePassword, ApiError } from "../api/client"; import { useAuth } from "../auth/useAuth"; import { BusyButton } from "../components/BusyButton"; @@ -65,54 +69,77 @@ export function AccountPage() { } return ( -
-
-

{t("heading")}

-
-

+ + + {t("eyebrow")} + + {t("heading")} + }} /> -

+ -
-

{t("preferences")}

- {SUPPORTED_LANGUAGES.length > 1 && ( - <> -

{t("languageHint")}

- - - )} - {/* #444 — the pack unit YOUR Daily Entry steppers bump by, overriding - the farm default set in Settings. */} -

{t("stepperUnitHint")}

- -
+ + }> + {t("preferences")} + + + {SUPPORTED_LANGUAGES.length > 1 && ( + <> + {t("languageHint")} + + + )} + {/* #444 — the pack unit YOUR Daily Entry steppers bump by, overriding + the farm default set in Settings. */} + {t("stepperUnitHint")} + + + -

{t("changePasswordHeading")}

-

- {t("changePasswordHint")} -

-
- - - - {error &&

{error}

} - {message &&

{message}

} -
- {t("changePasswordButton")} -
-
-
+ + }> + {t("changePasswordHeading")} + + + + {t("changePasswordHint")} + + + setCurrent(e.target.value)} + slotProps={{ htmlInput: { maxLength: 256 } }} + /> + setNext(e.target.value)} + slotProps={{ htmlInput: { minLength: MIN_LENGTH, maxLength: 256 } }} + /> + setConfirm(e.target.value)} + slotProps={{ htmlInput: { maxLength: 256 } }} + /> + {error && {error}} + {message && {message}} + + {t("changePasswordButton")} + + + + + ); } diff --git a/web/src/routes/AppLayout.test.tsx b/web/src/routes/AppLayout.test.tsx index 89b49c0cb..f7c0d2714 100644 --- a/web/src/routes/AppLayout.test.tsx +++ b/web/src/routes/AppLayout.test.tsx @@ -2,12 +2,34 @@ import { lazy, type ReactElement } from "react"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { act, render, screen, fireEvent, within, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router"; +import { ThemeProvider } from "@mui/material/styles"; import { renderWithProviders } from "../test/renderWithProviders"; import { AuthProvider } from "../auth/AuthContext"; import { FarmContext } from "../farm/FarmContext"; import { account, farmState } from "../test/fixtures"; import i18n from "../i18n"; import { AppLayout } from "./AppLayout"; +import { DEFAULT_BRAND } from "../lib/brand"; +import { createFarmTheme } from "../theme/FarmThemeProvider"; +import { tokensFor } from "../theme/farmTokens.test"; + +function rgb(value: string): [number, number, number] { + const channels = value.match(/\d+(?:\.\d+)?/g)?.slice(0, 3).map(Number); + if (channels?.length !== 3) throw new Error(`Expected an RGB colour, received ${value}`); + return channels as [number, number, number]; +} + +function contrast(a: string, b: string): number { + const luminance = (value: string) => { + const [r, g, blue] = rgb(value).map((channel) => { + const s = channel / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * blue; + }; + const [first, second] = [luminance(a), luminance(b)]; + return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05); +} // The theme toggle writes data-theme on the document root — reset it between // tests so one case's choice can't bleed into the next. @@ -84,6 +106,22 @@ describe("AppLayout lazy route containment (#595)", () => { }); describe("AppLayout sidebar", () => { + it("renders the selected destination as a contrasting filled row", () => { + const theme = createFarmTheme(tokensFor(DEFAULT_BRAND, "light"), "light"); + renderWithProviders( + , + { route: "/stock", token: { sub: "u1", role: "Admin" } }, + ); + + const selected = sidebar().getByRole("link", { name: "Stock" }); + const drawer = selected.closest(".MuiDrawer-paper"); + expect(drawer).not.toBeNull(); + const selectedStyle = getComputedStyle(selected); + const drawerStyle = getComputedStyle(drawer!); + expect(selectedStyle.backgroundColor).not.toBe(drawerStyle.backgroundColor); + expect(contrast(selectedStyle.color, selectedStyle.backgroundColor)).toBeGreaterThanOrEqual(4.5); + }); + it("groups the nav and gates links by role — an admin sees Setup destinations", () => { renderWithProviders(, { token: { sub: "u1", role: "Admin" } }); expect(sidebar().getByRole("link", { name: "Dashboard" })).toBeInTheDocument(); diff --git a/web/src/routes/AppLayout.tsx b/web/src/routes/AppLayout.tsx index cc81ede95..4fb857345 100644 --- a/web/src/routes/AppLayout.tsx +++ b/web/src/routes/AppLayout.tsx @@ -88,8 +88,7 @@ export function AppLayout() { ...(location.pathname === "/" ? { bgcolor: "var(--brand)", color: "var(--on-brand)", "& .brand, & .brand-name, & .MuiListItemIcon-root, & .nav-group-label, & .MuiTypography-root": { color: "inherit" }, - "& .Mui-selected, & .Mui-selected:hover": { bgcolor: "var(--on-brand)", color: "var(--brand)", borderLeftColor: "var(--on-brand)" }, - "& .MuiListItemButton-root:hover": { bgcolor: "var(--brand-press)" }, + "& .MuiListItemButton-root:not(.Mui-selected):hover": { bgcolor: "var(--brand-press)" }, "& :focus-visible": { outlineColor: "var(--on-brand)" }, } : {}), }, diff --git a/web/src/routes/AuditPage.test.tsx b/web/src/routes/AuditPage.test.tsx index d31cca4c4..d9a6ff99e 100644 --- a/web/src/routes/AuditPage.test.tsx +++ b/web/src/routes/AuditPage.test.tsx @@ -1,4 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { render, screen, within, act, fireEvent, waitFor } from "@testing-library/react"; import { Link, MemoryRouter, Route, Routes, useLocation, useNavigate } from "react-router"; import { AuditPage, isFetchStale } from "./AuditPage"; @@ -25,6 +27,13 @@ vi.mock("../api/cluckwork", () => ({ })); const mockListAuditEvents = vi.mocked(listAuditEvents); +const stylesheet = readFileSync(resolve(process.cwd(), "src/styles.css"), "utf8"); + +function auditFilterColumnCountAt(width: number) { + const base = /\.audit-filters\s*\{[^}]*grid-template-columns:\s*repeat\((\d+),/.exec(stylesheet)?.[1]; + const phone = /@media\s*\(max-width:\s*900px\)\s*\{[\s\S]*?\.audit-filters\s*\{[^}]*grid-template-columns:\s*repeat\((\d+),/.exec(stylesheet)?.[1]; + return Number(width <= 900 ? phone : base); +} function renderAudit(route = "/audit") { return render( @@ -34,6 +43,16 @@ function renderAudit(route = "/audit") { ); } +function expandPanel(panel: HTMLElement) { + fireEvent.click(getPanelSummary(panel)); +} + +function getPanelSummary(panel: HTMLElement) { + const summary = panel.querySelector("summary"); + if (!summary) throw new Error("Audit panel has no summary"); + return summary; +} + function LocationProbe() { const loc = useLocation(); const navigate = useNavigate(); @@ -82,6 +101,30 @@ beforeEach(() => { }); describe("AuditPage load + render", () => { + it("renders expandable event panels with timestamp and action in the summary", async () => { + mockListAuditEvents.mockResolvedValue([EVENT_A]); + renderAudit(); + + const panel = await screen.findByRole("article", { description: /admin@farm\.test/ }); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + const summary = getPanelSummary(panel); + expect(summary).toHaveTextContent("2026-07-19 14:30:05 UTC · Flock depleted"); + expect(panel).not.toHaveAttribute("open"); + fireEvent.click(summary); + expect(within(panel).getByText("admin@farm.test")).toBeInTheDocument(); + expect(within(panel).getByText("Flock f1234567")).toBeInTheDocument(); + expect(within(panel).getByText("culled sick birds")).toBeInTheDocument(); + }); + + it("lays out filters two across on phones and four across on desktop", async () => { + renderAudit(); + await screen.findByText("No audit events yet."); + + expect(screen.getByTestId("audit-filters")).toHaveClass("audit-filters"); + expect(auditFilterColumnCountAt(390)).toBe(2); + expect(auditFilterColumnCountAt(1280)).toBe(4); + }); + it("shows a loading state until the first audit page resolves", async () => { let resolve!: (events: AuditEvent[]) => void; mockListAuditEvents.mockReturnValue(new Promise((r) => (resolve = r))); @@ -183,10 +226,9 @@ describe("AuditPage load + render", () => { mockListAuditEvents.mockResolvedValue([LOGO_EVENT]); renderAudit(); - const row = await screen.findByRole("row", { name: /admin@farm\.test/ }); - // Action cell: friendly label, not the raw "Account.SetLogo". + const row = await screen.findByRole("article", { description: /admin@farm\.test/ }); expect(within(row).getByText("Farm logo set")).toBeInTheDocument(); - // Entity cell: entityTypeLabel("FarmLogo") + first 8 chars of the id. + expandPanel(row); expect(within(row).getByText("Farm logo fl123456")).toBeInTheDocument(); expect(within(row).queryByText("FarmLogo fl123456")).not.toBeInTheDocument(); }); @@ -195,30 +237,33 @@ describe("AuditPage load + render", () => { mockListAuditEvents.mockResolvedValue([EVENT_A, EVENT_B]); renderAudit(); - const rowA = await screen.findByRole("row", { name: /admin@farm\.test/ }); + const rowA = await screen.findByRole("article", { description: /admin@farm\.test/ }); // occurredAtUtc: "T" → space, truncated to the first 19 chars (drops ms/Z). - expect(within(rowA).getByText("2026-07-19 14:30:05")).toBeInTheDocument(); - // Action cell renders auditActionLabel(e.action), not the raw code. - expect(within(rowA).getByText("Flock depleted")).toBeInTheDocument(); + expect(getPanelSummary(rowA)).toHaveTextContent("2026-07-19 14:30:05 UTC · Flock depleted"); expect(within(rowA).queryByText("Flock.Deplete")).not.toBeInTheDocument(); - // entityTypeLabel(entityType) + first 8 chars of entityId. "Flock" is an - // identity label (enums:entityType.Flock === "Flock"), so this also - // covers the entity cell reading through entityTypeLabel. + expandPanel(rowA); expect(within(rowA).getByText("Flock f1234567")).toBeInTheDocument(); expect(within(rowA).getByText("culled sick birds")).toBeInTheDocument(); - const rowB = screen.getByRole("row", { name: /manager@farm\.test/ }); + const rowB = screen.getByRole("article", { description: /manager@farm\.test/ }); + expandPanel(rowB); expect(within(rowB).getByText("User u9abcdef")).toBeInTheDocument(); - expect(within(rowB).getByText("—")).toBeInTheDocument(); // null reason → em dash + expect(rowB.querySelector(".audit-event-body")?.children).toHaveLength(3); }); it("renders a read-only view with no mutation controls (and no paging on a short page)", async () => { mockListAuditEvents.mockResolvedValue([EVENT_A, EVENT_B]); // 2 < PAGE ⇒ no 'load more' renderAudit(); - await screen.findByRole("row", { name: /admin@farm\.test/ }); + await screen.findByRole("article", { description: /admin@farm\.test/ }); // #93: the audit trail is deliberately read-only — no adjust/void/delete // controls — and 'load more' only appears when a full page came back. expect(screen.queryByRole("button")).not.toBeInTheDocument(); + const panels = screen.getAllByRole("article"); + expect(panels.map((panel) => getPanelSummary(panel).textContent)).toEqual([ + "2026-07-19 14:30:05 UTC · Flock depleted", + "2026-07-18 09:15:00 UTC · User created", + ]); + expect(panels.every((panel) => !panel.hasAttribute("open"))).toBe(true); }); // #758 — the four AddItem price cases. Each asserts the cell's LITERAL text, @@ -243,13 +288,10 @@ describe("AuditPage load + render", () => { }), }); - // The cell is a fragment of text nodes, so read the whole cell's textContent - // rather than matching one node — that is what pins the ORDER of the two - // prices, and it is the assertion the bug would have failed. const detailsText = async () => { - const row = await screen.findByRole("row", { name: /admin@farm\.test/ }); - const cells = within(row).getAllByRole("cell"); - return cells[cells.length - 1].textContent; + const row = await screen.findByRole("article", { description: /admin@farm\.test/ }); + expandPanel(row); + return row.querySelector(".audit-event-body")?.lastElementChild?.textContent; }; it("shows the SALE price, and the list price beside it, on a below-list AddItem row", async () => { @@ -322,7 +364,8 @@ describe("AuditPage load + render", () => { }]); renderAudit(); - const row = await screen.findByRole("row", { name: /admin@farm\.test/ }); + const row = await screen.findByRole("article", { description: /admin@farm\.test/ }); + expandPanel(row); expect(within(row).getByText("Medium Eggs", { exact: false })).toBeInTheDocument(); // The artboard strikes the OLD price and bolds the NEW one. Asserting the // element, not just the text, is what makes that a real check. @@ -350,23 +393,26 @@ describe("AuditPage load + render", () => { }]); renderAudit(); - const row = await screen.findByRole("row", { name: /admin@farm\.test/ }); + const row = await screen.findByRole("article", { description: /admin@farm\.test/ }); + expandPanel(row); // #720: a null list price is "no list price", never a zero discount. expect(within(row).getByText(/no list price/i)).toBeInTheDocument(); expect(within(row).queryByText("$0.00")).not.toBeInTheDocument(); }); - it("falls back to the reason, then an em dash, and never throws on a malformed payload", async () => { + it("falls back to the reason and omits empty details without throwing on malformed payloads", async () => { mockListAuditEvents.mockResolvedValue([ { ...EVENT_A, detailsJson: "{not json" }, EVENT_B, ]); renderAudit(); - const rowA = await screen.findByRole("row", { name: /admin@farm\.test/ }); + const rowA = await screen.findByRole("article", { description: /admin@farm\.test/ }); + expandPanel(rowA); expect(within(rowA).getByText("culled sick birds")).toBeInTheDocument(); - const rowB = screen.getByRole("row", { name: /manager@farm\.test/ }); - expect(within(rowB).getByText("—")).toBeInTheDocument(); + const rowB = screen.getByRole("article", { description: /manager@farm\.test/ }); + expandPanel(rowB); + expect(rowB.querySelector(".audit-event-body")?.children).toHaveLength(3); }); // #756 — the discount reason on a SalesOrder.Confirm row. This is the trap @@ -390,7 +436,8 @@ describe("AuditPage load + render", () => { }]); renderAudit(); - const row = await screen.findByRole("row", { name: /admin@farm\.test/ }); + const row = await screen.findByRole("article", { description: /admin@farm\.test/ }); + expandPanel(row); expect(within(row).getByText("Discount reason: Damaged stock (Cracked in transit)")).toBeInTheDocument(); }); @@ -407,11 +454,12 @@ describe("AuditPage load + render", () => { }]); renderAudit(); - const row = await screen.findByRole("row", { name: /admin@farm\.test/ }); + const row = await screen.findByRole("article", { description: /admin@farm\.test/ }); + expandPanel(row); expect(within(row).getByText("Discount reason: Volume")).toBeInTheDocument(); }); - it("shows an em dash, not a broken cell, for a Confirm row with no discount payload", async () => { + it("omits details for a Confirm row with no discount payload", async () => { mockListAuditEvents.mockResolvedValue([{ id: "d6", occurredAtUtc: "2026-09-08T14:24:00Z", @@ -424,8 +472,167 @@ describe("AuditPage load + render", () => { }]); renderAudit(); - const row = await screen.findByRole("row", { name: /admin@farm\.test/ }); - expect(within(row).getByText("—")).toBeInTheDocument(); + const row = await screen.findByRole("article", { description: /admin@farm\.test/ }); + expandPanel(row); + expect(row.querySelector(".audit-event-body")?.children).toHaveLength(3); + }); +}); + +describe("AuditPage expandable event panels", () => { + it("uses one exact summary string with the native disclosure marker at its start", async () => { + mockListAuditEvents.mockResolvedValue([EVENT_A]); + renderAudit(); + + const panel = await screen.findByRole("article"); + const summary = panel.querySelector("summary"); + expect(summary).not.toBeNull(); + expect(summary).toHaveTextContent(/^2026-07-19 14:30:05 UTC · Flock depleted$/); + expect(summary?.querySelector("svg")).toBeNull(); + expect(panel).toHaveAccessibleName("2026-07-19 14:30:05 UTC · Flock depleted"); + }); + + it("stacks actor, action, entity and details in that order without labels", async () => { + mockListAuditEvents.mockResolvedValue([EVENT_A]); + renderAudit(); + + const panel = await screen.findByRole("article"); + fireEvent.click(getPanelSummary(panel)); + const body = panel.querySelector(".audit-event-body"); + expect(Array.from(body?.children ?? [], (line) => line.textContent)).toEqual([ + "admin@farm.test", + "Flock depleted", + "Flock f1234567", + "culled sick birds", + ]); + expect(within(panel).queryByText("Who")).not.toBeInTheDocument(); + expect(within(panel).queryByText("Entity")).not.toBeInTheDocument(); + expect(within(panel).queryByText("Details")).not.toBeInTheDocument(); + }); + + it("omits the details line when the event has no details", async () => { + mockListAuditEvents.mockResolvedValue([EVENT_B]); + renderAudit(); + + const panel = await screen.findByRole("article"); + fireEvent.click(getPanelSummary(panel)); + const body = panel.querySelector(".audit-event-body"); + expect(Array.from(body?.children ?? [], (line) => line.textContent)).toEqual([ + "manager@farm.test", + "User created", + "User u9abcdef", + ]); + expect(within(panel).queryByText("—")).not.toBeInTheDocument(); + }); + + it("puts the live scope caption and preview control together above the filters", async () => { + mockListAuditEvents.mockResolvedValue([EVENT_A]); + renderAudit(); + + const scope = await screen.findByTestId("audit-scope"); + expect(scope.firstElementChild).toHaveTextContent("UTC timestamps"); + expect(within(scope).getByRole("checkbox", { name: "Preview one record's history" })) + .not.toBeChecked(); + const filters = screen.getByTestId("audit-filters"); + expect(scope.compareDocumentPosition(filters) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); + }); + + it("previews the first visible record without dropping active filters", async () => { + mockListAuditEvents.mockResolvedValue([ + { ...EVENT_A, entityId: SCOPED_ENTITY_ID }, + ]); + renderAuditWithProbe("/audit?action=Flock.Deplete"); + + const preview = await screen.findByRole("checkbox", { + name: "Preview one record's history", + }); + fireEvent.click(preview); + + await waitFor(() => { + const search = screen.getByTestId("probe-search").textContent ?? ""; + expect(search).toContain(`entityId=${SCOPED_ENTITY_ID}`); + expect(search).toContain("action=Flock.Deplete"); + }); + }); + + it("leaves record history without dropping active filters", async () => { + mockListAuditEvents.mockResolvedValue([ + { ...EVENT_A, entityId: SCOPED_ENTITY_ID }, + ]); + renderAuditWithProbe(`/audit?entityId=${SCOPED_ENTITY_ID}&action=Flock.Deplete`); + + const preview = await screen.findByRole("checkbox", { + name: "Preview one record's history", + }); + expect(preview).toBeChecked(); + fireEvent.click(preview); + + await waitFor(() => { + const search = screen.getByTestId("probe-search").textContent ?? ""; + expect(search).not.toContain("entityId="); + expect(search).toContain("action=Flock.Deplete"); + }); + }); + + it("starts every row collapsed, with Entity and Details not in the document", async () => { + mockListAuditEvents.mockResolvedValue([EVENT_A]); + renderAudit(); + + const row = await screen.findByRole("article", { description: /admin@farm\.test/ }); + expect(row).not.toHaveAttribute("open"); + expect(within(row).getByText("Flock f1234567")).not.toBeVisible(); + expect(within(row).getByText("culled sick birds")).not.toBeVisible(); + }); + + it("expands a row on click, and collapses it again on a second click", async () => { + mockListAuditEvents.mockResolvedValue([EVENT_A]); + renderAudit(); + + const row = await screen.findByRole("article", { description: /admin@farm\.test/ }); + const toggle = getPanelSummary(row); + + fireEvent.click(toggle); + expect(row).toHaveAttribute("open"); + expect(within(row).getByText("Flock f1234567")).toBeInTheDocument(); + expect(within(row).getByText("culled sick birds")).toBeInTheDocument(); + + fireEvent.click(toggle); + expect(row).not.toHaveAttribute("open"); + expect(within(row).getByText("Flock f1234567")).not.toBeVisible(); + expect(within(row).getByText("culled sick birds")).not.toBeVisible(); + }); + + it("expands each row independently — opening one leaves the other collapsed", async () => { + mockListAuditEvents.mockResolvedValue([EVENT_A, EVENT_B]); + renderAudit(); + + const rowA = await screen.findByRole("article", { description: /admin@farm\.test/ }); + const rowB = screen.getByRole("article", { description: /manager@farm\.test/ }); + expandPanel(rowA); + + expect(rowA).toHaveAttribute("open"); + expect(rowB).not.toHaveAttribute("open"); + expect(within(rowB).getByText("User u9abcdef")).not.toBeVisible(); + }); + + it("does not call listAuditEvents again when a row is expanded", async () => { + mockListAuditEvents.mockResolvedValue([EVENT_A]); + renderAudit(); + const row = await screen.findByRole("article", { description: /admin@farm\.test/ }); + mockListAuditEvents.mockClear(); + + expandPanel(row); + + expect(mockListAuditEvents).not.toHaveBeenCalled(); + }); + + it("renders the expanded details as plain readable text", async () => { + mockListAuditEvents.mockResolvedValue([EVENT_A]); + renderAudit(); + const row = await screen.findByRole("article", { description: /admin@farm\.test/ }); + expandPanel(row); + + expect(within(row).getByText("culled sick birds")).toBeInTheDocument(); + expect(within(row).queryByText("Details")).not.toBeInTheDocument(); }); }); @@ -620,14 +827,12 @@ describe("AuditPage filter", () => { expect(screen.getByTestId("probe-search").textContent).toBe("?from=2026-08-05"); }); - // #653/#662 — mirrors Increment 3's StockPage structural guard: the width - // cap in styles.css is keyed on `.toolbar input[type="date"]`, so the wrapper - // is the only honest thing jsdom (no layout engine) can assert here. - it("puts the date range in the bounded toolbar, not a bare filters row", async () => { + it("puts the date range in the plain filter row", async () => { renderAudit("/audit"); await waitFor(() => expect(mockListAuditEvents).toHaveBeenCalled()); const fromInput = screen.getByLabelText("From"); - expect(fromInput.closest("div.toolbar")).not.toBeNull(); + expect(fromInput.closest(".MuiPaper-outlined")).toBeNull(); + expect(fromInput.closest("[data-testid='audit-filters']")).not.toBeNull(); }); // INV-4 — "No audit events yet." is a FALSE statement when a date filter @@ -667,7 +872,7 @@ describe("AuditPage clear filters (#679)", () => { it("offers no clear control until something is narrowing the list", async () => { mockListAuditEvents.mockResolvedValue([EVENT_A]); renderAudit(); - await screen.findByText(/admin@farm.test/); + await screen.findByRole("article", { description: /admin@farm\.test/ }); expect(screen.queryByRole("button", { name: "Clear filters" })).toBeNull(); }); @@ -678,7 +883,7 @@ describe("AuditPage clear filters (#679)", () => { it("shows the clear control beside the filters while rows are still listed", async () => { mockListAuditEvents.mockResolvedValue([EVENT_A]); renderAudit("/audit?action=Flock.Deplete"); - await screen.findByText(/admin@farm.test/); + await screen.findByRole("article", { description: /admin@farm\.test/ }); expect(screen.getByRole("button", { name: "Clear filters" })).toBeInTheDocument(); }); @@ -688,7 +893,7 @@ describe("AuditPage clear filters (#679)", () => { renderAuditWithProbe( "/audit?entityId=11111111-1111-1111-1111-111111111111&entityType=Flock&action=Flock.Deplete&from=2026-01-01&to=2026-01-31", ); - await screen.findByText(/admin@farm.test/); + await screen.findByRole("article", { description: /admin@farm\.test/ }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); @@ -710,7 +915,7 @@ describe("AuditPage clear filters (#679)", () => { it("re-queries unfiltered after clearing", async () => { mockListAuditEvents.mockResolvedValue([EVENT_A]); renderAudit("/audit?action=Flock.Deplete&from=2026-01-01"); - await screen.findByText(/admin@farm.test/); + await screen.findByRole("article", { description: /admin@farm\.test/ }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); @@ -742,7 +947,7 @@ describe("AuditPage paging", () => { expect(mockListAuditEvents).toHaveBeenLastCalledWith( expect.objectContaining({ offset: 100 }), ); - expect(await screen.findByText("next@farm.test")).toBeInTheDocument(); // appended page row + expect(await screen.findByRole("article", { description: /next@farm\.test/ })).toBeInTheDocument(); }); }); @@ -841,32 +1046,29 @@ describe("AuditPage i18n wiring (#182, Task 29)", () => { }); }); - it("reads every table header from the audit catalog, not a hardcoded literal", async () => { - // At least one event, so the table (with its ) actually renders — - // the empty-state branch renders no headers at all. - mockListAuditEvents.mockResolvedValue([EVENT_A]); - // Headers are checked one at a time (each override restored before the - // next) so a single shared render can't mask one key silently falling - // back to English while another is overridden. - for (const [key, marker, original] of [ - ["whenHeader", "WHEN-MARKER", "When (UTC)"], - ["whoHeader", "WHO-MARKER", "Who"], - ["actionHeader", "ACTION-HEADER-MARKER", "Action"], - ["entityHeader", "ENTITY-MARKER", "Entity"], - ["detailsHeader", "DETAILS-MARKER", "Details"], - ] as const) { - await withOverride("audit", key, marker, async () => { - // Unmounted at the end of this iteration (afterEach's cleanup() only - // runs between `it()` blocks, not between loop iterations) — without - // it, a prior iteration's un-overridden headers would stay in the - // DOM and falsely satisfy/defeat the next iteration's queries. - const { unmount } = renderAudit(); - await screen.findByRole("row", { name: /admin@farm\.test/ }); - expect(screen.getByRole("columnheader", { name: marker })).toBeInTheDocument(); - expect(screen.queryByRole("columnheader", { name: original })).not.toBeInTheDocument(); - unmount(); - }); - } + it("reads the UTC caption from the audit catalog", async () => { + await withOverride("audit", "utcTimestampsCaption", "UTC-CAPTION-MARKER", async () => { + renderAudit(); + expect(await screen.findByText("UTC-CAPTION-MARKER")).toBeInTheDocument(); + expect(screen.queryByText("UTC timestamps")).not.toBeInTheDocument(); + }); + }); + + it("reads the scoped caption from the audit catalog", async () => { + await withOverride("audit", "scopeRetainedCaption", "SCOPE-CAPTION-MARKER", async () => { + renderAudit(`/audit?entityId=${SCOPED_ENTITY_ID}`); + expect(await screen.findByText("SCOPE-CAPTION-MARKER")).toBeInTheDocument(); + expect(screen.queryByText("Record scope retained when clearing filters")).not.toBeInTheDocument(); + }); + }); + + it("reads the record preview label from the audit catalog", async () => { + await withOverride("audit", "previewRecordHistoryLabel", "PREVIEW-MARKER", async () => { + renderAudit(); + expect(await screen.findByRole("checkbox", { name: "PREVIEW-MARKER" })).toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: "Preview one record's history" })) + .not.toBeInTheDocument(); + }); }); it("reads the empty-state message from the audit catalog, not a hardcoded literal", async () => { @@ -889,13 +1091,11 @@ describe("AuditPage i18n wiring (#182, Task 29)", () => { }); }); - // Proves the action table cell reads enums:auditAction.* through - // auditActionLabel(e.action) rather than rendering e.action raw. - it("reads the action cell from the enums catalog via auditActionLabel, not the raw wire code", async () => { + it("reads the action from the enums catalog via auditActionLabel, not the raw wire code", async () => { mockListAuditEvents.mockResolvedValue([EVENT_A]); // action: "Flock.Deplete" await withOverride("enums", "auditAction.Flock.Deplete", "ACTION-LABEL-MARKER", async () => { renderAudit(); - const row = await screen.findByRole("row", { name: /admin@farm\.test/ }); + const row = await screen.findByRole("article", { description: /admin@farm\.test/ }); expect(within(row).getByText("ACTION-LABEL-MARKER")).toBeInTheDocument(); expect(within(row).queryByText("Flock depleted")).not.toBeInTheDocument(); expect(within(row).queryByText("Flock.Deplete")).not.toBeInTheDocument(); @@ -917,16 +1117,12 @@ describe("AuditPage i18n wiring (#182, Task 29)", () => { }); }); - // Proves the entity table cell reads enums:entityType.* through - // entityTypeLabel(e.entityType) rather than rendering e.entityType raw. - // EVENT_B's entityType is "User" (also an identity label in en, so this - // override is the only way to distinguish "reads the catalog" from - // "renders the raw value that happens to equal the label"). - it("reads the entity cell from the enums catalog via entityTypeLabel, not the raw wire value", async () => { + it("reads the entity from the enums catalog via entityTypeLabel, not the raw wire value", async () => { mockListAuditEvents.mockResolvedValue([EVENT_B]); // entityType: "User", entityId: "u9abcdef-0000" await withOverride("enums", "entityType.User", "ENTITY-LABEL-MARKER", async () => { renderAudit(); - const row = await screen.findByRole("row", { name: /manager@farm\.test/ }); + const row = await screen.findByRole("article", { description: /manager@farm\.test/ }); + expandPanel(row); expect(within(row).getByText("ENTITY-LABEL-MARKER u9abcdef")).toBeInTheDocument(); expect(within(row).queryByText("User u9abcdef")).not.toBeInTheDocument(); }); @@ -995,7 +1191,7 @@ describe("AuditPage entity-scoped mode (#493)", () => { expect(await screen.findByRole("heading", { name: "Flock history" })).toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Record history" })).not.toBeInTheDocument(); - expect(await screen.findByRole("row", { name: /admin@farm\.test/ })).toBeInTheDocument(); + expect(await screen.findByRole("article", { description: /admin@farm\.test/ })).toBeInTheDocument(); expect(mockListAuditEvents).toHaveBeenCalledWith( expect.objectContaining({ entityId: SCOPED_ENTITY_ID }), // normalized, not the raw uppercase URL value ); @@ -1059,22 +1255,19 @@ describe("AuditPage entity-scoped mode (#493)", () => { expect(await screen.findByRole("heading", { name: "Flock history" })).toBeInTheDocument(); }); - it("hides the entity column when scoped; shows it when unscoped", async () => { + it("shows the entity value in both scoped and unscoped expanded panels", async () => { mockListAuditEvents.mockResolvedValue([ { ...EVENT_A, entityType: "Flock", entityId: SCOPED_ENTITY_ID }, ]); const { unmount } = renderAudit(`/audit?entityId=${SCOPED_ENTITY_ID}`); - const scopedRow = await screen.findByRole("row", { name: /admin@farm\.test/ }); - expect(screen.queryByRole("columnheader", { name: "Entity" })).not.toBeInTheDocument(); - // The header and the row's own are two separate gates in the JSX - // (review round 1 finding) — asserting only the header would miss a - // regression that drops the header but leaves the cell rendered. - expect(within(scopedRow).queryByText(/Flock f1234567/)).not.toBeInTheDocument(); + const scopedRow = await screen.findByRole("article", { description: /admin@farm\.test/ }); + expandPanel(scopedRow); + expect(within(scopedRow).getByText(/Flock f1234567/)).toBeInTheDocument(); unmount(); renderAudit("/audit"); - const unscopedRow = await screen.findByRole("row", { name: /admin@farm\.test/ }); - expect(screen.getByRole("columnheader", { name: "Entity" })).toBeInTheDocument(); + const unscopedRow = await screen.findByRole("article", { description: /admin@farm\.test/ }); + expandPanel(unscopedRow); expect(within(unscopedRow).getByText(/Flock f1234567/)).toBeInTheDocument(); }); @@ -1168,7 +1361,7 @@ describe("AuditPage Flow A' — switching records without leaving /audit (#493)" expect.objectContaining({ entityId: OTHER_ENTITY_ID }), ); // Not stale-blended: the row shown belongs to the NEW entity only. - expect(await screen.findByRole("row", { name: /manager@farm\.test/ })).toBeInTheDocument(); + expect(await screen.findByRole("article", { description: /manager@farm\.test/ })).toBeInTheDocument(); }); it("shows the generic fallback while the switch is in flight, not the previous record's type", async () => { @@ -1261,8 +1454,8 @@ describe("AuditPage stale-scope coverage beyond Flow A' (#493)", () => { // showing only the previous single-entity scope's row under the global // heading, which is what the old content-based check could do (it never // treated entityId -> undefined as stale). - expect(await screen.findByRole("row", { name: /admin@farm\.test/ })).toBeInTheDocument(); - expect(screen.getByRole("row", { name: /manager@farm\.test/ })).toBeInTheDocument(); + expect(await screen.findByRole("article", { description: /admin@farm\.test/ })).toBeInTheDocument(); + expect(screen.getByRole("article", { description: /manager@farm\.test/ })).toBeInTheDocument(); expect(mockListAuditEvents).toHaveBeenLastCalledWith( expect.objectContaining({ entityId: undefined }), ); @@ -1292,7 +1485,7 @@ describe("AuditPage stale-scope coverage beyond Flow A' (#493)", () => { }); expect(await screen.findByRole("heading", { name: "Sales order history" })).toBeInTheDocument(); - expect(await screen.findByRole("row", { name: /manager@farm\.test/ })).toBeInTheDocument(); + expect(await screen.findByRole("article", { description: /manager@farm\.test/ })).toBeInTheDocument(); }); // codex review of #516, round 5 — the exact bug a ref-based version of @@ -1329,7 +1522,7 @@ describe("AuditPage stale-scope coverage beyond Flow A' (#493)", () => { expect(await screen.findByRole("heading", { name: "Sales order history" })).toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Record history" })).not.toBeInTheDocument(); - expect(await screen.findByRole("row", { name: /manager@farm\.test/ })).toBeInTheDocument(); + expect(await screen.findByRole("article", { description: /manager@farm\.test/ })).toBeInTheDocument(); }); it("recovers correctly through a THREE-way switch (B, then C, then D — B and C both superseded before either settles)", async () => { @@ -1363,6 +1556,6 @@ describe("AuditPage stale-scope coverage beyond Flow A' (#493)", () => { expect(await screen.findByRole("heading", { name: "Expense history" })).toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Record history" })).not.toBeInTheDocument(); - expect(await screen.findByRole("row", { name: /d-actor@farm\.test/ })).toBeInTheDocument(); + expect(await screen.findByRole("article", { description: /d-actor@farm\.test/ })).toBeInTheDocument(); }); }); diff --git a/web/src/routes/AuditPage.tsx b/web/src/routes/AuditPage.tsx index c7eb91c53..a9a4a7368 100644 --- a/web/src/routes/AuditPage.tsx +++ b/web/src/routes/AuditPage.tsx @@ -2,7 +2,11 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useFormat } from "../farm/useFormat"; import { useSearchParams } from "react-router"; +import { + Box, Checkbox, FormControlLabel, TextField, Typography, +} from "@mui/material"; import { listAuditEvents, type AuditEvent } from "../api/cluckwork"; +import { FilterDateField } from "../components/FilterBar"; import { usePagedList } from "../components/usePagedList"; import { isIsoCalendarDate } from "../lib/dates"; import { @@ -62,8 +66,8 @@ export function isFetchStale(committedFetchPage: unknown, currentFetchPage: unkn // `updateActionFilter`, `updateEntityTypeFilter` and `updateDateFilter` — // builds a full copy from the CURRENT params rather than a partial object // (INV-2). -// #745 — the Details cell. Renders the sales-line audit payloads as the summary -// the #722 artboard draws, and falls back to the row's reason, then an em dash. +// #745 — the Details line. Renders the sales-line audit payloads as the summary +// the #722 artboard draws, and falls back to the row's reason. // #756 — also renders the discount reason on a SalesOrder.Confirm row, when the // order was confirmed below list; absent on an order with no discount, and on // every row confirmed before this payload shipped (no backfill). @@ -155,7 +159,9 @@ function AuditDetails({ event }: { event: AuditEvent }) { return null; })(); - return <>{summary ?? event.reason ?? "—"}; + const details = summary ?? event.reason; + if (details === null) return null; + return {details}; } export function AuditPage() { @@ -398,57 +404,81 @@ export function AuditPage() { ? events.rows?.[0]?.entityType : undefined; + const previewEntityId = isScopedReloading ? undefined : events.rows?.[0]?.entityId; + const updateRecordPreview = useCallback((checked: boolean) => { + const next = new URLSearchParams(searchParams); + if (checked && previewEntityId) next.set("entityId", previewEntityId); + else next.delete("entityId"); + setSearchParams(next); + }, [previewEntityId, searchParams, setSearchParams]); + return (
-

+ + {t("eyebrow")} + + {entityId ? (scopedEntityType ? t("scopedHeading", { entityType: entityTypeLabel(scopedEntityType) }) : t("scopedHeadingFallback")) : t("heading")} -

+

{t("intro")}

-
- - - {/* #666/#653 — the date range gets its own bounded toolbar; the two - dropdowns above are not date controls and stay outside it. Mirrors - FeedPage/WaterPage/HistoryPage. */} -
- - -
- {/* #679 — persistent, and that is the whole point: this screen's empty - state is a bare muted paragraph by #655's classification, so a - control living there would appear only once the filters had already - hidden every row. Here it exists while rows are still showing. */} - {hasFilters && ( - - )} -
+ + updateDateFilter("from", e.target.value)} /> + updateDateFilter("to", e.target.value)} /> + + {hasFilters && ( + + )} {events.error &&

{events.error}

} @@ -491,29 +521,36 @@ export function AuditPage() {

) : ( <> - - - - - {/* #493, Slice 2 — every row in a scoped view shares the same - entity; repeating it up to 100 times is noise, not a - neutral no-op, so it's hidden rather than left in. */} - {!entityId && } - - - - - {events.rows.map((e) => ( - - - - - {!entityId && } - - - ))} - -
{t("whenHeader")}{t("whoHeader")}{t("actionHeader")}{t("entityHeader")}{t("detailsHeader")}
{e.occurredAtUtc.replace("T", " ").slice(0, 19)}{e.actorEmail}{auditActionLabel(e.action)}{entityTypeLabel(e.entityType)} {e.entityId.slice(0, 8)}
+
+ {events.rows.map((e) => { + const timestamp = e.occurredAtUtc.replace("T", " ").slice(0, 19); + const action = auditActionLabel(e.action); + const summaryId = `audit-event-${e.id}`; + const actorId = `audit-event-actor-${e.id}`; + return ( +
+ + {timestamp} UTC · {action} + +
+ {e.actorEmail} + {action} + + {entityTypeLabel(e.entityType)} {e.entityId.slice(0, 8)} + + +
+
+ ); + })} +
{events.canLoadMore && ( -

-

{t("fullBackupHint")}

+ + -

{t("singleDatasetsHeading")}

-
    - {EXPORT_DATASETS.map((d) => ( -
  • - -
  • - ))} -
+ {t("singleDatasetsHeading")} + {t("datasetHint")} + + setDataset(e.target.value)} + slotProps={{ select: { native: true }, inputLabel: { shrink: true } }} + > + {EXPORT_DATASETS.map((d) => )} + + + + +
); } diff --git a/web/src/routes/HelpPage.test.tsx b/web/src/routes/HelpPage.test.tsx index 60dcc0adb..51ccb756b 100644 --- a/web/src/routes/HelpPage.test.tsx +++ b/web/src/routes/HelpPage.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, within, act } from "@testing-library/react"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { render, screen, within, act, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; import { GlossaryLink } from "../components/GlossaryLink"; @@ -37,7 +39,7 @@ describe("HelpPage", () => { expect(within(toc).getByRole("link", { name: "The daily loop" })).toHaveAttribute("href", "#daily-loop"); expect(screen.getByRole("heading", { name: "The daily loop", level: 3 })).toBeInTheDocument(); - expect(screen.getByText("FIFO", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("FIFO", { selector: "summary" })).toBeInTheDocument(); }); it("keeps the contents rail and the sections in step, in document order", () => { @@ -57,12 +59,21 @@ describe("HelpPage", () => { expect(linked).toEqual(sections); }); + it("renders the literal /login?farm= as text, not as an opening HTML tag (#833)", () => { + render(); + const farmCodeDef = screen.getByText("Farm code", { selector: "summary" }) + .closest(".glossary-entry") + ?.querySelector("p"); + expect(farmCodeDef?.textContent).toContain("/login?farm="); + expect(farmCodeDef?.querySelector("code")).toBeNull(); + }); + it("documents Owner-only farm settings, the currency lock and the logo (#123/#729)", () => { render(); expect(screen.getByRole("heading", { name: "Farm settings (owner only)", level: 3 })).toBeInTheDocument(); - expect(screen.getByText("Farm settings", { selector: "dt a" })).toBeInTheDocument(); - expect(screen.getByText("Currency lock", { selector: "dt a" })).toBeInTheDocument(); - expect(screen.getByText("Farm logo", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("Farm settings", { selector: "summary" })).toBeInTheDocument(); + expect(screen.getByText("Currency lock", { selector: "summary" })).toBeInTheDocument(); + expect(screen.getByText("Farm logo", { selector: "summary" })).toBeInTheDocument(); for (const [catalog, owner, manager] of [ [en, /owner/i, /manager/i], @@ -85,7 +96,7 @@ describe("HelpPage", () => { render(); expect(screen.getByText(/first time you open a screen after starting or updating Cluckwork/i)) .toBeInTheDocument(); - expect(screen.getByText("Page loading", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("Page loading", { selector: "summary" })).toBeInTheDocument(); for (const catalog of [es, tl]) { expect(catalog.help.gettingAroundPageLoading).toBeTruthy(); @@ -247,7 +258,7 @@ describe("HelpPage", () => { // as lost work. Dropping it would leave a user re-entering a range they // never lost. expect(screen.getByText(/Nothing was recorded and nothing was lost/i)).toBeInTheDocument(); - expect(screen.getByText("Too many reports at once", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("Too many reports at once", { selector: "summary" })).toBeInTheDocument(); }); it("renders the report-throttle bullet through , so its tags are real elements", () => { @@ -354,7 +365,7 @@ describe("HelpPage", () => { i18n.addResource("en", "help", "glossaryFarmProvisioningDef", "PROVISIONING-DEF-MARKER"); try { render(); - expect(screen.getByText("PROVISIONING-TERM-MARKER", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("PROVISIONING-TERM-MARKER", { selector: "summary" })).toBeInTheDocument(); expect(screen.getByText("PROVISIONING-DEF-MARKER")).toBeInTheDocument(); } finally { i18n.addResource("en", "help", "glossaryFarmProvisioningTerm", originalTerm); @@ -409,7 +420,7 @@ describe("HelpPage", () => { it("documents the Disabled user term in the in-app glossary (#356)", () => { render(); - expect(screen.getByText("Disabled user", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("Disabled user", { selector: "summary" })).toBeInTheDocument(); expect(screen.getByText(/Revoked access, not deletion/i)).toBeInTheDocument(); }); @@ -450,6 +461,30 @@ describe("HelpPage", () => { expect(flocks).toHaveAttribute("aria-current", "location"); expect(within(toc).getByRole("link", { name: "Getting around" })).not.toHaveAttribute("aria-current"); }); + + it("keeps a late active topic visible in the phone contents strip", () => { + const scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + render(); + scrollIntoView.mockClear(); + + act(() => ioCallback?.([{ isIntersecting: true, target: { id: "export" }, boundingClientRect: { top: 12 } }])); + + const exportLink = screen.getByRole("navigation", { name: "Help contents" }) + .querySelector('a[href="#export"]'); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", inline: "nearest" }); + expect(scrollIntoView.mock.instances.at(-1)).toBe(exportLink); + }); + + it("bounds the long desktop contents rail and gives it independent vertical scrolling", () => { + render(); + expect(within(screen.getByRole("navigation", { name: "Help contents" })).getAllByRole("link").length) + .toBeGreaterThan(20); + const css = readFileSync(resolve(process.cwd(), "src/styles.css"), "utf8"); + const railRule = css.match(/\.help-toc\s*\{(?[^}]*)\}/)?.groups?.body ?? ""; + expect(railRule).toMatch(/max-height:\s*calc\(100dvh - 3rem\)/); + expect(railRule).toMatch(/overflow-y:\s*auto/); + }); }); // --------------------------------------------------------------------------- @@ -606,9 +641,9 @@ describe("HelpPage glossary i18n wiring (#182, Task 33)", () => { withOverride("glossaryFifoTerm", "FIFO-TERM-MARKER", () => { withOverride("glossaryFifoDef", "FIFO-DEF-MARKER", () => { render(); - expect(screen.getByText("FIFO-TERM-MARKER", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("FIFO-TERM-MARKER", { selector: "summary" })).toBeInTheDocument(); expect(screen.getByText("FIFO-DEF-MARKER")).toBeInTheDocument(); - expect(screen.queryByText("FIFO", { selector: "dt a" })).not.toBeInTheDocument(); + expect(screen.queryByText("FIFO", { selector: "summary" })).not.toBeInTheDocument(); expect(screen.queryByText(/first in, first out/i)).not.toBeInTheDocument(); }); }); @@ -618,7 +653,7 @@ describe("HelpPage glossary i18n wiring (#182, Task 33)", () => { withOverride("glossaryPageLoadingTerm", "PAGE-LOADING-TERM-MARKER", () => { withOverride("glossaryPageLoadingDef", "PAGE-LOADING-DEF-MARKER", () => { render(); - expect(screen.getByText("PAGE-LOADING-TERM-MARKER", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("PAGE-LOADING-TERM-MARKER", { selector: "summary" })).toBeInTheDocument(); expect(screen.getByText("PAGE-LOADING-DEF-MARKER")).toBeInTheDocument(); }); }); @@ -628,9 +663,9 @@ describe("HelpPage glossary i18n wiring (#182, Task 33)", () => { withOverride("glossaryFlockScopingTerm", "FLOCK-SCOPE-TERM-MARKER", () => { withOverride("glossaryFlockScopingDef", "FLOCK-SCOPE-DEF-MARKER", () => { render(); - expect(screen.getByText("FLOCK-SCOPE-TERM-MARKER", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("FLOCK-SCOPE-TERM-MARKER", { selector: "summary" })).toBeInTheDocument(); expect(screen.getByText("FLOCK-SCOPE-DEF-MARKER")).toBeInTheDocument(); - expect(screen.queryByText("Flock scoping", { selector: "dt a" })).not.toBeInTheDocument(); + expect(screen.queryByText("Flock scoping", { selector: "summary" })).not.toBeInTheDocument(); }); }); }); @@ -639,9 +674,9 @@ describe("HelpPage glossary i18n wiring (#182, Task 33)", () => { withOverride("glossaryTooManyReportsTerm", "REPORT-THROTTLE-TERM-MARKER", () => { withOverride("glossaryTooManyReportsDef", "REPORT-THROTTLE-DEF-MARKER", () => { render(); - expect(screen.getByText("REPORT-THROTTLE-TERM-MARKER", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("REPORT-THROTTLE-TERM-MARKER", { selector: "summary" })).toBeInTheDocument(); expect(screen.getByText("REPORT-THROTTLE-DEF-MARKER")).toBeInTheDocument(); - expect(screen.queryByText("Too many reports at once", { selector: "dt a" })).not.toBeInTheDocument(); + expect(screen.queryByText("Too many reports at once", { selector: "summary" })).not.toBeInTheDocument(); }); }); }); @@ -651,9 +686,9 @@ describe("HelpPage glossary i18n wiring (#182, Task 33)", () => { withOverride("glossaryWorkerSaleAllocationTerm", "WORKER-SALE-ALLOCATION-TERM-MARKER", () => { withOverride("glossaryWorkerSaleAllocationDef", "WORKER-SALE-ALLOCATION-DEF-MARKER", () => { render(); - expect(screen.getByText("WORKER-SALE-ALLOCATION-TERM-MARKER", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("WORKER-SALE-ALLOCATION-TERM-MARKER", { selector: "summary" })).toBeInTheDocument(); expect(screen.getByText("WORKER-SALE-ALLOCATION-DEF-MARKER")).toBeInTheDocument(); - expect(screen.queryByText("Worker sale allocation", { selector: "dt a" })).not.toBeInTheDocument(); + expect(screen.queryByText("Worker sale allocation", { selector: "summary" })).not.toBeInTheDocument(); }); }); }); @@ -664,9 +699,9 @@ describe("HelpPage glossary i18n wiring (#182, Task 33)", () => { withOverride("glossaryDisabledUserTerm", "DISABLED-USER-TERM-MARKER", () => { withOverride("glossaryDisabledUserDef", "DISABLED-USER-DEF-MARKER", () => { render(); - expect(screen.getByText("DISABLED-USER-TERM-MARKER", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("DISABLED-USER-TERM-MARKER", { selector: "summary" })).toBeInTheDocument(); expect(screen.getByText("DISABLED-USER-DEF-MARKER")).toBeInTheDocument(); - expect(screen.queryByText("Disabled user", { selector: "dt a" })).not.toBeInTheDocument(); + expect(screen.queryByText("Disabled user", { selector: "summary" })).not.toBeInTheDocument(); }); }); }); @@ -678,9 +713,9 @@ describe("HelpPage glossary i18n wiring (#182, Task 33)", () => { withOverride("glossaryStepUpAuthTerm", "STEP-UP-TERM-MARKER", () => { withOverride("glossaryStepUpAuthDef", "STEP-UP-DEF-MARKER", () => { render(); - expect(screen.getByText("STEP-UP-TERM-MARKER", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("STEP-UP-TERM-MARKER", { selector: "summary" })).toBeInTheDocument(); expect(screen.getByText("STEP-UP-DEF-MARKER")).toBeInTheDocument(); - expect(screen.queryByText("Step-up authentication", { selector: "dt a" })).not.toBeInTheDocument(); + expect(screen.queryByText("Step-up authentication", { selector: "summary" })).not.toBeInTheDocument(); // "re-enter your current password" also appears in the unrelated Signing-in // prose (signingInStepUp) — assert against phrasing unique to the glossary // def so this doesn't false-pass against that other section. @@ -763,7 +798,7 @@ describe("HelpPage glossary i18n wiring (#182, Task 33)", () => { i18n.addResource("en", "help", "glossaryLoginEmailDef", "LOGIN-EMAIL-DEF-MARKER"); try { render(); - expect(screen.getByText("LOGIN-EMAIL-TERM-MARKER", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("LOGIN-EMAIL-TERM-MARKER", { selector: "summary" })).toBeInTheDocument(); expect(screen.getByText("LOGIN-EMAIL-DEF-MARKER")).toBeInTheDocument(); } finally { i18n.addResource("en", "help", "glossaryLoginEmailTerm", originalTerm); @@ -844,8 +879,8 @@ describe("HelpPage searchable picker guidance (#512)", () => { it("defines the Searchable picker term in the in-app glossary, covering keyboard, Load more, Retry, and unavailable identities", () => { render(); - expect(screen.getByText("Searchable picker", { selector: "dt a" })).toBeInTheDocument(); - const def = screen.getByText("Searchable picker", { selector: "dt a" }).closest(".glossary-entry")!; + expect(screen.getByText("Searchable picker", { selector: "summary" })).toBeInTheDocument(); + const def = screen.getByText("Searchable picker", { selector: "summary" }).closest(".glossary-entry")!; expect(def).toHaveTextContent(/Enter/); expect(def).toHaveTextContent(/Escape/); expect(def).toHaveTextContent("Load more"); @@ -867,7 +902,7 @@ describe("HelpPage searchable picker guidance (#512)", () => { i18n.addResource("en", "help", "glossarySearchablePickerDef", "SEARCHABLE-PICKER-DEF-MARKER"); try { render(); - expect(screen.getByText("SEARCHABLE-PICKER-TERM-MARKER", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("SEARCHABLE-PICKER-TERM-MARKER", { selector: "summary" })).toBeInTheDocument(); expect(screen.getByText("SEARCHABLE-PICKER-DEF-MARKER")).toBeInTheDocument(); } finally { i18n.addResource("en", "help", "glossarySearchablePickerTerm", originalTerm); @@ -889,35 +924,36 @@ describe("HelpPage searchable picker guidance (#512)", () => { }); }); -// #657 — grouped, searchable, deep-linkable glossary; guide reordered around +// #657 — searchable, deep-linkable glossary; guide reordered around // the tasks people come for. describe("HelpPage glossary + search (#657)", () => { - it("renders the glossary as grouped definition lists, alphabetical within each group", () => { + it("renders one flat list of glossary disclosures in catalog order", () => { const { container } = render(); - const groups = Array.from(container.querySelectorAll(".glossary-group")); - expect(groups.length).toBeGreaterThanOrEqual(5); - let entries = 0; - for (const g of groups) { - expect(g.querySelector("h4")).not.toBeNull(); - const terms = Array.from(g.querySelectorAll("dl.glossary dt")).map((dt) => dt.textContent ?? ""); - expect(terms.length).toBeGreaterThan(0); - expect(terms).toEqual([...terms].sort((a, b) => a.localeCompare(b, "en"))); - entries += terms.length; - } - expect(entries).toBe(GLOSSARY.length); + const terms = Array.from(container.querySelectorAll(".glossary > .glossary-entry > summary")) + .map((summary) => summary.textContent ?? ""); + expect(terms).toHaveLength(GLOSSARY.length); + expect(terms).toEqual(GLOSSARY.map((entry) => en.help[entry.termKey])); + expect(container.querySelector(".glossary-group")).toBeNull(); + expect(container.querySelector(".glossary-jump")).toBeNull(); expect(container.querySelector("table.data th[scope=\"row\"]")).toBeNull(); }); - it("gives every term a stable anchor that links to itself", () => { + it("gives every collapsed term a stable disclosure anchor", () => { const { container } = render(); const entry = container.querySelector("#glossary-egg-lot"); expect(entry).not.toBeNull(); - expect(entry!.querySelector("dt a")).toHaveAttribute("href", "#glossary-egg-lot"); - expect(entry!.querySelector("dt")).toHaveTextContent("Egg lot"); + expect(entry?.tagName).toBe("DETAILS"); + expect(entry).not.toHaveAttribute("open"); + expect(entry!.querySelector("summary")).toHaveTextContent("Egg lot"); for (const e of Array.from(container.querySelectorAll(".glossary-entry"))) { expect(e.id).toMatch(/^glossary-/); - expect(e.querySelector("dt a")).toHaveAttribute("href", `#${e.id}`); + expect(e.tagName).toBe("DETAILS"); + expect(e.querySelector("summary")).not.toBeNull(); + expect(e.querySelector("p")).not.toBeNull(); } + + fireEvent.click(entry!.querySelector("summary")!); + expect(entry).toHaveAttribute("open"); }); it("puts Fixing mistakes straight after The daily loop", () => { @@ -926,10 +962,11 @@ describe("HelpPage glossary + search (#657)", () => { expect(ids.indexOf("mistakes")).toBe(ids.indexOf("daily-loop") + 1); }); - it("groups the contents rail under labels, in the same order as the sections", () => { + it("keeps the contents rail flat and in the same order as the sections", () => { const { container } = render(); const toc = screen.getByRole("navigation", { name: "Help contents" }); - expect(within(toc).getAllByText((_, el) => el?.classList.contains("help-toc-group") ?? false).length).toBeGreaterThanOrEqual(5); + expect(toc.querySelector(".help-toc-group")).toBeNull(); + expect(toc.querySelectorAll(":scope > ul")).toHaveLength(1); const linked = within(toc).getAllByRole("link").map((a) => a.getAttribute("href")?.slice(1)); const sections = Array.from(container.querySelectorAll("h3[id]")).map((h) => h.id); expect(linked).toEqual(sections); @@ -940,7 +977,8 @@ describe("HelpPage glossary + search (#657)", () => { const { container } = render(); await user.type(screen.getByRole("searchbox", { name: "Search the guide" }), "fifo"); - expect(screen.getByText("FIFO", { selector: "dt a" })).toBeVisible(); + expect(screen.getByText("FIFO", { selector: "summary" })).toBeVisible(); + expect(container.querySelector("#glossary-fifo")).toHaveAttribute("open"); expect(container.querySelector("#glossary-ui-language")).not.toBeVisible(); // hidden: true — a `hidden` section leaves the accessibility tree, which is the point. expect(screen.getByRole("heading", { name: "Install on a phone", level: 3, hidden: true })).not.toBeVisible(); @@ -959,7 +997,10 @@ describe("HelpPage glossary + search (#657)", () => { render(); await user.type(screen.getByRole("searchbox", { name: "Search the guide" }), "zzqxv"); expect(screen.getByRole("status")).toHaveTextContent("Nothing matches “zzqxv”."); - expect(screen.getByText("FIFO", { selector: "dt a" })).not.toBeVisible(); + expect(screen.getByText("FIFO", { selector: "summary" })).not.toBeVisible(); + const toc = screen.getByRole("navigation", { name: "Help contents" }); + expect(within(toc).queryByRole("link", { current: "location" })).not.toBeInTheDocument(); + expect(toc.querySelector("a.active")).toBeNull(); }); it("scrolls to the term named in the URL hash when the hash changes after mount", () => { @@ -1008,6 +1049,7 @@ describe("HelpPage glossary + search (#657)", () => { expect(container.querySelector("#glossary-egg-lot")).toBeVisible(); expect(scroll).toHaveBeenCalledTimes(1); expect(scroll.mock.instances[0]).toBe(container.querySelector("#glossary-egg-lot")); + expect(container.querySelector("#glossary-egg-lot")).toHaveAttribute("open"); } finally { window.location.hash = ""; } @@ -1039,7 +1081,7 @@ describe("HelpPage glossary + search (#657)", () => { }); }); -// #657 visual pass — head band, glossary grid with a jump bar, section +// #657 visual pass — head band, glossary disclosures, section // "Open " links, mistakes as a two-column list, "/" to search. describe("HelpPage visual pass (#657)", () => { it("holds kicker, title, lead and the search in one head band", () => { @@ -1062,27 +1104,6 @@ describe("HelpPage visual pass (#657)", () => { expect(search).toHaveValue("fifo/"); // inside the box, "/" is just a character }); - it("offers a jump bar to every glossary group", () => { - const { container } = render(); - const jump = screen.getByRole("navigation", { name: "Glossary groups" }); - const hrefs = within(jump).getAllByRole("link").map((a) => a.getAttribute("href")); - const groups = Array.from(container.querySelectorAll(".glossary-group h4")).map((h) => `#${h.id}`); - expect(hrefs).toEqual(groups); - expect(hrefs.length).toBe(7); - }); - - it("folds the glossary jump bar away while a search is active", async () => { - const user = userEvent.setup(); - const { container } = render(); - expect(screen.getByRole("navigation", { name: "Glossary groups" })).toBeVisible(); - await user.type(screen.getByRole("searchbox", { name: "Search the guide" }), "fifo"); - // A hidden element has no accessible name (accname step 2A), so the folded - // bar is reached by class rather than by role. - expect(container.querySelector(".glossary-jump")).not.toBeVisible(); - await user.click(screen.getByRole("button", { name: "Clear search" })); - expect(screen.getByRole("navigation", { name: "Glossary groups" })).toBeVisible(); - }); - it("renders Fixing mistakes as a mistake → fix list, not a table", () => { const { container } = render(); const section = container.querySelector("#mistakes")!.closest("section")!; @@ -1136,7 +1157,7 @@ describe("HelpPage visual pass (#657)", () => { expect(within(section).getByText(/reads as No entry until it is submitted/i)).toBeInTheDocument(); expect(within(section).getByText(/submitted days only/i)).toBeInTheDocument(); expect(within(section).getByText("stacked bar", { selector: "strong" })).toBeInTheDocument(); - expect(screen.getByText("Capture status", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("Capture status", { selector: "summary" })).toBeInTheDocument(); // The section must READ the catalog, not carry a copy of it: swap the // values and the rendered page has to follow. const originalTiles = i18n.getResource("en", "help", "dashboardTiles") as string; @@ -1146,7 +1167,7 @@ describe("HelpPage visual pass (#657)", () => { try { renderWithProviders(, { token: { sub: "u1", role: "Worker" } }); expect(screen.getAllByText("DASHBOARD-TILES-MARKER").length).toBeGreaterThan(0); - expect(screen.getAllByText("CAPTURE-STATUS-TERM-MARKER", { selector: "dt a" }).length).toBeGreaterThan(0); + expect(screen.getAllByText("CAPTURE-STATUS-TERM-MARKER", { selector: "summary" }).length).toBeGreaterThan(0); } finally { i18n.addResource("en", "help", "dashboardTiles", originalTiles); i18n.addResource("en", "help", "glossaryCaptureStatusTerm", originalTerm); @@ -1179,7 +1200,7 @@ describe("HelpPage discount ceiling (#727)", () => { // further down the page renders the same words as plain prose. expect(screen.getByText("Over maximum", { selector: "strong" })).toBeInTheDocument(); expect(screen.getByText("0 is not the same as blank", { selector: "strong" })).toBeInTheDocument(); - expect(screen.getByText("Discount ceiling", { selector: "dt a" })).toBeInTheDocument(); + expect(screen.getByText("Discount ceiling", { selector: "summary" })).toBeInTheDocument(); }); it.each(PACKS)("%s names the Farm settings control by its own label", (_name, pack) => { diff --git a/web/src/routes/HelpPage.tsx b/web/src/routes/HelpPage.tsx index 269526629..a7c1cf333 100644 --- a/web/src/routes/HelpPage.tsx +++ b/web/src/routes/HelpPage.tsx @@ -2,73 +2,56 @@ import { useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, use import { Trans, useTranslation } from "react-i18next"; import { Link, useInRouterContext, useLocation } from "react-router"; import { Search } from "lucide-react"; +import { Container, Typography } from "@mui/material"; import { AuthContext } from "../auth/AuthContext"; import { navGroups } from "./nav"; -import { GLOSSARY, GLOSSARY_GROUPS } from "./helpGlossary"; +import { GLOSSARY } from "./helpGlossary"; // F18 (#71): in-app user guide + glossary. #52 restyled it into a docs layout // with a sticky contents rail that scroll-spies the section in view; #657 -// grouped the rail, moved "Fixing mistakes" up beside the daily loop, turned -// the glossary into grouped, alphabetised, deep-linkable definition lists +// reordered the guide around common tasks, moved "Fixing mistakes" up beside +// the daily loop, and made the glossary deep-linkable // (data in ./helpGlossary.ts), and put one search box over all of it. KEEP // THIS PAGE CURRENT: the docs-sync rule (AGENTS.md) requires every // user-visible change to update the relevant section here and // specs/product/GLOSSARY.md in the same PR. -// The contents rail, grouped. Flattened it must mirror the

-// sections below, in document order — a section missing here is invisible to -// anyone who navigates by the contents list, and HelpPage.test.tsx compares -// the two lists. +// The contents rail mirrors the

sections below in document order. // // Task 32 (B6a, #182): the label (2nd element) is a `help` catalog key, not // literal text — rendered via t(label) at render time. The `id` (1st element) // stays byte-identical: it drives the

anchors below AND the // scroll-spy IntersectionObserver in the effect further down. Do NOT rename // an id — that would break both. -const RAIL = [ - { labelKey: "railGroupStartHere", entries: [ +const TOC = [ ["getting-around", "tocGettingAround"], ["dashboard", "tocDashboard"], ["signing-in", "tocSigningIn"], ["daily-loop", "tocDailyLoop"], ["mistakes", "tocMistakes"], ["dialogs", "tocDialogs"], - ] }, - { labelKey: "railGroupEveryDay", entries: [ ["daily-entry", "tocDailyEntry"], ["flocks", "tocFlocks"], ["stock", "tocStock"], ["history", "tocHistory"], - ] }, - { labelKey: "railGroupSelling", entries: [ ["sales", "tocSales"], ["products", "tocProducts"], ["grades", "tocGrades"], ["reports", "tocReports"], ["expenses", "tocExpenses"], - ] }, - { labelKey: "railGroupSupplies", entries: [ ["inventory", "tocInventory"], ["feed", "tocFeed"], ["water", "tocWater"], - ] }, - { labelKey: "railGroupFarm", entries: [ ["roles", "tocRoles"], ["farm-settings", "tocFarmSettings"], ["farm-palette", "tocFarmPalette"], ["account", "tocAccount"], ["audit", "tocAudit"], ["export", "tocExport"], - ] }, - { labelKey: "railGroupApp", entries: [ ["install", "tocInstall"], ["glossary", "tocGlossary"], - ] }, ] as const; -type RailEntry = (typeof RAIL)[number]["entries"][number]; -const TOC: readonly RailEntry[] = RAIL.flatMap((g) => g.entries as readonly RailEntry[]); - // #657 — the screen a guide section describes, so the section can offer // "Open Stock". Keyed by section id; a section with no screen (the daily // loop, fixing mistakes, install) has no entry. The link renders only for a @@ -112,6 +95,7 @@ export function HelpPage() { const { t: tn } = useTranslation("nav"); const searchId = useId(); const searchRef = useRef(null); + const tocRef = useRef(null); // #657 — "Open " beside a section heading. Null outside a session // (the page is also reachable before sign-in in tests) and outside a router. @@ -143,7 +127,21 @@ export function HelpPage() { }, []); // Scroll-spy the contents rail: highlight the section currently in view. - const [activeId, setActiveId] = useState(TOC[0][0]); + const [activeId, setActiveId] = useState(TOC[0][0]); + const lastActiveId = useRef(TOC[0][0]); + const previousActiveId = useRef(TOC[0][0]); + const activate = useCallback((id: string) => { + lastActiveId.current = id; + setActiveId(id); + }, []); + + useEffect(() => { + if (activeId === previousActiveId.current) return; + previousActiveId.current = activeId; + if (activeId === null) return; + tocRef.current?.querySelector("a.active") + ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [activeId]); useEffect(() => { // jsdom (tests) has no IntersectionObserver — the rail still works as plain @@ -154,7 +152,7 @@ export function HelpPage() { const inView = entries .filter((e) => e.isIntersecting) .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); - if (inView[0]) setActiveId(inView[0].target.id); + if (inView[0]) activate(inView[0].target.id); }, // "active" once a heading reaches the top ~30% of the viewport { rootMargin: "0px 0px -70% 0px", threshold: 0 }, @@ -164,7 +162,7 @@ export function HelpPage() { if (el) observer.observe(el); } return () => observer.disconnect(); - }, []); + }, [activate]); // #657 — a deep link (`/help#glossary-egg-lot`, from a GlossaryLink or a // pasted URL). The browser only scrolls to a fragment on a full load; a @@ -198,14 +196,16 @@ export function HelpPage() { }, [followHash]); useEffect(() => { if (pendingHash === null || query !== "") return; - document.getElementById(pendingHash)?.scrollIntoView(); + const target = document.getElementById(pendingHash); + if (target instanceof HTMLDetailsElement) target.open = true; + target?.scrollIntoView(); setPendingHash(null); }, [pendingHash, query]); // #657 — one search over the guide sections and the glossary terms. The // sections are prose the catalog assembles at render time, so the match is // taken from the DOM text rather than from any list this page could keep: // every `[data-searchable]` element is hidden when its text does not carry - // the query, and a glossary group folds when none of its terms survive. + // the query. const [matches, setMatches] = useState<{ sections: number; terms: number } | null>(null); const bodyRef = useRef(null); @@ -219,16 +219,19 @@ export function HelpPage() { const hit = q === "" || (node.textContent ?? "").toLowerCase().includes(q); node.hidden = !hit; if (!hit) continue; - if (node.dataset.searchable === "term") terms += 1; + if (node.dataset.searchable === "term") { + terms += 1; + if (q !== "" && node instanceof HTMLDetailsElement) node.open = true; + } else if (node.dataset.searchable === "section") sections += 1; } - for (const group of Array.from(body.querySelectorAll(".glossary-group"))) { - group.hidden = q !== "" && group.querySelector(".glossary-entry:not([hidden])") === null; - } - setMatches(q === "" ? null : { sections, terms }); + const nextMatches = { sections, terms }; + setMatches(q === "" ? null : nextMatches); + if (q !== "" && sections + terms === 0) setActiveId(null); + else if (activeId === null) setActiveId(lastActiveId.current); // i18n.language: a language switch re-renders every section's text, so an // active query is re-applied to the new words. - }, [query, i18n.language]); + }, [query, i18n.language, activeId]); const searchStatus = matches === null ? "" @@ -237,12 +240,12 @@ export function HelpPage() { : t("searchMatches", { query: query.trim(), sections: matches.sections, terms: matches.terms }); return ( -
+ {inRouter && }

{t("eyebrow")}

-

{t("heading")}

+ {t("heading")}

{t("lead")}

@@ -262,27 +265,22 @@ export function HelpPage() {
-
- + ); } diff --git a/web/src/routes/Login.styles.test.ts b/web/src/routes/Login.styles.test.ts deleted file mode 100644 index e09731510..000000000 --- a/web/src/routes/Login.styles.test.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import postcss from "postcss"; -import type { AtRule, Rule } from "postcss"; - -// What `min-block-size` / `min-height` the login screen's Forget control -// actually ends up with — the guarantee behind #587's "reachable on a phone" -// requirement, which is CSS-only and therefore invisible to the DOM tests in -// Login.test.tsx (jsdom computes no layout and evaluates no media query). -// -// THIS IS A CASCADE-RELEVANT EVALUATION, NOT A TEXT SEARCH: selectors are -// matched by asking the DOM and declarations are read from a parsed AST, so a -// later phone override spelled differently (an equivalent selector, a comment -// inside the selector, an extra media condition) cannot hide. -// -// Deliberately NOT the full dialog-evaluator cascade: #587's invariant is that -// NO matching rule — at any nesting depth, in any context — declares a minimum -// below 44px in EITHER axis. The app is horizontal writing-mode throughout, so -// block/inline ARE the two visual axes: a smaller declaration in either one -// anywhere in the cascade would shrink the touch target in some viewport, so -// this file fails on ANY sub-44px minimum rather than computing a winner. The guard also refuses at-rule contexts it -// does not understand (it walks into @media of any shape, including nested -// ones, but throws on @layer/@supports) rather than silently skipping them. -const css = readFileSync(resolve(process.cwd(), "src/styles.css"), "utf8"); -const root = postcss.parse(css); - -// Comments inside a selector are removed at tokenization and leave nothing -// behind — `.auth/* x */.auth-forget-farm` IS `.auth .auth-forget-farm` — so -// they are stripped rather than turned into whitespace. -const cleanSelector = (selector: string) => selector.replace(/\/\*[\s\S]*?\*\//g, "").trim(); - -// jsdom's Element.matches cannot see interaction pseudo-classes — `:hover`, -// `:focus`, `:focus-visible`, `:focus-within`, `:active` all report false on a -// static tree — so a floor spelled on such a selector would be silently -// dropped by a raw `el.matches`. This guard is a static floor sweep ("any -// matching rule must not floor below 44px"), and an interaction state can -// still shrink the touch target the instant the pointer arrives, so those -// pseudo-classes are treated as APPLICABLE: the selector is re-checked -// without them. Deliberately not a selector engine — only the listed -// interaction pseudo-classes are rewritten, and only as a whole -// `:pseudo` or `:pseudo(...)` piece (a class name merely containing `hover` -// is untouched). -// Longest names FIRST in the alternation: `focus` would otherwise consume -// the prefix of `:focus-visible`/`:focus-within` and leave a dangling -// `-visible`/`-within` in the normalised selector, which then matches nothing. -const INTERACTION_PSEUDOS = /:(focus-visible|focus-within|hover|focus|active)(\([^)]*\))?/g; -const applicableSelector = (selector: string) => - selector.replace(INTERACTION_PSEUDOS, "").trim(); - -/** The matching half of the size guard, shared by every size evaluator. */ -function selectorMatches(el: Element, selector: string): boolean { - const cleaned = cleanSelector(selector); - try { - return el.matches(cleaned) || el.matches(applicableSelector(cleaned)); - } catch { - // A selector this DOM cannot parse cannot match our element either. - return false; - } -} - -// The physical properties and their logical twins: the app is horizontal -// writing-mode throughout, so `min-height` floors the block axis exactly as -// `min-block-size` does, and `min-width` the inline axis as `min-inline-size`. -// Both axes are guarded: the inline axis is not a layout concern here — it is -// the SECOND axis of the 44px touch target, which a width alone does not -// guarantee (a 2.25rem-wide control is only 36px wide). -const FLOORS = [ - "min-block-size", "min-height", - "min-inline-size", "min-width", -]; - -// The TRUE login ancestor chain (Login.tsx): the Forget button sits in the -// picker entry, in the picker, in the form card, in the auth page. Every -// selector-evaluation helper below must evaluate against this chain, so a -// later override spelled against any real ancestor — e.g. `.auth .card -// .auth-forget-farm` — is recognised instead of silently unmatched. -function buildForgetControl() { - const main = document.createElement("div"); - main.className = "auth"; - const form = document.createElement("form"); - form.className = "card"; - const picker = document.createElement("div"); - picker.className = "auth-farm-picker"; - const group = document.createElement("div"); - group.setAttribute("role", "group"); - const entry = document.createElement("span"); - entry.className = "auth-farm-picker-entry"; - const el = document.createElement("button"); - el.type = "button"; - el.className = "auth-forget-farm"; - entry.append(el); - group.append(entry); - picker.append(group); - form.append(picker); - main.append(form); - document.body.append(main); - return { main, el }; -} - -/** Does this rule match the Forget control and declare a size floor? */ -function floorDecls(rule: Rule, el: Element) { - const matched = rule.selectors.some((selector) => selectorMatches(el, selector)); - if (!matched) return []; - return rule.nodes.filter( - (n) => n.type === "decl" && FLOORS.includes(n.prop), - ) as postcss.Declaration[]; -} - -const px = (value: string) => { - const parsed = /^(\d+(?:\.\d+)?)px$/.exec(value.trim()); - return parsed ? Number(parsed[1]) : null; -}; - -/** Every floor declaration a Forget control would read, at any depth. */ -function forgetFloors() { - // The true login topology (see buildForgetControl) — not a bare div: the - // button must sit in its entry, group, picker, card and auth page for any - // descendant selector the stylesheet (or a future override) may spell. - const { main, el } = buildForgetControl(); - const found: { value: string; selector: string }[] = []; - root.walkRules((rule) => { - // Refuse at-rule contexts this file does not model: a nested @supports or - // @layer could constrain the target in a way an unconditional read would - // pass over, and silently skipping it is exactly the vacuous guard this - // file exists to not be. - for (let node = rule.parent; node && node.type !== "root"; node = node.parent) { - // @keyframes cannot match an element and cannot hold a size floor — skip - // it; anything else besides media is refused below. - if (node.type === "atrule" && (node as AtRule).name === "keyframes") continue; - // @container is @media's sibling for this guard's purpose: both wrap a - // rule in a condition that can hold at some size, so the floor inside one - // has to meet the bar exactly as an unconditional rule does. Taught here - // when #777 put the Dashboard's recent-sales list in one. - if (node.type === "atrule" && (node as AtRule).name === "container") continue; - if (node.type === "atrule" && (node as AtRule).name !== "media") { - throw new Error( - `Login.styles.test cannot evaluate a rule inside @${(node as AtRule).name} ` - + `("${rule.selector}"). Teach the evaluator that context, or assert the ` - + "target in a real browser.", - ); - } - } - for (const decl of floorDecls(rule, el)) { - found.push({ value: decl.value, selector: rule.selector }); - } - }); - - main.remove(); - return found; -} - -describe("login Forget control touch target (#587)", () => { - it("has a matching Forget rule at all, rather than passing on absence", () => { - expect(forgetFloors()).not.toHaveLength(0); - }); - - // The 44px floor is a TWO-AXIS requirement: a rule that declares a block - // axis and says nothing about width (or vice versa) does not itself shrink - // anything — but the only rule that ever establishes an axis's floor must - // do so at 44px or above, in BOTH axes. Checked as a union, not per rule: - // a later phone override declaring just one axis would otherwise look like - // a rule missing the other. - it.each(["block", "inline"])( - "establishes a minimum of at least 44px on its %s axis, in some matching rule, in some context", - (axis) => { - const props = axis === "block" ? ["min-block-size", "min-height"] : ["min-inline-size", "min-width"]; - // Take the strongest (largest) floor declared on this axis by any - // matching rule, in any context — that is what the element would end - // up with if nothing stronger overrode it later. - const entries = collectAxisFloors(props); - expect(entries, `no matching rule declares any of ${props.join("/")} for .auth-forget-farm`).not.toHaveLength(0); - let largest = -Infinity; - let largestEntry: { value: string; selector: string } = entries[0]; - for (const entry of entries) { - const pixels = px(entry.value); - if (pixels !== null && pixels > largest) { - largest = pixels; - largestEntry = entry; - } - } - expect( - largest, - `the strongest ${axis}-axis floor on the Forget target is "${largestEntry.value}" in "${largestEntry.selector}"; nothing reaches 44px on that axis`, - ).toBeGreaterThanOrEqual(44); - }, - ); - - it("declares a minimum of at least 44px in every matching rule, in every context", () => { - for (const { value, selector } of forgetFloors()) { - const pixels = px(value); - expect( - pixels, - `"${selector}" declares ${FLOORS.join("/")} as "${value}", which this ` - + "guard can only evaluate as px. Use px, and keep it >= 44.", - ).not.toBeNull(); - expect(pixels, `"${selector}" declares a Forget target below 44px (${value}).`).toBeGreaterThanOrEqual(44); - } - }); - - // Every floor declaration a matching rule makes for the given axis's - // properties, with its selector. - function collectAxisFloors(props: string[]): { value: string; selector: string }[] { - // Same true topology as forgetFloors() (see buildForgetControl) — the - // button must sit in its full login chain for the descendant selector - // to match. - const { main, el } = buildForgetControl(); - const found: { value: string; selector: string }[] = []; - root.walkRules((rule) => { - const matched = rule.selectors.some((selector) => selectorMatches(el, selector)); - if (!matched) return; - for (const node of rule.nodes) { - if (node.type === "decl" && props.includes(node.prop)) - found.push({ value: node.value, selector: rule.selector }); - } - }); - main.remove(); - return found; - } - - // PROOF that the chain above is real, not just plausible: a selector that - // depends on the `form.card` ancestor (absent from the old flat fixture) is - // recognised by the very evaluation the size guard uses. Without this, a - // fixture regression back to a bare `.auth > span` would leave `.auth .card - // .auth-forget-farm` unmatched and a 30px override riding on it would keep - // the 44px guard green. - it("recognises a selector that depends on the form.card ancestor, so a .card-scoped override cannot evade the guard", () => { - const { main, el } = buildForgetControl(); - try { - const probe = postcss.parse( - ".auth .card .auth-forget-farm { min-inline-size: 30px; }\n" - + "form.card .auth-forget-farm { min-block-size: 30px; }", - ); - const rules: Rule[] = []; - probe.walkRules((rule) => { - rules.push(rule); - }); - const override = rules[0]; - if (override === undefined) throw new Error("probe CSS parsed to no rule"); - const matched: { selector: string; hit: boolean }[] = []; - for (const s of rules.flatMap((rule) => rule.selectors)) { - matched.push({ selector: s, hit: selectorMatches(el, s) }); - } - // The probe must have actually parsed to both override selectors — a - // silent parse-to-empty would make every assertion below pass vacuously. - expect(matched).toHaveLength(2); - for (const { selector, hit } of matched) { - expect(hit, `the guard's fixture chain does not match the real login topology's "${selector}"`).toBe(true); - } - // And the recognition has teeth: if the stylesheet carried that 30px - // override, the guard's own evaluator would surface it as a floor. - expect(floorDecls(override, el).length).toBe(1); - } finally { - main.remove(); - } - }); - - // Interaction-state floors are the P2 gap in this guard: jsdom's - // Element.matches reports :hover/:focus/:focus-visible/:focus-within/ - // :active as false on a static tree, so a floor spelled on one of those - // selectors used to be silently skipped by floorDecls/collectAxisFloors — - // a 30px floor riding on :hover would keep every other case green. The - // static sweep treats those pseudo-classes as applicable (the pointer - // arriving IS an interaction state), and the rewrite must be conservative: - // only a whole :pseudo piece is removed, never a class that merely - // contains the word, and the raw-selector path is kept as the primary - // check. - it("treats interaction-state selectors as applicable, so a :hover floor cannot evade the sweep", () => { - const probe = postcss.parse( - ".auth .card .auth-forget-farm:hover { min-inline-size: 30px; }\n" - + ".auth .auth-forget-farm:focus-visible { min-block-size: 30px; }\n" - + ".auth .auth-forget-farm:hover:not(:disabled) { min-width: 30px; }", - ); - const rules: Rule[] = []; - probe.walkRules((rule) => { - rules.push(rule); - }); - expect(rules, "probe CSS parsed to no rules").toHaveLength(3); - - const { main, el } = buildForgetControl(); - try { - // The raw DOM does not see interaction pseudo-classes — the premise the - // guard's normalization exists to override. Without this, a future - // jsdom that DOES report :hover would change the guard's behaviour - // silently. - expect(el.matches(".auth .card .auth-forget-farm:hover")).toBe(false); - - const [hoverRule, focusVisibleRule, hoverNotDisabledRule] = rules; - expect(hoverRule).toBeDefined(); - expect(focusVisibleRule).toBeDefined(); - expect(hoverNotDisabledRule).toBeDefined(); - - // Each probe variant is asserted directly through the guard's own - // matching: floorDecls must surface the sub-44px floor on every one of - // them, not just the plain :hover case. The :focus-visible variant is - // the one that pins the alternation order — a `focus`-before-`focus-` - // `visible` regex leaves a dangling "-visible" that matches nothing. - expect(floorDecls(hoverRule as Rule, el).map((d) => [d.prop, d.value])).toEqual([ - ["min-inline-size", "30px"], - ]); - expect(floorDecls(focusVisibleRule as Rule, el).map((d) => [d.prop, d.value])).toEqual([ - ["min-block-size", "30px"], - ]); - expect( - floorDecls(hoverNotDisabledRule as Rule, el).map((d) => [d.prop, d.value]), - ).toEqual([ - ["min-width", "30px"], - ]); - - // Conservatism of the rewrite: it removes whole :pseudo pieces only, - // leaving everything else — including a class that merely contains a - // pseudo's name — token-for-token intact. - expect(applicableSelector(".auth .hover .auth-forget-farm")).toBe( - ".auth .hover .auth-forget-farm", - ); - expect(applicableSelector(".auth .auth-forget-farm:hover:not(:disabled)")) - .toBe(".auth .auth-forget-farm:not(:disabled)"); - // Longest-first: the hyphenated twins must normalise to nothing, not - // to a dangling "-visible"/"-within" suffix. - expect(applicableSelector(".auth .auth-forget-farm:focus-visible")).toBe( - ".auth .auth-forget-farm", - ); - expect(applicableSelector(".auth .auth-forget-farm:focus-within")).toBe( - ".auth .auth-forget-farm", - ); - // Plain :focus and :active are treated the same as the rest. - expect(applicableSelector(".auth .auth-forget-farm:focus")).toBe( - ".auth .auth-forget-farm", - ); - expect(applicableSelector(".auth .auth-forget-farm:active")).toBe( - ".auth .auth-forget-farm", - ); - } finally { - main.remove(); - } - }); - - it("does not make the farm-selection chip destructive", () => { - // The select chip shares the entry wrapper; a later edit that moves the - // destructive colour onto the chip would pass the size guard above and - // still mislead the operator. Assert the chip declares no --danger colour. - // (The chip lives in the same true chain as the Forget button, minus the - // button itself — the descendant selectors the chip guard cares about do - // not reference the button.) - const { main, el } = buildForgetControl(); - const chip = el.cloneNode() as HTMLElement; - chip.className = "auth-farm-picker-select"; - el.replaceWith(chip); - const colours: string[] = []; - root.walkRules((rule) => { - const matched = rule.selectors.some((selector) => { - try { - return chip.matches(cleanSelector(selector)); - } catch { - return false; - } - }); - if (!matched) return; - for (const node of rule.nodes) { - if (node.type === "decl" && (node.prop === "color" || node.prop === "background")) { - colours.push(`${node.prop}: ${node.value}`); - } - } - }); - main.remove(); - for (const colour of colours) { - expect(colour, `the farm-selection chip is styled with a destructive "${colour}"`).not.toContain("--danger"); - } - }); -}); diff --git a/web/src/routes/Login.test.tsx b/web/src/routes/Login.test.tsx index 2587b475f..f247c2b9a 100644 --- a/web/src/routes/Login.test.tsx +++ b/web/src/routes/Login.test.tsx @@ -6,6 +6,8 @@ import { ProtectedRoute } from "./ProtectedRoute"; import { renderWithProviders } from "../test/renderWithProviders"; import { login as apiLogin, ApiError, setOnUnauthenticated } from "../api/client"; import { setStoredToken } from "../test/jwt"; +import { bindAccount, bindFarm, farmBindingToken, clearBoundAccount } from "../auth/tokenStore"; +import { cacheBannerBytes, readCachedBannerBlob } from "../lib/bannerCache"; import i18n from "../i18n"; // Keep the real ApiError (Login branches on `instanceof ApiError`) but stub the @@ -49,14 +51,28 @@ function fillCredentials(email: string, password: string) { beforeEach(() => vi.resetAllMocks()); describe("Login", () => { + it("uses the flat inset Field Console auth frame", async () => { + renderWithProviders(tree(), { route: "/login", token: null }); + await screen.findByRole("button", { name: "Sign in" }); + + const main = screen.getByRole("main"); + const frame = main.querySelector(".MuiPaper-root"); + expect(frame).not.toBeNull(); + const styles = Array.from(document.styleSheets) + .flatMap((sheet) => Array.from(sheet.cssRules, (rule) => rule.cssText)).join(""); + expect(styles).toContain("background: var(--surface-2)"); + expect(styles).toContain("padding: 66px 14px 22px"); + expect(styles).toContain("border: 1px solid var(--hairline)"); + expect(styles).toContain("border-radius: 0"); + expect(styles).toContain("box-shadow: none"); + }); + it("renders its labels from the auth i18n catalog (#182)", async () => { renderWithProviders(tree(), { route: "/login", token: null }); - // Pinned to i18n.t, not the literal — proves the screen is reading the - // catalog rather than a string that happens to still match it. - expect(await screen.findByText(i18n.t("auth:title"))).toBeInTheDocument(); - expect(screen.getByLabelText(i18n.t("auth:email"))).toBeInTheDocument(); - expect(screen.getByLabelText(i18n.t("auth:password"))).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: i18n.t("auth:title"), level: 2 })).toBeInTheDocument(); + expect(screen.getByLabelText(new RegExp(`^${i18n.t("auth:email")}`))).toBeInTheDocument(); + expect(screen.getByLabelText(new RegExp(`^${i18n.t("auth:password")}`))).toBeInTheDocument(); expect(screen.getByRole("button", { name: i18n.t("auth:signIn") })).toBeInTheDocument(); }); @@ -326,7 +342,7 @@ describe("Login — first-run setup notice", () => { fireEvent.click(screen.getByRole("button", { name: "Sign in" })); }); - const notice = (await screen.findByText(i18n.t("auth:noAdminYet"))).closest(".auth-setup"); + const notice = (await screen.findByText(i18n.t("auth:noAdminYet"))).closest("[role='status']"); expect(notice).not.toBeNull(); expect(notice!.querySelector("code")).toBeNull(); expect(notice!.textContent).not.toMatch(/docker|dotnet|bootstrap-admin|--email/i); @@ -404,6 +420,10 @@ describe("Login — farm-code prefill and picker", () => { expect(farmField()).toHaveValue("cached-farm"); expect(screen.getByRole("group", { name: i18n.t("auth:recentFarms") })).toBeInTheDocument(); + expect(getComputedStyle(screen.getByRole("button", { name: "cached-farm" })).borderRadius).toBe("0px"); + expect(getComputedStyle(screen.getByRole("button", { + name: i18n.t("auth:forgetFarm", { farmCode: "cached-farm" }), + })).borderRadius).toBe("0px"); }); it("with several remembered codes leaves the field empty, shows one button per code, and a click fills the field", async () => { @@ -468,7 +488,7 @@ describe("Login — farm-code prefill and picker", () => { // 82fbb5b5 then replaced it with the node query that ships today, which is // stronger than either: a string matcher is still literal, but the node now has // the trimmed "farm from link" copy. Verified by mutation. - expect(document.querySelector(".auth-farm-source")).toBeNull(); + expect(screen.queryByText(/Signing in to farm/)).not.toBeInTheDocument(); }); // #535 review round 1 — the picker's a11y wiring (a role="group" labelled by @@ -634,4 +654,123 @@ describe("Login — forgetting a remembered farm", () => { expect(passwordField).toHaveAttribute("name", "password"); expect(passwordField).toHaveAttribute("autocomplete", "current-password"); }); + + it("paints the Forget glyph with --error and the select chip with no destructive colour", async () => { + localStorage.setItem("cluckwork.farmCodes", JSON.stringify(["farm-a"])); + renderWithProviders(tree(), { route: "/login", token: null }); + await screen.findByRole("button", { name: "Sign in" }); + + const forget = screen.getByRole("button", { name: i18n.t("auth:forgetFarm", { farmCode: "farm-a" }) }); + expect(getComputedStyle(forget).color).toBe("var(--error)"); + + const select = screen.getByRole("button", { name: "farm-a" }); + const selectStyle = getComputedStyle(select); + expect(selectStyle.color).not.toBe("var(--danger)"); + expect(selectStyle.color).not.toBe("var(--error)"); + expect(selectStyle.background).not.toBe("var(--danger)"); + }); +}); + +async function cacheBannerFor(slug: string, bytes = "AAAA") { + bindAccount("acct-A"); + bindFarm(slug); + await cacheBannerBytes(new Blob([bytes], { type: "image/png" }), farmBindingToken()); + clearBoundAccount(); // Login itself starts unbound, before any sign-in +} + +describe("Login — cached pre-auth banner (#833)", () => { + it("first visit: shows no banner image when nothing is cached", async () => { + localStorage.setItem("cluckwork.farmCodes", JSON.stringify(["farm-a"])); + renderWithProviders(tree(), { route: "/login", token: null }); + await screen.findByRole("button", { name: "Sign in" }); + + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + it("cached: shows the cached banner for the remembered farm, from a blob: URL, never data:", async () => { + localStorage.setItem("cluckwork.farmCodes", JSON.stringify(["farm-a"])); + await cacheBannerFor("farm-a"); + renderWithProviders(tree(), { route: "/login", token: null }); + await screen.findByRole("button", { name: "Sign in" }); + + const img = await waitFor(() => { + const found = document.querySelector("img[alt='']") as HTMLImageElement | null; + expect(found).not.toBeNull(); + return found!; + }); + expect(img.src).toMatch(/^blob:/); + expect(img.src).not.toMatch(/^data:/); + }); + + it("does not show a cached banner when the field holds a DIFFERENT code (two remembered farms — no single prefill)", async () => { + localStorage.setItem("cluckwork.farmCodes", JSON.stringify(["farm-a", "farm-b"])); + await cacheBannerFor("farm-a"); + renderWithProviders(tree(), { route: "/login", token: null }); + await screen.findByRole("button", { name: "Sign in" }); + + expect(document.querySelector("img[alt='']")).toBeNull(); + }); + + it("shows the banner once its farm is picked from the roster, with two farms remembered", async () => { + localStorage.setItem("cluckwork.farmCodes", JSON.stringify(["farm-a", "farm-b"])); + await cacheBannerFor("farm-a"); + renderWithProviders(tree(), { route: "/login", token: null }); + await screen.findByRole("button", { name: "Sign in" }); + expect(document.querySelector("img[alt='']")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "farm-a" })); + + await waitFor(() => expect(document.querySelector("img[alt='']")).not.toBeNull()); + }); + + it("hides the banner once the field is typed away from the cached farm", async () => { + localStorage.setItem("cluckwork.farmCodes", JSON.stringify(["farm-a"])); + await cacheBannerFor("farm-a"); + renderWithProviders(tree(), { route: "/login", token: null }); + await screen.findByRole("button", { name: "Sign in" }); + await waitFor(() => expect(document.querySelector("img[alt='']")).not.toBeNull()); + + fireEvent.change(screen.getByLabelText(/Farm code/), { target: { value: "farm-b" } }); + + await waitFor(() => expect(document.querySelector("img[alt='']")).toBeNull()); + }); + + it("shows no banner for a link-prefilled code, even when this device has that farm's banner cached", async () => { + await cacheBannerFor("link-farm"); + renderWithProviders(tree(), { route: "/login?farm=link-farm", token: null }); + await screen.findByRole("button", { name: "Sign in" }); + + await act(async () => { + await readCachedBannerBlob("link-farm"); + }); + + expect(document.querySelector("img[alt='']")).toBeNull(); + }); + + it("shows the banner once the link-prefilled code is retyped by hand", async () => { + await cacheBannerFor("link-farm"); + renderWithProviders(tree(), { route: "/login?farm=link-farm", token: null }); + await screen.findByRole("button", { name: "Sign in" }); + expect(document.querySelector("img[alt='']")).toBeNull(); + + const field = screen.getByLabelText(/Farm code/); + fireEvent.change(field, { target: { value: "link-far" } }); + fireEvent.change(field, { target: { value: "link-farm" } }); + + await waitFor(() => expect(document.querySelector("img[alt='']")).not.toBeNull()); + }); + + it("forget: the banner disappears once its farm is forgotten", async () => { + localStorage.setItem("cluckwork.farmCodes", JSON.stringify(["farm-a"])); + await cacheBannerFor("farm-a"); + renderWithProviders(tree(), { route: "/login", token: null }); + await screen.findByRole("button", { name: "Sign in" }); + await waitFor(() => expect(document.querySelector("img[alt='']")).not.toBeNull()); + + fireEvent.click(screen.getByRole("button", { name: i18n.t("auth:forgetFarm", { farmCode: "farm-a" }) })); + fireEvent.click(screen.getByRole("button", { name: i18n.t("auth:forgetFarmConfirm") })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + await waitFor(() => expect(document.querySelector("img[alt='']")).toBeNull()); + }); }); diff --git a/web/src/routes/Login.tsx b/web/src/routes/Login.tsx index 777ce8fd2..6e9e15192 100644 --- a/web/src/routes/Login.tsx +++ b/web/src/routes/Login.tsx @@ -2,15 +2,18 @@ import { useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router"; import { useTranslation } from "react-i18next"; +import { X } from "lucide-react"; +import { Alert, Box, IconButton, Stack, TextField, Typography } from "@mui/material"; import { useAuth } from "../auth/useAuth"; import { ApiError } from "../api/client"; +import { AuthShell } from "../components/AuthShell"; import { BusyButton } from "../components/BusyButton"; -import { ThemeToggle } from "../components/ThemeToggle"; import { useConfirm } from "../components/useConfirm"; import { usePendingAction } from "../components/usePendingAction"; import i18n from "../i18n"; import { canonicalFarmCode, readFarmCodes, removeFarmCode } from "../auth/farmCodeCache"; import { applyDeviceBrand } from "../lib/brand"; +import { useCachedBannerUrl } from "../lib/bannerCache"; interface LocationState { from?: { pathname: string }; @@ -105,6 +108,10 @@ export function Login() { const [farmCode, setFarmCode] = useState( () => urlFarmCode ?? (rememberedCodes.length === 1 ? rememberedCodes[0] : ""), ); + // Link-prefilled farm codes do not reveal cached farm-supplied imagery. + const [farmCodeEdited, setFarmCodeEdited] = useState(false); + const bannerLookupCode = urlFarmCode !== null && !farmCodeEdited ? "" : farmCode; + const cachedBanner = useCachedBannerUrl(bannerLookupCode); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); @@ -208,22 +215,27 @@ export function Login() { } return ( -
- -
-

{t("title")}

+ + ) : undefined} + > + + {t("title")} {needsSetup && ( -
- {/* No command is shown, deliberately. Earlier drafts printed the - setup invocation here and it was wrong twice over: the bare verb - was not runnable at all, and the corrected version had to show - two forms because the app cannot know how it was deployed. A - login screen is also the wrong place to publish deployment shape - to anonymous visitors. State the situation and point at the - person who can fix it; the exact steps live in the README. */} -

{t("noAdminYet")}

-

{t("noAdminYetHint")}

-
+ + {t("noAdminYet")} + {t("noAdminYetHint")} + )} {urlFarmCode !== null && ( // #535 — a same-origin link ?farm=attacker-farm would otherwise silently @@ -232,98 +244,104 @@ export function Login() { // turns a silent substitution into a visible one. NOT a complete fix: // the username is still not farm-qualified (epic #530 requires that; // tracked in its own issue). -

+ {t("farmFromLink", { farmCode: urlFarmCode })} -

+ )} {rememberedCodes.length >= 1 && ( // #587 — one or more remembered codes. The single-code case previously // had no picker at all, which also meant a single remembered farm could // never be forgotten without clearing the whole origin. -
-

{t("recentFarms")}

-
+ + + {t("recentFarms")} + + {rememberedCodes.map((code) => ( - + {/* The selection button's accessible name is EXACTLY the code: tests and password-manager heuristics both key on it, and a prefix match would collide with the Forget control's label. The code is button TEXT — React escapes it, and it has passed the slug regex, so it carries no markup. */} - + {/* type="button" so a tap asks rather than submits, and rather than removing: the destructive confirm dialog is the gate. */} - - + + + ))} -
-
+
+ )} - - - - {error &&

{error}

} + { + setFarmCodeEdited(true); + setFarmCode(e.target.value); + }} + required + slotProps={{ + htmlInput: { + autoCapitalize: "none", autoCorrect: "off", spellCheck: false, maxLength: 32, + }, + }} + /> + setEmail(e.target.value)} + autoComplete="username" + required + slotProps={{ htmlInput: { maxLength: 256 } }} + /> + setPassword(e.target.value)} + autoComplete="current-password" + required + slotProps={{ htmlInput: { maxLength: 256 } }} + /> + {error && {error}} {busy ? t("signingIn") : t("signIn")} - + {confirmDialog} -
+ ); } diff --git a/web/src/routes/SetPasswordPage.test.tsx b/web/src/routes/SetPasswordPage.test.tsx index 929b8ad8f..dafb44502 100644 --- a/web/src/routes/SetPasswordPage.test.tsx +++ b/web/src/routes/SetPasswordPage.test.tsx @@ -144,7 +144,7 @@ describe("SetPasswordPage i18n wiring (#283)", () => { it("interpolates {{min}} into the new-password label from the catalog", async () => { await withOverride("setPasswordNewLabel", "NEW-PW-MARKER {{min}} MARKER-END", async () => { renderWithProviders(, { token: PENDING_ADMIN }); - expect(screen.getByLabelText("NEW-PW-MARKER 12 MARKER-END")).toBeInTheDocument(); + expect(screen.getByLabelText("NEW-PW-MARKER 12 MARKER-END *")).toBeInTheDocument(); }); }); diff --git a/web/src/routes/SetPasswordPage.tsx b/web/src/routes/SetPasswordPage.tsx index 14fe1f843..93836c11e 100644 --- a/web/src/routes/SetPasswordPage.tsx +++ b/web/src/routes/SetPasswordPage.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; import type { FormEvent } from "react"; import { useTranslation } from "react-i18next"; +import { Alert, Stack, TextField, Typography } from "@mui/material"; import { changePassword, ApiError } from "../api/client"; import { useAuth } from "../auth/useAuth"; +import { AuthShell } from "../components/AuthShell"; import { BusyButton } from "../components/BusyButton"; -import { ThemeToggle } from "../components/ThemeToggle"; import { usePendingAction } from "../components/usePendingAction"; const MIN_LENGTH = 12; @@ -62,46 +63,38 @@ export function SetPasswordPage() { } return ( -
- -
-

{t("setPasswordHeading")}

-

{t("setPasswordHint")}

- - - - {error &&

{error}

} + + + {t("setPasswordHeading")} + {t("setPasswordHint")} + setTemporaryPassword(e.target.value)} + autoComplete="current-password" + required + slotProps={{ htmlInput: { maxLength: 256 } }} + /> + setNext(e.target.value)} + autoComplete="new-password" + required + slotProps={{ htmlInput: { minLength: MIN_LENGTH, maxLength: 256 } }} + /> + setConfirm(e.target.value)} + autoComplete="new-password" + required + slotProps={{ htmlInput: { maxLength: 256 } }} + /> + {error && {error}} {busy ? t("setPasswordSubmitting") : t("setPasswordButton")} @@ -111,7 +104,7 @@ export function SetPasswordPage() { - -
+ + ); } diff --git a/web/src/routes/SettingsPage.test.tsx b/web/src/routes/SettingsPage.test.tsx index 595afee2a..c6f25bd17 100644 --- a/web/src/routes/SettingsPage.test.tsx +++ b/web/src/routes/SettingsPage.test.tsx @@ -1,5 +1,8 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, within, fireEvent, act, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { SettingsPage, formatByteCap } from "./SettingsPage"; import { FarmContext } from "../farm/FarmContext"; import { @@ -11,8 +14,38 @@ import { ApiError } from "../api/client"; import { account, farmState } from "../test/fixtures"; import { BRANDS } from "../lib/brand"; import type { Brand } from "../lib/brand"; +import { bindAccount, bindFarm, farmBindingToken } from "../auth/tokenStore"; +import { cacheBannerBytes, readCachedBannerBlob } from "../lib/bannerCache"; import i18n from "../i18n"; +const stylesCss = readFileSync(resolve(process.cwd(), "src/styles.css"), "utf8"); +const srOnlyCss = stylesCss.match(/\.sr-only\s*\{[^}]*\}/)?.[0] ?? ""; + +function renderedStyleRules(): CSSStyleRule[] { + const collect = (rules: CSSRuleList): CSSStyleRule[] => Array.from(rules).flatMap((rule) => { + if ("selectorText" in rule && "style" in rule) return [rule as CSSStyleRule]; + return "cssRules" in rule ? collect((rule as CSSMediaRule).cssRules) : []; + }); + + return Array.from(document.styleSheets).flatMap((sheet) => collect(sheet.cssRules)); +} + +function renderedDeclaration(element: Element, property: string): string { + const generatedClasses = Array.from(element.classList).filter((name) => name.startsWith("css-")); + return renderedStyleRules().reduce((value, rule) => { + const matches = !rule.selectorText.includes(":") + && generatedClasses.some((name) => rule.selectorText.includes(`.${name}`)); + return matches ? rule.style.getPropertyValue(property).trim() || value : value; + }, ""); +} + +function renderedRule(element: Element, selectorSuffix: string): CSSStyleRule | undefined { + const generatedClass = Array.from(element.classList).find((name) => name.startsWith("css-")); + return generatedClass + ? renderedStyleRules().find((rule) => rule.selectorText.includes(`.${generatedClass}${selectorSuffix}`)) + : undefined; +} + // Mirrors PALETTE_LABEL_KEYS in SettingsPage.tsx — kept local to the test // rather than imported, since the production module no longer exports a // label lookup (the labels themselves live in the `settings` i18n catalog, @@ -92,15 +125,29 @@ const SETTINGS = ( let refreshed = 0; let refreshOk = true; -async function renderReady(payload: FarmSettings = SETTINGS()) { +type CollapsedSettingsSection = "Localization" | "Counting & sales" | "Date & time formats"; +const LOCALIZATION = ["Localization"] as const; +const COUNTING_SALES = ["Counting & sales"] as const; +const DATE_TIME_FORMATS = ["Date & time formats"] as const; + +async function renderReady( + payload: FarmSettings = SETTINGS(), + sections: readonly CollapsedSettingsSection[] = [], +) { mockGetSettings.mockResolvedValue(payload); const value = farmState({ farm: payload.settings, refresh: async () => { refreshed += 1; return refreshOk; }, }); const result = render( - ); - expect(await screen.findByLabelText("Farm name")).toBeInTheDocument(); + <> + + + ); + expect(await screen.findByRole("button", { name: "Localization" })).toBeInTheDocument(); + for (const section of sections) { + fireEvent.click(screen.getByRole("button", { name: section })); + } return result; } @@ -135,10 +182,13 @@ describe("SettingsPage loading", () => { render(); expect(screen.getByText("Loading…")).toBeInTheDocument(); - expect(await screen.findByLabelText("Farm name")).toHaveValue("Hen House"); - expect(screen.getByLabelText("Timezone")).toHaveValue("America/Los_Angeles"); - expect(screen.getByLabelText("Locale")).toHaveValue("en-US"); - expect(screen.getByLabelText("Currency")).toHaveValue("USD"); + await screen.findByRole("button", { name: "Localization" }); + fireEvent.click(screen.getByRole("button", { name: "Localization" })); + fireEvent.click(screen.getByRole("button", { name: "Date & time formats" })); + expect(screen.getByLabelText("Farm name *")).toHaveValue("Hen House"); + expect(screen.getByLabelText("Timezone *")).toHaveValue("America/Los_Angeles"); + expect(screen.getByLabelText("Locale *")).toHaveValue("en-US"); + expect(screen.getByLabelText("Currency *")).toHaveValue("USD"); expect(screen.getByLabelText("Unit system")).toHaveValue("Metric"); expect(screen.getByLabelText("First day of week")).toHaveValue("Monday"); expect(screen.getByLabelText("Date format")).toHaveValue("dd/MM/yyyy"); @@ -151,7 +201,8 @@ describe("SettingsPage loading", () => { dateFormatOverride: "dd-MM-yy", timeFormatOverride: "HHmm", })); render(); - await screen.findByLabelText("Farm name"); + await screen.findByRole("button", { name: "Date & time formats" }); + fireEvent.click(screen.getByRole("button", { name: "Date & time formats" })); expect(screen.getByLabelText("Date format")).toHaveValue("__custom__"); expect(screen.getByLabelText("Custom date format")).toHaveValue("dd-MM-yy"); @@ -164,7 +215,8 @@ describe("SettingsPage loading", () => { dateFormatOverride: "yyyy-MM-dd", timeFormatOverride: "HH:mm", })); render(); - await screen.findByLabelText("Farm name"); + await screen.findByRole("button", { name: "Date & time formats" }); + fireEvent.click(screen.getByRole("button", { name: "Date & time formats" })); expect(screen.getByLabelText("Date format")).toHaveValue("yyyy-MM-dd"); expect(screen.queryByLabelText("Custom date format")).not.toBeInTheDocument(); @@ -183,9 +235,9 @@ describe("SettingsPage loading", () => { describe("SettingsPage saving", () => { it("sends every field, the base version, and an idempotency key", async () => { mockUpdate.mockResolvedValue(undefined); - await renderReady(); + await renderReady(SETTINGS(), ["Localization", "Date & time formats"]); - fireEvent.change(screen.getByLabelText("Farm name"), { target: { value: "Coop Co" } }); + fireEvent.change(screen.getByLabelText("Farm name *"), { target: { value: "Coop Co" } }); fireEvent.change(screen.getByLabelText("Unit system"), { target: { value: "Imperial" } }); fireEvent.change(screen.getByLabelText("First day of week"), { target: { value: "Sunday" } }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); @@ -213,7 +265,10 @@ describe("SettingsPage saving", () => { it("sends a blank override as null, not an empty string", async () => { mockUpdate.mockResolvedValue(undefined); - await renderReady(SETTINGS({ firstDayOfWeek: "Monday", dateFormatOverride: "dd/MM/yyyy" })); + await renderReady( + SETTINGS({ firstDayOfWeek: "Monday", dateFormatOverride: "dd/MM/yyyy" }), + DATE_TIME_FORMATS, + ); fireEvent.change(screen.getByLabelText("First day of week"), { target: { value: "" } }); // #452 — realistic path: switch the dropdown to "Custom…", which reveals @@ -230,7 +285,7 @@ describe("SettingsPage saving", () => { it("sends a preset picked directly from the date/time format dropdowns, no custom field involved (#452)", async () => { mockUpdate.mockResolvedValue(undefined); - await renderReady(); + await renderReady(SETTINGS(), DATE_TIME_FORMATS); fireEvent.change(screen.getByLabelText("Date format"), { target: { value: "yyyy-MM-dd" } }); fireEvent.change(screen.getByLabelText("Time format"), { target: { value: "HH:mm" } }); @@ -245,7 +300,7 @@ describe("SettingsPage saving", () => { it("reveals the custom field on Custom…, and re-hides it (discarding the typed value) if a preset is picked afterward (#452)", async () => { mockUpdate.mockResolvedValue(undefined); - await renderReady(); + await renderReady(SETTINGS(), DATE_TIME_FORMATS); fireEvent.change(screen.getByLabelText("Date format"), { target: { value: "__custom__" } }); const customField = screen.getByLabelText("Custom date format"); @@ -262,10 +317,10 @@ describe("SettingsPage saving", () => { it("uppercases the currency and trims the text fields", async () => { mockUpdate.mockResolvedValue(undefined); - await renderReady(); + await renderReady(SETTINGS(), LOCALIZATION); - fireEvent.change(screen.getByLabelText("Currency"), { target: { value: "eur" } }); - fireEvent.change(screen.getByLabelText("Farm name"), { target: { value: " Coop Co " } }); + fireEvent.change(screen.getByLabelText("Currency *"), { target: { value: "eur" } }); + fireEvent.change(screen.getByLabelText("Farm name *"), { target: { value: " Coop Co " } }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); const [body] = mockUpdate.mock.calls[0]; @@ -275,20 +330,20 @@ describe("SettingsPage saving", () => { it("re-reads the settings and refreshes the shell, so the change shows without a reload", async () => { mockUpdate.mockResolvedValue(undefined); - await renderReady(); + await renderReady(SETTINGS(), LOCALIZATION); mockGetSettings.mockResolvedValue(SETTINGS({ name: "Coop Co", version: 8 })); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); // The new version is what the NEXT save must carry — a screen still holding // 7 would 409 against its own write. - expect(await screen.findByLabelText("Farm name")).toHaveValue("Coop Co"); + expect(await screen.findByLabelText("Farm name *")).toHaveValue("Coop Co"); expect(refreshed).toBe(1); }); it("carries a fresh idempotency key into the second save", async () => { mockUpdate.mockResolvedValue(undefined); - await renderReady(); + await renderReady(SETTINGS(), LOCALIZATION); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); @@ -301,7 +356,7 @@ describe("SettingsPage saving", () => { }); it("re-uses the key when the SAME payload is retried after a failure", async () => { - await renderReady(); + await renderReady(SETTINGS(), LOCALIZATION); mockUpdate.mockRejectedValueOnce(new Error("connection lost")); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); @@ -314,11 +369,11 @@ describe("SettingsPage saving", () => { }); it("takes a NEW key when the payload changed after a failure", async () => { - await renderReady(); + await renderReady(SETTINGS(), LOCALIZATION); mockUpdate.mockRejectedValueOnce(new Error("connection lost")); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); - fireEvent.change(screen.getByLabelText("Farm name"), { target: { value: "Coop Co" } }); + fireEvent.change(screen.getByLabelText("Farm name *"), { target: { value: "Coop Co" } }); mockUpdate.mockResolvedValueOnce(undefined); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); @@ -382,22 +437,22 @@ describe("SettingsPage saving", () => { }); it("warns when the timezone is one this browser cannot format", async () => { - await renderReady(); - fireEvent.change(screen.getByLabelText("Timezone"), { target: { value: "Mars/Olympus_Mons" } }); + await renderReady(SETTINGS(), LOCALIZATION); + fireEvent.change(screen.getByLabelText("Timezone *"), { target: { value: "Mars/Olympus_Mons" } }); // The server validates against ITS tzdata. A zone it accepts but the // browser cannot format saves fine and then sends every date field back to // the device's day — silently, without this. expect(screen.getByText(/does not know that timezone/)).toBeInTheDocument(); - const tz = screen.getByLabelText("Timezone"); + const tz = screen.getByLabelText("Timezone *"); const describedBy = tz.getAttribute("aria-describedby"); expect(describedBy).toBeTruthy(); expect(document.getElementById(describedBy!)).toHaveTextContent(/does not know that timezone/); }); it("says nothing about a timezone the browser does know", async () => { - await renderReady(); - fireEvent.change(screen.getByLabelText("Timezone"), { target: { value: "Asia/Tokyo" } }); + await renderReady(SETTINGS(), LOCALIZATION); + fireEvent.change(screen.getByLabelText("Timezone *"), { target: { value: "Asia/Tokyo" } }); expect(screen.queryByText(/does not know that timezone/)).not.toBeInTheDocument(); }); @@ -434,7 +489,7 @@ describe("SettingsPage stepper unit (#444)", () => { ...CONVERSIONS, { id: "c4", unitCode: "Case", eggsPerUnit: 360, active: false, version: 0 }, ]); - await renderReady(SETTINGS({ defaultStepperUnit: "Tray" })); + await renderReady(SETTINGS({ defaultStepperUnit: "Tray" }), COUNTING_SALES); expect(select()).toHaveValue("Tray"); const options = within(select()).getAllByRole("option").map((o) => o.textContent); @@ -443,7 +498,7 @@ describe("SettingsPage stepper unit (#444)", () => { it("sends the picked unit on save", async () => { mockUpdate.mockResolvedValue(undefined); - await renderReady(); + await renderReady(SETTINGS(), COUNTING_SALES); fireEvent.change(select(), { target: { value: "Tray" } }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); @@ -458,7 +513,7 @@ describe("SettingsPage stepper unit (#444)", () => { { id: "c1", unitCode: "Individual", eggsPerUnit: 1, active: true, version: 0 }, { id: "c3", unitCode: "Tray", eggsPerUnit: 30, active: false, version: 0 }, ]); - await renderReady(SETTINGS({ defaultStepperUnit: "Tray" })); + await renderReady(SETTINGS({ defaultStepperUnit: "Tray" }), COUNTING_SALES); expect(select()).toHaveValue("Individual"); }); @@ -470,7 +525,7 @@ describe("SettingsPage worker sale allocation policy (#612)", () => { const select = () => screen.getByLabelText("Worker sale allocation"); it("shows both policy choices and selects the stored default", async () => { - await renderReady(SETTINGS({}, true, "AssignedFlocksOnly")); + await renderReady(SETTINGS({}, true, "AssignedFlocksOnly"), COUNTING_SALES); expect(select()).toHaveValue("AssignedFlocksOnly"); const options = within(select()).getAllByRole("option").map((o) => o.textContent); @@ -478,13 +533,13 @@ describe("SettingsPage worker sale allocation policy (#612)", () => { }); it("selects the opted-in farm-wide policy when that is what is stored", async () => { - await renderReady(SETTINGS({}, true, "AllFarmFlocks")); + await renderReady(SETTINGS({}, true, "AllFarmFlocks"), COUNTING_SALES); expect(select()).toHaveValue("AllFarmFlocks"); }); it("sends the picked policy on save", async () => { mockUpdate.mockResolvedValue(undefined); - await renderReady(); + await renderReady(SETTINGS(), COUNTING_SALES); fireEvent.change(select(), { target: { value: "AllFarmFlocks" } }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); @@ -502,22 +557,22 @@ describe("SettingsPage maximum discount (#727)", () => { const field = () => screen.getByLabelText("Maximum discount"); it("leaves the field blank for a farm with no ceiling", async () => { - await renderReady(SETTINGS({}, true, "AssignedFlocksOnly", null)); + await renderReady(SETTINGS({}, true, "AssignedFlocksOnly", null), COUNTING_SALES); expect(field()).toHaveValue(null); }); it("loads a stored ceiling of 0 as 0, not as blank", async () => { - await renderReady(SETTINGS({}, true, "AssignedFlocksOnly", 0)); + await renderReady(SETTINGS({}, true, "AssignedFlocksOnly", 0), COUNTING_SALES); expect(field()).toHaveValue(0); }); it("loads a stored ceiling", async () => { - await renderReady(SETTINGS({}, true, "AssignedFlocksOnly", 10)); + await renderReady(SETTINGS({}, true, "AssignedFlocksOnly", 10), COUNTING_SALES); expect(field()).toHaveValue(10); }); it("accepts only whole percents from 0 to 100", async () => { - await renderReady(); + await renderReady(SETTINGS(), COUNTING_SALES); expect(field()).toHaveAttribute("type", "number"); expect(field()).toHaveAttribute("min", "0"); expect(field()).toHaveAttribute("max", "100"); @@ -526,7 +581,7 @@ describe("SettingsPage maximum discount (#727)", () => { it("sends null when the field is cleared, which removes the ceiling", async () => { mockUpdate.mockResolvedValue(undefined); - await renderReady(SETTINGS({}, true, "AssignedFlocksOnly", 10)); + await renderReady(SETTINGS({}, true, "AssignedFlocksOnly", 10), COUNTING_SALES); fireEvent.change(field(), { target: { value: "" } }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); @@ -536,7 +591,7 @@ describe("SettingsPage maximum discount (#727)", () => { it("sends 0 when the field holds 0, which is a ceiling and not an absence", async () => { mockUpdate.mockResolvedValue(undefined); - await renderReady(); + await renderReady(SETTINGS(), COUNTING_SALES); fireEvent.change(field(), { target: { value: "0" } }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); @@ -546,7 +601,7 @@ describe("SettingsPage maximum discount (#727)", () => { it("sends the typed percent on save", async () => { mockUpdate.mockResolvedValue(undefined); - await renderReady(); + await renderReady(SETTINGS(), COUNTING_SALES); fireEvent.change(field(), { target: { value: "15" } }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); @@ -555,12 +610,22 @@ describe("SettingsPage maximum discount (#727)", () => { }); it("reads its hint from the settings catalog, not a hardcoded literal", async () => { - await renderReady(); + await renderReady(SETTINGS(), COUNTING_SALES); expect(screen.getByText(i18n.t("settings:maxDiscountPercentHint"))).toBeInTheDocument(); }); }); describe("SettingsPage palette (#149)", () => { + it("uses compact rectangular labelled swatch buttons", async () => { + await renderReady(SETTINGS({ brand: "forest" })); + + const label = screen.getByRole("radio", { name: "Forest" }).closest("label"); + expect(label).not.toBeNull(); + const style = getComputedStyle(label!); + expect(style.borderRadius).toBe("4px"); + expect(style.padding).toBe("6px 10px"); + }); + it("renders a swatch for every curated palette, with the current one selected", async () => { await renderReady(SETTINGS({ brand: "forest" })); @@ -623,31 +688,26 @@ describe("SettingsPage palette (#149)", () => { describe("SettingsPage currency lock (§4.6)", () => { it("leaves the field editable while the farm has recorded nothing", async () => { - await renderReady(SETTINGS({}, true)); - const currency = screen.getByLabelText("Currency"); + await renderReady(SETTINGS({}, true), LOCALIZATION); + const currency = screen.getByLabelText("Currency *"); expect(currency).not.toHaveAttribute("readonly"); - expect(currency).not.toHaveClass("locked"); expect(currency).not.toHaveAttribute("aria-describedby"); expect(screen.queryByText(/currency is fixed at/i)).not.toBeInTheDocument(); }); it("locks it with the reason once amounts exist — before the user meets the 422", async () => { - await renderReady(SETTINGS({}, false)); - const currency = screen.getByLabelText("Currency"); + await renderReady(SETTINGS({}, false), LOCALIZATION); + const currency = screen.getByLabelText("Currency *"); // readOnly rather than disabled: a disabled control leaves the tab order, // taking the explanation with it. expect(currency).toHaveAttribute("readonly"); expect(currency).not.toBeDisabled(); - // The locked LOOK hangs off this class, not off `input:read-only` — that - // pseudo-class also matches every checkbox, radio and file input in the - // app, which the blanket rule greyed out (round 2: codex + agent). - expect(currency).toHaveClass("locked"); expect(screen.getByText(/The currency is fixed at USD/)).toBeInTheDocument(); }); it("names the field 'Currency' and carries the reason as a DESCRIPTION", async () => { - await renderReady(SETTINGS({}, false)); - const currency = screen.getByLabelText("Currency"); + await renderReady(SETTINGS({}, false), LOCALIZATION); + const currency = screen.getByLabelText("Currency *"); // A note nested inside the