From ef13a072a9c94f7aa361392cef55b51e533acfa7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:40:04 +0800 Subject: [PATCH 1/7] feat(design-system): v2 token layer, 26 components, browser-crash fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design-system scope only. Nothing is adopted by a product surface: the new components are built and tested but unimported, and the v2 token layer is class-scoped to `.ckb-v2` so importing it cannot repaint any surface that does not opt in. Correctness: - Fix a P1 browser crash. `source-metadata.ts` imported the server logger, whose `activeLevel()` reads `process.env.LOG_LEVEL` — a ReferenceError in a browser, so any off-vocabulary metadata value unmounted the whole React tree instead of falling back. Drops the import, adds a browser-safe diagnostic seam, and guards the env read in `logger.ts` as defence in depth. - Type the three source badges' `metadata` prop. It was `unknown` while the published `.d.ts` promised a shape, so the wrong key compiled cleanly. - Encode the disabled state in `controlBase` instead of `opacity-50`, which dimmed fill and label together and dropped secondary labels below 4.5:1. - Move `fieldLabel` off `--text-soft` (3.07:1) and the uppercase eyebrow treatment. - Delete `ui/card.tsx` and `ui/badge.tsx` — 7 exports, 0 importers, colliding by name with Chip and AnswerCard. Components (built, not adopted): Button, TextField, SearchField, Chip, Toast, Tabs, Tooltip, Pagination, ConfirmDialog, PageHeader, Breadcrumb, AnswerCard, DoseLine, AnswerFooter, Quantity, StatusMark, Citation, the four Link variants, Select, Checkbox, RadioGroup, Disclosure, Progress, StageList. AccessibleTable: per-column numeric alignment, `aria-controls` on the expander, sticky header when expanded, and an explicit unverified-extraction treatment. Verification: tsc pass, lint pass, prettier clean, verify:ui 344 passed, design-system contract pass. Unit 4689 passed / 1 failed / 3 skipped — the failure is `installed-lock-parity`, environmental (brace-expansion 1.1.16 vs lockfile 1.1.18), tracked as issue #149. Co-Authored-By: Claude Opus 5 --- .design-sync/NOTES.md | 56 ++++ .design-sync/config.json | 47 +++- .design-sync/conventions.md | 39 ++- .design-sync/entry.tsx | 15 ++ .design-sync/previews/AnswerCard.tsx | 43 +++ .design-sync/previews/AnswerFooter.tsx | 20 ++ .design-sync/previews/AsyncButton.tsx | 28 ++ .design-sync/previews/Breadcrumb.tsx | 15 ++ .design-sync/previews/Button.tsx | 57 ++++ .design-sync/previews/Chip.tsx | 37 +++ .design-sync/previews/ConfirmDialog.tsx | 55 ++++ .design-sync/previews/DoseLine.tsx | 40 +++ .design-sync/previews/IconButton.tsx | 16 ++ .design-sync/previews/PageHeader.tsx | 31 +++ .design-sync/previews/Pagination.tsx | 19 ++ .design-sync/previews/SearchField.tsx | 19 ++ .design-sync/previews/Skeleton.tsx | 22 ++ .../previews/SourceDesignationBadge.tsx | 21 ++ .design-sync/previews/Tabs.tsx | 29 ++ .design-sync/previews/TextField.tsx | 30 +++ .design-sync/previews/ToastRegion.tsx | 45 ++++ .design-sync/previews/Tooltip.tsx | 26 ++ docs/clinical-badge-system-guide.md | 38 +++ scripts/design-system-contract-utils.mjs | 6 +- src/app/ckb-v2-tokens.css | 252 +++++++++++++++++ src/app/globals.css | 3 + src/components/AccessibleTable.tsx | 126 +++++++-- src/components/ui-primitives.tsx | 56 +++- src/components/ui/answer-card.tsx | 188 +++++++++++++ src/components/ui/badge.tsx | 25 -- src/components/ui/button.tsx | 87 ++++++ src/components/ui/card.tsx | 50 ---- src/components/ui/chip.tsx | 76 ++++++ src/components/ui/choice.tsx | 192 +++++++++++++ src/components/ui/citation.tsx | 134 +++++++++ src/components/ui/confirm-dialog.tsx | 112 ++++++++ src/components/ui/disclosure.tsx | 140 ++++++++++ src/components/ui/link.tsx | 128 +++++++++ src/components/ui/page-header.tsx | 110 ++++++++ src/components/ui/pagination.tsx | 96 +++++++ src/components/ui/progress.tsx | 149 ++++++++++ src/components/ui/quantity.tsx | 76 ++++++ src/components/ui/select.tsx | 100 +++++++ src/components/ui/sheet.tsx | 6 + src/components/ui/status-mark.tsx | 67 +++++ src/components/ui/tabs.tsx | 135 ++++++++++ src/components/ui/text-field.tsx | 163 +++++++++++ src/components/ui/toast.tsx | 152 +++++++++++ src/components/ui/tooltip.tsx | 60 +++++ src/lib/logger.ts | 14 +- src/lib/source-metadata.ts | 18 +- tests/accessible-table-alignment.dom.test.tsx | 98 +++++++ tests/ckb-v2-token-contract.test.ts | 203 ++++++++++++++ tests/source-badges-off-vocab.dom.test.tsx | 72 +++++ tests/source-metadata-browser-safety.test.ts | 69 +++++ tests/source-metadata.test.ts | 32 +-- tests/ui-v2-components.dom.test.tsx | 254 ++++++++++++++++++ 57 files changed, 4069 insertions(+), 128 deletions(-) create mode 100644 .design-sync/previews/AnswerCard.tsx create mode 100644 .design-sync/previews/AnswerFooter.tsx create mode 100644 .design-sync/previews/AsyncButton.tsx create mode 100644 .design-sync/previews/Breadcrumb.tsx create mode 100644 .design-sync/previews/Button.tsx create mode 100644 .design-sync/previews/Chip.tsx create mode 100644 .design-sync/previews/ConfirmDialog.tsx create mode 100644 .design-sync/previews/DoseLine.tsx create mode 100644 .design-sync/previews/IconButton.tsx create mode 100644 .design-sync/previews/PageHeader.tsx create mode 100644 .design-sync/previews/Pagination.tsx create mode 100644 .design-sync/previews/SearchField.tsx create mode 100644 .design-sync/previews/Skeleton.tsx create mode 100644 .design-sync/previews/SourceDesignationBadge.tsx create mode 100644 .design-sync/previews/Tabs.tsx create mode 100644 .design-sync/previews/TextField.tsx create mode 100644 .design-sync/previews/ToastRegion.tsx create mode 100644 .design-sync/previews/Tooltip.tsx create mode 100644 src/app/ckb-v2-tokens.css create mode 100644 src/components/ui/answer-card.tsx delete mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/button.tsx delete mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/chip.tsx create mode 100644 src/components/ui/choice.tsx create mode 100644 src/components/ui/citation.tsx create mode 100644 src/components/ui/confirm-dialog.tsx create mode 100644 src/components/ui/disclosure.tsx create mode 100644 src/components/ui/link.tsx create mode 100644 src/components/ui/page-header.tsx create mode 100644 src/components/ui/pagination.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/quantity.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/status-mark.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/ui/text-field.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 tests/accessible-table-alignment.dom.test.tsx create mode 100644 tests/ckb-v2-token-contract.test.ts create mode 100644 tests/source-badges-off-vocab.dom.test.tsx create mode 100644 tests/source-metadata-browser-safety.test.ts create mode 100644 tests/ui-v2-components.dom.test.tsx diff --git a/.design-sync/NOTES.md b/.design-sync/NOTES.md index cf6a5da3a1..985e9107cf 100644 --- a/.design-sync/NOTES.md +++ b/.design-sync/NOTES.md @@ -119,3 +119,59 @@ both before calling it a defect. NOT component source. A component-source or token change surfaces instead as render churn (`canary`/`[SPOT_CHECK]`) plus `styling: true`. Grade the spot-check sheets — the churn is real even though nothing is listed "changed". + +## v2 design-system pass (2026-07-31) + +Scope was explicitly **design system only, no site-wide changes**, so the app +surfaces (ClinicalDashboard, DocumentViewer, mode homes) were not touched. + +What landed: + +- **Browser crash (P1).** `source-metadata.ts` no longer imports the server + logger. It reached the browser through the source badges, and its unguarded + `process.env.LOG_LEVEL` read threw a ReferenceError there, so any + off-vocabulary metadata value unmounted the whole React tree instead of + falling back. The trace now goes through `sourceMetadataDiagnostics.warn` + (`console.warn`, spy-able the way `logger.warn` was), and `logger.ts` reads + the environment defensively as defence in depth. Regressions: + `tests/source-metadata-browser-safety.test.ts`, + `tests/source-badges-off-vocab.dom.test.tsx`. + The `ds-safety-shim.js` in the design project (`window.process = { env: {} }`) + can be deleted once the next bundle ships. +- **`.ckb-v2` token layer** — `src/app/ckb-v2-tokens.css`, imported from + `globals.css`. Values verbatim from the design project's `ckb-v2-tokens.css`, + but **everything is class-scoped**, including the structural half that the + source file puts on `:root`. That deviation is deliberate: on `:root` it would + repaint the live app, which was out of scope. Promoting the structural tokens + to `:root` is a separate change and needs its own visual-regression pass. +- **Twelve new components** (`src/components/ui/`): `Button`, `TextField`, + `SearchField`, `Chip`, `ToastProvider`/`ToastRegion`/`useToast`, `Tabs`, + `Tooltip`, `Pagination`, `ConfirmDialog`, `PageHeader`, `Breadcrumb`, + and the answer surface trio `AnswerCard` / `DoseLine` / `AnswerFooter`. + Components referencing v2-only tokens carry v1 fallbacks + (`var(--pad-panel,1.5rem)`) so they render with or without the class. +- **Four orphans documented** — `AsyncButton`, `IconButton`, `Skeleton`, + `SourceDesignationBadge` now have previews and config entries instead of + shipping undocumented. +- **`AccessibleTable`** — per-column alignment (`columnAlign` / `numericColumns`, + auto-detecting numeric columns by default), `aria-controls` on the expander, + sticky header in the expanded view, and a real warning treatment for an + unverified extraction instead of a muted grey line. +- **`Sheet`** gained an optional `id` so an opener can advertise `aria-controls`. +- **`EmptyState`** accepts `description` as a deprecated alias for `body`; + passing `PanelHeading`'s prop name used to render nothing, silently. + +Component count went 10 → 28, so the next sync writes a much larger bundle. + +### Still design-app-side, not fixable here + +- The generated half of the published `README.md` lists a `tokens/*.css` folder + that this DS does not ship — and contradicts itself two lines later ("this DS + ships one compiled stylesheet rather than separate token files"). That text + comes from the converter, not from `conventions.md`, so it needs a fix in + `resync.mjs` / the design app, not in this repo. +- The stale `_ds_manifest.json` (236 tokens indexed vs 341 declared, `themes: []`, + `--tw-*` runtime vars published as _spacing_, 126 Tailwind utility classes read + as theme scopes) is likewise classifier-side. Re-running the sync regenerates + it from the compiled CSS; the misclassification rules themselves are not in + this repo. diff --git a/.design-sync/config.json b/.design-sync/config.json index a08d135e9d..86cdee4e77 100644 --- a/.design-sync/config.json +++ b/.design-sync/config.json @@ -17,22 +17,63 @@ "docs/redesign/permanent-colour-direction.md" ], "dtsPropsFor": { + "Button": "variant?: \"primary\" | \"secondary\" | \"toolbar\" | \"ghost\" | \"danger\"; size?: \"sm\" | \"md\" | \"lg\"; children: React.ReactNode; icon?: LucideIcon; trailingIcon?: LucideIcon; block?: boolean; busy?: boolean; busyLabel?: string; disabled?: boolean; className?: string; type?: \"button\" | \"submit\" | \"reset\"; onClick?: () => void;", + "TextField": "label: string; hint?: React.ReactNode; error?: React.ReactNode; hideLabel?: boolean; icon?: LucideIcon; fieldClassName?: string; className?: string; placeholder?: string; value?: string; defaultValue?: string; disabled?: boolean; onChange?: (event: React.ChangeEvent) => void;", + "SearchField": "label: string; hint?: React.ReactNode; error?: React.ReactNode; hideLabel?: boolean; onClear?: () => void; clearLabel?: string; fieldClassName?: string; className?: string; placeholder?: string; value?: string; onChange?: (event: React.ChangeEvent) => void;", + "Chip": "children: React.ReactNode; tone?: \"neutral\" | \"info\" | \"success\" | \"warning\" | \"danger\"; dot?: boolean; icon?: LucideIcon; onRemove?: () => void; removeLabel?: string; className?: string;", + "ToastRegion": "", + "Tabs": "items: Array<{ id: string; label: string; icon?: LucideIcon; count?: number; disabled?: boolean }>; value: string; onChange: (id: string) => void; label: string; variant?: \"tabs\" | \"segmented\"; className?: string; children?: React.ReactNode;", + "Tooltip": "children: React.ReactElement; content: React.ReactNode; placement?: \"top\" | \"bottom\"; className?: string;", + "Pagination": "page: number; pageCount: number; onPageChange: (page: number) => void; label?: string; summary?: string; className?: string;", + "ConfirmDialog": "open: boolean; onCancel: () => void; onConfirm: () => void; title: string; description: React.ReactNode; confirmLabel?: string; cancelLabel?: string; tone?: \"danger\" | \"primary\"; busy?: boolean; busyLabel?: string; confirmPhrase?: string; confirmPhraseLabel?: React.ReactNode;", + "PageHeader": "title: string; eyebrow?: string; description?: React.ReactNode; icon?: LucideIcon; breadcrumb?: Array<{ label: string; href?: string }>; actions?: React.ReactNode; meta?: React.ReactNode; className?: string;", + "Breadcrumb": "items: Array<{ label: string; href?: string }>; className?: string;", + "AnswerCard": "children: React.ReactNode; header?: React.ReactNode; footer?: React.ReactNode; className?: string;", + "DoseLine": "rows: Array<{ drug: string; qualifier?: string; value: string; unit?: string; overdue?: boolean }>; caption?: string; className?: string;", + "AnswerFooter": "publisher?: string | null; version?: string | null; reviewDate?: string | null; generatedAt?: string | null; className?: string;", "InlineNotice": "tone: \"success\" | \"info\" | \"warning\" | \"danger\" | \"neutral\"; children?: React.ReactNode; onDismiss?: () => void; dismissLabel?: string; animated?: boolean; className?: string;", "ToggleSwitch": "enabled: boolean; onToggle?: () => void; disabled?: boolean; className?: string; \"aria-label\"?: string;", + "AsyncButton": "busy: boolean; busyLabel: string; children: React.ReactNode; idleIcon?: React.ReactNode; disabled?: boolean; className?: string; type?: \"button\" | \"submit\" | \"reset\"; onClick?: () => void;", + "IconButton": "label: string; icon: LucideIcon; className?: string; iconClassName?: string; disabled?: boolean; type?: \"button\" | \"submit\" | \"reset\"; onClick?: () => void;", + "Skeleton": "className?: string; animationDelay?: string;", "SourceStatusBadge": "metadata?: { document_status?: \"current\" | \"review_due\" | \"outdated\" | \"unknown\" }; className?: string; showTitle?: boolean;", + "SourceDesignationBadge": "metadata?: { publisher?: string; publisher_code?: string; jurisdiction?: string; source_kind?: \"document\" | \"registry_record\" }; className?: string;", "SourceProvenance": "metadata?: { clinical_validation_status?: \"unverified\" | \"locally_reviewed\" | \"approved\"; review_date?: string; jurisdiction?: string; extraction_quality?: \"good\" | \"partial\" | \"poor\" | \"unknown\" };", "PanelHeading": "icon?: React.ComponentType<{ className?: string }>; title: string; description?: string;", "LoadingPanel": "label: string; variant?: \"spinner\" | \"skeleton\"; lines?: number;", - "EmptyState": "icon?: React.ComponentType<{ className?: string }>; title: string; body: string;", + "EmptyState": "icon?: React.ComponentType<{ className?: string }>; title: string; body?: string; /** @deprecated alias for body */ description?: string; actions?: React.ReactNode; live?: \"polite\" | \"assertive\"; tone?: \"neutral\" | \"info\" | \"danger\";", "Sheet": "open: boolean; onClose: () => void; title?: string; description?: string; children?: React.ReactNode; footer?: React.ReactNode; closeLabel?: string; headerActions?: React.ReactNode; placement?: \"default\" | \"left\"; mobilePlacement?: \"bottom\" | \"top\" | \"fullscreen\"; mobileSize?: \"content\" | \"viewport\"; portal?: boolean; contentClassName?: string; bodyClassName?: string;", "SafeBoldText": "text: string;", - "AccessibleTable": "caption?: string | null; markdown?: string | null; rows?: string[][] | null; columns?: string[] | null; compact?: boolean; expandOnMobile?: boolean; previewRows?: number; hidePreviewCaption?: boolean; hidePreviewRowCount?: boolean; densePreview?: boolean; dialogTitle?: string | null; clinicalOnly?: boolean; rowActions?: Array; actionsHeader?: string; lowConfidenceFallback?: React.ReactNode;" + "AccessibleTable": "caption?: string | null; markdown?: string | null; rows?: string[][] | null; columns?: string[] | null; compact?: boolean; expandOnMobile?: boolean; previewRows?: number; hidePreviewCaption?: boolean; hidePreviewRowCount?: boolean; densePreview?: boolean; dialogTitle?: string | null; clinicalOnly?: boolean; rowActions?: Array; actionsHeader?: string; lowConfidenceFallback?: React.ReactNode; columnAlign?: Array<\"start\" | \"end\" | \"auto\">; numericColumns?: number[];" }, "overrides": { "Sheet": { "cardMode": "single", "primaryStory": "OpenDialog", "viewport": "480x640" }, - "AccessibleTable": { "cardMode": "column" } + "AccessibleTable": { "cardMode": "column" }, + "ConfirmDialog": { "cardMode": "single", "primaryStory": "Destructive", "viewport": "480x640" }, + "ToastRegion": { "cardMode": "single", "primaryStory": "Interactive", "viewport": "480x360" }, + "AnswerCard": { "cardMode": "column" }, + "DoseLine": { "cardMode": "column" }, + "PageHeader": { "cardMode": "column" } }, "componentSrcMap": { + "Button": "src/components/ui/button.tsx", + "TextField": "src/components/ui/text-field.tsx", + "SearchField": "src/components/ui/text-field.tsx", + "Chip": "src/components/ui/chip.tsx", + "ToastRegion": "src/components/ui/toast.tsx", + "Tabs": "src/components/ui/tabs.tsx", + "Tooltip": "src/components/ui/tooltip.tsx", + "Pagination": "src/components/ui/pagination.tsx", + "ConfirmDialog": "src/components/ui/confirm-dialog.tsx", + "PageHeader": "src/components/ui/page-header.tsx", + "Breadcrumb": "src/components/ui/page-header.tsx", + "AnswerCard": "src/components/ui/answer-card.tsx", + "DoseLine": "src/components/ui/answer-card.tsx", + "AnswerFooter": "src/components/ui/answer-card.tsx", + "AsyncButton": "src/components/ui-primitives.tsx", + "IconButton": "src/components/ui-primitives.tsx", + "Skeleton": "src/components/ui-primitives.tsx", + "SourceDesignationBadge": "src/components/ui-primitives.tsx", "InlineNotice": "src/components/ui-primitives.tsx", "ToggleSwitch": "src/components/ui-primitives.tsx", "SourceStatusBadge": "src/components/ui-primitives.tsx", diff --git a/.design-sync/conventions.md b/.design-sync/conventions.md index 136f9e7542..f8eae42eb6 100644 --- a/.design-sync/conventions.md +++ b/.design-sync/conventions.md @@ -33,10 +33,40 @@ arbitrary-value form — never hardcoded colours: focus owner by design; a second ring both stacks a halo and wipes the control's resting elevation. Radius rules: `rounded-md` chips/pills, `rounded-lg` controls/cards/panels, -`rounded-xl` sheets/dialogs. Tap targets: `min-h-tap` / `h-tap w-tap` (44px). +`rounded-xl` sheets/dialogs. Tap targets: `min-h-tap` / `h-tap w-tap` (44px) — +interactive controls only. A static chip is 28px text, not a touch target; +putting `min-h-tap` on one is why dense tables scrolled so much. Dark mode is automatic via the `.dark` class — the variables flip; never write `dark:` colour overrides yourself. +## Colour boundaries + +Three layers, and they do not borrow from each other: + +1. **Clinical state** — `success` / `warning` / `danger`. Reserved for currency, + validation and safety. Never decorative. Amber and red appear only in + `SourceStatusBadge`, `InlineNotice`, `ConfirmDialog`, the extraction-quality + row, and a `DoseLine` row whose cited source is overdue. +2. **Information** — `info` plus the accent. Neutral emphasis, not a verdict. +3. **Identity** — the muted `--type-*` hues that tell record kinds apart. + +`--command` is the one filled action colour, and a surface carries at most one +filled `--command` button. `--danger-solid` has exactly one home: the `danger` +variant of `Button`, i.e. a destructive confirmation. Importance is `primary`. + +`--text-soft` is around 3.2:1 on white — decoration only (dots, dividers, +glyphs). Label and caption **text** uses `--text-muted`. + +## Opt-in v2 token layer + +`.ckb-v2` is an opt-in class that swaps in the v2 shell: white surfaces, a blue +`--command`, a crisper `--e1`…`--e4` ladder, a 7-step type scale with per-step +line-height and tracking, semantic spacing (`--gap-*`, `--pad-*`), density +(`--tap-min`, `--chip-height`, `--row-*`), icon sizes (`--icon-*`) and motion +durations. Add `ckb-v2` (plus `dark` for the dark ramp) to a subtree to adopt it; +without the class nothing changes. Components that reference v2-only tokens carry +a v1 fallback (`var(--pad-panel,1.5rem)`) so they render correctly either way. + ## Class-string vocabulary (exported constants) The bundle exports ready-made class strings — compose them instead of @@ -52,6 +82,13 @@ chat/search composer and tone recipes documented in `docs/redesign/09-ui-primitives-recipes.md`. Join with the exported `cn(...)` helper. +Prefer a component over a recipe where one now exists. `Button` supersedes +hand-composing `primaryControl` / `floatingControl` / `toolbarButton` on a raw +` + + + + + +); + +export const Sizes = () => ( +
+ + + +
+); + +export const Busy = () => ( + +); + +export const Disabled = () => ( +
+ + +
+); + +export const Block = () => ( +
+ +
+); diff --git a/.design-sync/previews/Chip.tsx b/.design-sync/previews/Chip.tsx new file mode 100644 index 0000000000..e55fe9ad3e --- /dev/null +++ b/.design-sync/previews/Chip.tsx @@ -0,0 +1,37 @@ +import { Chip } from "prompt-for-codex-medical-knowledge-base"; +import { Filter } from "lucide-react"; + +export const Tones = () => ( +
+ Neutral + Registry summary + Current + Review due + Outdated +
+); + +export const WithDot = () => ( +
+ + Indexed + + + Partial extraction + + + Not started + +
+); + +export const Removable = () => ( +
+ {}} removeLabel="Remove filter: WA jurisdiction"> + WA + + {}} removeLabel="Remove filter: current sources only"> + Current only + +
+); diff --git a/.design-sync/previews/ConfirmDialog.tsx b/.design-sync/previews/ConfirmDialog.tsx new file mode 100644 index 0000000000..e162efff25 --- /dev/null +++ b/.design-sync/previews/ConfirmDialog.tsx @@ -0,0 +1,55 @@ +import { ConfirmDialog } from "prompt-for-codex-medical-knowledge-base"; + +// ConfirmDialog renders through Sheet (position: fixed), so each story is wrapped +// in an explicitly-sized, transformed container to keep the overlay inside the card. +function Stage({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export const Destructive = () => ( + + {}} + onConfirm={() => {}} + title="Delete this source?" + description="The document, its extracted pages and every citation pointing at it are removed. This cannot be undone." + confirmLabel="Delete source" + /> + +); + +export const TypedConfirmation = () => ( + + {}} + onConfirm={() => {}} + title="Retire approved guideline?" + description="Retiring removes this guideline from every future answer. Existing answers keep their citation." + confirmLabel="Retire guideline" + confirmPhrase="RETIRE" + /> + +); + +export const NonDestructive = () => ( + + {}} + onConfirm={() => {}} + title="Reindex this source?" + description="Reindexing re-runs extraction and embedding. The current index stays live until the new one commits." + confirmLabel="Reindex" + /> + +); diff --git a/.design-sync/previews/DoseLine.tsx b/.design-sync/previews/DoseLine.tsx new file mode 100644 index 0000000000..004d92b895 --- /dev/null +++ b/.design-sync/previews/DoseLine.tsx @@ -0,0 +1,40 @@ +import { DoseLine } from "prompt-for-codex-medical-knowledge-base"; + +// Numerals stack because the dose column is right-aligned and fixed; the unit is +// sans at the label step and never uppercased (g is not G, mg is not MG). +export const Ledger = () => ( +
+ +
+); + +export const WithOverdueSource = () => ( +
+ +
+); + +export const Ranges = () => ( +
+ +
+); diff --git a/.design-sync/previews/IconButton.tsx b/.design-sync/previews/IconButton.tsx new file mode 100644 index 0000000000..8565d8c46a --- /dev/null +++ b/.design-sync/previews/IconButton.tsx @@ -0,0 +1,16 @@ +import { IconButton, toolbarButton, floatingControl } from "prompt-for-codex-medical-knowledge-base"; +import { Copy, Download, Trash2, X } from "lucide-react"; + +// The accessible name is a required prop, so an unlabelled icon button cannot be +// built at all. The base is colour-neutral — chrome comes from a recipe. +export const Toolbar = () => ( +
+ + + +
+); + +export const Floating = () => ; + +export const Disabled = () => ; diff --git a/.design-sync/previews/PageHeader.tsx b/.design-sync/previews/PageHeader.tsx new file mode 100644 index 0000000000..8dd7dfbdf0 --- /dev/null +++ b/.design-sync/previews/PageHeader.tsx @@ -0,0 +1,31 @@ +import { PageHeader, Button, Chip } from "prompt-for-codex-medical-knowledge-base"; +import { FileText, Plus } from "lucide-react"; + +export const Full = () => ( +
+ + Add note + + } + meta={ + <> + Current + WA + + } + /> +
+); + +export const TitleOnly = () => ( +
+ +
+); diff --git a/.design-sync/previews/Pagination.tsx b/.design-sync/previews/Pagination.tsx new file mode 100644 index 0000000000..785947bda2 --- /dev/null +++ b/.design-sync/previews/Pagination.tsx @@ -0,0 +1,19 @@ +import { Pagination } from "prompt-for-codex-medical-knowledge-base"; + +export const Short = () => ( +
+ {}} summary="21–40 of 78 sources" /> +
+); + +export const Truncated = () => ( +
+ {}} summary="161–180 of 831 sources" /> +
+); + +export const FirstPage = () => ( +
+ {}} /> +
+); diff --git a/.design-sync/previews/SearchField.tsx b/.design-sync/previews/SearchField.tsx new file mode 100644 index 0000000000..52d57e09ff --- /dev/null +++ b/.design-sync/previews/SearchField.tsx @@ -0,0 +1,19 @@ +import { SearchField } from "prompt-for-codex-medical-knowledge-base"; + +export const Empty = () => ( +
+ +
+); + +export const WithClear = () => ( +
+ {}} /> +
+); + +export const VisibleLabel = () => ( +
+ +
+); diff --git a/.design-sync/previews/Skeleton.tsx b/.design-sync/previews/Skeleton.tsx new file mode 100644 index 0000000000..a47c78cde1 --- /dev/null +++ b/.design-sync/previews/Skeleton.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from "prompt-for-codex-medical-knowledge-base"; + +// Skeleton is a decorative placeholder only: it carries no role and no label. +// The announcement belongs to the surface that owns the load (see LoadingPanel), +// which is why every instance here is aria-hidden. +export const TextLines = () => ( +
+ + + +
+); + +export const SourceCard = () => ( +
+ + + +
+); + +export const Block = () => ; diff --git a/.design-sync/previews/SourceDesignationBadge.tsx b/.design-sync/previews/SourceDesignationBadge.tsx new file mode 100644 index 0000000000..74244c18fb --- /dev/null +++ b/.design-sync/previews/SourceDesignationBadge.tsx @@ -0,0 +1,21 @@ +import { SourceDesignationBadge } from "prompt-for-codex-medical-knowledge-base"; + +// Designation is derived from the source-authority registry, not passed in: it +// answers "who issued this", which is a different axis from currency +// (SourceStatusBadge) and local approval (SourceProvenance). None of the three +// implies the others — Official does not mean current or locally approved. +export const Official = () => ( + +); + +export const Trusted = () => ( + +); + +export const Unclassified = () => ; + +// An off-vocabulary or unrecognised publisher degrades to Unclassified rather +// than guessing — and, since the browser-safety fix, without unmounting. +export const OffVocabulary = () => ( + +); diff --git a/.design-sync/previews/Tabs.tsx b/.design-sync/previews/Tabs.tsx new file mode 100644 index 0000000000..f14fedcada --- /dev/null +++ b/.design-sync/previews/Tabs.tsx @@ -0,0 +1,29 @@ +import { Tabs } from "prompt-for-codex-medical-knowledge-base"; +import { BookOpen, FileText, Stethoscope } from "lucide-react"; + +const items = [ + { id: "answer", label: "Answer", icon: Stethoscope }, + { id: "sources", label: "Sources", icon: FileText, count: 6 }, + { id: "guidance", label: "Guidance", icon: BookOpen }, + { id: "audit", label: "Audit", disabled: true }, +]; + +export const Underlined = () => ( +
+ {}} /> +
+); + +export const Segmented = () => ( +
+ {}} /> +
+); + +export const WithPanel = () => ( +
+ {}}> +

