diff --git a/docs/designs/822-mui-revamp.md b/docs/designs/822-mui-revamp.md index 111ff32ea..453acd4c8 100644 --- a/docs/designs/822-mui-revamp.md +++ b/docs/designs/822-mui-revamp.md @@ -198,7 +198,7 @@ All from `@mui/material` 9.4.0 (`package-lock.json`). No `@mui/x-*` package; no **D3.1 Desktop (1280) is A, the ledger.** No `Card`, no `Paper` with elevation on any screen. The page is `Container maxWidth={false} sx={{ maxWidth: 1120 }}` (`maxWidth` is typed `Breakpoint | false`, so the pixel value goes through `sx`; matches `.content` L1559-1563), a `Typography h2` title row, then ruled content: `Table` for records, `Divider`-ruled regions for drill-downs and dashboard sections. The only floats are the ones #651 allow-lists: dialogs, the sticky bars, the picker popover, the update banner, the auth card. -**D3.2 Phone (390) is B, field-first.** Cards exist for houses only: the Dashboard's capture status renders one `Card variant="outlined"` per flock in a two-column `Grid`, missing first, the "no entry" `Chip` on the card (today L1754-1802). Every other surface is full-width, stacked in a `Stack spacing={2}`, no cards. Tables at 390 are `TableContainer` with horizontal scroll (today's `--scroll-cue`, L33 light and L203 dark, #150), first column sticky; a slice may replace a table with a `List` of two-line rows **only when its 390 screenshot shows the table unreadable**, and says so. +**D3.2 Phone (390) is B, field-first.** ~~Cards exist for houses only: the Dashboard's capture status renders one `Card variant="outlined"` per flock in a two-column `Grid`, missing first, the "no entry" `Chip` on the card (today L1754-1802).~~ **Superseded by #829's amendment below**: DIRECTION.md's confirmed phone Today is the ruled list by default (the `tiles` variant this sentence described is the alternative DIRECTION.md's own spec names and #829 did not build), so the Dashboard carries no house `Card` at 390 — see the D3.3 amendment. Every other surface is full-width, stacked in a `Stack spacing={2}`, no cards. Tables at 390 are `TableContainer` with horizontal scroll (today's `--scroll-cue`, L33 light and L203 dark, #150), first column sticky; a slice may replace a table with a `List` of two-line rows **only when its 390 screenshot shows the table unreadable**, and says so. One breakpoint. MUI's default `md` is 900px, the same number as the app's only width query, so `theme.breakpoints.down("md")` reproduces the boundary with no configuration, to within MUI's `step` (it emits `max-width: 899.95px`, so exactly 900 CSS px reads as desktop to MUI and as phone to the stylesheet and to `BottomNav.tsx:40`'s `min-width: 901px`; no capture viewport sits there). Do not add breakpoints; where a panel's own width matters, use a container query as `.panel:has(.dash-list)` already does. @@ -212,6 +212,8 @@ One breakpoint. MUI's default `md` is 900px, the same number as the app's only w | CRUD lists (#832: Customers, Products, Grades, Flocks, Users) | title row with the "New" `Button`, `Table`, `Dialog` forms. | same, `Dialog fullScreen` below 900px because a phone keyboard covers half a bottom sheet and B wants one task per screen (**owner review**). Confirmations stay centred. | | Tail (#833: Settings, Help, Login, Audit, Export, plus Account, SetPassword) | Settings as one `Stack` of `TextField`s with the `.actions` row; Help keeps its docs layout as `Container maxWidth="md"` prose; Login keeps the hero gradient and its floating card; Audit is a ledger; Export is a `List` of actions. | same parts stacked; Login card full-width with no gradient bleed. | +**Amendment (#829, landed after #864's DIRECTION.md was confirmed):** the Dashboard row above predates the owner's confirmed mockup and disagrees with it in three places, which AGENTS.md's rule resolves in DIRECTION.md's favour (confirmed later). #829 built the confirmed shape, not the row above: a two-column layout at 1280 (Today and Recent sales left, Eggs on hand and Last 14 days in a 320px right column across a hairline, not "four ruled sections in one column"), the attention line as a plain text line with a dot mark and ruled separators rather than a boxed `Alert severity="warning"`, and no house `Card`s at 390 — DIRECTION.md's default phone Today is the ruled list (missing house as a full-width `--tint-warn` band with a `--warn` left rule and a 48px "Record …" button), not the `tiles` variant. See [issue #829](https://github.com/mforce/cluckwork/issues/829) and [issue #864](https://github.com/mforce/cluckwork/issues/864) (owner comments, 2026-09-16) for the confirmation. + **D3.4 The phone action-button rule (#740).** Below 900px an action row (`.actions`, `DialogActions`, the entry footer) **stacks vertically and each button is full width**: `flexDirection: column`, `alignItems: stretch` (MUI's `DialogActions` root sets `alignItems: center`, `DialogActions.js:39`, which would leave stacked buttons at intrinsic width; `.actions` already stretches by default), `flex: 1 1 100%`, DOM order preserved (the primary is already first, `SalesPage.tsx:1493`). At 1280 buttons are `flex: 0 0 auto`, never `flex: 1`. The pill radius stays; it never resolves to an ellipse because a full-width button at 390 is about 353px wide (390 less `.content`'s 1.15rem side padding, L2830) and one line tall (the #740 measurement `91.5 x 103.2` at 390 becomes about `353 x 46`). This is #740's option 1 and it needs no per-label tuning, so `tl`'s longer strings are covered by construction. **The rule lands in #823, and at #823 it is a CSS change, not a theme one.** Until #827 every dialog foot is `Dialog.tsx`'s raw ` - + + {/* --tabbar-h (styles.css) is the bar's whole box — 3.6rem of content + plus the safe-area inset — and every OTHER sticky footer reads it + as that combined offset unchanged. The bar itself must not take + that whole value as its content height, or the actions centre + inside the inset and the labels sit under a notched phone's home + indicator: content gets exactly 3.6rem, and the inset becomes its + own bottom padding underneath (#883 round 2, finding 3). */} + + {tabs.map((e) => ( + } + /> + ))} + } + className={onATab ? undefined : "active"} + aria-haspopup="dialog" + aria-expanded={moreOpen} + aria-current={onATab ? undefined : "page"} + onClick={() => setMoreOpen(true)} + /> + + {/* The full map, so nothing is unreachable — the tabs are shortcuts into it, not a smaller menu. Dialog gives the focus trap, scroll lock and diff --git a/web/src/components/DayStrip.styles.test.ts b/web/src/components/DayStrip.styles.test.ts new file mode 100644 index 000000000..c7858ae52 --- /dev/null +++ b/web/src/components/DayStrip.styles.test.ts @@ -0,0 +1,138 @@ +// web/src/components/DayStrip.styles.test.ts +// +// #829 — successor to `styles.test.ts`'s "dashboard surfaces (#654, INV-8)" +// block, which retired in this PR (D8: `#829` deletes the Dashboard's own +// `.capture-*`/`.stock-ledger`/`.dash-list`/`.panel-wide` rules, all styled +// through `sx` now). `DayStrip` and `StockBar` are KEPT components (D2 pair +// 21 — bespoke data marks with no MUI equivalent), and their CSS +// (`.trend-*`, `.daystrip`, `.day*`, `.tip*`, `.avgline`, `.meter-stack`) +// stays untouched, so the assertions ABOUT that CSS move here rather than +// disappearing with the block that used to carry them. +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { literalColourIn } from "../test/cssTokens"; + +// Relative path held in a variable, not an inline literal: Vite's +// import-analysis plugin statically pattern-matches +// `new URL("literal", import.meta.url)` and rewrites it to a dev-server asset +// URL under the jsdom test environment, which fileURLToPath() then rejects as +// "not scheme file" (see src/test/cssTokens.ts). +const CSS_REL = "../styles.css"; +const css = readFileSync(fileURLToPath(new URL(CSS_REL, import.meta.url)), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, ""); +// Any rule that APPLIES to one of these surfaces, not only one whose selector +// starts with it: `.unrelated, .day:hover { … }` reaches the slot just as +// surely, and an anchored match would walk straight past it. +const TOUCHES = /(^|[\s,>+~])\.(trend[a-z-]*|daystrip|day|day-week|tip|tipdock|avgline|meter-stack)\b/; +const blocks = Array.from(css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) + .map((m) => ({ selector: m[1].trim(), body: m[2] })) + .filter((b) => TOUCHES.test(b.selector)); +const bodyOf = (selector: string) => { + const b = blocks.find((x) => x.selector === selector); + expect(b, `${selector} must be declared`).toBeDefined(); + return b!.body; +}; + +describe("DayStrip / StockBar surfaces (#654, INV-8, #829)", () => { + it("declares the day strip and stacked meter rules", () => { + for (const s of [ + ".trend-scale", ".daystrip", ".day", ".day > i", ".day.on", ".day.on > i", ".day.on::after", + ".day-partial > i", ".day.on.day-partial > i", + ".day-week", ".avgline", ".tipdock", ".tip", ".trend-rule", ".trend-kpi", + ".meter-stack", ".meter-stack > span", + ]) + bodyOf(s); + expect(blocks.length).toBeGreaterThanOrEqual(14); + }); + + it("carries no box-shadow, text-transform, transition, animation or literal colour on any of them", () => { + for (const b of blocks) { + expect(b.body, b.selector).not.toMatch(/box-shadow|text-transform|transition|animation/); + // Enumerating colour syntaxes is a losing game (hex, rgb(), hsl(), named, + // oklch(), color-mix()), so require the opposite: every colour-valued + // declaration resolves through a token, or is one of the few keywords + // that carry no colour of their own. + for (const decl of b.body.split(";")) { + const [rawProp, ...rest] = decl.split(":"); + const prop = rawProp.trim(); + const value = rest.join(":").trim(); + if (!value) continue; + // Only properties that can carry a COLOUR. `border(-)?` is the + // shorthand; `border-radius` / `-width` / `-style` are not colours and + // matching them here made a plain `3px 3px 0 0` look untokenised + // (#777). `border-color` is already caught by the `-color$` branch, and + // `border-image` is listed because it is the one other border longhand + // that can carry one. + if (!/(^|-)color$|^background(-color|-image)?$|^border(-(top|right|bottom|left|block|inline|image))?$|^stroke$|^fill$|^outline(-color)?$/.test(prop)) continue; + const tokenised = value.includes("var(--") + || /^(inherit|initial|unset|revert|none|transparent|currentColor)$/i.test(value); + expect(tokenised, `${b.selector}: "${prop}: ${value}" must resolve through a token`).toBe(true); + // Containing a token is necessary but not sufficient: a value can mix a + // literal INTO one. `color-mix(in oklab, #ff0000 7%, var(--surface))` + // passed the check above, and #777 introduced this stylesheet's first + // color-mix, so the hole went from inert to live on a dashboard surface. + // The first patch enumerated hex and four colour functions and missed + // NAMED colours, so `color-mix(in oklab, red 7%, var(--surface))` still + // walked through; literalColourIn strips the var() references and + // inspects whatever is left. + const literal = literalColourIn(value); + expect(literal, `${b.selector}: "${prop}: ${value}" carries the literal colour "${literal}" beside its token`).toBeNull(); + } + } + }); + + // #780 — the arrow under the day readout shipped 4/5ths hidden behind the box + // it points from, and every guard here stayed green: the selector was + // declared and its colours were tokenised, which is all this file used to + // ask. Both facts that made it wrong are asserted now. + // + // A CSS triangle's visible wedge is its TOP border, so a bottom border is + // 5px of invisible box between the wedge and what it points at — with + // `bottom: 100%` pinning the box to the slot, that gap pushes the wedge up + // behind the readout. The third side must be zero. + it("points the day readout's arrow at the slot, with no bottom border to push it off", () => { + const arrow = bodyOf(".day.on::after"); + expect(arrow).toMatch(/bottom:\s*100%/); + // A visible wedge: the top border carries the ink, the sides are clear. + expect(arrow).toMatch(/border-top-color:\s*var\(--ink\)/); + expect(arrow).toMatch(/border-color:\s*transparent/); + // Three values, the last a bare 0 — `5px 5px 0`. A fourth value, or a + // single one, reinstates the bottom border and the arrow disappears again. + const width = /border-width:\s*([^;]+)/.exec(arrow)?.[1].trim(); + expect(width, "border-width must be the three-value form").toBeDefined(); + const parts = width!.split(/\s+/); + expect(parts).toHaveLength(3); + expect(parts[2], "the bottom border must be 0 or the arrow hides behind the box").toBe("0"); + // Nothing may move it back up: `bottom: 100%` means a positive + // `margin-bottom` pushes AWAY from the slot, which is how this shipped. + expect(arrow).not.toMatch(/margin-bottom/); + }); + + // The selection ring must not be drawn inside the slot: a bar is 18px in a + // ~20px slot and `.day.on > i` paints it the ring's own colour, so an inset + // ring vanished on the peak day — the one most likely to be inspected. + it("draws the selected day's ring outside the slot, clear of its own bar", () => { + const offset = /outline-offset:\s*(-?[\d.]+)px/.exec(bodyOf(".day.on"))?.[1]; + expect(offset, "outline-offset must be declared in px").toBeDefined(); + expect(Number(offset)).toBeGreaterThan(0); + // And inside the 4px inter-slot gap, so two adjacent rings cannot touch. + expect(Number(offset)).toBeLessThan(2); + }); + + // A recorded day that produced nothing is a 2% bar, which is 1.58px on a 5rem + // strip — the entire visible difference between "the flock laid nothing" and + // "nobody looked". A percentage floor alone is not a legible mark. + it("gives the shortest bar a pixel floor, not only a percentage one", () => { + expect(bodyOf(".day > i")).toMatch(/min-height:\s*[3-9]px/); + }); + + it("keeps the production bar on the accent token and the grade meter unopinionated", () => { + // Eggs per day is the farm's own measure, so the bar carries the brand + // accent. Grade bands deliberately do NOT (see the --grade-N tokens in + // styles.grades.test.ts): a grade must not change colour with the farm's + // palette, so the shared track rule itself declares no background. + expect(bodyOf(".day > i")).toMatch(/background:\s*var\(--stat-accent\)/); + expect(bodyOf(".meter-stack > span")).not.toMatch(/background/); + }); +}); diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx index 7de523784..182dc3767 100644 --- a/web/src/components/ThemeToggle.tsx +++ b/web/src/components/ThemeToggle.tsx @@ -1,16 +1,21 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Moon, Sun } from "lucide-react"; +import { Button, IconButton } from "@mui/material"; import { applyTheme, initialTheme, type Theme } from "../lib/theme"; -// Light/night switch (#52). Shared by the sidebar and the login screen. Reads -// the resolved theme for its own label. +// Light/night switch (#52). Shared by the sidebar, the More sheet and the +// login screen. Reads the resolved theme for its own label. // // It deliberately does NOT track live OS changes (#149). The pre-paint script // always writes a concrete data-theme, so the old `!dataset.theme` guard could // never fire again — dead code that would have left the page dark while this // button still said "Switch to night mode". An OS flip mid-session now waits // for a reload; first-visit and cross-visit OS respect are both unaffected. +// +// D2 pair 19 (#829): `IconButton` where a caller renders it icon-only +// (`showLabel={false}`, the two auth screens), `Button variant="text"` where +// it carries a label (the sidebar and the More sheet). export function ThemeToggle({ className = "", showLabel = true, @@ -25,15 +30,28 @@ export function ThemeToggle({ setTheme(next); } + const label = theme === "dark" ? t("switchToLightMode") : t("switchToNightMode"); + const icon = theme === "dark" ? : ; + + if (!showLabel) { + return ( + + {icon} + + ); + } + return ( - + {theme === "dark" ? t("light") : t("night")} + ); } diff --git a/web/src/farm/useFormat.ts b/web/src/farm/useFormat.ts index b0c910d38..8f6816280 100644 --- a/web/src/farm/useFormat.ts +++ b/web/src/farm/useFormat.ts @@ -1,8 +1,8 @@ import { useMemo } from "react"; import { useFarm } from "./useFarm"; -import { DEFAULT_LOCALE, formatCount, formatDate, formatMoney } from "../lib/format"; +import { DEFAULT_LOCALE, formatCount, formatDate, formatMoney, formatTime } from "../lib/format"; -// §4.5 formatting bound to the farm (#650): one hook, three formatters, so a +// §4.5 formatting bound to the farm (#650): one hook, four formatters, so a // screen never reads `farm.locale` itself and never reaches for the UI // language. Before /account resolves — and outside a provider, in tests — the // default locale applies; the figures are right either way, only the @@ -11,10 +11,14 @@ export function useFormat() { const { farm } = useFarm(); const locale = farm?.locale ?? DEFAULT_LOCALE; const dateOverride = farm?.dateFormatOverride ?? null; + // Farm-local, never the browser's — undefined when no farm has resolved + // yet, the same degrade lib/relativeTime.ts's `timeZone` param uses. + const timeZone = farm?.timeZoneId; return useMemo(() => ({ money: (minorUnits: number, currencyCode: string, minorUnit: number) => formatMoney(minorUnits, currencyCode, minorUnit, locale), count: (value: number, fractionDigits?: number) => formatCount(value, locale, fractionDigits), date: (isoDate: string) => formatDate(isoDate, locale, dateOverride), - }), [locale, dateOverride]); + time: (iso: string | null) => (iso === null ? null : formatTime(iso, locale, timeZone)), + }), [locale, dateOverride, timeZone]); } diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 27c2f2958..2c682a8a4 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -757,10 +757,45 @@ export const en = { // bespoke warn badge, not a StatusBadge — is the loudest thing on the // screen and links straight to Daily entry for that flock and day. todayPanelTitle: "Today", + // "3 of 4 houses in" — the section-head caption (DIRECTION.md, #829). + // i18next selects the plural form on {{count}} (CodeRabbit, PR #883 + // round 1: the single-key version rendered "1 of 1 houses in"); the same + // value renders in the string, so `count` is both the selector and the + // figure. + todayInCount_one: "{{in}} of {{count}} house in", + todayInCount_other: "{{in}} of {{count}} houses in", noFlocksMessage: "No flocks yet — create one on the Daily entry page.", noEntryBadge: "No entry", - // {{total}} is the farm-locale formatted figure (#650). - todayEggsTotal: "{{total}} eggs today", + // Fixed text beside the numeral, not a sentence the total is + // interpolated into (owner's read of the #883 screenshots, 2026-09-16, + // amending D3.3's "{{total}} eggs today"). The figure renders separately, + // in its own numeral element. + todaySoFarLabel: "Today so far", + // Owner amendment on #864 (2026-09-16): a reference under the running + // total, sourced from the 14-day strip's own last (yesterday) slot — + // never a second fetch, and only rendered when that day is complete. + yesterdayByClose: "Yesterday by close: {{total}}", + // The single filled button on the desktop screen (#829, amending + // DIRECTION.md's "ruled text at 1280" for exactly this one row) and the + // 48px full-width phone action for the missing house. + recordHouseAction: "Record {{flock}}", + // Ruled-text row action for a Draft entry — one already started, not + // finished. Missing (`recordHouseAction`) and Draft are the only two + // entry states that carry a row action; a submitted/locked/voided entry + // has none, only its name links through. + continueHouseAction: "Continue {{flock}}", + // The entry state with its time (DIRECTION.md line 6, #883 round 2 + // finding 4) — {{time}} is fmt.time()'s farm-local HH:mm, never the + // browser's. Falls back to the bare status word when the record carries + // neither timestamp (data predating #494). + entryStateRecordedTime: "Recorded {{time}}", + entryStateDraftTime: "Draft, saved {{time}}", + // The attention line (#829/#864): one line, missing houses only, folding + // into a count past the attention cap (two at 1280, one at 390 — #883 + // round 2 finding 1). {{flock}} is the house's own name. + attentionHouseNotRecorded: "{{flock}} not recorded", + attentionMore_one: "+{{count}} more", + attentionMore_other: "+{{count}} more", // aria-label of a tile link; the visible tile shows name, eggs and status. tileLinkLabel: "{{flock}}: open today's entry", // The missing state has to be IN the accessible name: `aria-label` replaces @@ -847,6 +882,12 @@ export const en = { // (the customer left the caller's tenant scope between reads). Never a // raw id or a substituted catalog match. rowCustomerUnavailable: "This customer is no longer available.", + // A draft order's row action (DIRECTION.md line 9, #883 round 2 finding + // 5). NOT "Confirm order" — that is what this control does not do: it + // opens the customer's whole filtered order list, never the one order + // (no per-order deep link exists yet), so a "confirm" label would + // overclaim a completed action (Codex CLI review round 2, finding 3). + salesRowConfirmAction: "Review to confirm", }, // Water usage capture + correction screen (Task 13, #182, batch B2). // `water` is in TRANSLATED_NAMESPACES, same treatment as diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index b0d8d4fc7..4e31a2dfe 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -584,9 +584,19 @@ export const es = { // Estado de captura (#654) todayPanelTitle: "Hoy", + todayInCount_one: "{{in}} de {{count}} caseta registrada", + todayInCount_other: "{{in}} de {{count}} casetas registradas", noFlocksMessage: "Aún no hay lotes — cree uno en la página de Registro diario.", noEntryBadge: "Sin registro", - todayEggsTotal: "{{total}} huevos hoy", + todaySoFarLabel: "Hoy hasta ahora", + yesterdayByClose: "Ayer al cierre: {{total}}", + recordHouseAction: "Registrar {{flock}}", + continueHouseAction: "Continuar {{flock}}", + entryStateRecordedTime: "Registrado {{time}}", + entryStateDraftTime: "Borrador, guardado {{time}}", + attentionHouseNotRecorded: "{{flock}} sin registrar", + attentionMore_one: "+{{count}} más", + attentionMore_other: "+{{count}} más", tileLinkLabel: "{{flock}}: abrir el registro de hoy", tileLinkLabelMissing: "{{flock}}: sin registro todavía, abrir el registro de hoy", recordTodayHint: "Registrar hoy", @@ -626,6 +636,7 @@ export const es = { salesPanelTitle: "Ventas recientes", noOrdersMessage: "Aún no hay pedidos.", rowCustomerUnavailable: "Este cliente ya no está disponible.", + salesRowConfirmAction: "Revisar para confirmar", }, // machine-drafted (#182) — pending native review. Task CT2 (B2 catch-up): diff --git a/web/src/i18n/tl.ts b/web/src/i18n/tl.ts index 28463da1f..50f80b4eb 100644 --- a/web/src/i18n/tl.ts +++ b/web/src/i18n/tl.ts @@ -609,9 +609,21 @@ export const tl = { // Katayuan ng pagtatala (#654) todayPanelTitle: "Ngayon", + // Tagalog nouns do not inflect for number, so both plural forms carry + // the same text — same treatment as moreFlocks_one/_other below. + todayInCount_one: "{{in}} sa {{count}} bahay-manukan ang naitala", + todayInCount_other: "{{in}} sa {{count}} bahay-manukan ang naitala", noFlocksMessage: "Wala pang kawan — gumawa ng isa sa page na Araw-araw na Tala.", noEntryBadge: "Walang tala", - todayEggsTotal: "{{total}} itlog ngayon", + todaySoFarLabel: "Ngayon, hanggang ngayon", + yesterdayByClose: "Kahapon sa pagsara: {{total}}", + recordHouseAction: "Itala ang {{flock}}", + continueHouseAction: "Ituloy ang {{flock}}", + entryStateRecordedTime: "Naitala {{time}}", + entryStateDraftTime: "Draft, na-save {{time}}", + attentionHouseNotRecorded: "Hindi pa naitala ang {{flock}}", + attentionMore_one: "+{{count}} pa", + attentionMore_other: "+{{count}} pa", tileLinkLabel: "{{flock}}: buksan ang tala ngayong araw", tileLinkLabelMissing: "{{flock}}: wala pang tala, buksan ang tala ngayong araw", recordTodayHint: "Itala ngayon", @@ -659,6 +671,7 @@ export const tl = { salesPanelTitle: "Mga Kamakailang Benta", noOrdersMessage: "Wala pang order.", rowCustomerUnavailable: "Hindi na available ang customer na ito.", + salesRowConfirmAction: "Suriin para kumpirmahin", }, // machine-drafted (#182) — pending native review. Task CT2 (B2 catch-up): diff --git a/web/src/lib/breakpoints.ts b/web/src/lib/breakpoints.ts new file mode 100644 index 000000000..3f4171101 --- /dev/null +++ b/web/src/lib/breakpoints.ts @@ -0,0 +1,10 @@ +// The sidebar/tab-bar switch (AppLayout.tsx, BottomNav.tsx) happens at MUI's +// default `md` breakpoint, 900px — every `sx={{ display: { xs: …, md: … } }}` +// site already reads that key. Two call sites need the SAME boundary as a +// plain media-query STRING rather than an sx breakpoint key: a JS +// `matchMedia` listener and a `useMediaQuery` hook. One constant so neither +// can drift from the CSS switch or from each other (PR #883 round 2, finding +// 2: BottomNav's own `matchMedia` listener read 901px against the 900px CSS +// switch — a sheet opened at exactly 900px would have stayed open with its +// trigger hidden underneath the now-visible sidebar). +export const MD_UP_QUERY = "(min-width: 900px)"; diff --git a/web/src/lib/format.test.ts b/web/src/lib/format.test.ts index 5e405117e..9a2847ec7 100644 --- a/web/src/lib/format.test.ts +++ b/web/src/lib/format.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest"; -import { DEFAULT_LOCALE, formatCount, formatDate, formatMoney } from "./format"; +import { + DEFAULT_LOCALE, formatCount, formatDate, formatMoney, formatTime, +} from "./format"; // §4.5 display rule: money, counts and calendar dates render through the // FARM's locale + currency + date-format override (#650). The UI language is @@ -65,6 +67,22 @@ describe("formatCount", () => { }); }); +describe("formatTime", () => { + it("formats a UTC instant on the given timezone's 24-hour clock", () => { + expect(formatTime("2026-07-21T06:40:00Z", "en-US", "UTC")).toBe("06:40"); + // hourCycle is forced to h23 regardless of locale — the Today row this + // feeds ("Recorded 06:40") never switches to a locale's AM/PM default. + expect(formatTime("2026-07-21T18:05:00Z", "es-ES", "UTC")).toBe("18:05"); + }); + + // web/src/lib/** is pinned at 100% branch coverage (vite.config.ts): the + // `Number.isNaN` guard is the one branch a happy-path-only suite misses. + it("returns an unparseable instant unchanged rather than inventing a time", () => { + expect(formatTime("not a time", "en-US", "UTC")).toBe("not a time"); + expect(formatTime("", "en-US", "UTC")).toBe(""); + }); +}); + describe("formatDate", () => { it("renders an ISO calendar date in the locale's numeric short form when no override is set", () => { expect(formatDate("2026-08-14", "en-US", null)).toBe("08/14/2026"); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 361a11646..8b0273de7 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -71,6 +71,22 @@ const TOKEN = /yyyy|yy|MMMM|MMM|MM|M|dddd|ddd|dd|d|'[^']*'|"[^"]*"|\\.|./g; // Both silently normalise an impossible day (2026-02-30 → March 2), so the // components are read back and compared — a date that does not survive the // round trip is shown as it arrived rather than as a day the farm never had. +// A clock reading (HH:mm) for a UTC instant, on the farm's OWN calendar/clock +// (`Account.timeZoneId`), never the browser's — the same rule #650 states for +// every other figure on the page. `hourCycle: "h23"` is forced rather than +// left to the locale: the Today row this feeds ("Recorded 06:40") reads a +// 24-hour clock in every locale, matching the tabular-numeral convention the +// rest of the row uses, rather than switching to a locale's AM/PM default. +// `timeFormatOverride` (the Settings field beside `dateFormatOverride`) is +// deliberately NOT read here — nothing in the SPA renders a time through it +// yet, same as `formatDate`'s comment above notes for dates; wiring it is a +// separate decision. +export function formatTime(iso: string, locale: string, timeZone: string | undefined): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return dateFormat(locale, { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(date); +} + export function formatDate(isoDate: string, locale: string, override: string | null): string { const m = ISO_DATE.exec(isoDate); if (!m) return isoDate; diff --git a/web/src/routes/AppLayout.test.tsx b/web/src/routes/AppLayout.test.tsx index 6a64fb4bb..a3ccf16f8 100644 --- a/web/src/routes/AppLayout.test.tsx +++ b/web/src/routes/AppLayout.test.tsx @@ -91,6 +91,25 @@ describe("AppLayout sidebar", () => { expect(sidebar().getByRole("link", { name: "Daily entry" })).toBeInTheDocument(); }); + // #829 (D5) — nav.test.ts pins navGroups()/tabEntries() as pure functions, + // but it cannot see the RENDERER: a Drawer+List that dropped a group or + // rendered only tabEntries would leave that model-level suite green. This + // is the counter-check on the rendered DOM, one per role that changes the + // count (Admin has the most groups/links; ReadOnly the fewest). + it("renders every group and link the nav model builds for Admin — 7 groups, 20 links", () => { + renderWithProviders(, { token: { sub: "u1", role: "Admin" } }); + const nav = screen.getByRole("navigation", { name: "Primary" }); + expect(nav.querySelectorAll(".nav-group-label")).toHaveLength(7); + expect(within(nav).getAllByRole("link")).toHaveLength(20); + }); + + it("renders every group and link the nav model builds for ReadOnly — 5 groups, 6 links", () => { + renderWithProviders(, { token: { sub: "u1", role: "ReadOnly" } }); + const nav = screen.getByRole("navigation", { name: "Primary" }); + expect(nav.querySelectorAll(".nav-group-label")).toHaveLength(5); + expect(within(nav).getAllByRole("link")).toHaveLength(6); + }); + it("hides production + admin destinations from a ReadOnly role", () => { renderWithProviders(, { token: { sub: "u1", role: "ReadOnly" } }); expect(sidebar().getByRole("link", { name: "Stock" })).toBeInTheDocument(); diff --git a/web/src/routes/AppLayout.tsx b/web/src/routes/AppLayout.tsx index f4321efe8..cb71cf59e 100644 --- a/web/src/routes/AppLayout.tsx +++ b/web/src/routes/AppLayout.tsx @@ -1,7 +1,10 @@ -import { Suspense, useEffect } from "react"; +import { Fragment, Suspense, useEffect } from "react"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router"; import { LogOut } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { + Alert, Box, Button, Drawer, List, ListItemButton, ListItemIcon, ListItemText, ListSubheader, Typography, +} from "@mui/material"; import { useAuth } from "../auth/useAuth"; import { ThemeToggle } from "../components/ThemeToggle"; import { BottomNav } from "../components/BottomNav"; @@ -10,6 +13,7 @@ import { ErrorBoundary } from "../components/ErrorBoundary"; import { FarmBrand } from "../components/FarmBrand"; import { useFarm } from "../farm/useFarm"; import { navGroups, tabEntries } from "./nav"; +import type { NavEntry } from "./nav"; const ICON = 17; @@ -17,14 +21,25 @@ const ICON = 17; // relies on for crash reports; read once at module scope rather than per render. const APP_VERSION = import.meta.env.VITE_APP_VERSION as string | undefined; -// Authenticated shell (#52 redesign): an aubergine sidebar — the brand's -// navigation spine — with the 15+ destinations grouped by job, each with a +// A route matches an entry the same way everywhere in the shell: exact for an +// `end` entry (only the Dashboard today), a prefix match otherwise. BottomNav +// needs the identical test for its own current-tab check, so this is the one +// place both renderers would otherwise duplicate it from. +function matches(pathname: string, entry: NavEntry): boolean { + return entry.end ? pathname === entry.to : pathname.startsWith(entry.to); +} + +// Authenticated shell (#52 redesign, #829 MUI conversion): a permanent +// `Drawer` — the brand's navigation spine, tinted `--lavender` paper per the +// confirmed direction (DIRECTION.md), not the aubergine `--brand` slab this +// screen painted before — with the destinations grouped by job, each with a // lucide glyph. Role-tiered (#103): links and whole groups hide per role; the // API enforces the policy on every gated endpoint regardless. // -// Below 900px the sidebar gives way to a bottom tab bar + More sheet (BottomNav) -// — the wrapping top bar it used to become ate a third of a phone screen. Both -// navs render from the same nav model, so the role gates live in one place. +// Below 900px the sidebar gives way to a bottom tab bar + More sheet +// (BottomNav) — the wrapping top bar it used to become ate a third of a +// phone screen. Both navs render from the same nav model (nav.tsx, D5), so +// the role gates live in one place. export function AppLayout() { const { t } = useTranslation("nav"); const { t: tc } = useTranslation("common"); @@ -51,9 +66,7 @@ export function AppLayout() { // `nav` is English-only, but the entry is matched the same way BottomNav // marks a tab current (`end` -> exact match, else a prefix match). useEffect(() => { - const active = groups - .flatMap((g) => g.entries) - .find((e) => (e.end ? location.pathname === e.to : location.pathname.startsWith(e.to))); + const active = groups.flatMap((g) => g.entries).find((e) => matches(location.pathname, e)); document.title = active ? `${t(active.labelKey)}${t("titleSuffix")}` : "Cluckwork"; }, [groups, location.pathname, t]); @@ -63,39 +76,71 @@ export function AppLayout() { } return ( -
+ {/* First focusable element: lets a keyboard/screen-reader user jump past - the 15+ nav links straight to the screen content (#182, Task 7). */} + the nav links straight to the screen content (#182, Task 7). */} {t("skipToContent")} - + {APP_VERSION && ( + + {t("versionLabel", { version: APP_VERSION })} + + )} + + -
+ {/* Carries the warning the banner below could not announce because a dialog had it inert (#485), and stays empty otherwise so the two never say the same thing twice. @@ -121,12 +166,10 @@ export function AppLayout() { to replace, so a new timezone silently does not apply (round 2: codex + pi). */} {farmWarning !== null && ( -

- {farmWarning}{" "} - -

+ void refresh()}>{t("tryAgain")}}> + {farmWarning} + )} {/* Contain a routed screen's render throw to this pane — the sidebar and @@ -141,9 +184,9 @@ export function AppLayout() { -
+ -
+ ); } diff --git a/web/src/routes/Dashboard.test.tsx b/web/src/routes/Dashboard.test.tsx index af51e9480..fc2a6505e 100644 --- a/web/src/routes/Dashboard.test.tsx +++ b/web/src/routes/Dashboard.test.tsx @@ -10,6 +10,7 @@ import type { DailyEntry, Flock, ProductionDay, ProductionReport, SalesOrder, St import { daysBefore, todayIso } from "../lib/dates"; import i18n from "../i18n"; import { NO_RECORD_HISTORY, account } from "../test/fixtures"; +import { stubMatchMedia } from "../test/matchMedia"; // Keep the real formatters; stub the six read endpoints the dashboard fans out. vi.mock("../api/cluckwork", async (importOriginal) => { @@ -94,9 +95,29 @@ beforeEach(() => { mockReport.mockImplementation(reportByWindow(today)); }); -// The page renders "Loading…" until every read settles, so a panel is found, never got. +// The page renders "Loading…" until every read settles, so a panel is found, +// never got. #829 — sections are no longer `.panel` cards, just a `
` +// under a ruled `h3` (MUI `Typography variant="h3"`, which the theme maps to +// a real `

` element). const panel = async (title: string) => - (await screen.findByRole("heading", { name: title, level: 3 })).closest(".panel") as HTMLElement; + (await screen.findByRole("heading", { name: title, level: 3 })).closest("section") as HTMLElement; + +// A Today row's accessible group — `role="group"` named after the flock, so +// a row's status/action/count (siblings of the name link, not nested inside +// it) can be queried together without depending on layout classNames. +const todayRow = (flockName: string) => screen.getByRole("group", { name: flockName }); + +// The total row's label ("Today so far") and its numeral are separate +// elements (#883 round 4, finding D — the mockup pairs a fixed label with a +// numeral beside it, never a sentence built by interpolating the figure into +// the label). A bare numeral like "178" also appears on individual Today +// rows, so this scopes the read to the label's own sibling rather than an +// unscoped `getByText`, which would be ambiguous whenever a row's count +// happens to match the total. +const todayTotal = async () => { + const label = await screen.findByText("Today so far"); + return label.parentElement?.querySelector(".num")?.textContent; +}; function withOverride(ns: string, key: string, value: string, run: () => Promise | void) { const original = i18n.getResource("en", ns, key) as string; @@ -104,31 +125,35 @@ function withOverride(ns: string, key: string, value: string, run: () => Promise return Promise.resolve(run()).finally(() => { i18n.addResource("en", ns, key, original); }); } -describe("Dashboard capture status (#654)", () => { - it("renders one tile per active flock, no-entry tiles first, each linking to that flock's entry for today", async () => { +describe("Dashboard capture status (#654, #829 ruled list)", () => { + it("renders one row per active flock, no-entry rows first, each linking to that flock's entry for today", async () => { renderWithProviders(); - const f1 = await screen.findByRole("link", { name: "Flock f1: open today's entry" }); - expect(f1).toHaveAttribute("href", `/daily-entry?flockId=f1&date=${today}`); - expect(within(f1).getByText("178")).toBeInTheDocument(); - expect(within(f1).getByText("Submitted")).toBeInTheDocument(); + const f1link = await screen.findByRole("link", { name: "Flock f1: open today's entry" }); + expect(f1link).toHaveAttribute("href", `/daily-entry?flockId=f1&date=${today}`); + const f1row = todayRow("Flock f1"); + expect(within(f1row).getByText("178")).toBeInTheDocument(); + expect(within(f1row).getByText("Submitted")).toBeInTheDocument(); const names = screen.getAllByRole("link", { name: /open today's entry/ }).map((a) => a.getAttribute("aria-label")); expect(names).toEqual(["Flock f2: no entry yet, open today's entry", "Flock f3: no entry yet, open today's entry", "Flock f1: open today's entry"]); }); it("marks a flock with no entry — and one whose only entry is Voided — as missing (#82)", async () => { renderWithProviders(); - const f2 = await screen.findByRole("link", { name: "Flock f2: no entry yet, open today's entry" }); - const f3 = screen.getByRole("link", { name: "Flock f3: no entry yet, open today's entry" }); - for (const tile of [f2, f3]) { - expect(tile).toHaveClass("is-missing"); - expect(within(tile).getByText("No entry")).toBeInTheDocument(); - expect(within(tile).getByText("—")).toBeInTheDocument(); - expect(within(tile).queryByText("999")).not.toBeInTheDocument(); + await screen.findByRole("link", { name: "Flock f2: no entry yet, open today's entry" }); + for (const id of ["f2", "f3"]) { + const row = todayRow(`Flock ${id}`); + // The single filled button on the page (#829/#864 owner amendment) — + // its presence in the row IS the missing marker; there is no longer a + // classList to assert on. + expect(within(row).getByRole("link", { name: `Record Flock ${id}` })).toBeInTheDocument(); + expect(within(row).getByText("No entry")).toBeInTheDocument(); + expect(within(row).getByText("—")).toBeInTheDocument(); + expect(within(row).queryByText("999")).not.toBeInTheDocument(); } - expect(screen.getByRole("link", { name: "Flock f1: open today's entry" })).not.toHaveClass("is-missing"); + expect(within(todayRow("Flock f1")).queryByRole("link", { name: /^Record/ })).not.toBeInTheDocument(); }); - it("offers 'Record today' on hover for a tile with no entry, and not on one that has an entry", async () => { + it("offers 'Record today' on hover for a row with no entry, and not on one that has an entry", async () => { renderWithProviders(); const missing = await screen.findByRole("link", { name: "Flock f3: no entry yet, open today's entry" }); expect(missing).toHaveAttribute("title", "Record today"); @@ -136,7 +161,7 @@ describe("Dashboard capture status (#654)", () => { expect(screen.getByRole("link", { name: "Flock f1: open today's entry" })).not.toHaveAttribute("title"); }); - it("announces the missing state in the tile's accessible name, not only in its colour and badge", async () => { + it("announces the missing state in the row's accessible name, not only in its colour and badge", async () => { renderWithProviders(); // `aria-label` overrides the link's inner content, so the visible "no entry" // badge is NOT part of the accessible name — the name has to carry it. @@ -154,30 +179,194 @@ describe("Dashboard capture status (#654)", () => { }); }); + it("reads the Record action's label from the catalog, not a hardcoded literal", async () => { + await withOverride("dashboard", "recordHouseAction", "RECORD-ACTION {{flock}}", async () => { + renderWithProviders(); + await screen.findByRole("link", { name: "Flock f2: no entry yet, open today's entry" }); + expect(within(todayRow("Flock f2")).getByRole("link", { name: "RECORD-ACTION Flock f2" })).toBeInTheDocument(); + }); + }); + + it("gives a Draft entry a ruled-text Continue action; a Submitted one none", async () => { + mockEntries.mockResolvedValue([entry("f1", "Submitted", 178), entry("f2", "Draft", 40)]); + renderWithProviders(); + await screen.findByRole("link", { name: "Flock f1: open today's entry" }); + expect(within(todayRow("Flock f2")).getByRole("link", { name: "Continue Flock f2" })).toBeInTheDocument(); + expect(within(todayRow("Flock f1")).queryByRole("link", { name: /^Continue|^Record/ })).not.toBeInTheDocument(); + }); + + // #883 round 5 — the owner's read of the PR's screenshots found the + // Continue action rendering as bold, brand-coloured text on both widths: + // `Button variant="text"` reads MUI's default text-button styling (bold, + // primary colour), not the ruled-text row action DIRECTION.md line 7 calls + // for. The fix drops the Button and reuses the same Typography+Link pattern + // the sales row's "Review to confirm" action already renders with (below), + // so this asserts against THAT class rather than inventing a new one: a + // `MuiTypography-body2` element, never a `MuiButtonBase`/`MuiButton` one. + it("renders Continue as MUI Typography ruled text, not a MuiButton (#883 round 5)", async () => { + mockEntries.mockResolvedValue([entry("f2", "Draft", 40)]); + renderWithProviders(); + const action = await screen.findByRole("link", { name: "Continue Flock f2" }); + expect(action.className, "Continue should render as ruled Typography text").toMatch(/\bMuiTypography-body2\b/); + expect(action.className, "Continue should carry no Button chrome").not.toMatch(/MuiButton/); + }); + it("sums today's eggs excluding the Voided entry — 178, never 1,177", async () => { renderWithProviders(); - expect(await screen.findByText("178 eggs today")).toBeInTheDocument(); + expect(await todayTotal()).toBe("178"); expect(screen.queryByText(/1,177/)).not.toBeInTheDocument(); }); - it("caps the grid at 12 tiles, the missing ones first, and links the rest (INV-9)", async () => { + it("caps the list at 12 rows, the missing ones first, and links the rest (INV-9)", async () => { mockFlocks.mockResolvedValue(Array.from({ length: 15 }, (_, i) => flock(`f${i}`, "Active"))); mockEntries.mockResolvedValue(Array.from({ length: 12 }, (_, i) => entry(`f${i}`, "Submitted", 1))); // f12..f14 missing renderWithProviders(); const more = await screen.findByRole("link", { name: "3 more flocks" }); expect(more).toHaveAttribute("href", "/daily-entry"); - const tiles = screen.getAllByRole("link", { name: /open today's entry/ }); - expect(tiles).toHaveLength(12); - expect(tiles.slice(0, 3).map((t) => t.getAttribute("aria-label"))) - .toEqual(["Flock f12: no entry yet, open today's entry", "Flock f13: no entry yet, open today's entry", "Flock f14: no entry yet, open today's entry"]); - expect(tiles.slice(0, 3).every((t) => t.classList.contains("is-missing"))).toBe(true); + const rowLinks = screen.getAllByRole("link", { name: /open today's entry/ }); + expect(rowLinks).toHaveLength(12); + const missingNames = rowLinks.slice(0, 3).map((t) => t.getAttribute("aria-label")); + expect(missingNames).toEqual(["Flock f12: no entry yet, open today's entry", "Flock f13: no entry yet, open today's entry", "Flock f14: no entry yet, open today's entry"]); + expect(["f12", "f13", "f14"].every((id) => + within(todayRow(`Flock ${id}`)).queryByRole("link", { name: `Record Flock ${id}` }) !== null)).toBe(true); + }); +}); + +// #883 round 2, finding 4 — DIRECTION.md line 6: the entry state WITH its +// time ("Recorded 06:40", "Draft, saved 06:52"), farm-local. A house with no +// entry yet carries no time at all — there is nothing to have recorded. +describe("Dashboard Today row entry state time (#883 round 2, finding 4)", () => { + const farm = account({ timeZoneId: "UTC" }); + + it("shows a recorded entry's own time, and a draft's own save time, both farm-local", async () => { + mockEntries.mockResolvedValue([ + { ...entry("f1", "Submitted", 178), madeOfficialAtUtc: "2026-07-21T06:40:00Z" }, + { ...entry("f2", "Draft", 40), createdAtUtc: "2026-07-21T05:00:00Z", lastChangedAtUtc: "2026-07-21T06:52:00Z" }, + ]); + renderWithProviders(, { farm }); + await screen.findByRole("link", { name: "Flock f1: open today's entry" }); + expect(within(todayRow("Flock f1")).getByText("Recorded 06:40")).toBeInTheDocument(); + expect(within(todayRow("Flock f2")).getByText("Draft, saved 06:52")).toBeInTheDocument(); + }); + + it("shows no time on a house with no entry yet", async () => { + renderWithProviders(, { farm }); + await screen.findByRole("link", { name: "Flock f2: no entry yet, open today's entry" }); + expect(within(todayRow("Flock f2")).queryByText(/^Recorded|^Draft,/)).not.toBeInTheDocument(); + expect(within(todayRow("Flock f3")).queryByText(/^Recorded|^Draft,/)).not.toBeInTheDocument(); + }); + + it("falls back to the bare status word when the record has no timestamp to show", async () => { + // The default `entry()` fixture (NO_RECORD_HISTORY, no madeOfficialAtUtc) + // — data predating #494, or a fixture that doesn't care. + renderWithProviders(, { farm }); + await screen.findByRole("link", { name: "Flock f1: open today's entry" }); + expect(within(todayRow("Flock f1")).getByText("Submitted")).toBeInTheDocument(); + }); +}); + +// #829/#864 — the attention line: one line, missing houses only, folding +// past the attention cap into a count. The desktop-only "Needs attention" +// list combining a second data source (stock floors) was proposed on #864 +// and not taken — this line has exactly one source, so it renders nothing +// when every house is in. +describe("Dashboard attention line (#829, #864)", () => { + it("renders nothing when every house has an entry", async () => { + mockEntries.mockResolvedValue([entry("f1", "Submitted", 178), entry("f2", "Submitted", 1), entry("f3", "Submitted", 1)]); + renderWithProviders(); + expect(await todayTotal()).toBe("180"); + expect(screen.queryByText(/not recorded/)).not.toBeInTheDocument(); + }); + + it("names a single missing house with no fold", async () => { + mockFlocks.mockResolvedValue([flock("f1", "Active"), flock("f2", "Active")]); + mockEntries.mockResolvedValue([entry("f1", "Submitted", 178)]); // f2 missing + renderWithProviders(); + expect(await screen.findByText("Flock f2 not recorded")).toBeInTheDocument(); + expect(screen.queryByText(/\+\d+ more/)).not.toBeInTheDocument(); + }); + + it("shows two missing houses ruled apart at desktop width (md and up), and folds the rest into a count", async () => { + stubMatchMedia(true); // >= 900px, the md breakpoint AppLayout/BottomNav switch on + mockFlocks.mockResolvedValue(Array.from({ length: 4 }, (_, i) => flock(`f${i}`, "Active"))); + mockEntries.mockResolvedValue([]); // every one of the four is missing + renderWithProviders(); + expect(await screen.findByText("Flock f0 not recorded")).toBeInTheDocument(); + expect(screen.getByText("Flock f1 not recorded")).toBeInTheDocument(); + expect(screen.queryByText("Flock f2 not recorded")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "+2 more" })).toHaveAttribute("href", "/daily-entry"); + }); + + // #883 round 2, finding 1: DIRECTION.md's fold point is two items at 1280 + // and ONE at 390 — a flat cap of 2 overcounted on a phone. Below md (900px) + // MUI's `useMediaQuery` resolves to its `defaultMatches` (false) when + // `matchMedia` is left unstubbed, so this is also what a test gets by doing + // nothing — asserted explicitly here rather than left implicit. + it("shows exactly one missing house below the md breakpoint, and folds the rest into a count", async () => { + stubMatchMedia(false); // < 900px + mockFlocks.mockResolvedValue(Array.from({ length: 4 }, (_, i) => flock(`f${i}`, "Active"))); + mockEntries.mockResolvedValue([]); + renderWithProviders(); + expect(await screen.findByText("Flock f0 not recorded")).toBeInTheDocument(); + expect(screen.queryByText("Flock f1 not recorded")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "+3 more" })).toHaveAttribute("href", "/daily-entry"); + }); + + // CodeRabbit, PR #883 round 1: missingHouses and the "N of M houses in" + // caption were both derived from `tiles.shown`, the list `visibleTiles` + // caps at 12 — so a farm with more than 12 missing houses undercounted + // both, since every one of the 12 shown was itself missing (missing-first + // ordering) and nothing past the cap was ever counted. They now come from + // the FULL, uncapped capture-status list. + it("counts every missing house, not only the 12 visibleTiles caps the row list at", async () => { + stubMatchMedia(true); // desktop cap (2 shown) — this test is about the COUNT, not the width + mockFlocks.mockResolvedValue(Array.from({ length: 15 }, (_, i) => flock(`f${i}`, "Active"))); + mockEntries.mockResolvedValue([]); // all 15 missing + renderWithProviders(); + await screen.findByText("Flock f0 not recorded"); + // 15 missing, 2 shown ruled apart, so the fold count is 13 — not 10, + // which is what `tiles.shown.length` (capped at 12) minus 2 would give. + expect(screen.getByRole("link", { name: "+13 more" })).toBeInTheDocument(); + expect(screen.getByText("0 of 15 houses in")).toBeInTheDocument(); + }); + + // CodeRabbit, PR #883 round 1: a single-key catalog string could only ever + // render "houses" — i18next selects the plural on `{{count}}`, and the + // caption now carries `_one`/`_other` forms. + it("says 'house', singular, when the farm has exactly one", async () => { + mockFlocks.mockResolvedValue([flock("f1", "Active")]); + mockEntries.mockResolvedValue([entry("f1", "Submitted", 1)]); + renderWithProviders(); + expect(await screen.findByText("1 of 1 house in")).toBeInTheDocument(); + }); +}); + +// #864 owner amendment (2026-09-16) — a reference under the running total, +// read from the 14-day strip's own last (yesterday) slot: no second fetch, +// and no figure at all when yesterday was not a complete day. +describe("Dashboard 'Yesterday by close' caption (#864)", () => { + it("shows yesterday's total when the strip's last day is complete", async () => { + renderWithProviders(); + // currentFor maps n=7..1 to daysBefore(today,n) with value 320+n, so the + // window's last day — daysBefore(today,1), yesterday — is 320+1 = 321. + expect(await screen.findByText("Yesterday by close: 321")).toBeInTheDocument(); + }); + + it("shows no caption when yesterday was not fully recorded", async () => { + mockReport.mockImplementation((from, to) => + reportByWindow(today)(from, to).then((r) => (to === daysBefore(today, 1) + ? { ...r, days: r.days.map((d, i) => (i === 6 ? { ...d, recordedFlocks: 0, missingFlocks: d.expectedFlocks, totalEggs: 0 } : d)) } + : r))); + renderWithProviders(); + await todayTotal(); + expect(screen.queryByText(/Yesterday by close/)).not.toBeInTheDocument(); }); }); describe("Dashboard last 14 days (#654, INV-5)", () => { it("asks the production report for exactly the two 7-day windows ending yesterday", async () => { renderWithProviders(); - await screen.findByText("178 eggs today"); + await todayTotal(); expect(mockReport).toHaveBeenCalledTimes(2); expect(mockReport).toHaveBeenCalledWith(daysBefore(today, 7), daysBefore(today, 1)); expect(mockReport).toHaveBeenCalledWith(daysBefore(today, 14), daysBefore(today, 8)); @@ -354,7 +543,8 @@ describe("Dashboard stock bar (#654, INV-4)", () => { const spans = Array.from(stock.querySelectorAll(".meter-stack > span")) as HTMLElement[]; expect(spans.map((s) => [s.style.width, s.className])).toEqual([["79.5%", "grade-1"], ["20.5%", "grade-2"]]); // The ledger, not the band, is what names a grade and carries its share. - const rows = Array.from(stock.querySelectorAll(".stock-ledger li")) + // #829 — a plain `role="list"`, not a `.stock-ledger` class hook. + const rows = within(stock).getAllByRole("listitem") .map((li) => Array.from(li.querySelectorAll("span")).slice(1).map((s) => s.textContent)); expect(rows).toEqual([["Grade A", "1,240", "79.5%"], ["Grade B", "320", "20.5%"]]); expect(within(stock).queryByText(/restricted/)).not.toBeInTheDocument(); @@ -380,7 +570,7 @@ describe("Dashboard stock bar (#654, INV-4)", () => { expect(await within(stock).findByText("4 restricted")).toBeInTheDocument(); expect(stock.querySelector(".stock-total")?.textContent).toBe("0 eggs available"); expect(stock.querySelectorAll(".meter-stack > span")).toHaveLength(0); - expect(stock.querySelectorAll(".stock-ledger")).toHaveLength(0); + expect(within(stock).queryAllByRole("listitem")).toHaveLength(0); expect(within(stock).queryByText("No stock yet — record and submit a daily entry.")).not.toBeInTheDocument(); }); }); @@ -392,7 +582,7 @@ describe("Dashboard degrades one panel at a time (#654, INV-1)", () => { const boom = () => Promise.reject(new Error("down")); const asSales = { token: { sub: "u1", role: "Sales" } }; const expectOthersIntact = async (except: "today" | "trend" | "stock" | "sales") => { - if (except !== "today") expect(await screen.findByText("178 eggs today")).toBeInTheDocument(); + if (except !== "today") expect(await todayTotal()).toBe("178"); if (except !== "trend") expect(await screen.findByText("87.4%")).toBeInTheDocument(); if (except !== "stock") expect(await screen.findByText("1,560")).toBeInTheDocument(); if (except !== "sales") expect(await screen.findByText("No orders yet.")).toBeInTheDocument(); @@ -402,14 +592,14 @@ describe("Dashboard degrades one panel at a time (#654, INV-1)", () => { mockFlocks.mockImplementation(boom); renderWithProviders(, asSales); expect(within(await panel("Today")).getByText("Could not load.")).toBeInTheDocument(); - expect(screen.queryByText(/eggs today/)).not.toBeInTheDocument(); + expect(screen.queryByText("Today so far")).not.toBeInTheDocument(); await expectOthersIntact("today"); }); it("entries failed → Today panel errors, others intact", async () => { mockEntries.mockImplementation(boom); renderWithProviders(, asSales); expect(within(await panel("Today")).getByText("Could not load.")).toBeInTheDocument(); - expect(screen.queryByText("0 eggs today")).not.toBeInTheDocument(); + expect(screen.queryByText("Today so far")).not.toBeInTheDocument(); await expectOthersIntact("today"); }); it("current-week report failed → trend panel errors, others intact", async () => { @@ -462,13 +652,13 @@ describe("Dashboard degrades one panel at a time (#654, INV-1)", () => { describe("Dashboard sales panel role gate (#127)", () => { it("neither fetches nor shows sales for a ReadOnly user", async () => { renderWithProviders(, { token: { sub: "u1", role: "ReadOnly" } }); - expect(await screen.findByText("178 eggs today")).toBeInTheDocument(); + expect(await todayTotal()).toBe("178"); expect(screen.queryByText("Recent sales")).not.toBeInTheDocument(); expect(mockOrders).not.toHaveBeenCalled(); }); it("neither fetches nor shows sales for a Denied user", async () => { renderWithProviders(, { token: { sub: "u1", role: "Denied" } }); - expect(await screen.findByText("178 eggs today")).toBeInTheDocument(); + expect(await todayTotal()).toBe("178"); expect(screen.queryByText("Recent sales")).not.toBeInTheDocument(); expect(mockOrders).not.toHaveBeenCalled(); }); @@ -512,6 +702,34 @@ describe("Dashboard recent sales rows (#512)", () => { }); }); +// #883 round 2, finding 5 — DIRECTION.md line 9's row action: a draft order +// gets a row action; a non-draft row has none. +// +// Codex CLI review round 2 (finding 3): the label read "Confirm order" — +// the Sales page's OWN control for the real, in-place confirm — while this +// row's link only opens the customer's WHOLE filtered order list (there is +// no per-order deep link yet), which can hold several draft/confirmed +// orders for the same customer. That is a real behavior/label mismatch, not +// just an extra click: the Today row's "Record"/"Continue" precedent still +// lands on the ONE exact form for that flock+date, so the label there never +// overclaims what one more step gets you. Reworded to "Review to confirm" +// — honest about being a navigation, not a completed action. +describe("Dashboard recent sales row action (#883 round 2, finding 5)", () => { + it("shows a Review to confirm action on a draft row, linked through the customer filter", async () => { + mockOrders.mockResolvedValue([order("o-1", "SO-3", "Filtered Farm")]); // status: "Draft" + renderWithProviders(, { token: { sub: "u1", role: "Sales" } }); + const row = await screen.findByRole("listitem", { name: /SO-3/ }); + expect(within(row).getByRole("link", { name: "Review to confirm" })).toHaveAttribute("href", "/sales?customerId=c1"); + }); + + it("shows no action on a non-draft row", async () => { + mockOrders.mockResolvedValue([{ ...order("o-2", "SO-4", "Second Farm"), status: "Confirmed" }]); + renderWithProviders(, { token: { sub: "u1", role: "Sales" } }); + const row = await screen.findByRole("listitem", { name: /SO-4/ }); + expect(within(row).queryByRole("link", { name: "Review to confirm" })).not.toBeInTheDocument(); + }); +}); + // The farm's day and locale win over the browser's (#123, #650): with time // frozen at 23:30Z, a +14 farm is already on the next calendar day while any // browser between UTC−12 and UTC+0:30 is not; a de-DE farm groups with "." and @@ -557,9 +775,34 @@ describe("Dashboard i18n wiring (#654)", () => { renderWithProviders(); expect(await screen.findByText("TREND-MARKER")).toBeInTheDocument(); }); - await withOverride("dashboard", "todayEggsTotal", "TOTAL-MARKER {{total}}", async () => { + await withOverride("dashboard", "todaySoFarLabel", "TOTAL-MARKER", async () => { renderWithProviders(); - expect(await screen.findByText("TOTAL-MARKER 178")).toBeInTheDocument(); + expect(await screen.findByText("TOTAL-MARKER")).toBeInTheDocument(); }); }); }); + +// #883 round 4, finding C — DIRECTION.md's status vocabulary is a dot plus a +// word, never a filled badge. StatusBadge itself stays untouched (its +// conversion is #831); the Dashboard renders its own dot locally. +describe("Dashboard status rendering (#864, dot not badge)", () => { + it("renders Today and Recent sales status as a colour dot beside the word, not a filled badge", async () => { + mockOrders.mockResolvedValue([order("o1", "SO-1", "Ramos Grocery")]); // status: "Draft" + renderWithProviders(); + await screen.findByText("Today so far"); + + // No filled badge survives on this screen once the conversion lands. + expect(document.querySelectorAll(".badge").length).toBe(0); + + const recordedDot = within(todayRow("Flock f1")).getByText("Submitted").previousElementSibling; + expect(recordedDot).toHaveAttribute("aria-hidden", "true"); + expect(recordedDot?.className).not.toMatch(/badge/); + + // f3 has no entry in the default fixture — the missing-house state. + const missingDot = within(todayRow("Flock f3")).getByText("No entry").previousElementSibling; + expect(missingDot).toHaveAttribute("aria-hidden", "true"); + + const salesStatus = await screen.findByText("Draft"); + expect(salesStatus.previousElementSibling).toHaveAttribute("aria-hidden", "true"); + }); +}); diff --git a/web/src/routes/Dashboard.tsx b/web/src/routes/Dashboard.tsx index 5de1db64b..e48decc7b 100644 --- a/web/src/routes/Dashboard.tsx +++ b/web/src/routes/Dashboard.tsx @@ -3,6 +3,9 @@ import { useEffect, useState } from "react"; import { Link } from "react-router"; import { useTranslation } from "react-i18next"; import { Bird, Egg, ShoppingCart } from "lucide-react"; +import { + Alert, Box, Button, Container, Stack, Typography, useMediaQuery, +} from "@mui/material"; import { getProductionReport, getStock, listDailyEntries, listFlocks, listOrders, } from "../api/cluckwork"; @@ -11,16 +14,16 @@ import { ApiError } from "../api/client"; import { useFormat } from "../farm/useFormat"; import { FarmDate } from "../components/FarmDate"; import { EmptyState } from "../components/EmptyState"; -import { StatusBadge } from "../components/StatusBadge"; import { DayStrip } from "../components/DayStrip"; import { StockBar } from "../components/StockBar"; import { useAuth } from "../auth/useAuth"; import { useFarmToday } from "../farm/useFarm"; import { daysBefore } from "../lib/dates"; +import { MD_UP_QUERY } from "../lib/breakpoints"; import { captureTiles, dayStrip, henDayTrend, stockBar, todaysEggs, visibleTiles, } from "../lib/dashboard"; -import type { DayStripData, DayStripSlot } from "../lib/dashboard"; +import type { CaptureTile, DayStripData, DayStripSlot } from "../lib/dashboard"; import i18n from "../i18n"; import { statusLabel } from "../i18n/enums"; @@ -30,18 +33,20 @@ const RECENT_ORDERS = 5; // day comes. const MAX_PAGE = 500; -// F5 (#41) → #654: the landing page answers the 6 am question — which houses -// have no entry yet, and is lay rate normal? One tile per active flock, the +// F5 (#41) → #654 → #829: the landing page answers the 6 am question — which +// houses have no entry yet, and is lay rate normal? A ruled Today list, the // missing ones first and at most 12 (a link carries the rest), the last 14 -// days as a line with the production report's own hen-day % for the last 7 -// complete days against the 7 before, stock as one stacked bar by grade, and -// recent sales as a list. +// days as a bar strip with the production report's own hen-day % for the +// last 7 complete days against the 7 before, stock as one stacked bar by +// grade, and recent sales as a ruled list. #829 converts the shell to MUI +// (DIRECTION.md, the confirmed mockup at docs/designs/864-visual-language/) +// without changing any of the data pipeline below. // // Composed client-side from existing read endpoints (6 parallel GETs). The // trend is the production report — computed server-side in one place, so the // page never sums report rows: two calls, one per 7-day window, and the // server's periodHenDayPct from each. Panels degrade independently: one -// failed fetch blanks its panel, not the page. +// failed fetch blanks its section, not the page. export function Dashboard() { const { t } = useTranslation("dashboard"); const fmt = useFormat(); @@ -68,6 +73,14 @@ export function Dashboard() { const canSeeSales = role !== "ReadOnly" && role !== "Denied"; // END PROTECTED + // The attention line never wraps: DIRECTION.md's fold point is two items at + // 1280 and ONE at 390 — the same md (900px) boundary the sidebar/tab-bar + // switch uses, not a flat cap at every width (#883 round 2, finding 1: the + // desktop count was overcounting on a phone, wrapping or truncating a line + // that must stay one line). + const isDesktop = useMediaQuery(MD_UP_QUERY); + const attentionCap = isDesktop ? 2 : 1; + useEffect(() => { Promise.allSettled([ listFlocks({ limit: MAX_PAGE }), @@ -101,11 +114,30 @@ export function Dashboard() { const rowCustomerName = (o: { customerName?: string | null }) => o.customerName ?? t("rowCustomerUnavailable"); - if (loading) return

{t("title")}

{tc("loading")}

; - if (error) return

{t("title")}

{error}

; + if (loading) { + return ( + + {t("title")} + {tc("loading")} + + ); + } + if (error) { + return ( + + {t("title")} + {error} + + ); + } - const panelError =

{t("panelLoadError")}

; - const tiles = flocks !== null && entries !== null ? visibleTiles(captureTiles(flocks, entries)) : null; + const panelError = {t("panelLoadError")}; + // The FULL capture-status list, uncapped — the attention line and the "N of + // M houses in" caption must count every active flock, not only the 12 + // `visibleTiles` caps the RENDERED row list at. A farm with more than 12 + // missing houses undercounted both on the capped list (CodeRabbit, #883). + const allTiles = flocks !== null && entries !== null ? captureTiles(flocks, entries) : null; + const tiles = allTiles === null ? null : visibleTiles(allTiles); const trendData = trend === null ? null : { line: dayStrip({ days: [...trend.previous.days, ...trend.current.days], @@ -178,149 +210,441 @@ export function Dashboard() { const deltaClass = (delta: number | null) => delta === null || delta === 0 ? "trend-delta" : delta < 0 ? "trend-delta is-down" : "trend-delta is-up"; + // Owner amendment on #864 (2026-09-16): a caption under "Today so far" that + // gives the running total a reference — "Yesterday by close: N" — sourced + // from the SAME data the 14-day strip already reads (its last slot, since + // the strip runs oldest-first and ends on yesterday), never a second fetch. + // Only when yesterday was a COMPLETE day: a partial or unrecorded yesterday + // has no figure honest enough to caption "by close". + const yesterdaySlot = trendData?.line.slots.at(-1) ?? null; + const yesterdayByClose = yesterdaySlot?.kind === "recorded" ? yesterdaySlot.eggs : null; + + // The attention line (D3.3, #829): missing houses only — the desktop-only + // "Needs attention" list combining a second data source (stock floors) was + // proposed on #864 and the owner did not take it, so this line has exactly + // one source. Nothing renders when every house is in. + const missingHouses = allTiles === null ? [] : allTiles.filter((c) => c.entry === null).map((c) => c.flock); + const attentionShown = missingHouses.slice(0, attentionCap); + const attentionMore = missingHouses.length - attentionShown.length; + return ( -
-

{t("title")}

-

+ + {t("title")} + -
-
-

{t("todayPanelTitle")}

- {tiles === null || entries === null ? panelError : ( - <> -

{t("todayEggsTotal", { total: fmt.count(todaysEggs(entries)) })}

- {tiles.shown.length === 0 ? ( - // No action here: the panel's own h3 already links to - // /daily-entry, and there is no panel-local create handler to - // reuse without duplicating one. + {/* #829/#864 — a plain text line, not a boxed `Alert`: DIRECTION.md's + confirmed mockup renders this as a hairline-weight status line (a + dot mark, ruled separators between items), which an `Alert`'s fill + and padding would read heavier than. This amends D3.3's "Alert + severity='warning'" row — the owner confirmed the lighter mockup + later (#864 issue comments, 2026-09-16) — D3.3 is amended in the PR + body per AGENTS.md's "DIRECTION.md wins" rule. */} + {missingHouses.length > 0 && ( + + + {attentionShown.map((flock, i) => ( + + {i > 0 && } + {t("attentionHouseNotRecorded", { flock: flock.name })} + + ))} + {attentionMore > 0 && ( + + {t("attentionMore", { count: attentionMore })} + + )} + + )} + + + + {/* -------------------------------------------------------- Today */} + + + {t("todayPanelTitle")} + {allTiles !== null && entries !== null && ( + + {t("todayInCount", { in: allTiles.length - missingHouses.length, count: allTiles.length })} + + )} + + {tiles === null || entries === null ? panelError : ( + tiles.shown.length === 0 ? ( ) : ( <> -
- {tiles.shown.map(({ flock, entry }) => ( - -
{flock.name}
-
{entry ? fmt.count(entry.totalEggs) : "—"}
- {entry - ? - : {t("noEntryBadge")}} - - ))} -
+ {tiles.shown.map((tile) => )} + + {t("todaySoFarLabel")} + {fmt.count(todaysEggs(entries))} + + {yesterdayByClose !== null && ( + + {t("yesterdayByClose", { total: fmt.count(yesterdayByClose) })} + + )} {tiles.hidden > 0 && ( - + {t("moreFlocks", { count: tiles.hidden, total: fmt.count(tiles.hidden) })} - + )} - )} - - )} -
+ ) + )} + -
-

{t("trendPanelTitle")}

- {trendData === null ? panelError : ( - <> - } - to={} - /> - {/* #777 — hen-day is what this panel measures, so it is a figure. - It used to be the smallest text on the panel, inside a muted - sentence describing a quantity the chart above did not plot. */} -

- - {trendData.henDay.current === null ? "—" : `${fmt.count(trendData.henDay.current, 1)}%`} - - {deltaText(trendData.henDay.delta)} -

-

{t("henDaySubLabel")}

- - )} -
- -
-

{t("stockPanelTitle")}

- {bar === null || stock === null ? panelError : stock.length === 0 ? ( - - ) : ( - <> - {/* #777 — the total is the whole the bar divides, so it leads. */} -

- {fmt.count(bar.totalAvailable)} - {/* The space matters: .stock-fig is display:block so the two - never touch on screen, but the paragraph's text is what a - copy-paste and any text consumer gets, and without it that - reads "1egg available". */} - {" "}{t("eggsAvailableLabel", { count: bar.totalAvailable })} -

- - {/* The ledger is the bar's text of record (the track itself is - aria-hidden). It replaces the grade run-on the caption used to - carry, which asked the reader to count segments and trust the - order matched, and it carries each grade's share so a grade - worth well under a percent is readable as a number. */} - {bar.segments.length > 0 && ( -
    - {bar.segments.map((s) => ( -
  • -
  • + {/* --------------------------------------------------- Recent sales */} + {canSeeSales && ( + + + {t("salesPanelTitle")} + + {orders === null ? panelError : orders.length === 0 ? ( + + ) : ( + // #883 round 5 (owner's read of the #883 screenshots): a + // shared grid, not a flex row per `
  • ` — a flex row lets + // each row's cells take whatever width their own content + // needs, so the amount sat at a different x position on every + // row. `display: grid` on the LIST plus `subgrid` on each row + // (mockup: `.rows.sales`/`.sale`) makes every row share the + // same column tracks, so amounts align down the page the same + // way the mockup's table does. DIRECTION.md line 9's row is + // customer/order number, eggs and grade, amount, status, + // action — the eggs-and-grade column is still not rendered: + // `listOrders`'s `OrderItem`s carry a line's `quantity` but + // only an `eggGradeId`, never a grade NAME, and resolving one + // needs a `listEggGrades()` fetch this screen does not + // otherwise make (recorded on the PR and on #829; not built + // in this round), so the grid below has one fewer column than + // the mockup's until that lands. + + {orders.map((o) => ( + + + {/* Not `.cust` (styles.css: `overflow:hidden; + white-space:nowrap;text-overflow:ellipsis`) — that + truncated a real customer name to "KC…" once four + cells were forced onto one 390px line (#883 round + 5 finding). The row now stacks at 390, so the name + gets its own full-width line and truncation is no + longer needed there; kept nowrap+ellipsis at + desktop, where the column is genuinely narrow. */} + + {rowCustomerName(o)} + + {o.referenceNumber} + + {/* Right-aligned with tabular numerals so every row's + amount lines up on its ones digit — `.num` itself + stays un-right-aligned (styles.css, #829: scoped + narrow on purpose), so the alignment is this cell's + own, not a widened class. */} + + {fmt.money(o.totalMinorUnits, o.currencyCode, o.currencyMinorUnit)} + + + + + {/* A draft order's row action (#883 round 2, finding + 5; wording tightened in round 2's own Codex re- + review, finding 3). There is no per-order deep link + into Sales yet, so this lands on the customer's + WHOLE filtered order list, not the one order — that + list can hold several drafts for the same customer, + so the label says "review", never "confirm": this + control does not confirm anything itself, and a + word that claimed it did would be a real behavior + mismatch, not just one extra expected click (unlike + the Today row's Record/Continue, which land on the + one exact form for that flock and date). */} + {o.status === "Draft" && ( + + {t("salesRowConfirmAction")} + + )} + ))} -
- )} - {bar.totalRestricted > 0 && ( -

{t("stockCaptionRestricted", { restricted: fmt.count(bar.totalRestricted) })}

+ )} - + )} -
+ - {canSeeSales && ( -
-

{t("salesPanelTitle")}

- {orders === null ? panelError : orders.length === 0 ? ( - - ) : ( -
    - {orders.map((o) => ( -
  • - {o.referenceNumber} - {/* #512 US5 (FR-045) — authorized (canSeeSales, the gate this - whole panel is already behind) link into URL-filtered - Sales by canonical id; the name itself is row-owned. */} - {rowCustomerName(o)} - - {fmt.money(o.totalMinorUnits, o.currencyCode, o.currencyMinorUnit)} -
  • - ))} -
+ + {/* ------------------------------------------------------- Stock */} + + + {t("stockPanelTitle")} + + {bar === null || stock === null ? panelError : stock.length === 0 ? ( + + ) : ( + <> + {/* #777 — the total is the whole the bar divides, so it leads. */} + + {fmt.count(bar.totalAvailable)} + {/* The space matters: .stock-fig is display:block so the two + never touch on screen, but the paragraph's text is what a + copy-paste and any text consumer gets, and without it that + reads "1egg available". */} + {" "}{t("eggsAvailableLabel", { count: bar.totalAvailable })} + + + {/* The ledger is the bar's text of record (the track itself is + aria-hidden). It replaces the grade run-on the caption used to + carry, which asked the reader to count segments and trust the + order matched, and it carries each grade's share so a grade + worth well under a percent is readable as a number. */} + {bar.segments.length > 0 && ( + + {bar.segments.map((s) => ( + + + ))} + + )} + {bar.totalRestricted > 0 && ( + + {t("stockCaptionRestricted", { restricted: fmt.count(bar.totalRestricted) })} + + )} + + )} + + + {/* -------------------------------------------------- Last 14 days */} + + + {t("trendPanelTitle")} + + {trendData === null ? panelError : ( + <> + } + to={} + /> + {/* #777 — hen-day is what this panel measures, so it is a figure. + It used to be the smallest text on the panel, inside a muted + sentence describing a quantity the chart above did not plot. */} + + + {trendData.henDay.current === null ? "—" : `${fmt.count(trendData.henDay.current, 1)}%`} + + {deltaText(trendData.henDay.delta)} + + {t("henDaySubLabel")} + + )} + + + + + ); +} + +// One Today row: name, entry state, action, count. The missing house carries +// a 3px `--warn` left rule and its action is the page's single filled +// button (owner amendment on #864, 2026-09-16 — amends DIRECTION.md's +// "ruled text at 1280" for this one row); a Draft entry gets a ruled-text +// "Continue" action; a submitted/locked/voided entry has no action cell, +// only its name links through. +function TodayRow({ tile, today, fmt, t }: { + tile: CaptureTile; + today: string; + fmt: ReturnType; + t: ReturnType>["t"]; +}) { + const { flock, entry } = tile; + const href = `/daily-entry?flockId=${flock.id}&date=${today}`; + const missing = entry === null; + const draft = entry !== null && entry.status === "Draft"; + + // DIRECTION.md line 6 — the entry state with its time ("Recorded 06:40", + // "Draft, saved 06:52"), farm-local (#883 round 2, finding 4). A Draft's + // time is the last save (lastChangedAtUtc, falling back to createdAtUtc for + // a Draft that has never been edited since); a submitted/locked/adjusted + // entry's time is when it became official — madeOfficialAtUtc, sent only + // for a record that has actually reached that step. A record with neither + // timestamp (data predating #494, or a fixture that doesn't care) falls + // back to the bare status word, exactly as before this slice. + const stateTime = missing + ? null + : fmt.time(draft ? (entry.lastChangedAtUtc ?? entry.createdAtUtc) : (entry.madeOfficialAtUtc ?? null)); + const stateLabel = missing || stateTime === null + ? (missing ? undefined : statusLabel(entry.status)) + : t(draft ? "entryStateDraftTime" : "entryStateRecordedTime", { time: stateTime }); + + // A CSS grid, not a flex row: the DIRECTION.md phone layout reflows the + // SAME four pieces (name, state, action, count) into three lines instead + // of shrinking them onto one — a flex row with fixed minWidths overflowed + // a 390px viewport (measured: 475px, phone.spec.ts's viewport-overflow + // walk). `gridTemplateAreas` names the reflow directly rather than + // reordering flex children with `order`. + return ( + + + {flock.name} + + {/* `white-space: nowrap` (#883 round 5): the status cell is the one + piece of this row that must never wrap — "Draft, saved 05:26" onto a + second line is the defect this fix pins. */} + + {missing + ? + : } + + {(missing || draft) && ( + + {missing && ( + // The single filled button on the page at 1280 (owner amendment, + // #864) and the 48px full-width phone action (DIRECTION.md). + + )} + {draft && ( + // Ruled text (DIRECTION.md line 7), not a filled/text Button: + // `Button variant="text"` rendered bold and brand-coloured, the + // #883 round 5 owner finding — the same ruled-text Typography+Link + // pattern the sales row's "Review to confirm" action already + // uses below, so this row and that one share one convention. + + {t("continueHouseAction", { flock: flock.name })} + )} -
- )} -
-
+ + )} + + {entry ? fmt.count(entry.totalEggs) : "—"} + + + ); +} + +// DIRECTION.md line 15: status is a word with an 8px dot, never a filled +// badge — success (recorded, paid), --stat-accent (allocated), a hollow ring +// (draft and anything else with no mapped colour), --warn (not recorded, +// low). Dashboard-local: `StatusBadge` (../components/StatusBadge) stays +// untouched for the other screens, its own conversion is #831's, so this +// duplicates StatusBadge's small VARIANT table rather than exporting it — +// the two are expected to diverge until #831 unifies them. +const STATUS_DOT_COLOR: Record = { + active: "var(--success)", submitted: "var(--success)", confirmed: "var(--success)", + saleable: "var(--success)", paid: "var(--success)", + locked: "var(--stat-accent)", + manageradjusted: "var(--warn)", adjusted: "var(--warn)", partial: "var(--warn)", + voided: "var(--error)", cancelled: "var(--error)", inactive: "var(--error)", denied: "var(--error)", +}; + +function StatusDot({ status, label, forceColor }: { status?: string; label?: string; forceColor?: string }) { + const color = forceColor ?? (status ? STATUS_DOT_COLOR[status.toLowerCase()] : undefined); + return ( + + + {label ?? status} + ); } diff --git a/web/src/styles.css b/web/src/styles.css index 1ec316fac..eeacbaeda 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -405,11 +405,6 @@ button.link:hover:not(:disabled) { outline-offset: 2px; } -/* An aubergine ring is invisible on the aubergine sidebar — use a light one. */ -.sidebar :focus-visible { - outline-color: var(--on-brand); -} - /* Error-boundary fallback (#140). Reads like a page, in both themes: the screen-scope version sits inside the content pane with the shell around it; the app-scope version is the whole page, so it carries its own gutters. */ @@ -1341,42 +1336,27 @@ tr.discounted .badge-warn { .meter.is-warn > span { background: var(--warn); } -/* ------------------------------------------------------- Sidebar shell */ -.shell { - display: grid; - grid-template-columns: var(--sidebar-w) 1fr; - min-height: 100dvh; -} - -.sidebar { - background: var(--brand); - color: var(--on-brand); - display: flex; - flex-direction: column; - padding: 1.5rem 0 1rem; - position: sticky; - top: 0; - height: 100dvh; - overflow-y: auto; -} - +/* --------------------------------------------------- Shell branding (#829) + The sidebar and bottom-nav rule families that used to live here (`.shell`, + `.sidebar`, `.tabbar`, `.tab`) are gone: the sidebar is a permanent MUI + `Drawer` and the tab bar a `BottomNavigation`, both themed in + FarmThemeProvider.tsx (MuiDrawer/MuiListItemButton/MuiBottomNavigation*). + What survives is `FarmBrand` (kept as a component, D2 pair 21-adjacent — + it is not one of the 21 rows but the same "no MUI equivalent, stays as + is" reasoning applies) and the two group-label classes the caps guard + (`styles.caps.test.ts`) keys on. */ .brand { display: flex; align-items: center; gap: 0.5rem; - font-size: 1.35rem; - font-weight: 800; - letter-spacing: -0.03em; - color: var(--on-brand); - padding: 0 1.5rem 1.25rem; -} - -.brand-mark { - color: var(--on-brand-mute); + font-size: 1rem; + font-weight: 600; + color: var(--stat-accent); + padding: 0 1.5rem; } -/* The farm's own logo in the branding slot (#123). On a white plate because - the sidebar is aubergine and a dark logo would otherwise disappear into it. +/* The farm's own logo in the branding slot (#123). On a white plate so a + dark logo stays legible on any farm palette, light or lavender-tinted. `contain` stops a non-square logo being CROPPED — but on the fixed 26x26 square this used to be, contain is what collapsed a wide wordmark into a sliver: it kept the aspect ratio and gave up the height (#179). @@ -1404,142 +1384,16 @@ tr.discounted .badge-warn { white-space: nowrap; } -.nav-group { - padding: 0.35rem 0; -} - .nav-group-label { font-size: 0.66rem; font-weight: 700; letter-spacing: 0.11em; text-transform: uppercase; - color: var(--on-brand-mute); + color: var(--muted); padding: 0 1.5rem; margin: 0.6rem 0 0.3rem; } -.sidebar nav { - display: flex; - flex-direction: column; -} - -.sidebar nav a { - display: flex; - align-items: center; - gap: 0.65rem; - color: var(--on-brand-mute); - text-decoration: none; - padding: 0.5rem 1.5rem; - font-size: 0.94rem; - font-weight: 500; - border-left: 3px solid transparent; - transition: background 0.12s ease, color 0.12s ease; -} - -.sidebar nav a svg { - flex: 0 0 auto; - opacity: 0.8; -} - -.sidebar nav a:hover { - color: var(--on-brand); - background: rgba(255, 255, 255, 0.07); -} - -.sidebar nav a:hover svg { opacity: 1; } - -.sidebar nav a.active { - color: var(--on-brand); - background: rgba(255, 255, 255, 0.11); - border-left-color: var(--on-brand-mute); - font-weight: 600; -} - -.sidebar nav a.active svg { opacity: 1; } - -.sidebar-foot { - margin-top: auto; - padding: 0.75rem 1.25rem 0; - display: flex; - flex-direction: column; - gap: 0.1rem; - border-top: 1px solid rgba(255, 255, 255, 0.1); -} - -/* Foot buttons sit on aubergine — override the blue .link treatment */ -.sidebar button.link { - display: flex; - align-items: center; - gap: 0.65rem; - color: var(--on-brand-mute); - padding: 0.5rem 0.25rem; - font-size: 0.9rem; -} - -.sidebar button.link:hover:not(:disabled) { - color: var(--on-brand); - text-decoration: none; -} - -.sidebar-version { - margin: 0.35rem 0 0.6rem; - padding: 0 0.25rem; - font-size: 0.75rem; - color: var(--on-brand-mute); - opacity: 0.7; -} - -/* -------------------------------------------- Bottom nav (mobile) */ - -/* The tab bar and its More sheet own navigation below 900px; the sidebar owns - it above. Hidden here so both can live in the DOM at once — CSS, not JS, - decides which is active, so there is no flash and no media-query listener. */ -.tabbar { - display: none; -} - -@media (max-width: 900px) { - .tabbar { - display: grid; - grid-auto-columns: 1fr; - grid-auto-flow: column; - position: fixed; - inset: auto 0 0 0; - z-index: 20; /* over the page + the entry footer (10), under the dialog (50) */ - background: var(--surface); - border-top: 1px solid var(--hairline); - box-shadow: var(--shadow-bar); - padding-bottom: env(safe-area-inset-bottom); - } - - .tab { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.15rem; - /* Clears the 44px touch target the rest of the app now holds to. */ - min-height: 3.4rem; - padding: 0.4rem 0.25rem; - background: none; - border: none; - border-radius: 0; - color: var(--muted); - font-size: 0.66rem; - font-weight: 600; - letter-spacing: 0; - text-decoration: none; - } - - .tab svg { opacity: 0.85; } - - .tab.active { - color: var(--stat-accent); - } - - .tab.active svg { opacity: 1; } -} - /* The More sheet reuses the Dialog shell, so these only style its contents. */ .more-nav { display: flex; @@ -1638,12 +1492,26 @@ tr.discounted .badge-warn { letter-spacing: -0.01em; } -.content a { +/* #883 round 4, finding B — wrapped in `:where()` so this contributes ZERO + specificity, the same device the #823 bare-element sweep uses above (L340 + onward), extended to a scoped-but-still-outranking selector that sweep did + not catch (it only walked selectors with no class of their own). At its + un-where'd specificity (0,1,1), `.content a` beat MUI's own single + generated class (0,1,0) regardless of Emotion's `prepend: true` injection + order — so a contained `Button` rendered as `component={Link}` (an `` + under `.content`, e.g. Dashboard's Record button) had its `contrastText` + label repainted `--link` blue instead of `--on-brand` white. Zeroing this + selector leaves it applying only where nothing else sets `color` on the + element — every plain `Typography`-as-link and hand-rolled `` in the + app, none of which carry an explicit MUI `color` — while any MUI component + that does set one (a contained, outlined or text Button; a Chip) wins on + its own single class. */ +:where(.content a) { color: var(--link); text-decoration: none; } -.content a:hover { +:where(.content a):hover { color: var(--link-hover); text-decoration: underline; } @@ -1723,113 +1591,23 @@ label.check { } /* ------------------------------------------------------ Cards & panels */ +/* #829 — `.panel`, `.dash-grid`, `.panel-wide` and the Dashboard's own + capture-status tile grid (`.capture-*`) are gone with the Dashboard.tsx + rewrite: the ruled Today list is `sx`-laid-out MUI, not a card grid. + `.card` and `.order-panel` stay — Login and the drill-down panels (#831 to + #833) still convert to MUI later. */ .card, -.panel, .order-panel { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--r-panel); } -.panel { - padding: 1.5rem; - flex: 1 1 18rem; -} - -.panel h3 { - margin: 0 0 0.5rem; -} - -.panel h3 a { - color: var(--ink); - text-decoration: none; -} - -.panel h3 a:hover { - color: var(--stat-accent); - text-decoration: none; -} - -.panel table.data { - min-width: 0; -} - .order-panel { padding: 1.5rem 1.75rem; margin: 1.25rem 0; } -.dash-grid { - display: flex; - flex-wrap: wrap; - gap: 1.25rem; - align-items: flex-start; -} - -.panel-wide { - flex-basis: 100%; -} - -/* ------------------------------------------ Capture status (#654) */ -/* One tile per active flock, missing first, at most 12; a tile is a link to - that flock's day. Border only, no shadow (#651's direction), no caps - (#652's), no motion. */ -.capture-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); - gap: 0.75rem; -} - -.capture-tile { - display: block; - color: inherit; - text-decoration: none; - background: var(--surface); - border: 1px solid var(--hairline); - border-radius: var(--r-panel); - padding: 0.9rem 1rem; -} - -.capture-tile:hover { - border-color: var(--stat-accent); -} - -/* The alarm state — a house with no entry yet is the loudest thing on the screen. */ -.capture-tile.is-missing { - background: var(--tint-warn); - border-color: var(--warn); -} - -.capture-tile-name { - font-weight: 600; -} - -.capture-tile-eggs { - margin: 0.35rem 0 0.5rem; - font-size: 1.9rem; - font-weight: 800; - letter-spacing: -0.02em; - line-height: 1; - font-variant-numeric: tabular-nums; - color: var(--stat-accent); -} - -/* A house with no entry has no figure to show. The dash marks the slot without - competing with the real counts on the tiles beside it — at the full figure - size it reads as a loading skeleton rather than as an absence. */ -.capture-tile.is-missing .capture-tile-eggs { - font-size: 1rem; - font-weight: 600; - color: var(--muted); -} - -/* Past the cap: "N more flocks", a quiet link under the grid. */ -.capture-more { - display: inline-block; - margin-top: 0.6rem; - color: var(--muted); -} - /* ------------------------------------- Last 14 days (#654, #777) */ /* A strip of days, not a line. The line it replaces drew a zero day on the floor of its viewBox, so a zero was a drop rather than a plateau — but it @@ -2151,144 +1929,44 @@ label.check { The wheel opens blue, violet, green on purpose — brick, ochre, green at the first three positions made a farm's Small, Medium, Large read as a red/amber/green ramp, the interface saying Small is bad. */ -.meter-stack > span.grade-1, .stock-ledger .swatch.grade-1 { background: var(--grade-1); } -.meter-stack > span.grade-2, .stock-ledger .swatch.grade-2 { background: var(--grade-2); } -.meter-stack > span.grade-3, .stock-ledger .swatch.grade-3 { background: var(--grade-3); } -.meter-stack > span.grade-4, .stock-ledger .swatch.grade-4 { background: var(--grade-4); } -.meter-stack > span.grade-5, .stock-ledger .swatch.grade-5 { background: var(--grade-5); } -.meter-stack > span.grade-6, .stock-ledger .swatch.grade-6 { background: var(--grade-6); } -.meter-stack > span.grade-7, .stock-ledger .swatch.grade-7 { background: var(--grade-7); } -.meter-stack > span.grade-8, .stock-ledger .swatch.grade-8 { background: var(--grade-8); } - -/* The bar's text of record: swatch, name, count and share per grade. Wraps - into as many columns as the panel affords. No row rules — the four columns - align on their own, and ruling them broke into a ragged line at the gap. */ -.stock-ledger { - list-style: none; - padding: 0; - margin: 0; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); - gap: 0.05rem 1.25rem; - font-size: 0.8125rem; -} - -.stock-ledger li { - display: grid; - grid-template-columns: auto minmax(0, 1fr) max-content max-content; - align-items: center; - gap: 0.45rem; - padding: 0.22rem 0; -} - -.stock-ledger .swatch { +.meter-stack > span.grade-1, .swatch.grade-1 { background: var(--grade-1); } +.meter-stack > span.grade-2, .swatch.grade-2 { background: var(--grade-2); } +.meter-stack > span.grade-3, .swatch.grade-3 { background: var(--grade-3); } +.meter-stack > span.grade-4, .swatch.grade-4 { background: var(--grade-4); } +.meter-stack > span.grade-5, .swatch.grade-5 { background: var(--grade-5); } +.meter-stack > span.grade-6, .swatch.grade-6 { background: var(--grade-6); } +.meter-stack > span.grade-7, .swatch.grade-7 { background: var(--grade-7); } +.meter-stack > span.grade-8, .swatch.grade-8 { background: var(--grade-8); } + +/* #829 — the grade swatch's own size; it used to be scoped under the + `.stock-ledger` list, which is gone (the ledger is now `sx`-laid-out). */ +.swatch { + display: inline-block; width: 0.6rem; height: 0.6rem; border-radius: 2px; } -.stock-ledger .name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.stock-ledger .count { +/* #829 — `.num`'s tabular figures and `.cust`'s truncation, unscoped: both + used to be `.dash-list`-prefixed and the list itself is gone (Recent + sales is now an `sx`-laid-out row, D2 pair 14). Deliberately narrow — + only what the removed rules carried, not `table.data td.num`'s + right-alignment, which stays scoped to real tables (#831 to #833). */ +.num { font-variant-numeric: tabular-nums; -} - -.stock-ledger .share { - min-width: 2.6rem; - text-align: right; - color: var(--muted); - font-variant-numeric: tabular-nums; -} - -/* ---------------------------------- Recent sales list (#654, #777) */ -/* Four real columns, aligned DOWN the list rather than per row: the tracks are - sized on the
    and each
  • is a subgrid of it. Before #777 the row was - a plain flex line with `margin-left: auto` on the amount, so the amount was - the only thing that formed a column. - No track is sized in pixels or from an English string — the badge and amount - tracks are `max-content`, so they measure whatever the farm's locale renders - (#688: `tl` runs about 30% longer than `en` here). */ -.dash-list { - list-style: none; - padding: 0; - margin: 0; - display: grid; - /* The reference track is `minmax(0, max-content)` rather than bare - `max-content` so it can yield; the name gets a real floor rather than - `minmax(0, 1fr)`, which let the three intrinsic tracks squeeze it to zero - width and render a link with nothing visible in it. */ - grid-template-columns: minmax(0, max-content) minmax(7rem, 1fr) max-content max-content; - column-gap: 0.75rem; -} - -.dash-list li { - display: grid; - grid-column: 1 / -1; - grid-template-columns: subgrid; - align-items: center; - padding: 0.45rem 0; - border-top: 1px solid var(--hairline); -} - -/* A reference number is one identifier. Breaking it after the hyphen makes it - harder to match against the Sales page and makes the row two lines tall. - It still needs the same clip the name has: with nowrap alone a long reference - beside a seven-figure total overran the amount at a 280px panel. */ -.dash-list .ref { white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } -/* Truncated, not wrapped: rows stay one line so the four columns scan, and the - clip is visual only — the link's accessible name is still the whole name, - and following it lands on Sales filtered to that customer. */ -.dash-list .cust { +.cust { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.dash-list .num { - text-align: right; - white-space: nowrap; - font-variant-numeric: tabular-nums; -} - -/* The PANEL's width decides, not the viewport's. A viewport query was the - first answer and it was wrong: a narrow panel on a wide screen still got - four tracks and the name column collapsed to nothing. Scoped with :has() so - no other panel becomes a container. */ -.panel:has(.dash-list) { - container-type: inline-size; - container-name: dashpanel; -} - -/* Too narrow for four columns: reference and amount, then customer and status. - 32rem, not the 26rem this first shipped with: the three intrinsic tracks plus - the gaps need about 356px, so at a 418px panel the name was measured at 62px - in `en` and 45px in `tl` — about six characters of a customer name, which is - the collapse the rule above exists to prevent. `tl` is worse precisely - because the badge track is `max-content` and its labels run longer, so the - threshold is set for the widest locale rather than the one on screen. */ -@container dashpanel (max-width: 32rem) { - .dash-list { - grid-template-columns: minmax(0, 1fr) max-content; - } - - .dash-list li { - row-gap: 0.15rem; - } - - .dash-list .ref { grid-column: 1; grid-row: 1; } - .dash-list .num { grid-column: 2; grid-row: 1; } - .dash-list .cust { grid-column: 1; grid-row: 2; } - .dash-list .badge { grid-column: 2; grid-row: 2; justify-self: end; } -} +/* #829 — the grade ledger (`.stock-ledger`) and the recent-sales list + (`.dash-list`), including the panel-width container query the latter + used, are gone: both are `sx`-laid-out MUI rows now (D2 pairs 14/21- + adjacent), and `.swatch`/`.num`/`.cust` above carry what they still need. */ /* --------------------------------------------- Login — the hero moment */ .auth { @@ -2825,14 +2503,6 @@ dl.glossary { /* ------------------------------------------------ Responsive (#49 kept) */ @media (max-width: 900px) { - /* minmax(0, …), not plain 1fr: a bare `1fr` track floors at the item's - min-content, and the wrapping nav bar's is wider than a phone — the whole - page rendered ~510px wide on a 390px screen, pushing each screen's - "New …" button under the nav where it could not be tapped. */ - .shell { - grid-template-columns: minmax(0, 1fr); - } - /* Reserve the real bar height so the sticky footers clear it and the page can scroll its last row out from under it. Must sit on a selector, not bare in the @media — a loose custom-property declaration is invalid and the parser @@ -2844,11 +2514,9 @@ dl.glossary { --tabbar-h: calc(3.6rem + env(safe-area-inset-bottom)); } - /* The sidebar gives way to the bottom tab bar (BottomNav). It used to become - a wrapping top bar that cost ~298px — a third of the screen. */ - .sidebar { - display: none; - } + /* The Drawer (sidebar) and BottomNavigation (tab bar) are hidden/shown by + the theme's own `md` breakpoint (`sx={{ display: { xs: …, md: … } }}` in + AppLayout.tsx/BottomNav.tsx) — no CSS rule needed here for either. */ .content { /* Exactly the tab bar below the content (the token already carries the @@ -2910,10 +2578,11 @@ dl.glossary { } .help-layout { - /* minmax(0, …), not bare 1fr — same #441 grid-blowout shape as .shell's - own fix above: a bare 1fr track floors at .help-toc's wrapping nav's - min-content, not 0, and that nav's own flex-wrap row is wide enough - (many TOC entries) to blow the whole page out past the viewport. */ + /* minmax(0, …), not bare 1fr — same #441 grid-blowout shape the old + `.shell` fix guarded against: a bare 1fr track floors at .help-toc's + wrapping nav's min-content, not 0, and that nav's own flex-wrap row is + wide enough (many TOC entries) to blow the whole page out past the + viewport. */ grid-template-columns: minmax(0, 1fr); gap: 1rem; } @@ -2988,11 +2657,6 @@ dl.glossary { scroll-margin-top: 6.5rem; /* rail + heading */ } - .panel { - min-width: 0; - flex: 1 1 100%; - } - .form-grid label { min-width: 0; flex: 1 1 40%; diff --git a/web/src/styles.elevation.test.ts b/web/src/styles.elevation.test.ts index 033735f68..9e5ebf478 100644 --- a/web/src/styles.elevation.test.ts +++ b/web/src/styles.elevation.test.ts @@ -164,14 +164,21 @@ function declarationsForAt(selector: string, mediaParams: string | undefined): M return decls; } -// Everything allowed to cast a shadow, and why. Six floats plus one ring. +// Everything allowed to cast a shadow, and why. Five floats plus one ring. +// +// `.tabbar` retired here in #829: the mobile tab bar is now MUI +// `BottomNavigation`, themed with `boxShadow: "none"` (variant B, "ruled" — +// a hairline top rule instead of a shadow, owner pick 2026-09-16). Its +// successor assertion, "gives the tab bar no shadow and a hairline top rule +// instead (variant B)" in `web/src/theme/farmTheme.policy.test.ts`, already +// landed with #864's theme overrides — #829 needs no new row, only this +// retirement. const SHADOW_ALLOWED = [ ".auth .card", // the sign-in card, floating on the auth gradient ".dialog", // a modal, over its backdrop ".entry-foot", // the Daily entry sticky action bar ".glossary-entry:target", // not elevation: a spread-only deep-link halo ".named-picker-listbox", // the picker popover, over the form beneath it - ".tabbar", // the mobile tab bar ".update-banner", // the service-worker update prompt ].sort(); @@ -180,8 +187,16 @@ describe("#651 elevation: only a float casts a shadow", () => { expect(selectorsCastingShadow()).toEqual(SHADOW_ALLOWED); }); - it("a panel, a card and an order panel carry a border and nothing else", () => { - for (const selector of [".card", ".panel", ".order-panel"]) + // #829 — `.panel` retired from this list: the selector no longer exists + // (the Dashboard's cards are gone, not migrated onto `MuiCard`), and + // `declarationsFor` on a selector nothing declares returns an empty map, + // so keeping it here would pass vacuously — exactly the trap 822's D4 + // named for this rule ("passes vacuously once those selectors are gone"). + // No MuiCard successor exists for `.panel` specifically because nothing + // replaced it with a card; `.card`/`.order-panel` below still do, and + // `farmTheme.policy.test.ts`'s "makes a Card a hairline box" is their G2. + it("a card and an order panel carry a border and nothing else", () => { + for (const selector of [".card", ".order-panel"]) expect(declarationsFor(selector).get("box-shadow")).toBeUndefined(); }); @@ -218,13 +233,18 @@ describe("#651 radius: a three-step scale, declared as tokens", () => { // Every surface this slice owns, INCLUDING two --r-input consumers. Without // those the scale guard would assert nothing about the one token whose value // actually changes, and would read as safety it does not provide. + // + // #829 retires `.panel` and `.capture-tile` from both lists below: the + // Dashboard no longer renders either selector (it is `sx`-laid-out MUI), + // so a radius token on a selector nothing renders would be a guard reading + // as safety it does not provide (AGENTS.md, "writing a guard"). `.card` + // and `.order-panel` stay — other screens still convert their own cards + // in #831 to #833. it.each([ ".toolbar", ".card", - ".panel", ".order-panel", ".entry-pane", - ".capture-tile", "input", ".named-picker-trigger", ])("%s resolves its radius through a token, not a literal", (selector) => { @@ -237,7 +257,7 @@ describe("#651 radius: a three-step scale, declared as tokens", () => { // pattern match (above) would stay green if one of these silently reverted // to --r-card, so this pins the SPECIFIC token per surface. it.each([ - ".card", ".panel", ".order-panel", ".entry-pane", ".capture-tile", + ".card", ".order-panel", ".entry-pane", ".help-hero", ".logo-preview", ".banner-preview", ".farm-warning", ".palette-picker", ])("%s reads --r-panel, not the dialog radius", (selector) => { diff --git a/web/src/styles.grades.test.ts b/web/src/styles.grades.test.ts index 76da13976..816821f77 100644 --- a/web/src/styles.grades.test.ts +++ b/web/src/styles.grades.test.ts @@ -108,9 +108,12 @@ describe.each(MODES)("grade hues: %s", (mode) => { }); describe("grade hues: the classes that consume them", () => { + // #829 — `.swatch` is unscoped now (`.stock-ledger` is gone: the grade + // ledger renders through `sx`, not a CSS-driven list), so the selector + // this pins dropped that ancestor. it("styles every index stockBar can emit, on both the band and the swatch", () => { for (let i = 1; i <= GRADE_COLOURS; i++) { - expect(css, `grade-${i}`).toContain(`.meter-stack > span.grade-${i}, .stock-ledger .swatch.grade-${i} { background: var(--grade-${i}); }`); + expect(css, `grade-${i}`).toContain(`.meter-stack > span.grade-${i}, .swatch.grade-${i} { background: var(--grade-${i}); }`); } }); @@ -132,7 +135,7 @@ describe("grade hues: the classes that consume them", () => { // The track itself legitimately paints `--surface-2`; only rules reaching // a band inside it are in scope. if (!/\.meter-stack\s*[>\s]/.test(rule.selector)) return; - if (/^\.meter-stack > span\.grade-\d, \.stock-ledger \.swatch\.grade-\d$/.test(rule.selector)) return; + if (/^\.meter-stack > span\.grade-\d, \.swatch\.grade-\d$/.test(rule.selector)) return; rule.walkDecls(/^background(-color|-image)?$/, (d) => { offenders.push(`${rule.selector} { ${d.prop}: ${d.value} }`); }); }); expect(offenders).toEqual([]); diff --git a/web/src/styles.test.ts b/web/src/styles.test.ts index 7f244d40f..9567fe310 100644 --- a/web/src/styles.test.ts +++ b/web/src/styles.test.ts @@ -1,6 +1,4 @@ import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; import { contrast, declaredKeys, literalColourIn, luminance, resolveTokens, type Mode } from "./test/cssTokens"; import { BRANDS, DEFAULT_BRAND } from "./lib/brand"; @@ -101,9 +99,13 @@ describe.each(BRANDS)("palette: %s", (brand) => { for (const token of DARK_REQUIRED) expect(declared).toContain(token); }); - it.each(MODES)("%s: brand fill stays dark enough for the sidebar overlay", (mode) => { - // .sidebar nav a layers white at 7-11% over the brand fill (a deliberate - // hardcode, not a token). Too light a fill and hover/active stop reading. + it.each(MODES)("%s: brand fill stays dark enough for on-brand text", (mode) => { + // #829 — the aubergine nav-rail overlay this test named (`.sidebar nav a` + // layering white at 7-11% over the brand fill) is gone: the rail is now + // `--lavender` tinted paper, and `--brand` is left carrying the primary + // button's fill and `--on-brand` its contrast text (checked directly + // below). A dark ceiling on the fill is still the margin that keeps that + // pairing — and any future light-overlay-on-brand surface — legible. const tokens = resolveTokens(attrFor(brand), mode); expect(luminance(tokens.get("--brand")!)).toBeLessThanOrEqual(0.18); }); @@ -248,128 +250,11 @@ it("pins every brand-scoped token a palette block can declare", () => { // Walks EVERY block whose selector starts with a dashboard prefix (a second // `.capture-tile` block or a `:hover` rule is walked too), rather than a list // of the blocks the author remembered. -describe("dashboard surfaces (#654, INV-8)", () => { - // Relative path held in a variable, not an inline literal: Vite's - // import-analysis plugin statically pattern-matches - // `new URL("literal", import.meta.url)` and rewrites it to a dev-server - // asset URL under the jsdom test environment, which fileURLToPath() then - // rejects as "not scheme file" (see src/test/cssTokens.ts). - const CSS_REL = "./styles.css"; - const css = readFileSync(fileURLToPath(new URL(CSS_REL, import.meta.url)), "utf8") - .replace(/\/\*[\s\S]*?\*\//g, ""); - // Any rule that APPLIES to a dashboard surface, not only one whose selector - // starts with it: `.unrelated, .capture-tile:hover { … }` reaches the tile - // just as surely, and an anchored match walked straight past it. - const TOUCHES = /(^|[\s,>+~])\.(capture-[a-z-]*|trend[a-z-]*|daystrip|day|day-week|tip|tipdock|avgline|stock-[a-z-]*|meter-stack|dash-list|panel-wide)\b/; - const blocks = Array.from(css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) - .map((m) => ({ selector: m[1].trim(), body: m[2] })) - .filter((b) => TOUCHES.test(b.selector)); - const bodyOf = (selector: string) => { - const b = blocks.find((x) => x.selector === selector); - expect(b, `${selector} must be declared`).toBeDefined(); - return b!.body; - }; - - it("declares the tile, cap link, day strip, stacked meter, ledger and list rules", () => { - for (const s of [".capture-grid", ".capture-tile", ".capture-tile.is-missing", ".capture-tile.is-missing .capture-tile-eggs", ".capture-tile-eggs", ".capture-more", - ".trend-scale", ".daystrip", ".day", ".day > i", ".day.on", ".day.on > i", ".day.on::after", - ".day-partial > i", ".day.on.day-partial > i", - ".day-week", ".avgline", ".tipdock", ".tip", ".trend-rule", ".trend-kpi", - ".meter-stack", ".meter-stack > span", ".stock-ledger", ".stock-ledger li", ".dash-list", ".dash-list li", ".panel-wide"]) - bodyOf(s); - expect(blocks.length).toBeGreaterThanOrEqual(20); - }); - it("carries no box-shadow, text-transform, transition, animation or literal colour on any of them", () => { - for (const b of blocks) { - expect(b.body, b.selector).not.toMatch(/box-shadow|text-transform|transition|animation/); - // Enumerating colour syntaxes is a losing game (hex, rgb(), hsl(), named, - // oklch(), color-mix()), so require the opposite: every colour-valued - // declaration resolves through a token, or is one of the few keywords - // that carry no colour of their own. - for (const decl of b.body.split(";")) { - const [rawProp, ...rest] = decl.split(":"); - const prop = rawProp.trim(); - const value = rest.join(":").trim(); - if (!value) continue; - // Only properties that can carry a COLOUR. `border(-)?` is the - // shorthand; `border-radius` / `-width` / `-style` are not colours and - // matching them here made a plain `3px 3px 0 0` look untokenised - // (#777). `border-color` is already caught by the `-color$` branch, and - // `border-image` is listed because it is the one other border longhand - // that can carry one. - if (!/(^|-)color$|^background(-color|-image)?$|^border(-(top|right|bottom|left|block|inline|image))?$|^stroke$|^fill$|^outline(-color)?$/.test(prop)) continue; - const tokenised = value.includes("var(--") - || /^(inherit|initial|unset|revert|none|transparent|currentColor)$/i.test(value); - expect(tokenised, `${b.selector}: "${prop}: ${value}" must resolve through a token`).toBe(true); - // Containing a token is necessary but not sufficient: a value can mix a - // literal INTO one. `color-mix(in oklab, #ff0000 7%, var(--surface))` - // passed the check above, and #777 introduced this stylesheet's first - // color-mix, so the hole went from inert to live on a dashboard surface. - // The first patch enumerated hex and four colour functions and missed - // NAMED colours, so `color-mix(in oklab, red 7%, var(--surface))` still - // walked through; literalColourIn strips the var() references and - // inspects whatever is left. - const literal = literalColourIn(value); - expect(literal, `${b.selector}: "${prop}: ${value}" carries the literal colour "${literal}" beside its token`).toBeNull(); - } - } - }); - // #780 — the arrow under the day readout shipped 4/5ths hidden behind the box - // it points from, and every guard here stayed green: the selector was - // declared and its colours were tokenised, which is all this file used to - // ask. Both facts that made it wrong are asserted now. - // - // A CSS triangle's visible wedge is its TOP border, so a bottom border is - // 5px of invisible box between the wedge and what it points at — with - // `bottom: 100%` pinning the box to the slot, that gap pushes the wedge up - // behind the readout. The third side must be zero. - it("points the day readout's arrow at the slot, with no bottom border to push it off", () => { - const arrow = bodyOf(".day.on::after"); - expect(arrow).toMatch(/bottom:\s*100%/); - // A visible wedge: the top border carries the ink, the sides are clear. - expect(arrow).toMatch(/border-top-color:\s*var\(--ink\)/); - expect(arrow).toMatch(/border-color:\s*transparent/); - // Three values, the last a bare 0 — `5px 5px 0`. A fourth value, or a - // single one, reinstates the bottom border and the arrow disappears again. - const width = /border-width:\s*([^;]+)/.exec(arrow)?.[1].trim(); - expect(width, "border-width must be the three-value form").toBeDefined(); - const parts = width!.split(/\s+/); - expect(parts).toHaveLength(3); - expect(parts[2], "the bottom border must be 0 or the arrow hides behind the box").toBe("0"); - // Nothing may move it back up: `bottom: 100%` means a positive - // `margin-bottom` pushes AWAY from the slot, which is how this shipped. - expect(arrow).not.toMatch(/margin-bottom/); - }); - - // The selection ring must not be drawn inside the slot: a bar is 18px in a - // ~20px slot and `.day.on > i` paints it the ring's own colour, so an inset - // ring vanished on the peak day — the one most likely to be inspected. - it("draws the selected day's ring outside the slot, clear of its own bar", () => { - const offset = /outline-offset:\s*(-?[\d.]+)px/.exec(bodyOf(".day.on"))?.[1]; - expect(offset, "outline-offset must be declared in px").toBeDefined(); - expect(Number(offset)).toBeGreaterThan(0); - // And inside the 4px inter-slot gap, so two adjacent rings cannot touch. - expect(Number(offset)).toBeLessThan(2); - }); - - // A recorded day that produced nothing is a 2% bar, which is 1.58px on a 5rem - // strip — the entire visible difference between "the flock laid nothing" and - // "nobody looked". A percentage floor alone is not a legible mark. - it("gives the shortest bar a pixel floor, not only a percentage one", () => { - expect(bodyOf(".day > i")).toMatch(/min-height:\s*[3-9]px/); - }); - - it("keeps the tile on the panel radius and the production bar on the accent token", () => { - // #864 narrows --r-card to dialogs/sheets; a card-like tile reads --r-panel. - expect(bodyOf(".capture-tile")).toMatch(/border-radius:\s*var\(--r-panel\)/); - expect(bodyOf(".capture-tile")).toMatch(/border:\s*1px solid var\(--hairline\)/); - // Eggs per day is the farm's own measure, so the bar carries the brand - // accent. Grade bands deliberately do NOT (see the --grade-N tokens): - // a grade must not change colour with the farm's palette. - expect(bodyOf(".day > i")).toMatch(/background:\s*var\(--stat-accent\)/); - expect(bodyOf(".meter-stack > span")).not.toMatch(/background/); - }); - it("no longer declares the stat cards the dashboard stopped rendering", () => { - expect(css).not.toMatch(/^\.stat(-grid|-value|-label)?\s*\{/m); - }); -}); +// #829 — "dashboard surfaces (#654, INV-8)" retired here. The Dashboard's own +// rules it asserted (`.capture-*`, `.stock-ledger`, `.dash-list`, +// `.panel-wide`) are deleted with the Dashboard.tsx rewrite (styled through +// `sx` now, D2 pairs 14/19-adjacent). The kept-component rules it also +// asserted (`.trend-*`, `.daystrip`, `.day*`, `.tip*`, `.avgline`, +// `.meter-stack` — DayStrip and StockBar, D2 pair 21) are untouched, and +// their assertions moved to `components/DayStrip.styles.test.ts`, the named +// successor, rather than disappearing with this block. diff --git a/web/src/test/matchMedia.ts b/web/src/test/matchMedia.ts new file mode 100644 index 000000000..1502501a9 --- /dev/null +++ b/web/src/test/matchMedia.ts @@ -0,0 +1,41 @@ +import { vi } from "vitest"; + +// jsdom has no `matchMedia` (confirmed directly: FarmThemeProvider.render. +// test.tsx, and BottomNav's own effect guards on `typeof window.matchMedia +// !== "function"`). Left unstubbed, MUI's `useMediaQuery` falls back to its +// `defaultMatches` (false — the "below the breakpoint" branch) and a plain +// `window.matchMedia(...)` call throws. A test whose assertion depends on +// which side of a breakpoint the component resolves calls this first. +// +// `matches` is a shared mutable getter, not a value frozen at stub time: a +// caller that also wants to simulate a LIVE breakpoint crossing (a resize +// while a component is mounted, not just its initial render) uses the +// returned `triggerChange`, which flips it and fires every listener any +// `matchMedia(...)` call registered — the same list a real MediaQueryList's +// `change` event would reach. +export function stubMatchMedia(initialMatches: boolean) { + const state = { matches: initialMatches }; + const listeners = new Set<(e: MediaQueryListEvent) => void>(); + const mock = vi.fn((query: string) => ({ + get matches() { return state.matches; }, + media: query, + onchange: null, + addEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => listeners.add(cb), + removeEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb), + addListener: (cb: (e: MediaQueryListEvent) => void) => listeners.add(cb), + removeListener: (cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb), + dispatchEvent: vi.fn(), + })); + vi.stubGlobal("matchMedia", mock); + return { + // The mock itself, so a test can assert WHICH query string a component + // asked for (`expect(matchMedia).toHaveBeenCalledWith(MD_UP_QUERY)`) — + // the part a fixed `matches` value alone cannot pin, since any query + // string gets the same stubbed answer. + matchMedia: mock, + triggerChange: (matches: boolean) => { + state.matches = matches; + listeners.forEach((cb) => cb({ matches } as MediaQueryListEvent)); + }, + }; +} diff --git a/web/src/theme/FarmThemeProvider.tsx b/web/src/theme/FarmThemeProvider.tsx index a8a07c594..242ab8a46 100644 --- a/web/src/theme/FarmThemeProvider.tsx +++ b/web/src/theme/FarmThemeProvider.tsx @@ -109,6 +109,7 @@ export function createFarmTheme(tokens: TokenValues, mode: ThemeMode): Theme { const cardRadius = radius("--r-card", 12); const panelRadius = radius("--r-panel", 8); const pillRadius = radius("--r-pill", 999); + const controlRadius = radius("--r-input", 4); const base = createTheme({ palette: { @@ -222,7 +223,7 @@ export function createFarmTheme(tokens: TokenValues, mode: ThemeMode): Theme { styleOverrides: { root: { borderRadius: panelRadius } }, }, MuiDialog: { styleOverrides: { paper: { borderRadius: cardRadius } } }, - MuiOutlinedInput: { styleOverrides: { root: { borderRadius: radius("--r-input", 4) } } }, + MuiOutlinedInput: { styleOverrides: { root: { borderRadius: controlRadius } } }, // `Autocomplete` sets no elevation on its listbox paper, so it falls to // `Paper`'s default of 1 — which this scale flattens. The picker popover is // one of #651's floats, so it takes the dialog shadow explicitly. @@ -235,7 +236,10 @@ export function createFarmTheme(tokens: TokenValues, mode: ThemeMode): Theme { defaultProps: { disableElevation: true }, styleOverrides: { root: { - borderRadius: pillRadius, + // DIRECTION.md line 17 (#864): controls take the 4px control + // radius, not the pill — the mockup's buttons are 4px rectangles. + // MuiChip alone keeps the pill. + borderRadius: controlRadius, // Phone-scoped, because `Button variant="text"` is where the app's // inline row actions land: an unconditional floor would add ~20px to // every row of 22 ledger tables at 1280. @@ -368,6 +372,7 @@ export function createFarmTheme(tokens: TokenValues, mode: ThemeMode): Theme { // nav a.active`, `styles.css`), so the two shells say "active" // the same way. "&.Mui-selected": { + color: tokens["--stat-accent"], boxShadow: `inset 0 2px 0 0 ${tokens["--stat-accent"]}`, }, }, @@ -405,6 +410,49 @@ export function createFarmTheme(tokens: TokenValues, mode: ThemeMode): Theme { root: { height: 36, [phone]: { height: 52 } }, }, }, + // Sidebar shell (D2 pair 12, #829). `Drawer`'s paper defaults to + // `background.paper`; DIRECTION.md's confirmed nav rail is `--lavender` + // tinted paper with `--stat-accent` text, not the aubergine `--brand` + // slab `.sidebar` painted before this slice — brand appears in exactly + // four places now (farm name, active nav item, primary button, focus + // ring), and this is the rail's share of that. + MuiDrawer: { + styleOverrides: { + paper: { + backgroundColor: tokens["--lavender"], + borderRight: `1px solid ${tokens["--hairline"]}`, + }, + }, + }, + // The group heading keeps its own CSS class (`.nav-group-label` / + // `.more-group-label`, styles.css — D4/#824's guard keys on those two + // selectors for the caps casing), so this only neutralises MUI's own + // subheader chrome (its sticky positioning and background) rather than + // fighting the class for size and colour. + MuiListSubheader: { + styleOverrides: { + root: { + position: "static", + backgroundColor: "transparent", + lineHeight: "inherit", + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + borderLeft: "3px solid transparent", + gap: 8, + "&.Mui-selected, &.Mui-selected:hover": { + backgroundColor: "transparent", + borderLeftColor: tokens["--stat-accent"], + color: tokens["--stat-accent"], + fontWeight: 600, + }, + "&:hover": { backgroundColor: tokens["--surface-2"] }, + }, + }, + }, }, }); } diff --git a/web/src/theme/farmTheme.policy.test.ts b/web/src/theme/farmTheme.policy.test.ts index 0d82b4abc..fd20a1493 100644 --- a/web/src/theme/farmTheme.policy.test.ts +++ b/web/src/theme/farmTheme.policy.test.ts @@ -119,10 +119,14 @@ describe("farm theme policy (#823 G2)", () => { } }); - it("puts the 44px touch floor on phones only, and the pill on every button", () => { - for (const { label, theme, pill } of themes) { + it("puts the 44px touch floor on phones only, and the control radius on every button (#864 amendment)", () => { + for (const { label, theme, input } of themes) { const root = slot(theme.components?.MuiButton?.styleOverrides?.root, `${label} MuiButton root`); - expect(root.borderRadius, `${label} MuiButton radius`).toBe(pill); + // DIRECTION.md line 17: controls take the 4px control radius (--r-input), + // not the pill. The mockup's buttons render as 4px rectangles, and + // #883's after-screenshots caught this still reading the pill radius + // MuiChip alone keeps. MuiChip is unchanged. + expect(root.borderRadius, `${label} MuiButton radius`).toBe(input); expect(root, `${label} MuiButton minHeight at every width`).not.toHaveProperty("minHeight"); const phone = theme.breakpoints.down("md"); @@ -258,6 +262,9 @@ describe("farm theme policy (#823 G2)", () => { expect(icon.height, `${label} tab icon height`).toBe(24); const selected = slot(root["&.Mui-selected"], `${label} tab selected rule`); expect(selected.boxShadow, `${label} tab selected rule value`).toMatch(/^inset 0 2px 0 0 /); + // The selected label and icon take the same accent as the rule above them: + // MUI's default is palette.primary (the brand), which vanishes on the dark bar. + expect(selected.boxShadow, `${label} tab selected colour matches its rule`).toBe(`inset 0 2px 0 0 ${selected.color}`); const labelStyle = slot(theme.components?.MuiBottomNavigationAction?.styleOverrides?.label, `${label} MuiBottomNavigationAction label`); diff --git a/web/src/theme/farmTokens.ts b/web/src/theme/farmTokens.ts index b634ba8cf..c28ed5293 100644 --- a/web/src/theme/farmTokens.ts +++ b/web/src/theme/farmTokens.ts @@ -29,7 +29,7 @@ export const THEME_TOKENS = [ "--ink", "--muted", "--canvas", "--surface", "--surface-2", "--hairline", "--error", "--success", "--warn", "--danger", "--on-danger", "--tint-ok", "--tint-warn", "--tint-danger", "--tint-accent", "--tint-muted", - "--stat-accent", + "--stat-accent", "--lavender", "--link", "--focus", "--font", "--r-input", "--r-panel", "--r-card", "--r-pill", "--shadow-bar", "--shadow-dialog", @@ -58,7 +58,7 @@ const JSDOM_FALLBACK = { "--danger": "#cc4117", "--on-danger": "#ffffff", "--tint-ok": "#e2f2ec", "--tint-warn": "#f7ecd9", "--tint-danger": "#f9e5df", "--tint-accent": "#f3e9f5", "--tint-muted": "#eeeaf0", - "--stat-accent": "#4a154b", + "--stat-accent": "#4a154b", "--lavender": "#f9f0ff", "--link": "#1264a3", "--focus": "#4a154b", "--font": "system-ui, sans-serif", "--r-input": "4px", "--r-panel": "8px", "--r-card": "12px", "--r-pill": "999px",