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.
48 KiB
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 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-*onCardHeader,pt-*onCardContent. 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 togap-2 py-3 md:gap-2 md:py-4. Never spell it as a class list: the hand-writtengap-2 py-3 md:py-4kept the basemd:gap-6and opened 24 px on desktop (v1.43.8;card-density.test.tsxrefuses the class list on a Card).- No
md:py-5half-steps.
- Radius: cards are
rounded-xl(tracks--radius).rounded-md= controls,rounded-lg= inner tiles,rounded-full= pills.rounded-2xlonly as a deliberately marked identity surface (Coach hero/input). - Hand-rolled
bg-card … border … p-Nshells are legacy. New surfaces compose<Card>(orSettingsCardin Settings). If a hand-rolled shell must exist, its padding is exactlyp-4 md:p-6orp-3(dense tile), neverp-5, never a staticp-6, never asm:step (SettingsCard's docblock explains why the breakpoint ismd:). - 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) passesflush; 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-smtiles 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
AuthShellowns the scroll container's height and bottom padding (pb-20wrapper,pb-24in the desktop shell, + the phone shell's bottom-nav clearance on<main>). The shell itself takes the window's edges (absolute inset-0), neverh-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-fullchains, 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 usemax-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.5and3.5are 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 inpy-5orp-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 Adminpl-7body gutter, which is gone (§13). - Page frame: exactly one authenticated container, owned by
AuthShell(MAIN_CONTENT_WRAPPER_CLASSinlayout/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-10between groups,space-y-4inside a group (notifications/integrations pattern). Insights sub-pages joinspace-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. Fulltext-muted-foregroundIS the de-emphasis tier; a further tier is expressed via size/weight, never via alpha.text-foreground/80is safe (≥6.7:1) and allowed./60is icon-only (3:1 graphics floor). - Disabled look:
opacity-50on 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 thetext-2xstoken (0.6875rem, minted inglobals.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-axistext-[10px]/text-[11px]stays tolerated as documented. - Status colours carry meaning, not decoration:
text-success/text-warning/text-info/text-destructive(+bg-*/10washes). 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-yellowhas a light override;text-dracula-greenon 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, nevercolor="#22c55e". Data series use the--chart-1..5tokens (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). Rechartsstroke/filllikewise. 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), thethemeColormeta, the QR quiet-zonebg-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 |
| 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
refusedstate 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):
- A tile-header icon-colour check (
text-muted-foreground/text-primaryon a glyph inside aCardTitleor 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 everySectionHeading. If a sub-page heading must track the card body instead, it uses the responsive card insetpx-4 md:px-6, never a flatpx-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 topx-4 md:px-6. No card inset with a staticp-5(banned) or ap-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 atsize="default", 24 px atsize="sm"). Compact tiles useTileHeader size="sm", never a bespokeh-4icon row. Tile header icons aretext-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-endby default. Never hand-roll aflex justify-end gap-2div in a Settings or Admin card. - Primary action: last child of the row (rightmost),
variantdefault,size="sm"+min-h-11. One per card. - Secondary action:
outline, before the primary in the same row. Cancel/reset are secondary, neverghostin the same row as a primary. - Destructive card-wide action: the same row,
variant="destructive". Never hand-tinted (text-destructive border-destructive/30on anoutlineis 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
statusslot 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. outlinehas exactly one meaning: secondary. A card whose only actions are deep links out (privacy, about, export) has no primary; those links stayoutline size="sm"and the row usesalign="start", because a navigation row reads with the text, not against it. That is the one documented exception, andSettingsCardActionsspells it (align="start").
13. Settings + Admin card rhythm; owned by the container
SettingsCardis gap-based, likeCard:flex flex-col gap-4 p-4 md:p-6. The header→body distance comes from the container's gap. A call site never addsmt-4to its first body child, nevermb-4to the header, neverspace-y-4to the card. A denser card overrides the gap on the card itself (className="gap-2"), exactly asCarddoes.- The body starts at the card edge. The old
pl-7gutter (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 bareSettingsCardHeader.text-baseon the group,text-lgon 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.