Skip to content
Open
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
34 changes: 17 additions & 17 deletions tools/simulation/ui/mutation-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -261,21 +261,22 @@ declare -A FALSE_KILLS=(
# does — this mutant still dies inside sign-in, still proves nothing about the
# nav gate, and is still counted as a false kill rather than as coverage.
#
# The three phone entries were observed the same way. Two of them carry a custom
# message; `phone-table-overflow-unclipped` declares TWO lines, one per route it
# still breaks, because the softness of that walk is itself the claim — a hard
# assertion would stop at /sales and report half the damage, so requiring both
# is what keeps `expect.soft` there honest. /daily-entry and /stock are
# deliberately absent: neither renders a wide data table, and both stayed at
# exactly 390 under the mutant. /customers and /flocks were also on this list
# until #832: the mutant's CSS targets `table.data` specifically, and #832
# moved both routes onto MUI's `TableContainer`, which the mutant's rule does
# not reach — observed directly (`CLUCKWORK_E2E_MUTANT=phone-table-overflow-unclipped`
# against a #832 build): only /sales and /history still overflow. This is a
# real narrowing of what the mutant proves, not a typo; if a later slice moves
# /sales or /history onto MUI too, this mutant stops proving anything at all
# and needs a new CSS target (MUI's `TableContainer`, not `table.data`) or
# retirement, matching #824's "retire only with a named successor" rule.
# The three phone entries were observed the same way. One of them carries a
# custom message; `phone-table-overflow-unclipped` declares ONE line now
# (narrowed from two in #832 to one in #831 — see below), because the
# softness of that walk is itself the claim — a hard assertion would stop at
# the first offender and report only part of the damage, so requiring the
# line that remains is what keeps `expect.soft` there honest. /daily-entry
# and /stock are deliberately absent: neither renders a wide data table (Stock
# moved to MUI's `Table` in #831, same shape as Customers/Flocks below), and
# both stayed at exactly 390 under the mutant. /customers and /flocks were
# also on this list until #832, and /history until #831: the mutant's CSS
# targets `table.data` specifically, and each of those slices moved its route
# onto MUI's `TableContainer`, which the mutant's rule does not reach. This is
# a real narrowing of what the mutant proves, not a typo — only /sales still
# overflows under it now, and #831's own follow-up (Sales) needs a new CSS
# target (MUI's `TableContainer`, not `table.data`) or retirement, matching
# #824's "retire only with a named successor" rule, once it lands too.
#
# The two phone action mutants split the walk's rule between them, and each
# declares only what it can actually redden. #823 stacks every action row below
Expand Down Expand Up @@ -332,8 +333,7 @@ taller than it is wide, so its pill clamps into an ellipse"
[phone-entry-foot-stacked]="in the daily-entry save bar spans"
[phone-dialog-footer-stacked]="dialog footer's row is not laid out as a row (computed flex-direction: column)
dialog footer's buttons share no common vertical band"
[phone-table-overflow-unclipped]="/sales scrolls sideways at phone width
/history scrolls sideways at phone width"
[phone-table-overflow-unclipped]="/sales scrolls sideways at phone width"
)

