mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
07f9615dba
commit
d44af06467
46 files changed
+244
-55
No files matched your search
+5
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
Vendored
+1
-1
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user