Files
Marc-André Bombeck 798747f987 test(e2e): walk the app shell at every tablet size
tablet-shell.spec.ts runs on WebKit with an iPad profile (webkit-tablet,
in the WebKit job) and on Chromium with an Android tablet profile
(chromium-tablet, in the shards), across iPad mini to iPad Pro 13 in
both orientations, Safari's bars, Split View and Slide Over. It checks
that the shell fills the window and leaves the document nothing to
scroll, that every rail destination scrolls into view on top and takes a
tap, that the scroll cue follows the overflow, that main reaches its
last line clear of the bottom bar, that the page scrolls again after a
sheet and a dialog close, and that a tab strip leaves the vertical pan
to the page.

ui-standards records the shell and rail rules.
2026-10-23 18:23:49 +02:00

188 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# UI standards
The binding reference for building or touching any surface of the web app. The rules are derived from the primitives' own contracts (`src/components/ui/card.tsx`, `tile-header.tsx`, `page-header.tsx`, `settings/_card-header.tsx`, `layout/auth-shell.tsx`) and from a review of every surface against them. When a rule here conflicts with an existing file, the file is drift: fix it when you touch it, don't copy it. Where an older code comment or [`docs/ui-guidelines.md`](../ui-guidelines.md) says otherwise, this document wins. A deviation needs a written reason in the pull request.
---
## 1. Card anatomy
**The Card is gap-based, not padding-based** (`src/components/ui/card.tsx:24`): `flex flex-col gap-4 rounded-xl border py-4 md:gap-6 md:py-6`; header/content/footer carry `px-4 md:px-6`. The header→body distance comes from the **parent flex gap**, never from slot padding.
- FORBIDDEN: `pb-*` on `CardHeader`, `pt-*` on `CardContent`. Under the gap model these add on top of the gap (or are no-ops) and invert their intent. Want a tighter card? Override the gap on the Card itself: `<Card className="gap-2">`.
- Two densities, nothing else:
- `default`: Card as shipped: `py-4 md:py-6`, `gap-4 md:gap-6`.
- `compact`: `<Card density="compact">` (stat strips, assessment blocks, small tiles); resolves to `gap-2 py-3 md:gap-2 md:py-4`. Never spell it as a class list: the hand-written `gap-2 py-3 md:py-4` kept the base `md:gap-6` and opened 24 px on desktop (v1.43.8; `card-density.test.tsx` refuses the class list on a Card).
- No `md:py-5` half-steps.
- Radius: cards are `rounded-xl` (tracks `--radius`). `rounded-md` = controls, `rounded-lg` = inner tiles, `rounded-full` = pills. `rounded-2xl` only as a deliberately marked identity surface (Coach hero/input).
- Hand-rolled `bg-card … border … p-N` shells are legacy. New surfaces compose `<Card>` (or `SettingsCard` in Settings). If a hand-rolled shell must exist, its padding is exactly `p-4 md:p-6` or `p-3` (dense tile), never `p-5`, never a static `p-6`, never a `sm:` step (`SettingsCard`'s docblock explains why the breakpoint is `md:`).
- **Insights overview sections use `InsightSectionCard`** (`src/components/insights/insight-section-card.tsx`). It composes `<Card>`, so the frame is the card contract, and pins the section's inner rhythm at one step (`gap-3`); the six hand-rolled copies it replaced had drifted to three different rhythms in the same column. A section whose body is an edge-to-edge divided list (rows owning their own inset) passes `flush`; everything else takes the padded body. Do not hand-roll a section shell beside it.
- **The dashboard/insights hero band is a sanctioned identity surface.** Its translucent `bg-card/65 … backdrop-blur-sm` tiles are deliberate, not drift: the band reads as one glass surface behind the page's opening statement. It stays out of the shell sweep. Everything below the band is a plain card.
### Header patterns; exactly three, pick by surface
| Surface | Primitive | Contract |
| ---------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Page title | `PageHeader` (`ui/page-header.tsx`) | H1 `text-2xl font-bold tracking-tight`, description `text-muted-foreground text-sm`, `space-y-1.5`, **no icon beside the H1**, optional backLink + actions slot |
| Settings card | `SettingsCard` + `SettingsCardHeader` (`settings/_card-header.tsx`) | icon `text-muted-foreground h-5 w-5`, title `h2 text-lg font-semibold`, status slot top-right, description muted below |
| Insights / dashboard / module tile | `Card` + `TileHeader` (`insights/tile-header.tsx`) | icon **`text-foreground` `h-5 w-5 shrink-0`**, title `CardTitle text-base`, `flex items-center gap-2`, optional `right` slot |
**Icon colour rule (both header contracts):** header icons are `text-foreground` (tiles) or `text-muted-foreground` (settings). Never `text-primary`, `text-info`, or any accent; primary/purple stays reserved for actions and highlight states. Pass the Lucide component (`icon={Target}`), never a pre-sized node; the primitive owns size and colour.
The gaps this section used to list are closed: `TileHeader` carries a `size="sm"` variant for compact tiles and an optional `icon` for icon-less headers, and `InsightSectionCard` is the overview container mirroring `SettingsCard`. Use them; do not invent a fourth header language.
## 2. Spacing scale
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper, `pb-24` in the desktop shell, + the phone shell's bottom-nav clearance on `<main>`). The shell itself takes the window's edges (`absolute inset-0`), never `h-dvh`, and `<main>` is the one vertical scroller: the document neither scrolls nor bounces under it. Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13).
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`MAIN_CONTENT_WRAPPER_CLASS` in `layout/shell-metrics.ts`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6 shell-desktop:pb-24`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
- **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`.
- **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding.
- **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`.
- **Anchors:** `scroll-mt-28`, single token (31-to-1 in the tree already).
## 3. Text colour rules
The base tokens are AA-tuned in both themes (`--muted-foreground`: 5.0–6.4:1, documented in `globals.css`). The system is two tiers:
- **`text-foreground` = content.** Everything the user came to read: values, chart labels the reading depends on, AI narrative bodies, consent/safety copy, and **all user-entered data** (notes, journal text, free-text fields). User data is never muted.
- **`text-muted-foreground` = meta.** Timestamps, units, provenance, counts, placeholders, one-sentence descriptions under a heading. Heading-in-foreground + short-description-in-muted is the app-wide convention (CardDescription, PageHeader, SettingsCardHeader); keep it; do not "fix" it to foreground.
- **Description slots hold exactly one meta sentence, guideline ≤ 120 characters.** One sentence, one full stop, no paragraph stack. 120 characters is the guideline, not a hard cut: it is the p75 of the descriptions that already read well, and a description that needs 130 characters to stay true is fine while a two-sentence one never is. Multi-sentence explainer prose goes in the body as `<p className="text-sm">` (foreground), not into a muted description slot; if the body would not carry it either, the sentence is not needed on the surface at all and belongs in the docs. The description slot renders a single text run; it deliberately owns no vertical rhythm of its own, so a call site cannot stack paragraphs into it.
- **One statement, one string.** A section's page subtitle and its card description must not say the same thing twice on one screen. When a section owns a single card, the subtitle carries the statement and the card header drops its description. Same rule for a field label and its field description: the label names the field, the description adds what the label cannot.
- **FORBIDDEN: opacity modifiers on muted text.** `text-muted-foreground/70` ≈ 2.9–3.9:1 and `/80` ≈ 3.4–4.2:1, below AA, and they silently undo the token tuning. Full `text-muted-foreground` IS the de-emphasis tier; a further tier is expressed via size/weight, never via alpha. `text-foreground/80` is safe (≥6.7:1) and allowed. `/60` is icon-only (3:1 graphics floor).
- **Disabled look:** `opacity-50` on the container + `aria-disabled`, not colour dimming on the text node.
- **Size floor:** muted text is never smaller than `text-xs`. Below that, the one legal step is the `text-2xs` token (0.6875rem, minted in `globals.css`) and it is for chips, badges, and tabular-nums micro-labels with verified contrast, never for muted body or meta text. Arbitrary sizes (`text-[0.65rem]`, `text-[11px]`) are drift; chart-axis `text-[10px]`/`text-[11px]` stays tolerated as documented.
- **Status colours** carry meaning, not decoration: `text-success` / `text-warning` / `text-info` / `text-destructive` (+ `bg-*/10` washes). All four are AA-retuned per theme.
## 4. Colour token rules
- **Semantic tokens only** in components: `--foreground/--muted-foreground/--primary/--success/--warning/--info/--destructive/--chart-1..5/--border/--card`. The light theme remaps them wholesale; anything outside the token layer is invisible to a retune.
- **No raw `dracula-*` utilities in new code.** Only `--dracula-yellow` has a light override; `text-dracula-green` on a light card is 1.4:1. Status meaning → semantic token; brand identity (Coach gradient) → a named `--brand-*` token with per-theme values.
- **No hex in JS props.** Chart colours are passed as `var(--token)` strings, never `color="#22c55e"`. Data series use the `--chart-1..5` tokens (`color="var(--chart-2)"`); status tokens (`--success`, `--warning`, `--destructive`, `--info`) only where the colour carries meaning, such as a target band, time in range or a mood scale (v1.42). Recharts `stroke`/`fill` likewise. This is the known lint blind spot; review for it explicitly until the rule covers it.
- **No arbitrary-value colours** (`bg-[#…]`, `text-[oklch(…)]`).
- Exemptions (documented at the definition site): `global-error.tsx` (renders without the stylesheet), the `themeColor` meta, the QR quiet-zone `bg-white`.
## 5. Typography scale
| Tier | Classes | Where |
| ------------------------ | ------------------------------------------------------------------- | --------------------------------------------------------------- |
| App H1 | `text-2xl font-bold tracking-tight` | every module page, via `PageHeader`: weight is **bold**, always |
| Settings card title | `text-lg font-semibold` | via `SettingsCardHeader` |
| Tile title | `text-base` on `CardTitle` (`font-semibold` from the primitive) | via `TileHeader` |
| Chip / badge micro-label | `text-2xs` (11 px) | `TagChip`, `Badge`, tabular-nums counters: never muted prose |
| Section label | `text-xs font-medium uppercase tracking-wide text-muted-foreground` | list group headings |
| Body | `text-sm` (foreground) | default content |
| Meta | `text-xs text-muted-foreground` | timestamps, units, hints: the floor |
| Prose-page H1 | `text-3xl md:text-4xl font-bold` | about/privacy/landing tier only |
No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring step. Icon sizes come from the five-step vocabulary `size-3 / 3.5 / 4 / 5 / 6`; prefer `size-N` over `h-N w-N` (identical CSS, one spelling).
## 6. Component mapping; when to use which primitive
| Situation | Use | Never |
| --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Page title + description + actions | `PageHeader`: `headingRef` + `focusable` for a title that takes focus after navigation, `titleAccessory` for a glyph or badge beside the H1 (outside its name) | hand-rolled `text-2xl font-bold` H1 blocks (`page-header-h1-guard.test.ts` lists where another H1 is the point: heroes, sign-in, error and prose pages, first-run steps) |
| A second copy of the page title in the same shell (mobile above the nav strip, desktop inside the grid) | `PageHeader headingAs="div"`: one real `<h1>`, one `role="heading" aria-level={1}` | a hand-rolled `role="heading"` div beside a hand-rolled `<h1>` |
| Settings card | `SettingsCard` + `SettingsCardHeader` + `FieldGroup` | raw `Card` in Settings sections |
| Heading above a group of cards (Insights overview, Settings groups) | `SectionHeading` (`ui/section-heading.tsx`) | a `SettingsCardHeader` with no card under it, or a hand-rolled `h2` |
| Action row in a Settings / Admin card | `SettingsCardActions` | a hand-rolled `flex justify-end gap-2` div |
| Admin mini stat tile | `StatTile` (`admin/_stat-tile.tsx`) | a hand-rolled `bg-muted/NN rounded-lg p-3` box |
| Insights/dashboard tile | `Card` + `TileHeader` | hand-rolled icon+title rows, `bg-card rounded-xl border p-4` divs |
| Insights overview section | `InsightSectionCard` | a hand-rolled section shell beside it |
| Neutral label chip on a card header | `TagChip` | a hand-copied `bg-muted … rounded-full` literal, or a `Badge` (that means status) |
| Read-query failed (`isError` on a primary / list surface) | `QueryErrorCard` with `error={query.error}` and `onRetry={refetch}` | bare `text-destructive` paragraphs without retry; a card without `error` |
| Read-query failed inside a dense metric strip, tile or supplementary block, where the tall card would dominate the geometry | `QueryErrorRow` with `error={query.error}` and `onRetry={refetch}` | a hand-rolled row; a third read-failure primitive (`ReadFailureRow` was one and is gone) |
| Read-query failed inside a card that already has its frame and header (a tile body, a settings card section) | `QueryErrorRow inset`: keeps the card and its title, no second border | `QueryErrorCard` inside a card (card in card); replacing the card and its header with the error |
| Read-query failed on a chart body | `ChartErrorState` with `error={query.error}`: same decision as the other two | either of the above |
| A list the server capped (`meta.truncated`) | `ListCapNotice` under the list, read through `apiGetList` / `apiPostList` | a hand-written "showing the latest N" line; reading `data.truncated` |
| Mutation error inside a form/sheet | inline `role="alert"` text | `QueryErrorCard` (wrong shape) |
| Honest no-data state | `EmptyState` | reusing error copy: a failed query must never fall through to "no data yet" |
| Modal reachable on mobile | `ResponsiveSheet` (bottom sheet on phones) | raw `Dialog` (admin/desktop-only surfaces excepted) |
| A sheet from the bottom edge (phones) | `<SheetContent side="bottom">` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet |
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented-single-implementation.test.ts` holds the definition to one module) |
| A grade beside a reading (confidence, score gain, band, lab range flag) | `ScorePill` (`ui/score-pill.tsx`): tone `success` / `info` / `warning` / `destructive` / `neutral`, `numeric` for figures; `CONFIDENCE_TONE` maps both confidence scales | a hand-rolled `rounded-full px-2 py-0.5 text-xs bg-*/15` chip (`score-pill-guard.test.ts`); `TagChip` (neutral label) and `Badge` (connection/account state) stay what they are |
| "This vital on these days vs those days" board | `MetricCrosstab` (`insights/metric-crosstab.tsx`): caller formats the numbers, the board owns row, neutral signed delta, confidence chip, dismissal | a fourth copy of the crosstab list |
| A trend / stat tile in a grid (KPI strip, derived tiles, their skeletons and building states) | `TrendTile` (`ui/trend-tile.tsx`) | the hand-spelled `bg-card border-border … p-4 md:p-6` frame |
| A whole card that points to another page | `CrossLinkCard` (`insights/cross-link-card.tsx`) | a `Link` styled as a card |
| Picking documents from the vault | `DocumentPickerSheet` (`documents/document-picker-sheet.tsx`); the caller decides what a toggle means | a copy of the search + list sheet |
| A field / chart / list with nothing to show | `EmptyState variant="plain" size="compact"` (with `action` for the next step), `ChartEmptyState` on a chart body | a hand-written muted paragraph (`empty-state-ratchet.test.ts` freezes each remaining site as `path: key`, and a new one fails; the frozen ones are field-level "not recorded" lines and editor hints) |
| A number shown to a person | `useFormatters()` (`fmt.number` / `integer` / `percent`), `formatBytes(bytes, locale)` from `lib/format/bytes` | `toFixed()`, a locale-less `toLocaleString()`, `String(a / b)` (`healthlog/no-unlocalised-decimal`) |
| Numeric range entry (0–10 self-rating, any bounded scale) | `Slider` / `SliderField` (`ui/slider.tsx`): `SliderField` when the scale has anchors, which is nearly always | a hand-rolled `input type="range"`, or an 11-button radiogroup (eleven 32 px targets overflow a 390 px viewport) |
| Outbound fetch | `safeFetch` | raw `fetch(` (lint-enforced) |
| TanStack query keys | `src/lib/query-keys/` factory | bare arrays (lint-enforced) |
**What a failed read offers is decided by the primitive, never the call site.** `QueryErrorCard`, `QueryErrorRow` and `ChartErrorState` take the query's `error` (required) and branch on `readFailureKind` (`src/lib/api/read-failure.ts`). It reads `meta.errorCode` first and the status second, because every error envelope carries a code (`http.*` when the route names none): a step-up or re-proof code on a 401 is a failure with retry, not an ended session; an unknown code falls back to the status.
- **Session ended** (a session code, or a 401 without a more specific one) → one sentence and a Sign in button (back to the current page). Never a silent blank.
- **403, refused** → the surface's `refused` state if it has one, otherwise nothing. A refusal is an answer, not a failure; no retry.
- **429, throttled** → one sentence that the read budget is spent, no retry (an immediate retry would only be refused again).
- **anything else** → the surface's message with Retry.
The `queryFn` hands the `ApiError` through untouched: no `try { … } catch { throw new Error(t(…)) }` around the fetch, the surface's sentence goes in through `message`. A call site does not inspect `error.status` around a read-failure primitive, and does not pass `error={null}`. `query-error-error-prop-guard.test.ts` enforces the prop and the untouched error at every site, `read-failure-markup-guard.test.ts` refuses hand-rolled failure markup next to a read hook. Error codes become sentences through `src/lib/i18n/translate-code.ts`, never through a `t(key) === key` probe at the call site (`translate-probe-guard.test.ts`).
## 7. Enforcement
Live, all at `error`: `healthlog/queryKey-factory`, `healthlog/safe-fetch-required`, `healthlog/api-fetch-required`, `healthlog/no-raw-palette-color` (palette + colour-shaped JS props + arbitrary values), `healthlog/no-dracula-utility`, `healthlog/spacing-scale` (`pt-`/`pb-` on `CardHeader`/`CardContent`, and the banned `5` step on a `bg-card` + `border` shell), `healthlog/no-raw-card-shell` (since v1.43.8: `bg-card` + `border` + `rounded-xl` on one literal under `src/components` / `src/app` outside `ui/`; the files where a shell is the point (shell primitives, the hero, popovers, the chart frame shared by chart/skeleton/error state, table frames, sign-in and first-run screens) are the rule's `allow` list in `eslint.config.mjs`, each with its reason, no inline escape), `healthlog/no-unlocalised-decimal` (since v1.43.8; reasoned `eslint-disable-next-line` only, for SVG coordinates and number-input values).
Structural tests (since v1.43.8): `inline-action-guard`, `score-pill-guard`, `empty-state-ratchet`, `page-header-h1-guard`, `card-density`, `segmented` (one definition), `client-route-zod-guard` (documents and Coach ship no zod in their static client graph).
Planned (until it lands, this is a review-checklist item):
1. A tile-header icon-colour check (`text-muted-foreground` / `text-primary` on a glyph inside a `CardTitle` or tile-header row).
**PR checklist for any UI change:** both themes checked visually · no new `dracula-*`/hex/opacity-muted · headers via a primitive · card gap untouched by slot padding · `isError` → `QueryErrorCard` or `QueryErrorRow` by surface, with `error` · mobile modal = `ResponsiveSheet` · `pnpm format` + `pnpm lint` + `pnpm typecheck` green.
---
## 8. Insights left reading edge; two edges, one gutter
Every text run on an Insights surface starts on one of exactly two vertical lines, and card headings offset from the card line by exactly one gutter:
- **Edge A, reading edge (x = 0):** the AuthShell container inner edge (`px-4 md:px-6`). All **borderless page-level blocks** start here with **no extra horizontal padding**: the sub-page `<h1>` + explainer (`SubPageShell`), and every `SectionHeading`. If a sub-page heading must track the card body instead, it uses the responsive card inset `px-4 md:px-6`, **never** a flat `px-6` (that sat 8 px right of the card body on mobile and reopened the heading-vs-body step).
- **Edge B, card edge (x = card `px`):** every `<Card>` insets header **and** body to `px-4 md:px-6`. No card inset with a static `p-5` (banned) or a `p-4`-only/`sm:` shell; header and body share one inset so the body left edge is identical on every tile.
- **Heading gutter (Edge B + gutter):** every card heading routes through `TileHeader`. The icon's left edge sits on Edge B; the heading **text** sits at Edge B + gutter (icon box + `gap-2` = 28 px at `size="default"`, 24 px at `size="sm"`). Compact tiles use `TileHeader size="sm"`, never a bespoke `h-4` icon row. Tile header icons are `text-foreground` (never muted, never an accent) at both sizes.
Forbidden on any Insights card: a hand-rolled icon+title row, a card title centred by default, an icon box outside `TileHeader`'s h-5/h-4 vocabulary, and a horizontal inset other than `px-4 md:px-6` (or the sanctioned `p-3` dense inner tile). Verify at 390 px: every card body shares one left x; every card heading text shares a second x one gutter to the right; the `<h1>` + any `SectionHeading` sit on the container edge.
## 9. One scroll floor; sub-shells never re-declare bottom-space
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`), or the home indicator in the desktop shell, and its wrapper clears the Coach FAB (`pb-20`, `pb-24` in the desktop shell). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
A two-column sub-shell that needs a "don't jump while loading" floor derives it from **one shared source**: `SUB_SHELL_GRID_FLOOR` (`src/components/layout/shell-metrics.ts`); applied to the **grid** (not a column), so the `1fr` content row stretches to fill without over-shooting. It never re-declares the floor per-shell (`AdminShell` drifting to a stale 12rem column reserve is exactly how this recurred). Guard: `e2e/settings-overscroll.spec.ts` covers Settings **and** Admin and measures against each element's content-box bottom, so a redundant sub-shell `pb-*` fails instead of inflating the allowed budget.
## 10. Navigation; utilities live only in the user menu
Settings + Notifications are **account utilities, not feature destinations**. They live ONLY in the user/avatar menu (mobile top-bar `DropdownMenu`; desktop sidebar avatar menu, with Settings additionally in the sidebar footer). The mobile "More" overflow hub carries **feature destinations only** (Measurements, Mood, Cycle, Labs, Coach, …), never Settings or Notifications. The hub is `mobileMoreHubDestinations()` = `visibleNavDestinations` minus the primary slots (`/`, `/medications`, `/insights`); it does **not** append the utility tail. `NAV_UTILITY_DESTINATIONS` (`visibleUtilityDestinations()`) feeds the sidebar footer + user menus, not the mobile overflow. Never surface a utility in two menus reachable from the same screen.
The rail's destination list is the one part of the rail that gives up height: the logo band, the footer group and the account row stay pinned, and the list scrolls between them (`overscroll-contain`, no native scrollbar, fades at an edge with more). A mouse gets the height-stepped row density; a finger keeps the 44 px rows, and at an edge with more destinations the rail shows a scroll cue (`sidebar-nav-scroll-cue`) that a tap follows. Guard: `e2e/tablet-shell.spec.ts`.
## 11. Inline action row; the PageHeader pattern
A title/sentence with an adjacent action is one row, not a wrapping stack: `flex items-start justify-between gap-3`, the text `min-w-0 flex-1` (wraps internally), the action `shrink-0` (stays beside the text, never pushed to its own line). This is the canonical `PageHeader` / `TileHeader` (`right` slot) shape; reuse it (e.g. the dashboard-hero verdict + CTA) instead of a one-off `flex-wrap` row whose `gap-y-*` drops the action below on a full sentence. Keep the mobile tap-target floor (`min-h-11 sm:min-h-9`).
## 12. The action row; one place, one shape
A card's actions live in **one** row, and that row is the **last thing in the card**. Reference implementations: `src/components/settings/score-section.tsx`, `src/components/settings/coach-prefs-section.tsx`.
- **Use `SettingsCardActions`** (`settings/_card-actions.tsx`). It owns the row: `flex flex-wrap items-center gap-2`, `justify-end` by default. Never hand-roll a `flex justify-end gap-2` div in a Settings or Admin card.
- **Primary action:** last child of the row (rightmost), `variant` default, `size="sm"` + `min-h-11`. One per card.
- **Secondary action:** `outline`, before the primary in the same row. Cancel/reset are secondary, never `ghost` in the same row as a primary.
- **Destructive card-wide action:** the same row, `variant="destructive"`. Never hand-tinted (`text-destructive border-destructive/30` on an `outline` is drift).
- **Nothing follows the action row.** Status lines, result lists, and confirmations belong **above** it; a result set large enough to need its own frame becomes its own card or a collapsible.
- **The header `status` slot carries status, not actions**; a badge, a pill, a switch. Not a primary, not a destructive, not a filter group. Filters get a toolbar row above the content.
- **`outline` has exactly one meaning: secondary.** A card whose only actions are deep links out (privacy, about, export) has no primary; those links stay `outline size="sm"` and the row uses `align="start"`, because a navigation row reads with the text, not against it. That is the one documented exception, and `SettingsCardActions` spells it (`align="start"`).
## 13. Settings + Admin card rhythm; owned by the container
- **`SettingsCard` is gap-based**, like `Card`: `flex flex-col gap-4 p-4 md:p-6`. The header→body distance comes from the container's gap. A call site never adds `mt-4` to its first body child, never `mb-4` to the header, never `space-y-4` to the card. A denser card overrides the gap on the card itself (`className="gap-2"`), exactly as `Card` does.
- **The body starts at the card edge.** The old `pl-7` gutter (icon box + `gap-2`) is abolished: it is off the scale in §2, it broke the moment two neighbouring cards disagreed, and it silently mis-aligned action rows against their own forms. Header text is offset from the card edge by the icon gutter; body content is not. This is the deliberate difference from §8's Insights gutter, where the tiles carry no body form.
- **Group headings above a group of cards use `SectionHeading`** (`ui/section-heading.tsx`, the heading of the Insights overview, shared by every area), not a bare `SettingsCardHeader`. `text-base` on the group, `text-lg` on the card title: that step is what says which one labels which.
- **Admin uses the Settings anatomy verbatim.** No admin-only card shell, no admin-only header, no admin-only mini tile; `SettingsCard`, `SettingsCardHeader`, `SettingsCardActions`, `StatTile` (`admin/_stat-tile.tsx`) are the whole vocabulary.
- **A loading state paints the card and its header.** A section that renders a bare spinner while loading and a card afterwards moves the page under the cursor. Reference: `admin/coach-feedback-section.tsx`.
### Enforcement
The `p-5` / `py-5` / `px-5` / `space-y-5` family does not occur in the app, and `healthlog/spacing-scale` runs at `error` over `src/components/**` and `src/app/**`, so the step cannot come back unnoticed. `no-raw-card-shell` is described in §7. The header icon colour (§1) has no lint rule; check it in review.