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
8 changes: 5 additions & 3 deletions docs/designs/822-mui-revamp.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,9 @@ One breakpoint. MUI's default `md` is 900px, the same number as the app's only w

**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.
**Amendment (#830):** row 210's 390 column says "the footer stacks its buttons (D3.4)" — that predates §7's own record of the exception. #823 already carried the correction (its own paragraph below says so): the confirmed mockup (`docs/designs/864-visual-language/daily-entry.html`, `grid-template-columns: 1fr 1fr`) keeps Save draft and Submit day side by side at 390, and #823 wrote `.entry-foot .actions { flex-direction: row }` specifically as D3.4's one exemption. #830 keeps that arrangement — its sticky footer (a MUI `Paper elevation={4}`, replacing `.entry-foot`) lays the two saves out `display: grid; grid-template-columns: 1fr 1fr` at every width, unconditionally, because #823's row/column split was itself conditional on the 900px breakpoint and #830's footer never stacks at all. This is not a new decision, only this row catching up to one #823 and #740's own scope line already recorded.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

**D3.4 The phone action-button rule (#740).** Below 900px an action row (`.actions`, `DialogActions`) **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 Daily Entry footer is exempt, not a third member of this stacking set** — see the #830 amendment above: it keeps Save draft and Submit day side by side at every width, `.entry-foot .actions { flex-direction: row }` under #823 and #830's own unconditional grid after.

**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 @@ -305,7 +307,7 @@ Order is by dependency, then by what each slice teaches the next. **Deletions ar
| 5 | **#826** Autocomplete | D2 pair 1; delete L3367-3676; re-measure script time per the 674 record's method | the Flock picker open with results, with "Load more", and in the unavailable state, both widths; the open state in `tl` |
| 6 | **#828** small controls | pairs 3, 4, 8, 18; delete L1030-1093 (or less, per the pair 3 choice), `.update-banner` L3279-3322 and the busy-button block L3323-3366 | a stepper, a status pill with its tooltip open, a busy button, the update banner |
| 7 | **#829** Dashboard + shell | D3.3 row 1; D5; pairs 12, 13, 14, 19; delete the sidebar and bottom-nav families L1315-1558 (less the two label rules), `.panel`/`.dash-grid`/`.panel-wide` (L1705-1708, L1733-1742) and the capture tiles and recent-sales list (L1744-1802, L2178-2263); **keep** `.content` (§2.2) and the shared `.card, .panel, .order-panel` rule at L1697-1703, whose `.card` and `.order-panel` consumers convert in #831 to #833; retire `styles.test.ts` L251-374 | the whole app frame: sidebar and Dashboard at 1280; bottom bar, house cards and More sheet at 390 |
| 8 | **#830** Daily entry | D3.3 row 2; delete L800-1029, L1094-1151 | the three steps at 1280 side by side; at 390 each step full-width and the stacked footer |
| 8 | **#830** Daily entry | D3.3 row 2 (amended above: the footer stays side by side, never stacked); §2.2's `entry-context`/`entry-foot*` (a #830-only scope, re-derived by call-site count rather than the L800-1029/L1094-1151 range this row once named — `.entry-step`, `.entry-cols`, `.entry-pane`, `.entry-row`, `.entry-chip*`, `.entry-take`, `.step-n` and the numfield block all stay, because HistoryPage's adjust dialog mirrors this layout and still renders them) | the two panes at 1280 side by side; at 390 each pane full-width and the footer's two saves side by side |
| 9 | **#831** ledgers (+ Feed, Water, Reports) | D3.3 row 3; pairs 7, 9, 10, 11, 15; delete `.order-panel` (L1728-1731, last consumer Flocks moves to #832 if Flocks converts later) and `tr.discounted .badge-warn` (L1225-1253, Sales only); **keep** `.toolbar` L1168-1193 (seventh consumer is `AuditPage.tsx:432`, #833, and deleting early reopens #653), `table.data` L1623-1695 and `tr.inactive td` L1686 (the five CRUD lists in #832 and Audit in #833 still render them), `.page-head` L1571, and `.muted`/`.error`/`.success`/`.warn` (L1195-1224; `.muted` alone has 103 sites in 24 files including `ErrorBoundary`, `EmptyState` and `AppLayout`, and maps to `Typography color="text.secondary"` screen by screen under pair 10); one PR per screen when the diff is large (Sales alone has 4,110 test lines) | each screen's table and filter bar at both widths; Sales with a drill-down open |
| 10 | **#832** CRUD lists | D3.3 row 4; Flocks' `.order-panel` | each list and one open form dialog at both widths; Users' RBAC behaviour is fixed (3,872 test lines) |
| 11 | **#833** tail (+ Account, SetPassword) | D3.3 row 5; pairs 16, 20; delete the login and help blocks L2264-2796 (including `.glossary-entry:target`), the farm-settings block L3090-3278 (`.update-banner` L3279-3322 went with #828), the L316-534 remainder, and as last consumer: `.toolbar` L1168-1193, `table.data` L1623-1695, `.page-head` and `.content` L1559-1622, the badge rules L1275-1296, the semantic-text rules L1195-1224, and the shared `.card` rule; retire the last CSS guards | Login in both themes, Help, Settings, Audit at both widths |
Expand Down Expand Up @@ -364,7 +366,7 @@ Product calls this doc makes that the owner reviews before or at the slice that
| More sheet as `SwipeableDrawer` | D2 pair 13, #829 | keep it on MUI `Dialog` after #827 |
| `NumberField` on `TextField` + `IconButton`, or Base UI `NumberField` | D2 pair 3, #828 | the other option; measured cost decides |
| Form dialogs `fullScreen` below 900px | D2 pair 2, D3.3, #827/#832 | a bottom-sheet variant on MUI `Dialog` |
| Daily-entry footer buttons stack full-width, reversing F134's side-by-side pair | D3.4, #823 | keep the footer side by side and stack only `.actions` and dialog foots |
| Daily-entry footer buttons stay side by side at every width, exempt from D3.4's stacking rule | D3.4, #823, #830 | stack the footer full-width with every other action row, reversing F134's side-by-side pair |
| Links take `--brand` / `--stat-accent` (D7.1's original proposal, superseded) | D7.1, #834 | what #834 actually shipped: `--ink`, underlined in a new `--link-rule` (28% ink), per the owner's DIRECTION.md decision (2026-09-16) |
| Ceiling 1,800 KiB | D9, #825 | 1,600 KiB and the `opsz` fallback |

Expand Down
125 changes: 110 additions & 15 deletions tools/simulation/ui/specs/phone.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,13 @@ const PHONE_ACTION_ROWS: ReadonlyArray<{
layout: "side by side",
open: async (page) => {
await page.goto("/daily-entry");
const foot = page.locator(".entry-foot");
// #830 — the bar converted to a MUI `Paper component="footer"`, the
// only `<footer>` in the app; `.entry-actions` is a bare hook class
// with no styles.css rule (sx owns the visuals), kept so this walk
// measures the button row and not the footer's own outer padding.
const foot = page.locator("footer");
await expect(foot).toBeVisible();
return foot.locator(".actions");
return foot.locator(".entry-actions");
},
},
{
Expand Down Expand Up @@ -227,23 +231,27 @@ test.describe("Phone shell", { tag: "@phone" }, () => {
test("the daily-entry action bar stays clear of the tab bar", async ({ page, phone }) => {
await page.goto("/daily-entry");

const foot = page.locator(".entry-foot");
// #830 — the bar is now a MUI `Paper component="footer"`, the only
// `<footer>` in the app; measuring it directly is the same "outer sticky
// element, not the inner button row" distinction the old `.entry-foot`
// (not `.entry-foot .actions`) comment made.
const foot = page.locator("footer");
await expect(foot).toBeVisible();

// `.entry-foot` is the sticky element, NOT `.entry-foot .actions` — that
// inner row is deliberately `position: static` (web/src/styles.css), so
// measuring it would measure something the CSS never parks anywhere.
const footBox = await rectOf(foot, "the daily-entry action bar");
const barBox = await rectOf(phone.tabbar, "the tab bar");

// MEASURED MARGIN, and it is 2.2px: the action bar's bottom edge sits at
// 786.40625 and the tab bar's top edge at 788.609375. So this assertion is
// tight by construction rather than by choice — there is no slack to pick.
// That is the whole point of the rule it guards: `.entry-foot` parks at
// `bottom: var(--tabbar-h)` and `.content` reserves exactly the same token
// as bottom padding, so the two are designed to meet, not to overlap. Any
// change that drops either half puts the Submit button under the tab bar,
// where a thumb hits Sections instead.
// MEASURED MARGIN before #830, and it was 2.2px: the action bar's bottom
// edge sat at 786.40625 and the tab bar's top edge at 788.609375 — tight
// by construction, because `.entry-foot` parked at `bottom:
// var(--tabbar-h)` and `.content` reserved exactly the same token as
// bottom padding, so the two were designed to meet, not to overlap. #830
// drops the matching negative-margin cancel the old rule carried (its
// `Paper` sits in normal flow with #830's own spacing instead), so this
// is re-measured against the CURRENT stack rather than assumed unchanged
// — see this test's own failure message if the margin re-opens or closes
// to zero. Any change that drops either half puts the Submit button
// under the tab bar, where a thumb hits Sections instead.
expect(
footBox.y + footBox.height,
"the daily-entry action bar overlaps the tab bar — its Submit and Save buttons are under it",
Expand Down Expand Up @@ -330,6 +338,93 @@ test.describe("Phone shell", { tag: "@phone" }, () => {
}
});

// #830 (owner's screenshot review of #888), fix 1. The ratio check above
// (no control taller than it is wide) already passed on this row before
// this fix — a wrapped label inside a fixed-height MUI button still isn't
// TALLER than it is WIDE at this row's width, so it could not have caught
// the #740 shape the owner actually saw: "Save & submit (creates egg lots)"
// wrapped to three lines inside the pill. This asserts the more direct
// thing — the label fits on one line — by comparing the rendered height
// against the font's own line-height, which a wrap doubles and a fixed
// min-height with padding does not.
test("the daily-entry footer buttons render their label on one line", async ({ page }) => {
await page.goto("/daily-entry");
const foot = page.locator("footer");
await expect(foot).toBeVisible();
const buttons = foot.locator(".entry-actions button");
await expect(buttons, "the daily-entry footer rendered no buttons to measure").toHaveCount(2);

const measured = await buttons.evaluateAll((els) => els.map((el) => {
const style = getComputedStyle(el);
const parsedLineHeight = parseFloat(style.lineHeight);
// `line-height: normal` computes as the string "normal", not a px
// value — fall back to the CSS-typical 1.2x font-size multiplier.
const lineHeight = Number.isNaN(parsedLineHeight)
? parseFloat(style.fontSize) * 1.2
: parsedLineHeight;
return { name: (el.textContent ?? "").trim(), height: el.getBoundingClientRect().height, lineHeight };
}));

for (const b of measured) {
// At least 44px (WCAG 2.2 AAA 2.5.5), matching MIN_TARGET_PX above.
expect.soft(
b.height,
`"${b.name}" in the daily-entry footer is ${b.height.toFixed(1)}px tall — under the 44px touch-target floor`,
).toBeGreaterThanOrEqual(44);
// At most one line taller than the font's own line-height: a single
// line plus the control's padding fits well under this; a wrapped
// label (two text lines) does not.
expect.soft(
b.height,
`"${b.name}" in the daily-entry footer is ${b.height.toFixed(1)}px tall against a `
+ `${b.lineHeight.toFixed(1)}px line-height — its label wrapped onto a second line`,
).toBeLessThanOrEqual(b.lineHeight * 2);
}
});

// #830 (owner's screenshot review of #888), fix 2. Before this fix each row
// was a flex `justify-content: space-between` pair, so a label wide enough
// to overflow squeezed the stepper beside it by a different amount per
// row — the owner saw this as each row's minus button sitting at a
// different x. `.numfield > button:first-child` / `:last-child` are the
// minus/plus buttons structurally (NumberField.tsx), not by their
// (translated) aria-label, per this suite's own no-hardcoded-English rule.
// At 390 both panes stack into one column (DailyEntryPage.tsx's `gridTemplateColumns`
// switches from `repeat(2, ...)` to `1fr` below `md`), so every row in the
// walk shares one container width and a real fix lines every button in
// BOTH panes up to a single x, not just within one pane.
test("the daily-entry stepper rows line their minus and plus buttons up in one column", async ({ page }) => {
await page.goto("/daily-entry");
const minusButtons = page.locator(".numfield > button:first-child");
const plusButtons = page.locator(".numfield > button:last-child");
// `.count()` does not auto-wait like an assertion does — it reads
// whatever is in the DOM the instant it runs, and the flocks/grades
// fetch that gates this form (DailyEntryPage's `loading` state) has not
// always settled by then. Wait for the first row before counting.
await expect(minusButtons.first(), "the daily-entry screen rendered no stepper rows to measure")
.toBeVisible();
const rowCount = await minusButtons.count();
expect(rowCount, "the daily-entry screen rendered only one stepper row to measure").toBeGreaterThan(1);
expect(await plusButtons.count(), `${rowCount} minus buttons but a different number of plus buttons: a row is missing one`)
.toBe(rowCount);

const roundedXs = async (locator: Locator) =>
new Set((await locator.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().x)))
.map((x) => Math.round(x)));

const minusXs = await roundedXs(minusButtons);
const plusXs = await roundedXs(plusButtons);

expect(
minusXs.size,
`minus buttons sit at ${[...minusXs].join(", ")}px — not one shared x across the ${rowCount} rows`,
).toBe(1);
expect(
plusXs.size,
`plus buttons sit at ${[...plusXs].join(", ")}px — not one shared x across the ${rowCount} rows`,
).toBe(1);
});

test("no walked screen overflows the viewport horizontally", async ({ page }) => {
const viewport = page.viewportSize();
if (viewport === null) throw new Error("this project runs with a fixed viewport; none was set.");
Expand Down Expand Up @@ -381,7 +476,7 @@ test.describe("Phone shell", { tag: "@phone" }, () => {
// app — a money string in a `max-content` track beside a name.
{ path: "/", content: "ul.dash-sales-list", what: "the recent-sales list" },
{ path: "/sales", content: "table.data", what: "the orders table" },
{ path: "/daily-entry", content: ".entry-foot", what: "the entry form's sticky foot" },
{ path: "/daily-entry", content: "footer", what: "the entry form's sticky foot" },
{ path: "/customers", content: "table.data", what: "the customer book" },
{ path: "/flocks", content: "table.data", what: "the flock table" },
{ path: "/stock", content: "table.data", what: "the stock table" },
Expand Down
Loading
Loading