docs: publish the UI standards in docs/contributing

The binding UI rulebook (card anatomy, spacing scale, text colour,
tokens, primitive mapping, enforcement) lived outside the repository,
so a contributor could follow a code comment to a file they could not
open. It now lives at docs/contributing/ui-standards.md, every comment
that cited it points there, and the docs index and ui-guidelines.md
say which document wins when they disagree.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 19:37:22 +02:00
1 parent 07f9615dba
commit d44af06467
46 files changed
+244 -55

No files matched your search

+5 -1
View File
@@ -56,7 +56,11 @@ notes, and machine-readable specs that ship alongside the source:
— the iOS submission gate.
- [`codex-protocol-spec.md`](./codex-protocol-spec.md) — Codex / ChatGPT
OAuth protocol reverse-engineering notes.
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style.
- [`contributing/ui-standards.md`](./contributing/ui-standards.md) — the
binding UI standards: card anatomy, spacing, text colour, tokens and
which primitive to use where. Read before touching any surface.
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where
it disagrees with the UI standards, the standards win.
If you're a self-hoster looking to spin up an instance, start at
<https://docs.healthlog.dev> or jump to the [Quick Start in the project
+184
View File
@@ -0,0 +1,184 @@
# 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 (over-scroll class, #154 lineage).** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + `<md` bottom-nav clearance on `<main>`). 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 rhythm uses them docblock-sanctioned across ~78 sites and the in-between steps earn their keep in dense card interiors — reality wins over a sweep that would only churn settled surfaces. `5` (20 px) and `py-5`, `p-5` are banned in new code, swept on touch. `7` (28 px) is banned with them — it entered as the Settings/Admin `pl-7` body gutter and is gone (§13).
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. 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; 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) |
| 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.test.tsx` 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)]`) and its wrapper clears the Coach FAB (`pb-20`). **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.
## 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-scale (§2 allows `1/1.5/2/3/4/6/8/10`), 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 Insights overview's heading, which is not an Insights component and moved out of that folder), 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 follow-up
The `p-5` / `py-5` / `px-5` / `space-y-5` family is **zero** app-wide, and `healthlog/spacing-scale` runs at `error` over `src/components/**` + `src/app/**`, so the banned step cannot come back silently. `no-raw-card-shell` is live since v1.43.8 (see §7); what remains planned is the tile-header icon-colour check.
+5 -4
View File
@@ -1,9 +1,10 @@
# HealthLog UI Guidelines
> Single source of truth for visual & interaction patterns in the HealthLog
> PWA. Consult this document before adding a new component, page, or
> setting. If a pattern below doesn't fit your case, propose an addition
> here in the same PR.
> Visual & interaction patterns in the HealthLog PWA. The binding rules
> for card anatomy, spacing, text colour, tokens and primitives live in
> [`contributing/ui-standards.md`](./contributing/ui-standards.md); where
> this document disagrees with them, the standards win. If a pattern
> below doesn't fit your case, propose an addition in the same PR.
Last updated: 2026-05-08 (v1.4 cycle)
+1 -1
View File
@@ -135,7 +135,7 @@ test.describe("settings + admin vertical over-scroll guard", () => {
expect(dims, "main-content / wrapper present").not.toBeNull();
if (!dims) return;
// One-scroll-floor (UI-STANDARDS §9), structural half. `<main>` may
// One-scroll-floor (ui-standards.md §9), structural half. `<main>` may
// only be the ONLY vertical scroll surface if it also OWNS the
// containing block of its absolutely-positioned descendants: an
// abspos box with no positioned ancestor resolves against the
@@ -124,7 +124,7 @@ async function expectAnnounced(page: Page, selector: string) {
* Copy somebody has to act on is content, not meta.
*
* Two halves, and both are needed. The class check is the design standard —
* UI-STANDARDS §3 reserves `text-muted-foreground` for meta, and consent or
* ui-standards.md §3 reserves `text-muted-foreground` for meta, and consent or
* safety text rendered as meta is text people skip. The ratio check is the
* measurable one: it walks up for an opaque background and asserts the AA
* floor for body text, so a future token change that keeps the class and
+2 -2
View File
@@ -50,7 +50,7 @@
* `eslint-disable-next-line` with the reason — visible in review — rather
* than a silent registry entry here.
*
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2 + §7.
* @see docs/contributing/ui-standards.md §1 + §2 + §7.
*/
"use strict";
@@ -181,7 +181,7 @@ const spacingScaleRule = {
slotPadding:
'"{{match}}" on <{{element}}> fights the gap-based Card contract — the header→body distance comes from the parent flex gap, so slot padding stacks on top of it (or is a no-op). Tune density on the Card itself instead: <Card className="gap-2 py-3 md:py-4">.',
cardShellOffScale:
'Off-scale "{{match}}" on a bg-card+border shell — the scale is 1/1.5/2/3/4/6/8/10, so the `5`/`7` steps and half-steps other than 1.5 are banned (UI-STANDARDS §2) and read off next to every sibling <Card> (p-4 md:p-6). Sweep the shell to an on-scale step (`p-4 md:p-6`, or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
'Off-scale "{{match}}" on a bg-card+border shell — the scale is 1/1.5/2/3/4/6/8/10, so the `5`/`7` steps and half-steps other than 1.5 are banned (ui-standards.md §2) and read off next to every sibling <Card> (p-4 md:p-6). Sweep the shell to an on-scale step (`p-4 md:p-6`, or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
},
},
create(context) {
@@ -457,7 +457,7 @@ describe("the refusal stays on screen, addressed to its own row", () => {
const alert = /<[^>]*data-slot="grant-action-error"[^>]*>/.exec(html)?.[0];
expect(alert).toBeDefined();
// A refusal somebody has to act on is content. UI-STANDARDS §3 reserves
// A refusal somebody has to act on is content. ui-standards.md §3 reserves
// muted for meta, and a sentence nobody reads is a sentence nobody acts
// on.
expect(alert).toContain("text-destructive");
+1 -1
View File
@@ -318,7 +318,7 @@ export default function AchievementsPage() {
}
// A failed load must never fall through to the zeroed summary tiles + the
// "none unlocked yet" empty grid (UI-STANDARDS §6). Surface an honest
// "none unlocked yet" empty grid (ui-standards.md §6). Surface an honest
// retry instead.
if (isError) {
return (
+1 -1
View File
@@ -135,7 +135,7 @@
/* The one sanctioned step below `text-xs` (12px). Minted so badges,
tag chips, and tabular-nums micro-labels stop hand-rolling
`text-[0.6875rem]` / `text-[0.65rem]` arbitrary values that drift by a
third of a pixel per site. UI-STANDARDS §5 pins the usage: never for
third of a pixel per site. ui-standards.md §5 pins the usage: never for
muted body or meta text (the muted floor stays `text-xs`) — only for
chips, badges, and tabular micro-labels with verified contrast. */
--text-2xs: 0.6875rem;
+1 -1
View File
@@ -98,7 +98,7 @@ export default function InsightsWorkoutDetailPage({
<Skeleton className="h-60 w-full rounded-lg" />
</div>
) : error ? (
// A failed query must never read as "no data" (UI-STANDARDS §6).
// A failed query must never read as "no data" (ui-standards.md §6).
<QueryErrorCard error={error} onRetry={() => refetch()} />
) : !data ? (
<p
@@ -29,7 +29,7 @@ import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
* v1.29 — 30-day daily bar chart for the nutrients hydration / caffeine
* cards. One bar per day (dense series — 0 for an unlogged day, summed
* across sources), an optional thin dashed EFSA reference line, no
* attainment colouring (UI-STANDARDS: value stays foreground, the bar +
* attainment colouring (ui-standards.md: value stays foreground, the bar +
* reference line ARE the context). Registered in
* `src/components/charts/chart-runtime.ts` — never import recharts
* directly at the call site.
+1 -1
View File
@@ -539,7 +539,7 @@ export function TodayHero({
</div>
)}
{/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs
{/* Meta row — the hero's quiet tier (ui-standards.md §3: `text-xs
text-muted-foreground` is the meta floor; never an accent, never an
opacity modifier). Freshness note (plan §2.4) — provisional day,
last night's sleep not yet folded in. Muted, non-blocking,
+1 -1
View File
@@ -73,7 +73,7 @@ import { useDayValueFormat } from "./use-day-value-format";
* to look from, and the entry is where it is edited.
*/
/** Section label, UI-STANDARDS §5. */
/** Section label, ui-standards.md §5. */
export const DAY_SECTION_LABEL =
"text-muted-foreground text-xs font-medium tracking-wide uppercase";
@@ -199,7 +199,7 @@ describe("<HeroStrip>", () => {
/data-slot="insights-hero-strip-greeting"[^>]*class="([^"]*)"/,
);
expect(greeting).not.toBeNull();
// App H1 weight is bold (UI-STANDARDS §5) — never font-semibold.
// App H1 weight is bold (ui-standards.md §5) — never font-semibold.
expect(greeting![1]).toContain("font-bold");
expect(greeting![1]).not.toContain("font-semibold");
// No off-scale arbitrary type sizes (§5) — round to the neighbouring step.
@@ -65,7 +65,7 @@ describe("<SleepStageStackedBar>", () => {
expect(html).toContain("Stage composition");
expect(html).toContain("Last 23 nights");
// The title routes through the canonical tile header rather than a
// hand-rolled title row (UI-STANDARDS §1 / §8).
// hand-rolled title row (ui-standards.md §1 / §8).
expect(html).toContain('data-slot="tile-header"');
});
@@ -76,7 +76,7 @@ describe("Coach unverified-figures notice", () => {
/>,
);
expect(html).toContain('data-slot="coach-unverified-notice"');
// Muted meta treatment (UI-STANDARDS §3) — never a warning colour.
// Muted meta treatment (ui-standards.md §3) — never a warning colour.
expect(html).toContain("text-muted-foreground");
});
@@ -520,7 +520,7 @@ function ChatBubbleImpl({
// #781 — the persisted cancelled-turn marker (an EMPTY assistant row the
// server writes when the client aborts mid-generation, e.g. navigating
// away). Rendered as a quiet interrupted note — muted meta text per
// UI-STANDARDS §3, never an alarm colour: nothing failed, the user left —
// ui-standards.md §3, never an alarm colour: nothing failed, the user left —
// plus a retry that resubmits the question that produced the turn (the
// regenerate closure, so the resend runs through the normal send path and
// every budget/rate gate). No provenance, feedback, copy, or token footer:
@@ -700,7 +700,7 @@ function ChatBubbleImpl({
<p className="text-warning text-xs">{safeError}</p>
)}
{/* v1.32.14 — quiet grounding caveat when the guard withheld a figure.
Muted meta (UI-STANDARDS §3), never a warning colour — it is a
Muted meta (ui-standards.md §3), never a warning colour — it is a
grounding caveat, not an error. Static single line, decorative icon,
no interactivity. The `[…]` marks in the prose above point here. */}
{showUnverifiedNotice && (
+1 -1
View File
@@ -52,7 +52,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
});
if (isError) {
// A failed read must never read as "no data" (UI-STANDARDS §6), and the
// A failed read must never read as "no data" (ui-standards.md §6), and the
// alert is content, not muted meta — it also has to be recoverable.
return (
<QueryErrorCard
+1 -1
View File
@@ -164,7 +164,7 @@ export function HeroStrip({
>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex items-center gap-2">
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
{/* Header glyph stays in the foreground tier — ui-standards.md §1
reserves primary/accent for actions, not decorative header
icons. */}
<Sparkles
@@ -24,7 +24,7 @@ import { cn } from "@/lib/utils";
*
* RSC-safe: no hooks, no browser API.
*
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1
* @see docs/contributing/ui-standards.md §1
*/
export function InsightSectionCard({
children,
@@ -71,7 +71,7 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440];
* line discloses the coverage so the shape doesn't read as more continuous
* than it is.
*
* v1.29.x — a day navigator (`TileHeader`'s `right` slot, per UI-STANDARDS
* v1.29.x — a day navigator (`TileHeader`'s `right` slot, per ui-standards.md
* §11) pages the same route backward through prior days; "next" is disabled
* at today, so the view can never step into the future. A day outside the
* dense retention window (`DENSE_INTRADAY_RETENTION_DAYS`) reads back at the
@@ -99,7 +99,7 @@ export function LabsChangesCard({
</div>
{/* The delta is CONTENT — the number the row exists to report —
so it takes the analyte's tier, not the meta tier of the
line under it (UI-STANDARDS §3). At `text-xs` muted it wore
line under it (ui-standards.md §3). At `text-xs` muted it wore
the sub-line's typography and optically attached itself to
that lower line, which is what made a row centred to the
pixel read as bottom-aligned. `self-center` states the row's
@@ -8,7 +8,7 @@
* strictly association-framed, never a verdict. Each row links through to the
* medication's Wirkung tab for the full view.
*
* Card anatomy per UI-STANDARDS §8: TileHeader + the inherited `px-4 md:px-6`
* Card anatomy per ui-standards.md §8: TileHeader + the inherited `px-4 md:px-6`
* inset (never re-declared), foreground for content, muted for meta.
*/
import Link from "next/link";
@@ -22,7 +22,7 @@ const WINDOW_DAYS = 30;
* reference-intake meta line (omitted when the profile has no sex on
* file — the catalog's own contract), a 30-day bar chart with a dashed
* reference line. No ring, no attainment colour — the bar chart with a
* reference line IS the context (UI-STANDARDS: value stays foreground,
* reference line IS the context (ui-standards.md: value stays foreground,
* never coloured by attainment). Display only: water arrives by sync
* (Apple Health etc.), there is no in-app water entry here.
*/
+1 -1
View File
@@ -489,7 +489,7 @@ export function AuthShell({
// `overflow-y-auto` never clips them: their static position sits
// wherever the flow put them and extends the DOCUMENT's
// scrollable overflow instead, painting a second vertical scroll
// surface beside `<main>` (UI-STANDARDS §9 one-scroll-floor).
// surface beside `<main>` (ui-standards.md §9 one-scroll-floor).
// The class is not hypothetical and not confined to app code. A
// Radix `<Switch>` outside a `<form>` renders a hidden
// `position:absolute` bubble input on the first pass and drops it
+1 -1
View File
@@ -274,7 +274,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
*
* Settings and Notifications are account utilities, not feature destinations:
* on desktop they live in the sidebar footer + avatar menu, on mobile in the
* top-bar user menu (UI-STANDARDS §10; the More hub carries features only).
* top-bar user menu (ui-standards.md §10; the More hub carries features only).
* They used to be a second hand-curated list on each bar — the exact drift the
* one-model contract above set out to kill, just pushed down a level. This
* list is the single source both bars consume, so the two surfaces can no
@@ -33,7 +33,7 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
* dark for the muted token at this text size, while the foreground token keeps
* the context legible at the same contrast floor as the name.
*
* The row is the inline-action shape from UI-STANDARDS §11 rather than a
* The row is the inline-action shape from ui-standards.md §11 rather than a
* centred wrapping stack: sentence `min-w-0 flex-1`, action `shrink-0`, so the
* way out stays beside the sentence instead of dropping onto its own line once
* the name is long. `<MaintainershipBanner>` is the sibling that already had an
@@ -151,7 +151,7 @@ export function MedicationOrderEditor({
// `relative` anchors the sr-only drag-hint paragraph: `sr-only` is
// absolutely positioned, and without a positioned ancestor its 1px box
// lands below the list in DOCUMENT coordinates and makes the document
// itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor).
// itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
<div className="relative space-y-4" data-slot="medication-order-editor">
{medications.length === 0 && (
<EmptyState
@@ -110,7 +110,7 @@ export function ModuleOrderEditor({ items, onChange }: ModuleOrderEditorProps) {
// `relative` anchors the sr-only drag-hint paragraph: `sr-only` is
// absolutely positioned, and without a positioned ancestor its 1px box
// lands below the list in DOCUMENT coordinates and makes the document
// itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor).
// itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
<div className="relative space-y-2" data-slot="module-order-editor">
<DndContext
sensors={sensors}
@@ -224,7 +224,7 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
// `relative` anchors the sr-only drag-hint paragraph: `sr-only` is
// absolutely positioned, and without a positioned ancestor its 1px box
// lands below the list in DOCUMENT coordinates and makes the document
// itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor).
// itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
<div className="relative space-y-3" data-slot="mood-tag-groups-card">
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground min-w-0 text-xs">
@@ -296,7 +296,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
// `relative` anchors the sr-only drag-hint paragraph: `sr-only` is
// absolutely positioned, and without a positioned ancestor its 1px box
// lands below the list in DOCUMENT coordinates and makes the document
// itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor).
// itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
<div className="relative space-y-4" data-slot="mood-tag-manager-card">
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground min-w-0 text-xs">
+1 -1
View File
@@ -13,7 +13,7 @@ import { useEffect, useRef, type ReactNode } from "react";
* put it — the heading is at the top of the column anyway.
*
* Semibold rather than the app-wide bold PageHeader H1: the onboarding hero
* is the documented exception (UI-STANDARDS §5). Do not sweep to font-bold.
* is the documented exception (ui-standards.md §5). Do not sweep to font-bold.
*/
export function StepHeading({
id,
@@ -404,7 +404,7 @@ describe("how a field says what is wrong with it", () => {
// Announced, because it MOUNTS with the role rather than a class being
// swapped onto a node that was already there.
expect(alert).toContain('role="alert"');
// Content, not meta. UI-STANDARDS §3, and the same reasoning
// Content, not meta. ui-standards.md §3, and the same reasoning
// `grant-action-error.tsx` spells out: a refusal rendered as muted is a
// refusal nobody reads.
expect(alert).toContain("text-destructive");
@@ -521,7 +521,7 @@ describe("ending a managed profile", () => {
authRef.value = { accounts: [entry()], active: null };
const html = renderGatedSection();
expect(html).toContain('data-slot="managed-profile-delete"');
// The mobile tap floor UI-STANDARDS §11 asks of an inline action.
// The mobile tap floor ui-standards.md §11 asks of an inline action.
expect(html).toMatch(
/data-slot="managed-profile-delete"[^>]*class="[^"]*min-h-11/,
);
@@ -629,7 +629,7 @@ describe("ending a managed profile", () => {
const html = renderGatedSection();
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain("Could not load who looks after this record");
// And the way back is reachable, which is what UI-STANDARDS §6 asks of an
// And the way back is reachable, which is what ui-standards.md §6 asks of an
// error state.
expect(html).toContain('data-slot="query-error-retry"');
});
@@ -172,7 +172,7 @@ export function grantActionErrorKey(err: unknown, action: GrantAction): string {
* a person who pressed a button and got nothing has already looked away, and
* a screen reader that does not announce it is the same silence the panel had
* before. `text-destructive` because this is content somebody has to act on —
* UI-STANDARDS §3 reserves muted for meta, and a refusal rendered as meta is a
* ui-standards.md §3 reserves muted for meta, and a refusal rendered as meta is a
* refusal nobody reads.
*
* `data-grant-error-for` carries the row id so a panel holding two failed rows
@@ -466,7 +466,7 @@ export function resolveInviteScope({
* a button inside a label forwards its own tap to the label's control, so the
* radio and the popover would fight for the same click (the same reason
* `SectionOption` avoids the wrapping label). Muted, `text-xs` — the meta floor
* per UI-STANDARDS §3.
* per ui-standards.md §3.
*/
function AccessOption({
level,
+1 -1
View File
@@ -54,7 +54,7 @@ export function GrantRowItem({
/**
* Something the person has to read before they act on this row, rendered in
* the content column rather than beside the button: consent copy is content
* (UI-STANDARDS §3), and a sentence squeezed into the action column would
* (ui-standards.md §3), and a sentence squeezed into the action column would
* wrap into a stripe nobody reads.
*/
notice?: ReactNode;
@@ -92,7 +92,7 @@ export function ManagedProfileCard() {
/>
<div className="space-y-4">
{/* Content, not meta: this is what somebody is consenting to take on,
and UI-STANDARDS §3 reserves muted for the incidental. */}
and ui-standards.md §3 reserves muted for the incidental. */}
<p data-slot="managed-profile-explainer" className="text-sm">
{t("recordSharing.managed.explainer")}
</p>
@@ -159,7 +159,7 @@ function ManagedProfileRow({
data-managed-profile-id={profile.accountId}
className="space-y-2 py-3 first:pt-0 last:pb-0"
>
{/* UI-STANDARDS §11 inline action row. */}
{/* ui-standards.md §11 inline action row. */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{/* A person's name is user data and stays content. */}
@@ -168,7 +168,7 @@ function ManagedProfileRow({
{t("recordSharing.lookingAfter.kindManaged")}
</p>
</div>
{/* UI-STANDARDS §11: the actions stay beside the name, never below it.
{/* ui-standards.md §11: the actions stay beside the name, never below it.
Edit is offered without waiting for the roster — it changes what the
record IS and does not depend on how many people look after it,
unlike the deletion beside it, whose confirm copy states that
@@ -223,7 +223,7 @@ function ManagedProfileRow({
{/* A failed roster read is not an absent roster.
Without this branch a 500 renders exactly as a still-loading one —
the row with no guardian panel and no delete control — and the person
waits for something that is never coming. UI-STANDARDS §6: a read
waits for something that is never coming. ui-standards.md §6: a read
that failed says so and offers the way back. It is deliberately NOT
the same as the 404 arm: a 404 means this account is no longer a
Guardian of that profile, which is an answer, and the row correctly
@@ -321,7 +321,7 @@ export function ManagedProfileCreateForm({
* Two nodes, never one that swaps. The form used to render a single muted
* `<p>` whose TEXT changed from hint to error, which is wrong twice over:
*
* * a refusal is content somebody has to act on, and UI-STANDARDS §3
* * a refusal is content somebody has to act on, and ui-standards.md §3
* reserves `text-muted-foreground` for meta. The same reasoning is written
* out at `grant-action-error.tsx` — a refusal rendered as meta is a
* refusal nobody reads;
@@ -292,7 +292,7 @@ function ManagedProfileEditFields({
/>
</div>
{/* Above the action row, per UI-STANDARDS §12: nothing follows it. */}
{/* Above the action row, per ui-standards.md §12: nothing follows it. */}
{error && (
<p
role="alert"
@@ -151,7 +151,7 @@ export function ManagedProfileGuardians({
data-guardian-state={guardian.state}
className="space-y-2 py-3 first:pt-0 last:pb-0"
>
{/* UI-STANDARDS §11 inline action row. */}
{/* ui-standards.md §11 inline action row. */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
@@ -449,7 +449,7 @@ function HandoverLink({
</p>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
{/* The QR quiet zone stays white in both themes: a scanner needs the
contrast, and UI-STANDARDS §4 names this exemption. */}
contrast, and ui-standards.md §4 names this exemption. */}
{/* eslint-disable-next-line @next/next/no-img-element -- a data: URL minted client-side; nothing for the image optimiser to do */}
<img
src={link.qrDataUrl}
@@ -574,7 +574,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
// then sits at its static position (below this long list) in DOCUMENT
// coordinates and silently makes the document itself scrollable, a
// second vertical scrollbar next to the shell-owned `<main>` scroll
// (UI-STANDARDS §9 one-scroll-floor; guarded by
// (ui-standards.md §9 one-scroll-floor; guarded by
// e2e/settings-overscroll.spec.ts).
<div className="relative space-y-2">
{/* v1.4.15 — table-style header naming the two
@@ -110,7 +110,7 @@ export function ReportScopePicker({
return (
<div className="space-y-3" data-testid={`report-scope-picker-${surface}`}>
{/* The one-click way to a complete report. An inline action row
(UI-STANDARDS §11): the sentence wraps, the action stays beside it.
(ui-standards.md §11): the sentence wraps, the action stays beside it.
It is offered, never applied — and it stays offered so a person can
come back to the standard set after experimenting. On a phone the
action's own label is wider than half the row, so beside it the
@@ -518,7 +518,7 @@ export function ShareLinkCreateForm({
opens the record (and its shared documents). Promoted as the
primary mobile affordance: a clinician scans it on the spot to
open the record on their own device. Shown exactly once. The
white quiet-zone is the documented QR exemption (UI-STANDARDS §4)
white quiet-zone is the documented QR exemption (ui-standards.md §4)
so the code stays scannable in the dark theme too. */}
{qrDataUrl && (
<div
+1 -1
View File
@@ -17,7 +17,7 @@ import { cn } from "@/lib/utils";
* geometry and pushes everything the user came for below the fold — so this
* shape stays, promoted to a primitive instead of being copied a third time.
*
* Pick by surface (UI-STANDARDS §6):
* Pick by surface (ui-standards.md §6):
* - primary / list surface → `QueryErrorCard`
* - dense metric strip or tile where the tall card would dominate →
* `QueryErrorRow`
+1 -1
View File
@@ -56,7 +56,7 @@ export interface AccessibilityStateCase {
readonly keyboardTarget: string | null;
/**
* Copy the person has to read and act on, which must render as content
* rather than as muted meta (UI-STANDARDS §3). `null` where the state's only
* rather than as muted meta (ui-standards.md §3). `null` where the state's only
* text is a screen-reader label, which has no visual contrast to check.
*/
readonly contrastTarget: string | null;