Six sources support this answer.

+
+
+); diff --git a/.design-sync/previews/TextField.tsx b/.design-sync/previews/TextField.tsx new file mode 100644 index 0000000000..dba3f91380 --- /dev/null +++ b/.design-sync/previews/TextField.tsx @@ -0,0 +1,30 @@ +import { TextField } from "prompt-for-codex-medical-knowledge-base"; +import { Calendar } from "lucide-react"; + +export const WithHint = () => ( +
+ +
+); + +export const WithIcon = () => ( +
+ +
+); + +export const Invalid = () => ( +
+ +
+); + +export const Disabled = () => ( +
+ +
+); diff --git a/.design-sync/previews/ToastRegion.tsx b/.design-sync/previews/ToastRegion.tsx new file mode 100644 index 0000000000..81ac163c1a --- /dev/null +++ b/.design-sync/previews/ToastRegion.tsx @@ -0,0 +1,45 @@ +import { ToastProvider, ToastRegion, useToast, Button } from "prompt-for-codex-medical-knowledge-base"; + +// The region is fixed to the viewport, so stories stage it inside a sized, +// transformed container the same way Sheet does. +function Stage({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Trigger() { + const { push } = useToast(); + return ( + + ); +} + +export const Interactive = () => ( + + + + + +); + +export const Tones = () => ( + +
+ +
+

