diff --git a/.env.production.example b/.env.production.example index 0428201a7..ecf31e293 100644 --- a/.env.production.example +++ b/.env.production.example @@ -533,9 +533,11 @@ API_TOKEN_HMAC_KEY="" # TRUST_CF_CONNECTING_IP="1" # Memory cap of the app container (read by docker-compose.yml, not by the app). -# Node sizes its heap from it: 1536m (the default) gives 792 MB, 1g only 524 MB, -# which a long-used account fills in normal operation. Lower it on a host with -# 2 GB or less, raise it on a busy instance. +# Node sizes its heap from it: 1536m (the default) gives 792 MB. 1536m is the +# supported floor: below it a long-used account fills the heap in normal +# operation, and the boot log, the support report and Admin > System status +# say the app runs below the supported floor. The host needs 3 GB of RAM, +# 4 GB recommended. Raise it on a busy instance; do not lower it. # APP_MEMORY_LIMIT="1536m" # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index fb778bee4..c3aee3175 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -503,9 +503,10 @@ services: # workload can grow large enough to starve Postgres on a shared host. # Node sizes its heap from this limit: 1g gives a 524 MB heap, which a # long-used account fills in normal operation, so garbage collection - # stalls requests for seconds. 1536m gives 792 MB. The support report and - # Admin > System status flag a heap below 700 MB. On a host with 2 GB or less, lower it with - # APP_MEMORY_LIMIT in .env; on a busy instance, raise it. + # stalls requests for seconds. 1536m gives 792 MB and is the supported + # floor (host: 3 GB RAM, 4 GB recommended). Below it the boot log, the + # support report and Admin > System status say so. On a busy instance, + # raise it with APP_MEMORY_LIMIT in .env; do not lower it. mem_limit: "${APP_MEMORY_LIMIT:-1536m}" healthcheck: test: @@ -521,7 +522,11 @@ services: # minutes (docs/ops/deploy.md), and failed probes in that time must not # read as a dead container. The first probe that answers still ends # the start period, so a normal boot is healthy within 30 seconds of - # listening. + # listening. After it listens, the pg-boss worker starts in the + # background and /api/health answers 503 with reason "starting" until it + # is up (10–60 s under load, reported as "starting" for at most five + # minutes); inside the start period those probes do not count, so a slow + # worker start cannot mark the container unhealthy. start_period: 10m # Optional dedicated worker container — uncomment + set 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/api/openapi.yaml b/docs/api/openapi.yaml index 9b4f13c5b..932209f27 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4315,6 +4315,15 @@ paths: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - in: query name: from schema: @@ -9455,6 +9464,52 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/auth/me/workout-routes: + get: + tags: + - Auth + summary: Read the workout-route opt-in + description: Whether the GPS track of a workout is kept. Off by default. While it is off, `POST /api/workouts/batch` and + the Apple Health export import keep every workout but drop its `route`; the per-entry status is the same as with + the route stored. No AI path ever reads a stored track. + responses: + "200": + description: The current flag. + content: + application/json: + schema: + $ref: "#/components/schemas/GetWorkoutRoutesEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + patch: + tags: + - Auth + summary: Set the workout-route opt-in + description: Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user; body capped at 1 KB. Turning it off + stops new tracks; tracks already stored stay until their workout is deleted. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WorkoutRoutesPatchRequest" + responses: + "200": + description: The resolved next flag. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchWorkoutRoutesEnvelope" + "401": *a1 + "413": + description: Request body exceeds 1024 bytes. Nothing was written. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/auth/me/use-central-codex: get: tags: @@ -22797,8 +22852,9 @@ paths: - $ref: "#/components/schemas/HealthAdminResponse" - $ref: "#/components/schemas/HealthPublicResponse" "503": - description: "Degraded — the database did not answer, or a role this process runs is not up. Same body shape as the 200, - with `status: \"degraded\"`. A restart loop reports this rather than failing to answer, so a monitor can + description: "Degraded — the database did not answer within two seconds, or a role this process runs is not up. Same + body shape as the 200, with `status: \"degraded\"`, and `reason: \"starting\"` while the background role is + still coming up after a start. A restart loop reports this rather than failing to answer, so a monitor can tell 'starting' from 'gone'." content: application/json: @@ -25948,7 +26004,7 @@ components: type: number const: 2 leaves: - maxItems: 95 + maxItems: 104 type: array items: type: string @@ -26008,7 +26064,7 @@ components: type: number const: 2 leaves: - maxItems: 95 + maxItems: 104 type: array items: type: string @@ -26843,6 +26899,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY value: type: number measuredAt: @@ -28524,6 +28589,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY biomarkerId: type: string minLength: 1 @@ -29023,7 +29097,7 @@ components: type: number const: 2 leaves: - maxItems: 95 + maxItems: 104 type: array items: type: string @@ -29358,6 +29432,14 @@ components: - labsLocalOcrEnabled description: Turn browser-side OCR on or off for lab-report scanning. When on, a text-only provider can still read a paper report because the image is transcribed on the device and only the TEXT is sent. + WorkoutRoutesPatchRequest: + type: object + properties: + storeWorkoutRoutes: + type: boolean + required: + - storeWorkoutRoutes + description: Keep or stop keeping the GPS track of new workouts. Off by default. UseCentralCodexPatchRequest: type: object properties: @@ -30186,6 +30268,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature). InsightsSettingsPutRequest: @@ -38851,6 +38942,8 @@ components: type: boolean labsLocalOcrEnabled: type: boolean + storeWorkoutRoutes: + type: boolean onboardingCompletedAt: anyOf: - type: string @@ -38961,6 +39054,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + - AFIB_BURDEN value: type: number valueMin: @@ -39701,6 +39803,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + - AFIB_BURDEN - type: "null" biomarkerName: anyOf: @@ -42176,6 +42287,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + - AFIB_BURDEN dateKey: type: string pattern: ^\d{4}-\d{2}-\d{2}$ @@ -43354,6 +43474,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + - AFIB_BURDEN - type: "null" intervalDays: anyOf: @@ -43566,6 +43695,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + - AFIB_BURDEN metricSlot: type: - string @@ -55934,6 +56072,48 @@ components: - data - error additionalProperties: false + GetWorkoutRoutesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WorkoutRoutesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + WorkoutRoutesResponse: + type: object + properties: + storeWorkoutRoutes: + type: boolean + required: + - storeWorkoutRoutes + additionalProperties: false + PatchWorkoutRoutesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WorkoutRoutesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetUseCentralCodexEnvelope: type: object properties: @@ -84031,14 +84211,18 @@ components: when the process serves web, and the worker loop is running when the process runs the worker. `degraded` on any of those failing." HealthReason: - description: "Present only when the process refuses to serve for a reason a person has to fix. - `encryption_key_mismatch`: the configured encryption key cannot open the data in this database (checked at - boot). Every other route then answers 503 with `meta.errorCode` `encryption.key_mismatch`, and background jobs - are not started. Fix: restore the original key, point the server at the matching database, or start with an - empty one; then restart." + description: "Why a `degraded` process is degraded, when it is something other than a component failure. + `encryption_key_mismatch`: the process refuses to serve for a reason a person has to fix; the configured + encryption key cannot open the data in this database (checked at boot). Every other route then answers 503 with + `meta.errorCode` `encryption.key_mismatch`, and background jobs are not started. Fix: restore the original key, + point the server at the matching database, or start with an empty one; then restart. `starting`: the database + answers but the background role (the pg-boss worker, or the producer of a web-only process) is still coming up + after a start. Transient; nothing to fix, poll again. The probe answers at once while it lasts rather than + waiting for the start to finish." type: string enum: - encryption_key_mismatch + - starting HealthPublicResponse: type: object properties: 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 `