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: 5 additions & 1 deletion docs/designs/822-mui-revamp.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,10 +272,14 @@ So adopting it re-leads every paragraph, table cell and label that does not set

**Elevation: the #651 policy as data.** `theme.shadows` is rebuilt so that indices 4 and 6 (`AppBar`, `Snackbar` defaults) are `--shadow-bar`, indices 8, 16 and 24 (`Menu`/`Popover`, temporary `Drawer`, `Dialog` defaults) are `--shadow-dialog`, and every other index is `"none"`. Resolved strings, not `var()`, for the reason `farmTokens.ts:16-19` gives. Read from `@mui/material@9.4.0`: `Popover.js:110` `elevation = 8`, `SnackbarContent.js:88` `elevation: 6`, `Drawer.js:192` `elevation = 16`, `AppBar.js:190` `elevation: 4`, `Dialog.js:306` `elevation: 24`. **`Autocomplete` is the exception**: `Autocomplete.js` sets no elevation on its listbox paper, so it falls to `Paper.js:84`'s default of 1, which this map turns into `"none"`, and the picker popover (a #651 float) would render flat. #823 therefore adds `MuiAutocomplete: { styleOverrides: { paper: { boxShadow: theme.shadows[8] } } }` (or `defaultProps.slotProps.paper.elevation = 8`) and G2 pins it. `MuiPaper.defaultProps.elevation = 0` so a bare `Paper` is flat by default, and `MuiCard.defaultProps.variant = "outlined"` so a `Card` is a hairline box. **Not** `MuiPaper.defaultProps.variant = "outlined"`: `Paper.js:105-112` sets `--Paper-shadow` only when `variant === 'elevation'`, and none of the floats pass `variant`, so an outlined default would strip the shadow from every dialog, drawer, menu and snackbar and log MUI's "Combining elevation with variant=outlined has no effect" error on each. "Elevation encodes what floats" therefore holds by construction: a component floats only if MUI's own default elevation for it lands on one of the five mapped indices, and the allow-list is the set of those component names, pinned by G2. A component that reaches for `elevation={12}` gets nothing on screen, which is a visible bug rather than a silent second opinion about what floats. The two shadow tokens join `THEME_TOKENS`; `--shadow-card` stays retired. One more consumer reads the array by index rather than by default elevation: `Button.js:119-132` gives `variant="contained"` `shadows[2]` at rest, `[4]` on hover, `[8]` on press and `[6]` on focus, so under this map every primary button would cast the bar shadow on hover and the dialog shadow on press. #823 sets `MuiButton.defaultProps.disableElevation = true` and G2 pins it. Any other component that reads `shadows[n]` inside a variant style is found the same way (`grep -rn "shadows\[" node_modules/@mui/material --include='*.js'`) before G2 is written, and #823 records the list.

**Shape.** `shape.borderRadius` = `--r-panel` (10px), not `--r-card` as today (L39, §2.6); `MuiButton` and `MuiChip` get `--r-pill`; `MuiOutlinedInput` gets `--r-input` (6px); `MuiCard` and `MuiDialog` paper get `--r-card` (16px). The 6/10/16 scale from #651 D2 is preserved and pinned in G2. `--r-input`, `--r-panel` and `--r-pill` join `THEME_TOKENS`.
**Shape.** `shape.borderRadius` = `--r-panel` (10px), not `--r-card` as today (L39, §2.6); `MuiButton` and `MuiChip` get `--r-pill`; `MuiOutlinedInput` gets `--r-input` (6px); `MuiCard` and `MuiDialog` paper get `--r-card` (16px). The 6/10/16 scale from #651 D2 is preserved and pinned in G2. `--r-input`, `--r-panel` and `--r-pill` join `THEME_TOKENS`. (Superseded: the #864 amendment below moves the values to 4/8/12 and narrows `--r-card` to dialogs only; the numbers above are the historical #823 baseline.)

**Bare element rules.** #823 neutralises the six non-reset bare element rules (§2.3) against MUI's DOM through `components` overrides, enumerated from the list there, and deletes nothing from those families: their raw consumers survive until #833.

**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.

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

