From 9d53416369a8024a5feb64f3006c43c3b2b874f3 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:58:32 +0800 Subject: [PATCH 1/6] docs(issues): close #218 and #270, both shipped before this session Both rows were still open in docs/outstanding-issues.md while their work was already live on main, which had scoped a third session from them. #218 (cn() lacks tailwind-merge) shipped in PR #1678, aeba5a254. src/components/ui-primitives.tsx:37 is twMergeClinical(...) rather than a plain join, package.json carries tailwind-merge ^3.6.0, and src/lib/tailwind-merge.ts declares the repo's @theme scales to twMerge. #270 (declare the tap spacing token) shipped in PR #1738, 80cf78139, an ancestor of origin/main. "tap" is present in CLINICAL_TWMERGE_THEME.spacing, and tests/tailwind-merge-config.test.ts was inverted rather than deleted so the merge behaviour is now asserted rather than pinned out. Verified in source at origin/main 7aaf9349c, not inferred from the handover. Co-Authored-By: Claude Opus 5 --- docs/outstanding-issues.md | 75 +++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index e29af4210..717b470c8 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -133,42 +133,41 @@ removed after current-main verification; it is not missing recommended work. | 78 | `#206` | A2 | Specialist — answer UI contract | With AnswerState producer work (`#207`) | 2–4 hours | `partial_retrieval` has no app-facing producer — decide RAG contract vs UI-only mapping before AnswerCard. **Stop:** no retrieval behaviour change without RAG flag. | | 79 | `#208` | A2 | Specialist — clinical copy | With answer clipboard / PR-13 work | 1–2 hours | `answerClipboardText` must not replace `formatAnswerRenderCopyText` — compose render-policy warnings. **Gate:** focused clipboard/copy tests. **Stop:** do not drop render-policy caveats. | | 80 | `#216` | A2 | High — design-system answer shell | After `#207` and clinical surface decision | 0.5–1 day | Adopt AnswerCard on the answer surface (deferred from PR-J). Own PR, own `verify:ui`. **Stop:** not before `#207`; show both surface treatments before choosing. | -| 81 | `#218` | A2 | High — design tokens / cn() | Before Chip/metadataPill convergence | 2–4 hours | `cn()` lacks tailwind-merge — decide merge vs explicit size variants. **Stop:** do not fix by stacking more className overrides. | -| 82 | `#230` | A2 | High — PR policy / CI | Next ci.yml / pr-policy change | 1–2 hours | PR-policy body sync must no-op unless `PR_POLICY_BODY.md` is new in that PR's own diff (or move body out of repo). **Gate:** `check:github-actions` / workflow self-test. **Stop:** do not re-commit scratch bodies to main. | -| 83 | `#232` | A2 | High — review ledger hygiene | Next ledger touch for PR-J | 30–60 min | Supersede the PR-J clinical-governance ledger row so it describes the merged head (`ledger:append --supersede`). **Stop:** append-only — never edit/delete the old row. | -| 84 | `#209` | A3 | High — design tokens / contrast | Next Gate 1 / warning-token pass | 1–2 hours | Add contrast pair for `--warning` used as body text (VerificationNotice / DoseLine). **Gate:** design-system contrast checks. **Stop:** do not invent a new status token without TOKENS.md. | -| 85 | `#211` | A3 | High — TypeScript strictness | Dedicated migration branch | multi-PR | Plan and start `noUncheckedIndexedAccess` migration (1266 errors); highest-risk files first. **Stop:** do not flip the flag on main without a staged plan. | -| 86 | `#212` | A3 | High — runtime validation | After highest-risk cast inventory | multi-PR | Replace `as unknown as` and unvalidated `JSON.parse` with Zod/guards at trust boundaries. **Stop:** RAG/provider boundaries need clinical/privacy care. | -| 87 | `#213` | A3 | High — error handling | Next fetch/stream hardening pass | 0.5–1 day | Stop swallowing fetch/stream errors with empty catches; check `response.ok`. **Stop:** do not change telemetry contracts silently. | -| 88 | `#215` | Optional | High — image perf | Next image/PWA pass | 2–4 hours | Image-optimization basics for lightbox, PWA lifecycle, demo PNGs. **Stop:** optional until measured need. | -| 89 | `#221` | A3 | High — design-system convergence | After `#218` cn() decision | 0.5–1 day | Converge remaining local EmptyState/LoadingState/Chip duplicates. **Stop:** not piecemeal before cn()/Chip decisions. | -| 90 | `#222` | A3 | High — headers / search chrome | During headers redesign decision | 2–4 hours | Decide whether mode-home-template / search-results-header-band are in PageHeader scope or permanently out. **Stop:** do not flatten phone composer ownership. | -| 91 | `#233` | A3 | High — design-system docs | Next COMPONENTS.md docs PR | 1–2 hours | Refresh section 0 maturity matrix and document FormField optionality-marker contract. **Gate:** docs checks. **Stop:** docs-only; no product behaviour change. | -| 92 | `#234` | A3 | High — design-system docs | With answer-surface docs | 30–60 min | Document `answer-copy-payload.ts` as the clipboard contract for three surfaces. **Stop:** do not add a second copy builder. | -| 93 | `#235` | A3 | High — design-system evidence | Next warmed local proof-shot pass | 1–2 hours | Capture missing ADOPTION.md §7 proof shots for adopted surfaces. **Stop:** not visual-baseline PNGs (`#118`); no Playwright snapshot commit. | -| 94 | `#236` | Optional | High — branch hygiene | Next cleanup batch with `#079` | 30–60 min | Dispose orphan DS V2 builder branches and leftover wave-5 dev servers. **Stop:** content-verify before delete; no force-clean. | -| 95 | `#237` | A3 | High — design-system a11y | Before freezing Linux visual baselines (#242) | 30–60 min | Eyeball low-confidence AccessibleTable densities at 320px; MissingValue phrases must remain readable. **Gate:** visual spot-check only. **Stop:** do not abbreviate MissingValue to a dash. | -| 96 | `#238` | A3 | High — overlays/UI | After Sheet portal default change (#1616) | 30–60 min | Visual pass for Sheet portal default on settings, sidebar, and answer overlays under OverlayRoot. **Stop:** do not revert portal default without evidence. | -| 97 | `#239` | Optional | High — phone chrome | When phone orientation QA is available | 15–30 min | Manual phone rotation check for ResizeObserver-only phone chrome reserve. **Gate:** `verify:phone-chrome` still owns automated coverage. **Stop:** do not widen reserve heuristics without reproduction. | -| 98 | `#240` | Optional | High — design tokens | Next design-owner review | 15–30 min | Confirm tooltip visual hard-clip asymmetry with design owner (sr-only keeps full text). **Stop:** no product change without that confirmation. | -| 99 | `#241` | A3 | High — therapy catalogue | Standing; with any therapy-home change | 15–30 min | Therapy home summary count/slugs remain build-time; keep `build-therapies-index --check` load-bearing. **Stop:** do not bypass the check. | -| 100 | `#242` | A2 | High — design-system baselines | After human review of Linux baselines | 1–2 hours | Commit approved Linux visual baselines and promote adoption not-committed → committed. **Stop:** never commit baselines from an unreviewed machine run. | -| 101 | `#244` | A3 | High — design tokens / forced-colors | With any ckb-v2 forced-colours edit | 15–30 min | Keep grouped dark selectors in the forced-colours media block so specificity matches dark rules. **Stop:** do not trim to a single `.ckb-v2.ckb-v2` selector. | -| 102 | `#245` | A3 | High — cross-mode links | Next CrossModeLinks / analytics pass | 30–60 min | responsive-compact CrossModeLinks keeps duplicate rails in the DOM; prefer one mount or accept test double-counts. **Stop:** do not break phone-only rail contract. | -| 103 | `#248` | A2 | Operator — Supabase + Specialist | After PR #1614 symptom repair; approved live/history window | 1–2 hours | Investigate why 20260705180000 search-health indexes were missing on live despite applied history; decide if drift checks should catch this class. **Stop:** no hosted mutation without approval. | -| 104 | `#249` | A3 | High — agent process | Next issues-skill / plan touch | 1–2 hours | Extend issues/plan with an agent-safe wins classifier (optional filter; no new skill unless reused thrice). **Stop:** do not outrank A1 operator work. | -| 105 | `#250` | A2 | High — multi-agent execution | After Wave 0 queue repair on main (done in this capture); run remaining Wave 0/#202 process gates next on the engineering track | multi-wave | Execute the fastest-wins multi-wave plan (Waves 0–4 + operator track) with parallel agents and per-PR gates. Waves do not outrank A1 acuity. **Stop:** provider/RAG approvals still required where flagged. | -| 106 | `#251` | Optional | High — agent process | Next handoff/gates doc touch | 15–30 min | Handoff checklist pairs gates skill with verification-router; paste decisive proof line. **Stop:** do not stack broad gates by default. | -| 107 | `#252` | A2 | High — bundling/gates | Next bundle-budget decision | 1–2 hours | Decide whether check:bundle-budget should exclude mockup chunks or keep counting them as hygiene; do not raise tolerance to clear #1580. **Stop:** do not --update without deciding. | -| 108 | `#253` | A2 | High — phone results UI | Next open-PR sweep | 15–30 min | Decide #1606's fate: the `MobileResultFilterControl` it rewrites was deleted by #247, so there is nothing left to hand-merge. Verify keyboard parity of the replacement sheet on a real device, then close #1606 as superseded. **Stop:** the decision is a human's; do not close #1606 automatically. | -| 109 | `#254` | A2 | Operator — Codex Cloud | Before #1617 leaves draft | 1–2 hours | Re-run Codex Cloud acceptance at the exact current head or mark head-independent evidence. **Stop:** do not treat stale pins as coverage. | -| 110 | `#255` | A2 | High — Cloud/browser gates | Next environment image update | 2–4 hours | Align Cloud Playwright browser builds with lockfile pin; document CI delegation until then. **Stop:** do not force mismatched Chromium revisions. | -| 111 | `#256` | A2 | High — mode section nav | Next information-page / mode-nav pass | 2–4 hours | Declared information-page section sets whose target ids nothing renders — verify each set against the rendered DOM per route; render anchors or delete the set. **Stop:** do not audit by grepping for `id=` alone (sectionId props exist). | -| 112 | `#257` | Optional | High — formulation/specifiers flake | Standing until second reproduction | 15–30 min | Single unreproduced ui-formulation flake when run with ui-specifiers — record a second sighting only; do not quarantine until three on the same SHA. **Stop:** do not weaken assertions. | -| 113 | `#286` | A3 | High — in-page nav + frontend | After owner go-ahead for the information-page series | 1–2 days | Convert the six pill-rail information pages onto `InPageNavHeader`, widen Server Component–safe actions, then delete `informationPageSectionDefinitions`. **Gate:** focused DOM/contract tests + `verify:phone-chrome` for touched owners. **Stop:** do not convert DocumentViewer here; do not verify anchors by grepping `id=` alone. | -| 114 | `#287` | A3 | High — in-page nav + clinical owner | After `#286`; medications needs an owner product call | 0.5–1 day design + convert | Decide medications tab model, presentations MobileTabs vs `InPageNavHeader`, and factsheets heading→id scheme; convert or record lasting exceptions. **Stop:** do not port medications mechanically. | -| 115 | `#288` | Optional | High — document chrome | After `#286`/`#287`, or when declaring the series complete | 30–60 min | Confirm DocumentViewer non-adoption (already noted in `docs/search-chrome-behaviour.md`) as the final end state, or schedule a separate convergence PR that leaves pinned `--document-*` CSS names untouched. | -| 116 | `#289` | A2 | High — auth/identity | Next auth module touch | 1–2 hours | Export a named helper (e.g. `authorizationIdentity(headers)`) from the auth module and use it at every property-access call site; consider a lint rule or branded type so `.Authorization` stops type-checking at all. **Stop:** do not change `authorizationHeadersForAccessToken` to emit uppercase — lowercase is the correct Fetch/Headers convention and callers that pass the object wholesale to `fetch` depend on it. | +| 81 | `#230` | A2 | High — PR policy / CI | Next ci.yml / pr-policy change | 1–2 hours | PR-policy body sync must no-op unless `PR_POLICY_BODY.md` is new in that PR's own diff (or move body out of repo). **Gate:** `check:github-actions` / workflow self-test. **Stop:** do not re-commit scratch bodies to main. | +| 82 | `#232` | A2 | High — review ledger hygiene | Next ledger touch for PR-J | 30–60 min | Supersede the PR-J clinical-governance ledger row so it describes the merged head (`ledger:append --supersede`). **Stop:** append-only — never edit/delete the old row. | +| 83 | `#209` | A3 | High — design tokens / contrast | Next Gate 1 / warning-token pass | 1–2 hours | Add contrast pair for `--warning` used as body text (VerificationNotice / DoseLine). **Gate:** design-system contrast checks. **Stop:** do not invent a new status token without TOKENS.md. | +| 84 | `#211` | A3 | High — TypeScript strictness | Dedicated migration branch | multi-PR | Plan and start `noUncheckedIndexedAccess` migration (1266 errors); highest-risk files first. **Stop:** do not flip the flag on main without a staged plan. | +| 85 | `#212` | A3 | High — runtime validation | After highest-risk cast inventory | multi-PR | Replace `as unknown as` and unvalidated `JSON.parse` with Zod/guards at trust boundaries. **Stop:** RAG/provider boundaries need clinical/privacy care. | +| 86 | `#213` | A3 | High — error handling | Next fetch/stream hardening pass | 0.5–1 day | Stop swallowing fetch/stream errors with empty catches; check `response.ok`. **Stop:** do not change telemetry contracts silently. | +| 87 | `#215` | Optional | High — image perf | Next image/PWA pass | 2–4 hours | Image-optimization basics for lightbox, PWA lifecycle, demo PNGs. **Stop:** optional until measured need. | +| 88 | `#221` | A3 | High — design-system convergence | After `#218` cn() decision | 0.5–1 day | Converge remaining local EmptyState/LoadingState/Chip duplicates. **Stop:** not piecemeal before cn()/Chip decisions. | +| 89 | `#222` | A3 | High — headers / search chrome | During headers redesign decision | 2–4 hours | Decide whether mode-home-template / search-results-header-band are in PageHeader scope or permanently out. **Stop:** do not flatten phone composer ownership. | +| 90 | `#233` | A3 | High — design-system docs | Next COMPONENTS.md docs PR | 1–2 hours | Refresh section 0 maturity matrix and document FormField optionality-marker contract. **Gate:** docs checks. **Stop:** docs-only; no product behaviour change. | +| 91 | `#234` | A3 | High — design-system docs | With answer-surface docs | 30–60 min | Document `answer-copy-payload.ts` as the clipboard contract for three surfaces. **Stop:** do not add a second copy builder. | +| 92 | `#235` | A3 | High — design-system evidence | Next warmed local proof-shot pass | 1–2 hours | Capture missing ADOPTION.md §7 proof shots for adopted surfaces. **Stop:** not visual-baseline PNGs (`#118`); no Playwright snapshot commit. | +| 93 | `#236` | Optional | High — branch hygiene | Next cleanup batch with `#079` | 30–60 min | Dispose orphan DS V2 builder branches and leftover wave-5 dev servers. **Stop:** content-verify before delete; no force-clean. | +| 94 | `#237` | A3 | High — design-system a11y | Before freezing Linux visual baselines (#242) | 30–60 min | Eyeball low-confidence AccessibleTable densities at 320px; MissingValue phrases must remain readable. **Gate:** visual spot-check only. **Stop:** do not abbreviate MissingValue to a dash. | +| 95 | `#238` | A3 | High — overlays/UI | After Sheet portal default change (#1616) | 30–60 min | Visual pass for Sheet portal default on settings, sidebar, and answer overlays under OverlayRoot. **Stop:** do not revert portal default without evidence. | +| 96 | `#239` | Optional | High — phone chrome | When phone orientation QA is available | 15–30 min | Manual phone rotation check for ResizeObserver-only phone chrome reserve. **Gate:** `verify:phone-chrome` still owns automated coverage. **Stop:** do not widen reserve heuristics without reproduction. | +| 97 | `#240` | Optional | High — design tokens | Next design-owner review | 15–30 min | Confirm tooltip visual hard-clip asymmetry with design owner (sr-only keeps full text). **Stop:** no product change without that confirmation. | +| 98 | `#241` | A3 | High — therapy catalogue | Standing; with any therapy-home change | 15–30 min | Therapy home summary count/slugs remain build-time; keep `build-therapies-index --check` load-bearing. **Stop:** do not bypass the check. | +| 99 | `#242` | A2 | High — design-system baselines | After human review of Linux baselines | 1–2 hours | Commit approved Linux visual baselines and promote adoption not-committed → committed. **Stop:** never commit baselines from an unreviewed machine run. | +| 100 | `#244` | A3 | High — design tokens / forced-colors | With any ckb-v2 forced-colours edit | 15–30 min | Keep grouped dark selectors in the forced-colours media block so specificity matches dark rules. **Stop:** do not trim to a single `.ckb-v2.ckb-v2` selector. | +| 101 | `#245` | A3 | High — cross-mode links | Next CrossModeLinks / analytics pass | 30–60 min | responsive-compact CrossModeLinks keeps duplicate rails in the DOM; prefer one mount or accept test double-counts. **Stop:** do not break phone-only rail contract. | +| 102 | `#248` | A2 | Operator — Supabase + Specialist | After PR #1614 symptom repair; approved live/history window | 1–2 hours | Investigate why 20260705180000 search-health indexes were missing on live despite applied history; decide if drift checks should catch this class. **Stop:** no hosted mutation without approval. | +| 103 | `#249` | A3 | High — agent process | Next issues-skill / plan touch | 1–2 hours | Extend issues/plan with an agent-safe wins classifier (optional filter; no new skill unless reused thrice). **Stop:** do not outrank A1 operator work. | +| 104 | `#250` | A2 | High — multi-agent execution | After Wave 0 queue repair on main (done in this capture); run remaining Wave 0/#202 process gates next on the engineering track | multi-wave | Execute the fastest-wins multi-wave plan (Waves 0–4 + operator track) with parallel agents and per-PR gates. Waves do not outrank A1 acuity. **Stop:** provider/RAG approvals still required where flagged. | +| 105 | `#251` | Optional | High — agent process | Next handoff/gates doc touch | 15–30 min | Handoff checklist pairs gates skill with verification-router; paste decisive proof line. **Stop:** do not stack broad gates by default. | +| 106 | `#252` | A2 | High — bundling/gates | Next bundle-budget decision | 1–2 hours | Decide whether check:bundle-budget should exclude mockup chunks or keep counting them as hygiene; do not raise tolerance to clear #1580. **Stop:** do not --update without deciding. | +| 107 | `#253` | A2 | High — phone results UI | Next open-PR sweep | 15–30 min | Decide #1606's fate: the `MobileResultFilterControl` it rewrites was deleted by #247, so there is nothing left to hand-merge. Verify keyboard parity of the replacement sheet on a real device, then close #1606 as superseded. **Stop:** the decision is a human's; do not close #1606 automatically. | +| 108 | `#254` | A2 | Operator — Codex Cloud | Before #1617 leaves draft | 1–2 hours | Re-run Codex Cloud acceptance at the exact current head or mark head-independent evidence. **Stop:** do not treat stale pins as coverage. | +| 109 | `#255` | A2 | High — Cloud/browser gates | Next environment image update | 2–4 hours | Align Cloud Playwright browser builds with lockfile pin; document CI delegation until then. **Stop:** do not force mismatched Chromium revisions. | +| 110 | `#256` | A2 | High — mode section nav | Next information-page / mode-nav pass | 2–4 hours | Declared information-page section sets whose target ids nothing renders — verify each set against the rendered DOM per route; render anchors or delete the set. **Stop:** do not audit by grepping for `id=` alone (sectionId props exist). | +| 111 | `#257` | Optional | High — formulation/specifiers flake | Standing until second reproduction | 15–30 min | Single unreproduced ui-formulation flake when run with ui-specifiers — record a second sighting only; do not quarantine until three on the same SHA. **Stop:** do not weaken assertions. | +| 112 | `#286` | A3 | High — in-page nav + frontend | After owner go-ahead for the information-page series | 1–2 days | Convert the six pill-rail information pages onto `InPageNavHeader`, widen Server Component–safe actions, then delete `informationPageSectionDefinitions`. **Gate:** focused DOM/contract tests + `verify:phone-chrome` for touched owners. **Stop:** do not convert DocumentViewer here; do not verify anchors by grepping `id=` alone. | +| 113 | `#287` | A3 | High — in-page nav + clinical owner | After `#286`; medications needs an owner product call | 0.5–1 day design + convert | Decide medications tab model, presentations MobileTabs vs `InPageNavHeader`, and factsheets heading→id scheme; convert or record lasting exceptions. **Stop:** do not port medications mechanically. | +| 114 | `#288` | Optional | High — document chrome | After `#286`/`#287`, or when declaring the series complete | 30–60 min | Confirm DocumentViewer non-adoption (already noted in `docs/search-chrome-behaviour.md`) as the final end state, or schedule a separate convergence PR that leaves pinned `--document-*` CSS names untouched. | +| 115 | `#289` | A2 | High — auth/identity | Next auth module touch | 1–2 hours | Export a named helper (e.g. `authorizationIdentity(headers)`) from the auth module and use it at every property-access call site; consider a lint rule or branded type so `.Authorization` stops type-checking at all. **Stop:** do not change `authorizationHeadersForAccessToken` to emit uppercase — lowercase is the correct Fetch/Headers convention and callers that pass the object wholesale to `fetch` depend on it. | @@ -274,7 +273,6 @@ removed after current-main verification; it is not missing recommended work. | #213 | P2 | task | Stop swallowing fetch and stream errors with empty catch handlers | 51 call sites use response.json().catch(() => ({})) or void fetch(...).catch(() => undefined), hiding API, JSON, auth, telemetry and stream failures. Add explicit response.ok checks and a typed safeFetch wrapper that preserves the failure signal for telemetry and user feedback. See docs/review-findings-2026-08-02.md sections 3.1-3.4 and 8. | session 2026-08-02 /ledger sweep — docs/review-findings-2026-08-02.md | 2026-08-02 | | #215 | P3 | task | Add image-optimization basics for lightbox, PWA lifecycle and demo PNGs | image-lightbox.tsx and pwa-lifecycle.tsx lack decoding=async; public/demo-documents/*.png are ~80 KB each and not served in WebP/AVIF; SignedImage lacks a priority prop for above-fold evidence images. Apply decoding=async, add an optional priority prop, and convert demo PNGs with a PNG fallback. Most performance image findings are already tracked under #016, #013, #117 and #147; this row captures the new image-only observations from the 2026-08-02 audit. | session 2026-08-02 /ledger sweep — docs/audit/performance-image-cwv-audit-2026-08-02.md | 2026-08-02 | | #216 | P2 | task | Adopt the AnswerCard container on the answer surface (deferred from PR-J) | PR-J adopted the answer safety components (VerificationNotice, RetrievalStateBanner, AnswerState projection, composed clipboard) but NOT AnswerCard itself, so it stays at zero product imports and PR 13's answer surface is adopted in substance, not in shell. The swap replaces the answerSurface wrapper with AnswerCard's article, its query echo with UserQuestionBubble, wraps NaturalLanguageAnswer in its --measure-clamped prose div, maps copy/feedback/follow-up onto its structured actions array, and adds AnswerFooter (needs publisher/version/reviewDate/generatedAt threaded). Deferred deliberately: two competing surface treatments must be resolved by the design owner (SPEC 2.4 border-or-ring), the measure clamp reflows every answer and moves the phone scroll-runway pins that cost PR-V two CI cycles (finding L), and bundling it would make a red verify:ui unattributable across PR-J's other five surfaces. Reasons recorded in docs/design-system/ADOPTION.md 2.6. Next action: own PR after PR-J lands and soaks, own verify:ui pass, own glance; show the user both surface treatments before choosing. | session 2026-08-03 (PR-J Wave 5 controller) | 2026-08-02 | -| #218 | P2 | issue | cn() has no tailwind-merge, so className size overrides resolve by stylesheet order rather than intent | cn() in src/components/ui-primitives.tsx is a plain join. Overriding a design-system component's text-xs with text-2xs via className emits two text-* utilities whose winner is stylesheet order, not the call site's intent. Concrete live instance: src/components/clinical-dashboard/document-search-results.tsx lines around 830, 870, 924 and 1341 do cn(metadataPill, ... text-2xs) while metadataPill already carries text-xs. This also blocked convergence of the local Chip in differentials-home.tsx, whose own doc comment bans the pattern. Next action: decide between adopting tailwind-merge in cn() (repo-wide blast radius, needs its own PR and gate) or adding explicit size variants to metadataPill and Chip. Do not fix by adding more className overrides. Found during PR-J adoption, 2026-08-03. | session 2026-08-03 (PR-J Wave 5, Builder B) | 2026-08-02 | | #221 | P3 | task | Local EmptyState, LoadingState and Chip duplicates still unconverged after PR-J | PR-J converged what it could inside its allowlists and left four known duplicates, each blocked for a stated reason rather than missed. therapy-compass/ui.tsx defines its own LoadingState AND its own EmptyState used across nine screens (whole-module job, not a one-call-site conversion). mode-home-template.tsx ModeHomeStatusNotice is an EmptyState duplicate that four catalogue homes delegate to, which is why those four files show no diff. differentials-home.tsx has a local two-density Chip blocked by the cn() tailwind-merge gap. favourites-command-library-page.tsx SmallChip is driven by an eight-entry type-token map that Chip's five-tone vocabulary cannot express. Next action: take these as one convergence PR after the cn() decision lands, not piecemeal. Found during PR-J adoption, 2026-08-03. | session 2026-08-03 (PR-J Wave 5, Builder B) | 2026-08-02 | | #222 | P3 | task | Headers surface only partially converged in PR-J: mode-home-template and search-results-header-band untouched | Builder A converged DsmPageHeader, InformationPageHeader and InformationPageBreadcrumbs onto PageHeader plus Breadcrumb, and declined two files with reasons. mode-home-template.tsx ModeHomeHero is a centred display hero on the fluid text-hero token and is the slot the in-flow phone composer sits in, so converging it onto a left-aligned PageHeader is a redesign of 13 mode homes that collides with the one-composer-per-page contract. search-results-header-band.tsx is a results spine carrying status, counts and filters, not a page-title stack, so its pin tests/search-results-header-band.dom.test.tsx remains unflipped. Both are defensible; both leave the headers surface partially adopted. Next action: decide whether either is in scope at all, or record them as permanently out of the PageHeader vocabulary. Found during PR-J adoption, 2026-08-03. | session 2026-08-03 (PR-J Wave 5, Builder A) | 2026-08-02 | | #226 | P1 | task | PR-J: VerificationNotice pushes the phone short-answer runway past the in-flow activation band | CI run 30820496984 reported the real numbers: ui-smoke phantom-scroll 97 against an 8px budget, short-runway maxOffset 251 against a 200px ceiling. Local readings for the same tree were 29 and passing, confirming finding L's 41-81px offset and the rule never to pin from this machine. Re-pinned from CI per the user decision: bare phantom budget 8 to 112, maxOffset ceiling 200 to 280, postCollapseMaxOffset ceiling 72 to 160. State plainly what that means: the 72px in-flow activation band was a real contract and the post-collapse runway no longer fits inside it, because every answer now carries an unconditional verification notice above the prose. The collapse budget is untouched - chrome height did not change, only the content below it. OPEN QUESTION for the clinical owner, separate from the pin: a one-sentence answer now carries roughly 97px of notice above it on a phone, which is what the phantom-scroll guard was originally written to prevent. Widening the pin accepts that as intended product behaviour. | session 2026-08-03 (PR-J Wave 5, gate window, verify:phone-chrome) | 2026-08-03 | @@ -312,7 +310,6 @@ removed after current-main verification; it is not missing recommended work. | #267 | P3 | task | DS Track B2: AnswerFooter and DoseLine need a provenance/dose payload the answer surface does not produce | Backend-shaped work, not a component swap: the two components cannot be adopted until the answer surface emits the provenance and dose data they render. Do not stub one to make the adoption count look better. Sequence after the payload exists, then adopt via the Track B1 demand-driven route. | session 2026-08-07 — design-system HANDOVER-2026-08-07 Track A1 handoff (PR #1678) | 2026-08-07 | | #268 | P3 | task | DS Track B3: move the 19 genuine bare-dash sites onto MissingValue | Therapy-compass getters, specifier sourceFamily, favourites counts when untrusted. Leave the roughly 5 calculator 'derived.started ? score : dash' sites PERMANENTLY — 'not started' is not a missing clinical value, MissingValueReason has no member for it, and converting them would render 'Not recorded' for a score the clinician simply has not entered. | session 2026-08-07 — design-system HANDOVER-2026-08-07 Track A1 handoff (PR #1678) | 2026-08-07 | | #269 | P2 | task | DS Track B4: prove the per-component visual state matrix (blocked on the baseline hold) | hover / active / disabled / busy / invalid / 320px / dark / forced-colours / print, per component. Currently proven for none. Blocked on #118: zero visual baselines are committed and the harness is continue-on-error, so nothing in Track B is safe at scale until baselines exist. CORRECTION 2026-08-08: the claim that baselines cannot be generated on Windows is half true and led to the wrong conclusion. It is true that snapshotPathTemplate carries {platform}, so win32 PNGs are invisible to the ubuntu CI job — but the CI job already produces the ubuntu ones. .github/workflows/ci.yml job visual-baseline runs on ubuntu-24.04 whenever ui_changed, runs npm run test:e2e:visual, and uploads tests/__screenshots__/ as artifact visual-baseline-; playwright.visual.config.ts records that on a missing baseline Playwright writes the golden and fails the first attempt, which is why retries are pinned at 0. So the mechanism exists and adoption is mechanical — see #118. Stop rule unchanged: do not commit baselines until the owner declares the design final, and do not adopt them from a developer machine. | session 2026-08-07 — design-system HANDOVER-2026-08-07 Track A1 handoff (PR #1678) | 2026-08-07 | -| #270 | P2 | task | Declaring tap in tailwind-merge is no longer blocked by dead numeric heights — the 22-site premise did not survive re-measurement | Follow-up from #218 / PR #1678. RE-MEASURED 2026-08-08 against origin/main 00826bca2 and the row's premise does not hold at this HEAD. Method: scan every string literal in src/**.ts(x) (mockups excluded, comments blanked — backticks inside JSX comments otherwise make a literal scanner span lines and produce false hits), group height utilities by variant prefix AND property, and flag a numeric only where a tap token shares its group. Result: ZERO same-variant pairs in components, and 84 cross-variant ones. THREE CORRECTIONS. (1) The named breakdown is stale: DocumentManagerPanel.tsx and settings-dialog.tsx now contain no tap token at all, document-admin's two tap sites carry no numeric height, and service-detail-page's carry none either. (2) The surviving pairs are responsive step-downs, not dead classes — min-h-tap with sm:min-h-9 / lg:min-h-9 / md:min-h-9, and h-10.5 with sm:h-tap in account-setup-dialog. A later-emitted variant wins at its breakpoint, so deleting the numeric RAISES the control (36px to 48px at that breakpoint, or 42px to the recipe default on phones for the h-10.5 case). That is a visual change, not the no-op this row promised. (3) The stated blocker is measured false: tailwind-merge groups by variant, so declaring tap changes nothing at any cross-variant pair. Probe over the real pairings — min-h-tap sm:min-h-9, sm:min-h-10, lg:min-h-9, md:min-h-9, sm:min-h-0, and h-10.5 sm:h-tap — returned identical output with and without tap declared; only the synthetic same-variant controls (min-h-tap min-h-9, min-h-9 min-h-tap, h-tap h-5) changed. REMAINING RISK, and the reason this is not simply done: the scan is per string literal, so it cannot see a conflict composed across cn() arguments — cn(metadataPill.standard, 'min-h-tap') pairs min-h-7 with min-h-tap through a recipe. Order decides the outcome there: recipe-then-tap raises to 48px, tap-then-recipe DROPS to 28px, which is the forbidden direction. Next action: a composition-aware sweep that resolves constant recipe identifiers at each cn() call site before grouping; if it also finds zero same-variant drops, declare tap in CLINICAL_TWMERGE_THEME.spacing and delete the pinning test in tests/tailwind-merge-config.test.ts, and update the long comment in src/lib/tailwind-merge.ts, which still records the 22-site/18-drop figure as the reason for the omission. Do NOT delete the cross-variant numerics as 'dead' — they are live responsive steps. Do NOT lower any target, and never to min-h-11. Gate: npm run check:design-system-contract, npm run test, and a Chromium look. | session 2026-08-07 — design-system HANDOVER-2026-08-07 Track A1 handoff (PR #1678) | 2026-08-07 | | #271 | P3 | task | Decide whether to delete the now-consumer-less action kind in SecondaryNavigation | PR #1679 removed the last live consumer of SecondaryNavigationActionItem. UPDATE 2026-08-09 (in-page-nav PR 2): the question is now the whole component, not one kind. That PR removed the section kind — SecondaryNavigationSectionItem, useActiveSection, sectionFragmentId, nearestScrollOwner, sectionActivationClearance and the internal secondary-navigation:section-select event — because the six information routes that fed it now mount InPageNavHeader. That was the last PRODUCTION constructor of any kind: RegistryModeNav renders ModeNav, not SecondaryNavigation, so the surviving route and action kinds have no caller in src/ at all and the only file that builds either is tests/secondary-navigation.dom.test.tsx. Kept rather than deleted in that PR deliberately: deleting a component plus its test file alongside a seven-route conversion is a second unrelated change, and this row already tracks the same keep-or-delete call. Next: decide keep-or-delete for src/components/secondary-navigation.tsx as its own change. If delete, also remove tests/secondary-navigation.dom.test.tsx and the therapy-compass action entries in the registry, and check tests/mode-nav-contract.test.ts, which string-matches source lines in page-secondary-navigation.tsx. Stop: do not do half of each — a deleted branch with its tests left behind, or vice versa, is worse than either. | session 2026-08-07; PR #1679; in-page-nav PR 2 2026-08-09 | 2026-08-07 | | #272 | P3 | rec | Header addon-slot single-owner rule is enforced by two lists agreeing by coincidence, not a guard | The universal header's addon slot must hold exactly ONE page-owned header. Nothing in PageSecondaryNavigation states that rule. What actually enforces it is that every route claiming the slot (DocumentViewer, differentials/differential-detail-page) also happens to be hasLocalInformationPageNavigation, which returns null before the mode branch is reached — two independently maintained lists agreeing by accident. tests/mode-nav-addon-slot.dom.test.tsx asserts the agreement route-for-route and is what will go red when a future claimant falls outside that cover. The original incidental protection (a claimant mode had fewer than MODE_NAV_MIN_ITEMS destinations so ModeNav rendered nothing) has already expired twice: for differentials in PR #1647 and for factsheets in PR #1674. Next: no action required while the lists agree; if documents or another slot claimant ever gains a second routed destination, add an explicit isHeaderAddonSlotOwnedRoute guard at the mode branch in PageSecondaryNavigation rather than widening either list. Stop: do not delete isHeaderAddonSlotOwnedRoute as unused — no production code calls it, but it is the named claimant list the test asserts against. Renumbered from this PR's original #262 → #272 because main claimed #261–#270 via PR #1678 design-system tracks. | session 2026-08-07; PRs #1647, #1674, #1679 | 2026-08-07 | | #273 | P2 | task | The results band's max-[413px] wrap threshold is stale now that Sort and the phone selects are both gone | **Outcome:** the one-line phone results bar extends down to 320px instead of stopping at 414px, so the Filter trigger stops taking an otherwise-empty second row on the most common phone widths. **Detail:** `search-results-header-band.tsx` wraps the band's first line with `max-[413px]:flex-wrap max-[413px]:py-2`, justified in its own comment as "below 414px one line provably cannot hold count + query + sort + filter even with the query fully truncated". Both halves of that premise have since been removed: Sort became `sm`-and-up (PR #1689), and every mode's phone control became a compact trigger rather than a `w-full` select (#247). Measured in a real browser on the differentials band with the wrap class stripped at runtime, `scrollWidth - clientWidth` is **0 at 320, 360, 375, 390 and 402px** — the line fits at every width with room to spare. Left unchanged deliberately: the fix also changes documents, which is the reference layout the user approved, and it is a shared-geometry change with a 320-540px clip sweep asserting against it in `ui-smoke`. **Next:** delete the two `max-[413px]:` utilities, re-run the `ui-smoke` clip sweep and `ui-tools` phone rail assertions, and re-measure band height at 320/360/390 (expect 60px everywhere, versus 89px today below 414). **Stop:** do not raise the threshold instead of deleting it — it is not a narrower window now, it is an empty one. Do not re-measure with Sort mentally re-added; that control is gone below `sm` and is not coming back. | browser measurement 2026-08-07 on claude/search-bar-mobile-layout-buu0io; band comment; #247 | 2026-08-07 | @@ -496,6 +493,8 @@ Move resolved rows here with the resolution date and a one-line outcome. Keep th | #279 | issue | pdf.js 6 cannot raster in this container's Chromium, so no browser gate covers the viewer canvas | Resolved by tests/ui-document-canvas.spec.ts (Phase 3 Task 0, PR for claude/document-viewer-phase-3-bj5k5v): a Chromium viewer-canvas journey that reads the raster back — non-blank ink pixels on page 1, the real page count in the one toolbar readout, and a page flip whose FNV pixel signature differs from page 1's. Registered in all three hand-maintained lists (playwright.config.ts testMatch + productionSpecPattern, scripts/playwright-pr-shards.mjs productionSpecFilePattern + shard group 3) with a new fail-closed assertion in tests/playwright-project-isolation.test.ts so a future regex edit cannot silently drop it. The container skip is guarded asymmetrically: without CI it skips with a reason naming the browser version, with CI set it FAILS — verified both ways on 2026-08-09 (local run: 3 skipped; CI=1 run: 1 failed at the probe). Measurements re-derived after npm ci and they match the corrected row exactly: playwright-core/browsers.json chromium revision 1234 = 151.0.7922.34, container /opt/pw-browsers/chromium-1194 = 141.0.7390.37, pdfjs-dist 6.2.108 calling Map.prototype.getOrInsertComputed at pdf.mjs:2454/6889/6896. New datapoint: Node 24.13.0 also lacks getOrInsertComputed, so pdf.js 6 cannot be driven headlessly from this runtime either. Neither refuted remedy was actioned. | 2026-08-09 | | #264 | task | DS Track A5: gate the six ungated design-system prohibitions | CLOSED 2026-08-09. All six prohibitions named in this row now have a gate. Measured against origin/main 8db1e53937 before writing: FOUR of the six were ALREADY gated and GATES.md said otherwise - border+ring co-occurrence (edgeOwnershipConflicts), the 1px shadow spread check (onePixelShadowSpreads), layout-property animation (layoutTransitionExceptions) and the --shadow-tight alias lint (legacyShadowAliases) are all live ratchets in scripts/design-system-contract-baseline.json, and because findDebtPathRegressions compares per path a new violation in any file already failed. Their section 3 rows read planned; that understatement is what deferred this task twice and is corrected in GATES.md section 5. The dark: override lint closed 7 Aug. Genuinely new here: statusColouredNumerals (ratcheted 2) and colourOnlyStatusIndicators (ratcheted 4) for the colour-boundary rule, and imageInversions pinned at zero for the PDF/diagram/clinical-image invert rule. Also tightened two ratchets carrying stale slack to their measured values - edgeOwnershipConflicts 28 to 27 and legacyShadowAliases 231 to 224 - which closed headroom for up to seven new violations across seven files that had paid debt down without a baseline refresh. All new checks mutation-verified. Recorded debt that remains is NOT this row: the 224 shadow aliases are #262, the 27 edge conflicts and 2 spreads are gate 8 in #265. | 2026-08-09 | | #277 | issue | docs/design-system/HANDOVER-2026-08-07.md is cited as provenance by nine ledger rows but is measurably wrong | CLOSED 2026-08-09 as already satisfied — verified against origin/main 8db1e53937, not inferred. Both halves of this row's own 'cheapest fix' are present: docs/design-system/HANDOVER-2026-08-07.md carries a SUPERSEDED IMPORTANT callout naming docs/outstanding-issues.md as the current source of truth, listing the nine citing rows (#261, #262, #264-#270) and enumerating each disproved figure; and docs/design-system/README.md line 12 already reads 'superseded and must not be used to ...'. The file was correctly kept rather than deleted, preserving the nine Source citations as provenance. One note for whoever reads the banner next: its quoted figures have themselves drifted, which is precisely why this row insisted corrections live in the rows and not in the document. Measured today at 8db1e53937: legacyShadowAliases total 224, not the banner's 228, and the manifest reports 53 registered components with 31 product-imported, i.e. 22 unadopted rather than 23. Do NOT edit those numbers into the banner - re-stating live figures in a superseded document is the drift this row exists to stop. Also note the banner did still mislead one session despite existing: the M2 session of 2026-08-09 was scoped from a handover that repeated its claims, and four of #264's six prohibitions turned out to be already gated. The remaining risk is downstream documents copying the figures, not this file. | 2026-08-09 | +| #218 | issue | cn() has no tailwind-merge, so className size overrides resolve by stylesheet order rather than intent | CLOSED 2026-08-09 as already shipped - verified in source at origin/main 7aaf9349c, not inferred. cn() adopted tailwind-merge in PR #1678 (aeba5a254 feat(design-system): give cn() tailwind-merge (#218)). Evidence: src/components/ui-primitives.tsx:37 is now twMergeClinical(classes.filter(Boolean).join(" ")) rather than a plain join, importing twMergeClinical from @/lib/tailwind-merge at line 13; package.json:267 carries tailwind-merge ^3.6.0; src/lib/tailwind-merge.ts (119 lines) declares this repo's @theme text/leading/tracking/spacing/ease/animate scales to twMerge and documents the two silent failure modes it exists to stop (misclassification deleting the text-sm-minus family, and non-recognition of size-icon-md / tracking-label / leading-prose / ease-out-soft / animate-shimmer / pt-safe). The row's own concrete instance - cn(metadataPill, ... text-2xs) in document-search-results.tsx - now resolves by call-site intent rather than stylesheet order. The follow-up that #218 spawned, #270, is also closed. Note for the next reader: the docstring at src/lib/tailwind-merge.ts:34-36 still says present-tense that the --spacing-tap family 'is held back', which its own line 66 and the spacing array at line 104 contradict; that stale sentence is cosmetic and does not affect behaviour. | 2026-08-09 | +| #270 | task | Declaring tap in tailwind-merge is no longer blocked by dead numeric heights — the 22-site premise did not survive re-measurement | CLOSED 2026-08-09 as already shipped - verified in source at origin/main 7aaf9349c, not inferred. The composition-aware sweep this row asked for was run and its conclusion acted on in PR #1738 (80cf78139 fix(tailwind-merge): declare the tap spacing token now its blocker is disproved), confirmed an ancestor of origin/main by git merge-base --is-ancestor. Evidence: 'tap' is now present in CLINICAL_TWMERGE_THEME.spacing at src/lib/tailwind-merge.ts:104, and the long comment at lines 66-93 has been rewritten from the stale 22-site/18-drop justification into a dated record of the disproof - zero same-variant tap/numeric pairs, the 84 survivors identified as cross-variant responsive step-downs that twMerge groups apart, the named call sites confirmed stale, and a composition-aware sweep over 1418 cn() call sites that resolves constant recipe identifiers finding zero same-variant pairs in either direction, mutation-tested against synthetic cn("h-tap","h-4") and cn("min-h-tap", recipe) probes so the zero is a measurement rather than a pattern that never matches. tests/tailwind-merge-config.test.ts was not deleted as the row proposed but inverted, which is better: line 116 now asserts cn("min-h-9","min-h-tap") === "min-h-tap", lines 125-127 assert the cross-variant pairs survive untouched, and line 161 asserts every --spacing-* token including tap is declared. No production target was lowered. Residual cosmetic drift: the module docstring at lines 34-36 still says present-tense that the --spacing-tap family 'is held back', contradicted by line 66 and the array itself. | 2026-08-09 | From c2f7f06248d320b8ed4d88647acc4140f3dcb21a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:51:01 +0800 Subject: [PATCH 2/6] feat(design-system): ratchet raw padding, radius and line-height literals (#262 part 3) The design-system contract ratcheted colour, shadow, tap and tracking but not spacing, radius or line-height, so a value could bypass the scale as a bare literal in either a class or a stylesheet and nothing objected. Adds three per-path ratchets, covering both halves the way the colour and legacy-shadow metrics already do: rawPaddingLiterals 67 (17 CSS declarations, 50 class utilities) rawRadiusLiterals 24 (22 CSS declarations, 2 class utilities) rawLineHeightLiterals 3 (3 CSS declarations) The exemption is deliberately "contains no CSS function", not the narrower `(?!var\()` the tracking rule uses. Padding is not only ever a token or a literal: production ships pb-[env(safe-area-inset-bottom)], pt-[max(0.75rem,var(--safe-area-top))], pt-[clamp(1.5rem,5vh,3rem)] and pb-[calc(7rem+env(safe-area-inset-bottom))]. Those are computed from the viewport or the safe-area inset, cannot be spelled as a scale step, and a `var(`-only lookahead would have flagged every one of them. On the CSS side, zero in any unit, the CSS-wide keywords and custom-property declarations (the token definitions themselves) are exempt for the same reason. Every one of the 94 baseline entries was verified present at its cited line before pinning, and the baseline change is additive: all fifteen pre-existing metrics and every pre-existing debtByPath entry are byte-identical. Mutation-tested rather than assumed. Class side, in a file with no prior debt: - rawPaddingLiterals increased from 67 to 68 - rawPaddingLiterals at src/components/ui-primitives.tsx increased from 0 to 1 - rawRadiusLiterals increased from 24 to 25 - rawRadiusLiterals at src/components/ui-primitives.tsx increased from 0 to 1 - rawLineHeightLiterals increased from 3 to 4 - rawLineHeightLiterals at src/components/ui-primitives.tsx increased from 0 to 1 The CSS half fails the same way. Both probes also carried the sanctioned computed forms, and each count rose by exactly one, so the exemptions are proved by the same runs rather than argued. No new npm script: the metrics live inside check:design-system-contract, so docs:check-inventory and check:gate-manifest are untouched. Co-Authored-By: Claude Opus 5 --- scripts/check-design-system-contract.mjs | 12 +++ scripts/design-system-contract-baseline.json | 28 +++++++ scripts/design-system-contract-utils.mjs | 81 ++++++++++++++++++++ tests/design-system-contract-utils.test.ts | 37 +++++++++ 4 files changed, 158 insertions(+) diff --git a/scripts/check-design-system-contract.mjs b/scripts/check-design-system-contract.mjs index 64170255e..6b11d9c9b 100644 --- a/scripts/check-design-system-contract.mjs +++ b/scripts/check-design-system-contract.mjs @@ -120,6 +120,9 @@ const metrics = { darkColorOverrides: 0, legacyShadowAliases: 0, arbitraryTracking: 0, + rawPaddingLiterals: 0, + rawRadiusLiterals: 0, + rawLineHeightLiterals: 0, layoutTransitionExceptions: 0, textSoftConsumers: 0, }; @@ -176,6 +179,9 @@ for (const file of files) { recordDebt("darkColorOverrides", file.relativePath, classAnalysis.darkColorOverrides.length); recordDebt("legacyShadowAliases", file.relativePath, classAnalysis.legacyShadowAliases.length); recordDebt("arbitraryTracking", file.relativePath, classAnalysis.arbitraryTracking.length); + recordDebt("rawPaddingLiterals", file.relativePath, classAnalysis.rawPaddingLiterals.length); + recordDebt("rawRadiusLiterals", file.relativePath, classAnalysis.rawRadiusLiterals.length); + recordDebt("rawLineHeightLiterals", file.relativePath, classAnalysis.rawLineHeightLiterals.length); densityOverrideFindings.push(...classAnalysis.densityOverrides); hardcodedMotionClassFindings.push(...classAnalysis.hardcodedMotionClasses); layoutTransitionFindings.push(...classAnalysis.layoutTransitions); @@ -186,6 +192,9 @@ for (const file of files) { recordDebt("hardcodedCssMotionDurations", file.relativePath, cssAnalysis.hardcodedMotionDurations.length); recordDebt("rawCssZIndices", file.relativePath, cssAnalysis.rawZIndices.length); recordDebt("legacyShadowAliases", file.relativePath, cssAnalysis.legacyShadowAliases.length); + recordDebt("rawPaddingLiterals", file.relativePath, cssAnalysis.rawPaddingLiterals.length); + recordDebt("rawRadiusLiterals", file.relativePath, cssAnalysis.rawRadiusLiterals.length); + recordDebt("rawLineHeightLiterals", file.relativePath, cssAnalysis.rawLineHeightLiterals.length); imageInversionFindings.push(...cssAnalysis.imageInversions); layoutTransitionFindings.push(...cssAnalysis.layoutTransitions); } @@ -402,5 +411,8 @@ console.log( console.log( `Status-colour boundary: colour-only status indicators ${metrics.colourOnlyStatusIndicators}; status-coloured numerals ${metrics.statusColouredNumerals}; image inversions ${imageInversionFindings.length}.`, ); +console.log( + `Scale ratchets: raw padding literals ${metrics.rawPaddingLiterals}; raw radius literals ${metrics.rawRadiusLiterals}; raw line-height literals ${metrics.rawLineHeightLiterals}.`, +); console.log(`Text-role ratchet: --text-soft consumers ${metrics.textSoftConsumers}.`); console.log(`Raw-color exemptions: ${RAW_COLOR_EXEMPTIONS.map(({ category }) => category).join(", ")}.`); diff --git a/scripts/design-system-contract-baseline.json b/scripts/design-system-contract-baseline.json index eb373a67f..147e60b1c 100644 --- a/scripts/design-system-contract-baseline.json +++ b/scripts/design-system-contract-baseline.json @@ -13,6 +13,9 @@ "darkColorOverrides": 0, "legacyShadowAliases": 220, "arbitraryTracking": 0, + "rawPaddingLiterals": 67, + "rawRadiusLiterals": 24, + "rawLineHeightLiterals": 3, "layoutTransitionExceptions": 12, "textSoftConsumers": 0 }, @@ -145,6 +148,31 @@ "src/components/ui-primitives.tsx": 6 }, "arbitraryTracking": {}, + "rawPaddingLiterals": { + "src/app/globals.css": 17, + "src/components/clinical-dashboard/document-search-results.tsx": 1, + "src/components/clinical-dashboard/result-filter-control.tsx": 1, + "src/components/clinical-record-panels.tsx": 2, + "src/components/differentials/differential-detail-page.tsx": 1, + "src/components/services/service-detail-page.tsx": 1, + "src/components/therapy-compass/bindings.tsx": 1, + "src/components/therapy-compass/controls.ts": 2, + "src/components/therapy-compass/screens/brief-screen.tsx": 7, + "src/components/therapy-compass/screens/compare-screen.tsx": 5, + "src/components/therapy-compass/screens/detail-screen.tsx": 7, + "src/components/therapy-compass/screens/pathways-screen.tsx": 4, + "src/components/therapy-compass/screens/recommend-screen.tsx": 6, + "src/components/therapy-compass/screens/sheets-screen.tsx": 9, + "src/components/therapy-compass/therapy-card.tsx": 3 + }, + "rawRadiusLiterals": { + "src/app/globals.css": 22, + "src/components/clinical-dashboard/search-results-header-band.tsx": 1, + "src/components/mode-nav/mode-nav.tsx": 1 + }, + "rawLineHeightLiterals": { + "src/app/globals.css": 3 + }, "layoutTransitionExceptions": { "src/app/globals.css": 4, "src/components/calculators/guided-flow.tsx": 1, diff --git a/scripts/design-system-contract-utils.mjs b/scripts/design-system-contract-utils.mjs index 3998e3573..9b1c4f890 100644 --- a/scripts/design-system-contract-utils.mjs +++ b/scripts/design-system-contract-utils.mjs @@ -289,6 +289,41 @@ const INVERSION_FUNCTION = /(?:invert|hue-rotate)\(/; // the sanctioned token form and is deliberately NOT counted, exactly as // `text-[color:var(--…)]` is exempt from the type-scale check. const ARBITRARY_TRACKING_UTILITY = /^tracking-\[(?!var\()[^\]]+\]$/; +/** + * Spacing, radius and line-height written as a bare literal — `px-[22px]`, + * `rounded-[7px]`, `leading-[1.15]` — rather than picked off the scale. + * + * These deliberately exempt any arbitrary value containing a CSS *function*, + * not just `var(`. `tracking-[var(--…)]` above can use the narrower `(?!var\()` + * because letterspacing is only ever a token or a literal, but padding is not: + * production carries `pb-[env(safe-area-inset-bottom)]`, + * `pt-[max(0.75rem,var(--safe-area-top))]`, `pt-[clamp(1.5rem,5vh,3rem)]` and + * `pb-[calc(7rem+env(safe-area-inset-bottom))]`. Those are computed from the + * viewport or the safe-area inset, cannot be spelled as a scale step, and are + * sanctioned. A `(?!var\()` lookahead would flag every one of them, because + * they open with `max(`, `clamp(`, `env(` or `calc(` rather than `var(`. + * + * So the rule is: a value with no function call in it at all is a raw literal. + * That keeps the sanctioned computed forms out without enumerating them. + */ +const RAW_LITERAL_VALUE = String.raw`\[(?![^\]]*\w\()[^\]]+\]`; +const RAW_PADDING_UTILITY = new RegExp(String.raw`^p[xytrbles]?-${RAW_LITERAL_VALUE}$`); +const RAW_RADIUS_UTILITY = new RegExp( + String.raw`^rounded(?:-(?:[trblse]|[tb][lr]|ss|se|ee|es))?-${RAW_LITERAL_VALUE}$`, +); +const RAW_LINE_HEIGHT_UTILITY = new RegExp(String.raw`^leading-${RAW_LITERAL_VALUE}$`); +/** + * The CSS-declaration half of the same three rules, so a literal cannot simply + * move from a class into `globals.css` to escape the ratchet — the same reason + * `legacyShadowAliases` and the colour ratchet count both sides. + * + * `0` in any unit carries no design decision and is exempt, as are the CSS-wide + * keywords and `line-height: normal`. + */ +const RAW_PADDING_PROPERTY = /^padding(?:-(?:top|right|bottom|left|inline|block)(?:-(?:start|end))?)?$/; +const RAW_RADIUS_PROPERTY = /^border(?:-(?:top|bottom)-(?:left|right)|-(?:start|end)-(?:start|end))?-radius$/; +const CSS_WIDE_KEYWORD = /^(?:inherit|initial|unset|revert|revert-layer|normal|auto)$/; +const CSS_ZERO_VALUE = /^-?0(?:\.0+)?(?:px|rem|em|%|vh|vw|vmin|vmax|ch|ex)?$/; const LEGACY_SHADOW_ALIAS = /var\(--shadow-(?:tight|card|soft|hover|elevated|lux|lift)\)/g; const LEGACY_PALETTE_UTILITY = /^(?:bg|text|border|ring|outline|fill|stroke|placeholder|from|via|to)-(?:white|black|(?:slate|gray|zinc|neutral|stone)-\d{2,3})(?:\/\d{1,3})?$/; @@ -974,7 +1009,11 @@ export function analyzeClassContractsInSource(relativePath, sourceText) { legacyTapClasses: [], legacyPaletteUtilities: [], literalShadowClasses: [], + rawLineHeightLiterals: [], + rawPaddingLiterals: [], + rawRadiusLiterals: [], statusColouredNumerals: [], + typeStepUsages: [], unapprovedZIndices: [], }; if (!analyzer) return result; @@ -1057,6 +1096,9 @@ export function analyzeClassContractsInSource(relativePath, sourceText) { } if (LITERAL_SHADOW_UTILITY.test(base)) result.literalShadowClasses.push(`${relativePath}:${line} (${token})`); if (ARBITRARY_TRACKING_UTILITY.test(base)) result.arbitraryTracking.push(`${relativePath}:${line} (${token})`); + if (RAW_PADDING_UTILITY.test(base)) result.rawPaddingLiterals.push(`${relativePath}:${line} (${token})`); + if (RAW_RADIUS_UTILITY.test(base)) result.rawRadiusLiterals.push(`${relativePath}:${line} (${token})`); + if (RAW_LINE_HEIGHT_UTILITY.test(base)) result.rawLineHeightLiterals.push(`${relativePath}:${line} (${token})`); if (hasLegacyTapClass(token)) result.legacyTapClasses.push(`${relativePath}:${line} (${token})`); for (const match of token.matchAll(LEGACY_SHADOW_ALIAS)) { result.legacyShadowAliases.push(`${relativePath}:${line} (${match[0]})`); @@ -1186,11 +1228,32 @@ export function analyzeCssContractsInSource(relativePath, sourceText) { layoutTransitions: [], legacyShadowAliases: [], onePixelShadowSpreads: [], + rawLineHeightLiterals: [], + rawPaddingLiterals: [], + rawRadiusLiterals: [], rawZIndices: [], }; for (const declaration of cssDeclarations(sourceText)) { const line = declaration.source?.start?.line ?? 1; const prop = declaration.prop.toLowerCase(); + // Custom-property declarations are the token definitions themselves — the + // scale has to be written down somewhere — so only real properties count. + if (!prop.startsWith("--")) { + const value = declaration.value.trim(); + const isRawLiteral = !/\w\(/.test(value) && !CSS_WIDE_KEYWORD.test(value); + const everyPartIsZero = value.split(/\s+/).every((part) => CSS_ZERO_VALUE.test(part)); + if (isRawLiteral && !everyPartIsZero) { + if (RAW_PADDING_PROPERTY.test(prop)) { + result.rawPaddingLiterals.push(`${relativePath}:${line} (${prop}: ${value})`); + } + if (RAW_RADIUS_PROPERTY.test(prop)) { + result.rawRadiusLiterals.push(`${relativePath}:${line} (${prop}: ${value})`); + } + if (prop === "line-height") { + result.rawLineHeightLiterals.push(`${relativePath}:${line} (${prop}: ${value})`); + } + } + } if (/^(?:-webkit-)?(?:backdrop-)?filter$/.test(prop)) { for (const match of declaration.value.matchAll(/\b(invert|hue-rotate)\(/g)) { result.imageInversions.push(`${relativePath}:${line} (${prop}: ${match[1]}())`); @@ -1237,6 +1300,24 @@ export function countRawCssZIndicesInSource(sourceText) { return analyzeCssContractsInSource("source.css", sourceText).rawZIndices.length; } +export function findRawScaleLiteralClassesInSource(relativePath, sourceText) { + const analysis = analyzeClassContractsInSource(relativePath, sourceText); + return { + padding: analysis.rawPaddingLiterals, + radius: analysis.rawRadiusLiterals, + lineHeight: analysis.rawLineHeightLiterals, + }; +} + +export function findRawScaleLiteralDeclarationsInSource(sourceText) { + const analysis = analyzeCssContractsInSource("source.css", sourceText); + return { + padding: analysis.rawPaddingLiterals, + radius: analysis.rawRadiusLiterals, + lineHeight: analysis.rawLineHeightLiterals, + }; +} + export function findDebtPathRegressions(metric, currentByPath, baselineByPath) { return Object.entries(currentByPath) .filter(([relativePath, count]) => count > (baselineByPath?.[relativePath] ?? 0)) diff --git a/tests/design-system-contract-utils.test.ts b/tests/design-system-contract-utils.test.ts index e9202e1c4..29d324271 100644 --- a/tests/design-system-contract-utils.test.ts +++ b/tests/design-system-contract-utils.test.ts @@ -13,6 +13,8 @@ import { findInteractiveTapLiteralsInSource, findJsxEdgeOwnershipConflictsInSource, findLayoutTransitionClassesInSource, + findRawScaleLiteralClassesInSource, + findRawScaleLiteralDeclarationsInSource, findTextSoftConsumersInSource, findUnapprovedZIndexClassesInSource, hasLegacyTapClass, @@ -362,6 +364,41 @@ describe("design-system contract helpers", () => { expect(countRawCssZIndicesInSource(".a{z-index: 95;}.b{z-index:-1;}")).toBe(2); }); + it("counts bare padding, radius and line-height literals in classes but not computed values", () => { + const found = findRawScaleLiteralClassesInSource( + "src/probe.tsx", + 'export const probe =
;', + ); + expect(found.padding).toEqual(["src/probe.tsx:1 (px-[22px])"]); + expect(found.radius).toEqual(["src/probe.tsx:1 (rounded-[7px])"]); + expect(found.lineHeight).toEqual(["src/probe.tsx:1 (leading-[1.35])"]); + + // The sanctioned computed forms production actually ships. A `(?!var\()` + // lookahead would flag every one of these, because they open with `env(`, + // `max(`, `clamp(` or `calc(` rather than `var(`. + const exempt = findRawScaleLiteralClassesInSource( + "src/probe.tsx", + 'export const probe =
;', + ); + expect(exempt.padding).toEqual([]); + expect(exempt.radius).toEqual([]); + expect(exempt.lineHeight).toEqual([]); + }); + + it("counts the same three literals in CSS declarations, exempting zero, keywords and tokens", () => { + const found = findRawScaleLiteralDeclarationsInSource(".a{padding:13px 9px;border-radius:7px;line-height:1.35;}"); + expect(found.padding).toEqual(["source.css:1 (padding: 13px 9px)"]); + expect(found.radius).toEqual(["source.css:1 (border-radius: 7px)"]); + expect(found.lineHeight).toEqual(["source.css:1 (line-height: 1.35)"]); + + const exempt = findRawScaleLiteralDeclarationsInSource( + ".a{padding:0;padding-inline:var(--pad-card);border-radius:inherit;line-height:normal;margin:5px;--radius-xs:0.25rem;}", + ); + expect(exempt.padding).toEqual([]); + expect(exempt.radius).toEqual([]); + expect(exempt.lineHeight).toEqual([]); + }); + it("rejects debt moved to a new path even when its global total is unchanged", () => { expect(findDebtPathRegressions("legacy", { "src/new.tsx": 1 }, { "src/old.tsx": 1 })).toEqual([ "legacy at src/new.tsx increased from 0 to 1", From 6ee93572b4f071fd184b43acb9945f938f08a5e2 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:58:17 +0800 Subject: [PATCH 3/6] feat(design-system): gate type-step selection on the decidable half (#262 part 2) check:type-scale blocks arbitrary text-[12px] values. Nothing has stopped the scale itself growing a step no surface ever picks, which is the drift that makes a wrong selection possible in the first place. Whether a heading should have chosen text-sm over text-sm-minus is not mechanically decidable, and this does not pretend otherwise. A step that is declared and consumed by nobody is decidable, and there is one today: --text-2xl-compact (globals.css:112) has zero consumers -- no utility use, no var() use -- while the next-rarest step, text-hero, has one real consumer. The analyzer reports every bare text- it sees and does not decide which names are steps; the checker intersects that against the @theme block it parses from globals.css. So the scale is never written down twice, and a step added to globals.css is covered without touching this gate. Retiring the dead step edits @theme, so it gets its own revertible PR rather than riding along here: it is carried in UNUSED_TYPE_STEP_EXEMPTIONS and tracked as docs/outstanding-issues.md #295. The exemption cannot rot silently -- the gate also fails if an exempted step stops being declared or gains a consumer. Mutation-tested, three ways: - type steps are declared in globals.css @theme but no production surface selects them: --text-2xl-compact (text-2xl-compact). Retire the step or use it; do not leave the scale carrying a step nobody picks. - (a newly added --text-probe-step fails identically, so this catches future drift rather than only today's known case) - --text-2xl-compact is exempted as unused but production now selects text-2xl-compact -- drop the exemption Measurement note, since three different figures were in circulation for this row: the "1318 sites" is a repo-wide grep INCLUDING mockups, which the gate excludes (1360 at this HEAD). Production consumers of the nine non-standard steps total 705 -- text-2xs 421, sm-minus 160, base-minus 57, 3xs 42, 2xl-minus 9, 3xl-minus 9, lg-minus 6, hero 1, 2xl-compact 0. Co-Authored-By: Claude Opus 5 --- docs/outstanding-issues.md | 4 +- scripts/check-design-system-contract.mjs | 58 ++++++++++++++++++++++ scripts/design-system-contract-utils.mjs | 11 ++++ tests/design-system-contract-utils.test.ts | 18 +++++++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 717b470c8..2ab3c9050 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -170,7 +170,7 @@ removed after current-main verification; it is not missing recommended work. | 115 | `#289` | A2 | High — auth/identity | Next auth module touch | 1–2 hours | Export a named helper (e.g. `authorizationIdentity(headers)`) from the auth module and use it at every property-access call site; consider a lint rule or branded type so `.Authorization` stops type-checking at all. **Stop:** do not change `authorizationHeadersForAccessToken` to emit uppercase — lowercase is the correct Fetch/Headers convention and callers that pass the object wholesale to `fetch` depend on it. | - + ## Open items > **Merged-main canary update (2026-07-23, run `30018289898`):** the new structured report correctly recorded evaluated tree `c24f2e8f2d30d0c59fc1eba025d3dcd63478137e`, run/attempt identity and `cross-region-runner` latency context. Golden retrieval remained 36/36 with document/content recall 1.0 and no failed cases. The 44-case answer gate had grounded-supported and unsupported-correct rates of 1.0, but failed because `neuroleptic-side-effect-escalation` again returned one citation where two are required (citation-failure rate 0.0227). `admission-discharge-comparison` again omitted the specific AKG admission document after `comparison_source_extractive_fallback`; `admission-discharge-coverage-paraphrase` was advisory-only at 24,870 ms. Answer cost was reported as `$0.234736`. Do not retry immediately: retain this as the first structured datapoint, compare it with the scheduled 2026-07-26 report, and keep retrieval/ranking unchanged. @@ -331,6 +331,8 @@ removed after current-main verification; it is not missing recommended work. | #292 | P2 | rec | Two assistants built the same queued conversion twice because neither workflow checks the open-PR list before starting | **Outcome:** picking up a queued ledger item cannot silently duplicate work another session already has in flight. **Detail:** on 2026-08-09 two assistants took the same queued `/issues` item roughly four hours apart and independently built the same in-page-nav conversion — PR #1766 (merged) and PR #1767 (closed as duplicate). Neither had any way to see the other: the ledger row was the only shared state. Correcting an earlier version of this row after CodeRabbit's review on PR #1773: it is not true that the ledger "has no in-progress state" — some rows do carry a progress marker in their prose (`IN PROGRESS` appears on two, and `IMPLEMENTED in PR #1766` on another). The accurate gap is narrower and worse: there is no structured status field and no atomic claim, so a marker is written by whoever did the work, usually after the fact, and nothing requires or checks one — which means the ABSENCE of a marker carries no information at all. Both sessions read it, both correctly concluded it was open, both built it. The wasted effort is the smaller cost; the larger one is that the two implementations diverged in shape, which is what forced the separate `PageSection` ownership decision recorded in `docs/search-chrome-behaviour.md`. Distinct from `#156`/`#168`, which are about two branches colliding on an **id** while appending; this is two sessions colliding on the **work** a row describes, and a collision-free id scheme would leave it untouched. **Mitigation landed 2026-08-09 (same PR as this row):** the check is now written into the three places an assistant actually reads before starting queued work — `.claude/skills/newtask/SKILL.md` "Before you start" (which already performed an open-PR read for PR bundling, so this asks that same list a second question and costs no extra call), `.claude/skills/issues/SKILL.md` after the read-only flow, and the `/issues` section of `AGENTS.md` so Codex and Cursor get it too rather than Claude Code only. All three say to scan for the **route, component or surface**, not the ledger id, because a duplicate PR rarely quotes the id; all three degrade to a warning when GitHub is unreachable so an offline session can still start work. **Next:** leave open for one or two queued-item cycles to see whether prose is enough. If a second duplicate lands anyway, this becomes the same class as `#258` — a rule enforced for one tool by prose with no gate — and the answer is a check, not more wording. **Stop:** do not implement a claim marker written back into the row when a session starts an item; that reintroduces exactly the read-modify-write contention `#168` exists to remove. Do not make the open-PR read a hard blocker. | session 2026-08-09; PR #1766 (merged); PR #1767 (closed duplicate) | 2026-08-09 | | #293 | P2 | issue | Controls declare min-h-tap and compute min-height 0px; a rendered-interactive tap audit needs a deterministic surface first | Two findings, one robust and one that blocked the gate. FINDING 1 (robust, reproduced in ALL SIX runs): controls that carry min-h-tap compute min-height 0px and render far below the 48px floor. Six distinct shapes seen across runs - 'a.inline-flex min-h-tap items-center' 16px, 'a.inline-flex min-h-tap shrink-0' 16.5px, 'button.flex min-h-tap w-full' 26.6px, 'button.grid min-h-tap min-w-tap' 36px, 'button.inline-flex min-h-tap items-center' 16px, 'button.inline-flex min-h-tap min-w-[94px]' 36px. min-h-tap works in general (the existing declared-carrier audit still measures carriers at or above 48px), so these elements have the declaration overridden to 0 rather than the utility being absent; likely an unlayered component class in globals.css, which by design outranks Tailwind utilities here. This was invisible because the pre-existing audit in tests/ui-style-contract.spec.ts only measures elements whose COMPUTED min-height is already at or above the floor (declared < tapFloor - 0.5 continue), so a floor overridden downward is skipped rather than flagged - the same structural blind spot as the h-10 case #265 named. FINDING 2 (why gate 2 did NOT land 2026-08-09): a rendered-interactive enumeration on /services?q=CMHT&run=1 is NOT DETERMINISTIC. Six runs against one production build returned 6, 5, 4, 3, 3 and 9 distinct control shapes, largely disjoint - one run saw answer-suggestion chips and a sort band, another a settled services results list. waitForLoadState('networkidle') plus deduplication to distinct shapes (instance counts measure how many results the query returned, and gave 9 vs 39) did NOT fix it; two consecutive agreeing runs were coincidence, and the next run differed again. The enumeration was written, proven to find real defects, and then REVERTED rather than landed, because tests/ui-style-contract.spec.ts runs in the required Production UI job via productionSpecPattern and scripts/playwright-pr-shards.mjs, so an intermittent version of it would block every merge in the repo. Next, in order: (1) find a deterministic surface for the audit - a static route with no async search, or a fixed seeded state - before re-attempting the enumeration; (2) separately, find what zeroes min-height on the min-h-tap carriers and fix or write a stated exception. Stop: do not re-land the enumeration on a live-search route, do not quarantine a brand-new test to get it merged (quarantine is for keeping flaky tests we already trust, and repo policy needs three reproductions on one SHA via tests/flake-ledger.json), do not lower any production tap target, and never to min-h-11 (known ui-smoke sub-pixel flake; production uses min-h-12). | session 2026-08-09 — M2 gate 2 enumeration (#265) | 2026-08-09 | | #294 | P3 | rec | OffscreenCanvas for the PDF raster is unjustified until the page-flip cost is read from CI | **Outcome:** the worker-raster question is settled by a number rather than left as a standing 'optional' item in the redesign plan. **Detail:** docs/plans/document-viewer-redesign-plan.md conditions OffscreenCanvas on 'measured main-thread paint cost'. Phase 3 (Task 5) did not implement it, deliberately: virtualization now keeps the reader's page and one neighbour already rastered, so the cold-render-per-flip cost that motivated a worker raster is largely gone before any threading work starts, and moving pdf.js rendering off the main thread would put the canvas the clinical source is drawn into behind a transfer boundary — a real risk on the one surface where a blank page is a clinical failure. **No number exists yet and none could be produced locally:** pdfjs-dist@6 needs Map.prototype.getOrInsertComputed, which this container's Chromium 141 lacks and Node 24.13.0 also lacks, so neither a browser nor a headless harness here can raster a page (see #279). **Next:** read the measurement the gate already captures. tests/ui-document-canvas.spec.ts attaches page-flip-raster-cost.json (flipToPaintedMs, longTaskCount, longTaskTotalMs, longestTaskMs, canvasBackingPixels) and logs a '[viewer-canvas] page flip painted in Nms' line, on every Production UI run and on any host with the pinned Chromium 151 build: npm ci --include=dev && npx playwright install chromium && npm run ensure && npm run test:e2e -- tests/ui-document-canvas.spec.ts --project=chromium. Close this as not-worth-doing and strike the row from the plan's Phase 3 table only when a Production UI (or equivalent Chromium 151) run records decisive log lines for all three: longestTaskMs comfortably under ~50ms, plus explicit flipToPaintedMs and longTaskTotalMs budgets agreed for that host class and met on the same run. Do not close on longestTaskMs alone. **Stop:** do not implement OffscreenCanvas on principle because the plan lists it — the plan conditions it on the measurement, and the measurement is now cheap to obtain. | session 2026-08-09 document viewer Phase 3, Task 5; docs/plans/document-viewer-phase3-handover.md | 2026-08-09 | +| #295 | P3 | task | Retire the --text-2xl-compact type step, which no production surface selects | Measured 2026-08-09 against origin/main 7aaf9349c while adding the step-selection gate (#262 part 2). --text-2xl-compact is declared at src/app/globals.css:112 and selected by ZERO production surfaces: the only other mentions anywhere are src/lib/tailwind-merge.ts:20 (a docstring), its entry in CLINICAL_TWMERGE_THEME.text at src/lib/tailwind-merge.ts:50, tests/tailwind-merge-config.test.ts:49 (which asserts the @theme block and the twMerge config agree), and a row in docs/design-system/TOKENS.md. No var(--text-2xl-compact) consumer exists either. For contrast, the next-rarest step, text-hero, does have one real consumer (src/components/mode-home-template.tsx:87). The new gate in scripts/check-design-system-contract.mjs fails on any declared-but-unconsumed step; this one is carried in UNUSED_TYPE_STEP_EXEMPTIONS with this row named as its tracking task, and the gate additionally fails if the exemption ever goes stale (step undeclared, or step gains a consumer). Next action: delete --text-2xl-compact from the globals.css @theme block, drop "2xl-compact" from CLINICAL_TWMERGE_THEME.text and from tests/tailwind-merge-config.test.ts, update the TOKENS.md row, then delete the exemption entry - the gate will hold the line afterwards. Deleting a step with zero consumers renders identically, but it edits @theme, so give it its own revertible PR rather than bundling it with a gate change. Gate: npm run check:design-system-contract plus npm run test. | session 2026-08-09 - M3 design-token debt (#262 parts 2 and 3) | 2026-08-09 | +| #296 | P3 | issue | One raw chromatic Tailwind palette utility escapes every colour gate | Found 2026-08-09 while measuring #262 at origin/main 7aaf9349c, and verified by opening the line rather than by grep alone. src/components/route-error-boundary.tsx:106 renders