From 2948a54e9d4372b3992e6bb117afd8efe4dae569 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:36:27 +0800 Subject: [PATCH 1/5] fix(design-system): LinkAction refuses tone instead of silently ignoring it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LinkActionProps extended BaseProps, which declares tone?: 'accent' | 'inherit', but LinkAction destructures only { href, children, className }. tone='inherit' therefore type-checked, read as working, and did nothing — while TextLink, ExternalTextLink and DownloadLink all honour it. Omitting tone from the props makes the compiler reject the call instead. Refusing rather than implementing: a forward action is the accent by design, which is what makes it read as the card's next step rather than as prose, so there is no 'inherit' variant to select. LinkAction has zero production importers today, so this is free to change now and expensive once call sites exist. .design-sync/config.json is regenerated, not hand-edited: npm run design-system:design-sync:update. Changing a public *Props type without it fails check:design-sync-contract. Co-Authored-By: Claude Opus 5 --- .design-sync/config.json | 2 +- src/components/ui/link.tsx | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.design-sync/config.json b/.design-sync/config.json index 6491a74f25..4e18f705fd 100644 --- a/.design-sync/config.json +++ b/.design-sync/config.json @@ -43,7 +43,7 @@ "FormField": "autoComplete?: string; children: (field: FormFieldRenderProps) => ReactNode; className?: string; describedBy?: string; error?: string; hideLabel?: boolean; hint?: string; id?: string; label: string; required?: boolean;", "IconButton": "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-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; 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; disabled?: boolean; draggable?: (boolean | \"true\" | \"false\"); enterKeyHint?: \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\"; exportparts?: string; form?: string; formAction?: string | ((formData: FormData) => void | Promise); formEncType?: string; formMethod?: string; formNoValidate?: boolean; formTarget?: string; hidden?: boolean; icon: LucideIcon; iconClassName?: 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; label: string; lang?: string; name?: 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; popover?: \"\" | \"auto\" | \"manual\" | \"hint\"; popoverTarget?: string; popoverTargetAction?: \"toggle\" | \"show\" | \"hide\"; prefix?: string; property?: string; radioGroup?: string; rel?: string; 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; translate?: \"yes\" | \"no\"; type?: \"submit\" | \"reset\" | \"button\"; typeof?: string; unselectable?: \"off\" | \"on\"; value?: string | number | readonly string[]; vocab?: string;", "InlineNotice": "animated?: boolean; children: ReactNode; className?: string; dismissLabel?: string; onDismiss?: (() => void); tone: NoticeTone;", - "LinkAction": "children: ReactNode; className?: string; href: string; tone?: \"inherit\" | \"accent\";", + "LinkAction": "children: ReactNode; className?: string; href: string;", "LoadingPanel": "label: string; layout?: \"panel\" | \"centered\"; lines?: number; variant?: \"spinner\" | \"skeleton\";", "MissingValue": "className?: string; density?: \"cell\" | \"inline\"; reason: MissingValueReason;", "OverlayRoot": "", diff --git a/src/components/ui/link.tsx b/src/components/ui/link.tsx index b295d82355..2245aaf1db 100644 --- a/src/components/ui/link.tsx +++ b/src/components/ui/link.tsx @@ -40,7 +40,15 @@ export type DownloadLinkProps = BaseProps & { href: string; format?: string; siz "href" | "children" | "className" | "download" >; -export type LinkActionProps = BaseProps & { href: string }; +/** + * `tone` is deliberately omitted rather than inherited from `BaseProps`. A + * forward action is the accent by design — that is what makes it read as the + * card's next step rather than as prose — so there is no `inherit` variant to + * select. Accepting the prop and ignoring it was worse than refusing it: three + * sibling links honour `tone`, so `tone="inherit"` here looked like it worked + * and silently did nothing. Omitting it makes the compiler say so. + */ +export type LinkActionProps = Omit & { href: string }; /** * Internal navigation. Wraps `next/link` so a call site never reaches for a raw From 824c1b74a2977e22a2db04ef30ed408fc8f64f65 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:54:23 +0800 Subject: [PATCH 2/5] docs(issues): correct #270, and record the measured #118/#269 baseline mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #270 said 22 call sites pair a tap token with a dead numeric height and that declaring `tap` in tailwind-merge would drop 18 production targets from 48px. Re-measured against origin/main 00826bca2 and none of that holds at this HEAD. Method: scan every string literal in src (mockups excluded, comments blanked — backticks inside JSX comments otherwise let a literal scanner span lines and invent hits), then 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, 84 cross-variant ones. DocumentManagerPanel.tsx and settings-dialog.tsx carry no tap token at all any more. The survivors are responsive step-downs, not dead classes: `min-h-tap` with `sm:min-h-9`, `h-10.5` with `sm:h-tap`. The later variant wins at its breakpoint, so deleting the numeric raises the control — a visual change, not the no-op the row promised. And tailwind-merge groups by variant, so the stated blocker is false: a probe over the real pairings returns identical output with and without `tap` declared; only synthetic same-variant controls change. What stays open is narrower and is now written down: the per-literal scan cannot see a conflict composed across cn() arguments, where order decides whether a recipe raises to 48px or drops to 28px. #118 records the owner's 2026-08-09 decision (adopt, advisory-only first, promote after a soak) and the sequencing constraint — baselines frozen while design changes are still merging go red on the next merge. #269's 'baselines cannot be generated on Windows' is corrected: true of win32 PNGs, but the ubuntu CI job already produces the ones that count. Co-Authored-By: Claude Opus 5 --- docs/branch-review-ledger.md | 1 + docs/outstanding-issues.md | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 7cf9166e9a..3649079f8e 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -744,3 +744,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-08 | claude/ds-a4-component-defects | a029a543f744eb80e608ec482aacdbdc5f5599c2 | unblock PR #1712 | Merged origin/main (ef28960e) to clear dirty mergeable_state: real conflict in docs/branch-review-ledger.md auto-merged via merge=ledger driver. Prior tip 9ba483d3 was 1 behind main. Static PR and PR required failures were dirty-state blockers (GitHub could not build refs/pull/1712/merge). Proved post-merge: merge-tree clean, check:branch-review-ledger, check:design-system-contract. | merge-tree clean; ledger:dedupe; check:branch-review-ledger; check:design-system-contract | | 2026-08-08 | claude/ds-a4-component-defects | d3a697aa8784c9cbdecfba24402cf2269bfe15d4 | heavy review-and-fix PR #1712 | Lint blocker fixed (react-hooks/refs in Pagination); CodeRabbit threads dispositioned; synced main (4a9d81d3 Lighthouse pin); merge-tree clean; verify:cheap 5567 passed; verify:pr-local green; check:design-system-contract passed | lint; typecheck; prettier --check .; verify:cheap (5567 passed); verify:pr-local; check:design-system-contract; check:branch-review-ledger; vitest ui-v2-components.dom (75 passed) | | 2026-08-08 | cursor/run-pr-sweep-ledger-d56c (PR #1698) | ccf7284cbbe2315e5dc6a1bf126403a8a2205fa7 | Run PR sweep: CI fix + threads + drift | before: DIRTY/CONFLICTING, PR mergeability fail, behind 33, 0 threads, fake single-parent merge tip → after: real merge origin/main (conflicts resolved: docs/outstanding-issues.md + lighthouse-budget.json took main), merge-tree clean, unique diff ledger-only, 0 threads | check:outstanding-issues pass; check:branch-review-ledger pass; ledger:dedupe none; merge-tree clean; format; no provider-backed checks run | +| 2026-08-08 | claude/ds-doc-corrections | 534405600dca67317b4d60266cda03ec95f028e7 | M1 stranded doc corrections (docs/outstanding-issues.md #262/#266, docs/design-system/COMPONENTS.md TextField row + section 4) | authored and handed off as PR #1719; every inherited figure re-measured against origin/main rather than copied forward, and the stranded version's 'eight shadow tokens, focus 2' claim was found wrong — LEGACY_SHADOW_ALIAS matches seven tokens and has never included focus | check:outstanding-issues pass (274 rows, unique ids, no ids deleted from base); prettier --check . pass whole-tree; legacyShadowAliases re-measured 228 via the contract's own analyzers; docs-only diff so no unit/lint/typecheck/browser gate applies | diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 060cb337ad..683823808f 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -212,7 +212,7 @@ removed after current-main verification; it is not missing recommended work. | #101 | P3 | rec | Canary-gated retrieval parallelisation candidates | **Outcome:** independent retrieval stages stop running serially, proven by a live canary pair. Candidates: metadata/memory/visual hydration triples repeated on four branches (`rag.ts:2460,2493,2521` and three more) while `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all`, so the omission is inconsistency rather than intent; the nested `await`-in-loop scope enumeration (`search-scope.ts:202,328`); typeahead results never cached (`rag.ts:2698-2711`); universal-search coalescing (`/api/search` has it, `/api/search/universal` does not). Each changes candidate assembly, truncation, or what the next keystroke returns, so each needs 36/36 retrieval plus recall 1.0 and zero per-case rr regressions. Distinct from #001 (semantic rerank). Resolved #075 and #083 are the precedents for why these are gated rather than free. **Stop:** needs the #098 harness and explicit canary approval first. | `docs/audit/latency-audit-2026-07-28.md` L2-1/L2-2/L2-8/L1-5 | 2026-07-29 | | #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Runbook prepared 2026-07-29 — NOT applied, item stays open:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive, though **the "recall is byte-identical" claim was RETRACTED on 2026-07-29 review**: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with no `ORDER BY`, so a new index can change which title-alias documents feed candidate assembly. Only the documents-list use stays ordering-safe; `(status,id)` is canary-gated too — see runbook, and making that `.limit(12)` deterministic first does **not** lift the gate — an unordered `LIMIT` has no stable selection to preserve, so imposing an order can pick a different twelve and is itself an ordering behaviour change on a retrieval surface, which AGENTS.md requires a canary pair for. Sequencing the ordering fix first is worthwhile (unordered `LIMIT` on a retrieval input is latent nondeterminism regardless) but yields two canary-gated changes, not one (PR #1377 review). **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** **author the migration first** — `supabase/migrations/` is the source of truth and `schema.sql` only a mirror, so hand-run operator SQL never reaches staging, disaster-recovery replay, or a local `supabase db reset`, and a `required_indexes` registration would fail there (PR #1377 review); follow the `20260717170000_registry_projection_cleanup.sql` idempotent pattern. **That migration must also carry the health-function change** — `required_indexes` lives inside `search_schema_health()`, which is redefined by `create or replace function` in eleven migrations (copy `20260705180000_reconcile_search_health_indexes.sql:62`); editing `schema.sql:3177` alone moves only the mirror and leaves the indexes unmonitored on hosted (PR #1377 review). Then apply concurrently, confirm `indisvalid`, mirror both the index statements and the identical function body into `schema.sql`, run `npm run drift:manifest` (Docker), and deploy the migration LAST — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. **Rollback is three deployed phases, not the reverse of one:** retract `required_indexes` via its own `create or replace function` migration and deploy → drop concurrently live → only then deploy the `schema.sql` removal plus an idempotent forward `drop index if exists` migration, because Supabase wraps migrations in a transaction and a plain `DROP INDEX` there takes the lock the concurrent procedure exists to avoid (PR #1377 review). | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | | #117 | P2 | rec | Therapy Compass catalogue payload is the mobile LCP outlier | **Outcome:** `/therapy-compass` mobile LCP lands near the other mobile routes instead of double them. **Measured 2026-07-30** by the new pre-merge Lighthouse budget: mobile LCP 5229 ms, TBT 612 ms, CLS 0.142, against 2123-2460 ms on every other mobile route and 826 ms on desktop — so it is client-side work under mobile CPU/network throttling, not server latency. **Cause before this PR:** `useTherapyData` fetched `public/therapy-compass-data/therapies-index.json` (the thin browse index; 205 records) for the home/search/pathways screens, so the download plus JSON parse sat on the critical path before content painted. **Current split:** home now fetches `public/therapy-compass-data/therapies-home.211dab554c4ec62d.json` (136,288 bytes raw), pathways use the thin browse index, and search loads the full prose corpus (#1471). 90% of the index weight is long-form clinical prose — indications 159 KB (26%), contraindicationsOrCautions 139 KB (23%), bestUsedFor 73 KB (12%), clinicalSummary 67 KB (11%), patientPopulation 59 KB (10%), targetSymptoms 48 KB (8%) — while name, slug, category, tags and setting together are 54 KB (7%). **Remaining decision for search/pathways: rendered on the card, matched by search, or neither.** `therapy-card.tsx` references five of those prose fields and the same index feeds the search screen, so stripping fields could silently change clinical display or search recall. **Next:** settle that per-field question, then either pre-truncate prose that only feeds card display, or move search matching server-side / load prose on first keystroke. **Gate:** `check:therapy-data-index` plus the therapy Playwright journeys; re-measure with `npm run verify:lighthouse`. **Stop:** do not drop a field from the catalogue payload without confirming no card renders it and no search path matches on it. Same class as #013 (route-chunk / catalogue JSON weight), different route and now measured. | session 2026-07-30 Lighthouse budget first run; PR #1404; PR #1489 | 2026-07-30 | -| #118 | P2 | task | Adopt the visual and Lighthouse baselines so the two new gates actually gate | **Outcome:** `visual-baseline` and `lighthouse-budget` stop reporting and start blocking. **Detail:** PR #1404 added both as `continue-on-error` jobs outside `pr-required`, deliberately. `tests/ui-visual-baseline.spec.ts` has no committed baselines, so all six targets fail with a missing-snapshot error by design; the job uploads them on every run (run 30513537912, artifact 8748062487, 31 files). `lighthouse-budget.json` ships `enforce: false` with `baseline: null`, so the grader warns rather than grades. **Next:** (1) download that artifact, review the six PNGs and commit them under the platform-scoped screenshots directory that `playwright.visual.config.ts` names in its `snapshotPathTemplate` — from CI, never a developer machine, because font hinting differs between them; (2) run `npm run check:lighthouse-budget -- --update` against a known-good CI build and flip `enforce`, but not before #117 or the baseline pins a known-slow route; (3) then add each job to `pr-required` and drop `continue-on-error` in the same edit. **Also:** PR #1404 added the first rendered-effect contract for #094, but 37 of the 38 unlayered visual classes still carry exemptions in `tests/helpers/style-contracts.ts` rather than contracts; and `scripts/run-lighthouse-budget.mjs` duplicates about 50 lines of the isolated-server boot in `scripts/run-playwright.mjs`, deferred to avoid destabilising the required UI gate in the same change. **Stop:** do not make a missing baseline skip instead of fail — that is the soft-skip-green pattern `AGENTS.md` forbids. | session 2026-07-30; PR #1404 | 2026-07-30 | +| #118 | P2 | task | Adopt the visual and Lighthouse baselines so the two new gates actually gate | **Outcome:** `visual-baseline` and `lighthouse-budget` stop reporting and start blocking. **Detail:** PR #1404 added both as `continue-on-error` jobs outside `pr-required`, deliberately. `tests/ui-visual-baseline.spec.ts` has no committed baselines, so all six targets (dashboard-shell, dashboard-shell-phone, search-results-band, search-results-band-phone, document-viewer, therapy-compass-home) fail with a missing-snapshot error by design; the job uploads them on every run (run 30513537912, artifact 8748062487, 31 files). `tests/__screenshots__/` still holds only README.md, re-checked 2026-08-09. `lighthouse-budget.json` ships `enforce: false` with `baseline: null`, so the grader warns rather than grades. **Owner decision 2026-08-09: adopt the baselines, advisory-only first, and promote to required only after they have held across a few runs** — the workflow comment asks for that soak explicitly. **Sequencing constraint recorded the same day:** the owner had design changes merging at the time, and pixel baselines frozen while the look is still moving go red on the next merge, which is the churn the hold exists to avoid. Trigger the ubuntu job once that merge wave has landed, not before. **Next:** (1) trigger `visual-baseline` on a UI PR, download the artifact, review the six PNGs and commit them under the platform-scoped screenshots directory that `playwright.visual.config.ts` names in its `snapshotPathTemplate` — from CI, never a developer machine, because font hinting differs between them; (2) run `npm run check:lighthouse-budget -- --update` against a known-good CI build and flip `enforce`, but not before #117 or the baseline pins a known-slow route; (3) then add each job to `pr-required` and drop `continue-on-error` in the same edit. **Also:** PR #1404 added the first rendered-effect contract for #094, but 37 of the 38 unlayered visual classes still carry exemptions in `tests/helpers/style-contracts.ts` rather than contracts; and `scripts/run-lighthouse-budget.mjs` duplicates about 50 lines of the isolated-server boot in `scripts/run-playwright.mjs`, deferred to avoid destabilising the required UI gate in the same change. **Stop:** do not make a missing baseline skip instead of fail — that is the soft-skip-green pattern `AGENTS.md` forbids. | session 2026-07-30; PR #1404 | 2026-07-30 | | #142 | P3 | task | Four loose dated docs need source and migration edits before they can be filed | **Outcome:** every dated point-in-time doc lives in `docs/audit/` or `docs/archive/` as `docs/README.md` requires, not loose at the `docs/` top level. **Detail:** PR #1436 filed the five that were docs-only moves. These four are referenced from outside `docs/`, so relocating them means editing source, tests and migration SQL comments — a different risk class than a docs tidy, and not worth bundling into one: `capacity-review.md` (`scripts/soak-test.ts`), `tenancy-defense-in-depth-review.md` (`src/lib/owner-scope.ts`, `tests/owner-scope-guard.test.ts`, two migrations, `SECURITY.md`, `.claude/agents/clinical-governance-reviewer.md`), `operator-apply-july8-batch.md` (three migrations plus `supabase/schema.sql`), `scale-readiness-review.md` (one migration). Also note `forward-codify-retrieval-rpcs-workorder.md` is indexed as a completed workorder but live is still ahead of the repo on those RPCs, so archiving it would misrepresent open operator work. **Next:** treat as low priority — the docs are correctly indexed and reachable where they are; only file them if a pass is already editing those migrations. Editing applied migration SQL is subject to `npm run check:migration-role`'s immutability pin. | PR #1436; session 2026-07-30 | 2026-07-30 | | #147 | P2 | rec | Mobile CLS breaches four routes, and it reproduces exactly offline | **Outcome:** mobile CLS is under 0.1 on every measured route, or each remaining breach has a recorded reason. **Measured 2026-07-30 with the LOCAL OFFLINE production harness** (`CHROME_PATH=/opt/pw-browsers/chromium npm run verify:lighthouse -- --keep`; Lighthouse 12.8.2, Chromium 141.0.7390.37, demo corpus, inert loopback Supabase). Mobile CLS: `/dsm` 0.363, `/documents/search` 0.220, `/therapy-compass` 0.142, `/` 0.023 — `/forms` did not measure locally (Lighthouse `NO_NAVSTART`, its own "run again" transient), but the live dispatch put it at 0.212. Desktop passes everywhere: 0.016–0.097. **The finding that matters is that these reproduce the live production dispatch EXACTLY.** Against run `30548662649` on `psychiatry.tools`, all four locally measurable mobile routes match to three decimals — 0.023 / 0.142 / 0.220 / 0.363 on both — and the desktop range matches at both endpoints, 0.016–0.097. So CLS on these routes is deterministic layout structure, not network, latency, or production data. Three consequences: (a) the CLS half of `#017` is debuggable and gate-able locally at zero provider cost; (b) `#017`'s recorded blocker "prod server hard-requires Supabase secrets" is stale — `scripts/run-lighthouse-budget.mjs` builds and serves an offline production app precisely to sidestep that, and its earlier note that dev-mode CLS looked "excellent at 0.00–0.04" was measuring DEV, which does not reproduce production; (c) re-dispatching the live workflow to re-measure CLS buys nothing. **LCP does NOT reproduce and must not be read from local runs:** local mobile LCP is 2014–2051 ms against 3639–4716 ms live, because the loopback server has no network latency. The LCP _ranking_ does survive — `/therapy-compass` is the outlier both locally (4978 ms, ~2.4x every other route) and live — which corroborates `#117` rather than replacing it. Mobile TBT, local: `/documents/search` 581 ms, `/dsm` 448, `/therapy-compass` 379, `/` 338. **Ranking by measured contribution, which is what `#017` asked for:** 1. `/dsm` CLS 0.363 (3.6x the threshold, the worst single number anywhere); 2. `/documents/search` 0.220; 3. `/forms` 0.212 (live only); 4. `/therapy-compass` 0.142, and separately the sole LCP outlier via `#117`; 5. `/` 0.023, which passes and needs nothing. **ATTRIBUTED 2026-07-30 — one cause dominates, and it is a self-inflicted round trip.** Driving Chromium directly against the same offline production build (Lighthouse mobile emulation: 412x823, DPR 1.75, 4x CPU throttle) with a `PerformanceObserver` on `layout-shift` reading `entry.sources[].node`: on `/dsm`, `/documents/search`, `/forms` and `/therapy-compass` the entire main content region moves **down 128px and straight back up 128px** within 15-60ms. Both moves score, so the round trip is pure cost with zero net movement. It is **100% of `/documents/search`** (0.110 down + 0.110 up = 0.220) and about 75% of `/dsm`. The shifting element is the `max-sm:pt-[var(--phone-overlay-chrome-h)]` wrapper around `
`, i.e. the phone overlay chrome reserve. **Mechanism, pinned by a MutationObserver timeline on the root style attribute rather than inferred:** the property goes CSS seed -> `200px` -> `72px`, and the decisive line is that the 200px is written when the header stack **already measures 72px** — `t=1552ms reserve=200px stack=72`, corrected to `72px` at `t=1612ms`. So `usePhoneOverlayChromeReserve` (`src/components/clinical-dashboard/use-phone-overlay-chrome-reserve.ts`) publishes a **stale** measurement: its `useLayoutEffect` reads `stack.offsetHeight` while the stack is still transiently 200px, and the write lands after the stack has already collapsed to 72px; its `ResizeObserver` then corrects it. The CSS seed at `globals.css:375` (`calc(max(0.5rem, var(--safe-area-top)) + var(--shell-header-h))` = 72px on a zero-inset profile) is **correct for the settled stack** — the seed is not the problem. **This corrects the mechanism recorded on `#130`** (now archived), which framed the defect as the seed under-reserving by `max(0, 0.5rem - inset)`, i.e. 0px on a notched iPhone and 8px elsewhere. Measured, the CLS driver is not an 8px seed shortfall but a 128px transient over-reserve written by the hook. Do not reopen `#130` on this; it is recorded here. **Control that makes the attribution trustworthy:** `/` never writes the property at all (no overlay stack) and is the one clean route at 0.023. **Variance, stated rather than smoothed over:** `/dsm` measured 0.363 (3 shift entries) and 0.219 (2 entries) on two runs — the round trip is the stable core and the third entry is intermittent. This harness has no network throttling, so `/forms` (0.310 local vs 0.212 live) and `/therapy-compass` (0.240 vs 0.142) run high locally; only `/dsm` 0.363, `/documents/search` 0.220 and `/` 0.023 reproduced the live dispatch exactly, and only those three should be treated as production-equivalent. **Next:** stop the hook publishing a measurement it is about to revise — defer the first publish until the stack has settled, or let the `ResizeObserver` be the only writer and trust the seed until it fires. That is a phone-chrome change, so it is governed by `docs/search-chrome-behaviour.md` and needs `npm run verify:phone-chrome` plus a before/after CLS pair from this harness. **Harness trap worth keeping:** attaching a `MutationObserver` to `document.documentElement` inside a Playwright `addInitScript` throws when the document element does not exist yet, which silently takes the CLS observer down with it and reports a uniform `CLS=0.000` across every route — a false clean bill, not a pass. Guard the attach. **Original next step, now done:** attribute the shifts to elements. Lighthouse's `layout-shift-elements` audit returned **zero items on every route** — the `cumulative-layout-shift` audit carries only `debugdata` — so attribution needs a driven Chromium session with a `PerformanceObserver` on `layout-shift`, reading `entry.sources[].node` and `entry.value`, against the same offline production build. Start with `/dsm`. **Gate — `#118` owns it, and this row supplies a constraint it was missing.** `#118` already tracks flipping `lighthouse-budget.json` to `enforce: true` and says not to do it "before `#117` or the baseline pins a known-slow route". These numbers make that concrete: pinning a baseline today would bake CLS 0.363 on `/dsm` in as the accepted value, so the CLS fixes must land first or the gate ratifies the breach. Separately, no baseline could have been committed from this run anyway — the grader correctly refused it as incomplete evidence because `/forms` produced no report, and that refusal was not overridden. **Reproduction trap:** without `CHROME_PATH`, `chrome-launcher` cannot find a browser in this container and every route fails; the harness reads `CHROME_PATH`/`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` (`run-lighthouse-budget.mjs:291`) but nothing sets either, even though `PLAYWRIGHT_BROWSERS_PATH` is set. Related to `#121`. Also seen: a truncated `progress` package in the npx cache broke Lighthouse startup entirely; clearing the npx cache entry fixed it. **Stop:** do not re-dispatch the live Web-Vitals workflow to re-measure CLS — it is reproducible offline for free. Do not commit a budget baseline from a run with a failed route. Do not treat local LCP numbers as production LCP. | `#017` live run `30548662649`; local `verify:lighthouse` 2026-07-30; `lighthouse-budget.json` | 2026-07-30 | | #149 | P2 | issue | `check:installed-lock-parity` only compares seven top-level packages, so transitive drift passes | **Outcome:** a session cannot run local gates against a `node_modules` that silently disagrees with the lockfile. **Evidence 2026-07-30:** the `SessionStart` hook reported "node_modules matches the lockfile, skipping install" while `brace-expansion` was installed at **1.1.16** and the lockfile pinned **1.1.18** — the CVE-2026-14257 patch. Every local gate run in that window was weaker than it appeared, including several `verify:cheap` runs reported as green during PR #1430/#1444 work. It surfaced only because `tests/installed-lock-parity.test.ts` asserts the patched behaviour directly (`maxLength` honoured); its failure was initially mistaken for a pre-existing repo defect, since it also failed on clean `main` **in the same stale container**. `npm ci --include=dev` fixed it. **CORRECTION 2026-07-30 (PR #1496 review, Codex):** an earlier version of this row said the parity check lives only in `verify:ui` and proposed adding it to `verify:cheap`. **Both were wrong** — `verify:cheap:internal` already runs `check:installed-lock-parity`, so that follow-up was a no-op, and it ran green throughout the stale window. The real gap is its **scope**: `scripts/check-installed-lock-parity.mjs` compares only seven top-level packages (`criticalInstalledPackages` = next, react, react-dom, eslint, playwright, typescript, vitest), so a stale **transitive** dependency is invisible to it. Proved by faking `node_modules/brace-expansion` back to `1.1.16` against the `1.1.18` lockfile pin: `npm run check:installed-lock-parity` **exits 0**. That also explains why the hook and the gate both reported parity while the tree was stale — neither was looking at the package that drifted. **Next:** broaden the check beyond the seven names — validate the full installed tree against the lockfile, or stamp the install with the lockfile hash and compare that — so transitive drift is caught rather than assumed absent. **Stop:** do not treat a green `check:installed-lock-parity` as proof the install is lockfile-current; today it means seven packages match. **Do not archive on the audit-worktree disposition (checked 2026-07-31):** several `comprehensive-audit-*` worktrees carry an uncommitted row archiving this as “Resolved 2026-07-31 — `check:installed-lock-parity` now validates every concrete package location in `package-lock.json`, including nested transitives”. That fix exists nowhere: not on `main`, not in any of the 42 worktrees, and not in any open PR. `main` still reads `criticalInstalledPackages = ["next", "react", "react-dom", "eslint", "playwright", "typescript", "vitest"]`. The sibling `#103` disposition from the same worktrees WAS verified true and landed in PR #1528; this one was not. | `scripts/check-installed-lock-parity.mjs`; `tests/installed-lock-parity.test.ts`; `package.json` | 2026-07-30 | @@ -307,8 +307,8 @@ removed after current-main verification; it is not missing recommended work. | #266 | P2 | task | DS Track B1: adopt the 24 unadopted components demand-driven, never as a race to 53/53 | Pick a surface and let it pull, the way PR #1658 did for AnswerCard. Forms are the largest single tranche: FieldError, FieldHint, ErrorSummary, SearchField, Checkbox and RadioGroup all land together on one form conversion. Do not stub a component to move the adoption count. Regenerate with npm run design-system:adoption:update after any import change; the manifest is generated, never hand-edited. | session 2026-08-07 — design-system HANDOVER-2026-08-07 Track A1 handoff (PR #1678) | 2026-08-07 | | #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. Baselines cannot be generated on Windows — snapshotPathTemplate carries {platform}, so win32 PNGs are invisible to the ubuntu CI job. Stop rule: do not commit baselines until the owner declares the design final. | session 2026-08-07 — design-system HANDOVER-2026-08-07 Track A1 handoff (PR #1678) | 2026-08-07 | -| #270 | P2 | task | 22 call sites pair min-h-tap/h-tap with a dead numeric height, blocking tap from the tailwind-merge config | Follow-up from #218 / PR #1678. Tailwind emits .min-h-tap after every numeric .min-h-* and .h-tap after .h-4 / .h-10.5, so the 48px tap token already wins at these sites and the numeric class is dead: document-admin (3), DocumentManagerPanel (7), favourites-hub (3), settings-dialog (2), service-detail-page (4, all min-h-12 so genuinely no-ops), form-detail-page (1), clinical-output-helpers (1), account-setup-dialog (1). Because of them, --spacing-tap is deliberately NOT declared in src/lib/tailwind-merge.ts — declaring it would flip the win to the later class and drop 18 production targets from 48px to 32/36/40/42px, which AGENTS.md forbids. Next action: delete the dead numeric class at each site (no visual change — it does not apply today), then add 'tap' to CLINICAL_TWMERGE_THEME.spacing and delete the pinning test in tests/tailwind-merge-config.test.ts. Do NOT instead lower any target 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 | +| #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-09: 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-09 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: the seven single-surface modes registered one action entry each and those were deleted. therapy-compass still declares action entries but PageSecondaryNavigation early-returns on /therapy-compass* before reading them, so nothing builds an action item any more. Kept deliberately rather than deleted in the same PR: the kind carries the tablist roving-focus behaviour and is covered directly by tests/secondary-navigation.dom.test.tsx, so it is component API with tests rather than orphaned code, and check:knip runs without --include exports so the dead-code gate will never flag it either way. Next: decide keep-or-delete as its own change. If delete, also remove the tablist-mode tests in tests/secondary-navigation.dom.test.tsx and the therapy-compass action entries in the registry. Stop: do not do half of each — a deleted branch with its tests left behind, or vice versa, is worse than either. Renumbered from this PR's original #261 → #271 because main claimed #261–#270 via PR #1678 design-system tracks. | session 2026-08-07; PR #1679 | 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 | From ff307cc5b12acacacfc9b615b3f94827f9c644cf Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:17:00 +0800 Subject: [PATCH 3/5] docs(issues): correct future-dated re-measurement records to 2026-08-08 Same defect Codex flagged on PR #1719: the commits recording these measurements were authored 2026-08-08 (07:xx UTC), so a 2026-08-09 stamp places every re-measurement after the commit that recorded it. Four occurrences, in the #118, #269 and #270 rows. Co-Authored-By: Claude Opus 5 --- docs/outstanding-issues.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 683823808f..6c5b82a93d 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -212,7 +212,7 @@ removed after current-main verification; it is not missing recommended work. | #101 | P3 | rec | Canary-gated retrieval parallelisation candidates | **Outcome:** independent retrieval stages stop running serially, proven by a live canary pair. Candidates: metadata/memory/visual hydration triples repeated on four branches (`rag.ts:2460,2493,2521` and three more) while `rag.ts:2751-2804` already parallelises three RPCs in one `Promise.all`, so the omission is inconsistency rather than intent; the nested `await`-in-loop scope enumeration (`search-scope.ts:202,328`); typeahead results never cached (`rag.ts:2698-2711`); universal-search coalescing (`/api/search` has it, `/api/search/universal` does not). Each changes candidate assembly, truncation, or what the next keystroke returns, so each needs 36/36 retrieval plus recall 1.0 and zero per-case rr regressions. Distinct from #001 (semantic rerank). Resolved #075 and #083 are the precedents for why these are gated rather than free. **Stop:** needs the #098 harness and explicit canary approval first. | `docs/audit/latency-audit-2026-07-28.md` L2-1/L2-2/L2-8/L1-5 | 2026-07-29 | | #102 | P3 | task | Apply the additive `documents` index debt (operator) | **Outcome:** bare-column `ILIKE` and the paged status scan on `documents` are index-served on hosted. `documents_title_trgm_idx` indexes a CONCATENATED expression, so the bare-column predicates in `api/documents/route.ts:193` and `rag-candidate-sources.ts:477` (RAG path) cannot use it and fall back to scanning; `search-scope.ts:271-277` sorts per page against the single-column `documents_status_idx`. **Runbook prepared 2026-07-29 — NOT applied, item stays open:** three `CREATE INDEX CONCURRENTLY` statements authored and reviewed in `docs/operator-apply-performance-latency-remediation.md` — additive, though **the "recall is byte-identical" claim was RETRACTED on 2026-07-29 review**: `fetchDocumentTitleAliasRows` (`rag-candidate-sources.ts:482`) applies `.limit(12)` with no `ORDER BY`, so a new index can change which title-alias documents feed candidate assembly. Only the documents-list use stays ordering-safe; `(status,id)` is canary-gated too — see runbook, and making that `.limit(12)` deterministic first does **not** lift the gate — an unordered `LIMIT` has no stable selection to preserve, so imposing an order can pick a different twelve and is itself an ordering behaviour change on a retrieval surface, which AGENTS.md requires a canary pair for. Sequencing the ordering fix first is worthwhile (unordered `LIMIT` on a retrieval input is latent nondeterminism regardless) but yields two canary-gated changes, not one (PR #1377 review). **Deliberately NO migration file:** an additive-index migration without a synchronized `schema.sql` mirror and regenerated drift manifest is exactly what closed PR #1312, and the mirror cannot come first because `required_indexes` in `search_schema_health()` (`schema.sql:3178`) runs against live. **Next (operator):** **author the migration first** — `supabase/migrations/` is the source of truth and `schema.sql` only a mirror, so hand-run operator SQL never reaches staging, disaster-recovery replay, or a local `supabase db reset`, and a `required_indexes` registration would fail there (PR #1377 review); follow the `20260717170000_registry_projection_cleanup.sql` idempotent pattern. **That migration must also carry the health-function change** — `required_indexes` lives inside `search_schema_health()`, which is redefined by `create or replace function` in eleven migrations (copy `20260705180000_reconcile_search_health_indexes.sql:62`); editing `schema.sql:3177` alone moves only the mirror and leaves the indexes unmonitored on hosted (PR #1377 review). Then apply concurrently, confirm `indisvalid`, mirror both the index statements and the identical function body into `schema.sql`, run `npm run drift:manifest` (Docker), and deploy the migration LAST — in that order, in one change. Expect `check:drift` to report them as unexpected between steps 1 and 2. **Rollback is three deployed phases, not the reverse of one:** retract `required_indexes` via its own `create or replace function` migration and deploy → drop concurrently live → only then deploy the `schema.sql` removal plus an idempotent forward `drop index if exists` migration, because Supabase wraps migrations in a transaction and a plain `DROP INDEX` there takes the lock the concurrent procedure exists to avoid (PR #1377 review). | `docs/audit/latency-audit-2026-07-28.md` L2-3/L2-5; `docs/operator-apply-performance-latency-remediation.md` | 2026-07-29 | | #117 | P2 | rec | Therapy Compass catalogue payload is the mobile LCP outlier | **Outcome:** `/therapy-compass` mobile LCP lands near the other mobile routes instead of double them. **Measured 2026-07-30** by the new pre-merge Lighthouse budget: mobile LCP 5229 ms, TBT 612 ms, CLS 0.142, against 2123-2460 ms on every other mobile route and 826 ms on desktop — so it is client-side work under mobile CPU/network throttling, not server latency. **Cause before this PR:** `useTherapyData` fetched `public/therapy-compass-data/therapies-index.json` (the thin browse index; 205 records) for the home/search/pathways screens, so the download plus JSON parse sat on the critical path before content painted. **Current split:** home now fetches `public/therapy-compass-data/therapies-home.211dab554c4ec62d.json` (136,288 bytes raw), pathways use the thin browse index, and search loads the full prose corpus (#1471). 90% of the index weight is long-form clinical prose — indications 159 KB (26%), contraindicationsOrCautions 139 KB (23%), bestUsedFor 73 KB (12%), clinicalSummary 67 KB (11%), patientPopulation 59 KB (10%), targetSymptoms 48 KB (8%) — while name, slug, category, tags and setting together are 54 KB (7%). **Remaining decision for search/pathways: rendered on the card, matched by search, or neither.** `therapy-card.tsx` references five of those prose fields and the same index feeds the search screen, so stripping fields could silently change clinical display or search recall. **Next:** settle that per-field question, then either pre-truncate prose that only feeds card display, or move search matching server-side / load prose on first keystroke. **Gate:** `check:therapy-data-index` plus the therapy Playwright journeys; re-measure with `npm run verify:lighthouse`. **Stop:** do not drop a field from the catalogue payload without confirming no card renders it and no search path matches on it. Same class as #013 (route-chunk / catalogue JSON weight), different route and now measured. | session 2026-07-30 Lighthouse budget first run; PR #1404; PR #1489 | 2026-07-30 | -| #118 | P2 | task | Adopt the visual and Lighthouse baselines so the two new gates actually gate | **Outcome:** `visual-baseline` and `lighthouse-budget` stop reporting and start blocking. **Detail:** PR #1404 added both as `continue-on-error` jobs outside `pr-required`, deliberately. `tests/ui-visual-baseline.spec.ts` has no committed baselines, so all six targets (dashboard-shell, dashboard-shell-phone, search-results-band, search-results-band-phone, document-viewer, therapy-compass-home) fail with a missing-snapshot error by design; the job uploads them on every run (run 30513537912, artifact 8748062487, 31 files). `tests/__screenshots__/` still holds only README.md, re-checked 2026-08-09. `lighthouse-budget.json` ships `enforce: false` with `baseline: null`, so the grader warns rather than grades. **Owner decision 2026-08-09: adopt the baselines, advisory-only first, and promote to required only after they have held across a few runs** — the workflow comment asks for that soak explicitly. **Sequencing constraint recorded the same day:** the owner had design changes merging at the time, and pixel baselines frozen while the look is still moving go red on the next merge, which is the churn the hold exists to avoid. Trigger the ubuntu job once that merge wave has landed, not before. **Next:** (1) trigger `visual-baseline` on a UI PR, download the artifact, review the six PNGs and commit them under the platform-scoped screenshots directory that `playwright.visual.config.ts` names in its `snapshotPathTemplate` — from CI, never a developer machine, because font hinting differs between them; (2) run `npm run check:lighthouse-budget -- --update` against a known-good CI build and flip `enforce`, but not before #117 or the baseline pins a known-slow route; (3) then add each job to `pr-required` and drop `continue-on-error` in the same edit. **Also:** PR #1404 added the first rendered-effect contract for #094, but 37 of the 38 unlayered visual classes still carry exemptions in `tests/helpers/style-contracts.ts` rather than contracts; and `scripts/run-lighthouse-budget.mjs` duplicates about 50 lines of the isolated-server boot in `scripts/run-playwright.mjs`, deferred to avoid destabilising the required UI gate in the same change. **Stop:** do not make a missing baseline skip instead of fail — that is the soft-skip-green pattern `AGENTS.md` forbids. | session 2026-07-30; PR #1404 | 2026-07-30 | +| #118 | P2 | task | Adopt the visual and Lighthouse baselines so the two new gates actually gate | **Outcome:** `visual-baseline` and `lighthouse-budget` stop reporting and start blocking. **Detail:** PR #1404 added both as `continue-on-error` jobs outside `pr-required`, deliberately. `tests/ui-visual-baseline.spec.ts` has no committed baselines, so all six targets (dashboard-shell, dashboard-shell-phone, search-results-band, search-results-band-phone, document-viewer, therapy-compass-home) fail with a missing-snapshot error by design; the job uploads them on every run (run 30513537912, artifact 8748062487, 31 files). `tests/__screenshots__/` still holds only README.md, re-checked 2026-08-08. `lighthouse-budget.json` ships `enforce: false` with `baseline: null`, so the grader warns rather than grades. **Owner decision 2026-08-08: adopt the baselines, advisory-only first, and promote to required only after they have held across a few runs** — the workflow comment asks for that soak explicitly. **Sequencing constraint recorded the same day:** the owner had design changes merging at the time, and pixel baselines frozen while the look is still moving go red on the next merge, which is the churn the hold exists to avoid. Trigger the ubuntu job once that merge wave has landed, not before. **Next:** (1) trigger `visual-baseline` on a UI PR, download the artifact, review the six PNGs and commit them under the platform-scoped screenshots directory that `playwright.visual.config.ts` names in its `snapshotPathTemplate` — from CI, never a developer machine, because font hinting differs between them; (2) run `npm run check:lighthouse-budget -- --update` against a known-good CI build and flip `enforce`, but not before #117 or the baseline pins a known-slow route; (3) then add each job to `pr-required` and drop `continue-on-error` in the same edit. **Also:** PR #1404 added the first rendered-effect contract for #094, but 37 of the 38 unlayered visual classes still carry exemptions in `tests/helpers/style-contracts.ts` rather than contracts; and `scripts/run-lighthouse-budget.mjs` duplicates about 50 lines of the isolated-server boot in `scripts/run-playwright.mjs`, deferred to avoid destabilising the required UI gate in the same change. **Stop:** do not make a missing baseline skip instead of fail — that is the soft-skip-green pattern `AGENTS.md` forbids. | session 2026-07-30; PR #1404 | 2026-07-30 | | #142 | P3 | task | Four loose dated docs need source and migration edits before they can be filed | **Outcome:** every dated point-in-time doc lives in `docs/audit/` or `docs/archive/` as `docs/README.md` requires, not loose at the `docs/` top level. **Detail:** PR #1436 filed the five that were docs-only moves. These four are referenced from outside `docs/`, so relocating them means editing source, tests and migration SQL comments — a different risk class than a docs tidy, and not worth bundling into one: `capacity-review.md` (`scripts/soak-test.ts`), `tenancy-defense-in-depth-review.md` (`src/lib/owner-scope.ts`, `tests/owner-scope-guard.test.ts`, two migrations, `SECURITY.md`, `.claude/agents/clinical-governance-reviewer.md`), `operator-apply-july8-batch.md` (three migrations plus `supabase/schema.sql`), `scale-readiness-review.md` (one migration). Also note `forward-codify-retrieval-rpcs-workorder.md` is indexed as a completed workorder but live is still ahead of the repo on those RPCs, so archiving it would misrepresent open operator work. **Next:** treat as low priority — the docs are correctly indexed and reachable where they are; only file them if a pass is already editing those migrations. Editing applied migration SQL is subject to `npm run check:migration-role`'s immutability pin. | PR #1436; session 2026-07-30 | 2026-07-30 | | #147 | P2 | rec | Mobile CLS breaches four routes, and it reproduces exactly offline | **Outcome:** mobile CLS is under 0.1 on every measured route, or each remaining breach has a recorded reason. **Measured 2026-07-30 with the LOCAL OFFLINE production harness** (`CHROME_PATH=/opt/pw-browsers/chromium npm run verify:lighthouse -- --keep`; Lighthouse 12.8.2, Chromium 141.0.7390.37, demo corpus, inert loopback Supabase). Mobile CLS: `/dsm` 0.363, `/documents/search` 0.220, `/therapy-compass` 0.142, `/` 0.023 — `/forms` did not measure locally (Lighthouse `NO_NAVSTART`, its own "run again" transient), but the live dispatch put it at 0.212. Desktop passes everywhere: 0.016–0.097. **The finding that matters is that these reproduce the live production dispatch EXACTLY.** Against run `30548662649` on `psychiatry.tools`, all four locally measurable mobile routes match to three decimals — 0.023 / 0.142 / 0.220 / 0.363 on both — and the desktop range matches at both endpoints, 0.016–0.097. So CLS on these routes is deterministic layout structure, not network, latency, or production data. Three consequences: (a) the CLS half of `#017` is debuggable and gate-able locally at zero provider cost; (b) `#017`'s recorded blocker "prod server hard-requires Supabase secrets" is stale — `scripts/run-lighthouse-budget.mjs` builds and serves an offline production app precisely to sidestep that, and its earlier note that dev-mode CLS looked "excellent at 0.00–0.04" was measuring DEV, which does not reproduce production; (c) re-dispatching the live workflow to re-measure CLS buys nothing. **LCP does NOT reproduce and must not be read from local runs:** local mobile LCP is 2014–2051 ms against 3639–4716 ms live, because the loopback server has no network latency. The LCP _ranking_ does survive — `/therapy-compass` is the outlier both locally (4978 ms, ~2.4x every other route) and live — which corroborates `#117` rather than replacing it. Mobile TBT, local: `/documents/search` 581 ms, `/dsm` 448, `/therapy-compass` 379, `/` 338. **Ranking by measured contribution, which is what `#017` asked for:** 1. `/dsm` CLS 0.363 (3.6x the threshold, the worst single number anywhere); 2. `/documents/search` 0.220; 3. `/forms` 0.212 (live only); 4. `/therapy-compass` 0.142, and separately the sole LCP outlier via `#117`; 5. `/` 0.023, which passes and needs nothing. **ATTRIBUTED 2026-07-30 — one cause dominates, and it is a self-inflicted round trip.** Driving Chromium directly against the same offline production build (Lighthouse mobile emulation: 412x823, DPR 1.75, 4x CPU throttle) with a `PerformanceObserver` on `layout-shift` reading `entry.sources[].node`: on `/dsm`, `/documents/search`, `/forms` and `/therapy-compass` the entire main content region moves **down 128px and straight back up 128px** within 15-60ms. Both moves score, so the round trip is pure cost with zero net movement. It is **100% of `/documents/search`** (0.110 down + 0.110 up = 0.220) and about 75% of `/dsm`. The shifting element is the `max-sm:pt-[var(--phone-overlay-chrome-h)]` wrapper around `
`, i.e. the phone overlay chrome reserve. **Mechanism, pinned by a MutationObserver timeline on the root style attribute rather than inferred:** the property goes CSS seed -> `200px` -> `72px`, and the decisive line is that the 200px is written when the header stack **already measures 72px** — `t=1552ms reserve=200px stack=72`, corrected to `72px` at `t=1612ms`. So `usePhoneOverlayChromeReserve` (`src/components/clinical-dashboard/use-phone-overlay-chrome-reserve.ts`) publishes a **stale** measurement: its `useLayoutEffect` reads `stack.offsetHeight` while the stack is still transiently 200px, and the write lands after the stack has already collapsed to 72px; its `ResizeObserver` then corrects it. The CSS seed at `globals.css:375` (`calc(max(0.5rem, var(--safe-area-top)) + var(--shell-header-h))` = 72px on a zero-inset profile) is **correct for the settled stack** — the seed is not the problem. **This corrects the mechanism recorded on `#130`** (now archived), which framed the defect as the seed under-reserving by `max(0, 0.5rem - inset)`, i.e. 0px on a notched iPhone and 8px elsewhere. Measured, the CLS driver is not an 8px seed shortfall but a 128px transient over-reserve written by the hook. Do not reopen `#130` on this; it is recorded here. **Control that makes the attribution trustworthy:** `/` never writes the property at all (no overlay stack) and is the one clean route at 0.023. **Variance, stated rather than smoothed over:** `/dsm` measured 0.363 (3 shift entries) and 0.219 (2 entries) on two runs — the round trip is the stable core and the third entry is intermittent. This harness has no network throttling, so `/forms` (0.310 local vs 0.212 live) and `/therapy-compass` (0.240 vs 0.142) run high locally; only `/dsm` 0.363, `/documents/search` 0.220 and `/` 0.023 reproduced the live dispatch exactly, and only those three should be treated as production-equivalent. **Next:** stop the hook publishing a measurement it is about to revise — defer the first publish until the stack has settled, or let the `ResizeObserver` be the only writer and trust the seed until it fires. That is a phone-chrome change, so it is governed by `docs/search-chrome-behaviour.md` and needs `npm run verify:phone-chrome` plus a before/after CLS pair from this harness. **Harness trap worth keeping:** attaching a `MutationObserver` to `document.documentElement` inside a Playwright `addInitScript` throws when the document element does not exist yet, which silently takes the CLS observer down with it and reports a uniform `CLS=0.000` across every route — a false clean bill, not a pass. Guard the attach. **Original next step, now done:** attribute the shifts to elements. Lighthouse's `layout-shift-elements` audit returned **zero items on every route** — the `cumulative-layout-shift` audit carries only `debugdata` — so attribution needs a driven Chromium session with a `PerformanceObserver` on `layout-shift`, reading `entry.sources[].node` and `entry.value`, against the same offline production build. Start with `/dsm`. **Gate — `#118` owns it, and this row supplies a constraint it was missing.** `#118` already tracks flipping `lighthouse-budget.json` to `enforce: true` and says not to do it "before `#117` or the baseline pins a known-slow route". These numbers make that concrete: pinning a baseline today would bake CLS 0.363 on `/dsm` in as the accepted value, so the CLS fixes must land first or the gate ratifies the breach. Separately, no baseline could have been committed from this run anyway — the grader correctly refused it as incomplete evidence because `/forms` produced no report, and that refusal was not overridden. **Reproduction trap:** without `CHROME_PATH`, `chrome-launcher` cannot find a browser in this container and every route fails; the harness reads `CHROME_PATH`/`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` (`run-lighthouse-budget.mjs:291`) but nothing sets either, even though `PLAYWRIGHT_BROWSERS_PATH` is set. Related to `#121`. Also seen: a truncated `progress` package in the npx cache broke Lighthouse startup entirely; clearing the npx cache entry fixed it. **Stop:** do not re-dispatch the live Web-Vitals workflow to re-measure CLS — it is reproducible offline for free. Do not commit a budget baseline from a run with a failed route. Do not treat local LCP numbers as production LCP. | `#017` live run `30548662649`; local `verify:lighthouse` 2026-07-30; `lighthouse-budget.json` | 2026-07-30 | | #149 | P2 | issue | `check:installed-lock-parity` only compares seven top-level packages, so transitive drift passes | **Outcome:** a session cannot run local gates against a `node_modules` that silently disagrees with the lockfile. **Evidence 2026-07-30:** the `SessionStart` hook reported "node_modules matches the lockfile, skipping install" while `brace-expansion` was installed at **1.1.16** and the lockfile pinned **1.1.18** — the CVE-2026-14257 patch. Every local gate run in that window was weaker than it appeared, including several `verify:cheap` runs reported as green during PR #1430/#1444 work. It surfaced only because `tests/installed-lock-parity.test.ts` asserts the patched behaviour directly (`maxLength` honoured); its failure was initially mistaken for a pre-existing repo defect, since it also failed on clean `main` **in the same stale container**. `npm ci --include=dev` fixed it. **CORRECTION 2026-07-30 (PR #1496 review, Codex):** an earlier version of this row said the parity check lives only in `verify:ui` and proposed adding it to `verify:cheap`. **Both were wrong** — `verify:cheap:internal` already runs `check:installed-lock-parity`, so that follow-up was a no-op, and it ran green throughout the stale window. The real gap is its **scope**: `scripts/check-installed-lock-parity.mjs` compares only seven top-level packages (`criticalInstalledPackages` = next, react, react-dom, eslint, playwright, typescript, vitest), so a stale **transitive** dependency is invisible to it. Proved by faking `node_modules/brace-expansion` back to `1.1.16` against the `1.1.18` lockfile pin: `npm run check:installed-lock-parity` **exits 0**. That also explains why the hook and the gate both reported parity while the tree was stale — neither was looking at the package that drifted. **Next:** broaden the check beyond the seven names — validate the full installed tree against the lockfile, or stamp the install with the lockfile hash and compare that — so transitive drift is caught rather than assumed absent. **Stop:** do not treat a green `check:installed-lock-parity` as proof the install is lockfile-current; today it means seven packages match. **Do not archive on the audit-worktree disposition (checked 2026-07-31):** several `comprehensive-audit-*` worktrees carry an uncommitted row archiving this as “Resolved 2026-07-31 — `check:installed-lock-parity` now validates every concrete package location in `package-lock.json`, including nested transitives”. That fix exists nowhere: not on `main`, not in any of the 42 worktrees, and not in any open PR. `main` still reads `criticalInstalledPackages = ["next", "react", "react-dom", "eslint", "playwright", "typescript", "vitest"]`. The sibling `#103` disposition from the same worktrees WAS verified true and landed in PR #1528; this one was not. | `scripts/check-installed-lock-parity.mjs`; `tests/installed-lock-parity.test.ts`; `package.json` | 2026-07-30 | @@ -307,8 +307,8 @@ removed after current-main verification; it is not missing recommended work. | #266 | P2 | task | DS Track B1: adopt the 24 unadopted components demand-driven, never as a race to 53/53 | Pick a surface and let it pull, the way PR #1658 did for AnswerCard. Forms are the largest single tranche: FieldError, FieldHint, ErrorSummary, SearchField, Checkbox and RadioGroup all land together on one form conversion. Do not stub a component to move the adoption count. Regenerate with npm run design-system:adoption:update after any import change; the manifest is generated, never hand-edited. | session 2026-08-07 — design-system HANDOVER-2026-08-07 Track A1 handoff (PR #1678) | 2026-08-07 | | #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-09: 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-09 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 | +| #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: the seven single-surface modes registered one action entry each and those were deleted. therapy-compass still declares action entries but PageSecondaryNavigation early-returns on /therapy-compass* before reading them, so nothing builds an action item any more. Kept deliberately rather than deleted in the same PR: the kind carries the tablist roving-focus behaviour and is covered directly by tests/secondary-navigation.dom.test.tsx, so it is component API with tests rather than orphaned code, and check:knip runs without --include exports so the dead-code gate will never flag it either way. Next: decide keep-or-delete as its own change. If delete, also remove the tablist-mode tests in tests/secondary-navigation.dom.test.tsx and the therapy-compass action entries in the registry. Stop: do not do half of each — a deleted branch with its tests left behind, or vice versa, is worse than either. Renumbered from this PR's original #261 → #271 because main claimed #261–#270 via PR #1678 design-system tracks. | session 2026-08-07; PR #1679 | 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 | From 6916c80526603514d91bd29d224959dd420af59c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:28:16 +0800 Subject: [PATCH 4/5] fix(design-system): LinkAction refuses tone through a spread, not only a literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on PR #1720, and it is right. Omit only closes the object-literal path: excess-property checking runs on literals, so is rejected, but a caller who builds props in a variable or wrapper and writes is doing an ordinary assignment, where extra properties are allowed. Verified rather than taken on trust — a focused tsc probe accepted the spread form against the Omit type with no diagnostic, and rejected it against tone?: never with TS2345. So the advertised refusal was still silently rendering the accent for the one call shape most likely to carry a stale tone. tone?: never rejects both forms. The contract test asserts the spread case at the type level, so it stops compiling if the prop ever widens back to something assignable, plus a render assertion that the accent is what actually ships. Manifests regenerated, not hand-edited: design-system:design-sync:update picks up tone?: undefined, and design-system:adoption:update records the new test file against LinkAction. Adoption counts are unchanged at 53 registered / 30 adopted. Co-Authored-By: Claude Opus 5 --- .design-sync/config.json | 2 +- docs/design-system/adoption-manifest.json | 2 +- src/components/ui/link.tsx | 15 ++++++++++++--- tests/ui-v2-components.dom.test.tsx | 20 +++++++++++++++++++- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/.design-sync/config.json b/.design-sync/config.json index 4e18f705fd..63727c2b52 100644 --- a/.design-sync/config.json +++ b/.design-sync/config.json @@ -43,7 +43,7 @@ "FormField": "autoComplete?: string; children: (field: FormFieldRenderProps) => ReactNode; className?: string; describedBy?: string; error?: string; hideLabel?: boolean; hint?: string; id?: string; label: string; required?: boolean;", "IconButton": "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-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; 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; disabled?: boolean; draggable?: (boolean | \"true\" | \"false\"); enterKeyHint?: \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\"; exportparts?: string; form?: string; formAction?: string | ((formData: FormData) => void | Promise); formEncType?: string; formMethod?: string; formNoValidate?: boolean; formTarget?: string; hidden?: boolean; icon: LucideIcon; iconClassName?: 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; label: string; lang?: string; name?: 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; popover?: \"\" | \"auto\" | \"manual\" | \"hint\"; popoverTarget?: string; popoverTargetAction?: \"toggle\" | \"show\" | \"hide\"; prefix?: string; property?: string; radioGroup?: string; rel?: string; 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; translate?: \"yes\" | \"no\"; type?: \"submit\" | \"reset\" | \"button\"; typeof?: string; unselectable?: \"off\" | \"on\"; value?: string | number | readonly string[]; vocab?: string;", "InlineNotice": "animated?: boolean; children: ReactNode; className?: string; dismissLabel?: string; onDismiss?: (() => void); tone: NoticeTone;", - "LinkAction": "children: ReactNode; className?: string; href: string;", + "LinkAction": "children: ReactNode; className?: string; href: string; tone?: undefined;", "LoadingPanel": "label: string; layout?: \"panel\" | \"centered\"; lines?: number; variant?: \"spinner\" | \"skeleton\";", "MissingValue": "className?: string; density?: \"cell\" | \"inline\"; reason: MissingValueReason;", "OverlayRoot": "", diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 7958e78adf..151c19f916 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -912,7 +912,7 @@ "preview": ".design-sync/previews/LinkAction.tsx", "previewValid": true }, - "testFiles": ["tests/design-sync-visual-exports.test.ts"], + "testFiles": ["tests/design-sync-visual-exports.test.ts", "tests/ui-v2-components.dom.test.tsx"], "baseline": { "targetLayer": "v2", "liveLayer": "v2", diff --git a/src/components/ui/link.tsx b/src/components/ui/link.tsx index 2245aaf1db..b2e7727d17 100644 --- a/src/components/ui/link.tsx +++ b/src/components/ui/link.tsx @@ -41,14 +41,23 @@ export type DownloadLinkProps = BaseProps & { href: string; format?: string; siz >; /** - * `tone` is deliberately omitted rather than inherited from `BaseProps`. A + * `tone` is deliberately refused rather than inherited from `BaseProps`. A * forward action is the accent by design — that is what makes it read as the * card's next step rather than as prose — so there is no `inherit` variant to * select. Accepting the prop and ignoring it was worse than refusing it: three * sibling links honour `tone`, so `tone="inherit"` here looked like it worked - * and silently did nothing. Omitting it makes the compiler say so. + * and silently did nothing. + * + * `tone?: never`, not `Omit`. Omit alone only closes the + * literal path: excess-property checking runs on object literals, so + * `` is rejected, but a caller who builds props in + * a variable or wrapper and writes `` is doing an + * ordinary assignment, where extra properties are allowed — measured, that form + * type-checked clean and still rendered the accent, which is the exact silent + * behaviour this change exists to end. `never` rejects both forms; the contract + * test in `ui-v2-components.dom.test.tsx` pins the spread one. */ -export type LinkActionProps = Omit & { href: string }; +export type LinkActionProps = Omit & { href: string; tone?: never }; /** * Internal navigation. Wraps `next/link` so a call site never reaches for a raw diff --git a/tests/ui-v2-components.dom.test.tsx b/tests/ui-v2-components.dom.test.tsx index 3702c60705..e4274488a1 100644 --- a/tests/ui-v2-components.dom.test.tsx +++ b/tests/ui-v2-components.dom.test.tsx @@ -11,7 +11,7 @@ import { Chip } from "@/components/ui/chip"; import { Checkbox, RadioGroup } from "@/components/ui/choice"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Disclosure } from "@/components/ui/disclosure"; -import { DownloadLink, ExternalTextLink, TextLink } from "@/components/ui/link"; +import { DownloadLink, ExternalTextLink, LinkAction, TextLink, type LinkActionProps } from "@/components/ui/link"; import { OverlayPortal, OverlayRoot } from "@/components/ui/overlay-root"; import { PageHeader } from "@/components/ui/page-header"; import { Pagination } from "@/components/ui/pagination"; @@ -1260,6 +1260,24 @@ describe("Links — invariants a spread cannot override", () => { expect(link).toHaveAttribute("target", "_blank"); expect(link).toHaveAttribute("rel", "noopener noreferrer"); }); + + it("refuses LinkAction's tone through a spread, not only through a literal", () => { + // `LinkAction` renders the accent unconditionally, so an accepted-but-ignored + // `tone` is a silent lie. `Omit` alone does not close it: + // excess-property checking only runs on object literals, so the literal form + // is rejected while `` — an ordinary assignment, + // where extra properties are allowed — type-checks clean and still renders + // the accent. `tone?: never` rejects both. This assertion is the gate: it + // stops compiling the moment the prop widens back to something assignable. + type ToneCarryingProps = { href: string; children: string; tone: "inherit" }; + type SpreadIsRejected = ToneCarryingProps extends LinkActionProps ? false : true; + const spreadIsRejected: SpreadIsRejected = true; + + expect(spreadIsRejected).toBe(true); + + render(Review sources); + expect(screen.getByRole("link", { name: /Review sources/ })).toHaveClass("text-[color:var(--clinical-accent)]"); + }); }); describe("Checkbox / RadioGroup — dimensions on the spacing scale", () => { From add61feb5e540c17225454334d409583618f26f0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:33:51 +0800 Subject: [PATCH 5/5] docs(ledger): supersede both review rows with their final reviewed heads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit flagged the claude/ds-doc-corrections row as understating the scope of PR #1720. That finding conflates two things: the row records a different ref at a different head, and it was accurate as written. But it does predate the review pass on #1719, whose scope grew by one commit afterwards, so a superseding row is warranted on its own merits — appended through ledger:append --supersede rather than editing the append-only original. Same for #1720's own row, which recorded 824c1b74a and now supersedes to 6916c8052 with the tone?: never fix and its type-contract test. Co-Authored-By: Claude Opus 5 --- docs/branch-review-ledger.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 11f0c2e14e..2007d1c9b6 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -747,3 +747,5 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-08 | claude/ds-doc-corrections | 534405600dca67317b4d60266cda03ec95f028e7 | M1 stranded doc corrections (docs/outstanding-issues.md #262/#266, docs/design-system/COMPONENTS.md TextField row + section 4) | authored and handed off as PR #1719; every inherited figure re-measured against origin/main rather than copied forward, and the stranded version's 'eight shadow tokens, focus 2' claim was found wrong — LEGACY_SHADOW_ALIAS matches seven tokens and has never included focus | check:outstanding-issues pass (274 rows, unique ids, no ids deleted from base); prettier --check . pass whole-tree; legacyShadowAliases re-measured 228 via the contract's own analyzers; docs-only diff so no unit/lint/typecheck/browser gate applies | | 2026-08-08 | claude/differentials-inpage-navigation-h2u8fq (PR #1715) | c67c4927d8a088be267b8bd280a06f300aad60bc | differentials detail: adopt PR #1688 default in-page navigation template | Header rebuilt to the four template slots (back / title + active-section chevron sheet / ellipsis actions / weighted segment track); new detail-section-index.ts shapes the five tabs as DocumentSections reusing DocumentSectionTrack + DocumentSectionList; labelled strip gated to sm+; max-sm:static -> relative so the absolutely-positioned track keeps a positioned ancestor; tab panel renamed via aria-label since the sm-hidden strip cannot label it; sheets kept as siblings of PhoneHeaderCollapsePortal; no scroll spy (discrete panels). Adoption manifest regenerated - the dropped Tabs/ui-tools association was a coincidental capital-T comment match, not lost coverage. | typecheck exit 0; lint clean --max-warnings 0; verify:pr-local 5557 passed, stops only at pre-existing pr-handoff-stop (confirmed by stashed re-run on clean base); verify:phone-chrome lock-parity+runtime PASS, contracts 119 passed, changed-browser ui-tools passed, focused-browser 7 passed; new differential-section-nav.dom 9 passed; 40 passed re-run post-format; live browser 0 h-overflow at 320/390/768, collapse matches DocumentViewer (data-scroll-hidden=true, stack bottom 0) | | 2026-08-08 | claude/differentials-inpage-navigation-h2u8fq (PR #1715) | f0b27ec857a70130d1616ddc1ccae1c9952c697b | heavy review-and-fix PR #1715 differentials in-page navigation | merge-blocker cleared (origin/main sync); no P0/P1 findings; phone-chrome + verify:pr-local green on f0b27ec8 | merge-tree clean post-sync; verify:phone-chrome lock-parity+runtime PASS, contracts 119 passed, changed-browser ui-tools passed, focused-browser 7 passed (19.4s); verify:pr-local Test Files 523 passed (523), Tests 5547 passed \| 4 skipped (5551); lint+typecheck+build+rag fixtures green; 0 unresolved review threads | +| 2026-08-08 | claude/ds-doc-corrections | b4051d21f38755f7d37dbc2b49994f689af801b5 | M1 stranded doc corrections, final reviewed head (adds the review-response commit: COMPONENTS.md section 4 integration-vs-adoption split and the re-measured ui-primitives row) | merged to main as 8cffad59a. Supersedes the 534405600 record, which was accurate at that head but predates the review pass. Three findings, all valid and all fixed: Codex caught four future-dated 2026-08-09 records (corrected to the 2026-08-08 authoring date by f3a91c67c, verified none remain); CodeRabbit caught 'Select/choice controls remain separate adoption work', wrong on both axes since select.tsx consumes FormField and Select has 2 production importers while SearchField has zero; CodeRabbit caught a stale '27 adopted', and re-measuring that row also corrected 686 to 698 lines and 200 to 157 production importers of ui-primitives (200 was close to the 202 mockup-inclusive figure) | prettier --check . pass whole-tree; check:outstanding-issues pass (274 rows, unique ids, no ids deleted from base); adoption figures read from the generated adoption-manifest.json; docs-only diff so no unit, lint, typecheck or browser gate applies to it | +| 2026-08-08 | claude/ds-tap-and-linkaction | 6916c80526603514d91bd29d224959dd420af59c | M5 LinkAction tone refusal plus re-measured corrections to outstanding-issues #270, #118 and #269 — final reviewed head, adds the tone?: never fix, its type-contract test and both regenerated manifests | PR #1720, superseding the 824c1b74a record. Codex found the Omit form still accepted tone through a spread; verified with a focused tsc probe before changing anything (Omit accepted the spread with no diagnostic, tone?: never rejected it with TS2345), because excess-property checking only fires on object literals. Fixed with tone?: never plus a type-level contract test that stops compiling if the prop widens back. CodeRabbit's future-dated finding fixed in ff307cc5b. CodeRabbit's ledger-scope finding does not apply: that row records a different ref and head and was accurate as written, but a superseding row for the final #1719 head was appended anyway since its scope grew after the review pass | tsc -p tsconfig.typecheck.json --noEmit exit 0 zero diagnostics; lint exit 0; check:design-system-contract exit 0 (676 production files, legacy shadow aliases 228 confirming the #262 re-measure, adoption 53 components 55 roots, design-sync 53 components and 7 guidelines); check-icon-scale.mjs --strict exit 0; vitest threads pool 3 files 164 tests passed; check:outstanding-issues pass; check:branch-review-ledger pass; prettier --check . pass whole-tree; main merged in with merge-tree proven clean first and an id-set proof over both merge parents showing 274 ids each side, none lost, none invented |