**D7.1 Links derive from the brand: yes. Owner review.** `--link: var(--brand)` in light, `--link: var(--stat-accent)` in night; `--link-hover` is `--brand-press` in light and the same `--stat-accent` at 85% opacity in night. Verified with the WCAG formula over the declared values, on `--surface` and `--surface-2`, all four palettes: each palette resolved through its own `data-brand` block (the `--surface-2` value differs per palette, so a fixed value gives wrong numbers): light `--brand` on `--surface` / `--surface-2` is 14.00/12.57 (aubergine), 11.25/10.23 (forest), 11.63/10.44 (slate), 10.90/9.63 (terracotta); night `--stat-accent` is 11.05/9.99, 10.95/9.66, 10.39/9.32, 9.67/8.91. Every pair clears 4.5:1 with margin, worst case 8.91; today's blue is 6.22 light and 7.70 night, so contrast rises everywhere. Pinned in `styles.test.ts` with the repo's own `contrast()` (`test/cssTokens.ts`), not with the numbers above. The one reason this stays an owner call is legibility rather than contrast: an aubergine link can read as a visited link, and a terracotta one as an error. The alternative, if that reads badly in the #834 captures, is a fifth hand-chosen `--link` per palette block, brand-adjacent and contrast-checked like `--stat-accent`, which also means adding it to `LIGHT_REQUIRED` (L15), `DARK_REQUIRED` (L26) and `GOLDEN` (L161) in `styles.test.ts` in the same commit, or the relation test at L238 fails. Consequence stated deliberately: `palette.info.main` (`FarmThemeProvider.tsx:34`) then equals `primary`, so an `Alert severity="info"` reads as brand-coloured, which is the intended monotheism. The sidebar's `.sidebar button.link` override (L1441-1453) already forces on-brand colour there and is unaffected.
Expand Down
8 changes: 4 additions & 4 deletions web/src/components/BottomNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function BottomNav({
<nav className="tabbar" aria-label={t("tabBarAriaLabel")}>
{tabs.map((e) => (
<NavLink key={e.to} to={e.to} end={e.end} className="tab">
<e.Icon size={ICON} aria-hidden />
<e.Icon size={ICON} strokeWidth={1.5} aria-hidden />
<span>{t(e.labelKey)}</span>
</NavLink>
))}
Expand All @@ -60,7 +60,7 @@ export function BottomNav({
aria-current={onATab ? undefined : "page"}
onClick={() => setMoreOpen(true)}
>
<Menu size={ICON} aria-hidden />
<Menu size={ICON} strokeWidth={1.5} aria-hidden />
<span>{t("moreButton")}</span>
</button>
</nav>
Expand All @@ -76,7 +76,7 @@ export function BottomNav({
{g.entries.map((e) => (
<NavLink key={e.to} to={e.to} end={e.end}
onClick={() => setMoreOpen(false)}>
<e.Icon size={ICON} aria-hidden />
<e.Icon size={ICON} strokeWidth={1.5} aria-hidden />
<span>{t(e.labelKey)}</span>
</NavLink>
))}
Expand All @@ -86,7 +86,7 @@ export function BottomNav({
<div className="more-foot">
<ThemeToggle iconSize={ICON} />
<button className="link" onClick={() => { setMoreOpen(false); onLogout(); }}>
<LogOut size={ICON} aria-hidden /><span>{t("signOut")}</span>
<LogOut size={ICON} strokeWidth={1.5} aria-hidden /><span>{t("signOut")}</span>
</button>
</div>
</Dialog>
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/FarmBrand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function FarmBrand() {
return (
<span className="brand">
{url === null ? (
<Egg size={20} aria-hidden className="brand-mark" />
<Egg size={20} strokeWidth={1.5} aria-hidden className="brand-mark" />
) : (
// Empty alt on purpose: the farm name sits right beside it, so the
// image is decoration and a screen reader should not read the farm
Expand Down
4 changes: 2 additions & 2 deletions web/src/routes/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function AppLayout() {
<p className="nav-group-label">{t(g.labelKey)}</p>
{g.entries.map((e) => (
<NavLink key={e.to} to={e.to} end={e.end}>
<e.Icon size={ICON} aria-hidden /><span>{t(e.labelKey)}</span>
<e.Icon size={ICON} strokeWidth={1.5} aria-hidden /><span>{t(e.labelKey)}</span>
</NavLink>
))}
</div>
Expand All @@ -86,7 +86,7 @@ export function AppLayout() {
<div className="sidebar-foot">
<ThemeToggle iconSize={ICON} />
<button className="link" onClick={onLogout}>
<LogOut size={ICON} aria-hidden /><span>{t("signOut")}</span>
<LogOut size={ICON} strokeWidth={1.5} aria-hidden /><span>{t("signOut")}</span>
</button>
{/* #458 — set at build time (VITE_APP_VERSION, release-please-owned
via web/.env.production); absent in dev builds, so this line
Expand Down
37 changes: 22 additions & 15 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@
radial-gradient(50% 50% at 18% 90%, #f7e4ff 0%, transparent 60%),
var(--lavender);

--r-input: 6px;
--r-panel: 10px;
--r-card: 16px;
--r-input: 4px;
--r-panel: 8px;
--r-card: 12px;
--r-pill: 999px;
--sidebar-w: 244px;
/* Height reserved for the mobile tab bar. Zero on desktop, where the bar is
Expand All @@ -100,8 +100,13 @@

--font: "Inter Variable", Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--scrim: rgba(29, 21, 33, 0.42);
--shadow-dialog: 0 24px 64px rgba(29, 21, 33, 0.24);
--shadow-bar: 0 -8px 24px rgba(29, 21, 33, 0.08);
/* #864 — three-layer diffused shadows, borrowed from the high-end-visual-
design skill's proposal and confirmed on the issue (2026-09-16). Replaces
the single 24px/64px blob with a hairline, a mid spread and a soft far
glow, visible on the phone tab bar and the dialog/drawer/menu floats
`elevationScale()` in FarmThemeProvider.tsx maps onto these same tokens. */
--shadow-dialog: 0 1px 2px rgba(29, 21, 33, 0.06), 0 12px 32px rgba(29, 21, 33, 0.10), 0 40px 96px rgba(29, 21, 33, 0.12);
--shadow-bar: 0 -1px 0 rgba(29, 21, 33, 0.05), 0 -16px 40px rgba(29, 21, 33, 0.07);

font-family: var(--font);
color: var(--ink);
Expand Down Expand Up @@ -235,8 +240,10 @@
radial-gradient(55% 55% at 82% 88%, rgba(45, 90, 70, 0.35) 0%, transparent 62%),
var(--canvas);
--scrim: rgba(0, 0, 0, 0.62);
--shadow-dialog: 0 24px 64px rgba(0, 0, 0, 0.6);
--shadow-bar: 0 -8px 24px rgba(0, 0, 0, 0.45);
/* #864 — the dark twin of the light block above; same three layers, tuned
for a dark surface (opaque black layers rather than tinted ink). */
--shadow-dialog: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 32px rgba(0, 0, 0, 0.4), 0 40px 96px rgba(0, 0, 0, 0.45);
--shadow-bar: 0 -1px 0 rgba(0, 0, 0, 0.35), 0 -16px 40px rgba(0, 0, 0, 0.3);
color-scheme: dark;
}

Expand Down Expand Up @@ -881,7 +888,7 @@ input.cell {
.entry-pane {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--r-card);
border-radius: var(--r-panel);
padding: 1.35rem 1.5rem;
}

Expand Down Expand Up @@ -1721,7 +1728,7 @@ label.check {
.order-panel {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--r-card);
border-radius: var(--r-panel);
}

.panel {
Expand Down Expand Up @@ -1779,7 +1786,7 @@ label.check {
text-decoration: none;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--r-card);
border-radius: var(--r-panel);
padding: 0.9rem 1rem;
}

Expand Down Expand Up @@ -2461,7 +2468,7 @@ label.check {
so the page opens on the thing a reader came to do. */
.help-hero {
background: var(--surface-2);
border-radius: var(--r-card);
border-radius: var(--r-panel);
padding: 1.75rem 2rem 1.25rem;
margin: 0 0 2rem;
}
Expand Down Expand Up @@ -3158,7 +3165,7 @@ dl.glossary {
object-fit: contain;
background: #fff;
border: 1px solid var(--hairline);
border-radius: var(--r-card);
border-radius: var(--r-panel);
padding: 0.5rem;
}

Expand All @@ -3171,7 +3178,7 @@ dl.glossary {
object-fit: contain;
background: #fff;
border: 1px solid var(--hairline);
border-radius: var(--r-card);
border-radius: var(--r-panel);
padding: 0.5rem;
}

Expand Down Expand Up @@ -3230,7 +3237,7 @@ dl.glossary {
.farm-warning {
margin: 0 0 1rem;
padding: 0.6rem 0.85rem;
border-radius: var(--r-card);
border-radius: var(--r-panel);
background: var(--tint-warn);
font-size: 0.9rem;
}
Expand Down Expand Up @@ -3279,7 +3286,7 @@ input.locked {
currently applied, so the values are literal here by necessity. */
.palette-picker {
border: 1px solid var(--hairline);
border-radius: var(--r-card);
border-radius: var(--r-panel);
padding: 1rem;
margin: 0;
}
Expand Down
66 changes: 63 additions & 3 deletions web/src/styles.elevation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,38 @@ function declarationsFor(selector: string): Map<string, string> {
return decls;
}

// The nearest enclosing `@media` rule's `params`, or `undefined` at the top
// level. Same shape as `insideKeyframes` above: walk the parent chain rather
// than assume one nesting depth.
function enclosingMediaParams(node: Node | undefined): string | undefined {
let current: Node | undefined = node;
while (current !== undefined) {
if (current.type === "atrule" && (current as AtRule).name === "media") {
return (current as AtRule).params;
}
current = current.parent as Node | undefined;
}
return undefined;
}

// `declarationsFor` MERGES every matching rule into one map in document
// order, so a selector declared once at the top level and again inside a
// LATER `@media` block silently loses the top-level value to the media one —
// found by a Codex review of #882 (2026-09-16) against the ".dialog" case
// below: a mutation of the unconditional `.dialog` radius (styles.css:634)
// passed the old assertion because it read back the phone media query's
// value instead. This reads only rules whose nearest `@media` matches
// `mediaParams` exactly (`undefined` for "not inside any @media").
function declarationsForAt(selector: string, mediaParams: string | undefined): Map<string, string> {
const decls = new Map<string, string>();
root.walkRules((rule: Rule) => {
if (!rule.selectors.map(clean).flatMap(unwrap).includes(selector)) return;
if (enclosingMediaParams(rule) !== mediaParams) return;
rule.walkDecls((d) => { decls.set(d.prop, d.value); });
});
return decls;
}

// Everything allowed to cast a shadow, and why. Six floats plus one ring.
const SHADOW_ALLOWED = [
".auth .card", // the sign-in card, floating on the auth gradient
Expand Down Expand Up @@ -176,9 +208,11 @@ describe("#651 radius: a three-step scale, declared as tokens", () => {
declarationsFor(":root").get(name);

it("declares three distinct steps in increasing order", () => {
expect(tokenValue("--r-input")).toBe("6px");
expect(tokenValue("--r-panel")).toBe("10px");
expect(tokenValue("--r-card")).toBe("16px");
// #864 (owner decision, 2026-09-16): controls 4px, cards/panels 8px,
// dialogs 12px. D6's "the scale does not move" is superseded.
expect(tokenValue("--r-input")).toBe("4px");
expect(tokenValue("--r-panel")).toBe("8px");
expect(tokenValue("--r-card")).toBe("12px");
});

// Every surface this slice owns, INCLUDING two --r-input consumers. Without
Expand All @@ -197,4 +231,30 @@ describe("#651 radius: a three-step scale, declared as tokens", () => {
const radius = declarationsFor(selector).get("border-radius");
expect(radius).toMatch(/^var\(--r-[a-z]+\)$/);
});

// #864 narrows the meaning of --r-card to dialogs and sheets only: every
// card-like surface reads --r-panel instead. A generic "some r-* token"
// 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",
".help-hero", ".logo-preview", ".banner-preview", ".farm-warning",
".palette-picker",
])("%s reads --r-panel, not the dialog radius", (selector) => {
expect(declarationsFor(selector).get("border-radius")).toBe("var(--r-panel)");
});

// The dialog family is the one place --r-card still belongs: a modal and
// its phone-sheet variant. Checked as two SEPARATE declarations, not one
// merged lookup — declarationsFor(".dialog") would report only the phone
// media query's value here, because it is declared later in the file and
// overwrites the unconditional one in the merged map.
it("the unconditional dialog radius keeps --r-card", () => {
expect(declarationsForAt(".dialog", undefined).get("border-radius")).toBe("var(--r-card)");
});

it("the phone dialog-sheet radius keeps --r-card", () => {
expect(declarationsForAt(".dialog", "(max-width: 900px)").get("border-radius"))
.toBe("var(--r-card) var(--r-card) 0 0");
});
});
5 changes: 3 additions & 2 deletions web/src/styles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,9 @@ describe("dashboard surfaces (#654, INV-8)", () => {
expect(bodyOf(".day > i")).toMatch(/min-height:\s*[3-9]px/);
});

it("keeps the tile on the card radius and the production bar on the accent token", () => {
expect(bodyOf(".capture-tile")).toMatch(/border-radius:\s*var\(--r-card\)/);
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):
Expand Down
Loading
Loading