+ Outcomes announce through role=status / aria-live=polite: important, but never interrupting what is being read. +

+
+); diff --git a/.design-sync/previews/Tooltip.tsx b/.design-sync/previews/Tooltip.tsx new file mode 100644 index 0000000000..c5d4ba7e5e --- /dev/null +++ b/.design-sync/previews/Tooltip.tsx @@ -0,0 +1,26 @@ +import { Tooltip, IconButton, toolbarButton } from "prompt-for-codex-medical-knowledge-base"; +import { Info } from "lucide-react"; + +// The tooltip supplements a control's accessible name via aria-describedby; it +// never replaces it. Opens on hover AND keyboard focus. +export const OnIconButton = () => ( + + + +); + +export const OnText = () => ( + + + +); + +export const Below = () => ( + + + +); diff --git a/docs/clinical-badge-system-guide.md b/docs/clinical-badge-system-guide.md index 52c7d86a0f..fa122985f7 100644 --- a/docs/clinical-badge-system-guide.md +++ b/docs/clinical-badge-system-guide.md @@ -45,6 +45,44 @@ Use these terms consistently. Static badges must not look clickable. Interactive chips must use proper button or link semantics. +### One Status System — Which Component For Which Job + +The four status affordances are not interchangeable. Pick by what the mark answers, +not by how much room is left. + +| Affordance | Component | Answers | Use when | +| ------------- | ------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Badge | `SourceStatusBadge`, `SourceDesignationBadge` | "Is this current?" / "Who issued this?" | The state is a clinical-governance fact about the source and must be readable at a glance | +| Dot | `statusDotReady` / `statusDotReview` / `statusDotMuted` | "Is this healthy?" | A dense row or list where the label already carries the words; **never** the sole carrier of meaning | +| Pill | `metadataPill`, `subtleStatusPill` | "What is this value?" | Neutral metadata (page count, version, jurisdiction) with no governance verdict | +| Identity chip | `Chip` with a `--type-*` hue | "What kind of record is this?" | Distinguishing record kinds (document, service, form, differential) — identity, not state | + +A dot plus its own visible label is a dot. A dot alone is a colour-only signal and +fails both forced-colors and fast scanning. + +### Enum Vocabularies + +These three fields are closed vocabularies, normalized in `src/lib/source-metadata.ts`: + +| Field | Values | Fallback | +| ---------------------------- | ------------------------------------------------- | ------------ | +| `document_status` | `current` · `review_due` · `outdated` · `unknown` | `unknown` | +| `clinical_validation_status` | `unverified` · `locally_reviewed` · `approved` | `unverified` | +| `extraction_quality` | `good` · `partial` · `poor` · `unknown` | `unknown` | + +**Off-vocabulary values degrade to the neutral triad.** A value that is present but +unrecognised — a typo, a renamed enum, a malformed ingest — coerces to the fallback +above and is traced once, with its field and value, through +`sourceMetadataDiagnostics.warn`. It must never be rendered raw, guessed at, or +allowed to throw: an unknown status is a governance signal, and a crash is not. + +Note the field name: it is `clinical_validation_status`, **not** `validation_status`. +The wrong key silently normalizes to "Not locally validated" with no error. + +The three axes are independent. Official does not imply current; current does not +imply locally approved; approved does not imply a good extraction. Never collapse +two of them into one mark. + ## Master Palette Use six top-level tones only. Do not add more badge colours. diff --git a/scripts/design-system-contract-utils.mjs b/scripts/design-system-contract-utils.mjs index 084c6ba988..0c4f642ab0 100644 --- a/scripts/design-system-contract-utils.mjs +++ b/scripts/design-system-contract-utils.mjs @@ -6,7 +6,11 @@ export const LEGACY_TAP_CLASS = new RegExp(`(?:^|[\\s\"'\\x60])${LEGACY_TAP_TOKE const LEGACY_TAP_CLASS_TEST = new RegExp(`(?:^|[\\s\"'\\x60])${LEGACY_TAP_TOKEN_SOURCE}(?=[\\s\"'\\x60]|$)`); export const RAW_COLOR_EXEMPTIONS = [ - { category: "global theme tokens", pattern: /^src\/app\/globals\.css$/, scope: "whole-file" }, + // Both files are the theme-token layer itself — the one place raw colour values + // are *defined* rather than consumed. `ckb-v2-tokens.css` is the opt-in `.ckb-v2` + // layer, split out of globals.css only for readability; it declares no rules + // beyond custom properties. + { category: "global theme tokens", pattern: /^src\/app\/(?:globals|ckb-v2-tokens)\.css$/, scope: "whole-file" }, { category: "brand artwork", pattern: diff --git a/src/app/ckb-v2-tokens.css b/src/app/ckb-v2-tokens.css new file mode 100644 index 0000000000..2713d692e9 --- /dev/null +++ b/src/app/ckb-v2-tokens.css @@ -0,0 +1,252 @@ +/* Clinical KB v2 token layer — OPT-IN, design-system scope only. + * + * Source of truth: the design project's `ckb-v2-tokens.css` (values verbatim). + * It resolves register findings 3-5, 7-8, 10, 12, 14-21, 23-24, 34-36, 39-46. + * + * DELIBERATE DEVIATION from the source file: it puts the structural half + * (space, type, radius, elevation, density, icons, motion) on `:root` and only + * theme-scopes the shell colours. Everything is class-scoped here instead, so + * adopting the layer cannot repaint the live app - this pass is design-system + * only. Opt in per subtree with `class="ckb-v2"` (plus `dark` for the dark + * ramp); promoting the structural half to `:root` is a separate, app-wide + * change that needs its own visual-regression pass. + * + * Rules the values encode, which call sites must honour: + * - One filled `--command` button per surface. Navy is app chrome only. + * - `--text-soft` is ~3.2:1 on white - decoration only (dots, dividers, + * glyphs). Label and caption TEXT uses `--text-muted` (~6.2:1). + * - Borders own the edge, shadows own the lift - never both on one element. + * Borderless floating surfaces (menus, toasts, tooltips, sheets) take + * `--ring-hairline` for their edge. + * - No raw pixels in component markup: use the semantic space tokens. + */ +.ckb-v2 { + /* Space - 4px base, one scale */ + --space-0: 0; + --space-1: 0.25rem; /* 4 */ + --space-2: 0.5rem; /* 8 */ + --space-3: 0.75rem; /* 12 */ + --space-4: 1rem; /* 16 */ + --space-5: 1.25rem; /* 20 */ + --space-6: 1.5rem; /* 24 */ + --space-7: 2rem; /* 32 */ + --space-8: 2.5rem; /* 40 */ + --space-9: 3rem; /* 48 */ + --space-10: 4rem; /* 64 */ + --space-11: 6rem; /* 96 */ + + /* Semantic spacing - the only values layout should reference */ + --gap-tight: var(--space-2); /* icon/label, chip rows */ + --gap-inline: var(--space-3); /* sibling controls */ + --gap-stack: var(--space-4); /* stacked items in a group */ + --gap-block: var(--space-6); /* between blocks inside a panel */ + --gap-section: var(--space-7); /* between page sections */ + --pad-chip-x: var(--space-3); + --pad-control-x: var(--space-4); + --pad-cta-x: var(--space-5); + --pad-card: var(--space-5); /* source cards, dose rows */ + --pad-panel: var(--space-6); /* answer card, panels */ + --pad-strip: var(--space-3) var(--space-6); /* footers, quiet strips */ + --page-gutter: var(--space-6); + --page-max: 1080px; + --measure: 68ch; /* prose measure - never wider */ + --header-h: 4rem; /* 64 */ + + /* Type - 7 steps, each with its own line-height and tracking */ + --text-xs: 0.75rem; + --text-xs-lh: 1rem; + --text-xs-tr: 0; + --text-sm: 0.8125rem; + --text-sm-lh: 1.125rem; + --text-sm-tr: 0; + --text-body: 0.9375rem; + --text-body-lh: 1.375rem; + --text-body-tr: -0.005em; + --text-md: 1.0625rem; + --text-md-lh: 1.75rem; + --text-md-tr: -0.008em; + --text-lg: 1.25rem; + --text-lg-lh: 1.625rem; + --text-lg-tr: -0.012em; + --text-xl: 1.5rem; + --text-xl-lh: 1.875rem; + --text-xl-tr: -0.018em; + --text-hero: clamp(1.75rem, 1.2rem + 2vw, 2.25rem); + --text-hero--line-height: 1.12; + --text-hero-tr: -0.022em; + + --leading-prose: 1.65; + --tracking-eyebrow: 0.08em; + --nums: tabular-nums; /* font-variant-numeric: var(--nums) on all data */ + + /* Weights - one job each (Geist is variable, so 650 is real) */ + --font-weight-body: 400; + --font-weight-label: 500; + --font-weight-heading: 600; + --font-weight-value: 650; + + /* Radius - one step per surface role */ + --radius-sm: 0.375rem; /* 6 - chips, pills, dots */ + --radius-md: 0.625rem; /* 10 - controls, inputs, buttons */ + --radius-lg: 0.75rem; /* 12 - cards, table cards, popovers */ + --radius-xl: 1rem; /* 16 - panels, answer card */ + --radius-2xl: 1.25rem; /* 20 - sheets, dialogs */ + + /* Icons - four sizes, paired with the type steps */ + --icon-xs: 0.75rem; /* 12 - inside chips */ + --icon-sm: 0.875rem; /* 14 - inline with 13-15px text */ + --icon-md: 1rem; /* 16 - controls */ + --icon-lg: 1.25rem; /* 20 - empty states, tiles */ + + /* Density - tap target is not row height */ + --tap-min: 2.75rem; /* 44 - interactive only */ + --chip-height: 1.75rem; /* 28 - static chips */ + --row-comfortable: 2.75rem; + --row-compact: 2.25rem; + --cell-pad-comfortable: var(--space-3); + --cell-pad-compact: var(--space-2); + + /* Accent rules */ + --rule-w: 3px; + --rule-accent: inset var(--rule-w) 0 0 var(--clinical-accent); + --rule-warning: inset var(--rule-w) 0 0 var(--warning); + + /* Evidence gutter - one fixed column owning both the connector line and the + dot, centred on the same axis. The bleed is derived from the row padding so + a density switch keeps the line continuous. */ + --gutter-col: 1.25rem; /* 20 - column width */ + --gutter-dot: 0.5rem; /* 8 - dot diameter, identical for every state */ + --gutter-line-w: 1px; + + /* Quantity - the unit is demoted, never uppercased. Slight positive tracking + because a 13px sans unit beside a mono numeral otherwise reads cramped. */ + --quantity-unit-tracking: 0.01em; + --quantity-unit-gap: 0.15em; + + /* Dashed edge - drop targets and "nothing here yet" boundaries. A distinct + token so a dropzone stops borrowing --border-strong, which means emphasis. */ + --border-dashed: #c3cedd; + + /* Stacking rungs. The prose ladder in globals.css was unenforceable and had + drifted into two notations (`z-80` and `z-[80]`) for one rung. Elevation and + stacking are the same decision, so each rung names its --eN partner. */ + --z-base: 0; /* flush, in-flow */ + --z-raised: 10; /* sticky headers, badges, count bubbles - pairs with --e1 */ + --z-chrome: 60; /* app chrome, mode menus - pairs with --e2 */ + --z-overlay: 80; /* viewer + table fullscreen - pairs with --e3 */ + --z-popover: 95; /* popovers that must beat an overlay - pairs with --e3 */ + --z-modal: 100; /* the Sheet layer, skip link - pairs with --e4 */ + --z-toast: 110; /* outcome announcements beat the modal - pairs with --e4 */ + + /* Motion */ + --duration-fast: 120ms; + --duration-base: 180ms; + --duration-slow: 260ms; + /* Two curves, not five. globals.css ships --ease-out-soft (28 uses), + --ease-spring (9), and three dead springs, one of which is byte-identical + to --ease-spring. Enter/exit uses the soft curve; only a control that should + feel physical (toggle thumb, sheet drag) uses the spring. */ + --ease-standard: cubic-bezier(0.22, 1, 0.36, 1); + --ease-physical: cubic-bezier(0.34, 1.3, 0.64, 1); +} + +@media (prefers-reduced-motion: reduce) { + .ckb-v2 { + --duration-fast: 0ms; + --duration-base: 0ms; + --duration-slow: 0ms; + } +} + +/* Crisp white light shell (#10, #24) + blue command colour (#1, #12) */ +.ckb-v2:not(.dark) { + --background: #ffffff; + --surface: #ffffff; + --surface-chrome: #ffffff; + --surface-raised: #ffffff; + --surface-lux: #ffffff; + + /* The only non-white surfaces - both whispers */ + --surface-subtle: #fbfcfd; /* table headers, zebra rows */ + --surface-wash: #f8fafc; /* footers, quiet strips */ + --surface-inset: #f4f7fa; /* recessed wells, inputs */ + --surface-highlight: rgb(255 255 255 / 70%); + --clinical-chat-table-header: #fbfcfd; + --clinical-chat-document: #f8fafc; + + --border: #e6ebf2; + --border-strong: #d3dbe5; + /* Solid, not 55% alpha: alpha computed LIGHTER than --border, so panels had + weaker edges than the cards nested inside them (#43). */ + --border-lux: #dde4ee; + + --text-heading: #0a1220; + --text: #1b2533; + --text-muted: #55627a; + /* 3.07:1 on white. Decoration only - dots, dividers, disabled glyphs, rules. + Never a text node. `--decoration-soft` is the name that should have shipped: + `--text-soft` reads like a text tier and has now been misused in three + separate places, including inside the document prohibiting the misuse. Both + names resolve to the same value during the deprecation window. */ + --text-soft: #8894a6; + --decoration-soft: #8894a6; + --disabled: #9aa5b5; + + --command: #1a66a8; + --command-hover: #185c99; + --command-active: #164a7a; + --command-contrast: #ffffff; + + --clinical-accent-soft: #f2f8fe; + --clinical-accent-border: #cfe2f6; + --primary-soft: #f2f8fe; + + --ring-hairline: 0 0 0 1px rgb(13 40 71 / 7%); + --e0: none; + --e1: 0 1px 2px rgb(13 40 71 / 5%); + --e2: 0 1px 2px rgb(13 40 71 / 4%), 0 8px 16px -8px rgb(13 40 71 / 9%); + --e3: 0 2px 4px rgb(13 40 71 / 4%), 0 16px 28px -12px rgb(13 40 71 / 11%); + --e4: 0 4px 8px rgb(13 40 71 / 5%), 0 32px 56px -20px rgb(13 40 71 / 15%); + /* A true inset, not a 1px ring - the ring is why inputs looked outlined twice (#40). */ + --shadow-inset: inset 0 1px 2px rgb(13 40 71 / 4%); + --glow-primary: 0 0 0 1px var(--clinical-accent), 0 8px 20px -6px rgb(29 111 184 / 22%); + --glow-soft: 0 0 0 1px rgb(29 111 184 / 24%), 0 4px 12px -4px rgb(29 111 184 / 14%); + + --overlay-backdrop: rgb(13 40 71 / 32%); +} + +/* Dark ramp (#8, #19): four surfaces >=4 L* apart, lighter as they rise */ +.ckb-v2.dark { + --background: #0b0e11; + --surface: #12161a; + --surface-chrome: #0e1216; + --surface-subtle: #1c2126; /* aliased UP - "subtle" must lift, not sink */ + --surface-wash: #161a1e; + --surface-raised: #1c2126; + --surface-inset: #0a0c0e; + --surface-lux: #262c32; + --surface-highlight: rgb(255 255 255 / 6%); + --clinical-chat-table-header: #1c2126; + + --border: #333a41; + --border-strong: #47505a; + --border-lux: #3a424a; + + --text-muted: #a8b2bd; + --text-soft: #7d8792; + --decoration-soft: #7d8792; + --border-dashed: #454e57; + + --command: #3a80c0; + --command-hover: #4a91d2; + --command-active: #1d6fb8; + --command-contrast: #06121e; + + --ring-hairline: 0 0 0 1px rgb(255 255 255 / 9%); + --e0: none; + --e1: inset 0 1px 0 rgb(255 255 255 / 4%), 0 1px 2px rgb(0 0 0 / 40%); + --e2: inset 0 1px 0 rgb(255 255 255 / 5%), 0 8px 18px -8px rgb(0 0 0 / 48%); + --e3: inset 0 1px 0 rgb(255 255 255 / 6%), 0 16px 30px -12px rgb(0 0 0 / 56%); + --e4: inset 0 1px 0 rgb(255 255 255 / 7%), 0 32px 56px -20px rgb(0 0 0 / 64%); + --shadow-inset: inset 0 1px 2px rgb(0 0 0 / 40%); +} diff --git a/src/app/globals.css b/src/app/globals.css index 0cbdb5ddfb..50730e7f14 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,4 +1,7 @@ @import "tailwindcss"; +/* Opt-in v2 design-system token layer. Every rule is scoped to `.ckb-v2`, so + importing it cannot change any surface that does not carry the class. */ +@import "./ckb-v2-tokens.css"; @custom-variant dark (&:where(.dark, .dark *)); diff --git a/src/components/AccessibleTable.tsx b/src/components/AccessibleTable.tsx index e9fc35d045..c7818e9291 100644 --- a/src/components/AccessibleTable.tsx +++ b/src/components/AccessibleTable.tsx @@ -1,7 +1,7 @@ "use client"; -import { Maximize2 } from "lucide-react"; -import { type ReactNode, useCallback, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { Maximize2, TriangleAlert } from "lucide-react"; +import { type ReactNode, useCallback, useId, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { cn, textMuted } from "@/components/ui-primitives"; import { Sheet } from "@/components/ui/sheet"; import { normalizeAccessibleTable, type NormalizedAccessibleTable } from "@/lib/accessible-table-normalization"; @@ -44,6 +44,51 @@ function isMetadataHeader(value: string) { return metadataHeaderPattern.test(value.trim()); } +export type AccessibleTableColumnAlign = "start" | "end" | "auto"; + +// Register #48: dose columns were left-aligned as text, so `tabular-nums` had +// nothing to stack against — "12.5 mg" and "5 mg" still started at different +// optical positions. A numeric column is one whose every non-empty cell starts +// with a number (optionally signed/decimal, optionally followed by a unit, +// range, or comparator). The test is deliberately strict: a single prose cell +// disqualifies the column, so a "Notes" column that happens to open with a +// figure is never flipped to the right. +const numericCellPattern = /^[<>~≤≥±+-]?\s*\d[\d\s,.'’/×x*-]*(?:\s*[^\s\d]{0,12})?$/u; + +function isNumericCell(value: string) { + const trimmed = value.trim(); + if (!trimmed || trimmed === "-" || trimmed === "—") return false; + return numericCellPattern.test(trimmed); +} + +function detectNumericColumns(header: string[], body: string[][]): boolean[] { + return header.map((_, index) => { + const cells = body.map((row) => (row[index] ?? "").trim()).filter((cell) => cell && cell !== "-" && cell !== "—"); + // A single-cell column is not enough evidence to right-align a whole column. + if (cells.length < 2) return false; + return cells.every(isNumericCell); + }); +} + +function resolveColumnAlign( + header: string[], + body: string[][], + columnAlign: AccessibleTableColumnAlign[] | undefined, + numericColumns: number[] | undefined, +): boolean[] { + if (numericColumns) { + const explicit = new Set(numericColumns); + return header.map((_, index) => explicit.has(index)); + } + const detected = detectNumericColumns(header, body); + return header.map((_, index) => { + const requested = columnAlign?.[index]; + if (requested === "end") return true; + if (requested === "start") return false; + return detected[index]; + }); +} + function clinicalOnlyTable(table: NormalizedAccessibleTable) { const keptIndexes = table.header .map((header, index) => ({ header, index })) @@ -74,6 +119,7 @@ function AccessibleTableMarkup({ densePreview = false, rowActions, actionsHeader = "Actions", + alignEnd, }: { caption?: string | null; header: string[]; @@ -86,6 +132,7 @@ function AccessibleTableMarkup({ densePreview?: boolean; rowActions?: Array; actionsHeader?: string; + alignEnd?: boolean[]; }) { const defaultPreviewRows = compact ? 6 : 20; const visibleBody = expanded ? body : body.slice(0, previewRows ?? defaultPreviewRows); @@ -140,10 +187,16 @@ function AccessibleTableMarkup({ scope="col" className={cn( "nums border-b border-[color:var(--border)] align-top font-semibold leading-5 text-[color:var(--text)]", + // Sticky only in the expanded/full-screen view, where the table + // owns a scroll container tall enough to lose its header. + expanded && "sticky top-0 z-10 bg-[color:var(--surface-subtle)]", renderDensePreview ? "overflow-hidden text-ellipsis whitespace-nowrap" : "whitespace-normal break-words", index > 0 && "border-l border-[color:var(--border)]/70", + // A numeric column's header right-aligns with its values so the + // column reads as one block rather than a split label/value pair. + alignEnd?.[index] && "text-right", renderDensePreview ? "px-2 py-1.5 text-2xs uppercase tracking-[0.06em]" : expanded @@ -159,6 +212,7 @@ function AccessibleTableMarkup({ scope="col" className={cn( "nums border-b border-l border-[color:var(--border)]/70 align-top font-semibold leading-5 text-[color:var(--text)]", + expanded && "sticky top-0 z-10 bg-[color:var(--surface-subtle)]", renderDensePreview ? "overflow-hidden text-ellipsis whitespace-nowrap" : "whitespace-normal break-words", @@ -207,6 +261,10 @@ function AccessibleTableMarkup({ (renderDensePreview ? "border-l border-[color:var(--border)]/60" : "md:border-l md:border-[color:var(--border)]/60"), + // Stacked phone cards keep every value left-aligned under + // its own label; the right-align only applies once the + // cells actually form a column. + alignEnd?.[cellIndex] && (renderDensePreview ? "text-right" : "md:text-right"), !renderDensePreview && (expanded ? "md:px-4 md:py-3 md:leading-6" : "md:px-3 md:py-2 md:leading-5"), !renderDensePreview && cellIndex > 0 && "pt-2 md:pt-0", @@ -274,6 +332,33 @@ function AccessibleTableMarkup({ ); } +// An unverified extraction is a clinical-governance signal, not a caption: a +// muted grey line under a confident-looking grid reads as decoration. Give it the +// warning treatment the rest of the system uses for "do not trust this at face +// value" — warning rule, warning icon, and an explicit heading — so the caveat +// survives fast scanning. `role="status"` (not alert) because it is present from +// first paint rather than interrupting. +function UnverifiedExtractionNotice({ showingFallback }: { showingFallback: boolean }) { + return ( +
+ + + Unverified extraction.{" "} + {showingFallback + ? "Table structure could not be confidently reconstructed — showing the source document image instead." + : "Table structure could not be confidently reconstructed — verify values against the source document."} + +
+ ); +} + function useMobileTableExpansion(enabledByDefault: boolean) { const subscribe = useCallback( (callback: () => void) => { @@ -335,6 +420,8 @@ export function AccessibleTable({ rowActions, actionsHeader, lowConfidenceFallback, + columnAlign, + numericColumns, }: { caption?: string | null; markdown?: string | null; @@ -356,8 +443,15 @@ export function AccessibleTable({ // Callers that have the cropped source image (e.g. the visual-evidence cards) // can pass it here to show the real table screenshot instead of that grid. lowConfidenceFallback?: ReactNode; + // Per-column horizontal alignment. Default is "auto": columns whose every + // non-empty cell is numeric right-align so their digits stack, everything else + // stays left. Pass "start"/"end" to pin a column, or `numericColumns` to state + // the numeric indexes outright and skip detection entirely. + columnAlign?: AccessibleTableColumnAlign[]; + numericColumns?: number[]; }) { const restoreFocusRef = useRef(null); + const dialogId = useId(); const [open, setOpen] = useState(false); const canExpand = useMobileTableExpansion(expandOnMobile); const hasExplicitRows = Boolean(rows?.length); @@ -379,6 +473,11 @@ export function AccessibleTable({ return clinicalOnly ? clinicalOnlyTable(table) : table; }, [clinicalOnly, columns, hasExplicitRows, normalizedTable, parsed]); + const alignEnd = useMemo(() => { + if (!normalized) return undefined; + return resolveColumnAlign(normalized.header, normalized.body, columnAlign, numericColumns); + }, [normalized, columnAlign, numericColumns]); + const dialogOpen = open; if (!normalized) return null; @@ -390,13 +489,7 @@ export function AccessibleTable({ const showFallback = lowConfidence && Boolean(lowConfidenceFallback); const table = ( <> - {lowConfidence ? ( -

- {showFallback - ? "Table structure could not be confidently reconstructed — showing the source document image instead." - : "Table structure could not be confidently reconstructed — verify values against the source document."} -

- ) : null} + {lowConfidence ? : null} {showFallback ? (
{lowConfidenceFallback}
) : ( @@ -411,6 +504,7 @@ export function AccessibleTable({ densePreview={densePreview} rowActions={rowActions} actionsHeader={actionsHeader} + alignEnd={alignEnd} /> )} @@ -436,6 +530,10 @@ export function AccessibleTable({ aria-label={`Open ${title} full screen`} aria-haspopup="dialog" aria-expanded={dialogOpen} + // `aria-expanded` alone says something opened but never what. Point at + // the dialog it controls; the id is only advertised while the dialog is + // actually in the DOM, since aria-controls must resolve to a real node. + aria-controls={dialogOpen ? dialogId : undefined} onClick={(event) => { event.stopPropagation(); openDialog(event.currentTarget); @@ -448,6 +546,7 @@ export function AccessibleTable({ ) : null} setOpen(false)} title={title} @@ -461,13 +560,7 @@ export function AccessibleTable({ bodyClassName="py-3 pb-[max(1rem,env(safe-area-inset-bottom))] modal-landscape-container sm:p-3" >
- {lowConfidence ? ( -

- {showFallback - ? "Table structure could not be confidently reconstructed — showing the source document image instead." - : "Table structure could not be confidently reconstructed — verify values against the source document."} -

- ) : null} + {lowConfidence ? : null} {showFallback ? (
{lowConfidenceFallback}
) : ( @@ -479,6 +572,7 @@ export function AccessibleTable({ expanded rowActions={rowActions} actionsHeader={actionsHeader} + alignEnd={alignEnd} /> )}
diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx index 87930b3981..7cad0732b1 100644 --- a/src/components/ui-primitives.tsx +++ b/src/components/ui-primitives.tsx @@ -10,6 +10,18 @@ import { validationStatusLabel, } from "@/lib/source-metadata"; import { classifySourceAuthority } from "@/lib/source-authority-registry"; +import type { ClinicalSourceMetadata } from "@/lib/types"; + +/** + * What the source badges accept. Previously `unknown`, which meant the `.d.ts` + * published to the design system promised a typed shape TypeScript refused to + * enforce — so `{ validation_status: … }` (the wrong key; the real one is + * `clinical_validation_status`) compiled cleanly and silently fell back, and an + * off-vocabulary value reached the normalizer at runtime instead of at build + * time. `Partial` because every field is genuinely optional on legacy rows; + * `null` because that is what a missing join returns. + */ +export type SourceMetadataInput = Partial | null; export function cn(...classes: Array) { return classes.filter(Boolean).join(" "); @@ -31,15 +43,27 @@ export const sourceCard = `${panelSubtle} transition hover:border-[color:var(--b export const answerSurface = "rounded-lg bg-transparent"; export const panel = "rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] shadow-[var(--shadow-soft)] ring-1 ring-[color:var(--border-strong)]/20 dark:ring-[color:var(--border-strong)]/10"; -export const controlBase = - "inline-flex min-h-tap items-center justify-center gap-2 rounded-lg text-sm font-semibold transition active:translate-y-px focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] forced-colors:border disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none"; +// Disabled is ENCODED, not faded. `opacity-50` dims the label and the fill +// together, so a disabled primary stayed a large saturated block that still read +// as available, and a disabled secondary's label dropped below 4.5:1. Instead: +// flatten the fill to --surface-subtle, put the label on --disabled, drop the +// shadow, and remove the press affordance. `!` is required because the variant +// classes that follow this base would otherwise win on source order. +export const controlDisabled = + "disabled:cursor-not-allowed disabled:border-[color:var(--border)] disabled:bg-[color:var(--surface-subtle)]! disabled:text-[color:var(--disabled)]! disabled:shadow-none! disabled:active:translate-y-0 aria-disabled:cursor-not-allowed"; +export const controlBase = `inline-flex min-h-tap items-center justify-center gap-2 rounded-lg text-sm font-semibold transition active:translate-y-px focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] forced-colors:border ${controlDisabled}`; export const primaryControl = `${controlBase} bg-[color:var(--command)] px-5 text-[color:var(--command-contrast)] shadow-[var(--shadow-tight)] hover:bg-[color:var(--command-hover)] hover:shadow-[var(--shadow-hover)]`; export const floatingControl = "inline-flex min-h-tap items-center justify-center gap-2 rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] px-3 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none"; export const toolbarButton = "grid h-tap w-tap shrink-0 place-items-center rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none"; export const eyebrowText = "text-2xs font-semibold uppercase leading-4 tracking-[0.06em] text-[color:var(--text-soft)]"; -export const fieldLabel = `mb-1.5 block ${eyebrowText}`; +// A field label is a text node, so it cannot use `--text-soft` (3.07:1) or the +// uppercase eyebrow treatment: weight said "important" while colour said +// "secondary", and the label was quieter than the value it described. Sentence +// case, label weight, full-strength ink. `eyebrowText` stays for actual eyebrows +// - section kickers above a heading, which are decoration beside a real title. +export const fieldLabel = "mb-1.5 block text-sm font-medium leading-5 text-[color:var(--text)]"; export const fieldControl = "h-tap w-full rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] text-sm text-[color:var(--text)] shadow-[var(--shadow-inset)] outline-none transition placeholder:text-[color:var(--text-soft)] focus:border-[color:var(--focus)] forced-colors:border aria-[invalid=true]:border-[color:var(--danger)] aria-[invalid=true]:bg-[color:var(--danger-soft)] aria-[invalid=true]:text-[color:var(--danger)] aria-[invalid=true]:focus:border-[color:var(--danger)] disabled:cursor-not-allowed disabled:border-[color:var(--border)] disabled:bg-[color:var(--surface-inset)] disabled:text-[color:var(--disabled)] disabled:shadow-none disabled:opacity-75 read-only:cursor-default read-only:bg-[color:var(--surface-subtle)] read-only:text-[color:var(--text-muted)] read-only:shadow-none"; export const fieldControlWithIcon = `${fieldControl} pl-9 pr-3`; @@ -313,7 +337,13 @@ export function ToggleSwitch({ type IconComponent = LucideIcon; -export function SourceDesignationBadge({ metadata, className }: { metadata?: unknown; className?: string }) { +export function SourceDesignationBadge({ + metadata, + className, +}: { + metadata?: SourceMetadataInput; + className?: string; +}) { const source = normalizeSourceMetadata(metadata); const classification = classifySourceAuthority(source); const toneClassName = @@ -350,7 +380,7 @@ export function SourceStatusBadge({ className, showTitle = true, }: { - metadata?: unknown; + metadata?: SourceMetadataInput; className?: string; showTitle?: boolean; }) { @@ -383,7 +413,7 @@ export function SourceStatusBadge({ ); } -export function SourceProvenance({ metadata }: { metadata?: unknown }) { +export function SourceProvenance({ metadata }: { metadata?: SourceMetadataInput }) { const source = normalizeSourceMetadata(metadata); const reviewDate = formatClinicalDate(source.review_date); // Unknown review date / jurisdiction segments are dropped as filler; the @@ -489,6 +519,7 @@ export function EmptyState({ icon: Icon, title, body, + description, actions, live = "polite", tone = "neutral", @@ -496,7 +527,16 @@ export function EmptyState({ }: { icon?: IconComponent; title: string; - body: string; + /** Supporting copy. `PanelHeading` calls the same slot `description`. */ + body?: string; + /** + * Deprecated alias for `body`, accepted because `PanelHeading` names this slot + * `description` and passing `description` here used to render nothing at all — + * silently, with no type error, because `body` was the only recognised name. + * Prefer `body`; this alias exists so the mistake is impossible rather than + * invisible, and will be removed once call sites converge. + */ + description?: string; /** Optional controls stay within the shared state surface rather than becoming a second panel. */ actions?: ReactNode; /** Announce a state transition only when the state is introduced dynamically. */ @@ -528,7 +568,7 @@ export function EmptyState({ )}

{title}

-

{body}

+ {(body ?? description) ?

{body ?? description}

: null} {actions ?
{actions}
: null}
diff --git a/src/components/ui/answer-card.tsx b/src/components/ui/answer-card.tsx new file mode 100644 index 0000000000..7382c1aa69 --- /dev/null +++ b/src/components/ui/answer-card.tsx @@ -0,0 +1,188 @@ +"use client"; + +import type { ReactNode } from "react"; +import { cn } from "@/components/ui-primitives"; + +/* + * The answer surface. `answerSurface` was `"rounded-lg bg-transparent"` — the + * screen the product is judged on had no surface at all. + * + * Token note: the v2 semantic tokens (--pad-panel, --measure, --text-md, + * --leading-prose, --pad-card, --rule-w, --e2) are declared on the opt-in + * `.ckb-v2` layer, so every reference here carries the v1 fallback it resolves to + * today. The components therefore render correctly with or without the layer, and + * pick up the v2 values automatically inside it. + */ + +export type AnswerCardProps = { + children: ReactNode; + /** Rendered above the prose — question echo, mode chip, provenance row. */ + header?: ReactNode; + /** Rendered below the prose, outside the reading measure — usually AnswerFooter. */ + footer?: ReactNode; + className?: string; +}; + +export function AnswerCard({ children, header, footer, className }: AnswerCardProps) { + return ( +
+ {header ? ( +
+ {header} +
+ ) : null} +
+ {children} +
+ {footer} +
+ ); +} + +export type DoseRow = { + /** Drug or intervention name. */ + drug: string; + /** Route, population, indication — the qualifier that makes the dose specific. */ + qualifier?: string; + /** The numeral only, e.g. "12.5" or "250–750". Never include the unit here. */ + value: string; + /** The unit, e.g. "mg", "mg/day". Rendered in sans, never uppercased. */ + unit?: string; + /** + * True when the source this row was read from is past its review date. Turns the + * row's inset rule amber — a dose from a stale guideline is exactly the case + * where "looks authoritative" is the danger. + */ + overdue?: boolean; +}; + +export type DoseLineProps = { + rows: DoseRow[]; + /** Optional caption above the ledger. */ + caption?: string; + className?: string; +}; + +/** + * The ledger treatment: one bordered card, hairline separators, drug on the left, + * dose right-aligned in a fixed column so the numerals stack. `tabular-nums` alone + * does nothing when the column is left-aligned — the alignment is what makes the + * figures comparable at a glance. + * + * Unit typography is deliberate and was a real defect in the first pass: the unit + * is sans at the label step, NOT uppercased. In medicine `g` and `G`, `mg` and + * `MG` are not interchangeable, and an uppercasing transform silently changes a + * dose. + */ +export function DoseLine({ rows, caption, className }: DoseLineProps) { + if (!rows.length) return null; + + return ( +
+ {caption ? ( +

+ {caption} +

+ ) : null} +
    + {rows.map((row, index) => ( +
  • + + {row.drug} + {row.qualifier ? ( + {row.qualifier} + ) : null} + + + + {row.value} + + {row.unit ? ( + + {row.unit} + + ) : null} + +
  • + ))} +
+
+ ); +} + +export type AnswerFooterProps = { + publisher?: string | null; + version?: string | null; + /** Formatted review date. Pass the formatted string, not a raw timestamp. */ + reviewDate?: string | null; + /** Formatted generation timestamp. */ + generatedAt?: string | null; + className?: string; +}; + +/** + * Provenance strip, always visible. Trust is layout, not a tooltip: publisher, + * version, review date and generation time are the four things a clinician needs + * to decide whether to act on an answer, so they are not hidden behind a hover. + * Missing segments are dropped rather than filled with "Unknown" — a run of + * unknown fillers is noise — except the review date, which stays explicit because + * "no review date" is itself a governance signal. + */ +export function AnswerFooter({ publisher, version, reviewDate, generatedAt, className }: AnswerFooterProps) { + const segments = [ + publisher || null, + version ? `Version ${version}` : null, + `Review ${reviewDate || "date unknown"}`, + generatedAt ? `Generated ${generatedAt}` : null, + ].filter((segment): segment is string => Boolean(segment)); + + return ( +
+ {segments.map((segment, index) => ( + + {index > 0 ? : null} + {segment} + + ))} +
+ ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx deleted file mode 100644 index a4327f4cf3..0000000000 --- a/src/components/ui/badge.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import * as React from "react"; -import { cn } from "@/components/ui-primitives"; - -export interface BadgeProps extends React.HTMLAttributes { - variant?: "default" | "secondary" | "destructive" | "outline"; -} - -function Badge({ className, variant = "default", ...props }: BadgeProps) { - const baseStyles = - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-[color:var(--focus)] focus:ring-offset-2 forced-colors:border"; - - const variants = { - default: - "border-transparent bg-[color:var(--primary)] text-[color:var(--primary-contrast)] hover:bg-[color:var(--primary-strong)]", - secondary: - "border-transparent bg-[color:var(--surface-subtle)] text-[color:var(--text)] hover:bg-[color:var(--border)]", - destructive: - "border-transparent bg-[color:var(--danger)] text-[color:var(--danger-solid-contrast)] hover:bg-[color:var(--danger)]/80", - outline: "text-[color:var(--text)] border-[color:var(--border-strong)]", - }; - - return
; -} - -export { Badge }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000000..51d1c16341 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Loader2, type LucideIcon } from "lucide-react"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { cn, controlBase } from "@/components/ui-primitives"; + +export type ButtonVariant = "primary" | "secondary" | "toolbar" | "ghost" | "danger"; +export type ButtonSize = "sm" | "md" | "lg"; + +// One filled `--command` button per surface (register #12). `secondary` is the +// default for everything that is not the single primary action on screen. +// +// `danger` is the ONLY home for `--danger-solid` (register #13): a destructive +// action is the one place a filled red is not decoration. Do not reach for it to +// mean "important" — importance is `primary`. +const VARIANT: Record = { + primary: + "bg-[color:var(--command)] text-[color:var(--command-contrast)] shadow-[var(--shadow-tight)] hover:bg-[color:var(--command-hover)] hover:shadow-[var(--shadow-hover)] active:bg-[color:var(--command-active)]", + secondary: + "border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text)] shadow-[var(--shadow-inset)] hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)]", + toolbar: + "border border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]", + ghost: + "bg-transparent text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]", + danger: + "bg-[color:var(--danger-solid)] text-[color:var(--command-contrast)] shadow-[var(--shadow-tight)] hover:brightness-110 active:brightness-95", +}; + +// Height is the tap target and never drops below 44px; `size` moves the optical +// padding and label step, not the hit area (register #7/#18). +const SIZE: Record = { + sm: "px-3 text-xs", + md: "px-4 text-sm", + lg: "px-5 text-sm", +}; + +export type ButtonProps = Omit, "children"> & { + variant?: ButtonVariant; + size?: ButtonSize; + children: ReactNode; + /** Leading icon, rendered decoratively. Swapped for the spinner while busy. */ + icon?: LucideIcon; + /** Trailing icon; hidden while busy so the row cannot show two glyphs. */ + trailingIcon?: LucideIcon; + /** Stretch to the container width — for phone dialogs and stacked forms. */ + block?: boolean; + /** + * Busy state, folded in from AsyncButton: disables the control, announces via + * `aria-busy`, swaps the leading glyph for a spinner and the label for + * `busyLabel`. A busy button with no `busyLabel` keeps its idle label. + */ + busy?: boolean; + busyLabel?: string; +}; + +export function Button({ + variant = "secondary", + size = "md", + children, + icon: Icon, + trailingIcon: TrailingIcon, + block = false, + busy = false, + busyLabel, + className, + disabled, + type, + ...props +}: ButtonProps) { + return ( + + ); +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx deleted file mode 100644 index 54c0371c37..0000000000 --- a/src/components/ui/card.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import * as React from "react"; - -import { cn } from "@/components/ui-primitives"; - -const Card = React.forwardRef>(({ className, ...props }, ref) => ( -
-)); -Card.displayName = "Card"; - -const CardHeader = React.forwardRef>( - ({ className, ...props }, ref) => ( -
- ), -); -CardHeader.displayName = "CardHeader"; - -const CardTitle = React.forwardRef>( - ({ className, ...props }, ref) => ( -

- ), -); -CardTitle.displayName = "CardTitle"; - -const CardDescription = React.forwardRef>( - ({ className, ...props }, ref) => ( -

- ), -); -CardDescription.displayName = "CardDescription"; - -const CardContent = React.forwardRef>( - ({ className, ...props }, ref) =>

, -); -CardContent.displayName = "CardContent"; - -const CardFooter = React.forwardRef>( - ({ className, ...props }, ref) => ( -
- ), -); -CardFooter.displayName = "CardFooter"; - -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/src/components/ui/chip.tsx b/src/components/ui/chip.tsx new file mode 100644 index 0000000000..dcdb6f5a53 --- /dev/null +++ b/src/components/ui/chip.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { X, type LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { cn, toneDanger, toneInfo, toneNeutral, toneSuccess, toneWarning } from "@/components/ui-primitives"; + +export type ChipTone = "neutral" | "info" | "success" | "warning" | "danger"; + +const TONE: Record = { + neutral: toneNeutral, + info: toneInfo, + success: toneSuccess, + warning: toneWarning, + danger: toneDanger, +}; + +const DOT: Record = { + neutral: "bg-[color:var(--text-soft)]", + info: "bg-[color:var(--info)]", + success: "bg-[color:var(--success)]", + warning: "bg-[color:var(--warning)]", + danger: "bg-[color:var(--danger)]", +}; + +export type ChipProps = { + children: ReactNode; + tone?: ChipTone; + /** Status dot. Never the only carrier of meaning — the label still says it. */ + dot?: boolean; + icon?: LucideIcon; + /** + * Removal handler. The label is per-chip and required when removable: a row of + * identical "Remove" buttons is unusable by voice or screen reader. + */ + onRemove?: () => void; + removeLabel?: string; + className?: string; +}; + +// A chip is static text at 28px (`--chip-height`), NOT a 44px tap target — that +// floor is for interactive controls (register #7/#18). The remove control inside +// a removable chip is interactive and keeps its own hit area. +export function Chip({ + children, + tone = "neutral", + dot = false, + icon: Icon, + onRemove, + removeLabel, + className, +}: ChipProps) { + return ( + + {dot ? : null} + {Icon ? + ); +} diff --git a/src/components/ui/choice.tsx b/src/components/ui/choice.tsx new file mode 100644 index 0000000000..5a42cca36d --- /dev/null +++ b/src/components/ui/choice.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { Check } from "lucide-react"; +import { type InputHTMLAttributes, type ReactNode, useId } from "react"; +import { cn, textMuted } from "@/components/ui-primitives"; + +/* + * Checkbox and radio, which the system was missing entirely — `ToggleSwitch` was + * standing in for both, and it means something different. + * + * Switch — applies immediately. "Dark mode on." + * Checkbox — applies on submit, or contributes to a set. "Include outdated sources." + * Radio — one of N, applies on submit. "Sort by relevance / date / publisher." + * + * Using a switch for a filter tells the user the filter has already run. In a + * clinical search that is a claim about what they are currently looking at. + * + * Both controls keep the NATIVE input, visually hidden but focusable, with the + * visible box driven from `peer-*` state. That buys real keyboard behaviour, + * form participation, `:indeterminate`, and correct screen-reader roles for free. + */ + +const boxBase = + "grid size-[1.125rem] shrink-0 place-items-center rounded-[0.3125rem] border transition motion-reduce:transition-none"; + +const rowBase = + "group flex min-h-tap w-full cursor-pointer items-start gap-3 rounded-md px-1 py-1.5 transition hover:bg-[color:var(--surface-subtle)] has-[:disabled]:cursor-not-allowed has-[:disabled]:hover:bg-transparent"; + +function Row({ + children, + description, + label, + disabled, + htmlFor, + describedBy, +}: { + children: ReactNode; + label: ReactNode; + description?: ReactNode; + disabled?: boolean; + htmlFor: string; + describedBy?: string; +}) { + return ( + + ); +} + +export type CheckboxProps = Omit, "type" | "id"> & { + label: ReactNode; + description?: ReactNode; + /** Mixed state for a parent controlling a partially-selected group. */ + indeterminate?: boolean; +}; + +export function Checkbox({ label, description, indeterminate, disabled, className, ...props }: CheckboxProps) { + const id = useId(); + const descId = description ? `${id}-desc` : undefined; + + return ( + + + { + if (node) node.indeterminate = Boolean(indeterminate); + }} + className="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-[0.3125rem] disabled:cursor-not-allowed" + /> + + {indeterminate ? ( + + ) : ( + + )} + + + + ); +} + +export type RadioOption = { + value: string; + label: ReactNode; + description?: ReactNode; + disabled?: boolean; +}; + +export type RadioGroupProps = { + /** Group label — rendered as the fieldset legend, which is what gets announced. */ + label: string; + name: string; + options: RadioOption[]; + value?: string; + onChange?: (value: string) => void; + hideLabel?: boolean; + className?: string; +}; + +/** + * A real `
` + ``. A radio set without one announces each option + * with no idea what question it answers — "Relevance, radio button, 1 of 3" tells + * a screen-reader user nothing about what is being sorted. + */ +export function RadioGroup({ label, name, options, value, onChange, hideLabel, className }: RadioGroupProps) { + const groupId = useId(); + + return ( +
+ + {label} + +
+ {options.map((option) => { + const id = `${groupId}-${option.value}`; + const descId = option.description ? `${id}-desc` : undefined; + return ( + + + onChange?.(option.value)} + className="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full disabled:cursor-not-allowed" + /> + + + + + + ); + })} +
+
+ ); +} diff --git a/src/components/ui/citation.tsx b/src/components/ui/citation.tsx new file mode 100644 index 0000000000..d6cf69a6af --- /dev/null +++ b/src/components/ui/citation.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { FileText } from "lucide-react"; +import type { ReactNode } from "react"; +import { cn } from "@/components/ui-primitives"; +import { StatusMark, type DocumentStatus } from "@/components/ui/status-mark"; + +export type CitationProps = { + /** 1-based index as it appears in the answer text. */ + index: number; + /** Short source label — document title, abbreviated. */ + label: string; + /** Page or section locator, e.g. "p. 14" or "§3.2". */ + locator?: string; + /** Currency of the cited source. Drives the mark, never the label wording. */ + status?: DocumentStatus; + onActivate?: () => void; + /** Rendered as static when there is nowhere to go (print, export). */ + interactive?: boolean; + className?: string; +}; + +/** + * The citation chip — the mark that makes a grounded answer auditable, and the + * single most product-defining element in this system. It existed only as three + * class strings (`sourceCapsuleHit`, `sourceCapsule`, `sourceCapsuleCountBadge`) + * plus unlayered CSS, so a designer could not place one and nothing tested it. + * + * Structure: an invisible 44px hit target wrapping a compact visible face. The + * chip reads small and light without shrinking the tap area — the same trick the + * old recipes used, kept because it is correct. + * + * The status mark is the source's currency, not the citation's. A citation to an + * outdated source is still a valid citation; it is the source that is stale, and + * the reader has to be able to see that without opening it. + */ +export function Citation({ index, label, locator, status, onActivate, interactive = true, className }: CitationProps) { + const face = ( + + {index} + {label} + {locator ? {locator} : null} + {status ? : null} + + ); + + const accessibleName = [`Source ${index}`, label, locator, status ? statusPhrase(status) : null] + .filter(Boolean) + .join(", "); + + if (!interactive) { + return ( + + {face} + + ); + } + + return ( + + ); +} + +// One phrase per state — the same vocabulary the badges and provenance use, so a +// screen reader never hears the same state described two ways on one page. +function statusPhrase(status: DocumentStatus) { + if (status === "current") return "current source"; + if (status === "review_due") return "review due"; + if (status === "outdated") return "outdated source"; + return "review status unknown"; +} + +/** + * A run of citations under an answer paragraph or claim. A list, not a row of + * loose buttons, so assistive technology announces how many sources back the + * claim before reading them. + */ +export function CitationList({ + citations, + label = "Sources for this answer", + emptyNote = "No source supports this statement.", + className, +}: { + citations: ReactNode[]; + label?: string; + /** + * Shown when there are zero citations. An ungrounded statement in a clinical + * tool is a governance event, so the empty case says so rather than rendering + * nothing and looking identical to a cited one. + */ + emptyNote?: ReactNode; + className?: string; +}) { + if (!citations.length) { + return ( +

+

+ ); + } + + return ( +
    + {citations.map((citation, index) => ( +
  • + {citation} +
  • + ))} +
+ ); +} diff --git a/src/components/ui/confirm-dialog.tsx b/src/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000000..8a8f4972d3 --- /dev/null +++ b/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useId, useState, type ReactNode } from "react"; +import { Button } from "@/components/ui/button"; +import { Sheet } from "@/components/ui/sheet"; +import { fieldControlPlain, textMuted, cn } from "@/components/ui-primitives"; + +export type ConfirmDialogProps = { + open: boolean; + onCancel: () => void; + onConfirm: () => void; + title: string; + /** What will happen, in plain terms. Say the irreversible part out loud. */ + description: ReactNode; + confirmLabel?: string; + cancelLabel?: string; + /** Destructive by default — this is the governance-action preset. */ + tone?: "danger" | "primary"; + busy?: boolean; + busyLabel?: string; + /** + * When set, the confirm control stays disabled until the user types this exact + * string. Reserved for actions with no undo (deleting an indexed source, + * retiring an approved guideline) — a typed confirmation is friction on + * purpose. Leave unset for reversible actions; ceremony everywhere trains + * people to type through it. + */ + confirmPhrase?: string; + confirmPhraseLabel?: string; +}; + +export function ConfirmDialog({ + open, + onCancel, + onConfirm, + title, + description, + confirmLabel = "Confirm", + cancelLabel = "Cancel", + tone = "danger", + busy = false, + busyLabel, + confirmPhrase, + confirmPhraseLabel, +}: ConfirmDialogProps) { + const [typed, setTyped] = useState(""); + const [wasOpen, setWasOpen] = useState(open); + const inputId = useId(); + + // A stale confirmation phrase from a previous open would let the next + // destructive action through with no typing at all. Reset during render rather + // than in an effect: an effect would leave one frame where the confirm control + // is already enabled against the previous answer. + if (wasOpen !== open) { + setWasOpen(open); + if (!open) setTyped(""); + } + + const phraseSatisfied = !confirmPhrase || typed.trim() === confirmPhrase; + + return ( + + + +
+ } + > +
+

{description}

+ {confirmPhrase ? ( +
+ + setTyped(event.target.value)} + autoComplete="off" + className={fieldControlPlain} + /> +
+ ) : null} +
+ + ); +} diff --git a/src/components/ui/disclosure.tsx b/src/components/ui/disclosure.tsx new file mode 100644 index 0000000000..f1b07ccfa2 --- /dev/null +++ b/src/components/ui/disclosure.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import { type ReactNode, useId, useState } from "react"; +import { cn, textMuted } from "@/components/ui-primitives"; + +export type DisclosureProps = { + title: ReactNode; + children: ReactNode; + /** Right-aligned summary that stays visible while collapsed — a count, a status. */ + meta?: ReactNode; + description?: ReactNode; + defaultOpen?: boolean; + /** Controlled mode. Omit both to let the component own its state. */ + open?: boolean; + onOpenChange?: (open: boolean) => void; + className?: string; +}; + +/** + * Expand/collapse, built once instead of five times. + * + * The trigger is a real ` +

+ +
+ ); +} + +/** + * A stack of disclosures. `exclusive` makes it an accordion (one open at a time); + * the default lets several stay open, which is usually right for reference + * content where a reader compares two sections. + */ +export function DisclosureGroup({ + items, + exclusive = false, + className, +}: { + items: Array<{ id: string; title: ReactNode; description?: ReactNode; meta?: ReactNode; content: ReactNode }>; + exclusive?: boolean; + className?: string; +}) { + const [openIds, setOpenIds] = useState([]); + + return ( +
+ {items.map((item) => ( + + setOpenIds((current) => { + if (!next) return current.filter((id) => id !== item.id); + return exclusive ? [item.id] : [...current, item.id]; + }) + } + > + {item.content} + + ))} +
+ ); +} diff --git a/src/components/ui/link.tsx b/src/components/ui/link.tsx new file mode 100644 index 0000000000..193611148f --- /dev/null +++ b/src/components/ui/link.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { ArrowRight, Download, ExternalLink } from "lucide-react"; +import NextLink from "next/link"; +import type { AnchorHTMLAttributes, ReactNode } from "react"; +import { cn } from "@/components/ui-primitives"; + +type BaseProps = { + children: ReactNode; + className?: string; + /** Quiet links inherit ink and underline on hover; loud links are always accented. */ + tone?: "accent" | "inherit"; +}; + +const base = + "inline-flex items-baseline gap-1 rounded-sm underline-offset-[3px] transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +const toneClass = { + accent: + "text-[color:var(--clinical-accent)] underline decoration-[color:var(--clinical-accent)]/35 hover:decoration-[color:var(--clinical-accent)]", + inherit: "text-inherit no-underline hover:underline", +} as const; + +/** + * Internal navigation. Wraps `next/link` so a call site never reaches for a raw + * `` — which loses client-side routing, prefetch and scroll + * restoration, and which `docs/wiring-conventions.md` bans without previously + * offering an alternative. + */ +export function TextLink({ + href, + children, + tone = "accent", + className, + ...props +}: BaseProps & { href: string } & Omit, "href" | "children" | "className">) { + return ( + + {children} + + ); +} + +/** + * A link that leaves the app. Three things are non-negotiable and are therefore + * not props: + * + * - `rel="noopener noreferrer"` — `target="_blank"` without it hands the + * opened page a live `window.opener` handle back into a clinical app. + * - A visible indicator glyph, so "this leaves the app" is not conveyed by + * hover alone. + * - An `sr-only` "opens in a new tab", because an unannounced context switch + * is disorienting for screen-reader and switch users. + */ +export function ExternalTextLink({ + href, + children, + tone = "accent", + className, + ...props +}: BaseProps & { href: string } & Omit< + AnchorHTMLAttributes, + "href" | "children" | "className" | "rel" | "target" + >) { + return ( + + {children} + + ); +} + +/** + * A link that produces a file. States the format and size up front — a clinician + * on hospital wifi deciding whether to tap a 40 MB PDF needs that before the tap, + * not after. + */ +export function DownloadLink({ + href, + children, + format, + size, + className, + ...props +}: BaseProps & { href: string; format?: string; size?: string } & Omit< + AnchorHTMLAttributes, + "href" | "children" | "className" + >) { + const detail = [format, size].filter(Boolean).join(", "); + return ( + + + ); +} + +/** + * A link styled as the forward action of a card or section. Not a button: it + * navigates, so it must be a real anchor for middle-click, copy-link, and the + * browser's own affordances. + */ +export function LinkAction({ href, children, className }: BaseProps & { href: string }) { + return ( + + {children} + + ); +} diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx new file mode 100644 index 0000000000..d2b16c9e96 --- /dev/null +++ b/src/components/ui/page-header.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { ChevronRight, type LucideIcon } from "lucide-react"; +import Link from "next/link"; +import type { ReactNode } from "react"; +import { cn, eyebrowText, iconTilePremium, textMuted } from "@/components/ui-primitives"; + +export type Crumb = { + label: string; + /** Omit on the final crumb — the current page is not a link to itself. */ + href?: string; +}; + +export type BreadcrumbProps = { + items: Crumb[]; + className?: string; +}; + +/** + * Internal navigation goes through ``, never a raw `` + * (docs/wiring-conventions.md). The trailing crumb is plain text carrying + * `aria-current="page"`. + */ +export function Breadcrumb({ items, className }: BreadcrumbProps) { + if (!items.length) return null; + + return ( + + ); +} + +export type PageHeaderProps = { + title: string; + /** Short kicker above the title — section, mode, or record kind. */ + eyebrow?: string; + description?: ReactNode; + icon?: LucideIcon; + breadcrumb?: Crumb[]; + /** Primary/secondary controls. One filled `--command` button, at most. */ + actions?: ReactNode; + /** Status chips, counts, or provenance shown under the description. */ + meta?: ReactNode; + className?: string; +}; + +/** + * Page-level counterpart to `PanelHeading`: `PanelHeading` titles a panel inside a + * page, `PageHeader` titles the page itself and owns the `

`. A page should + * carry exactly one. + */ +export function PageHeader({ + title, + eyebrow, + description, + icon: Icon, + breadcrumb, + actions, + meta, + className, +}: PageHeaderProps) { + return ( +
+ {breadcrumb?.length ? : null} +
+
+ {Icon ? ( + + + ) : null} +
+ {eyebrow ?

{eyebrow}

: null} +

{title}

+ {description ?

{description}

: null} +
+
+ {actions ?
{actions}
: null} +
+ {meta ?
{meta}
: null} +
+ ); +} diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000000..4331f92d07 --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { cn } from "@/components/ui-primitives"; + +export type PaginationProps = { + page: number; + pageCount: number; + onPageChange: (page: number) => void; + /** Accessible name for the nav landmark. */ + label?: string; + /** Optional "1–20 of 340 sources" line, rendered with tabular figures. */ + summary?: string; + className?: string; +}; + +// Truncated window: first, last, and a three-wide band around the current page. +// `null` marks an elision, which renders as a non-interactive ellipsis rather +// than a disabled button — there is nothing to activate. +function pageWindow(page: number, pageCount: number): Array { + if (pageCount <= 7) return Array.from({ length: pageCount }, (_, index) => index + 1); + const pages = new Set([1, pageCount, page, page - 1, page + 1]); + const sorted = [...pages].filter((value) => value >= 1 && value <= pageCount).sort((a, b) => a - b); + const out: Array = []; + sorted.forEach((value, index) => { + if (index > 0 && value - sorted[index - 1] > 1) out.push(null); + out.push(value); + }); + return out; +} + +export function Pagination({ + page, + pageCount, + onPageChange, + label = "Pagination", + summary, + className, +}: PaginationProps) { + if (pageCount <= 1) return null; + const items = pageWindow(page, pageCount); + + const step = + "grid size-tap shrink-0 place-items-center rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text)] transition hover:border-[color:var(--border-strong)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50"; + + return ( + + ); +} diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000000..bd6d198203 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { Check, Loader2, TriangleAlert } from "lucide-react"; +import type { ReactNode } from "react"; +import { cn, textMuted } from "@/components/ui-primitives"; + +export type ProgressProps = { + /** 0–100. Omit for an indeterminate bar. */ + value?: number; + label: string; + /** Right-aligned detail, e.g. "42 of 118 chunks". Tabular so it stops jittering. */ + detail?: ReactNode; + className?: string; +}; + +/** + * A determinate bar when a total is known, indeterminate when it is not — and + * never a determinate-looking bar over an unknown total, which is a lie about how + * long something will take. + * + * `role="progressbar"` with real `aria-valuenow/min/max`, so the percentage is + * announced rather than inferred from a coloured rectangle. + */ +export function Progress({ value, label, detail, className }: ProgressProps) { + const determinate = typeof value === "number"; + const clamped = determinate ? Math.max(0, Math.min(100, value)) : undefined; + + return ( +
+
+ {label} + {detail ? {detail} : null} +
+
+
+
+
+ ); +} + +export type Stage = { + id: string; + label: string; + state: "pending" | "active" | "done" | "failed"; + /** Live count while the stage runs, e.g. "118 chunks". */ + detail?: ReactNode; +}; + +/** + * A staged job, shown as stages — not as one skeleton. + * + * Ingestion is upload → parse → chunk → embed → index. Rendering that as a single + * indeterminate placeholder throws away every piece of information the user + * actually wants: which stage is running, how far in, whether it is stuck, and + * which stage failed. A five-minute job with no named stage is indistinguishable + * from a hung one. + * + * `aria-live="polite"` on the list so a stage transition is announced without + * interrupting; the current stage carries `aria-current="step"`. + */ +export function StageList({ + stages, + label = "Progress", + className, +}: { + stages: Stage[]; + label?: string; + className?: string; +}) { + const activeIndex = stages.findIndex((stage) => stage.state === "active"); + const doneCount = stages.filter((stage) => stage.state === "done").length; + + return ( +
    + {stages.map((stage, index) => { + const last = index === stages.length - 1; + return ( +
  1. + {/* Connector owned by the gutter column, stopping at the dot centres. */} + {!last ? ( + + ) : null} + + {stage.state === "done" ? ( +
  2. + ); + })} +
+ ); +} diff --git a/src/components/ui/quantity.tsx b/src/components/ui/quantity.tsx new file mode 100644 index 0000000000..d9d03e8d4e --- /dev/null +++ b/src/components/ui/quantity.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { cn } from "@/components/ui-primitives"; + +export type QuantityProps = { + /** The numeral only, e.g. "12.5", "0.6–0.8". Never include the unit here. */ + value: string; + /** The unit, e.g. "mg", "mg/day", "mmol/L". Rendered as authored. */ + unit?: string; + /** + * Demote the numeral from value weight to label weight. Use when the figure is + * machine-derived and low confidence: a confident-looking number is the wrong + * output for an uncertain extraction. + */ + demoted?: boolean; + /** Step for the numeral. Units always sit one step below, in sans. */ + size?: "sm" | "md" | "lg"; + className?: string; +}; + +const SIZE: Record, { value: string; unit: string }> = { + sm: { value: "text-sm", unit: "text-xs" }, + md: { value: "text-base-minus", unit: "text-sm" }, + lg: { value: "text-lg", unit: "text-sm" }, +}; + +/** + * The one quantity style. Every number that carries clinical meaning — a dose, a + * serum level, an overdue interval, a count — renders through this. + * + * Two rules are safety rules, not style: + * + * 1. **The unit is never uppercased.** `g` is not `G`; `mg` is not `MG`. A + * `text-transform: uppercase` on a dose changes what the dose says. This + * component pins `normal-case` so an inherited transform cannot reach it. + * 2. **The unit is demoted, never equal.** When the numeral and unit share the + * same font and weight, "1 g" reads as one token and the figure stops being + * the thing you see first. + * + * Numerals are Geist Mono with `tabular-nums` so figures stack in a column and + * do not reflow as they change. + */ +export function Quantity({ value, unit, demoted = false, size = "md", className }: QuantityProps) { + const step = SIZE[size]; + + return ( + + + {value} + + {unit ? ( + + {unit} + + ) : null} + + ); +} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000000..622b31e41d --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { ChevronDown } from "lucide-react"; +import { type ReactNode, type SelectHTMLAttributes, useId } from "react"; +import { cn, fieldControl, fieldLabel, textMuted } from "@/components/ui-primitives"; + +export type SelectOption = { value: string; label: string; disabled?: boolean }; + +export type SelectProps = Omit, "id" | "children"> & { + label: string; + options: SelectOption[]; + hint?: ReactNode; + error?: ReactNode; + hideLabel?: boolean; + /** Rendered as a disabled first option, so "nothing chosen" is a visible state. */ + placeholder?: string; + fieldClassName?: string; +}; + +/** + * A native `` beside an + * `` in the same row was missing the inset well its siblings carried, so + * two controls doing the same job read as different kinds of thing. The fix is + * one shell for every control type — and the cheapest way to guarantee that is to + * make the native element take the shell, rather than reimplementing keyboard + * handling, mobile pickers and typeahead in a div. + * + * Reach for a combobox only when the list needs filtering. A 6-option select does + * not. + */ +export function Select({ + label, + options, + hint, + error, + hideLabel, + placeholder, + className, + fieldClassName, + value, + defaultValue, + ...props +}: SelectProps) { + const id = useId(); + const hintId = `${id}-hint`; + const errorId = `${id}-error`; + const describedBy = [hint && !error ? hintId : null, error ? errorId : null].filter(Boolean).join(" ") || undefined; + + return ( +
+ +
+ +
+ {hint && !error ? ( +

+ {hint} +

+ ) : null} + {error ? ( + + ) : null} +
+ ); +} diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 6219a53b29..34bce46873 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -57,6 +57,7 @@ export function Sheet({ portal = false, desktopBackdropClassName, testId, + id, }: { open: boolean; onClose: () => void; @@ -84,6 +85,10 @@ export function Sheet({ portal?: boolean; desktopBackdropClassName?: string; testId?: string; + // Stable id for the dialog element so an opener can advertise `aria-controls`. + // Without it a trigger can only carry `aria-expanded`, which tells assistive + // technology that something expanded but never which region. + id?: string; }) { const backdropRef = useRef(null); const panelRef = useRef(null); @@ -332,6 +337,7 @@ export function Sheet({ >