docs: publish the UI standards in docs/contributing

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

No files matched your search

@@ -29,7 +29,7 @@ import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
* v1.29 — 30-day daily bar chart for the nutrients hydration / caffeine
* cards. One bar per day (dense series — 0 for an unlogged day, summed
* across sources), an optional thin dashed EFSA reference line, no
* attainment colouring (UI-STANDARDS: value stays foreground, the bar +
* attainment colouring (ui-standards.md: value stays foreground, the bar +
* reference line ARE the context). Registered in
* `src/components/charts/chart-runtime.ts` — never import recharts
* directly at the call site.
+1 -1
View File
@@ -539,7 +539,7 @@ export function TodayHero({
</div>
)}
{/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs
{/* Meta row — the hero's quiet tier (ui-standards.md §3: `text-xs
text-muted-foreground` is the meta floor; never an accent, never an
opacity modifier). Freshness note (plan §2.4) — provisional day,
last night's sleep not yet folded in. Muted, non-blocking,
+1 -1
View File
@@ -73,7 +73,7 @@ import { useDayValueFormat } from "./use-day-value-format";
* to look from, and the entry is where it is edited.
*/
/** Section label, UI-STANDARDS §5. */
/** Section label, ui-standards.md §5. */
export const DAY_SECTION_LABEL =
"text-muted-foreground text-xs font-medium tracking-wide uppercase";
@@ -199,7 +199,7 @@ describe("<HeroStrip>", () => {
/data-slot="insights-hero-strip-greeting"[^>]*class="([^"]*)"/,
);
expect(greeting).not.toBeNull();
// App H1 weight is bold (UI-STANDARDS §5) — never font-semibold.
// App H1 weight is bold (ui-standards.md §5) — never font-semibold.
expect(greeting![1]).toContain("font-bold");
expect(greeting![1]).not.toContain("font-semibold");
// No off-scale arbitrary type sizes (§5) — round to the neighbouring step.
@@ -65,7 +65,7 @@ describe("<SleepStageStackedBar>", () => {
expect(html).toContain("Stage composition");
expect(html).toContain("Last 23 nights");
// The title routes through the canonical tile header rather than a
// hand-rolled title row (UI-STANDARDS §1 / §8).
// hand-rolled title row (ui-standards.md §1 / §8).
expect(html).toContain('data-slot="tile-header"');
});
@@ -76,7 +76,7 @@ describe("Coach unverified-figures notice", () => {
/>,
);
expect(html).toContain('data-slot="coach-unverified-notice"');
// Muted meta treatment (UI-STANDARDS §3) — never a warning colour.
// Muted meta treatment (ui-standards.md §3) — never a warning colour.
expect(html).toContain("text-muted-foreground");
});
@@ -520,7 +520,7 @@ function ChatBubbleImpl({
// #781 — the persisted cancelled-turn marker (an EMPTY assistant row the
// server writes when the client aborts mid-generation, e.g. navigating
// away). Rendered as a quiet interrupted note — muted meta text per
// UI-STANDARDS §3, never an alarm colour: nothing failed, the user left —
// ui-standards.md §3, never an alarm colour: nothing failed, the user left —
// plus a retry that resubmits the question that produced the turn (the
// regenerate closure, so the resend runs through the normal send path and
// every budget/rate gate). No provenance, feedback, copy, or token footer:
@@ -700,7 +700,7 @@ function ChatBubbleImpl({
<p className="text-warning text-xs">{safeError}</p>
)}
{/* v1.32.14 — quiet grounding caveat when the guard withheld a figure.
Muted meta (UI-STANDARDS §3), never a warning colour — it is a
Muted meta (ui-standards.md §3), never a warning colour — it is a
grounding caveat, not an error. Static single line, decorative icon,
no interactivity. The `[…]` marks in the prose above point here. */}
{showUnverifiedNotice && (
+1 -1
View File
@@ -52,7 +52,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
});
if (isError) {
// A failed read must never read as "no data" (UI-STANDARDS §6), and the
// A failed read must never read as "no data" (ui-standards.md §6), and the
// alert is content, not muted meta — it also has to be recoverable.
return (
<QueryErrorCard
+1 -1
View File
@@ -164,7 +164,7 @@ export function HeroStrip({
>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex items-center gap-2">
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
{/* Header glyph stays in the foreground tier — ui-standards.md §1
reserves primary/accent for actions, not decorative header
icons. */}
<Sparkles
@@ -24,7 +24,7 @@ import { cn } from "@/lib/utils";
*
* RSC-safe: no hooks, no browser API.
*
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1
* @see docs/contributing/ui-standards.md §1
*/
export function InsightSectionCard({
children,
@@ -71,7 +71,7 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440];
* line discloses the coverage so the shape doesn't read as more continuous
* than it is.
*
* v1.29.x — a day navigator (`TileHeader`'s `right` slot, per UI-STANDARDS
* v1.29.x — a day navigator (`TileHeader`'s `right` slot, per ui-standards.md
* §11) pages the same route backward through prior days; "next" is disabled
* at today, so the view can never step into the future. A day outside the
* dense retention window (`DENSE_INTRADAY_RETENTION_DAYS`) reads back at the
@@ -99,7 +99,7 @@ export function LabsChangesCard({
</div>
{/* The delta is CONTENT — the number the row exists to report —
so it takes the analyte's tier, not the meta tier of the
line under it (UI-STANDARDS §3). At `text-xs` muted it wore
line under it (ui-standards.md §3). At `text-xs` muted it wore
the sub-line's typography and optically attached itself to
that lower line, which is what made a row centred to the
pixel read as bottom-aligned. `self-center` states the row's
@@ -8,7 +8,7 @@
* strictly association-framed, never a verdict. Each row links through to the
* medication's Wirkung tab for the full view.
*
* Card anatomy per UI-STANDARDS §8: TileHeader + the inherited `px-4 md:px-6`
* Card anatomy per ui-standards.md §8: TileHeader + the inherited `px-4 md:px-6`
* inset (never re-declared), foreground for content, muted for meta.
*/
import Link from "next/link";
@@ -22,7 +22,7 @@ const WINDOW_DAYS = 30;
* reference-intake meta line (omitted when the profile has no sex on
* file — the catalog's own contract), a 30-day bar chart with a dashed
* reference line. No ring, no attainment colour — the bar chart with a
* reference line IS the context (UI-STANDARDS: value stays foreground,
* reference line IS the context (ui-standards.md: value stays foreground,
* never coloured by attainment). Display only: water arrives by sync
* (Apple Health etc.), there is no in-app water entry here.
*/
+1 -1
View File
@@ -489,7 +489,7 @@ export function AuthShell({
// `overflow-y-auto` never clips them: their static position sits
// wherever the flow put them and extends the DOCUMENT's
// scrollable overflow instead, painting a second vertical scroll
// surface beside `<main>` (UI-STANDARDS §9 one-scroll-floor).
// surface beside `<main>` (ui-standards.md §9 one-scroll-floor).
// The class is not hypothetical and not confined to app code. A
// Radix `<Switch>` outside a `<form>` renders a hidden
// `position:absolute` bubble input on the first pass and drops it
+1 -1
View File
@@ -274,7 +274,7 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
*
* Settings and Notifications are account utilities, not feature destinations:
* on desktop they live in the sidebar footer + avatar menu, on mobile in the
* top-bar user menu (UI-STANDARDS §10; the More hub carries features only).
* top-bar user menu (ui-standards.md §10; the More hub carries features only).
* They used to be a second hand-curated list on each bar — the exact drift the
* one-model contract above set out to kill, just pushed down a level. This
* list is the single source both bars consume, so the two surfaces can no
@@ -33,7 +33,7 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
* dark for the muted token at this text size, while the foreground token keeps
* the context legible at the same contrast floor as the name.
*
* The row is the inline-action shape from UI-STANDARDS §11 rather than a
* The row is the inline-action shape from ui-standards.md §11 rather than a
* centred wrapping stack: sentence `min-w-0 flex-1`, action `shrink-0`, so the
* way out stays beside the sentence instead of dropping onto its own line once
* the name is long. `<MaintainershipBanner>` is the sibling that already had an
@@ -151,7 +151,7 @@ export function MedicationOrderEditor({
// `relative` anchors the sr-only drag-hint paragraph: `sr-only` is
// absolutely positioned, and without a positioned ancestor its 1px box
// lands below the list in DOCUMENT coordinates and makes the document
// itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor).
// itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
<div className="relative space-y-4" data-slot="medication-order-editor">
{medications.length === 0 && (
<EmptyState
@@ -110,7 +110,7 @@ export function ModuleOrderEditor({ items, onChange }: ModuleOrderEditorProps) {
// `relative` anchors the sr-only drag-hint paragraph: `sr-only` is
// absolutely positioned, and without a positioned ancestor its 1px box
// lands below the list in DOCUMENT coordinates and makes the document
// itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor).
// itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
<div className="relative space-y-2" data-slot="module-order-editor">
<DndContext
sensors={sensors}
@@ -224,7 +224,7 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
// `relative` anchors the sr-only drag-hint paragraph: `sr-only` is
// absolutely positioned, and without a positioned ancestor its 1px box
// lands below the list in DOCUMENT coordinates and makes the document
// itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor).
// itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
<div className="relative space-y-3" data-slot="mood-tag-groups-card">
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground min-w-0 text-xs">
@@ -296,7 +296,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
// `relative` anchors the sr-only drag-hint paragraph: `sr-only` is
// absolutely positioned, and without a positioned ancestor its 1px box
// lands below the list in DOCUMENT coordinates and makes the document
// itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor).
// itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
<div className="relative space-y-4" data-slot="mood-tag-manager-card">
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground min-w-0 text-xs">
+1 -1
View File
@@ -13,7 +13,7 @@ import { useEffect, useRef, type ReactNode } from "react";
* put it — the heading is at the top of the column anyway.
*
* Semibold rather than the app-wide bold PageHeader H1: the onboarding hero
* is the documented exception (UI-STANDARDS §5). Do not sweep to font-bold.
* is the documented exception (ui-standards.md §5). Do not sweep to font-bold.
*/
export function StepHeading({
id,
@@ -404,7 +404,7 @@ describe("how a field says what is wrong with it", () => {
// Announced, because it MOUNTS with the role rather than a class being
// swapped onto a node that was already there.
expect(alert).toContain('role="alert"');
// Content, not meta. UI-STANDARDS §3, and the same reasoning
// Content, not meta. ui-standards.md §3, and the same reasoning
// `grant-action-error.tsx` spells out: a refusal rendered as muted is a
// refusal nobody reads.
expect(alert).toContain("text-destructive");
@@ -521,7 +521,7 @@ describe("ending a managed profile", () => {
authRef.value = { accounts: [entry()], active: null };
const html = renderGatedSection();
expect(html).toContain('data-slot="managed-profile-delete"');
// The mobile tap floor UI-STANDARDS §11 asks of an inline action.
// The mobile tap floor ui-standards.md §11 asks of an inline action.
expect(html).toMatch(
/data-slot="managed-profile-delete"[^>]*class="[^"]*min-h-11/,
);
@@ -629,7 +629,7 @@ describe("ending a managed profile", () => {
const html = renderGatedSection();
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain("Could not load who looks after this record");
// And the way back is reachable, which is what UI-STANDARDS §6 asks of an
// And the way back is reachable, which is what ui-standards.md §6 asks of an
// error state.
expect(html).toContain('data-slot="query-error-retry"');
});
@@ -172,7 +172,7 @@ export function grantActionErrorKey(err: unknown, action: GrantAction): string {
* a person who pressed a button and got nothing has already looked away, and
* a screen reader that does not announce it is the same silence the panel had
* before. `text-destructive` because this is content somebody has to act on —
* UI-STANDARDS §3 reserves muted for meta, and a refusal rendered as meta is a
* ui-standards.md §3 reserves muted for meta, and a refusal rendered as meta is a
* refusal nobody reads.
*
* `data-grant-error-for` carries the row id so a panel holding two failed rows
@@ -466,7 +466,7 @@ export function resolveInviteScope({
* a button inside a label forwards its own tap to the label's control, so the
* radio and the popover would fight for the same click (the same reason
* `SectionOption` avoids the wrapping label). Muted, `text-xs` — the meta floor
* per UI-STANDARDS §3.
* per ui-standards.md §3.
*/
function AccessOption({
level,
+1 -1
View File
@@ -54,7 +54,7 @@ export function GrantRowItem({
/**
* Something the person has to read before they act on this row, rendered in
* the content column rather than beside the button: consent copy is content
* (UI-STANDARDS §3), and a sentence squeezed into the action column would
* (ui-standards.md §3), and a sentence squeezed into the action column would
* wrap into a stripe nobody reads.
*/
notice?: ReactNode;
@@ -92,7 +92,7 @@ export function ManagedProfileCard() {
/>
<div className="space-y-4">
{/* Content, not meta: this is what somebody is consenting to take on,
and UI-STANDARDS §3 reserves muted for the incidental. */}
and ui-standards.md §3 reserves muted for the incidental. */}
<p data-slot="managed-profile-explainer" className="text-sm">
{t("recordSharing.managed.explainer")}
</p>
@@ -159,7 +159,7 @@ function ManagedProfileRow({
data-managed-profile-id={profile.accountId}
className="space-y-2 py-3 first:pt-0 last:pb-0"
>
{/* UI-STANDARDS §11 inline action row. */}
{/* ui-standards.md §11 inline action row. */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{/* A person's name is user data and stays content. */}
@@ -168,7 +168,7 @@ function ManagedProfileRow({
{t("recordSharing.lookingAfter.kindManaged")}
</p>
</div>
{/* UI-STANDARDS §11: the actions stay beside the name, never below it.
{/* ui-standards.md §11: the actions stay beside the name, never below it.
Edit is offered without waiting for the roster — it changes what the
record IS and does not depend on how many people look after it,
unlike the deletion beside it, whose confirm copy states that
@@ -223,7 +223,7 @@ function ManagedProfileRow({
{/* A failed roster read is not an absent roster.
Without this branch a 500 renders exactly as a still-loading one —
the row with no guardian panel and no delete control — and the person
waits for something that is never coming. UI-STANDARDS §6: a read
waits for something that is never coming. ui-standards.md §6: a read
that failed says so and offers the way back. It is deliberately NOT
the same as the 404 arm: a 404 means this account is no longer a
Guardian of that profile, which is an answer, and the row correctly
@@ -321,7 +321,7 @@ export function ManagedProfileCreateForm({
* Two nodes, never one that swaps. The form used to render a single muted
* `<p>` whose TEXT changed from hint to error, which is wrong twice over:
*
* * a refusal is content somebody has to act on, and UI-STANDARDS §3
* * a refusal is content somebody has to act on, and ui-standards.md §3
* reserves `text-muted-foreground` for meta. The same reasoning is written
* out at `grant-action-error.tsx` — a refusal rendered as meta is a
* refusal nobody reads;
@@ -292,7 +292,7 @@ function ManagedProfileEditFields({
/>
</div>
{/* Above the action row, per UI-STANDARDS §12: nothing follows it. */}
{/* Above the action row, per ui-standards.md §12: nothing follows it. */}
{error && (
<p
role="alert"
@@ -151,7 +151,7 @@ export function ManagedProfileGuardians({
data-guardian-state={guardian.state}
className="space-y-2 py-3 first:pt-0 last:pb-0"
>
{/* UI-STANDARDS §11 inline action row. */}
{/* ui-standards.md §11 inline action row. */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
@@ -449,7 +449,7 @@ function HandoverLink({
</p>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
{/* The QR quiet zone stays white in both themes: a scanner needs the
contrast, and UI-STANDARDS §4 names this exemption. */}
contrast, and ui-standards.md §4 names this exemption. */}
{/* eslint-disable-next-line @next/next/no-img-element -- a data: URL minted client-side; nothing for the image optimiser to do */}
<img
src={link.qrDataUrl}
@@ -574,7 +574,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
// then sits at its static position (below this long list) in DOCUMENT
// coordinates and silently makes the document itself scrollable, a
// second vertical scrollbar next to the shell-owned `<main>` scroll
// (UI-STANDARDS §9 one-scroll-floor; guarded by
// (ui-standards.md §9 one-scroll-floor; guarded by
// e2e/settings-overscroll.spec.ts).
<div className="relative space-y-2">
{/* v1.4.15 — table-style header naming the two
@@ -110,7 +110,7 @@ export function ReportScopePicker({
return (
<div className="space-y-3" data-testid={`report-scope-picker-${surface}`}>
{/* The one-click way to a complete report. An inline action row
(UI-STANDARDS §11): the sentence wraps, the action stays beside it.
(ui-standards.md §11): the sentence wraps, the action stays beside it.
It is offered, never applied — and it stays offered so a person can
come back to the standard set after experimenting. On a phone the
action's own label is wider than half the row, so beside it the
@@ -518,7 +518,7 @@ export function ShareLinkCreateForm({
opens the record (and its shared documents). Promoted as the
primary mobile affordance: a clinician scans it on the spot to
open the record on their own device. Shown exactly once. The
white quiet-zone is the documented QR exemption (UI-STANDARDS §4)
white quiet-zone is the documented QR exemption (ui-standards.md §4)
so the code stays scannable in the dark theme too. */}
{qrDataUrl && (
<div
+1 -1
View File
@@ -17,7 +17,7 @@ import { cn } from "@/lib/utils";
* geometry and pushes everything the user came for below the fold — so this
* shape stays, promoted to a primitive instead of being copied a third time.
*
* Pick by surface (UI-STANDARDS §6):
* Pick by surface (ui-standards.md §6):
* - primary / list surface → `QueryErrorCard`
* - dense metric strip or tile where the tall card would dominate →
* `QueryErrorRow`