From d23f5b5e422a4a2a355985a85251f10a96f81a35 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 2 Aug 2026 04:55:36 +0800 Subject: [PATCH 1/4] =?UTF-8?q?Close=20DS=20V2=20correctness=20slices=202?= =?UTF-8?q?=E2=80=934=20(HCM,=20contrast/disabled,=20prop=20unions).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prove forced-colours remaps under the three .ckb-v2 selectors, move placeholders and eyebrows off the decoration tier, retire design-system disabled:opacity recipes, harden Citation/Chip/ToggleSwitch/RadioGroup contracts, and correct the token-file SoT header. Co-authored-by: Cursor --- docs/design-system/COMPONENTS.md | 52 ++++++------- docs/design-system/GATES.md | 56 +++++++------- docs/design-system/SPEC.md | 32 ++++---- src/app/ckb-v2-tokens.css | 47 ++++++++---- src/components/DocumentViewer.tsx | 4 +- src/components/applications-launcher-page.tsx | 4 +- src/components/calculators/directory-grid.tsx | 4 +- .../master-search-header.tsx | 6 +- .../formulation/formulation-builder-page.tsx | 8 +- src/components/ui-primitives.tsx | 73 ++++++++++++------- src/components/ui/chip.tsx | 17 +++-- src/components/ui/choice.tsx | 34 +++++++-- src/components/ui/citation.tsx | 12 ++- src/components/ui/pagination.tsx | 2 +- src/components/ui/tabs.tsx | 2 +- tests/ckb-v2-hcm.computed.spec.ts | 66 +++++++++++++++++ tests/ckb-v2-token-contract.test.ts | 51 +++++++++++++ tests/decoration-on-text.contract.test.ts | 27 +++++++ tests/disabled-encoding.contract.test.ts | 26 +++++++ tests/ui-primitives.dom.test.tsx | 38 +++++++++- tests/ui-v2-components.dom.test.tsx | 45 +++++++++++- 21 files changed, 456 insertions(+), 150 deletions(-) create mode 100644 tests/ckb-v2-hcm.computed.spec.ts create mode 100644 tests/decoration-on-text.contract.test.ts create mode 100644 tests/disabled-encoding.contract.test.ts diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 9616803bf2..9282b8745c 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 | danger contrast token · brightness hover/active bypass tokens · 44px comment · no ref · needless client boundary | PR 3, PR 4 | +| 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 · placeholder on the decoration tier · no external id/refs | 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} 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({