From d44af0646782b107f2ef02f55f0bb6373ab34346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:37:22 +0200 Subject: [PATCH] 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. --- docs/README.md | 6 +- docs/contributing/ui-standards.md | 184 ++++++++++++++++++ docs/ui-guidelines.md | 9 +- e2e/settings-overscroll.spec.ts | 2 +- ...v137-sharing-managed-profiles-a11y.spec.ts | 2 +- eslint-plugins/healthlog/spacing-scale.js | 4 +- .../sharing-recoverable-errors.test.tsx | 2 +- src/app/achievements/page.tsx | 2 +- src/app/globals.css | 2 +- src/app/insights/workouts/[id]/page.tsx | 2 +- .../charts/nutrient-daily-bar-chart.tsx | 2 +- src/components/daily/today-hero.tsx | 2 +- src/components/day/day-sections.tsx | 2 +- .../insights/__tests__/hero-strip.test.tsx | 2 +- .../sleep-stage-stacked-bar.test.tsx | 2 +- .../chat-bubble-unverified-notice.test.tsx | 2 +- .../insights/coach-panel/chat-bubble.tsx | 4 +- src/components/insights/ecg-detail.tsx | 2 +- src/components/insights/hero-strip.tsx | 2 +- .../insights/insight-section-card.tsx | 2 +- .../insights/intraday-pulse-chart.tsx | 2 +- src/components/insights/labs-changes-card.tsx | 2 +- .../insights/medication-efficacy-summary.tsx | 2 +- .../insights/nutrients/hydration-card.tsx | 2 +- src/components/layout/auth-shell.tsx | 2 +- src/components/layout/nav-model.ts | 2 +- .../layout/shared-record-banner.tsx | 2 +- .../medications/medication-order-editor.tsx | 2 +- .../module-list/module-order-editor.tsx | 2 +- .../mood/manage/tag-groups-card.tsx | 2 +- .../mood/manage/tag-manager-card.tsx | 2 +- src/components/onboarding/step-heading.tsx | 2 +- .../managed-profile-affordances.test.tsx | 6 +- .../settings/access/grant-action-error.tsx | 2 +- .../settings/access/grant-invite-card.tsx | 2 +- src/components/settings/access/grant-row.tsx | 2 +- .../settings/access/managed-profile-card.tsx | 8 +- .../access/managed-profile-create-form.tsx | 2 +- .../access/managed-profile-edit-form.tsx | 2 +- .../access/managed-profile-guardians.tsx | 2 +- .../access/managed-profile-handover.tsx | 2 +- .../settings/dashboard-layout-section.tsx | 2 +- .../report-selection/report-scope-picker.tsx | 2 +- .../settings/share-link-create-form.tsx | 2 +- src/components/ui/query-error-row.tsx | 2 +- tests/fixtures/v137/e2e-journeys.ts | 2 +- 46 files changed, 244 insertions(+), 55 deletions(-) create mode 100644 docs/contributing/ui-standards.md diff --git a/docs/README.md b/docs/README.md index 34465adf4..aabff21cc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 or jump to the [Quick Start in the project diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md new file mode 100644 index 000000000..f2c982788 --- /dev/null +++ b/docs/contributing/ui-standards.md @@ -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: ``. +- Two densities, nothing else: + - `default` — Card as shipped: `py-4 md:py-6`, `gap-4 md:gap-6`. + - `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 `` (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 ``, 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 + ``). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `
` — 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 `

` (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 `

`, one `role="heading" aria-level={1}` | a hand-rolled `role="heading"` div beside a hand-rolled `

` | +| 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 `