MUTANTS=("$@")
Expand Down
6 changes: 4 additions & 2 deletions tools/simulation/ui/specs/phone.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,8 +557,10 @@ test.describe("Phone shell", { tag: "@phone" }, () => {
// either way, `table.data` or MUI's.
{ path: "/customers", content: "role=table", what: "the customer book" },
{ path: "/flocks", content: "role=table", what: "the flock table" },
{ path: "/stock", content: "table.data", what: "the stock table" },
{ path: "/history", content: "table.data", what: "the entry history table" },
// #831 — Stock and History moved their table onto MUI's `Table` too,
// same reasoning as Customers/Flocks above.
{ path: "/stock", content: "role=table", what: "the stock table" },
{ path: "/history", content: "role=table", what: "the entry history table" },
];

for (const { path: route, content, what } of ROUTES) {
Expand Down
28 changes: 17 additions & 11 deletions tools/simulation/ui/src/mutants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1121,19 +1121,25 @@ export const MUTANTS: Record<string, Mutant> = {
+ "so an unconverted screen's table lays its full content width out into the page instead of "
+ "scrolling within itself. #832 gave `Customers`/`Flocks` (and `Products`/`Grades`/`Users`) "
+ "the same containment through a different mechanism — a `MuiTableContainer` theme override, "
+ "not this class — so this mutant's `table.data`-scoped rule no longer reaches them; see the "
+ "note on EXPECT_MSG_FOR in mutation-check.sh.",
+ "not this class — and #831 did the same for `Stock`/`History` — so this mutant's "
+ "`table.data`-scoped rule no longer reaches any of them; see the note on EXPECT_MSG_FOR in "
+ "mutation-check.sh.",
caughtBy: "phone.spec.ts — no walked screen overflows the viewport horizontally",
apply: (page) =>
// Two of the six walked routes overflow under this now — /sales and
// /history — and four do not: /daily-entry and /stock render no wide
// data table, and /customers and /flocks moved off `table.data` in
// #832 (see `breaks` above). That per-route spread is why the spec's
// walk asserts PER ROUTE and asserts SOFTLY: a hard assertion stops at
// the first and reports half the damage. The exact widths are
// deliberately not recorded here; they drift with fixture content, and
// a stale copy of them in this file is a defect this file has already
// had once.
// Only ONE of the six walked routes overflows under this now — /sales
// — narrowed from two (/sales and /history) once #831 converted
// History. /daily-entry and /stock render no wide data table (Stock
// moved off `table.data` in #831 too); /customers, /flocks and
// /history moved onto MUI's `TableContainer` (#832, #831 — see
// `breaks` above), which this mutant's rule does not reach. That
// per-route spread is why the spec's walk asserts PER ROUTE and
// asserts SOFTLY: a hard assertion stops at the first and reports
// only part of the damage. The exact widths are deliberately not
// recorded here; they drift with fixture content, and a stale copy of
// them in this file is a defect this file has already had once. Once
// /sales converts too this mutant proves nothing at all and needs a
// new CSS target (MUI's `TableContainer`) or retirement (#824's
// "retire only with a named successor" rule).
//
// Desktop-green, stated honestly rather than claimed as containment:
// the rule is inside `@media (max-width: 900px)`, so it cannot apply at
Expand Down
108 changes: 108 additions & 0 deletions web/src/components/EntryRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Box, Typography } from "@mui/material";
import type { ReactNode } from "react";
import { remainderDropProps } from "./GradingChip";

// #830/#831 — shared between Daily entry's capture form and History's adjust
// dialog: both render the identical two-step egg-counts/grading layout (the
// dialog IS that form, per History's own copy), so the alignment fix below
// has to live in one place or drift the moment either screen's rows change.
//
// #830 (owner's screenshot review of #888) — the stepper row's 48px squares
// (mockup: docs/designs/864-visual-language/daily-entry.html) are an sx
// override on NumberField's OWN classes (`.numfield-step`), never an edit to
// NumberField.tsx or its base CSS block (styles.css, #828's): those stay
// exactly as #828 left them, and this override reaches only rows rendered
// through EntryRow. Every part NumberField renders is a FIXED size at a given
// breakpoint — the two step buttons, and the input's own ch-width — so
// `.numfield`'s overall footprint is constant across every row; that
// constancy is what the grid below leans on to line the minus/plus buttons
// up without touching NumberField itself.
export const STEPPER_SX = {
"& .numfield": { width: "100%", justifyContent: "space-between" },
"& .numfield-step": {
width: { xs: 48, md: 36 }, height: { xs: 48, md: 36 },
borderRadius: "var(--r-input)",
},
"& .numfield input": {
// The row numeral size (FarmThemeProvider's `h2`/title scale, DIRECTION.md
// — 24/28 desktop, 28/32 phone), not a bespoke size: the readout is the
// biggest thing in the row and reads as one more title-weight figure
// beside the others this screen shows (the sellable value, the grading
// count), right-aligned and tabular so a column of them lines up by digit.
fontSize: { xs: "1.75rem", md: "1.5rem" },
lineHeight: { xs: "2rem", md: "1.75rem" },
fontWeight: 500, textAlign: "right",
// Wide enough for a 4-digit count (a flock's daily total can run into the
// low thousands) with room to spare — measured against "430" clipping to
// "43" at a tighter "4ch" on desktop (Playwright capture, #830).
width: { xs: "5.5ch", md: "6ch" },
},
} as const;

// #830 (owner's screenshot review of #888) — one ruled GRID row: label (+
// optional caption, e.g. "deactivated") in a flexible truncating column,
// stepper in a fixed-content column, per the mockup's `.row`. The row used to
// be a flex `justify-content: space-between` pair, which reads as aligned
// only until a label overflows: a flex item shrinks by default, so "Total
// eggs" wrapping onto two lines squeezed the stepper beside it by a different
// amount on every row — the owner's screenshot review of #888 caught this as
// each row's minus button sitting at a different x. A grid's second column
// sizes to its own max-content and does NOT shrink to make room for an
// overflowing sibling; pairing that with `minmax(0, 1fr)` + an ellipsis on
// the label (never wrap) is what makes the fix structural rather than a
// pinned width. `groupLabel` names a grade row as an `aria-label`ed group
// (mirrors Dashboard's TodayRow `role="group"` pattern) — the drop target the
// test suite locates by name instead of a class, and `armed` draws the F134
// "taking" outline the same rows carried before, now an inline sx state
// instead of a shared `.taking` class.
export function EntryRow({
htmlFor, label, caption, groupLabel, armed = false, dropProps, children,
}: {
htmlFor: string;
label: string;
caption?: string;
groupLabel?: string;
armed?: boolean;
dropProps?: ReturnType<typeof remainderDropProps>;
children: ReactNode;
}) {
return (
<Box
role={groupLabel ? "group" : undefined}
aria-label={groupLabel}
{...dropProps}
sx={{
display: "grid", gridTemplateColumns: "minmax(0, 1fr) auto",
alignItems: "center",
gap: 2, minHeight: { xs: 52, md: 44 }, py: 1,
borderBottom: "1px solid var(--rule)",
...(armed ? {
outline: "1px dashed var(--stat-accent)", outlineOffset: "4px",
borderRadius: "var(--r-input)",
} : {}),
...STEPPER_SX,
}}
>
<Box component="label" htmlFor={htmlFor}
sx={{ minWidth: 0, overflow: "hidden", cursor: "pointer" }}
>
<Typography component="span" sx={{
fontWeight: 500, display: "block",
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}
>{label}</Typography>
{caption && (
<Typography component="span" variant="caption" className="muted" sx={{
display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}
>
{caption}
</Typography>
)}
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: "flex-end" }}>
{children}
</Box>
</Box>
);
}
63 changes: 63 additions & 0 deletions web/src/components/FilterBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FilterBar, FilterDateField } from "./FilterBar";

describe("FilterBar", () => {
it("renders every child control, each reachable by its own label", () => {
render(
<FilterBar>
<FilterDateField label="From" value="2026-01-01" onChange={() => {}} />
<FilterDateField label="To" value="2026-01-31" onChange={() => {}} />
</FilterBar>,
);
expect(screen.getByLabelText("From", { exact: true })).toHaveValue("2026-01-01");
expect(screen.getByLabelText("To", { exact: true })).toHaveValue("2026-01-31");
});

it("lays the row out as a wrapping flex row, never a column, at rest", () => {
render(
<FilterBar>
<FilterDateField label="From" value="2026-01-01" onChange={() => {}} />
</FilterBar>,
);
// The Stack is the immediate child of the outlined Paper.
const paper = screen.getByLabelText("From").closest(".MuiPaper-root");
expect(paper).not.toBeNull();
expect(paper).toHaveClass("MuiPaper-outlined");
const stack = paper!.querySelector(":scope > .MuiStack-root");
expect(stack).not.toBeNull();
expect(stack).toHaveStyle({ flexWrap: "wrap" });
});

it("a date field reports the value change the caller's onChange receives", () => {
const onChange = vi.fn();
render(
<FilterBar>
<FilterDateField label="From" value="2026-01-01" onChange={onChange} />
</FilterBar>,
);
fireEvent.change(screen.getByLabelText("From", { exact: true }), { target: { value: "2026-02-01" } });
expect(onChange).toHaveBeenCalledTimes(1);
});

// CodeRabbit on #901 (FilterBar cherry-picked into Audit): `{ ...sx }` only
// spreads a plain object — a theme-callback `sx` function or an `sx` array
// has no own enumerable properties to spread, so either was silently
// dropped. `sx` accepts both shapes; a caller passing a function must still
// see it applied alongside the field's own bounded-width default.
it("still applies a caller's function-form sx alongside the bounded-width default", () => {
render(
<FilterBar>
<FilterDateField
label="From"
value="2026-01-01"
onChange={() => {}}
sx={() => ({ color: "rgb(1, 2, 3)" })}
/>
</FilterBar>,
);
const field = screen.getByLabelText("From", { exact: true }).closest(".MuiFormControl-root");
expect(field).not.toBeNull();
expect(field).toHaveStyle({ color: "rgb(1, 2, 3)" });
});
});
56 changes: 56 additions & 0 deletions web/src/components/FilterBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { ReactNode } from "react";
import { Paper, Stack, TextField } from "@mui/material";
import type { TextFieldProps } from "@mui/material";

/**
* #831/#653 — the shared filter row every ledger screen (Sales, Stock,
* Inventory, History, Expenses, Feed, Water, Reports) and Audit (#833) mount
* above their table. `variant="outlined"` is load-bearing: #651 D1 measured
* `--surface-2` against `--canvas` at 1.05:1-1.21:1 in every palette and
* mode, too close to read as an edge without the hairline border.
*/
export function FilterBar({ children }: { children: ReactNode }) {
return (
<Paper
variant="outlined"
sx={{ bgcolor: "var(--surface-2)", p: "1rem 1.25rem", mt: "0.5rem", mb: "1.5rem" }}
>
<Stack
direction="row"
spacing={2}
useFlexGap
sx={{
flexWrap: "wrap",
alignItems: "flex-end",
"& > *": { flex: { xs: "1 1 100%", md: "0 0 auto" } },
}}
>
{children}
</Stack>
</Paper>
);
}

const DATE_FIELD_MAX_WIDTH = "12rem";

/**
* A date control sized for a FilterBar. Bounded at #653's 12rem from `md` up
* (two ten-character dates do not need the row's full width); the FilterBar
* itself widens it back to one control per line below that, per D3.3.
*/
export function FilterDateField({ sx, slotProps, ...props }: TextFieldProps) {
return (
<TextField
type="date"
size="small"
{...props}
slotProps={{ ...slotProps, inputLabel: { shrink: true, ...slotProps?.inputLabel } }}
// An array, not a spread: `sx` may be a callback (a theme function) or
// an array itself, and `{ ...sx }` on either silently drops it (spreads
// no own enumerable properties). MUI merges an sx array by applying
// each entry in order, so the caller's own sx — of any shape — still
// applies after the bounded-width default.
sx={[{ maxWidth: { md: DATE_FIELD_MAX_WIDTH } }, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}
/>
);
}
Loading
Loading