From 6b45f8817d8340237fe592f3cf5e4bdc174c95f3 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:40:03 +0800 Subject: [PATCH] feat(design-system): gate the `dark:` colour-override prohibition at zero (#264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GATES.md §3 listed "`dark:` colour override" as **planned** with three live instances. A prohibition with no gate row is a suggestion, which is the failure mode that document exists to prevent. This closes it. All three instances were the same shape — `ring-1 ring-/20 dark:ring-/10` on a lux surface (`panel` in ui-primitives, the mobile FAB menu in dashboard-nav, the PWA notice card). They are now `--ring-highlight` and `--ring-glass`: per-theme tokens that carry 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 the contract baseline rather than 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, so clearing one number could not silently move another. Deliberately not fixed here, having been triaged and rejected as unsafe for a minimal change: `onePixelShadowSpreads` (2) are `0 0 0 1px … inset` border-substitutes whose conversion changes the box model, and `rawColorLiterals` (2) are a medication accent DATA fallback under a documented colour contract, not markup paint. Co-Authored-By: Claude Opus 5 --- docs/design-system/GATES.md | 28 +++++++++++++++++-- docs/design-system/TOKENS.md | 1 + scripts/design-system-contract-baseline.json | 14 +++------- src/app/globals.css | 17 +++++++++++ .../clinical-dashboard/dashboard-nav.tsx | 2 +- src/components/pwa-lifecycle.tsx | 2 +- src/components/ui-primitives.tsx | 2 +- 7 files changed, 51 insertions(+), 15 deletions(-) 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: