Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/designs/822-mui-revamp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
**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 `<button>`s and until #828 every action is `BusyButton`'s raw `<button>` (`BusyButton.tsx:26`), so `MuiButton`/`MuiDialogActions` overrides reach nothing yet; #823 writes them for #827/#828 to inherit, and closes #740 through `styles.css`: `.actions` (L525-529, phone L2985-2990) and `.dialog .dialog-foot` (L757) get `flex-direction: column` inside the 900px query, and the two `flex: 1` rules (L3072-3075, L3077-3080) go. Sticky when stacked: **no** for `.actions` (L2985-2988's `position: sticky` is removed, because three stacked buttons above the tab bar are about 170px of chrome on an 844px viewport) and **yes** for `.entry-foot`, which stays the field-first capture bar. #740 closes with #823. Three things make that a real close: `phone.spec.ts:198` already asserts "no action control is taller than it is wide" over `/daily-entry` and its comment at L237-247 says to extend the walk to `/sales` when #740 lands and delete the comment, so #823 does exactly that; the mutant `phone-action-label-wrapped` (`src/mutants.ts:910`) is **re-targeted** in the same PR, because a full-width button cannot become taller than wide however long its label, so the current mutant would survive and `mutation-check.sh` would report it: the spec gains "each `.actions button` at 390 spans at least 90% of its container" and the mutant restores `flex-direction: row`, with the new healthy and mutated boxes recorded in `phone.spec.ts` where its measurements live, and the `footBox.y + footBox.height <= barBox.y` assertion re-run after stacking (its recorded margin today is 2.2px). Stacking also reverses a recorded F134 decision: `styles.css:2993-2996` buys back 2.3rem of width so the two daily-entry footer buttons sit side by side within a thumb's reach, so that row is in §7 for the owner; and #740's own scope line asks for a pass over the app at 420px in `es` and `tl`, so #823's PR body lists the 6 `.actions` sites in 5 files (`SalesPage` has two, L1492 and L1612, and L1492 is the #740 reproduction) and the 2 dialog-foot rules it changed. #830 states "closed by #823" and does nothing else about it. The correction #823 carries: #740 is filed as an `es`/`tl` defect and is not one (§1).
Expand Down Expand Up @@ -278,7 +280,7 @@ So adopting it re-leads every paragraph, table cell and label that does not set

**Amendment (#864, 2026-09-16): typography, colour use and the shape scale now belong to #864, not here.** #823 landed the baseline this section describes: MUI's default sizes ported into named variants, no chosen scale, and the 6/10/16 radius scale from #651 D2. #864 is the visual-language slice the epic always deferred to (`docs/designs/864-visual-language/DIRECTION.md`, confirmed by the owner on the issue), and its theme overrides in `web/src/theme/FarmThemeProvider.tsx` supersede the **Typography** paragraph above: display 40/44 weight 600, title 24/28 (phone 28/32) weight 600, section 13/16 weight 600, rows 14/20 desktop / 16/24 phone with tabular numerals, caption 12/16 — not the ported `h1` 2rem/800 etc. this paragraph names. Colour use (how much brand a screen carries, the status vocabulary) is DIRECTION.md's, not a repeat of #823's palette bridge, which is unchanged.

**The shape scale moves too, by a later owner decision on #864 (2026-09-16), superseding this same PR's own D6 amendment above** (which had argued the mockup's 4/8/12 was a standalone prototype's numbers with no tie to this app's tokens, and declined to move). The owner's call: move anyway. `--r-input` is 4px (controls), `--r-panel` is 8px (cards and every card-like surface — `.card`, `.panel`, `.order-panel`, `.entry-pane`, `.capture-tile`, `.help-hero`, `.logo-preview`, `.banner-preview`, `.farm-warning`, `.palette-picker`), and `--r-card` is 12px, narrowed to dialogs and sheets only (`.dialog` and its phone-sheet variant). `MuiCard`'s `styleOverrides.root.borderRadius` now reads `--r-panel`, because a card is a panel-family surface rather than a dialog; `MuiDialog`'s paper keeps `--r-card`; `theme.shape.borderRadius` stays the `--r-panel` default. Unlike typography and colour, this reaches every hand-rolled `.card`, `.panel`, `.toolbar` and `input` app-wide by design, not only the screens #864 converts: the tokens are shared, so moving a token is a whole-app move whether or not a screen's markup changed. G2's radius rows and the `styles.elevation.test.ts`/`styles.test.ts` scale guards move to the new values and the new `--r-panel`/`--r-card` split in the same PR that makes this change.
**The shape scale moves too, by a later owner decision on #864 (2026-09-16), superseding this same PR's own D6 amendment above** (which had argued the mockup's 4/8/12 was a standalone prototype's numbers with no tie to this app's tokens, and declined to move). The owner's call: move anyway. `--r-input` is 4px (controls), `--r-panel` is 8px (cards and every card-like surface — `.card`, `.panel`, `.order-panel`, `.entry-pane`, `.capture-tile`, `.help-hero`, `.logo-preview`, `.banner-preview`, `.farm-warning`, `.palette-picker`), and `--r-card` is 12px, narrowed to dialogs and sheets only (`.dialog` and its phone-sheet variant). `MuiCard`'s `styleOverrides.root.borderRadius` now reads `--r-panel`, because a card is a panel-family surface rather than a dialog; `MuiDialog`'s paper keeps `--r-card`; `theme.shape.borderRadius` stays the `--r-panel` default. `MuiButton` also moved off the pill onto `--r-input`, by the same owner decision (#864 issue comments, 2026-09-16): the confirmed mockup's buttons are 4px rectangles, not pills, so a button is a control for radius purposes even though it is not one for elevation; `MuiChip` keeps the pill. Unlike typography and colour, this reaches every hand-rolled `.card`, `.panel`, `.toolbar` and `input` app-wide by design, not only the screens #864 converts: the tokens are shared, so moving a token is a whole-app move whether or not a screen's markup changed. G2's radius rows and the `styles.elevation.test.ts`/`styles.test.ts` scale guards move to the new values and the new `--r-panel`/`--r-card` split in the same PR that makes this change.

### D7. The three identity questions (#656), decided here so #834 to #836 inherit a decision

Expand Down
7 changes: 5 additions & 2 deletions tools/simulation/ui/specs/a11y-live-regions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,12 @@ test.describe("live regions under a modal", () => {
// THE ORDINARY PATH: the VISIBLE banner carries the text and keeps its
// `role="alert"`, which is both how it announces itself and how it stays
// in the app-wide alert vocabulary the rest of this suite reads.
const banner = page.locator("p.farm-warning");
// #829 — the banner is an MUI `Alert` now (`<div role="alert">`, D2 pair
// 17), not the plain `<p role="alert">` it was; the class-only locator
// stays tag-agnostic across that conversion.
const banner = page.locator(".farm-warning");
await expect(banner).toContainText(tEn("nav:farmLoadFailedNeverLoaded"));
const bannerAx = await ax.node("p.farm-warning");
const bannerAx = await ax.node(".farm-warning");
expect(bannerAx.exposed, "the visible banner is not exposed to assistive technology").toBe(true);
expect(bannerAx.role, "the visible banner dropped out of the alert vocabulary").toBe("alert");

Expand Down
104 changes: 96 additions & 8 deletions tools/simulation/ui/specs/owner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// #243's fixture rather than standing up an empty app.

import { expect, test } from "../src/fixtures";
import { owner } from "../src/cast";
import { owner, readmeFarmOwner } from "../src/cast";
import { tEn } from "../src/i18n";

test.describe("Owner", () => {
Expand All @@ -31,11 +31,12 @@ test.describe("Owner", () => {
await expect(page.getByText(tEn("dashboard:loadFailed"))).toHaveCount(0);
await expect(page.getByText(tEn("dashboard:panelLoadError"))).toHaveCount(0);

// Capture status: at least one tile (the seeder's flock count is
// configurable, so ">= 1", never an exact count), and the empty state hidden.
// `.capture-tile` is a class locator, not English — the tile's accessible
// name interpolates the flock's name, which this spec does not know.
await expect(page.locator(".capture-tile").first()).toBeVisible();
// Capture status: at least one Today row (the seeder's flock count is
// configurable, so ">= 1", never an exact count), and the empty state
// hidden. #829 — the row is `role="group"`, not a `.capture-tile` class
// locator; nothing else on the Dashboard renders that role, so this
// stays a stable, English-independent hook the same way the class was.
await expect(page.getByRole("group").first()).toBeVisible();
await expect(page.getByText(tEn("dashboard:noFlocksMessage"))).toBeHidden();

// Stock: the stacked bar has at least one segment (a grade with available
Expand All @@ -45,11 +46,98 @@ test.describe("Owner", () => {

// The test's name promises sales data, so it has to actually look at it.
// Without this, deleting the Sales panel outright left the spec green — it
// asserted production and stock and called that "and sales" (PR #390 review).
await expect(page.locator(".dash-list li").first()).toBeVisible();
// asserted production and stock and called that "and sales" (PR #390
// review). #829 — the list carries its own accessible name now (the
// stock ledger renders `role="list"` too, on the same page), so this
// scopes to the named one rather than a `.dash-list` class locator.
const salesList = page.getByRole("list", { name: tEn("dashboard:salesPanelTitle") });
await expect(salesList.getByRole("listitem").first()).toBeVisible();
await expect(page.getByText(tEn("dashboard:noOrdersMessage"))).toBeHidden();
});

// #883 round 4, finding B. `.content a` in styles.css (un-`:where()`'d)
// outranked MUI's own generated class regardless of Emotion's injection
// order, so the contained Record button — an `<a>` under `.content` via
// `component={Link}` — rendered its label in `--link` blue instead of the
// theme's `--on-brand` white contrastText. jsdom cannot see styles.css at
// all (Dashboard.test.tsx never renders real CSS), so this can only be
// proven against a real browser over the built stylesheet. The seeder's
// catalog flocks guarantee at least one missing house every day, so a
// "Record <flock>" link is always on screen for the Owner's own farm —
// no need for a second farm just to reach this assertion.
test("the filled Record button's label is on-brand, not link-blue (#883 finding B)", async ({ page }) => {
const recordButton = page.getByRole("link", { name: /^Record / }).first();
await expect(recordButton).toBeVisible();

const [buttonColor, onBrandColor] = await recordButton.evaluate((el) => {
const probe = document.createElement("span");
probe.style.color = "var(--on-brand)";
document.body.appendChild(probe);
const resolved = getComputedStyle(probe).color;
probe.remove();
return [getComputedStyle(el).color, resolved];
});
expect(buttonColor).toBe(onBrandColor);
});

// #883 round 5 — the owner's read of the PR's screenshots: a Draft row's
// status cell ("Draft, saved 05:26") wrapped onto a second line at 1280
// because the action column was a fixed 200px, squeezing the status track.
// Dashboard.tsx now gives the row the mockup's own column model (name
// 150px, status 1fr, action auto, count 110px) plus an explicit
// `white-space: nowrap` on the status cell — this proves it holds by
// reading the CELL'S OWN computed line-height and asserting its rendered
// height matches it, rather than pinning a pixel figure that would drift
// with the type scale.
//
// SimulationDataSeeder only backfills PAST days (DraftWindowDays covers
// yesterday and the day before, never today), so there is no seeded Draft
// row on the live TODAY panel to read. This creates its own flock and
// saves — never submits — a draft, the same re-runnable shape
// manager.spec.ts uses for its own Draft entry: a fresh, timestamp-named
// flock every run, so this never collides with another spec or a
// previous run.
test("a Draft row's status cell never wraps at 1280 (#883 round 5)", async ({ page, signIn }) => {
// The simulation farm caps Today at twelve rows and every one of its
// 101 houses is unrecorded, so a draft created here never reaches the
// list. The demo farm (readme-farm) seeds House 1 as a draft for today,
// which is the row whose "Draft, saved HH:MM" text is the wrap risk.
await page.context().clearCookies();
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); });
await signIn(readmeFarmOwner());
await page.goto("/");
const status = page.getByRole("group").getByText(/^Draft, saved/).first();
await expect(status, "the demo farm seeds one draft entry for today, and none rendered").toBeVisible();
const [box, lineHeight] = await Promise.all([
status.boundingBox(),
status.evaluate((el) => parseFloat(getComputedStyle(el).lineHeight)),
]);
if (box === null) throw new Error("the status cell has no bounding box, so it is not rendered");
expect(
box.height,
`the status cell rendered ${box.height}px tall against a ${lineHeight}px line height — it wrapped `
+ "onto more than one line",
).toBeCloseTo(lineHeight, 0);
});

test("recent sales amounts share one right edge at 1280 (#883 round 5)", async ({ page }) => {
const salesList = page.getByRole("list", { name: tEn("dashboard:salesPanelTitle") });
await expect(salesList.getByRole("listitem").first()).toBeVisible();
const amounts = salesList.locator(".num");
const count = await amounts.count();
expect(count, "the sales list rendered no numeral cells to measure alignment against")
.toBeGreaterThan(1);

const edges = await amounts.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().right));
const first = edges[0]!;
for (const [i, edge] of edges.entries()) {
expect(
edge,
`sales row ${i}'s amount right edge is ${edge}, row 0's is ${first} — the amounts do not align`,
).toBeCloseTo(first, 0);
}
});

test("reports renders the default 7-day window with the admin-only money section", async ({
page,
nav,
Expand Down
Loading
Loading