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 ` @@ -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({