diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md
index 63010521a1..0bca45f0b0 100644
--- a/docs/branch-review-ledger.md
+++ b/docs/branch-review-ledger.md
@@ -584,3 +584,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie
| 2026-08-01 | claude/ds-v2-tooling-loop | 40181192519fddc9405523d06bd3e691096cda6c | PR-0 tooling loop: Context7 + Chrome DevTools MCP wiring, design-sync and mockup-capture scripts, docs/env-example updates (11 files, +1005/-11, no clinical or RAG surfaces) | gates green | verify:pr-local: format/lint/typecheck/lock-parity green; unit 4803 passed, 6 env-class WSL relay failures in ci-cache-safety.test.ts (Ubuntu distro stopped), focused rerun 13/13 green after WSL boot; check:rag:fixtures 36 golden cases green; build skipped by selector |
| 2026-08-01 | claude/ds-v2-tooling-loop (PR #1568) | f6317ea48355c26e080ce45b6f574159e8397e24 | PR #1568 unblock/fix | merged origin/main (behind-but-clean; merge-tree was clean); cleared GitHub DIRTY/CONFLICTING; advisory review threads left open (not merge/CI blockers); auto-merge remains armed | merge-tree clean; ahead 7 behind 0; no provider-backed checks; no product code fix needed |
| 2026-08-01 | claude/ds-v2-tooling-loop (PR #1568) | b76bcc9fbc39f2f986a52869c810ba3cb89994f2 | PR #1568 review-thread resolve | fixed and resolved all 13 review threads (fail-closed project identity, inventory exit code, provenance demo id, buildCmd execution, chrome pin, context7 rollback); ledger row for superseded 93d41c1 dispositioned | node --check scripts; design-sync --dry-run; no provider-backed checks |
+| 2026-08-01 | claude/ds-v2-correctness | 1aa9822978668a10447ef3246bd60cda6f1208cb | ds-v2 PR-A correctness (HCM/contrast/prop-unions) | ready-to-merge | unit 4878p; verify:ui 344p; style-contract HCM 5p; verify:pr-local green (eval:rag:offline 574/574; fixture 36/23); typecheck+ds-contract green |
diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md
index 9616803bf2..c8c757a918 100644
--- a/docs/design-system/COMPONENTS.md
+++ b/docs/design-system/COMPONENTS.md
@@ -97,32 +97,32 @@ print primitives (`PrintHeader`, `PrintFooter`, `CitationFootnote`, `PrintOnly`,
### 0.4 Open-defect ledger (existing components → closing PR)
-| Component | Open defects (compressed) | Closes in |
-| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------ |
-| Button | danger contrast token · brightness hover/active bypass tokens · 44px comment · no ref · needless client boundary | PR 3, PR 4 |
-| AsyncButton | no `type="button"` (can submit a form) | PR 4 (retire or alias) |
-| IconButton | `disabled:opacity` (one of 10 remaining uses) | PR 3 |
-| ToggleSwitch | unnameable · knob animates `left`/`right` · opacity disabled | PR 4, PR 9 |
-| Chip | 20px remove target · optional `removeLabel` · no full-value path | PR 4 |
-| TextField/SearchField/Select | hint dropped on error (comment promises otherwise) · describedBy overwritten · placeholder on the decoration tier · no external id/refs | PR 7 |
-| Checkbox/RadioGroup | RadioGroup inert-control contract · raw dimensions · unsanitised ids · no group hint/error | PR 4, PR 7 |
-| Citation/CitationList | enabled-inert · `aria-label` on bare span · index keys · unstructured data | PR 4 |
-| DoseLine | must compose `Quantity` · structured dose model · overdue text + non-colour mark + open action | PR 6 |
-| StatusMark | app-type coupling · inline styles/raw geometry · HCM asserted-not-proven | PR 2, PR 12 |
-| AnswerCard | unrestricted slots — no required verification/answer state | PR 6 |
-| AnswerFooter | accepts preformatted strings; must take machine values + compose `DateDisplay` + `MissingValue` | PR 6 |
-| PageHeader/Breadcrumb | `
` truncates · actions starve title · low-contrast eyebrow | PR 3, PR 7-adjacent layout fix |
-| Tabs | `aria-controls` to unrendered panels · invalid selected value can empty the tab order · split `SegmentedControl` | PR 4-adjacent, own tranche |
-| Pagination | unclamped props · 320px overflow · opacity disabled · no focus/announce policy | PR 3, PR 8 |
-| Links | `tone` leaks to DOM · `download` overridable by spread · `gap` animation · new-tab policy implicit | PR 9 |
-| Tooltip | overwrites child handlers/describedBy · no portal/collision/delay · hardcoded z | PR 10 |
-| Toast | z below `--z-toast` · warning=danger icon · urgency coupled to tone · no pause on hover/focus · no portal/queue cap | PR 10 |
-| Sheet/ConfirmDialog | optional name · portal default off · hardcoded z/duration · title truncates · bare "Confirm" default | PR 10 |
-| Disclosure | hardcoded `` · no print behaviour · truncation | PR 11 |
-| Progress/StageList | width animation · "step 0 of N" · whole-list live region | PR 8, PR 9 |
-| EmptyState | `live="polite"` by default | PR 8 |
-| AccessibleTable | div caption vs `aria-label` · optional caption · uppercase dense headers · **bare `-` for missing cells** · inline equal widths · hand-rolled expander | PR 6 (`MissingValue`), PR 12 |
-| ui-primitives.tsx | 572-line module mixing recipes/actions/feedback/clinical — split | PR 12 |
+| Component | Open defects (compressed) | Closes in |
+| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
+| Button | brightness hover/active bypass tokens · 44px comment · no ref · needless client boundary (danger contrast landed #1538) | follow-on |
+| AsyncButton | `type` applied after spread (default `button`; explicit `submit` preserved). Prefer `Button` busy API for new sites. | **done** (PR-A) |
+| IconButton | disabled encoding uses `controlDisabled` (opacity retired in PR-A) | **done** (PR-A) |
+| ToggleSwitch | operable branch requires `aria-label`; opacity disabled retired; knob still animates `left`/`right` | PR 9 (motion) |
+| Chip | 20px remove target · `removeLabel` required when removable · no full-value path | PR 12 (target size) |
+| TextField/SearchField/Select | hint dropped on error (comment promises otherwise) · describedBy overwritten · no external id/refs (placeholder off decoration tier in PR-A) | PR 7 |
+| Checkbox/RadioGroup | RadioGroup controlled/uncontrolled union done; raw dimensions · unsanitised ids · no group hint/error | PR 7 |
+| Citation/CitationList | interactive requires `onActivate`; static form uses `aria-label` on span · index keys · unstructured data | follow-on |
+| DoseLine | must compose `Quantity` · structured dose model · overdue text + non-colour mark + open action | PR 6 |
+| StatusMark | app-type coupling · inline styles/raw geometry · HCM token remaps proven (computed suite); mark shape still visual | PR 12 |
+| AnswerCard | unrestricted slots — no required verification/answer state | PR 6 |
+| AnswerFooter | accepts preformatted strings; must take machine values + compose `DateDisplay` + `MissingValue` | PR 6 |
+| PageHeader/Breadcrumb | `` truncates · actions starve title · eyebrow ink moved off decoration (PR 3); layout starve remains | PR 7-adjacent layout fix |
+| Tabs | `aria-controls` to unrendered panels · invalid selected value can empty the tab order · split `SegmentedControl` | PR 4-adjacent, own tranche |
+| Pagination | unclamped props · 320px overflow · opacity disabled retired · no focus/announce policy | PR 8 |
+| Links | `tone` leaks to DOM · `download` overridable by spread · `gap` animation · new-tab policy implicit | PR 9 |
+| Tooltip | overwrites child handlers/describedBy · no portal/collision/delay · hardcoded z | PR 10 |
+| Toast | z below `--z-toast` · warning=danger icon · urgency coupled to tone · no pause on hover/focus · no portal/queue cap | PR 10 |
+| Sheet/ConfirmDialog | optional name · portal default off · hardcoded z/duration · title truncates · bare "Confirm" default | PR 10 |
+| Disclosure | hardcoded `` · no print behaviour · truncation | PR 11 |
+| Progress/StageList | width animation · "step 0 of N" · whole-list live region | PR 8, PR 9 |
+| EmptyState | `live="polite"` by default | PR 8 |
+| AccessibleTable | div caption vs `aria-label` · optional caption · uppercase dense headers · **bare `-` for missing cells** · inline equal widths · hand-rolled expander | PR 6 (`MissingValue`), PR 12 |
+| ui-primitives.tsx | 572-line module mixing recipes/actions/feedback/clinical — split | PR 12 |
---
diff --git a/docs/design-system/GATES.md b/docs/design-system/GATES.md
index 3bd7636ec3..8e7273fc45 100644
--- a/docs/design-system/GATES.md
+++ b/docs/design-system/GATES.md
@@ -39,7 +39,7 @@ those rules as gates.
| # | Gate | Status | Evidence / path to blocking |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 1 | Contrast ≥4.5:1 for every text/background pair, both themes, live **and** v2; `--decoration-soft` asserted below 4.5 and never on a text node | **implemented-partial** | v2 pairs above are tested; the full pair matrix, live-layer pairs, and the "never on a text node" AST check are planned (PR 3 extends). |
+| 1 | Contrast ≥4.5:1 for every text/background pair, both themes, live **and** v2; `--decoration-soft` asserted below 4.5 and never on a text node | **implemented-blocking** | v2 pairs + `--text-placeholder` + `--decoration-soft` tier + recipe AST (`decoration-on-text.contract.test.ts`); live full matrix remains follow-on. |
| 2 | Tap targets ≥48px interactive; static pills never carry `min-h-tap`; no production target reduced | **implemented-partial** | Legacy-class and literal ratchets run; the 48px value itself lands in PR 5b (until then the token honestly says 44); geometry assertions live in the held visual harness. |
| 3 | Focus outline present, `--focus`, no companion ring | **planned** | `--focus` is referenced nowhere in the DS export (finding N3). Global fix first, then the check. |
| 4 | Non-colour encoding on every status indicator | **manual → planned** | `MissingValue`/`DoseLine` text marks arrive in PR 6; contract test follows. |
@@ -68,33 +68,33 @@ as design tokens · export/preview parity.
## 3 · The prohibition table
-| Never do this | Gate | Status |
-| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
-| Reintroduce `.ckb-v2:not(.dark)` | Cascade computed-style test (PR 1) + contract-test selector filter update | implemented-blocking — landed #1538; contract parser + dark-ink regression guard |
-| Add `prefers-color-scheme` rules to the v2 layer | — (users pin a theme against OS preference) | **manual** |
-| Paint a numeral with a status colour; use amber/red decoratively | Colour-boundary lint | planned |
-| Put `--decoration-soft`/`--text-soft` on a text node | Tier pinned both sides in the token contract; text-node AST check | implemented-partial |
-| Uppercase a dose unit | `Quantity` pins `normal-case` + unit test | implemented-blocking (branch) |
-| Invert a PDF, diagram or clinical image in any theme | `DocumentFrame` refuses transforms; CSS check | planned |
-| Border **and** ring on one surface, or a 1px spread in a drop shadow | Gate 8 | implemented-partial |
-| A child shadow heavier than its parent's | Gate 7 | implemented-partial |
-| Use `--shadow-tight`/any alias in new code | PR 9 recipe cleanup, then lint | planned |
-| Raw pixel size, padding, radius or line-height in markup | Contract ratchet | implemented-partial (colour/shadow/tap literals only) |
-| Animate `width`, `height`, `grid-template-*`, `top`, `left`, `gap` | Gate 9 | planned |
-| Hardcode a transition duration | Gate 9 | planned |
-| A `z-` value outside the named rungs | `require-z-index-ladder` (ESLint) | implemented-blocking |
-| Ship a registered component without a direct test | Gate 12 | implemented-partial |
-| Lower a production tap target below 48px | Gate 2 + `ui-smoke` flake history + held visual probe | implemented-partial |
-| Construct an enabled control with no action, destination or name | PR 4 discriminated unions + contract tests; `require-button-wiring` covers raw `` today | implemented-partial |
-| Render `AnswerCard` without a verification and answer state | PR 6 required props (type-level) | planned |
-| Use a bare dash for a missing clinical value | `MissingValue` + review | planned |
-| Render "0 matches" after a failed request | `ErrorState` adoption + check | planned |
-| Hand-rolled rows replacing `AccessibleTable` without declaring it | — | **manual** (declaration convention) |
-| Drop the AI verification disclaimer | PR 6 required prop | planned |
-| Fix a collapsed column by nudging pixels | 320px snapshot | planned (held harness) |
-| `dark:` colour override | Lint | planned (3 live instances to burn down) |
-| Truncate a page title, dialog title, drug name or review warning | — | **manual** |
-| Restate a token value in prose | — | **manual** (eleven divergences came from this; TOKENS.md is the only inventory) |
+| Never do this | Gate | Status |
+| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
+| Reintroduce `.ckb-v2:not(.dark)` | Cascade computed-style test (PR 1) + contract-test selector filter update | implemented-blocking — landed #1538; contract parser + dark-ink regression guard |
+| Add `prefers-color-scheme` rules to the v2 layer | — (users pin a theme against OS preference) | **manual** |
+| Paint a numeral with a status colour; use amber/red decoratively | Colour-boundary lint | planned |
+| Put `--decoration-soft`/`--text-soft` on a text node | Tier pinned both sides in the token contract; recipe AST (`decoration-on-text.contract.test.ts`) | implemented-blocking (recipes); broader AST follow-on |
+| Uppercase a dose unit | `Quantity` pins `normal-case` + unit test | implemented-blocking (branch) |
+| Invert a PDF, diagram or clinical image in any theme | `DocumentFrame` refuses transforms; CSS check | planned |
+| Border **and** ring on one surface, or a 1px spread in a drop shadow | Gate 8 | implemented-partial |
+| A child shadow heavier than its parent's | Gate 7 | implemented-partial |
+| Use `--shadow-tight`/any alias in new code | PR 9 recipe cleanup, then lint | planned |
+| Raw pixel size, padding, radius or line-height in markup | Contract ratchet | implemented-partial (colour/shadow/tap literals only) |
+| Animate `width`, `height`, `grid-template-*`, `top`, `left`, `gap` | Gate 9 | planned |
+| Hardcode a transition duration | Gate 9 | planned |
+| A `z-` value outside the named rungs | `require-z-index-ladder` (ESLint) | implemented-blocking |
+| Ship a registered component without a direct test | Gate 12 | implemented-partial |
+| Lower a production tap target below 48px | Gate 2 + `ui-smoke` flake history + held visual probe | implemented-partial |
+| Construct an enabled control with no action, destination or name | PR 4 discriminated unions + DOM contracts; `require-button-wiring` covers raw `` | implemented-blocking (Citation/Chip/ToggleSwitch/RadioGroup) |
+| Render `AnswerCard` without a verification and answer state | PR 6 required props (type-level) | planned |
+| Use a bare dash for a missing clinical value | `MissingValue` + review | planned |
+| Render "0 matches" after a failed request | `ErrorState` adoption + check | planned |
+| Hand-rolled rows replacing `AccessibleTable` without declaring it | — | **manual** (declaration convention) |
+| Drop the AI verification disclaimer | PR 6 required prop | planned |
+| Fix a collapsed column by nudging pixels | 320px snapshot | planned (held harness) |
+| `dark:` colour override | Lint | planned (3 live instances to burn down) |
+| Truncate a page title, dialog title, drug name or review warning | — | **manual** |
+| Restate a token value in prose | — | **manual** (eleven divergences came from this; TOKENS.md is the only inventory) |
## 3a · Rules added by the perfection pass (31 July, second half)
diff --git a/docs/design-system/SPEC.md b/docs/design-system/SPEC.md
index b22bf6b425..23d3a7406b 100644
--- a/docs/design-system/SPEC.md
+++ b/docs/design-system/SPEC.md
@@ -178,14 +178,14 @@ headers, zebra) and `--surface-inset` (wells, inputs) — plus `--surface-wash`
**Ink roles, in full** (the incomplete hierarchy is why the decoration token kept escaping
onto text):
-| Role | Token | Rule |
-| ------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
-| Label | `--text` | Sentence case, label weight. Not muted, not 600, not uppercase. |
-| Entered value | `--text` | |
-| Hint / caption | `--text-muted` | |
-| Placeholder | `--text-placeholder` _(planned, PR 3)_, `--text-muted` until then | Placeholder is still text; it carries the expected format in clinical data entry. Must clear 4.5:1. |
-| Decorative glyph, divider, rule | `--decoration-soft` | **Never a text node.** Measured 3.07:1. |
-| Disabled label | `--disabled` | With an explicit contrast contract. |
+| Role | Token | Rule |
+| ------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------- |
+| Label | `--text` | Sentence case, label weight. Not muted, not 600, not uppercase. |
+| Entered value | `--text` | |
+| Hint / caption | `--text-muted` | |
+| Placeholder | `--text-placeholder` | Placeholder is still text; it carries the expected format in clinical data entry. Must clear 4.5:1. |
+| Decorative glyph, divider, rule | `--decoration-soft` | **Never a text node.** Measured 3.07:1. |
+| Disabled label | `--disabled` | With an explicit contrast contract. |
`--decoration-soft` is the canonical name; `--text-soft` is a deprecated alias to the same
value — the token was misused three times _because_ its name contained "text". The contract
@@ -427,8 +427,9 @@ the element. Every component takes `className`.
pagination. **Remove unnecessary client boundaries** — most component files are
`"use client"` without a hook.
-**The disabled encoding is the `controlBase` recipe, not opacity.** Ten `disabled:opacity`
-uses remain across three files — finishing the adoption is PR 3.
+**The disabled encoding is the `controlBase` recipe, not opacity.** The ten design-system
+`disabled:opacity*` recipes across `ui-primitives.tsx`, `tabs.tsx`, and `pagination.tsx`
+were retired in PR 3 / PR-A (`disabled-encoding.contract.test.ts`).
Per-component defect inventory and dispositions: COMPONENTS.md §Maturity and §Existing-defects.
@@ -629,13 +630,13 @@ and adoption.** Status keys as in the header; "done" entries cite their commit.
### Phase 1 — correctness
-| PR | Contents | Status |
-| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
-| PR 0 · Truth correction | Retractions, gate labelling, accurate packaging contract — zero code change | **done** — this document set |
-| PR 1 · Theme cascade | Port `.ckb-v2` / `.dark .ckb-v2, .ckb-v2.dark`; update the contract test's selector filter and block names in the same commit; computed-style tests, ancestor **and** same-node | **done** — landed #1538 (cascade port + contract parser + dark-ink regression) |
-| PR 2 · Forced colours | v2 HCM block over the three selectors (§4.2); computed assertions: filled command, filled danger, status marks, disabled, focus, flattened elevation | partial — HCM block + three selectors landed (#1538); transparent backdrop this PR; computed assertions still open |
-| PR 3 · Contrast and text roles | `--danger-solid-contrast` on danger · eyebrows and placeholders off the decoration tier · `--text-placeholder` role · finish the disabled encoding across the 10 remaining `disabled:opacity` sites · extend the contrast gate to live tokens | partial — danger contrast landed (#1538); rest open |
-| PR 4 · Interaction contracts | Discriminated unions for `Citation`, `Chip`, `ToggleSwitch`; `RadioGroup` controlled-or-uncontrolled; `AsyncButton` `type="button"` or retirement | open |
+| PR | Contents | Status |
+| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
+| PR 0 · Truth correction | Retractions, gate labelling, accurate packaging contract — zero code change | **done** — this document set |
+| PR 1 · Theme cascade | Port `.ckb-v2` / `.dark .ckb-v2, .ckb-v2.dark`; update the contract test's selector filter and block names in the same commit; computed-style tests, ancestor **and** same-node | **done** — landed #1538 (cascade port + contract parser + dark-ink regression) |
+| PR 2 · Forced colours | v2 HCM block over the three selectors (§4.2); computed assertions: filled command, filled danger, status marks, disabled, focus, flattened elevation | **done** — HCM remaps + Chromium computed suite + token-contract source pins |
+| PR 3 · Contrast and text roles | `--danger-solid-contrast` on danger · eyebrows and placeholders off the decoration tier · `--text-placeholder` role · finish the disabled encoding across the 10 remaining `disabled:opacity` sites · extend the contrast gate to live tokens | **done** — `--text-placeholder`, eyebrow/placeholder off decoration, 10 opacities retired, Gate 1 extended |
+| PR 4 · Interaction contracts | Discriminated unions for `Citation`, `Chip`, `ToggleSwitch`; `RadioGroup` controlled-or-uncontrolled; `AsyncButton` `type="button"` or retirement | **done** — Citation/Chip/ToggleSwitch/RadioGroup unions; AsyncButton `type` after spread |
### Phase 2 — values, split three ways
diff --git a/src/app/ckb-v2-tokens.css b/src/app/ckb-v2-tokens.css
index 379ddf04c1..32875a06d4 100644
--- a/src/app/ckb-v2-tokens.css
+++ b/src/app/ckb-v2-tokens.css
@@ -1,20 +1,21 @@
/* 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.
+ * Source of truth: THIS FILE (`src/app/ckb-v2-tokens.css`), ranked #2 under
+ * AGENTS.md per docs/design-system/SPEC.md. Committed tests are #3. The design
+ * project (08d6f126…) mirrors this reconciled copy — a divergent design export
+ * is not authoritative (DECISIONS C1 / TOKENS.md).
*
- * 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.
+ * Structural half (space, type, radius, elevation, density, icons, motion) and
+ * shell colours are both class-scoped here, so adopting the layer cannot
+ * repaint the live app. 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 (PR-K) 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).
+ * - `--text-soft` / `--decoration-soft` are ~3.2:1 on white — decoration only
+ * (dots, dividers, glyphs). Label and caption TEXT uses `--text-muted`;
+ * placeholders use `--text-placeholder` (both ≥4.5: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.
@@ -200,6 +201,9 @@
--text-heading: #0a1220;
--text: #1b2533;
--text-muted: #55627a;
+ /* Placeholder is still text (expected format in clinical data entry) — must
+ clear 4.5:1. Same ink as muted until a distinct placeholder step is needed. */
+ --text-placeholder: #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
@@ -267,6 +271,7 @@
--text: #f4f6f8;
--disabled: #626c7a;
--text-muted: #a8b2bd;
+ --text-placeholder: #a8b2bd;
--text-soft: #7d8792;
--decoration-soft: #7d8792;
--border-dashed: #454e57;
@@ -318,16 +323,28 @@
--text-heading: CanvasText;
--text: CanvasText;
--text-muted: CanvasText;
+ --text-placeholder: CanvasText;
--text-soft: GrayText;
--decoration-soft: GrayText;
--disabled: GrayText;
- --command: LinkText;
- --command-hover: LinkText;
- --command-active: LinkText;
- --command-contrast: Canvas;
+ /* Match the live HCM lesson (globals.css): Chromium paints a Canvas
+ backplate behind glyph runs, so command fills use the native button
+ pairing — not LinkText/Canvas, which blanks labels and swallows icons. */
+ --command: ButtonFace;
+ --command-hover: ButtonFace;
+ --command-active: ButtonFace;
+ --command-contrast: ButtonText;
+ --clinical-accent: LinkText;
--clinical-accent-soft: Canvas;
--clinical-accent-border: CanvasText;
+ --clinical-accent-contrast: ButtonText;
--primary-soft: Canvas;
+ --success: CanvasText;
+ --warning: CanvasText;
+ --danger: Mark;
+ --danger-solid: Mark;
+ --danger-solid-contrast: MarkText;
+ --focus: Highlight;
--ring-hairline: 0 0 0 1px CanvasText;
--e0: none;
--e1: none;
diff --git a/src/app/globals.css b/src/app/globals.css
index 89bf81f9ae..6a62567f38 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -196,6 +196,9 @@
--text-heading: #080b0f;
--text-muted: var(--neutral-600);
--text-soft: var(--neutral-500);
+ /* Placeholder is still text (≥4.5:1). Same ink as muted until a distinct step
+ is needed; also declared on the v2 layer for opt-in adoption. */
+ --text-placeholder: var(--neutral-600);
--border: #e3e8ef;
--border-strong: #c8d2de;
/* Tracks --border-strong at 55% — re-derive both together. */
@@ -437,6 +440,7 @@
--text-heading: var(--neutral-950);
--text-muted: var(--neutral-600);
--text-soft: var(--neutral-500);
+ --text-placeholder: var(--neutral-600);
/* Per-theme values, not neutral-ramp aliases: tracking --neutral-300 left the
dark border at ~1.35:1 against --surface, i.e. effectively invisible. */
--border: #2b3136;
@@ -2957,6 +2961,7 @@ html[data-motion="reduced"] .source-capsule-hit[aria-expanded="true"]:hover .sou
--text: CanvasText;
--text-muted: CanvasText;
--text-soft: CanvasText;
+ --text-placeholder: CanvasText;
--border: ButtonBorder;
--border-strong: ButtonText;
--primary: LinkText;
diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx
index 4a1f471e9a..aeac362077 100644
--- a/src/components/DocumentViewer.tsx
+++ b/src/components/DocumentViewer.tsx
@@ -1670,7 +1670,7 @@ export function DocumentViewer({
value={sourceSearch}
onChange={(event) => setSourceSearch(event.target.value)}
placeholder="Search within this document..."
- className="min-h-tap min-w-0 flex-1 bg-transparent px-2 text-base font-medium text-[color:var(--text)] outline-none placeholder:text-[color:var(--text-soft)]"
+ className="min-h-tap min-w-0 flex-1 bg-transparent px-2 text-base font-medium text-[color:var(--text)] outline-none placeholder:text-[color:var(--text-placeholder)]"
/>
onChange(event.target.value)}
placeholder={copy.searchPlaceholder}
- className="w-full min-w-0 bg-transparent text-sm font-medium text-[color:var(--text)] placeholder:text-[color:var(--text-soft)] focus:outline-none"
+ className="w-full min-w-0 bg-transparent text-sm font-medium text-[color:var(--text)] placeholder:text-[color:var(--text-placeholder)] focus:outline-none"
/>
setQuery(event.target.value)}
placeholder="Search by scale, symptom, or indication"
aria-label="Search calculators"
- className="min-w-0 bg-transparent text-sm font-semibold text-[color:var(--text)] placeholder:text-[color:var(--text-soft)] focus:outline-none"
+ className="min-w-0 bg-transparent text-sm font-semibold text-[color:var(--text)] placeholder:text-[color:var(--text-placeholder)] focus:outline-none"
/>
diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx
index 008bc33d7c..b084ea3ea5 100644
--- a/src/components/clinical-dashboard/master-search-header.tsx
+++ b/src/components/clinical-dashboard/master-search-header.tsx
@@ -1180,7 +1180,7 @@ export function MasterSearchHeader({
value={filterText(scopeFilters[field.key])}
onChange={(event) => updateTextScopeFilter(field.key, event.target.value)}
placeholder={field.placeholder}
- className="h-tap min-w-0 rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] px-2 text-xs font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] outline-none placeholder:text-[color:var(--text-soft)] focus:border-[color:var(--clinical-accent)] focus:ring-4 focus:ring-[color:var(--clinical-accent)]/20"
+ className="h-tap min-w-0 rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] px-2 text-xs font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] outline-none placeholder:text-[color:var(--text-placeholder)] focus:border-[color:var(--clinical-accent)] focus:ring-4 focus:ring-[color:var(--clinical-accent)]/20"
/>
))}
@@ -1220,7 +1220,7 @@ export function MasterSearchHeader({
data-testid="document-scope-filter"
aria-label="Filter document scope"
placeholder="Filter documents by title or file"
- className="h-tap w-full rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] pl-9 pr-3 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] outline-none transition placeholder:text-[color:var(--text-soft)] focus:border-[color:var(--clinical-accent)] focus:ring-4 focus:ring-[color:var(--clinical-accent)]/20"
+ className="h-tap w-full rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] pl-9 pr-3 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] outline-none transition placeholder:text-[color:var(--text-placeholder)] focus:border-[color:var(--clinical-accent)] focus:ring-4 focus:ring-[color:var(--clinical-accent)]/20"
/>
diff --git a/src/components/formulation/formulation-builder-page.tsx b/src/components/formulation/formulation-builder-page.tsx
index ae41a4aaf7..89c290f620 100644
--- a/src/components/formulation/formulation-builder-page.tsx
+++ b/src/components/formulation/formulation-builder-page.tsx
@@ -367,7 +367,7 @@ export function FormulationBuilderPage({
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search mechanisms or patient language..."
- className="min-h-12 w-full rounded-lg border border-[color:var(--border-strong)] bg-[color:var(--surface)] pl-10 pr-3 text-sm font-semibold text-[color:var(--text)] outline-none placeholder:text-[color:var(--text-soft)] focus:border-[color:var(--focus)] focus:ring-4 focus:ring-[color:var(--focus)]/20"
+ className="min-h-12 w-full rounded-lg border border-[color:var(--border-strong)] bg-[color:var(--surface)] pl-10 pr-3 text-sm font-semibold text-[color:var(--text)] outline-none placeholder:text-[color:var(--text-placeholder)] focus:border-[color:var(--focus)] focus:ring-4 focus:ring-[color:var(--focus)]/20"
/>
@@ -535,7 +535,7 @@ export function FormulationBuilderPage({
rows={3}
aria-label={section.label}
placeholder="Add de-identified case evidence..."
- className="mt-3 min-h-24 w-full resize-y rounded-lg border border-[color:var(--border-strong)] bg-[color:var(--surface-raised)] p-3 text-sm font-medium leading-6 text-[color:var(--text)] outline-none placeholder:text-[color:var(--text-soft)] focus:border-[color:var(--focus)] focus:ring-4 focus:ring-[color:var(--focus)]/20"
+ className="mt-3 min-h-24 w-full resize-y rounded-lg border border-[color:var(--border-strong)] bg-[color:var(--surface-raised)] p-3 text-sm font-medium leading-6 text-[color:var(--text)] outline-none placeholder:text-[color:var(--text-placeholder)] focus:border-[color:var(--focus)] focus:ring-4 focus:ring-[color:var(--focus)]/20"
/>
{suggestions.length ? (
@@ -597,7 +597,7 @@ export function FormulationBuilderPage({
rows={4}
aria-label={prompt.label}
placeholder="Record a concise review note..."
- className="mt-3 min-h-28 w-full resize-y rounded-lg border border-[color:var(--border-strong)] bg-[color:var(--surface-raised)] p-3 text-sm font-medium leading-6 text-[color:var(--text)] outline-none placeholder:text-[color:var(--text-soft)] focus:border-[color:var(--focus)] focus:ring-4 focus:ring-[color:var(--focus)]/20"
+ className="mt-3 min-h-28 w-full resize-y rounded-lg border border-[color:var(--border-strong)] bg-[color:var(--surface-raised)] p-3 text-sm font-medium leading-6 text-[color:var(--text)] outline-none placeholder:text-[color:var(--text-placeholder)] focus:border-[color:var(--focus)] focus:ring-4 focus:ring-[color:var(--focus)]/20"
/>
{starter.length ? (
diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx
index f51678a16e..91b9532a2f 100644
--- a/src/components/ui-primitives.tsx
+++ b/src/components/ui-primitives.tsx
@@ -53,27 +53,26 @@ 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 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)] forced-colors:border ${controlDisabled}`;
+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)] forced-colors:border ${controlDisabled}`;
+// Eyebrows are text (section kickers), so they sit on `--text-muted` (≥4.5:1),
+// never the decoration tier. Uppercase + tracking keep the kicker role.
+export const eyebrowText =
+ "text-2xs font-semibold uppercase leading-4 tracking-[0.06em] text-[color:var(--text-muted)]";
// 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.
+// case, label weight, full-strength ink.
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";
+ "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-placeholder)] 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 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`;
export const fieldControlPlain = `${fieldControl} px-3`;
export const fieldIcon =
"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[color:var(--text-soft)]";
export const shellChip =
"inline-flex min-h-tap items-center gap-2 rounded-lg border px-3 text-xs font-semibold shadow-[var(--shadow-inset)] transition hover:border-[color:var(--border-strong)]";
-export const navPill =
- "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-xs font-semibold text-[color:var(--text-muted)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] 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";
+export const navPill = `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-xs font-semibold text-[color:var(--text-muted)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] forced-colors:border ${controlDisabled}`;
export const metadataPill =
"inline-flex min-h-7 items-center rounded-md border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] px-2 text-xs font-semibold text-[color:var(--text-muted)] shadow-[var(--shadow-inset)]";
export const subtleStatusPill =
@@ -95,8 +94,7 @@ export const chatAnswerText =
"max-w-[68ch] text-base-minus font-medium leading-prose text-[color:var(--text-heading)] sm:text-base";
export const chatActionRow =
"flex min-h-tap flex-wrap items-center gap-1.5 text-xs font-semibold text-[color:var(--text-heading)] sm:min-h-8";
-export const chatMicroAction =
- "inline-flex min-h-tap min-w-tap items-center justify-center gap-1.5 rounded-md px-2 text-xs font-semibold text-[color:var(--text-muted)] transition hover:bg-[color:var(--clinical-accent-soft)] hover:text-[color:var(--clinical-accent)] 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";
+export const chatMicroAction = `inline-flex min-h-tap min-w-tap items-center justify-center gap-1.5 rounded-md px-2 text-xs font-semibold text-[color:var(--text-muted)] transition hover:bg-[color:var(--clinical-accent-soft)] hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] ${controlDisabled}`;
// Answer "Sources" capsule. `sourceCapsuleHit` is an invisible 44px WCAG touch
// target that wraps the compact visible pill `sourceCapsule` (`.source-capsule-face`),
// so the control reads smaller and lighter without shrinking the tap area. Hover,
@@ -123,8 +121,7 @@ export const tableCardHeader =
"border-b border-[color:var(--border)] bg-[color:var(--clinical-chat-table-header)] px-3 py-2.5 text-sm font-semibold text-[color:var(--text-heading)]";
export const tableMicroActionRow =
"flex min-h-tap flex-wrap items-center gap-1 border-t border-[color:var(--border)] px-2 py-1.5 text-xs font-semibold text-[color:var(--text-muted)] sm:min-h-9";
-export const sidebarItem =
- "flex min-h-tap min-w-0 w-full items-center gap-2 overflow-hidden rounded-lg px-2.5 text-sm font-semibold text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)] 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";
+export const sidebarItem = `flex min-h-tap min-w-0 w-full items-center gap-2 overflow-hidden rounded-lg px-2.5 text-sm font-semibold text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] ${controlDisabled}`;
const statusDotBase = "inline-block h-2 w-2 shrink-0 rounded-full";
export const statusDotReady = `${statusDotBase} bg-[color:var(--success)]`;
export const statusDotReview = `${statusDotBase} bg-[color:var(--warning)]`;
@@ -171,10 +168,16 @@ type AsyncButtonProps = Omit
, "children"
idleIcon?: ReactNode;
};
-/** Shared busy-state contract for async actions: one label, spinner, disabled state, and announcement hook. */
-export function AsyncButton({ busy, busyLabel, children, disabled, idleIcon, ...props }: AsyncButtonProps) {
+/**
+ * Shared busy-state contract for async actions. Prefer `Button` with
+ * `busy`/`busyLabel` for new call sites — this helper remains for existing
+ * forms that pass a ReactNode idle icon. `type` is applied AFTER the spread so
+ * a missing type cannot open a surrounding form, while an explicit
+ * `type="submit"` still wins.
+ */
+export function AsyncButton({ busy, busyLabel, children, disabled, idleIcon, type, ...props }: AsyncButtonProps) {
return (
-
+
{busy ? : idleIcon}
{busy ? busyLabel : children}
@@ -208,7 +211,8 @@ export function IconButton({ label, icon: Icon, className, iconClassName, type,
type={type ?? "button"}
aria-label={label}
className={cn(
- "grid size-tap shrink-0 place-items-center rounded-lg transition 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",
+ "grid size-tap shrink-0 place-items-center rounded-lg transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
+ controlDisabled,
className,
)}
>
@@ -277,21 +281,33 @@ export function semanticChipTone(tone: SemanticChipTone | undefined | null) {
return toneNeutral;
}
+type ToggleSwitchBase = {
+ enabled: boolean;
+ className?: string;
+ disabled?: boolean;
+};
+
+export type ToggleSwitchProps = ToggleSwitchBase &
+ (
+ | {
+ /** Operable switch — requires an accessible name. */
+ onToggle: () => void;
+ "aria-label": string;
+ }
+ | {
+ /** Read-only presentational indicator (no interactive role). */
+ onToggle?: undefined;
+ "aria-label"?: string;
+ }
+ );
+
export function ToggleSwitch({
enabled,
className,
onToggle,
disabled = false,
"aria-label": ariaLabel,
-}: {
- enabled: boolean;
- className?: string;
- // When provided the switch is an operable control; when omitted it renders as a
- // read-only presentational indicator (no interactive role is advertised).
- onToggle?: () => void;
- disabled?: boolean;
- "aria-label"?: string;
-}) {
+}: ToggleSwitchProps) {
const track = cn(
"relative inline-flex h-6 w-10 shrink-0 rounded-full transition",
enabled ? "bg-[color:var(--clinical-accent)]" : "bg-[color:var(--border-strong)]",
@@ -318,7 +334,8 @@ export function ToggleSwitch({
onClick={onToggle}
className={cn(
track,
- "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",
+ "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
+ controlDisabled,
)}
>
{knob}
@@ -327,9 +344,15 @@ export function ToggleSwitch({
}
// Read-only: expose the state as an image label so assistive tech announces
- // on/off without implying the control can be operated.
+ // on/off without implying the control can be operated. Unlabeled indicators
+ // are decorative — hide them from the accessibility tree.
return (
-
+
{knob}
);
diff --git a/src/components/ui/chip.tsx b/src/components/ui/chip.tsx
index dcdb6f5a53..1e7bc79f31 100644
--- a/src/components/ui/chip.tsx
+++ b/src/components/ui/chip.tsx
@@ -22,21 +22,22 @@ const DOT: Record = {
danger: "bg-[color:var(--danger)]",
};
-export type ChipProps = {
+type ChipBase = {
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;
};
+/**
+ * Removable chips require a per-chip `removeLabel` — a row of identical "Remove"
+ * buttons is unusable by voice or screen reader.
+ */
+export type ChipProps = ChipBase &
+ ({ onRemove: () => void; removeLabel: string } | { onRemove?: never; removeLabel?: never });
+
// 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.
@@ -65,7 +66,7 @@ export function Chip({
diff --git a/src/components/ui/choice.tsx b/src/components/ui/choice.tsx
index 976b929ad9..6a97105984 100644
--- a/src/components/ui/choice.tsx
+++ b/src/components/ui/choice.tsx
@@ -125,19 +125,40 @@ export type RadioGroupProps = {
label: string;
name: string;
options: RadioOption[];
- value?: string;
- onChange?: (value: string) => void;
hideLabel?: boolean;
className?: string;
-};
+} & (
+ | {
+ /** Controlled mode — value and onChange travel together. */
+ value: string;
+ onChange: (value: string) => void;
+ defaultValue?: never;
+ }
+ | {
+ /** Uncontrolled mode — native radio state, optional initial value. */
+ value?: never;
+ onChange?: never;
+ defaultValue?: 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) {
+export function RadioGroup({
+ label,
+ name,
+ options,
+ value,
+ onChange,
+ defaultValue,
+ hideLabel,
+ className,
+}: RadioGroupProps) {
const groupId = useId();
+ const controlled = value !== undefined;
return (
@@ -163,10 +184,11 @@ export function RadioGroup({ label, name, options, value, onChange, hideLabel, c
type="radio"
name={name}
value={option.value}
- checked={value === option.value}
+ checked={controlled ? value === option.value : undefined}
+ defaultChecked={!controlled ? defaultValue === option.value : undefined}
disabled={option.disabled}
aria-describedby={descId}
- onChange={() => onChange?.(option.value)}
+ onChange={controlled ? () => onChange(option.value) : undefined}
className="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-full disabled:cursor-not-allowed"
/>
void;
- /** Rendered as static when there is nowhere to go (print, export). */
- interactive?: boolean;
className?: string;
};
+/**
+ * Interactive citation requires an activation handler. Static (print/export)
+ * citations set `interactive={false}` and must not advertise an action.
+ */
+export type CitationProps = CitationBase &
+ ({ interactive?: true; onActivate: () => void } | { interactive: false; onActivate?: never });
+
/**
* 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
diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx
index 4331f92d07..d5c0a7502e 100644
--- a/src/components/ui/pagination.tsx
+++ b/src/components/ui/pagination.tsx
@@ -41,7 +41,7 @@ export function Pagination({
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";
+ "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:border-[color:var(--border)] disabled:bg-[color:var(--surface-subtle)] disabled:text-[color:var(--disabled)] disabled:shadow-none";
return (
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
index 7192108fa5..81d8b276ba 100644
--- a/src/components/ui/tabs.tsx
+++ b/src/components/ui/tabs.tsx
@@ -101,7 +101,7 @@ export function Tabs({ items, value, onChange, label, variant = "tabs", classNam
disabled={item.disabled}
onClick={() => onChange(item.id)}
className={cn(
- "inline-flex min-h-tap shrink-0 items-center gap-2 whitespace-nowrap px-3 text-sm font-semibold transition 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",
+ "inline-flex min-h-tap shrink-0 items-center gap-2 whitespace-nowrap px-3 text-sm font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:border-[color:var(--border)] disabled:bg-[color:var(--surface-subtle)] disabled:text-[color:var(--disabled)] disabled:shadow-none",
segmented ? "rounded-md" : "-mb-px rounded-t-md border-b-2",
segmented
? selected
diff --git a/tests/ckb-v2-token-contract.test.ts b/tests/ckb-v2-token-contract.test.ts
index 6d466d87bf..c4f0a39863 100644
--- a/tests/ckb-v2-token-contract.test.ts
+++ b/tests/ckb-v2-token-contract.test.ts
@@ -87,6 +87,11 @@ describe("ckb-v2 layer stays opt-in", () => {
const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
expect(globals).toContain('@import "./ckb-v2-tokens.css";');
});
+
+ it("keeps --text-placeholder on the live layer too, so production placeholders resolve before .ckb-v2 adoption", () => {
+ const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
+ expect(globals).toMatch(/--text-placeholder\s*:/);
+ });
});
describe("ckb-v2 borders", () => {
@@ -178,6 +183,57 @@ describe("ckb-v2 command colour (#1, #12)", () => {
});
});
+describe("ckb-v2 source-of-truth header", () => {
+ it("claims this repo file as canonical, not a design-project export", () => {
+ const header = stylesheet.slice(0, stylesheet.indexOf("\n.ckb-v2"));
+ expect(header).not.toMatch(/Source of truth:\s*the design project's/i);
+ expect(header).toMatch(/Source of truth:\s*THIS FILE/i);
+ expect(header).toMatch(/AGENTS\.md/);
+ });
+});
+
+describe("ckb-v2 placeholder and decoration roles (PR 3 / Gate 1)", () => {
+ it("declares --text-placeholder at ≥4.5:1 on both shells", () => {
+ expect(
+ contrastRatio(hexOf(lightShell, "--text-placeholder"), hexOf(lightShell, "--surface")),
+ ).toBeGreaterThanOrEqual(4.5);
+ expect(contrastRatio(hexOf(darkShell, "--text-placeholder"), hexOf(darkShell, "--surface"))).toBeGreaterThanOrEqual(
+ 4.5,
+ );
+ });
+
+ it("pins --decoration-soft as a decoration tier, twin to --text-soft", () => {
+ const soft = contrastRatio(hexOf(lightShell, "--text-soft"), hexOf(lightShell, "--surface"));
+ const decoration = contrastRatio(hexOf(lightShell, "--decoration-soft"), hexOf(lightShell, "--surface"));
+ expect(decoration).toBeLessThan(4.5);
+ expect(decoration).toBeGreaterThanOrEqual(3);
+ expect(hexOf(lightShell, "--decoration-soft")).toBe(hexOf(lightShell, "--text-soft"));
+ expect(soft).toBe(decoration);
+ });
+});
+
+describe("ckb-v2 forced-colours block (PR 2)", () => {
+ it("remaps filled command/danger, focus, disabled, and flattens elevation under all three selectors", () => {
+ const start = stylesheet.indexOf("@media (forced-colors: active)");
+ expect(start).toBeGreaterThan(-1);
+ const block = stylesheet.slice(start, stylesheet.indexOf("\n}", stylesheet.lastIndexOf("--spine-stale")) + 2);
+ expect(block).toContain(".ckb-v2,");
+ expect(block).toContain(".dark .ckb-v2,");
+ expect(block).toContain(".ckb-v2.dark");
+ expect(block).toMatch(/--command:\s*ButtonFace/);
+ expect(block).toMatch(/--command-contrast:\s*ButtonText/);
+ expect(block).toMatch(/--danger-solid:\s*Mark/);
+ expect(block).toMatch(/--danger-solid-contrast:\s*MarkText/);
+ expect(block).toMatch(/--focus:\s*Highlight/);
+ expect(block).toMatch(/--disabled:\s*GrayText/);
+ expect(block).toMatch(/--success:\s*CanvasText/);
+ expect(block).toMatch(/--warning:\s*CanvasText/);
+ for (const tier of ["--e1", "--e2", "--e3", "--e4", "--glow-primary", "--glow-soft", "--shadow-well"]) {
+ expect(block).toMatch(new RegExp(`${tier}:\\s*none`));
+ }
+ });
+});
+
describe("ckb-v2 structure", () => {
it("keeps --shadow-well a true inset and leaves the --shadow-inset bevel un-overridden (#40, C1)", () => {
// The v1 value was `0 0 0 1px …`, which is a ring — that is why inputs looked
diff --git a/tests/decoration-on-text.contract.test.ts b/tests/decoration-on-text.contract.test.ts
new file mode 100644
index 0000000000..a1d28d7cf3
--- /dev/null
+++ b/tests/decoration-on-text.contract.test.ts
@@ -0,0 +1,27 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+/**
+ * Gate 1 / PR 3 — design-system recipes must not put the decoration tier on
+ * text nodes (eyebrows, placeholders). Decorative glyphs may still use
+ * `--text-soft` / `--decoration-soft`.
+ */
+
+const primitives = readFileSync(new URL("../src/components/ui-primitives.tsx", import.meta.url), "utf8");
+
+describe("decoration-on-text contracts", () => {
+ it("keeps eyebrowText on a text-tier token, not --text-soft", () => {
+ const match = primitives.match(/export const eyebrowText\s*=\s*"([^"]+)"/);
+ expect(match, "eyebrowText recipe missing").toBeTruthy();
+ expect(match![1]).not.toContain("--text-soft");
+ expect(match![1]).not.toContain("--decoration-soft");
+ expect(match![1]).toContain("--text-muted");
+ });
+
+ it("keeps fieldControl placeholders on --text-placeholder", () => {
+ const match = primitives.match(/export const fieldControl\s*=\s*"([^"]+)"/);
+ expect(match, "fieldControl recipe missing").toBeTruthy();
+ expect(match![1]).toContain("placeholder:text-[color:var(--text-placeholder)]");
+ expect(match![1]).not.toContain("placeholder:text-[color:var(--text-soft)]");
+ });
+});
diff --git a/tests/disabled-encoding.contract.test.ts b/tests/disabled-encoding.contract.test.ts
new file mode 100644
index 0000000000..6689b8da74
--- /dev/null
+++ b/tests/disabled-encoding.contract.test.ts
@@ -0,0 +1,26 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+/**
+ * PR 3 — the disabled encoding is the `controlBase` / `controlDisabled` recipe,
+ * not opacity. The SPEC §6 set is these three design-system files.
+ */
+
+const FILES = [
+ "../src/components/ui-primitives.tsx",
+ "../src/components/ui/tabs.tsx",
+ "../src/components/ui/pagination.tsx",
+] as const;
+
+describe("disabled encoding contracts", () => {
+ it("has zero disabled:opacity* recipes in the design-system control files", () => {
+ const hits: string[] = [];
+ for (const rel of FILES) {
+ const source = readFileSync(new URL(rel, import.meta.url), "utf8");
+ for (const match of source.matchAll(/disabled:opacity-\d+/g)) {
+ hits.push(`${rel}: ${match[0]}`);
+ }
+ }
+ expect(hits).toEqual([]);
+ });
+});
diff --git a/tests/ui-primitives.dom.test.tsx b/tests/ui-primitives.dom.test.tsx
index 9a14925ca4..565ca0ca89 100644
--- a/tests/ui-primitives.dom.test.tsx
+++ b/tests/ui-primitives.dom.test.tsx
@@ -1,8 +1,9 @@
import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { Search } from "lucide-react";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
-import { AsyncButton, EmptyState } from "@/components/ui-primitives";
+import { AsyncButton, EmptyState, ToggleSwitch } from "@/components/ui-primitives";
describe("EmptyState", () => {
it("keeps recovery actions inside an announced state surface", () => {
@@ -53,4 +54,37 @@ describe("AsyncButton", () => {
expect(button).toBeEnabled();
expect(button).not.toHaveAttribute("aria-busy");
});
+
+ it("defaults to type=button after the props spread", () => {
+ render(
+
+ Save
+ ,
+ );
+ expect(screen.getByRole("button")).toHaveAttribute("type", "button");
+ });
+
+ it("preserves an explicit type=submit for form actions", () => {
+ render(
+
+ Save
+ ,
+ );
+ expect(screen.getByRole("button")).toHaveAttribute("type", "submit");
+ });
+});
+
+describe("ToggleSwitch", () => {
+ it("requires a name when operable", async () => {
+ const onToggle = vi.fn();
+ render( );
+ await userEvent.click(screen.getByRole("switch", { name: "Pregnancy" }));
+ expect(onToggle).toHaveBeenCalledOnce();
+ });
+
+ it("renders a non-interactive indicator without onToggle", () => {
+ render( );
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument();
+ expect(screen.getByRole("img", { name: "Available: on" })).toBeInTheDocument();
+ });
});
diff --git a/tests/ui-style-contract.spec.ts b/tests/ui-style-contract.spec.ts
index 4db1bdcaee..4c987cb5c5 100644
--- a/tests/ui-style-contract.spec.ts
+++ b/tests/ui-style-contract.spec.ts
@@ -136,3 +136,68 @@ test.describe("unlayered style rules render their effect", () => {
expect(audit.undersized, "controls rendered below their declared min-height").toEqual([]);
});
});
+
+/**
+ * PR 2 — computed HCM proofs for the opt-in `.ckb-v2` layer under all three
+ * cascade selectors. Class-string checks are not accepted (GATES.md).
+ *
+ * Chromium-only: WebKit has no forced-colors implementation (same skip as
+ * ui-accessibility solid-button glyph lock).
+ */
+const CKB_V2_HCM_SELECTORS = [".ckb-v2", ".dark .ckb-v2", ".ckb-v2.dark"] as const;
+
+const CKB_V2_HCM_EXPECTED: Record = {
+ "--command": "ButtonFace",
+ "--command-contrast": "ButtonText",
+ "--danger-solid": "Mark",
+ "--danger-solid-contrast": "MarkText",
+ "--focus": "Highlight",
+ "--disabled": "GrayText",
+ "--success": "CanvasText",
+ "--warning": "CanvasText",
+ "--e2": "none",
+ "--e4": "none",
+ "--glow-primary": "none",
+ "--glow-soft": "none",
+ "--shadow-well": "none",
+ "--overlay-backdrop": "transparent",
+};
+
+test.describe("ckb-v2 forced-colours computed tokens", () => {
+ test.skip(({ browserName }) => browserName !== "chromium", "forced-colors remaps are Chromium-only under Playwright");
+
+ for (const selector of CKB_V2_HCM_SELECTORS) {
+ test(`${selector} remaps command/danger/focus/disabled and flattens elevation`, async ({ page }) => {
+ // Load the real app stylesheet (imports ckb-v2-tokens.css), then inject probes.
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.emulateMedia({ forcedColors: "active" });
+
+ const values = await page.evaluate(
+ ({ sel, keys }) => {
+ document.documentElement.classList.remove("dark");
+ const root = document.createElement("div");
+ if (sel === ".ckb-v2") {
+ root.className = "ckb-v2";
+ } else if (sel === ".dark .ckb-v2") {
+ document.documentElement.classList.add("dark");
+ root.className = "ckb-v2";
+ } else {
+ root.className = "ckb-v2 dark";
+ }
+ document.body.append(root);
+ const style = getComputedStyle(root);
+ const out: Record = {};
+ for (const key of keys) {
+ out[key] = style.getPropertyValue(key).trim();
+ }
+ return out;
+ },
+ { sel: selector, keys: Object.keys(CKB_V2_HCM_EXPECTED) },
+ );
+
+ for (const [token, expected] of Object.entries(CKB_V2_HCM_EXPECTED)) {
+ expect(values[token], `${selector} ${token}`).toBe(expected);
+ }
+ });
+ }
+});
diff --git a/tests/ui-v2-components.dom.test.tsx b/tests/ui-v2-components.dom.test.tsx
index 9139f5bdc4..8fa33ad6ae 100644
--- a/tests/ui-v2-components.dom.test.tsx
+++ b/tests/ui-v2-components.dom.test.tsx
@@ -6,7 +6,9 @@ import { describe, expect, it, vi } from "vitest";
import { AnswerFooter, DoseLine } from "@/components/ui/answer-card";
import { Button } from "@/components/ui/button";
+import { Citation } from "@/components/ui/citation";
import { Chip } from "@/components/ui/chip";
+import { RadioGroup } from "@/components/ui/choice";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Pagination } from "@/components/ui/pagination";
import { Tabs } from "@/components/ui/tabs";
@@ -71,8 +73,12 @@ describe("Chip", () => {
const onRemove = vi.fn();
render(
<>
- WA
- Current only
+
+ WA
+
+
+ Current only
+
>,
);
@@ -82,6 +88,41 @@ describe("Chip", () => {
});
});
+describe("Citation", () => {
+ it("activates when interactive and named", async () => {
+ const onActivate = vi.fn();
+ render( );
+ await userEvent.click(screen.getByRole("button", { name: /Source 1, RANZCP/ }));
+ expect(onActivate).toHaveBeenCalledOnce();
+ });
+
+ it("renders a static mark when interactive is false", () => {
+ render( );
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ expect(screen.getByTestId("citation")).toHaveAttribute("aria-label", expect.stringContaining("NICE"));
+ });
+});
+
+describe("RadioGroup", () => {
+ it("keeps controlled value and onChange paired", async () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByLabelText("Newest"));
+ expect(onChange).toHaveBeenCalledWith("newest");
+ });
+});
+
describe("Tabs", () => {
const items = [
{ id: "answer", label: "Answer" },