From 1299f2bdec6edbffafbff97bbc0430b5ac261f2c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:08:11 +0800 Subject: [PATCH 1/4] docs(issues): close #218 and #270 as implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both rows were left open after their implementing PRs merged. Verified in source against origin/main 199b303b7, not inferred: - #218 (cn() has no tailwind-merge) — src/components/ui-primitives.tsx:36 is twMergeClinical(...), package.json:267 carries tailwind-merge ^3.6.0, and src/lib/tailwind-merge.ts declares this repo's @theme scales to extendTailwindMerge. Landed in aeba5a254 (PR #1678). - #270 (declaring tap in tailwind-merge) — "tap" is listed in CLINICAL_TWMERGE_THEME.spacing at src/lib/tailwind-merge.ts:104, the stale 22-site/18-drop comment is corrected in place, and the pinning test in tests/tailwind-merge-config.test.ts is replaced by a positive same-variant/cross-variant suite. Landed in 80cf78139 (PR #1738). Both commits confirmed ancestors of origin/main via git merge-base --is-ancestor. Rows written with npm run issues:done, never by hand; the writer also dropped #218 from the recommended queue and renumbered it. 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 f155750c95..00e6ee78b7 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,4 +493,6 @@ 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 implemented, verified in source against origin/main 199b303b7 rather than inferred. cn() is no longer a plain join: src/components/ui-primitives.tsx:36-38 reads twMergeClinical(classes.filter(Boolean).join(" ")), importing from @/lib/tailwind-merge at line 13. package.json:267 carries tailwind-merge ^3.6.0, and src/lib/tailwind-merge.ts declares this repo's @theme scales (spacing, ease, animate and the rest) to extendTailwindMerge so the merge does not silently delete tokens it cannot classify. Landed in aeba5a254, PR #1678, confirmed an ancestor of origin/main by git merge-base --is-ancestor. The row's decision point (adopt tailwind-merge in cn() versus add explicit size variants to metadataPill and Chip) was resolved the first way, so the concrete instances it named in document-search-results.tsx now resolve last-wins by call-site intent. Follow-on #270 covering the held-back tap token is also closed. #221 (converge remaining local EmptyState/LoadingState/Chip duplicates) was gated on this decision and is now unblocked. | 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 implemented, verified in source against origin/main 199b303b7 rather than inferred. The row's own next action was: run a composition-aware sweep, and if it finds zero same-variant drops, declare tap in CLINICAL_TWMERGE_THEME.spacing, delete the pinning test, and correct the stale 22-site/18-drop comment. All three landed in 80cf78139, PR #1738, confirmed an ancestor of origin/main by git merge-base --is-ancestor. Evidence: src/lib/tailwind-merge.ts:104 now lists "tap" in CLINICAL_TWMERGE_THEME.spacing; the comment above it (lines 66-93) records the corrected evidence including the composition-aware sweep over 1418 cn() call sites 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 and not a pattern that never matches; and tests/tailwind-merge-config.test.ts replaced the pinning test with a positive suite, "--spacing-tap merges, and only within a variant" (line 109) asserting cn("min-h-9","min-h-tap") is min-h-tap and cn("min-h-tap","sm:min-h-9") keeps both, plus "declares every --spacing-* token, including the tap knob" (line 161). No production target was lowered: the cross-variant responsive step-downs the row warned against deleting are untouched. | 2026-08-09 | From 56e5757c969deb1fbd3817aa713a2153b95e15d4 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:43:32 +0800 Subject: [PATCH 2/4] feat(design-system): ratchet raw padding, gap, radius and line-height (#262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GATES.md §3's "Raw pixel size, padding, radius or line-height in markup" row has read implemented-partial (colour/shadow/tap literals only) since the table was written. These four metrics are the missing half, added inside check:design-system-contract rather than as a new npm script: a new script breaks docs:check-inventory until docs:update runs, and check:gate-manifest binds every verify:cheap:internal gate to the browser-less static-pr job. Measured against origin/main 199b303b7: arbitraryPadding 58 across 18 files (ratchet, per-path pins) arbitraryGap 21 across 8 files (ratchet, all in therapy-compass) arbitraryRadius 2 across 2 files (ratchet) arbitraryLeading 0 (hard zero + text backstop) Kept as four metrics rather than one pooled count so the 59th padding site cannot be traded against the first leading-[1.15]. Two exemptions, both measured. The first draft used the same (?!var\() lookahead the shadow and tracking ratchets carry and found 100 sites; 20 were not debt. Safe-area values (19) reference env(safe-area-inset-*) or this repo's runtime wrappers over it, which no token can restate and which docs/search-chrome-behaviour.md mandates — the strict reading fails a correct new phone composer and passes an incorrect one that drops the inset. Token-only arithmetic (1) is pl-[calc(var(--pad-card)_+_var(--rule-w))], the form the ratchet exists to encourage. Replacing the lookahead with isSanctionedRawValue also caught one site it had been hiding: px-[var(--pad-panel,1.5rem)], a literal fallback restating a TOKENS.md value. All 100 initial findings were read in source before anything was pinned, per the §5 lesson. Mutation-verified: each family fails naming the metric and the path, a leading-[…] outside any class root fails the text backstop, and both exemptions leave the gate green. GATES.md §6 records the evidence. legacyShadowAliases measures 218 against its pinned 220; that slack is left exactly as found, because tightening an unrelated ratchet here would make the two non-revertible as one commit. Co-Authored-By: Claude Opus 5 --- docs/design-system/GATES.md | 56 ++++++++++++- scripts/check-design-system-contract.mjs | 30 +++++++ scripts/design-system-contract-baseline.json | 39 +++++++++ scripts/design-system-contract-utils.mjs | 85 ++++++++++++++++++++ 4 files changed, 209 insertions(+), 1 deletion(-) diff --git a/docs/design-system/GATES.md b/docs/design-system/GATES.md index fcd72fbaef..6f5f5ff342 100644 --- a/docs/design-system/GATES.md +++ b/docs/design-system/GATES.md @@ -94,7 +94,7 @@ theme-list parity, and remote design-project publication remain separate concern | Border **and** ring on one surface, or a 1px spread in a drop shadow | `check:design-system-contract` — `edgeOwnershipConflicts` (27) + `onePixelShadowSpreads` (2) | **implemented-blocking for new use** — AST/CSS ratchets with per-path pins; the recorded debt itself is Gate 8's remaining half | | A child shadow heavier than its parent's | Gate 7 | implemented-partial | | Use `--shadow-tight`/any alias in new code | `check:design-system-contract` — `legacyShadowAliases`, ratcheted at 224 with per-path pins | **implemented-blocking for new use** — a new alias in any file fails; retiring the existing 224 is `#262` | -| Raw pixel size, padding, radius or line-height in markup | Contract ratchet | implemented-partial (colour/shadow/tap literals only) | +| Raw pixel size, padding, radius or line-height in markup | `check:design-system-contract` — `arbitraryPadding` (58), `arbitraryGap` (21), `arbitraryRadius` (2), `arbitraryLeading` (**zero**) | **implemented-blocking for new use** (9 Aug 2026) — line-height is pinned at zero with a whole-file text backstop; padding/gap/radius are ratchets with per-path pins. Raw *size* is still covered only for tap/shadow/colour. Values referencing a safe-area inset are exempt, and so are values whose lengths all come from tokens; see §6 | | Animate `width`, `height`, `grid-template-*`, `top`, `left`, `gap` | `check:design-system-contract` — `layoutTransitionExceptions`, ratcheted at 12 with per-path pins | **implemented-blocking for new use** — `SAFE_TRANSITION_PROPERTIES` carries the compositor-only allowlist; phone chrome's deliberate `grid-template-rows` is in the recorded 12 | | Hardcode a transition duration | `check:design-system-contract` — `hardcodedMotionClasses` (**zero**) + `hardcodedCssMotionDurations` (42) | **implemented-blocking** for the Tailwind `duration-*`/`delay-*`/`transition-all` form; the CSS form is a ratchet, so its 42 are debt | | A `z-` value outside the named rungs | `require-z-index-ladder` (ESLint) | implemented-blocking | @@ -214,3 +214,57 @@ again. Since `ui-style-contract.spec.ts` runs in the required `Production UI` jo an intermittent version would have blocked every merge in the repo, which is worse than the gap it closes. Recorded here so the next attempt starts from a deterministic surface rather than re-deriving the same six runs. + +--- + +## 6 · Raw padding, gap, radius and line-height — ratcheted 9 August 2026 + +Ledger `#262` part 3, and the §3 row that had read `implemented-partial (colour/shadow/tap +literals only)` since the table was written. Measured against `origin/main` `199b303b7`. + +Four metrics rather than one pooled `arbitrarySpacing`, because the debt is three different +sizes and one of them is zero. Pooling would let the 59th padding site be traded against the +first `leading-[1.15]`, which is the exact swap a per-family ratchet exists to refuse. + +- `arbitraryPadding` — **58** across 18 files. Covers `p-`/`px-`/`py-`/`pt-`/`pr-`/`pb-`/ + `pl-` and the logical `ps-`/`pe-`. +- `arbitraryGap` — **21** across 8 files, every one of them in `therapy-compass/`. +- `arbitraryRadius` — **2**: `rounded-[7px]` on the search band's filter chip and + `rounded-t-[2px]` on the mode-nav rule. +- `arbitraryLeading` — pinned at **zero**, not ratcheted, and carrying a whole-file text + backstop so a `leading-[…]` the AST class-root pass cannot resolve still fails. + +**Two exemptions, both measured rather than assumed.** The first draft used the same +`(?!var\()` lookahead the shadow and tracking ratchets carry and found 100 sites. Twenty of +them were not debt: + +- **Safe-area insets (19).** `env(safe-area-inset-*)` has no value at author time, and + `--safe-area-*` / `--keyboard-height` are this repo's own runtime wrappers over it. No + design token can restate them. `docs/search-chrome-behaviour.md` *mandates* this form — + "visible composer chrome may still consume safe-area inset" — so a strict ratchet would + have failed a correct new phone composer while passing an incorrect one that dropped the + inset. They are exempt as a form, so `pb-[calc(9rem+var(--safe-area-bottom))]` is exempt + even though `9rem` is literal. That is a known hole, recorded here rather than left silent; + the alternative is a rule that punishes compliance, and those get suppressed, not obeyed. +- **Token-only arithmetic (1).** `pl-[calc(var(--pad-card)_+_var(--rule-w))]` in + `answer-card.tsx` is entirely tokens. The lookahead flagged it for the crime of adding two + of them together — it is the very form the ratchet exists to encourage. + +Removing the lookahead in favour of `isSanctionedRawValue` also **caught one site the +lookahead had been hiding**: `px-[var(--pad-panel,1.5rem)]` in `answer-card.tsx:92`, where a +literal fallback restates a value `TOKENS.md` owns. It is in the recorded 58. + +**All four are mutation-verified, and both exemptions are verified in the negative.** Adding +each class to `missing-value.tsx` fails with the metric *and* the path: +`arbitraryPadding increased from 58 to 59`, `arbitraryGap increased from 21 to 23`, +`arbitraryRadius increased from 2 to 3`, `arbitraryLeading increased from 0 to 1`, each with +its `at src/components/ui/missing-value.tsx increased from 0 to N` line. A `leading-[1.35]` +placed outside any class root — where the AST pass sees nothing — fails the backstop instead: +`has 1 arbitrary line-height text match(es) but the AST class-root pass only saw 0`. Adding +`p-[var(--space-x)]` or `pb-[calc(1rem+env(safe-area-inset-bottom))]` leaves the gate green. + +**All 100 initial findings were read in source before anything was pinned**, per the lesson +in §5. `legacyShadowAliases` measures 218 today against a pinned 220 — two aliases were +retired on `main` without a baseline refresh. That slack is left exactly as found: tightening +an unrelated ratchet inside this change would make the two non-revertible as one commit, and +retiring those aliases is `#262` part 1. diff --git a/scripts/check-design-system-contract.mjs b/scripts/check-design-system-contract.mjs index 64170255e5..531625dc2e 100644 --- a/scripts/check-design-system-contract.mjs +++ b/scripts/check-design-system-contract.mjs @@ -10,6 +10,7 @@ import { findDebtPathRegressions, findInteractiveTapLiteralsInSource, findTextSoftConsumersInSource, + isSanctionedRawValue, LEGACY_TAP_CLASS, hasLegacyTapClass, jsxClassText, @@ -26,6 +27,17 @@ const RAW_COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|oklch)\(/gi; /** Whole-file backstop for literal shadow utilities the AST class-root pass can miss. */ const LITERAL_SHADOW_TEXT = /(?:^|[\s"'`])shadow-\[(?!var\()[^\]]+\]/g; const ARBITRARY_TRACKING_TEXT = /(?:^|[\s"'`])tracking-\[(?!var\()[^\]]+\]/g; +/** + * Whole-file backstop for the one raw-value family pinned at zero. The three + * ratcheted families deliberately have no text backstop: `AST >= text` is an + * assertion that the class-root pass missed nothing, which is only safe to + * demand where the count is zero — a file carrying recorded padding debt inside + * an expression shape the analyzer resolves differently would fail the + * assertion rather than the ratchet, reporting an analyzer disagreement as a + * design defect. `leading-[…]` is at zero and stays there, so any text match at + * all is a real regression, whether or not the AST pass can see it. + */ +const ARBITRARY_LEADING_TEXT = /(?:^|[\s"'`])leading-\[([^\]]+)\]/g; const CUSTOM_CONTROL_CLASS_PROP = /(?:closeButtonClassName|sheetCloseButtonClassName|buttonClassName|triggerClassName)\s*=\s*(?:"([^"]*)"|`([^`]*)`)/g; @@ -120,6 +132,10 @@ const metrics = { darkColorOverrides: 0, legacyShadowAliases: 0, arbitraryTracking: 0, + arbitraryPadding: 0, + arbitraryGap: 0, + arbitraryRadius: 0, + arbitraryLeading: 0, layoutTransitionExceptions: 0, textSoftConsumers: 0, }; @@ -155,6 +171,9 @@ for (const file of files) { const textLegacyTap = countMatches(classTextSource, LEGACY_TAP_CLASS); const textLiteralShadow = countMatches(classTextSource, LITERAL_SHADOW_TEXT); const textArbitraryTracking = countMatches(classTextSource, ARBITRARY_TRACKING_TEXT); + const textArbitraryLeading = [...classTextSource.matchAll(ARBITRARY_LEADING_TEXT)].filter( + (match) => !isSanctionedRawValue(match[1]), + ).length; assert( classAnalysis.legacyTapClasses.length >= textLegacyTap, `${file.relativePath} has ${textLegacyTap} legacy tap class text match(es) but the AST class-root pass only saw ${classAnalysis.legacyTapClasses.length}`, @@ -167,6 +186,10 @@ for (const file of files) { classAnalysis.arbitraryTracking.length >= textArbitraryTracking, `${file.relativePath} has ${textArbitraryTracking} arbitrary tracking text match(es) but the AST class-root pass only saw ${classAnalysis.arbitraryTracking.length}`, ); + assert( + classAnalysis.arbitraryLeading.length >= textArbitraryLeading, + `${file.relativePath} has ${textArbitraryLeading} arbitrary line-height text match(es) but the AST class-root pass only saw ${classAnalysis.arbitraryLeading.length}`, + ); const fileEdgeFindings = classAnalysis.edgeOwnershipConflicts; recordDebt("edgeOwnershipConflicts", file.relativePath, fileEdgeFindings.length); recordDebt("colourOnlyStatusIndicators", file.relativePath, classAnalysis.colourOnlyStatusIndicators.length); @@ -176,6 +199,10 @@ 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("arbitraryPadding", file.relativePath, classAnalysis.arbitraryPadding.length); + recordDebt("arbitraryGap", file.relativePath, classAnalysis.arbitraryGap.length); + recordDebt("arbitraryRadius", file.relativePath, classAnalysis.arbitraryRadius.length); + recordDebt("arbitraryLeading", file.relativePath, classAnalysis.arbitraryLeading.length); densityOverrideFindings.push(...classAnalysis.densityOverrides); hardcodedMotionClassFindings.push(...classAnalysis.hardcodedMotionClasses); layoutTransitionFindings.push(...classAnalysis.layoutTransitions); @@ -403,4 +430,7 @@ console.log( `Status-colour boundary: colour-only status indicators ${metrics.colourOnlyStatusIndicators}; status-coloured numerals ${metrics.statusColouredNumerals}; image inversions ${imageInversionFindings.length}.`, ); console.log(`Text-role ratchet: --text-soft consumers ${metrics.textSoftConsumers}.`); +console.log( + `Raw-value ratchets: arbitrary padding ${metrics.arbitraryPadding}; gap ${metrics.arbitraryGap}; radius ${metrics.arbitraryRadius}; line-height ${metrics.arbitraryLeading}.`, +); 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 eb373a67fc..d8fdf184b1 100644 --- a/scripts/design-system-contract-baseline.json +++ b/scripts/design-system-contract-baseline.json @@ -13,6 +13,10 @@ "darkColorOverrides": 0, "legacyShadowAliases": 220, "arbitraryTracking": 0, + "arbitraryPadding": 58, + "arbitraryGap": 21, + "arbitraryRadius": 2, + "arbitraryLeading": 0, "layoutTransitionExceptions": 12, "textSoftConsumers": 0 }, @@ -145,6 +149,41 @@ "src/components/ui-primitives.tsx": 6 }, "arbitraryTracking": {}, + "arbitraryPadding": { + "src/components/calculators/search-detail.tsx": 1, + "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/factsheets/factsheets-home-page.tsx": 2, + "src/components/mode-home-template.tsx": 4, + "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, + "src/components/ui/answer-card.tsx": 1 + }, + "arbitraryGap": { + "src/components/therapy-compass/controls.ts": 1, + "src/components/therapy-compass/screens/brief-screen.tsx": 4, + "src/components/therapy-compass/screens/compare-screen.tsx": 2, + "src/components/therapy-compass/screens/detail-screen.tsx": 4, + "src/components/therapy-compass/screens/pathways-screen.tsx": 4, + "src/components/therapy-compass/screens/recommend-screen.tsx": 2, + "src/components/therapy-compass/screens/sheets-screen.tsx": 2, + "src/components/therapy-compass/therapy-card.tsx": 2 + }, + "arbitraryRadius": { + "src/components/clinical-dashboard/search-results-header-band.tsx": 1, + "src/components/mode-nav/mode-nav.tsx": 1 + }, + "arbitraryLeading": {}, "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 3998e3573f..6b06862a74 100644 --- a/scripts/design-system-contract-utils.mjs +++ b/scripts/design-system-contract-utils.mjs @@ -289,6 +289,81 @@ 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\()[^\]]+\]$/; +/** + * GATES.md §3, "Raw pixel size, padding, radius or line-height in markup" — the + * row that has read `implemented-partial (colour/shadow/tap literals only)` + * since the table was written. These four families are the missing half. + * + * They are separated rather than pooled into one `arbitrarySpacing` count + * because their debt is three different sizes and one of them is zero: pooling + * would let the 65th padding site be traded against a new `leading-[1.15]`, + * which is exactly the swap a per-family ratchet exists to refuse. + * + * What counts is decided by `isSanctionedRawValue` below rather than by a + * `(?!var\()` lookahead, because measurement showed the lookahead is too blunt + * here in both directions. Padding is the one family that legitimately carries + * values no token can express, and 20 of the 100 sites this first matched were + * of exactly that kind. + * + * Padding covers the logical properties (`ps-`/`pe-`) as well as the physical + * ones. The single-letter class is bounded by the alternation rather than a + * character class so no non-padding utility can be swept in: `place-items-*` + * and `pointer-events-*` both start with `p` and neither can match. + */ +const ARBITRARY_PADDING_UTILITY = /^p(?:[xytrbl]|[se])?-\[([^\]]+)\]$/; +const ARBITRARY_GAP_UTILITY = /^gap(?:-[xy])?-\[([^\]]+)\]$/; +const ARBITRARY_RADIUS_UTILITY = /^rounded(?:-(?:[trbl]|[se]|[tb][lr]|ss|se|ee|es))?-\[([^\]]+)\]$/; +/** + * A length written out by hand: `18px`, `0.6875rem`, `4vh`, `1.15`. Unitless is + * included so `leading-[1.15]` is caught; `var(--x)` and `env(…)` are not + * lengths by this definition, which is the whole point. + */ +const RAW_LENGTH_VALUE = /(?:^|[^\w.-])\d*\.?\d+(?:px|rem|em|ch|ex|vh|vw|vmin|vmax|pt|%)?(?![\w-])/; +/** + * Insets the browser supplies and no design token can restate. `env(safe-area- + * inset-*)` has no value at author time; `--safe-area-*` and `--keyboard-height` + * are this repo's own wrappers over it, set from JS at runtime. + * + * These are exempt as a *form*, not merely when they are the entire value — + * `pb-[calc(9rem+var(--safe-area-bottom))]` is exempt even though `9rem` is + * literal. That is deliberate and it is a known hole. The alternative refuses + * the phone composer reserve that `docs/search-chrome-behaviour.md` mandates — + * "visible composer chrome may still consume safe-area inset" — so the strict + * reading would fail a *correct* new composer and pass an incorrect one that + * dropped the inset. A ratchet that punishes compliance gets suppressed, not + * obeyed. The literal component is counted separately and reported, so the hole + * is visible rather than silent. + */ +const BROWSER_INSET_VALUE = /env\(\s*safe-area-inset-|var\(--safe-area-|var\(--keyboard-height/; + +/** + * True when an arbitrary value is the sanctioned token form rather than debt. + * + * Two ways to qualify: it references a browser-provided inset, or every length + * in it comes from a design token. The second admits arithmetic, so + * `p-[calc(var(--pad-card)_+_var(--rule-w))]` passes — it is exactly the token + * form the ratchet exists to encourage, and a `(?!var\()` lookahead would have + * flagged it for the crime of adding two tokens together. + */ +export function isSanctionedRawValue(value) { + if (BROWSER_INSET_VALUE.test(value)) return true; + return value.includes("var(--") && !RAW_LENGTH_VALUE.test(value); +} +/** + * Line-height is the one family with nothing to pay down, so it is pinned at a + * hard zero rather than ratcheted. Leading is where a raw value does the most + * damage per site: it is invisible in review, it does not scale with the type + * step it sits on, and a literal `leading-[1.15]` silently opts that text out + * of the whole type scale. + */ +const ARBITRARY_LEADING_UTILITY = /^leading-\[([^\]]+)\]$/; +/** Pattern → result bucket, walked once per resolved class token. */ +const RAW_VALUE_FAMILIES = [ + [ARBITRARY_PADDING_UTILITY, "arbitraryPadding"], + [ARBITRARY_GAP_UTILITY, "arbitraryGap"], + [ARBITRARY_RADIUS_UTILITY, "arbitraryRadius"], + [ARBITRARY_LEADING_UTILITY, "arbitraryLeading"], +]; 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})?$/; @@ -962,6 +1037,10 @@ function hasAccessibleNameInScope(owner, source) { export function analyzeClassContractsInSource(relativePath, sourceText) { const analyzer = classExpressionAnalyzer(relativePath, sourceText); const result = { + arbitraryGap: [], + arbitraryLeading: [], + arbitraryPadding: [], + arbitraryRadius: [], arbitraryTracking: [], colourOnlyStatusIndicators: [], darkColorOverrides: [], @@ -1057,6 +1136,12 @@ 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})`); + for (const [pattern, bucket] of RAW_VALUE_FAMILIES) { + const value = base.match(pattern)?.[1]; + if (value !== undefined && !isSanctionedRawValue(value)) { + result[bucket].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]})`); From 868d6671c4feed626b8fe2b5003dcb5b8a562cec Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:02:05 +0800 Subject: [PATCH 3/4] feat(design-system): build ErrorState, the gate with nothing behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GATES.md §3 lists the prohibition "Render '0 matches' after a failed request" with the gate "ErrorState adoption + check", status planned. Measured at origin/main 199b303b7, ErrorState existed nowhere in src or tests — only in COMPONENTS.md, GATES.md and SPEC.md. This builds it. The invariant is clinical, not cosmetic. A search that failed has no count to report, so reporting zero is a false statement about the corpus: on the services page "0 matches" asserts there are no crisis services when the search never ran, and on favourites it reads as "you have saved nothing" rather than "we could not load them". COMPONENTS.md:322 draws the same line from the other side — "no result count is available" is not a MissingValue. The component therefore takes no count and no children. There is no prop through which a number can arrive, and the generated dtsPropsFor entry now records that as the published API. The one remaining route, a caller writing a count into title or body, is covered by a development-time tripwire that matches a figure against a counted noun ("0 matches", "no results") so an error code or a duration does not trip it. It warns and never throws: on the one screen already reporting a failure, a thrown error is a blank page. Requirements came from the surfaces that hand-roll this guard today. Three do, and their comments state the rule outright: search-results-header-band.tsx:210 ("no number may reach the DOM"), services-navigator-page.tsx:634 ("a blocked registry must not reach the band as '0 matches'") and favourites-command-library-page.tsx:1182. They are correct, just not shared; converting them is a live-look change and deliberately not here. Three further sites carried into this task as hand-rolled guards are not that, measured at this HEAD, and are recorded so the next reader does not convert them: differentials-home.tsx:716,729 renders "0 matches"/"No matches" when sourcesChecked is true, i.e. a legitimate zero after a search that SUCCEEDED; specifiers-home-page.tsx:211 is a comment about not showing a stale zero above real catalogue results, and lives in src/components/specifiers/, not clinical-dashboard/; document-search-results gates on recordStatus for loading, not for a failed count. Registered per gates 11 and 12: source, design-sync export, preview, prop contract, publication test entry, behavioural DOM tests, adoption-contract family, and both generators regenerated (54 components, 59 roots). Uses the shared floatingControl recipe rather than a hand-rolled control, so the tap floor, focus ring and forced-colors border come from one owner. Adds zero arbitrary padding/gap/radius/line-height, so the ratchet landed in the previous commit is unaffected and the two stay separately revertible. Scope: ErrorState only. OfflineState, PermissionDeniedState, NotFoundState and UnavailableState share the pattern but have no gate pointing at them. Co-Authored-By: Claude Opus 5 --- .design-sync/config.json | 2 + .design-sync/entry.tsx | 1 + .design-sync/previews/ErrorState.tsx | 16 ++ docs/design-system/ADOPTION.md | 2 +- docs/design-system/COMPONENTS.md | 5 +- docs/design-system/GATES.md | 58 +++--- docs/design-system/adoption-contract.json | 1 + docs/design-system/adoption-manifest.json | 35 +++- src/components/ui/error-state.tsx | 211 ++++++++++++++++++++++ tests/design-sync-visual-exports.test.ts | 1 + tests/error-state.dom.test.tsx | 130 +++++++++++++ 11 files changed, 427 insertions(+), 35 deletions(-) create mode 100644 .design-sync/previews/ErrorState.tsx create mode 100644 src/components/ui/error-state.tsx create mode 100644 tests/error-state.dom.test.tsx diff --git a/.design-sync/config.json b/.design-sync/config.json index 63727c2b52..fb177362cc 100644 --- a/.design-sync/config.json +++ b/.design-sync/config.json @@ -36,6 +36,7 @@ "DoseLine": "caption?: string; className?: string; onOpenSource: (sourceId: string, locator?: string) => void; rows: readonly DoseRow[];", "DownloadLink": "about?: string; accessKey?: string; \"aria-activedescendant\"?: string; \"aria-atomic\"?: (boolean | \"true\" | \"false\"); \"aria-autocomplete\"?: \"none\" | \"list\" | \"inline\" | \"both\"; \"aria-braillelabel\"?: string; \"aria-brailleroledescription\"?: string; \"aria-busy\"?: (boolean | \"true\" | \"false\"); \"aria-checked\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-colcount\"?: number; \"aria-colindex\"?: number; \"aria-colindextext\"?: string; \"aria-colspan\"?: number; \"aria-controls\"?: string; \"aria-current\"?: boolean | \"true\" | \"false\" | \"page\" | \"step\" | \"location\" | \"date\" | \"time\"; \"aria-describedby\"?: string; \"aria-description\"?: string; \"aria-details\"?: string; \"aria-disabled\"?: (boolean | \"true\" | \"false\"); \"aria-dropeffect\"?: \"none\" | \"link\" | \"copy\" | \"execute\" | \"move\" | \"popup\"; \"aria-errormessage\"?: string; \"aria-expanded\"?: (boolean | \"true\" | \"false\"); \"aria-flowto\"?: string; \"aria-grabbed\"?: (boolean | \"true\" | \"false\"); \"aria-haspopup\"?: boolean | \"true\" | \"false\" | \"dialog\" | \"grid\" | \"listbox\" | \"menu\" | \"tree\"; \"aria-hidden\"?: (boolean | \"true\" | \"false\"); \"aria-invalid\"?: boolean | \"true\" | \"false\" | \"grammar\" | \"spelling\"; \"aria-keyshortcuts\"?: string; \"aria-label\"?: string; \"aria-labelledby\"?: string; \"aria-level\"?: number; \"aria-live\"?: \"off\" | \"assertive\" | \"polite\"; \"aria-modal\"?: (boolean | \"true\" | \"false\"); \"aria-multiline\"?: (boolean | \"true\" | \"false\"); \"aria-multiselectable\"?: (boolean | \"true\" | \"false\"); \"aria-orientation\"?: \"horizontal\" | \"vertical\"; \"aria-owns\"?: string; \"aria-placeholder\"?: string; \"aria-posinset\"?: number; \"aria-pressed\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-readonly\"?: (boolean | \"true\" | \"false\"); \"aria-relevant\"?: \"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"; \"aria-required\"?: (boolean | \"true\" | \"false\"); \"aria-roledescription\"?: string; \"aria-rowcount\"?: number; \"aria-rowindex\"?: number; \"aria-rowindextext\"?: string; \"aria-rowspan\"?: number; \"aria-selected\"?: (boolean | \"true\" | \"false\"); \"aria-setsize\"?: number; \"aria-sort\"?: \"none\" | \"ascending\" | \"descending\" | \"other\"; \"aria-valuemax\"?: number; \"aria-valuemin\"?: number; \"aria-valuenow\"?: number; \"aria-valuetext\"?: string; autoCapitalize?: \"off\" | \"none\" | \"on\" | \"sentences\" | \"words\" | \"characters\" | (string & {}); autoCorrect?: string; autoFocus?: boolean; autoSave?: string; children: ReactNode; className?: string; color?: string; content?: string; contentEditable?: (boolean | \"true\" | \"false\") | \"inherit\" | \"plaintext-only\"; contextMenu?: string; dangerouslySetInnerHTML?: { __html: string | TrustedHTML; }; datatype?: string; defaultChecked?: boolean; defaultValue?: string | number | readonly string[]; dir?: string; draggable?: (boolean | \"true\" | \"false\"); enterKeyHint?: \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\"; exportparts?: string; format?: string; hidden?: boolean; href: string; hrefLang?: string; id?: string; inert?: boolean; inlist?: any; inputMode?: \"none\" | \"search\" | \"text\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"; is?: string; itemID?: string; itemProp?: string; itemRef?: string; itemScope?: boolean; itemType?: string; lang?: string; media?: string; nonce?: string; onAbort?: import(\"react\").ReactEventHandler; onAbortCapture?: import(\"react\").ReactEventHandler; onAnimationEnd?: import(\"react\").AnimationEventHandler; onAnimationEndCapture?: import(\"react\").AnimationEventHandler; onAnimationIteration?: import(\"react\").AnimationEventHandler; onAnimationIterationCapture?: import(\"react\").AnimationEventHandler; onAnimationStart?: import(\"react\").AnimationEventHandler; onAnimationStartCapture?: import(\"react\").AnimationEventHandler; onAuxClick?: import(\"react\").MouseEventHandler; onAuxClickCapture?: import(\"react\").MouseEventHandler; onBeforeInput?: import(\"react\").InputEventHandler; onBeforeInputCapture?: import(\"react\").InputEventHandler; onBeforeToggle?: import(\"react\").ToggleEventHandler; onBlur?: import(\"react\").FocusEventHandler; onBlurCapture?: import(\"react\").FocusEventHandler; onCanPlay?: import(\"react\").ReactEventHandler; onCanPlayCapture?: import(\"react\").ReactEventHandler; onCanPlayThrough?: import(\"react\").ReactEventHandler; onCanPlayThroughCapture?: import(\"react\").ReactEventHandler; onChange?: import(\"react\").ChangeEventHandler; onChangeCapture?: import(\"react\").ChangeEventHandler; onClick?: import(\"react\").MouseEventHandler; onClickCapture?: import(\"react\").MouseEventHandler; onCompositionEnd?: import(\"react\").CompositionEventHandler; onCompositionEndCapture?: import(\"react\").CompositionEventHandler; onCompositionStart?: import(\"react\").CompositionEventHandler; onCompositionStartCapture?: import(\"react\").CompositionEventHandler; onCompositionUpdate?: import(\"react\").CompositionEventHandler; onCompositionUpdateCapture?: import(\"react\").CompositionEventHandler; onContextMenu?: import(\"react\").MouseEventHandler; onContextMenuCapture?: import(\"react\").MouseEventHandler; onCopy?: import(\"react\").ClipboardEventHandler; onCopyCapture?: import(\"react\").ClipboardEventHandler; onCut?: import(\"react\").ClipboardEventHandler; onCutCapture?: import(\"react\").ClipboardEventHandler; onDoubleClick?: import(\"react\").MouseEventHandler; onDoubleClickCapture?: import(\"react\").MouseEventHandler; onDrag?: import(\"react\").DragEventHandler; onDragCapture?: import(\"react\").DragEventHandler; onDragEnd?: import(\"react\").DragEventHandler; onDragEndCapture?: import(\"react\").DragEventHandler; onDragEnter?: import(\"react\").DragEventHandler; onDragEnterCapture?: import(\"react\").DragEventHandler; onDragExit?: import(\"react\").DragEventHandler; onDragExitCapture?: import(\"react\").DragEventHandler; onDragLeave?: import(\"react\").DragEventHandler; onDragLeaveCapture?: import(\"react\").DragEventHandler; onDragOver?: import(\"react\").DragEventHandler; onDragOverCapture?: import(\"react\").DragEventHandler; onDragStart?: import(\"react\").DragEventHandler; onDragStartCapture?: import(\"react\").DragEventHandler; onDrop?: import(\"react\").DragEventHandler; onDropCapture?: import(\"react\").DragEventHandler; onDurationChange?: import(\"react\").ReactEventHandler; onDurationChangeCapture?: import(\"react\").ReactEventHandler; onEmptied?: import(\"react\").ReactEventHandler; onEmptiedCapture?: import(\"react\").ReactEventHandler; onEncrypted?: import(\"react\").ReactEventHandler; onEncryptedCapture?: import(\"react\").ReactEventHandler; onEnded?: import(\"react\").ReactEventHandler; onEndedCapture?: import(\"react\").ReactEventHandler; onError?: import(\"react\").ReactEventHandler; onErrorCapture?: import(\"react\").ReactEventHandler; onFocus?: import(\"react\").FocusEventHandler; onFocusCapture?: import(\"react\").FocusEventHandler; onGotPointerCapture?: import(\"react\").PointerEventHandler; onGotPointerCaptureCapture?: import(\"react\").PointerEventHandler; onInput?: import(\"react\").InputEventHandler; onInputCapture?: import(\"react\").InputEventHandler; onInvalid?: import(\"react\").ReactEventHandler; onInvalidCapture?: import(\"react\").ReactEventHandler; onKeyDown?: import(\"react\").KeyboardEventHandler; onKeyDownCapture?: import(\"react\").KeyboardEventHandler; onKeyPress?: import(\"react\").KeyboardEventHandler; onKeyPressCapture?: import(\"react\").KeyboardEventHandler; onKeyUp?: import(\"react\").KeyboardEventHandler; onKeyUpCapture?: import(\"react\").KeyboardEventHandler; onLoad?: import(\"react\").ReactEventHandler; onLoadCapture?: import(\"react\").ReactEventHandler; onLoadedData?: import(\"react\").ReactEventHandler; onLoadedDataCapture?: import(\"react\").ReactEventHandler; onLoadedMetadata?: import(\"react\").ReactEventHandler; onLoadedMetadataCapture?: import(\"react\").ReactEventHandler; onLoadStart?: import(\"react\").ReactEventHandler; onLoadStartCapture?: import(\"react\").ReactEventHandler; onLostPointerCapture?: import(\"react\").PointerEventHandler; onLostPointerCaptureCapture?: import(\"react\").PointerEventHandler; onMouseDown?: import(\"react\").MouseEventHandler; onMouseDownCapture?: import(\"react\").MouseEventHandler; onMouseEnter?: import(\"react\").MouseEventHandler; onMouseLeave?: import(\"react\").MouseEventHandler; onMouseMove?: import(\"react\").MouseEventHandler; onMouseMoveCapture?: import(\"react\").MouseEventHandler; onMouseOut?: import(\"react\").MouseEventHandler; onMouseOutCapture?: import(\"react\").MouseEventHandler; onMouseOver?: import(\"react\").MouseEventHandler; onMouseOverCapture?: import(\"react\").MouseEventHandler; onMouseUp?: import(\"react\").MouseEventHandler; onMouseUpCapture?: import(\"react\").MouseEventHandler; onPaste?: import(\"react\").ClipboardEventHandler; onPasteCapture?: import(\"react\").ClipboardEventHandler; onPause?: import(\"react\").ReactEventHandler; onPauseCapture?: import(\"react\").ReactEventHandler; onPlay?: import(\"react\").ReactEventHandler; onPlayCapture?: import(\"react\").ReactEventHandler; onPlaying?: import(\"react\").ReactEventHandler; onPlayingCapture?: import(\"react\").ReactEventHandler; onPointerCancel?: import(\"react\").PointerEventHandler; onPointerCancelCapture?: import(\"react\").PointerEventHandler; onPointerDown?: import(\"react\").PointerEventHandler; onPointerDownCapture?: import(\"react\").PointerEventHandler; onPointerEnter?: import(\"react\").PointerEventHandler; onPointerLeave?: import(\"react\").PointerEventHandler; onPointerMove?: import(\"react\").PointerEventHandler; onPointerMoveCapture?: import(\"react\").PointerEventHandler; onPointerOut?: import(\"react\").PointerEventHandler; onPointerOutCapture?: import(\"react\").PointerEventHandler; onPointerOver?: import(\"react\").PointerEventHandler; onPointerOverCapture?: import(\"react\").PointerEventHandler; onPointerUp?: import(\"react\").PointerEventHandler; onPointerUpCapture?: import(\"react\").PointerEventHandler; onProgress?: import(\"react\").ReactEventHandler; onProgressCapture?: import(\"react\").ReactEventHandler; onRateChange?: import(\"react\").ReactEventHandler; onRateChangeCapture?: import(\"react\").ReactEventHandler; onReset?: import(\"react\").ReactEventHandler; onResetCapture?: import(\"react\").ReactEventHandler; onScroll?: import(\"react\").UIEventHandler; onScrollCapture?: import(\"react\").UIEventHandler; onScrollEnd?: import(\"react\").UIEventHandler; onScrollEndCapture?: import(\"react\").UIEventHandler; onSeeked?: import(\"react\").ReactEventHandler; onSeekedCapture?: import(\"react\").ReactEventHandler; onSeeking?: import(\"react\").ReactEventHandler; onSeekingCapture?: import(\"react\").ReactEventHandler; onSelect?: import(\"react\").ReactEventHandler; onSelectCapture?: import(\"react\").ReactEventHandler; onStalled?: import(\"react\").ReactEventHandler; onStalledCapture?: import(\"react\").ReactEventHandler; onSubmit?: import(\"react\").SubmitEventHandler; onSubmitCapture?: import(\"react\").SubmitEventHandler; onSuspend?: import(\"react\").ReactEventHandler; onSuspendCapture?: import(\"react\").ReactEventHandler; onTimeUpdate?: import(\"react\").ReactEventHandler; onTimeUpdateCapture?: import(\"react\").ReactEventHandler; onToggle?: import(\"react\").ToggleEventHandler; onTouchCancel?: import(\"react\").TouchEventHandler; onTouchCancelCapture?: import(\"react\").TouchEventHandler; onTouchEnd?: import(\"react\").TouchEventHandler; onTouchEndCapture?: import(\"react\").TouchEventHandler; onTouchMove?: import(\"react\").TouchEventHandler; onTouchMoveCapture?: import(\"react\").TouchEventHandler; onTouchStart?: import(\"react\").TouchEventHandler; onTouchStartCapture?: import(\"react\").TouchEventHandler; onTransitionCancel?: import(\"react\").TransitionEventHandler; onTransitionCancelCapture?: import(\"react\").TransitionEventHandler; onTransitionEnd?: import(\"react\").TransitionEventHandler; onTransitionEndCapture?: import(\"react\").TransitionEventHandler; onTransitionRun?: import(\"react\").TransitionEventHandler; onTransitionRunCapture?: import(\"react\").TransitionEventHandler; onTransitionStart?: import(\"react\").TransitionEventHandler; onTransitionStartCapture?: import(\"react\").TransitionEventHandler; onVolumeChange?: import(\"react\").ReactEventHandler; onVolumeChangeCapture?: import(\"react\").ReactEventHandler; onWaiting?: import(\"react\").ReactEventHandler; onWaitingCapture?: import(\"react\").ReactEventHandler; onWheel?: import(\"react\").WheelEventHandler; onWheelCapture?: import(\"react\").WheelEventHandler; part?: string; ping?: string; popover?: \"\" | \"auto\" | \"manual\" | \"hint\"; popoverTarget?: string; popoverTargetAction?: \"toggle\" | \"show\" | \"hide\"; prefix?: string; property?: string; radioGroup?: string; referrerPolicy?: import(\"react\").HTMLAttributeReferrerPolicy; rel?: string; resource?: string; results?: number; rev?: string; role?: import(\"react\").AriaRole; security?: string; size?: string; slot?: string; spellCheck?: (boolean | \"true\" | \"false\"); style?: import(\"react\").CSSProperties; suppressContentEditableWarning?: boolean; suppressHydrationWarning?: boolean; tabIndex?: number; target?: import(\"react\").HTMLAttributeAnchorTarget; title?: string; tone?: \"inherit\" | \"accent\"; translate?: \"yes\" | \"no\"; type?: string; typeof?: string; unselectable?: \"off\" | \"on\"; vocab?: string;", "EmptyState": "actions?: ReactNode; align?: \"start\" | \"center\"; body?: string; centeredTreatment?: \"neutral\" | \"clinical\"; description?: string; headingLevel?: 2 | 3 | 4 | 5 | 6; icon?: LucideIcon; iconNode?: ReactNode; live?: \"off\" | \"assertive\" | \"polite\"; testId?: string; title: string; tone?: \"danger\" | \"neutral\" | \"info\";", + "ErrorState": "actions?: ReactNode; body?: string; className?: string; density?: \"inline\" | \"panel\"; live?: \"off\" | \"assertive\" | \"polite\"; onRetry?: (() => void | Promise); reason: ErrorStateReason; subject?: string; testId?: string; title?: string;", "ErrorSummary": "attempt?: number; className?: string; errors: { fieldId: string; label: string; message: string; }[]; heading?: string;", "ExternalTextLink": "about?: string; accessKey?: string; \"aria-activedescendant\"?: string; \"aria-atomic\"?: (boolean | \"true\" | \"false\"); \"aria-autocomplete\"?: \"none\" | \"list\" | \"inline\" | \"both\"; \"aria-braillelabel\"?: string; \"aria-brailleroledescription\"?: string; \"aria-busy\"?: (boolean | \"true\" | \"false\"); \"aria-checked\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-colcount\"?: number; \"aria-colindex\"?: number; \"aria-colindextext\"?: string; \"aria-colspan\"?: number; \"aria-controls\"?: string; \"aria-current\"?: boolean | \"true\" | \"false\" | \"page\" | \"step\" | \"location\" | \"date\" | \"time\"; \"aria-describedby\"?: string; \"aria-description\"?: string; \"aria-details\"?: string; \"aria-disabled\"?: (boolean | \"true\" | \"false\"); \"aria-dropeffect\"?: \"none\" | \"link\" | \"copy\" | \"execute\" | \"move\" | \"popup\"; \"aria-errormessage\"?: string; \"aria-expanded\"?: (boolean | \"true\" | \"false\"); \"aria-flowto\"?: string; \"aria-grabbed\"?: (boolean | \"true\" | \"false\"); \"aria-haspopup\"?: boolean | \"true\" | \"false\" | \"dialog\" | \"grid\" | \"listbox\" | \"menu\" | \"tree\"; \"aria-hidden\"?: (boolean | \"true\" | \"false\"); \"aria-invalid\"?: boolean | \"true\" | \"false\" | \"grammar\" | \"spelling\"; \"aria-keyshortcuts\"?: string; \"aria-label\"?: string; \"aria-labelledby\"?: string; \"aria-level\"?: number; \"aria-live\"?: \"off\" | \"assertive\" | \"polite\"; \"aria-modal\"?: (boolean | \"true\" | \"false\"); \"aria-multiline\"?: (boolean | \"true\" | \"false\"); \"aria-multiselectable\"?: (boolean | \"true\" | \"false\"); \"aria-orientation\"?: \"horizontal\" | \"vertical\"; \"aria-owns\"?: string; \"aria-placeholder\"?: string; \"aria-posinset\"?: number; \"aria-pressed\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-readonly\"?: (boolean | \"true\" | \"false\"); \"aria-relevant\"?: \"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"; \"aria-required\"?: (boolean | \"true\" | \"false\"); \"aria-roledescription\"?: string; \"aria-rowcount\"?: number; \"aria-rowindex\"?: number; \"aria-rowindextext\"?: string; \"aria-rowspan\"?: number; \"aria-selected\"?: (boolean | \"true\" | \"false\"); \"aria-setsize\"?: number; \"aria-sort\"?: \"none\" | \"ascending\" | \"descending\" | \"other\"; \"aria-valuemax\"?: number; \"aria-valuemin\"?: number; \"aria-valuenow\"?: number; \"aria-valuetext\"?: string; autoCapitalize?: \"off\" | \"none\" | \"on\" | \"sentences\" | \"words\" | \"characters\" | (string & {}); autoCorrect?: string; autoFocus?: boolean; autoSave?: string; children: ReactNode; className?: string; color?: string; content?: string; contentEditable?: (boolean | \"true\" | \"false\") | \"inherit\" | \"plaintext-only\"; contextMenu?: string; dangerouslySetInnerHTML?: { __html: string | TrustedHTML; }; datatype?: string; defaultChecked?: boolean; defaultValue?: string | number | readonly string[]; dir?: string; download?: any; draggable?: (boolean | \"true\" | \"false\"); enterKeyHint?: \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\"; exportparts?: string; hidden?: boolean; href: string; hrefLang?: string; id?: string; inert?: boolean; inlist?: any; inputMode?: \"none\" | \"search\" | \"text\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"; is?: string; itemID?: string; itemProp?: string; itemRef?: string; itemScope?: boolean; itemType?: string; lang?: string; media?: string; nonce?: string; onAbort?: import(\"react\").ReactEventHandler; onAbortCapture?: import(\"react\").ReactEventHandler; onAnimationEnd?: import(\"react\").AnimationEventHandler; onAnimationEndCapture?: import(\"react\").AnimationEventHandler; onAnimationIteration?: import(\"react\").AnimationEventHandler; onAnimationIterationCapture?: import(\"react\").AnimationEventHandler; onAnimationStart?: import(\"react\").AnimationEventHandler; onAnimationStartCapture?: import(\"react\").AnimationEventHandler; onAuxClick?: import(\"react\").MouseEventHandler; onAuxClickCapture?: import(\"react\").MouseEventHandler; onBeforeInput?: import(\"react\").InputEventHandler; onBeforeInputCapture?: import(\"react\").InputEventHandler; onBeforeToggle?: import(\"react\").ToggleEventHandler; onBlur?: import(\"react\").FocusEventHandler; onBlurCapture?: import(\"react\").FocusEventHandler; onCanPlay?: import(\"react\").ReactEventHandler; onCanPlayCapture?: import(\"react\").ReactEventHandler; onCanPlayThrough?: import(\"react\").ReactEventHandler; onCanPlayThroughCapture?: import(\"react\").ReactEventHandler; onChange?: import(\"react\").ChangeEventHandler; onChangeCapture?: import(\"react\").ChangeEventHandler; onClick?: import(\"react\").MouseEventHandler; onClickCapture?: import(\"react\").MouseEventHandler; onCompositionEnd?: import(\"react\").CompositionEventHandler; onCompositionEndCapture?: import(\"react\").CompositionEventHandler; onCompositionStart?: import(\"react\").CompositionEventHandler; onCompositionStartCapture?: import(\"react\").CompositionEventHandler; onCompositionUpdate?: import(\"react\").CompositionEventHandler; onCompositionUpdateCapture?: import(\"react\").CompositionEventHandler; onContextMenu?: import(\"react\").MouseEventHandler; onContextMenuCapture?: import(\"react\").MouseEventHandler; onCopy?: import(\"react\").ClipboardEventHandler; onCopyCapture?: import(\"react\").ClipboardEventHandler; onCut?: import(\"react\").ClipboardEventHandler; onCutCapture?: import(\"react\").ClipboardEventHandler; onDoubleClick?: import(\"react\").MouseEventHandler; onDoubleClickCapture?: import(\"react\").MouseEventHandler; onDrag?: import(\"react\").DragEventHandler; onDragCapture?: import(\"react\").DragEventHandler; onDragEnd?: import(\"react\").DragEventHandler; onDragEndCapture?: import(\"react\").DragEventHandler; onDragEnter?: import(\"react\").DragEventHandler; onDragEnterCapture?: import(\"react\").DragEventHandler; onDragExit?: import(\"react\").DragEventHandler; onDragExitCapture?: import(\"react\").DragEventHandler; onDragLeave?: import(\"react\").DragEventHandler; onDragLeaveCapture?: import(\"react\").DragEventHandler; onDragOver?: import(\"react\").DragEventHandler; onDragOverCapture?: import(\"react\").DragEventHandler; onDragStart?: import(\"react\").DragEventHandler; onDragStartCapture?: import(\"react\").DragEventHandler; onDrop?: import(\"react\").DragEventHandler; onDropCapture?: import(\"react\").DragEventHandler; onDurationChange?: import(\"react\").ReactEventHandler; onDurationChangeCapture?: import(\"react\").ReactEventHandler; onEmptied?: import(\"react\").ReactEventHandler; onEmptiedCapture?: import(\"react\").ReactEventHandler; onEncrypted?: import(\"react\").ReactEventHandler; onEncryptedCapture?: import(\"react\").ReactEventHandler; onEnded?: import(\"react\").ReactEventHandler; onEndedCapture?: import(\"react\").ReactEventHandler; onError?: import(\"react\").ReactEventHandler; onErrorCapture?: import(\"react\").ReactEventHandler; onFocus?: import(\"react\").FocusEventHandler; onFocusCapture?: import(\"react\").FocusEventHandler; onGotPointerCapture?: import(\"react\").PointerEventHandler; onGotPointerCaptureCapture?: import(\"react\").PointerEventHandler; onInput?: import(\"react\").InputEventHandler; onInputCapture?: import(\"react\").InputEventHandler; onInvalid?: import(\"react\").ReactEventHandler; onInvalidCapture?: import(\"react\").ReactEventHandler; onKeyDown?: import(\"react\").KeyboardEventHandler; onKeyDownCapture?: import(\"react\").KeyboardEventHandler; onKeyPress?: import(\"react\").KeyboardEventHandler; onKeyPressCapture?: import(\"react\").KeyboardEventHandler; onKeyUp?: import(\"react\").KeyboardEventHandler; onKeyUpCapture?: import(\"react\").KeyboardEventHandler; onLoad?: import(\"react\").ReactEventHandler; onLoadCapture?: import(\"react\").ReactEventHandler; onLoadedData?: import(\"react\").ReactEventHandler; onLoadedDataCapture?: import(\"react\").ReactEventHandler; onLoadedMetadata?: import(\"react\").ReactEventHandler; onLoadedMetadataCapture?: import(\"react\").ReactEventHandler; onLoadStart?: import(\"react\").ReactEventHandler; onLoadStartCapture?: import(\"react\").ReactEventHandler; onLostPointerCapture?: import(\"react\").PointerEventHandler; onLostPointerCaptureCapture?: import(\"react\").PointerEventHandler; onMouseDown?: import(\"react\").MouseEventHandler; onMouseDownCapture?: import(\"react\").MouseEventHandler; onMouseEnter?: import(\"react\").MouseEventHandler; onMouseLeave?: import(\"react\").MouseEventHandler; onMouseMove?: import(\"react\").MouseEventHandler; onMouseMoveCapture?: import(\"react\").MouseEventHandler; onMouseOut?: import(\"react\").MouseEventHandler; onMouseOutCapture?: import(\"react\").MouseEventHandler; onMouseOver?: import(\"react\").MouseEventHandler; onMouseOverCapture?: import(\"react\").MouseEventHandler; onMouseUp?: import(\"react\").MouseEventHandler; onMouseUpCapture?: import(\"react\").MouseEventHandler; onPaste?: import(\"react\").ClipboardEventHandler; onPasteCapture?: import(\"react\").ClipboardEventHandler; onPause?: import(\"react\").ReactEventHandler; onPauseCapture?: import(\"react\").ReactEventHandler; onPlay?: import(\"react\").ReactEventHandler; onPlayCapture?: import(\"react\").ReactEventHandler; onPlaying?: import(\"react\").ReactEventHandler; onPlayingCapture?: import(\"react\").ReactEventHandler; onPointerCancel?: import(\"react\").PointerEventHandler; onPointerCancelCapture?: import(\"react\").PointerEventHandler; onPointerDown?: import(\"react\").PointerEventHandler; onPointerDownCapture?: import(\"react\").PointerEventHandler; onPointerEnter?: import(\"react\").PointerEventHandler; onPointerLeave?: import(\"react\").PointerEventHandler; onPointerMove?: import(\"react\").PointerEventHandler; onPointerMoveCapture?: import(\"react\").PointerEventHandler; onPointerOut?: import(\"react\").PointerEventHandler; onPointerOutCapture?: import(\"react\").PointerEventHandler; onPointerOver?: import(\"react\").PointerEventHandler; onPointerOverCapture?: import(\"react\").PointerEventHandler; onPointerUp?: import(\"react\").PointerEventHandler; onPointerUpCapture?: import(\"react\").PointerEventHandler; onProgress?: import(\"react\").ReactEventHandler; onProgressCapture?: import(\"react\").ReactEventHandler; onRateChange?: import(\"react\").ReactEventHandler; onRateChangeCapture?: import(\"react\").ReactEventHandler; onReset?: import(\"react\").ReactEventHandler; onResetCapture?: import(\"react\").ReactEventHandler; onScroll?: import(\"react\").UIEventHandler; onScrollCapture?: import(\"react\").UIEventHandler; onScrollEnd?: import(\"react\").UIEventHandler; onScrollEndCapture?: import(\"react\").UIEventHandler; onSeeked?: import(\"react\").ReactEventHandler; onSeekedCapture?: import(\"react\").ReactEventHandler; onSeeking?: import(\"react\").ReactEventHandler; onSeekingCapture?: import(\"react\").ReactEventHandler; onSelect?: import(\"react\").ReactEventHandler; onSelectCapture?: import(\"react\").ReactEventHandler; onStalled?: import(\"react\").ReactEventHandler; onStalledCapture?: import(\"react\").ReactEventHandler; onSubmit?: import(\"react\").SubmitEventHandler; onSubmitCapture?: import(\"react\").SubmitEventHandler; onSuspend?: import(\"react\").ReactEventHandler; onSuspendCapture?: import(\"react\").ReactEventHandler; onTimeUpdate?: import(\"react\").ReactEventHandler; onTimeUpdateCapture?: import(\"react\").ReactEventHandler; onToggle?: import(\"react\").ToggleEventHandler; onTouchCancel?: import(\"react\").TouchEventHandler; onTouchCancelCapture?: import(\"react\").TouchEventHandler; onTouchEnd?: import(\"react\").TouchEventHandler; onTouchEndCapture?: import(\"react\").TouchEventHandler; onTouchMove?: import(\"react\").TouchEventHandler; onTouchMoveCapture?: import(\"react\").TouchEventHandler; onTouchStart?: import(\"react\").TouchEventHandler; onTouchStartCapture?: import(\"react\").TouchEventHandler; onTransitionCancel?: import(\"react\").TransitionEventHandler; onTransitionCancelCapture?: import(\"react\").TransitionEventHandler; onTransitionEnd?: import(\"react\").TransitionEventHandler; onTransitionEndCapture?: import(\"react\").TransitionEventHandler; onTransitionRun?: import(\"react\").TransitionEventHandler; onTransitionRunCapture?: import(\"react\").TransitionEventHandler; onTransitionStart?: import(\"react\").TransitionEventHandler; onTransitionStartCapture?: import(\"react\").TransitionEventHandler; onVolumeChange?: import(\"react\").ReactEventHandler; onVolumeChangeCapture?: import(\"react\").ReactEventHandler; onWaiting?: import(\"react\").ReactEventHandler; onWaitingCapture?: import(\"react\").ReactEventHandler; onWheel?: import(\"react\").WheelEventHandler; onWheelCapture?: import(\"react\").WheelEventHandler; part?: string; ping?: string; popover?: \"\" | \"auto\" | \"manual\" | \"hint\"; popoverTarget?: string; popoverTargetAction?: \"toggle\" | \"show\" | \"hide\"; prefix?: string; property?: string; radioGroup?: string; referrerPolicy?: import(\"react\").HTMLAttributeReferrerPolicy; resource?: string; results?: number; rev?: string; role?: import(\"react\").AriaRole; security?: string; slot?: string; spellCheck?: (boolean | \"true\" | \"false\"); style?: import(\"react\").CSSProperties; suppressContentEditableWarning?: boolean; suppressHydrationWarning?: boolean; tabIndex?: number; title?: string; tone?: \"inherit\" | \"accent\"; translate?: \"yes\" | \"no\"; type?: string; typeof?: string; unselectable?: \"off\" | \"on\"; vocab?: string;", "FieldError": "children: string; id: string;", @@ -120,6 +121,7 @@ "DoseLine": "src/components/ui/answer-card.tsx", "DownloadLink": "src/components/ui/link.tsx", "EmptyState": "src/components/ui-primitives.tsx", + "ErrorState": "src/components/ui/error-state.tsx", "ErrorSummary": "src/components/ui/form-field.tsx", "ExternalTextLink": "src/components/ui/link.tsx", "FieldError": "src/components/ui/form-field.tsx", diff --git a/.design-sync/entry.tsx b/.design-sync/entry.tsx index e6a939588e..8d20aabc24 100644 --- a/.design-sync/entry.tsx +++ b/.design-sync/entry.tsx @@ -55,6 +55,7 @@ export { type DisclosureGroupProps, type DisclosureProps, } from "@/components/ui/disclosure"; +export { ErrorState, errorStateCopy, type ErrorStateProps, type ErrorStateReason } from "@/components/ui/error-state"; export { ErrorSummary, FieldError, diff --git a/.design-sync/previews/ErrorState.tsx b/.design-sync/previews/ErrorState.tsx new file mode 100644 index 0000000000..a13c8d122a --- /dev/null +++ b/.design-sync/previews/ErrorState.tsx @@ -0,0 +1,16 @@ +import { ErrorState } from "prompt-for-codex-medical-knowledge-base"; + +// A failed request has no count to report, so none of these render a number. +// "0 matches" on a failed services search asserts there are no crisis services +// when the search never ran — see SPEC §10 and COMPONENTS §0.3. +export const Reasons = () => ( +
+ {}} /> + + {}} /> +
+); + +// `inline` drops the panel chrome for a surface that already owns a border, +// exactly as the search band's fault panel sits inside the results ribbon. +export const Inline = () => ; diff --git a/docs/design-system/ADOPTION.md b/docs/design-system/ADOPTION.md index eee8fc5069..b734870ab8 100644 --- a/docs/design-system/ADOPTION.md +++ b/docs/design-system/ADOPTION.md @@ -326,7 +326,7 @@ multiply-owned routes fail the check. `src/app/api/**` and `src/app/mockups/**` product exclusions; the only route-only disposition is the documented legacy document-source redirect. Shared shell/component roots carry their own explicit `shared-shell` disposition. -Registered public components: 53 +Registered public components: 54 Declared product roots: 59 Roots with a literal `.ckb-v2` opt-in: 1 Roots inheriting `.ckb-v2` from the global ``: 58 diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 601576f07b..7ee1e9fe6b 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -945,8 +945,8 @@ tests to run. ## Generated maturity snapshot -Registered public components: 53 -Components with a valid design-sync preview: 53 +Registered public components: 54 +Components with a valid design-sync preview: 54 Components with product imports: 31 This generated snapshot is a local source-derived inventory. It does not assert remote design-project publication. @@ -970,6 +970,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `DoseLine` | answer | yes | yes | no | yes | no | 0 | | `DownloadLink` | controls | yes | yes | no | yes | no | 0 | | `EmptyState` | feedback | yes | yes | inherited-global-root | yes | no | 12 | +| `ErrorState` | feedback | yes | yes | no | yes | no | 0 | | `ErrorSummary` | feedback | yes | yes | no | yes | no | 0 | | `ExternalTextLink` | controls | yes | yes | no | yes | no | 0 | | `FieldError` | feedback | yes | yes | no | yes | no | 0 | diff --git a/docs/design-system/GATES.md b/docs/design-system/GATES.md index 6f5f5ff342..5da3e75c1c 100644 --- a/docs/design-system/GATES.md +++ b/docs/design-system/GATES.md @@ -83,33 +83,33 @@ theme-list parity, and remote design-project publication remain separate concern ## 3 · The prohibition table -| Never do this | Gate | Status | -| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Reintroduce `.ckb-v2:not(.dark)` | Cascade computed-style test (PR 1) + contract-test selector filter update | implemented-blocking — landed #1538; contract parser + dark-ink regression guard | -| Add `prefers-color-scheme` rules to the v2 layer | — (users pin a theme against OS preference) | **manual** | -| Paint a numeral with a status colour; use amber/red decoratively | `check:design-system-contract` — `statusColouredNumerals` + `colourOnlyStatusIndicators` | **implemented-blocking** (9 Aug 2026) — ratcheted at 2 and 4 with per-path pins; see §5 | -| Put `--decoration-soft`/`--text-soft` on a text node | Tier pinned both sides in the token contract; recipe AST (`decoration-on-text.contract.test.ts`) | implemented-blocking (recipes); broader AST follow-on | -| Uppercase a dose unit | `Quantity` pins `normal-case` + unit test | implemented-blocking (branch) | -| Invert a PDF, diagram or clinical image in any theme | `check:design-system-contract` — `imageInversions`, pinned at **zero**, not ratcheted | **implemented-blocking** (9 Aug 2026) — CSS `filter`/`backdrop-filter` plus the Tailwind `invert`/`hue-rotate` utilities; see §5 | -| Border **and** ring on one surface, or a 1px spread in a drop shadow | `check:design-system-contract` — `edgeOwnershipConflicts` (27) + `onePixelShadowSpreads` (2) | **implemented-blocking for new use** — AST/CSS ratchets with per-path pins; the recorded debt itself is Gate 8's remaining half | -| A child shadow heavier than its parent's | Gate 7 | implemented-partial | -| Use `--shadow-tight`/any alias in new code | `check:design-system-contract` — `legacyShadowAliases`, ratcheted at 224 with per-path pins | **implemented-blocking for new use** — a new alias in any file fails; retiring the existing 224 is `#262` | -| Raw pixel size, padding, radius or line-height in markup | `check:design-system-contract` — `arbitraryPadding` (58), `arbitraryGap` (21), `arbitraryRadius` (2), `arbitraryLeading` (**zero**) | **implemented-blocking for new use** (9 Aug 2026) — line-height is pinned at zero with a whole-file text backstop; padding/gap/radius are ratchets with per-path pins. Raw *size* is still covered only for tap/shadow/colour. Values referencing a safe-area inset are exempt, and so are values whose lengths all come from tokens; see §6 | -| Animate `width`, `height`, `grid-template-*`, `top`, `left`, `gap` | `check:design-system-contract` — `layoutTransitionExceptions`, ratcheted at 12 with per-path pins | **implemented-blocking for new use** — `SAFE_TRANSITION_PROPERTIES` carries the compositor-only allowlist; phone chrome's deliberate `grid-template-rows` is in the recorded 12 | -| Hardcode a transition duration | `check:design-system-contract` — `hardcodedMotionClasses` (**zero**) + `hardcodedCssMotionDurations` (42) | **implemented-blocking** for the Tailwind `duration-*`/`delay-*`/`transition-all` form; the CSS form is a ratchet, so its 42 are debt | -| A `z-` value outside the named rungs | `require-z-index-ladder` (ESLint) | implemented-blocking | -| Ship a registered component without a direct publication test | Gate 12 | implemented-blocking | -| Lower a production tap target below 48px | Gate 2 + `ui-smoke` flake history + held visual probe | implemented-partial — token floor + declared carriers only; fixed `h-10` interactive controls and the unwired Chromium audit are not blocked | -| Construct an enabled control with no action, destination or name | PR 4 discriminated unions + DOM contracts; `require-button-wiring` covers raw `} />, + ); + expect(screen.getByRole("button", { name: "Browse library" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + it("degrades an unrecognised reason to the neutral copy and never throws", () => { + const reason = "catastrophe" as unknown as ErrorStateReason; + expect(() => render()).not.toThrow(); + expect(screen.getByRole("alert")).toHaveTextContent("Services could not be loaded"); + expect(screen.getByRole("alert")).toHaveAttribute("data-reason", "unknown"); + }); + + it("survives a prototype-chain reason without throwing", () => { + const reason = "toString" as unknown as ErrorStateReason; + expect(() => render()).not.toThrow(); + expect(errorStateCopy(reason).reason).toBe("unknown"); + }); + + it("maps live to a role so a state present on first paint need not interrupt", () => { + const { rerender } = render(); + expect(screen.getByTestId("es")).toHaveAttribute("role", "status"); + rerender(); + expect(screen.getByTestId("es")).not.toHaveAttribute("role"); + }); +}); From f5173da96d071af9eadf302a3313fcd0ee06c3e8 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:29:03 +0800 Subject: [PATCH 4/4] docs(issues): capture the M4 follow-ups before the context is lost Four rows, all measured against origin/main 199b303b7 in this session: - #295 (P2 task) the ErrorState enforcement check. GATES.md:106 still reads "planned" and grep over scripts/ and eslint-rules/ returns zero references to ErrorState, so the component exists but nothing requires it. Deliberately not flipped to implemented. - #296 (P3 task) adopting ErrorState at the three surfaces that genuinely hand-roll the guard. Live-look change, downstream of the redesign. - #297 (P3 issue) the safe-area exemption in the new raw-value ratchet, which is by form rather than by absence of a literal. - #298 (P2 issue) the three sites carried into M4 as guards that are not guards, so the next reader does not convert them. Written with npm run issues:add, never by hand. Co-Authored-By: Claude Opus 5 --- docs/outstanding-issues.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 00e6ee78b7..b4b9fb432d 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. @@ -330,6 +330,10 @@ 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 | P2 | task | ErrorState exists but nothing enforces it - the '0 matches after a failed request' row is still planned | GATES.md line 106 still reads: Render "0 matches" after a failed request \| ErrorState adoption + check \| planned. The component landed (src/components/ui/error-state.tsx, PR for branch claude/errorstate-ratchets-m4) and is registered across all gate-11/12 surfaces, but grep over scripts/ and eslint-rules/ returns ZERO references to ErrorState, so nothing prevents a new surface rendering a count under a failed status. Building the component closed the 'documented gate with nothing behind it' half; the enforcement half is untouched and the row was deliberately NOT flipped to implemented, because that would be exactly the drift GATES.md exists to stop. Next action: add a check that fails when a count-bearing node renders under an error/failed status - the analyzer in scripts/design-system-contract-utils.mjs already resolves class roots and JSX children (see childrenAreNumeralOnly, used by statusColouredNumerals), so a metric there is the cheapest route and needs no new npm script. Prefer this BEFORE adoption: a check with no adoption still stops the next regression, adoption with no check does not. Stop: do not flip the GATES.md row to implemented until a check actually runs in verify:cheap. | session 2026-08-09 M4 - ErrorState build | 2026-08-09 | +| #296 | P3 | task | Adopt ErrorState at the three surfaces that genuinely hand-roll the failed-request guard | Three surfaces hand-roll the guard and their comments state the rule outright: src/components/clinical-dashboard/search-results-header-band.tsx:210 ('no number may reach the DOM'), src/components/services/services-navigator-page.tsx:634 ('a blocked registry must not reach the band as 0 matches'), src/components/clinical-dashboard/favourites-command-library-page.tsx:1182. They are CORRECT today, just not shared, so this is convergence rather than a bug fix. The band's fault panel is the richest existing implementation (role=alert, warning tokens, AsyncButton retry with busy state, faultAction slot) and ErrorState was modelled on it, so the shapes already line up. This is a live-look change: own PR, Chromium pass, and per the M4 brief it sits DOWNSTREAM of design decisions the owner has not made yet - doing it before the site-wide redesign risks redoing it. Do NOT bundle with the enforcement check. Stop: only these three. See the sibling row for three further sites that were carried into M4 as guards and are not. | session 2026-08-09 M4 - ErrorState build | 2026-08-09 | +| #297 | P3 | issue | The raw-value ratchet exempts safe-area padding as a form, so a literal length inside one escapes | Measured 2026-08-09 against origin/main 199b303b7 while landing #262 part 3. isSanctionedRawValue in scripts/design-system-contract-utils.mjs exempts any arbitrary value referencing env(safe-area-inset-*), var(--safe-area-*) or var(--keyboard-height). That exemption is by FORM, not by absence of a literal, so pb-[calc(9rem+var(--safe-area-bottom))] is exempt even though 9rem is a hand-written length. 19 of the 100 initial findings were this shape. The strict alternative was measured and rejected: it fails a CORRECT new phone composer while passing an incorrect one that drops the inset, and docs/search-chrome-behaviour.md mandates the inset form ('visible composer chrome may still consume safe-area inset'). A ratchet that punishes compliance gets suppressed rather than obeyed. Recorded in GATES.md section 6 rather than left silent. Next action: none required - this is a documented trade-off, not a defect. IF it is ever tightened, the honest route is a token for the composer reserve height so the literal disappears rather than the exemption narrowing. Stop: do not narrow the exemption without first providing that token, and do not delete the cross-variant safe-area values as dead. | session 2026-08-09 M4 - raw-value ratchet | 2026-08-09 | +| #298 | P2 | issue | Three sites carried into M4 as hand-rolled '0 matches' guards are not guards - do not convert them | Re-measured 2026-08-09 against origin/main 199b303b7. The M4 handover listed six surfaces hand-rolling the failed-request guard; three do not survive measurement and converting them to ErrorState would be WRONG. (1) src/components/clinical-dashboard/differentials-home.tsx:716,729 renders '0 matches' and 'No matches' when sourcesChecked is TRUE - sourcesChecked: boolean means the source search RAN, so that is a legitimate zero after a search that SUCCEEDED, which is the opposite of a failed-request guard and must keep reporting its count. (2) specifiers-home-page.tsx is not under clinical-dashboard/ at all - the real path is src/components/specifiers/specifiers-home-page.tsx, and its line 211 is a comment about not showing a stale zero ABOVE real catalogue results, a different problem. (3) document-search-results.tsx:1508 gates on recordStatus for LOADING (recordSearchStillRunning), not for a failed count; its genuine fault handling is recordBandOwnsFault, which delegates to the band. Only search-results-header-band, services-navigator-page and favourites-command-library-page are real. Next action: none - this row exists so the next reader does not convert the wrong three. Stop: do not 'fix' differentials-home to suppress its count. | session 2026-08-09 M4 - ErrorState build | 2026-08-09 | ## Resolved / archive