mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
docs(ui-standards): public wording, one spacing scale, no fragment-redirect claim
The UI standards drop working shorthand, state the spacing scale once in section 2 (section 13 had listed an older scale without 2.5 and 3.5) and use no em dashes. The v1.3 to v1.4 migration note no longer claims that /settings#anchor URLs redirect to their section: the fragment never reaches the server. A test holds both documents to this.
This commit is contained in:
1 parent
f428b14ce9
commit
59c3c38379
3 files changed
+144
-81
No files matched your search
@@ -6,19 +6,19 @@ The binding reference for building or touching any surface of the web app. The r
|
||||
|
||||
## 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.
|
||||
**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).
|
||||
- `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.
|
||||
- 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
|
||||
### Header patterns; exactly three, pick by surface
|
||||
|
||||
| Surface | Primitive | Contract |
|
||||
| ---------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -26,15 +26,15 @@ The binding reference for building or touching any surface of the web app. The r
|
||||
| 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.
|
||||
**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.
|
||||
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`.
|
||||
> **Scroll height is shell-owned.** 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).
|
||||
- **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's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which 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.
|
||||
@@ -46,71 +46,71 @@ The gaps this section used to list are closed: `TileHeader` carries a `size="sm"
|
||||
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.
|
||||
- **`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).
|
||||
- **FORBIDDEN: opacity modifiers on muted text.** `text-muted-foreground/70` ≈ 2.9–3.9:1 and `/80` ≈ 3.4–4.2:1, below AA, and they silently undo the token tuning. 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.
|
||||
- **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 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 |
|
||||
| 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
|
||||
## 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) |
|
||||
| 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.
|
||||
|
||||
@@ -123,7 +123,7 @@ The `queryFn` hands the `ApiError` through untouched: no `try { … } catch { th
|
||||
|
||||
## 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).
|
||||
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).
|
||||
|
||||
@@ -135,31 +135,31 @@ Planned (until it lands, this is a review-checklist item):
|
||||
|
||||
---
|
||||
|
||||
## 8. Insights left reading edge — two edges, one gutter
|
||||
## 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.
|
||||
- **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
|
||||
## 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.
|
||||
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.
|
||||
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
|
||||
## 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.
|
||||
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
|
||||
## 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`).
|
||||
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
|
||||
## 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`.
|
||||
|
||||
@@ -168,17 +168,17 @@ A card's actions live in **one** row, and that row is the **last thing in the ca
|
||||
- **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"`).
|
||||
- **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
|
||||
## 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.
|
||||
- **The body starts at the card edge.** The old `pl-7` gutter (icon box + `gap-2`) is abolished: it is off the scale in §2, it broke the moment two neighbouring cards disagreed, and it silently mis-aligned action rows against their own forms. Header text is offset from the card edge by the icon gutter; body content is not. This is the deliberate difference from §8's Insights gutter, where the tiles carry no body form.
|
||||
- **Group headings above a group of cards use `SectionHeading`** (`ui/section-heading.tsx`, the heading of the Insights overview, shared by every area), not a 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
|
||||
### Enforcement
|
||||
|
||||
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.
|
||||
The `p-5` / `py-5` / `px-5` / `space-y-5` family does not occur in the app, and `healthlog/spacing-scale` runs at `error` over `src/components/**` and `src/app/**`, so the step cannot come back unnoticed. `no-raw-card-shell` is described in §7. The header icon colour (§1) has no lint rule; check it in review.
|
||||
@@ -204,9 +204,11 @@ n8n, and Health Connect integrations need no change.
|
||||
## URL changes
|
||||
|
||||
- **Settings has moved to per-section routes.** `/settings#integrations`
|
||||
→ `/settings/integrations`, etc. The legacy `/settings#anchor` URLs
|
||||
still 308-redirect to the new routes; sidebar deep-links and the
|
||||
`<a href="/settings#thresholds">` patterns from 1.3 keep working.
|
||||
→ `/settings/integrations`, etc. A legacy `/settings#anchor` URL opens
|
||||
Settings (`/settings` redirects to `/settings/account`), but the part
|
||||
after `#` never reaches the server, so it cannot pick the section: update
|
||||
bookmarks and links of the `/settings#thresholds` shape to the
|
||||
per-section route.
|
||||
|
||||
## What landed in v1.4 vs v1.4.1
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* The published UI standards read as public documentation.
|
||||
*
|
||||
* `docs/contributing/ui-standards.md` is the binding rulebook, linked from the
|
||||
* code. It must not carry working shorthand that only made sense in the
|
||||
* maintainer's notes, must not contradict itself on the spacing scale, and
|
||||
* follows the docs' punctuation (no em dashes).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const DOC = readFileSync(
|
||||
join(process.cwd(), "docs/contributing/ui-standards.md"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function section(number: number): string {
|
||||
const start = DOC.indexOf(`## ${number}. `);
|
||||
const next = DOC.indexOf("\n## ", start + 1);
|
||||
return DOC.slice(start, next < 0 ? undefined : next);
|
||||
}
|
||||
|
||||
describe("docs/contributing/ui-standards.md", () => {
|
||||
it("has no em dashes", () => {
|
||||
expect(DOC.length).toBeGreaterThan(5000);
|
||||
expect(DOC).not.toContain("—");
|
||||
});
|
||||
|
||||
it("carries no internal working shorthand", () => {
|
||||
for (const phrase of [
|
||||
/lineage/i,
|
||||
/swept on touch/i,
|
||||
/remains planned/i,
|
||||
/#\d{2,4}\b/,
|
||||
]) {
|
||||
expect(DOC).not.toMatch(phrase);
|
||||
}
|
||||
});
|
||||
|
||||
it("states the spacing scale once, in §2", () => {
|
||||
const scale = /`1 \/ 1\.5 \/ 2 \/ 2\.5 \/ 3 \/ 3\.5 \/ 4 \/ 6 \/ 8 \/ 10`/;
|
||||
expect(section(2)).toMatch(scale);
|
||||
// Every other section refers to §2 instead of listing its own copy.
|
||||
for (const n of [1, 3, 8, 13]) {
|
||||
expect(section(n), `§${n}`).not.toMatch(/`1\/1\.5\/2/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs/migration/v1.3-to-v1.4.md", () => {
|
||||
it("does not promise a fragment redirect the server cannot see", () => {
|
||||
const doc = readFileSync(
|
||||
join(process.cwd(), "docs/migration/v1.3-to-v1.4.md"),
|
||||
"utf8",
|
||||
);
|
||||
expect(doc).toContain("Settings has moved to per-section routes");
|
||||
expect(doc).not.toMatch(/#anchor` URLs\s+still 308-redirect/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user