diff --git a/docs/design-system/GATES.md b/docs/design-system/GATES.md index 76dc1528e2..202948c35b 100644 --- a/docs/design-system/GATES.md +++ b/docs/design-system/GATES.md @@ -18,7 +18,7 @@ than CI, two of them by the second reader; review does not scale past two carefu | Check | What it enforces | Label | | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -| `npm run check:design-system-contract` (token baseline + adoption + design-sync contracts) | Raw colour literals (ratchet against `design-system-contract-baseline.json`), literal `shadow-[…]` classes, the legacy tap class, interactive tap-size literals, therapy-compass `tc-btn` base-class use; deterministic adoption truth; local source/export/preview/design-sync parity. Mockups exempt. | implemented-blocking | +| `npm run check:design-system-contract` (token baseline + adoption + design-sync contracts) | Raw colour literals (ratchet against `design-system-contract-baseline.json`), literal `shadow-[…]` classes, the legacy tap class, interactive tap-size literals, therapy-compass `tc-btn` base-class use; `dark:` colour overrides and legacy palette utilities (both pinned at **zero**, not ratcheted); deterministic adoption truth; local source/export/preview/design-sync parity. Mockups exempt. | implemented-blocking | | `tests/ckb-v2-token-contract.test.ts` | v2 layer stays class-scoped (no `:root` leak) · `--border-lux` solid and no lighter than `--border`, both themes · dark surface ramp monotonic and separated · `--surface-subtle` aliases up · text/muted/heading ≥4.5:1 on the light shell, muted ≥4.5:1 dark · `--text-soft` pinned **below** 4.5:1 and ≥3:1 (both sides, so the tier cannot be "fixed" away) · command pair ≥4.5:1 both themes · `--shadow-inset` true inset · elevation ladder carries no baked 1px hairline · tap floor ≠ chip height ≠ compact row · reduced-motion zeroes durations · every type step has its own line-height and tracking | implemented-blocking | | `tests/design-token-contract.test.ts` | Live-layer token relationships (companion to the v2 contract) | implemented-blocking | | `tests/source-badges-off-vocab.dom.test.tsx` | Enum resilience: off-vocabulary `clinical_validation_status` degrades to the neutral triad, logs once, never throws | implemented-blocking | @@ -107,7 +107,7 @@ theme-list parity, and remote design-project publication remain separate concern | Hand-rolled rows replacing `AccessibleTable` without declaring it | — | **manual** (declaration convention) | | Drop the AI verification disclaimer | PR 6 required prop | implemented-blocking in `AnswerCard`; production shell wording remains separately guarded during adoption | | Fix a collapsed column by nudging pixels | 320px snapshot | planned (held harness) | -| `dark:` colour override | Lint | planned (3 live instances to burn down) | +| `dark:` colour override | `check:design-system-contract` — `darkColorOverrides`, baseline 0 | **implemented-blocking** (7 Aug 2026) — all 3 instances retired; see §4 | | 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) | @@ -136,3 +136,27 @@ Recorded at export (31 July 2026), **not re-run for this document set**: The honest sentence includes the failure. Exit code 0 alone is never proof — quote the "N passed" line. + +### `dark:` colour override — closed 7 August 2026 + +The §3 row moved from **planned** to **implemented-blocking**. What that took: + +- All three live instances were the same shape — `ring-1 ring-/20 +dark:ring-/10` on a lux surface (`ui-primitives.tsx` `panel`, the mobile FAB menu + in `dashboard-nav.tsx`, the PWA notice card). They are now `--ring-highlight` / + `--ring-glass`, per-theme tokens carrying the value the override used to supply, so the + utility no longer needs a variant. `legacyPaletteUtilities` (the `ring-white/35` on the + same PWA line) went to zero with them. +- **No visual change**, and that is measured rather than assumed: Tailwind emits + `color-mix(in oklab, N%, transparent)` for `ring-/N`, and mixing any + colour with `transparent` yields that colour at alpha N regardless of colour space, so the + token values are equivalent by construction. `--ring-glass` is deliberately literal white + and **not** `--neutral-0`, which inverts to `#060708` in dark and would have turned a white + glass ring near-black. +- Both metrics are pinned at **0** in `design-system-contract-baseline.json`, not ratcheted. + The check asserts `value <= baseline`, so zero is a hard floor. Mutation-verified: + reintroducing one `dark:ring-white/10` fails with `darkColorOverrides increased from 0 to +1` plus the matching per-path assertion. +- Every other metric was asserted unchanged before the baseline was rewritten — the + discipline §8 of the handover describes, so clearing one number cannot silently move + another. diff --git a/docs/design-system/TOKENS.md b/docs/design-system/TOKENS.md index e054dd9f2f..e8a3e34aef 100644 --- a/docs/design-system/TOKENS.md +++ b/docs/design-system/TOKENS.md @@ -104,6 +104,7 @@ The v2 layer _references_ or _depends on_ these; their values stay in `live` / ` | `--danger`, `--danger-solid`, `--danger-solid-contrast` | `live` | Filled danger pairs with `--danger-solid-contrast` (HCM-mapped to `MarkText`); pairing enforced from PR 3. | | `--success`, `--info` | `live` | Clinical/status palette — reserved channel. | | `--focus` | `live` | The only focus outline colour; no companion ring, ever. | +| `--ring-highlight`, `--ring-glass` | `live` | Highlight rings on lux surfaces; each carries its own per-theme value, so `dark:ring-*` is gated at zero (GATES §3). | | `--text-placeholder` | `planned` (PR 3) | New role ≥4.5:1; until it exists, placeholders use `--text-muted`. | ## 6 · Deprecations and deletions diff --git a/scripts/design-system-contract-baseline.json b/scripts/design-system-contract-baseline.json index c94e56b7f3..028312f924 100644 --- a/scripts/design-system-contract-baseline.json +++ b/scripts/design-system-contract-baseline.json @@ -7,8 +7,8 @@ "onePixelShadowSpreads": 2, "hardcodedCssMotionDurations": 42, "rawCssZIndices": 9, - "legacyPaletteUtilities": 2, - "darkColorOverrides": 3, + "legacyPaletteUtilities": 0, + "darkColorOverrides": 0, "legacyShadowAliases": 231, "arbitraryTracking": 0, "layoutTransitionExceptions": 12, @@ -47,14 +47,8 @@ "rawCssZIndices": { "src/app/globals.css": 9 }, - "legacyPaletteUtilities": { - "src/components/pwa-lifecycle.tsx": 2 - }, - "darkColorOverrides": { - "src/components/clinical-dashboard/dashboard-nav.tsx": 1, - "src/components/pwa-lifecycle.tsx": 1, - "src/components/ui-primitives.tsx": 1 - }, + "legacyPaletteUtilities": {}, + "darkColorOverrides": {}, "legacyShadowAliases": { "src/app/globals.css": 9, "src/app/layout.tsx": 1, diff --git a/src/app/globals.css b/src/app/globals.css index f461df7bdc..17ee21e81e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -260,6 +260,19 @@ --border-strong: #c8d2de; /* Tracks --border-strong at 55% — re-derive both together. */ --border-lux: rgb(200 210 222 / 55%); + /* Highlight rings on lux surfaces (panel, mobile FAB menu, PWA notice). These + exist so the ring carries its own per-theme value and the call site does not + need a `dark:` override — the prohibition GATES §3 lists, now gated at zero. + Values are byte-equivalent to the utilities they replace: Tailwind emits + `color-mix(in oklab, N%, transparent)` for `ring-/N`, and + mixing any colour with `transparent` yields that colour at alpha N + regardless of space, so the glass ring is written in the plainer rgb() form + the .dark --surface-highlight already uses. + --ring-highlight tracks --border-strong, 20% light / 10% dark + --ring-glass literal white, 35% light / 10% dark — deliberately NOT + --neutral-0, which inverts to #060708 in dark */ + --ring-highlight: color-mix(in oklab, var(--border-strong) 20%, transparent); + --ring-glass: rgb(255 255 255 / 35%); --primary: var(--primary-500); --primary-strong: var(--primary-700); --primary-soft: #eff5fc; @@ -532,6 +545,10 @@ --border: #2b3136; --border-strong: #3b444b; --border-lux: rgba(255, 255, 255, 0.1); + /* Dark halves both highlight rings — the exact values the retired + `dark:ring-*` overrides carried. See the :root declarations. */ + --ring-highlight: color-mix(in oklab, var(--border-strong) 10%, transparent); + --ring-glass: rgb(255 255 255 / 10%); --primary: var(--primary-500); --primary-strong: var(--primary-700); diff --git a/src/components/clinical-dashboard/dashboard-nav.tsx b/src/components/clinical-dashboard/dashboard-nav.tsx index 6297a88c48..6af42e4f01 100644 --- a/src/components/clinical-dashboard/dashboard-nav.tsx +++ b/src/components/clinical-dashboard/dashboard-nav.tsx @@ -274,7 +274,7 @@ export function MobileSectionFab({ aria-hidden={!open} inert={!open} hidden={!open} - className="fixed z-40 overflow-hidden rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] text-[color:var(--text)] shadow-[var(--shadow-lux)] ring-1 ring-[color:var(--border-strong)]/20 backdrop-blur-md dark:ring-[color:var(--border-strong)]/10" + className="fixed z-40 overflow-hidden rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] text-[color:var(--text)] shadow-[var(--shadow-lux)] ring-1 ring-[color:var(--ring-highlight)] backdrop-blur-md" style={{ right: "max(0.75rem, env(safe-area-inset-right))", bottom: "calc(max(0.75rem, env(safe-area-inset-bottom)) + 4.5rem)", diff --git a/src/components/pwa-lifecycle.tsx b/src/components/pwa-lifecycle.tsx index abbd0ce312..220f581f6e 100644 --- a/src/components/pwa-lifecycle.tsx +++ b/src/components/pwa-lifecycle.tsx @@ -101,7 +101,7 @@ async function teardownLocalPwa() { } const cardClassName = - "pwa-notice-card pointer-events-auto relative rounded-2xl border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] p-4 text-[color:var(--text)] shadow-[var(--shadow-lux)] ring-1 ring-white/35 backdrop-blur-md dark:ring-white/10"; + "pwa-notice-card pointer-events-auto relative rounded-2xl border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] p-4 text-[color:var(--text)] shadow-[var(--shadow-lux)] ring-1 ring-[color:var(--ring-glass)] backdrop-blur-md"; const primaryButtonClassName = "inline-flex min-h-tap items-center justify-center rounded-lg bg-[color:var(--clinical-accent)] px-3.5 py-2 text-sm font-semibold text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)] transition-colors hover:bg-[color:var(--clinical-accent-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; const secondaryButtonClassName = diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx index 1e6d03b978..87d4dbdc7c 100644 --- a/src/components/ui-primitives.tsx +++ b/src/components/ui-primitives.tsx @@ -52,7 +52,7 @@ export const panelSubtle = export const sourceCard = `${panelSubtle} transition hover:border-[color:var(--border-strong)] hover:shadow-[var(--shadow-hover)]`; export const answerSurface = "rounded-lg bg-transparent"; export const panel = - "rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] shadow-[var(--shadow-soft)] ring-1 ring-[color:var(--border-strong)]/20 dark:ring-[color:var(--border-strong)]/10"; + "rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] shadow-[var(--shadow-soft)] ring-1 ring-[color:var(--ring-highlight)]"; // Disabled is ENCODED, not faded. `opacity-50` dims the label and the fill // together, so a disabled primary stayed a large saturated block that still read // as available, and a disabled secondary's label dropped below 4.5:1. Instead: