diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index d85d34ac67..e8394daafc 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -700,20 +700,10 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-07 | cursor/viewer-phase0-gesture-a11y-1db8 (PR #1660) | 097dfd245f798f8105eeb6c1cf4fc077f969496f | prlanded | MERGED; squash tip empty vs branch tip 810cfc9b4a1c476a0dcc995bffb00d7329a85686; Phase 0 gesture INP, preview a11y, image decode | content tree empty vs squash; no provider-backed checks run | | 2026-08-07 | claude/pr-handoff-loop-prevention-54y5zr (PR #1670) | dfe2946110e0ff93bd4acc571ae79c26b79a7a85 | PR #1670 heavy review-and-fix | synced origin/main (behind-but-clean DIRTY cleared); fixed CodeRabbit checks-cell to name exact #1649 gates + incomplete verify:pr-local/ui + no provider checks; Bugbot none; no P0/P1; #258/#ledger delta accurate; merge-tree clean; threads cleared | verify:cheap 519 files/5493 passed; verify:pr-local docs scope (format+docs+ledger+outstanding-issues); check:branch-review-ledger; check:outstanding-issues; no provider gates | | 2026-08-07 | claude/handover-review-nlhuln | 978623337c12dc1721fe5236eadbf9a5ad929f03 | mode nav remaining modes: factsheets adoption (PR #1674) | Adopted the shared ModeNav for factsheets (Topics + Search); replaced the action-only entry, added the activeId branch, q/category/run carry, BookOpenText icon; three pinned adopted-mode lists updated together; record-route protection pinned at render now the item-count protection has expired | lint clean; typecheck clean; test 518/519 files (pr-handoff-stop failure confirmed pre-existing via stashed re-run); focused 5 files 95 tests; ui-mode-nav-density 55 passed incl 7 new factsheets rows; two mutation checks confirmed red; format committed; verify:pr-local blocked at check:installed-lock-parity (playwright 1.62.0 vs 1.62.1) | -| 2026-08-07 | cursor/privacy-live-signal-variants-bc81 (PR #1676) | f63eba10f5e9b6db047302c34412ea5261cd410b | PR #1676 unblock | before: PR policy fail (missing Clinical Governance Preflight; privacy* mockup paths trip clinicalRisk), behind-but-clean then main advanced; after: PR body preflight completed (policy green), tip synced to main via merge f63eba10 (duplicate local merge discarded), merge-tree clean, 0 unresolved threads, required CI in progress on synced tip; no code fix needed | local evaluatePullRequestPolicy ok after body; PR policy run 31172248093/31172327100 success; merge-tree clean; no provider-backed checks run | | 2026-08-07 | claude/handover-review-nlhuln | 4ff613c10fbf734b1e740a31611296c17c791ec7 | mode nav remaining modes: vestigial strip removal (PR #1679) | Removed the single-button action strip from answer/documents/services/forms/favourites/prescribing/tools; deleted the registry index-0 fallback (TS2493-forced) and the dead documents clause; stripped modeItems/onSearch/modeAriaLabel/stickyTop from PageSecondaryNavigation, keeping the empty-registry return below the information-section branch; kept the action kind with a no-live-consumer note. Completes the 13-mode navigation rollout. | lint exit 0; typecheck clean; focused 5 files 97 tests; test 518/519 files (pr-handoff-stop re-confirmed pre-existing on this base via stashed re-run); ui-mode-nav-density + ui-accessibility 71 passed (landmark scan green); branch-order guard mutation-checked (hoisting it fails 2 tests); format committed; verify:pr-local blocked at check:installed-lock-parity (playwright 1.62.0 vs 1.62.1) | -| 2026-08-07 | cursor/pr-1676-unblock-ledger-ef51 (PR #1677) | dec8f7489b4e5492e924af30dec85859932af3bb | review-and-fix | synced origin/main (behind-but-clean DIRTY; merge-tree clean); Bugbot mid-table finding dispositioned (row was tip-append before #1679; post-merge order correct; ledger guard passed); no P0/P1; 0 threads; no code fix; merge left to user | check:branch-review-ledger pass; ledger:dedupe none; merge-tree clean vs origin/main; format unchanged; no provider gates | -| 2026-08-07 | cursor/pr-1676-unblock-ledger-ef51 (PR #1677) | 574702a681cbb4d455151da023428d16c22fb460 | review-and-fix | late-synced origin/main after CI green (brought #1678 cn/tailwind-merge; remote merge 574702a6); prior sync cleared DIRTY; Bugbot mid-table finding dispositioned (tip-append before #1679; post-merge order correct); no P0/P1; 0 threads; no code fix; merge left to user | check:branch-review-ledger pass; ledger:dedupe none; merge-tree clean; prior tip required CI green; format unchanged; no provider gates | | 2026-08-05 | HEAD | 2a8881afad230880166de60a533e17588d9920ec | seven-report repo-wide audit | P2 confirmed: live drift and live-only migration; mobile CLS; ACL runner; OOXML declared-size robustness; assertion false positives. Numerous report claims stale, narrowed, or unsafe. | verify:cheap (5088 pass, 3 skip, 1 timeout); focused retry 21/21 pass; build pass; bundle budget pass; offline RAG 574/574; assertions 98%; live drift 34; browser CLS 0.228/0.218 | | 2026-08-07 | codex/consolidated-ledger-updates (PR #1683) | 413e679bb92cb19717d6d8301764df44694eb73e | review-and-fix PR #1683 | synced origin/main (behind-but-clean DIRTY cleared); restored main ledger order + sole seven-report row; Bugbot none; no P0/P1; merge-tree clean | verify:pr-local docs scope PASS (format:changed Prettier; check:branch-review-ledger 648; docs links 1650; outstanding-issues 258); merge-tree clean | -| 2026-08-07 | cursor/pr-1676-unblock-ledger-ef51 (PR #1677) | fddf495b5176f570a5238b5c17326f64f333ecff | review-and-fix | synced origin/main (behind-but-clean DIRTY; merge-tree clean; main advanced with #1684/#1666); 0 review threads; no P0/P1; no code fix; merge left to user | merge-tree clean vs origin/main; ledger:dedupe none; prior tip required CI green except stale PR mergeability; format unchanged; no provider gates | | 2026-08-07 | cursor/inpage-nav-default-235a | c6d72e406c03e205bac86d23e84813c41332c205 | docs: default in-page nav DocumentViewer chrome + PhoneHeaderCollapsePortal | docs-only; verify:pr-local passed (low-risk docs scope) | verify:pr-local --files AGENTS.md,docs/search-chrome-behaviour.md | -| 2026-08-07 | claude/issues-256-section-nav-clean | 169323053db5c572d183d59c113ecd0c76e7aca5 | issues #256: forms section anchors + differentials presentation set (PR #1697) | Wired all six formSections anchors in form-detail-page.tsx (four direct ids, two breakpoint pairs via existing mobile wrappers and single-child desktop wrappers, no component signature change); deleted differentialPresentationSections and declared /differentials/presentations/ locally-owned instead, since three of its six sections declared a -mobile targetId ReviewPanels can never satisfy and the page owns MobileTabs below xl plus the xl review sidebar. Added a registered browser spec because source-text and jsdom guards both structurally cannot see breakpoint-variant resolution. | lint exit 0; typecheck clean; test 519/520 files (pr-handoff-stop confirmed pre-existing via stashed re-run); check:gate-manifest and check:ci-scope pass with the new spec in both playwright allowlists; ui-forms-section-nav + ui-accessibility 18 passed incl real-record nav with 6 links and exactly one variant per pair visible at 390px and 1280px; binding guard mutation-checked red on one removed id; browser spec observed failing when nav genuinely absent; format clean. Environment: npm ci blocked (main lockfile needs Node >=24.15, container has 24.13), tailwind-merge@3.6.0 materialised from tarball only | -| 2026-08-07 | claude/handover-review-nlhuln | de8b74e2fb94d1ec9b1982c15a2dea43421c3eee | outstanding-issues ledger capture after the mode-nav rollout (PR #1685) | Confirmed #256's two remaining suspected section sets are dead (/forms/ and /differentials/presentations/ draw no section nav; form-decision-context-mobile is a testId not an id; ruled out sectionId indirection in both files). Added #261 (delete-or-keep the consumer-less action kind) and #262 (addon-slot single-owner rule held by two lists agreeing by coincidence). #207/#226/#231 reviewed and deliberately left untouched as existing P1 rows. | check:outstanding-issues passed (260 rows, 119 open, unique ids, no ids deleted from base 1ff9ed206456); prettier --check clean on the changed file; rows written via scripts/outstanding-issues.mjs, never hand-edited; no code gates run - docs-only diff | -| 2026-08-07 | claude/handover-review-nlhuln | de8b74e2fb94d1ec9b1982c15a2dea43421c3eee | outstanding-issues ledger capture after the mode-nav rollout (PR #1685) (supersedes 2026-08-07) | Confirmed #256's two remaining suspected section sets are dead (/forms/ and /differentials/presentations/ draw no section nav; form-decision-context-mobile is a testId not an id; ruled out sectionId indirection in both files). Added #271 (delete-or-keep the consumer-less action kind) and #272 (addon-slot single-owner rule held by two lists agreeing by coincidence) — renumbered from this PR's original #261/#262 because main claimed #261-#270 via PR #1678 design-system tracks in the interim. #207/#226/#231 reviewed and deliberately left untouched as existing P1 rows. | check:outstanding-issues passed (270 rows, 129 open, 141 archived, unique ids, next-id=273 above the highest, no ids deleted from base d32dd549a3dd); prettier --check clean on the changed file; rows written via scripts/outstanding-issues.mjs, never hand-edited; no code gates run - docs-only diff | -| 2026-08-07 | cursor/document-citation-landing-7bc3 | 82378a2bb4b875f1b610ef60c0ec3c94ee461f10 | document-viewer citation landing | ship: PDF-first citation landing; excerpt chip; indexed text collapsed until inspect/search; phone overview condensed; rail pin removed | unit 5539 pass; playwright critical citation+mobile PDF-first 2 pass; browser QA desktop/phone pass; typecheck; lint; build ALLOW_BUILD_WITH_DEV_SERVER=1; eval:rag:offline 36 golden; verify:pr-local stages green (first run flaked design-system-adoption timeout, retry green) | -| 2026-08-07 | claude/search-bar-mobile-layout-buu0io | 9d64388c0ce530d0c20bb7efe8ffb32cd928319c | phone results-filter idiom: 7 modes off MobileResultFilterControl onto ResultFilterTrigger + ResultFilterSheet; band, docs, tests | changes-shipped | typecheck; lint; test 5538 passed (1 pre-existing pr-handoff-stop failure, baselined on unmodified tree); build; check:rag:fixtures; check:bundle-budget +6.3% within tolerance; targeted Playwright: ui-accessibility 16, ui-specifiers+ui-formulation 12, ui-tools 5, ui-smoke 2, ui-stress 3 | -| 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-07 | cursor/phone-mode-dense-production-05c0 (PR #1648) | 4e0cca2ccbc19ed676765b029642da0afea6215a | Run PR sweep: CI fix + threads + drift | before: behind 17, checks green, 0 threads → after: merged origin/main (conflicts: none), CI re-running; 0 threads | merge-tree clean; git merge origin/main + push; no provider-backed checks run | | 2026-08-07 | cursor/tools-search-mockups-72e1 (PR #1653) | a7fbc26a917b347d90c6bab1e6c1b2ede6422263 | Run PR sweep: CI fix + threads + drift | before: behind 17, checks green, 0 threads → after: merged origin/main (conflicts: none), CI re-running; 0 threads | merge-tree clean; git merge origin/main + push; no provider-backed checks run | | 2026-08-07 | cursor/ship-first-redesign-mockups-2398 (PR #1654) | 9d9eb0be47073a7f051a5885359b82f2ff978a85 | Run PR sweep: CI fix + threads + drift | before: behind 17, checks green, 0 threads → after: merged origin/main (conflicts: none), CI re-running; 0 threads | merge-tree clean; git merge origin/main + push; no provider-backed checks run | @@ -727,6 +717,14 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-07 | cursor/viewer-phase2a-frame-controls-1db8 (PR #1687) | 5c10730be1641a386ee8c8476778933588a822fc | Run PR sweep: CI fix + threads + drift | before: up to date with main, Static PR fail (format:changed pdf-canvas-viewer), 1 outdated CodeRabbit thread (ref sync) already fixed on head → after: prettier format fix pushed; thread left open (no review-write API as cursor[bot]); CI re-running | format:changed fail→prettier --write pdf-canvas-viewer.tsx; format:changed PASS locally; no provider-backed checks run | | 2026-08-07 | claude/search-bar-mobile-layout-buu0io (PR #1689) | a152ffd89c962e3589509c0c3740dc429264063a | Run PR sweep: CI fix + threads + drift | before: behind 1, required CI green (advisory lighthouse fail ignored), 1 CodeRabbit lighthouse baseline thread (human disagreement in progress) → after: waited for CI settle, disabled automerge, merged origin/main, pushed, re-enabled automerge; thread left open for human | settled CI then merge+push; no provider-backed checks run; advisory lighthouse not chased | | 2026-08-07 | claude/issues-256-section-nav-clean (PR #1697) | 2b99db1f00c42fd7c11c4bf8acbad904ef5003e1 | Run PR sweep: CI fix + threads + drift | before: DIRTY/PR mergeability fail, behind 3, merge-tree CLEAN, 0 threads → after: merged origin/main (conflicts: none), CI re-running; 0 threads | merge-tree clean; git merge origin/main + push; no provider-backed checks run | +| 2026-08-07 | cursor/privacy-live-signal-variants-bc81 (PR #1676) | f63eba10f5e9b6db047302c34412ea5261cd410b | PR #1676 unblock | before: PR policy fail (missing Clinical Governance Preflight; privacy* mockup paths trip clinicalRisk), behind-but-clean then main advanced; after: PR body preflight completed (policy green), tip synced to main via merge f63eba10 (duplicate local merge discarded), merge-tree clean, 0 unresolved threads, required CI in progress on synced tip; no code fix needed | local evaluatePullRequestPolicy ok after body; PR policy run 31172248093/31172327100 success; merge-tree clean; no provider-backed checks run | +| 2026-08-07 | cursor/pr-1676-unblock-ledger-ef51 (PR #1677) | dec8f7489b4e5492e924af30dec85859932af3bb | review-and-fix | synced origin/main (behind-but-clean DIRTY; merge-tree clean); Bugbot mid-table finding dispositioned (row was tip-append before #1679; post-merge order correct; ledger guard passed); no P0/P1; 0 threads; no code fix; merge left to user | check:branch-review-ledger pass; ledger:dedupe none; merge-tree clean vs origin/main; format unchanged; no provider gates | +| 2026-08-07 | cursor/pr-1676-unblock-ledger-ef51 (PR #1677) | 574702a681cbb4d455151da023428d16c22fb460 | review-and-fix | late-synced origin/main after CI green (brought #1678 cn/tailwind-merge; remote merge 574702a6); prior sync cleared DIRTY; Bugbot mid-table finding dispositioned (tip-append before #1679; post-merge order correct); no P0/P1; 0 threads; no code fix; merge left to user | check:branch-review-ledger pass; ledger:dedupe none; merge-tree clean; prior tip required CI green; format unchanged; no provider gates | +| 2026-08-07 | cursor/pr-1676-unblock-ledger-ef51 (PR #1677) | fddf495b5176f570a5238b5c17326f64f333ecff | review-and-fix | synced origin/main (behind-but-clean DIRTY; merge-tree clean; main advanced with #1684/#1666); 0 review threads; no P0/P1; no code fix; merge left to user | merge-tree clean vs origin/main; ledger:dedupe none; prior tip required CI green except stale PR mergeability; format unchanged; no provider gates | +| 2026-08-07 | claude/issues-256-section-nav-clean | 169323053db5c572d183d59c113ecd0c76e7aca5 | issues #256: forms section anchors + differentials presentation set (PR #1697) | Wired all six formSections anchors in form-detail-page.tsx (four direct ids, two breakpoint pairs via existing mobile wrappers and single-child desktop wrappers, no component signature change); deleted differentialPresentationSections and declared /differentials/presentations/ locally-owned instead, since three of its six sections declared a -mobile targetId ReviewPanels can never satisfy and the page owns MobileTabs below xl plus the xl review sidebar. Added a registered browser spec because source-text and jsdom guards both structurally cannot see breakpoint-variant resolution. | lint exit 0; typecheck clean; test 519/520 files (pr-handoff-stop confirmed pre-existing via stashed re-run); check:gate-manifest and check:ci-scope pass with the new spec in both playwright allowlists; ui-forms-section-nav + ui-accessibility 18 passed incl real-record nav with 6 links and exactly one variant per pair visible at 390px and 1280px; binding guard mutation-checked red on one removed id; browser spec observed failing when nav genuinely absent; format clean. Environment: npm ci blocked (main lockfile needs Node >=24.15, container has 24.13), tailwind-merge@3.6.0 materialised from tarball only | +| 2026-08-07 | claude/handover-review-nlhuln | de8b74e2fb94d1ec9b1982c15a2dea43421c3eee | outstanding-issues ledger capture after the mode-nav rollout (PR #1685) | Confirmed #256's two remaining suspected section sets are dead (/forms/ and /differentials/presentations/ draw no section nav; form-decision-context-mobile is a testId not an id; ruled out sectionId indirection in both files). Added #261 (delete-or-keep the consumer-less action kind) and #262 (addon-slot single-owner rule held by two lists agreeing by coincidence). #207/#226/#231 reviewed and deliberately left untouched as existing P1 rows. | check:outstanding-issues passed (260 rows, 119 open, unique ids, no ids deleted from base 1ff9ed206456); prettier --check clean on the changed file; rows written via scripts/outstanding-issues.mjs, never hand-edited; no code gates run - docs-only diff | +| 2026-08-07 | claude/handover-review-nlhuln | de8b74e2fb94d1ec9b1982c15a2dea43421c3eee | outstanding-issues ledger capture after the mode-nav rollout (PR #1685) (supersedes 2026-08-07) | Confirmed #256's two remaining suspected section sets are dead (/forms/ and /differentials/presentations/ draw no section nav; form-decision-context-mobile is a testId not an id; ruled out sectionId indirection in both files). Added #271 (delete-or-keep the consumer-less action kind) and #272 (addon-slot single-owner rule held by two lists agreeing by coincidence) — renumbered from this PR's original #261/#262 because main claimed #261-#270 via PR #1678 design-system tracks in the interim. #207/#226/#231 reviewed and deliberately left untouched as existing P1 rows. | check:outstanding-issues passed (270 rows, 129 open, 141 archived, unique ids, next-id=273 above the highest, no ids deleted from base d32dd549a3dd); prettier --check clean on the changed file; rows written via scripts/outstanding-issues.mjs, never hand-edited; no code gates run - docs-only diff | +| 2026-08-07 | claude/search-bar-mobile-layout-buu0io | 9d64388c0ce530d0c20bb7efe8ffb32cd928319c | phone results-filter idiom: 7 modes off MobileResultFilterControl onto ResultFilterTrigger + ResultFilterSheet; band, docs, tests | changes-shipped | typecheck; lint; test 5538 passed (1 pre-existing pr-handoff-stop failure, baselined on unmodified tree); build; check:rag:fixtures; check:bundle-budget +6.3% within tolerance; targeted Playwright: ui-accessibility 16, ui-specifiers+ui-formulation 12, ui-tools 5, ui-smoke 2, ui-stress 3 | | 2026-08-07 | cursor/remove-specifiers-back-arrow-f1c4 | 095791235d58a6309b21b139911a1aad9fe9086b | specifiers-search-results-breadcrumb | removed lone ← Specifiers crumb from search results; deep pages unchanged | format,typecheck,lint,ensure+phone-spot-check | | 2026-08-07 | cursor/phone-mode-dense-production-05c0 (PR #1648) | 1091b17933beba655dac3e37f0e5c1bc4cdfb679 | Run PR sweep: CI fix + threads + drift | No action needed: PR required green, no unresolved review threads, not behind main. Only advisory Lighthouse job failing (never chased). | get_check_runs (PR required: success), get_review_comments (0 unresolved threads) | | 2026-08-07 | cursor/tools-search-mockups-72e1 (PR #1653) | 1df72ba6119226aee92b203db8188f58851a6d3c | Run PR sweep: CI fix + threads + drift | No action needed: PR required green, no unresolved review threads, not behind main. Only advisory Lighthouse job failing (never chased). | get_check_runs (PR required: success), get_review_comments (0 unresolved threads) | @@ -746,32 +744,36 @@ 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/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 | 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 | +| 2026-08-08 | cursor/safety-plan-phone-safe-area-624a (PR #1711) | ad1b1f5db24ed68ee4c0d5963620e4562829884e | heavy review-and-fix PR #1711 | fixed CodeRabbit sm:py guard parity; late-synced #1720 behind-but-clean; no P0/P1; Bugbot none; threads cleared; merge-tree clean; required CI green on 78c14205 pre-sync | vitest safety-plan+standalone 18p; verify:cheap 523/5582; verify:pr-local format+lint+typecheck+test+build+rag-fixtures; Production UI critical+(1)(2)(3)+PR required SUCCESS on 78c14205; no provider gates | +| 2026-08-07 | cursor/document-citation-landing-7bc3 | 82378a2bb4b875f1b610ef60c0ec3c94ee461f10 | document-viewer citation landing | ship: PDF-first citation landing; excerpt chip; indexed text collapsed until inspect/search; phone overview condensed; rail pin removed | unit 5539 pass; playwright critical citation+mobile PDF-first 2 pass; browser QA desktop/phone pass; typecheck; lint; build ALLOW_BUILD_WITH_DEV_SERVER=1; eval:rag:offline 36 golden; verify:pr-local stages green (first run flaked design-system-adoption timeout, retry green) | | 2026-08-08 | claude/ds-close-276 (PR #1724) | 75c89993f3ea23b70a250f605b21437b4ea9aac8 | PR #1724 review-and-fix | fixed Codex P2 wrong #118 Lighthouse cause (150 overwrite vs 151 pin); dispositioned CodeRabbit #276 archive claim as false (issues:done move); merge-tree clean; required CI was green on prior tip 8ae8c48f; no Bugbot findings | check:outstanding-issues pass; prettier --check docs/outstanding-issues.md pass; no provider-backed checks | | 2026-08-08 | claude/ds-close-276 (PR #1724) | 4baa9a1b42fa05731a6f983b3e0d0ebbd37f5271 | PR #1724 review-and-fix | synced origin/main (#1725 conflict on outstanding-issues resolved by preferring main queue then re-applying #276 done + corrected #118 diagnosis); Codex P2 fixed; CodeRabbit #276 archive claim dispositioned false; merge-tree clean after sync | check:outstanding-issues pass; prettier --check docs/outstanding-issues.md pass; merge-tree clean vs origin/main; no provider-backed checks | -| 2026-08-08 | claude/mode-routing-search-pages-jabe17 | 3a0bdd62466080ad713873cdd690ae600635a979 | mode routing: one shared home page at /, mode pill retargets the composer, /documents + /medications mode homes | handoff — PR #1744 opened; 2 pre-existing failures verified at base bc33d41 | test:e2e:pr 406 passed/2 failed (both fail at base); vitest 5608 passed/1 failed (pre-existing); lint clean; tsc clean; sitemap:check, docs:check-index, docs:check-inventory, check:design-system-contract, check:outstanding-issues pass; verify:pr-local and verify:ui blocked by pre-existing installed-lock-parity (playwright 1.62.0 vs locked 1.62.1) | -| 2026-08-08 | claude/mode-routing-search-pages-jabe17 | 468cc3fce85726a66098af0600d2b5d5951e3213 | bug-hunt | findings: P1 documents home autoRun on keystroke; P2 stale PWA /?mode=prescribing; P2 landing vs lastAppMode race; P2 /medications?q&run deep-link lost | vitest app-modes+search-route-ownership 36 pass; static ownership/ask-routing proof; no browser/UI/provider | +| 2026-08-08 | claude/document-viewer-optimization-tu8tnj | 98b799a372b1e341c86e8807d5cf37e987413e49 | document viewer phone/PWA rework: CSP-blocked native reader removed, one toolbar, fit-mode pinch, canvas pixel budget, source-first phone order, in-window detail-refetch guard, pdf.js on-demand fetch + teardown, image/signed-URL wins | ship: PR #1741 | lint, typecheck, test 5625 pass (1 pre-existing root-container failure), build, check:rag:fixtures, check:bundle-budget 1499.8 KiB vs base 1500.0 KiB, check:runtime, check:installed-lock-parity, format:changed; verify:ui not run (container Chromium 141 cannot raster pdfjs 6, see #278) | +| 2026-08-08 | claude/document-viewer-optimization-tu8tnj | 2359e158cb7bca5954e9c5ee84ca0766964ad901 | PR #1741 document-viewer phone/PWA review-and-fix | supersede: fixed Production UI phone Zoom/section-trigger; handlePdfLoadSuccess clamp; prior P1/P2 fixes retained; merge-tree clean | prior verify:cheap+pr-local green; ui-smoke selectors fixed for overflow Zoom + revealPhoneHeaderControl; no provider gates | | 2026-08-08 | claude/document-image-mobile-view-30xzw8 | 2394d903a6ca1ba7a84e380c9ed5cada038fa5c0 | document-viewer phone image layout + lightbox geometry (PR #1737) | implemented: capped rail/body grid tracks, removed aspect-ratio min-height transfer, rebuilt phone image viewer (legible open scale, rotation re-fit, clamped pan, double-tap, footer controls) | lint, typecheck, test (5647 pass / 1 pre-existing fail), build, eval:rag:offline, check:bundle-budget, all verify:pr-local static steps by hand; browser gates blocked by #255 | | 2026-08-08 | claude/document-image-mobile-view-30xzw8 | d257df7e11913db1d367535171fac726f47e7f1c | PR #1737 document-viewer phone image review-and-fix | fixed P1 expand fixture/threshold + P2 double-tap stage coords/pointer-up + resize re-clamp; Production UI timeout root cause cleared; merge-tree clean | verify:pr-local PASS (525 files/5653 tests); lint; typecheck; focused vitest 64/64; Production UI delegated to CI | | 2026-08-08 | PR #1740 / claude/inpage-nav-info-pages-v8rhnd | b67f33f65e00529eb0dd1682d6925e708243ee93 | Extract InPageNavHeader (default in-page nav template) + convert differentials detail; PR 1 of 3 | HANDOFF. Template extracted from the duplicated DocumentViewer/differential-detail markup into src/components/in-page-nav/ (InPageNavHeader, PageSection/toDocumentSections, usePageSectionWeights); differential-detail-page converted (-207 lines), behaviour-neutral. section-index.ts untouched so document tests unaffected. DocumentViewer deliberately NOT converged (owns h1, edge-glass-header, visual baselines) - follow-up. Anchor-offset hook generalisation deferred to PR 2 where it is consumed. 3 source-scanning contracts + addon-slot guard updated to follow the markup and additionally assert adoption; addon-slot scan widened to InPageNavHeader or it would go silent for every future adopter. Single failing test (pr-handoff-stop) is a root-uid artifact: chmod 0555 does not block root, reproduced with work stashed on clean tree. | verify:cheap 5618 passed/1 failed (root artifact); verify:pr-local same, short-circuits at test so build not reached; build run separately - Compiled successfully in 53s + client bundle secret check passed; verify:phone-chrome EXIT=0 (stage1 119 passed, stage2 7 passed 23.5s, full UI policy auto not selected); lint/typecheck/prettier --check . clean. No provider-backed gates. Deps installed with engine check relaxed (user-approved; Node 24.13.0 vs jsdom floor 24.15) - lockfile untouched. | -| 2026-08-08 | claude/document-viewer-optimization-tu8tnj | 98b799a372b1e341c86e8807d5cf37e987413e49 | document viewer phone/PWA rework: CSP-blocked native reader removed, one toolbar, fit-mode pinch, canvas pixel budget, source-first phone order, in-window detail-refetch guard, pdf.js on-demand fetch + teardown, image/signed-URL wins | ship: PR #1741 | lint, typecheck, test 5625 pass (1 pre-existing root-container failure), build, check:rag:fixtures, check:bundle-budget 1499.8 KiB vs base 1500.0 KiB, check:runtime, check:installed-lock-parity, format:changed; verify:ui not run (container Chromium 141 cannot raster pdfjs 6, see #278) | -| 2026-08-08 | claude/document-viewer-optimization-tu8tnj | 2359e158cb7bca5954e9c5ee84ca0766964ad901 | PR #1741 document-viewer phone/PWA review-and-fix | supersede: fixed Production UI phone Zoom/section-trigger; handlePdfLoadSuccess clamp; prior P1/P2 fixes retained; merge-tree clean | prior verify:cheap+pr-local green; ui-smoke selectors fixed for overflow Zoom + revealPhoneHeaderControl; no provider gates | -| 2026-08-08 | claude/mode-routing-search-pages-jabe17 | 6d1099b479358caa05c92f236848117feb920d4e | shared-home mode-routed search navigation | no high-confidence P0-P2 PR-introduced defects; prior bug-hunt P1/P2s appear fixed on tip; residual: prescribing submit-from-shared-home URL omits run=1 (pre-existing path), seed effect untested behaviourally, no browser/UI proof this pass | vitest app-modes+search-route-ownership+audit-navigation+pwa-manifest 61 pass; static read of focus files vs origin/main; ledger:lookup NOT REVIEWED; no provider/UI | -| 2026-08-08 | cursor/safety-plan-phone-safe-area-624a (PR #1711) | ad1b1f5db24ed68ee4c0d5963620e4562829884e | heavy review-and-fix PR #1711 | fixed CodeRabbit sm:py guard parity; late-synced #1720 behind-but-clean; no P0/P1; Bugbot none; threads cleared; merge-tree clean; required CI green on 78c14205 pre-sync | vitest safety-plan+standalone 18p; verify:cheap 523/5582; verify:pr-local format+lint+typecheck+test+build+rag-fixtures; Production UI critical+(1)(2)(3)+PR required SUCCESS on 78c14205; no provider gates | -| 2026-08-08 | claude/mode-routing-search-pages-jabe17 (PR #1760) | ce880f23f7ae5cbf9bc28a8a87f1de6585a4343e | home-mode seed navigation context, its contract test, and three outstanding-issues rows | handoff: PR #1760 opened; carries focus/scope context through the cold-/ replaceState, repoints the contract test at behaviour, records #285/#286 and a third #255 reproduction | lint clean; tsc --noEmit clean; unit 5710 passed/1 pre-existing pr-handoff-stop failure (#286); maintainability budgets passed; check:outstanding-issues 284 rows unique; format committed; UI delegated to CI (mismatched Chromium, #255) | -| 2026-08-08 | dependabot/npm_and_yarn/js-yaml-4.3.1 | 072b83f79a70037a04a8412844c041db43c9ce48 | PR #1668 unblock | synced main; merge-tree clean; required CI was green on prior tip e9516021; js-yaml 4.3.1 + nanoid 3.3.18 preserved; no unresolved threads; CI re-run after sync | pre-sync PR required pass; Production UI skipped (deps); post-sync pending | +| 2026-08-08 | cursor/differentials-query-lit-stream-8bc0 | bed84986742ca85b3724dd3ccc0758d4b9649934 | differentials diagnoses query-lit stream | implemented query-lit Diagnoses stream with match jump, related clusters, compare select, browse chapters; PR #1757 | unit:pass;lint:pass;typecheck:pass;verify:ui:not-run | +| 2026-08-08 | cursor/differentials-query-lit-stream-8bc0 | 106124d8084a1eab2eddab828076d10f96d3cedc | differentials diagnoses query-lit stream | implemented query-lit Diagnoses stream with match jump, related clusters, compare select, browse chapters; PR #1757 | unit:pass;lint:pass;typecheck:pass;verify:ui:not-run | | 2026-08-08 | claude/planning-build-intelligence-9ot0nm | 1ebc84bb288b516bb322c09cde2889e981d302a4 | AGENTS.md reasoning-effort calibration section (docs-only) | Authored and handed off as PR #1730; docs-only, pr-policy classifier returns clinicalRisk/operationalRisk/ragRanking false | prettier --check . (repo-wide, pass); docs:check-links (1665 refs resolve, pass); pr-policy classifyPullRequestFiles(AGENTS.md) | | 2026-08-08 | claude/planning-build-intelligence-9ot0nm | 2b0ad7d41d841c13515f10de7c41e449470dfa78 | pr-1730 review-and-fix | Deep review + Bugbot: no P0/P1; fixed 2 scoped P2 clarity risks (version-bump under-planning; live-state vs provider boundary). Residual: OPENAI_*_REASONING_EFFORT vocab overlap. Merge-tree clean; required CI was green pre-push. | prettier --check AGENTS.md; docs:check-links (1667); verify:pr-local (docs route pass); verify:cheap (524 files / 5607 tests pass); pr-policy classify clinical/operational/rag false; Bugbot no P0-P2 | +| 2026-08-08 | claude/mode-routing-search-pages-jabe17 | 3a0bdd62466080ad713873cdd690ae600635a979 | mode routing: one shared home page at /, mode pill retargets the composer, /documents + /medications mode homes | handoff — PR #1744 opened; 2 pre-existing failures verified at base bc33d41 | test:e2e:pr 406 passed/2 failed (both fail at base); vitest 5608 passed/1 failed (pre-existing); lint clean; tsc clean; sitemap:check, docs:check-index, docs:check-inventory, check:design-system-contract, check:outstanding-issues pass; verify:pr-local and verify:ui blocked by pre-existing installed-lock-parity (playwright 1.62.0 vs locked 1.62.1) | +| 2026-08-08 | claude/mode-routing-search-pages-jabe17 | 468cc3fce85726a66098af0600d2b5d5951e3213 | bug-hunt | findings: P1 documents home autoRun on keystroke; P2 stale PWA /?mode=prescribing; P2 landing vs lastAppMode race; P2 /medications?q&run deep-link lost | vitest app-modes+search-route-ownership 36 pass; static ownership/ask-routing proof; no browser/UI/provider | +| 2026-08-08 | claude/mode-routing-search-pages-jabe17 | 6d1099b479358caa05c92f236848117feb920d4e | shared-home mode-routed search navigation | no high-confidence P0-P2 PR-introduced defects; prior bug-hunt P1/P2s appear fixed on tip; residual: prescribing submit-from-shared-home URL omits run=1 (pre-existing path), seed effect untested behaviourally, no browser/UI proof this pass | vitest app-modes+search-route-ownership+audit-navigation+pwa-manifest 61 pass; static read of focus files vs origin/main; ledger:lookup NOT REVIEWED; no provider/UI | +| 2026-08-08 | cursor/differentials-query-lit-stream-8bc0 (PR #1757) | e7529dd2dd847b0bfd4b53daa723a8f5a329a50e | heavy review-and-fix | synced main; fixed P1 compare id drop + P2 mobile threshold + P2 query normalize; 3 threads need reply (API 403) | vitest differential-stream+differentials-navigation+differentials 42 passed; no provider-backed checks | +| 2026-08-08 | cursor/differentials-query-lit-stream-8bc0 (PR #1757) | d76c4cc8b8633a65df66ea46b090c2864a2c1592 | heavy review-and-fix | CI fix on tip: type-scale text-3xs; presentations redirect lowercases+drops unknown while preserving valid cross-workflow ids; prior P1/P2 fixes retained | check:type-scale; vitest audit-nav+differentials-nav+stream; no provider | +| 2026-08-08 | dependabot/npm_and_yarn/js-yaml-4.3.1 | 072b83f79a70037a04a8412844c041db43c9ce48 | PR #1668 unblock | synced main; merge-tree clean; required CI was green on prior tip e9516021; js-yaml 4.3.1 + nanoid 3.3.18 preserved; no unresolved threads; CI re-run after sync | pre-sync PR required pass; Production UI skipped (deps); post-sync pending | | 2026-08-08 | dependabot/npm_and_yarn/js-yaml-4.3.1 | a79943df33e653d2a65d4db2f192ee77c22ab75a | PR #1668 unblock | late-synced main after CI green on f04a96c3; merge-tree clean (GitHub DIRTY was stale); js-yaml 4.3.1 + nanoid 3.3.18 preserved; no unresolved threads; CI re-run after push | pre-late-sync: PR required pass on f04a96c3; Production UI skipped; post-sync pending | -| 2026-08-08 | cursor/safety-plan-copy-timer-a650 | cf57b34a36b768e150cd776f7e19acfd984245f7 | PR #1717 unblock | fixed missing it() closer from Copilot autofix; merged origin/main after #1668; merge-tree clean; no unresolved threads | local: vitest patient-safety-plan.dom.test.tsx (8/8); format ok; pending hosted CI after push | -| 2026-08-08 | cursor/safety-plan-copy-timer-a650 | 3142eb9a93275ce2c2435523560b4ed6624d8f53 | PR #1717 unblock | fixed parse + no-explicit-any from Copilot autofix; merged origin/main after #1668; merge-tree clean; 0 threads | local: vitest 8/8; eslint file clean; format ok; pending hosted CI | -| 2026-08-08 | cursor/compact-services-result-text-9b7d (PR #1731) | f07828041199458bd756090d04fb5105f41e3ca4 | PR #1731 unblock | before: MERGEABLE/BEHIND(1) merge-tree CLEAN tip 14fd8aa9; required CI green (PR required + Production UI 1/2/3 + critical); 0 threads; autoMerge SQUASH armed. after: late-synced origin/main (aa6cf68c from #1668/#1717) via worktree merge (update-branch 403); merge-tree clean; 0 behind; CI will re-run on sync tip; autoMerge left armed; no product code change | gh pr checks --watch: PR required SUCCESS; Production UI (1)(2)(3)+critical SUCCESS; merge-tree clean; ledger:dedupe none; no provider gates | | 2026-08-08 | cursor/fix-differentials-compare-5c66 | fd4801b07309625780b06afef718f93799655885 | differentials-compare-selection-handoff | fixed: preserve cross-presentation compare ids via ad-hoc /differentials/compare; URL ids sync; ModeNav Compare wired | verify:pr-local:5709 passed; test:focused:255 passed | | 2026-08-08 | cursor/fix-differentials-compare-5c66 (PR #1756) | c739708981083b816843ceec5e50ea00818996b5 | heavy review-and-fix | merged origin/main (docs/adoption/sitemap regenerated); fixed P1 cold-load URL wipe + P2 unsupported criterion + lowercase ids; CodeRabbit empty-state/auto-seed left as intentional ModeNav handoff; threads unreplied (403) | vitest differentials+navigation+compare-selection DOM 37 pass; related nav tests 44 pass; eslint touched files; no provider-backed checks | | 2026-08-08 | cursor/fix-differentials-compare-5c66 (PR #1756) | bd62d3a23b888d30112fdc11e86fe1811f1919bc | heavy review-and-fix | merged origin/main (docs/adoption/sitemap regenerated); fixed P1 cold-load URL wipe (state-captured ids + defer sync while loading) + P2 unsupported criterion + lowercase ids; CodeRabbit empty-state/auto-seed left as intentional ModeNav handoff; threads unreplied (403) | vitest differentials+navigation+compare-selection DOM 37 pass; related nav tests 44 pass; eslint touched files clean; no provider-backed checks | +| 2026-08-08 | cursor/safety-plan-copy-timer-a650 | cf57b34a36b768e150cd776f7e19acfd984245f7 | PR #1717 unblock | fixed missing it() closer from Copilot autofix; merged origin/main after #1668; merge-tree clean; no unresolved threads | local: vitest patient-safety-plan.dom.test.tsx (8/8); format ok; pending hosted CI after push | +| 2026-08-08 | cursor/safety-plan-copy-timer-a650 | 3142eb9a93275ce2c2435523560b4ed6624d8f53 | PR #1717 unblock | fixed parse + no-explicit-any from Copilot autofix; merged origin/main after #1668; merge-tree clean; 0 threads | local: vitest 8/8; eslint file clean; format ok; pending hosted CI | | 2026-08-08 | cursor/forms-info-disclosure-68d6 | f5dd1dea495e8d6e9bd5106dcf0a4d062ee02292 | forms-info-disclosure | fixed Form information tick rows to expand via DisclosureGroup | verify:pr-local; forms-information-disclosure.dom.test; check:design-system-adoption | | 2026-08-08 | cursor/forms-info-disclosure-68d6 | 8f25e6c482d8e4cd879098d7cfd73b7f8603e478 | forms-info-disclosure | fixed Form information tick rows to expand via DisclosureGroup | verify:pr-local; forms-information-disclosure.dom.test; check:design-system-adoption | | 2026-08-08 | cursor/forms-info-disclosure-68d6 (PR #1735) | 9e1390d73ebbae0bbfc0f81bf3b3921dadf24577 | heavy review-and-fix | CONFLICT merge-tree on docs/design-system/adoption-manifest.json resolved by regenerating (DisclosureGroup form-detail import + main documents/medications routes); product forms DisclosureGroup intent preserved; 0 unresolved threads; no ambiguous clinical/auth conflicts | check:design-system-adoption PASS (53 components, 57 roots); vitest forms-information-disclosure.dom 2/2 PASS; no provider-backed checks | @@ -780,6 +782,7 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-08 | cursor/confirm-checklist-polish-195c | 3cf0ed99a1a90f46cb7c6aff7e8b7f7bfd6212b8 | form-detail Confirm checklist polish (supersedes 2026-08-08) | shipped spacing/typography polish + DOM guard | vitest form-confirm-callout.dom; visual Form 1A Confirm | | 2026-08-08 | cursor/confirm-checklist-polish-195c | 96c4d3a3a1a46efedfa5b43c4bf1de227c1d19a6 | PR #1734 confirm checklist | clean; no P0/P1/P2 in ConfirmCalloutText/confirmCheckParts/Avoid row | diff vs main; form-1a catalog wiring; vitest form-confirm-callout.dom.test.tsx PASS | | 2026-08-08 | cursor/confirm-checklist-polish-195c | 89cc8711dd0536c32818cbbd493edff860763a61 | PR #1734 unblock | synced origin/main (behind-but-clean DIRTY; merge-tree clean); no product conflict; advisory lighthouse ignored | merge-tree clean vs origin/main; ledger:dedupe none | +| 2026-08-08 | cursor/compact-services-result-text-9b7d (PR #1731) | f07828041199458bd756090d04fb5105f41e3ca4 | PR #1731 unblock | before: MERGEABLE/BEHIND(1) merge-tree CLEAN tip 14fd8aa9; required CI green (PR required + Production UI 1/2/3 + critical); 0 threads; autoMerge SQUASH armed. after: late-synced origin/main (aa6cf68c from #1668/#1717) via worktree merge (update-branch 403); merge-tree clean; 0 behind; CI will re-run on sync tip; autoMerge left armed; no product code change | gh pr checks --watch: PR required SUCCESS; Production UI (1)(2)(3)+critical SUCCESS; merge-tree clean; ledger:dedupe none; no provider gates | | 2026-08-08 | cursor/services-content-cleanup-1c73 | 1b62fdabbabcfbb05ccbbae08b070bf7740426d5 | services content cleanup: compact catalogue fields + hide empty detail sections | APPROVE pending required CI; verify:pr-local passed; UI spot-check recommended | verify:pr-local (lint/typecheck/test/build/rag-fixtures) | | 2026-08-08 | claude/ds-visual-advisory-off-prs (PR #1755) | e6d24190eb1e02c435003d429d88f2d293b14867 | heavy review-and-fix | merged origin/main; fixed Bugbot/Codex P2 merge_group exclusion via event allowlist; synced docs/testing.md + #118 note; CodeRabbit date nit dispositioned (owner +0800); threads unreplied (403) | vitest ci-cache-safety; check:github-actions; no provider-backed checks | | 2026-08-08 | claude/document-viewer-optimization-tu8tnj | 9a5f79ab133c6ab9ea2a47e93b0101df8db44607 | docs-only: one outstanding-issues row (#285) recording the lowercase authorizationHeader trap surfaced by PR #1741 review | ship: PR #1754 | check:outstanding-issues (283 rows, unique ids, next-id above highest), prettier --check on the changed file; no source touched so lint/typecheck/test/build have no changed failure path | @@ -812,3 +815,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-08 | cursor/form-1a-priority-facts-dc36 | e965d96258cdd3d5ad6f520616de0f32bf02498e | Form 1A priority facts: condense cards + Act section detail sheets | implemented; Form 1A Source status card replaced with Act sections 26/31/36/37/41/42; condensed clock/maker/criteria with tap sheets | typecheck pass; lint pass; npm run test 530 files / 5704 passed | | 2026-08-08 | cursor/form-1a-priority-facts-dc36 | 7040b850e655dc7ba9a23ad3e3db765cc1ad7755 | Form 1A priority facts: condense cards + Act section detail sheets | implemented; Form 1A Source status card replaced with Act sections 26/31/36/37/41/42; condensed clock/maker/criteria with tap sheets | typecheck pass; lint pass; npm run test 530 files / 5704 passed | | 2026-08-08 | cursor/form-1a-priority-facts-dc36 | e965d96258cdd3d5ad6f520616de0f32bf02498e | Form 1A priority facts: condense cards + Act section detail sheets (supersedes 2026-08-08) | implemented; Form 1A Source status card replaced with Act sections 26/31/36/37/41/42; condensed clock/maker/criteria with tap sheets | typecheck pass; lint pass; npm run test 530 files / 5704 passed | +| 2026-08-08 | claude/mode-routing-search-pages-jabe17 (PR #1760) | ce880f23f7ae5cbf9bc28a8a87f1de6585a4343e | home-mode seed navigation context, its contract test, and three outstanding-issues rows | handoff: PR #1760 opened; carries focus/scope context through the cold-/ replaceState, repoints the contract test at behaviour, records #285/#286 and a third #255 reproduction | lint clean; tsc --noEmit clean; unit 5710 passed/1 pre-existing pr-handoff-stop failure (#286); maintainability budgets passed; check:outstanding-issues 284 rows unique; format committed; UI delegated to CI (mismatched Chromium, #255) | diff --git a/src/app/(search-app)/differentials/diagnoses/page.tsx b/src/app/(search-app)/differentials/diagnoses/page.tsx index f5cb2f166f..472576de79 100644 --- a/src/app/(search-app)/differentials/diagnoses/page.tsx +++ b/src/app/(search-app)/differentials/diagnoses/page.tsx @@ -1,7 +1,11 @@ import { DifferentialStreamPage } from "@/components/differentials/differential-stream-page"; type DifferentialDiagnosesRouteProps = { - searchParams?: Promise<{ query?: string | string[]; q?: string | string[] }>; + searchParams?: Promise<{ + query?: string | string[]; + q?: string | string[]; + focus?: string | string[]; + }>; }; function firstSearchParam(value?: string | string[]) { @@ -11,6 +15,7 @@ function firstSearchParam(value?: string | string[]) { export default async function DifferentialDiagnosesRoute({ searchParams }: DifferentialDiagnosesRouteProps) { const params = searchParams ? await searchParams : {}; const query = firstSearchParam(params.query ?? params.q)?.trim() ?? ""; + const focus = firstSearchParam(params.focus)?.trim() ?? ""; - return ; + return ; } diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 63328892a8..fddfd51963 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -414,7 +414,8 @@ function GlobalStandaloneSearchShellBody({ const isDocumentCommandSearchView = pathname === "/documents/search" && requestedQuery.length > 0; const useCompactBottomSearch = hasSubmittedModeSearch || isDocumentCommandSearchView; const differentialsCompareAddonActive = - pathname === "/differentials" && searchMode === "differentials" && hasSubmittedModeSearch; + searchMode === "differentials" && + (pathname === "/differentials/diagnoses" || (pathname === "/differentials" && hasSubmittedModeSearch)); // Registry and local decision-support modes own their submitted-search views on their // standalone routes; the shell must not swap them to the dashboard. On the // home route the dashboard always renders, so these exclusions only apply diff --git a/src/components/differentials/differential-stream-page.tsx b/src/components/differentials/differential-stream-page.tsx index f3f0475b7c..32e09e5513 100644 --- a/src/components/differentials/differential-stream-page.tsx +++ b/src/components/differentials/differential-stream-page.tsx @@ -1,114 +1,14 @@ -import Link from "next/link"; -import { ArrowLeft, ArrowRight, FileText } from "lucide-react"; -import { appModeHomeHref } from "@/lib/app-modes"; - -import { - differentialDiagnosesCards, - differentialPresentationsCards, - type DifferentialStreamCard, - type DifferentialStreamType, -} from "@/lib/differentials"; +import { DifferentialStreamWorkspace } from "@/components/differentials/differential-stream-workspace"; +import { buildDifferentialStreamModel } from "@/lib/differential-stream"; +import type { DifferentialStreamType } from "@/lib/differential-stream-model"; type DifferentialStreamPageProps = { query?: string; + focus?: string; stream: DifferentialStreamType; }; -const streamCopy: Record< - DifferentialStreamType, - { - heading: string; - description: string; - intro: string; - entriesLabel: string; - cards: DifferentialStreamCard[]; - } -> = { - presentations: { - heading: "Differentials: Presentations", - description: "Search and refine by presenting pattern before locking differential pathways.", - intro: "Use this stream for symptom-first intake, acute presentations, and rapid sorting.", - entriesLabel: "Presentation-focused differential content", - cards: differentialPresentationsCards, - }, - diagnoses: { - heading: "Differentials: Diagnoses", - description: "Compare likely causes side-by-side and check exclusion clues.", - intro: "Use this stream for differential ranking, safety ordering, and comparison notes.", - entriesLabel: "Diagnosis-focused differential content", - cards: differentialDiagnosesCards, - }, -}; - -export function DifferentialStreamPage({ stream, query = "" }: DifferentialStreamPageProps) { - const copy = streamCopy[stream]; - return ( -
-
-
-

- {copy.heading} -

-

- {copy.description} -

-

{copy.intro}

- {query ?

Query: {query}

: null} -
- -
-
-

Clinical entries

- {copy.entriesLabel} -
-
- {copy.cards.map((card) => ( - -

{card.title}

-

{card.description}

-
    - {card.examples.map((example, index) => ( -
  • - - {example} -
  • - ))} -
- - ))} -
-
- -
-
-

Keep exploring

-

- Return to the differentials home to start from a different presentation, or open search to look up another - differential. -

-
-
- - - Back to differential home - - - - Open differential search - -
-
-
-
- ); +export function DifferentialStreamPage({ stream, query = "", focus = "" }: DifferentialStreamPageProps) { + const model = buildDifferentialStreamModel(stream, query); + return ; } diff --git a/src/components/differentials/differential-stream-workspace.tsx b/src/components/differentials/differential-stream-workspace.tsx new file mode 100644 index 0000000000..a9bdee9a02 --- /dev/null +++ b/src/components/differentials/differential-stream-workspace.tsx @@ -0,0 +1,711 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp, FileText, GitCompareArrows, ShieldAlert } from "lucide-react"; + +import { appModeHomeHref } from "@/lib/app-modes"; +import { normalizeSearchText } from "@/lib/catalog-search"; +import { differentialRouteWithQuery, differentialSelectedCompareHref } from "@/lib/differentials-navigation"; +import { differentialsMobileCompareAddonSlotId } from "@/lib/mode-home-composer"; +import type { + DifferentialStreamItem, + DifferentialStreamModel, + DifferentialStreamType, +} from "@/lib/differential-stream-model"; +import type { DifferentialLikelihood } from "@/lib/differential-snapshot"; + +type BrowseGrouping = "urgency" | "presentation"; + +type DifferentialStreamWorkspaceProps = { + model: DifferentialStreamModel; + query: string; + initialFocus?: string; +}; + +const streamCopy: Record< + DifferentialStreamType, + { heading: string; description: string; intro: string; entriesLabel: string } +> = { + presentations: { + heading: "Differentials: Presentations", + description: "Search and refine by presenting pattern before locking differential pathways.", + intro: "Use this stream for symptom-first intake, acute presentations, and rapid sorting.", + entriesLabel: "Presentation-focused differential content", + }, + diagnoses: { + heading: "Differentials: Diagnoses", + description: "Compare likely causes side-by-side and check exclusion clues.", + intro: "Use this stream for differential ranking, safety ordering, and comparison notes.", + entriesLabel: "Diagnosis-focused differential content", + }, +}; + +function statusLabel(status: DifferentialStreamItem["status"]) { + if (status === "emergent") return "Emergent"; + if (status === "urgent") return "Urgent"; + return "Routine"; +} + +function statusTone(status: DifferentialStreamItem["status"]) { + if (status === "emergent") { + return "border-transparent bg-[color:var(--danger-solid)] text-[color:var(--danger-solid-contrast)]"; + } + if (status === "urgent") { + return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]"; + } + return "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]"; +} + +function likelihoodTone(likelihood: DifferentialLikelihood) { + if (likelihood === "must-not-miss") { + return "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]"; + } + if (likelihood === "most-likely") { + return "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"; + } + if (likelihood === "possible") { + return "border-[color:var(--border)] bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]"; + } + return "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)]"; +} + +function StreamMobileCompareBar({ + selectedCount, + selectedIds, + query, +}: { + selectedCount: number; + selectedIds: Set; + query: string; +}) { + const [host, setHost] = useState(null); + + useEffect(() => { + const phoneMediaQuery = window.matchMedia("(max-width: 1023px)"); + const sync = () => { + setHost(phoneMediaQuery.matches ? document.getElementById(differentialsMobileCompareAddonSlotId) : null); + }; + sync(); + phoneMediaQuery.addEventListener("change", sync); + const observer = new MutationObserver(sync); + observer.observe(document.body, { childList: true, subtree: true }); + return () => { + phoneMediaQuery.removeEventListener("change", sync); + observer.disconnect(); + }; + }, []); + + if (!host) return null; + + const canCompare = selectedCount >= 2; + + return createPortal( +
+ {canCompare ? ( + + + Compare selected + + {selectedCount} + + + ) : ( +

+ + + {selectedCount === 1 ? "Select one more to compare" : "Tick diagnoses to compare"} + +

+ )} +
, + host, + ); +} + +function MatchRail({ + matchItems, + activeSlug, + onJump, +}: { + matchItems: DifferentialStreamItem[]; + activeSlug: string | null; + onJump: (slug: string) => void; +}) { + if (matchItems.length === 0) return null; + return ( +
+ {matchItems.slice(0, 12).map((item) => { + const active = item.slug === activeSlug; + return ( + + ); + })} +
+ ); +} + +function StreamCard({ + item, + highlight, + selected, + showSelect, + familyMode, + onFocus, + onToggleSelect, + onShowFamily, +}: { + item: DifferentialStreamItem; + highlight: "match" | "related" | "dim" | "neutral"; + selected: boolean; + showSelect: boolean; + familyMode: boolean; + onFocus: () => void; + onToggleSelect: () => void; + onShowFamily: () => void; +}) { + const cardTone = + highlight === "match" + ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] shadow-[var(--shadow-inset)]" + : highlight === "related" + ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] shadow-[var(--shadow-inset)]" + : highlight === "dim" + ? "border-[color:var(--border)] bg-[color:var(--surface)] opacity-45" + : "border-[color:var(--border)] bg-[color:var(--surface)] shadow-[var(--shadow-inset)]"; + + return ( +
+
+
+
+

{item.title}

+ + {statusLabel(item.status)} + +
+

{item.description}

+
+ {showSelect ? ( + + ) : null} +
+ + {item.matchReasons.length > 0 ? ( +
    + {item.matchReasons.map((reason) => ( +
  • + {reason} +
  • + ))} +
+ ) : null} + + {item.exclusionPreview ? ( +

+ Exclusion / mimics: + {item.exclusionPreview} +

+ ) : null} + +
    + {item.examples.map((example, index) => ( +
  • + + {example} +
  • + ))} +
+ + {item.related.length > 0 ? ( +
+ {item.related.slice(0, 4).map((node) => ( + + {node.label} + + ))} +
+ ) : null} + +
+ + Open + + + +
+
+ ); +} + +export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: DifferentialStreamWorkspaceProps) { + const router = useRouter(); + const copy = streamCopy[model.stream]; + // Match buildDifferentialStreamModel: punctuation-only queries are browse mode. + const hasQuery = Boolean(normalizeSearchText(query)); + const matchItems = useMemo(() => model.items.filter((item) => item.isMatch), [model.items]); + const itemBySlug = useMemo(() => new Map(model.items.map((item) => [item.slug, item])), [model.items]); + const itemById = useMemo(() => new Map(model.items.map((item) => [item.id, item])), [model.items]); + + const [focusedSlug, setFocusedSlug] = useState(() => { + const requested = initialFocus.trim().toLowerCase(); + if (requested && itemBySlug.has(requested)) return requested; + return matchItems[0]?.slug ?? null; + }); + const [familyMode, setFamilyMode] = useState(false); + const [browseGrouping, setBrowseGrouping] = useState("urgency"); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const matchJumpRegionId = useId(); + const didAutoJumpForQuery = useRef(""); + + const resultSignature = matchItems.map((item) => item.slug).join("|"); + const [lastResultSignature, setLastResultSignature] = useState(""); + if (lastResultSignature !== resultSignature) { + setLastResultSignature(resultSignature); + if (model.stream === "diagnoses") { + setSelectedIds(new Set(model.compareSeedIds)); + } else { + setSelectedIds(new Set()); + } + if (!initialFocus.trim() && matchItems[0]) { + setFocusedSlug(matchItems[0].slug); + } + setFamilyMode(false); + } + + const focusedItem = focusedSlug ? (itemBySlug.get(focusedSlug) ?? null) : null; + const relatedSlugSet = useMemo(() => { + if (!focusedItem) return new Set(); + return new Set(focusedItem.related.map((node) => node.slug)); + }, [focusedItem]); + + const visibleItems = useMemo(() => { + if (!familyMode || !focusedItem) return model.items; + const allowed = new Set([focusedItem.slug, ...relatedSlugSet]); + return model.items.filter((item) => allowed.has(item.slug)); + }, [familyMode, focusedItem, model.items, relatedSlugSet]); + + const activeChapters = + !hasQuery && model.stream === "diagnoses" + ? browseGrouping === "presentation" + ? model.presentationChapters + : model.chapters + : model.chapters; + + const matchIndex = focusedSlug ? matchItems.findIndex((item) => item.slug === focusedSlug) : -1; + + function scrollToSlug(slug: string) { + const node = document.getElementById(`differential-stream-card-${slug}`); + if (!node) return; + node.scrollIntoView({ behavior: "smooth", block: "center" }); + setFocusedSlug(slug); + } + + useEffect(() => { + const queryKey = `${query.trim().toLowerCase()}::${initialFocus.trim().toLowerCase()}`; + if (!hasQuery || didAutoJumpForQuery.current === queryKey) return; + const target = (initialFocus.trim().toLowerCase() || matchItems[0]?.slug) ?? ""; + if (!target) return; + didAutoJumpForQuery.current = queryKey; + // Defer one frame so cards are in the DOM after hydration. + const frame = window.requestAnimationFrame(() => { + const node = document.getElementById(`differential-stream-card-${target}`); + if (!node) return; + node.scrollIntoView({ behavior: "smooth", block: "center" }); + setFocusedSlug(target); + }); + return () => window.cancelAnimationFrame(frame); + }, [hasQuery, initialFocus, matchItems, query]); + + function highlightFor(item: DifferentialStreamItem): "match" | "related" | "dim" | "neutral" { + if (!hasQuery && !familyMode) { + if (focusedSlug && item.slug === focusedSlug) return "match"; + if (focusedSlug && relatedSlugSet.has(item.slug)) return "related"; + return "neutral"; + } + if (item.isMatch) return "match"; + if (relatedSlugSet.has(item.slug) || (focusedSlug && item.slug === focusedSlug)) return "related"; + if (hasQuery) return "dim"; + return "neutral"; + } + + function jumpMatch(delta: number) { + if (matchItems.length === 0) return; + const current = matchIndex >= 0 ? matchIndex : 0; + const next = (current + delta + matchItems.length) % matchItems.length; + scrollToSlug(matchItems[next]!.slug); + } + + function toggleSelected(slug: string) { + setSelectedIds((current) => { + const next = new Set(current); + if (next.has(slug)) next.delete(slug); + else next.add(slug); + return next; + }); + } + + function applyPreset(presetQuery: string) { + router.push(differentialRouteWithQuery("/differentials/diagnoses", presetQuery)); + } + + const safetyItems = model.safetyShelfIds + .map((id) => itemById.get(id)) + .filter((item): item is DifferentialStreamItem => Boolean(item)); + + const selectedCount = selectedIds.size; + const showSelect = model.stream === "diagnoses"; + + function renderCardGrid(items: DifferentialStreamItem[]): ReactNode { + return ( +
+ {items.map((item) => ( + setFocusedSlug(item.slug)} + onToggleSelect={() => toggleSelected(item.slug)} + onShowFamily={() => { + setFocusedSlug(item.slug); + setFamilyMode(true); + }} + /> + ))} +
+ ); + } + + return ( +
+
+
+

+ {copy.heading} +

+

+ {copy.description} +

+

{copy.intro}

+ {hasQuery ? ( +

+ Query: {query.trim()} + {model.matchCount > 0 ? ( + + · {model.matchCount} match{model.matchCount === 1 ? "" : "es"} + + ) : ( + · no direct matches + )} +

+ ) : null} +
+ + {!hasQuery && model.presets.length > 0 ? ( +
+

+ Start from a scenario +

+
+ {model.presets.map((preset) => ( + + ))} +
+
+ ) : null} + + {!hasQuery && safetyItems.length > 0 ? ( +
+
+ + Must-check / emergent shelf +
+
+ {safetyItems.map((item) => ( + + ))} +
+
+ ) : null} + + {hasQuery ? ( +
+
+

+ {model.matchCount} match{model.matchCount === 1 ? "" : "es"} + {focusedItem ? ` · focused ${focusedItem.title}` : ""} +

+
+ + + +
+
+ + {focusedItem && relatedSlugSet.size > 0 ? ( +

+ Related cluster lit for {focusedItem.title} + {" — "} + {focusedItem.related + .slice(0, 4) + .map((node) => node.label) + .join(", ")} +

+ ) : null} +
+ ) : null} + + {showSelect ? ( +
+

+ {selectedCount === 0 + ? "Tick two or more diagnoses to open side-by-side compare." + : selectedCount === 1 + ? "Select one more diagnosis to enable compare." + : `${selectedCount} diagnoses selected for compare.`} +

+ {selectedCount >= 2 ? ( + + + Compare selected ({selectedCount}) + + ) : ( + + )} + + Select at least two diagnoses before opening compare. + +
+ ) : null} + +
+
+
+

Clinical entries

+ {copy.entriesLabel} +
+ {!hasQuery && model.stream === "diagnoses" ? ( +
+ + + {familyMode ? ( + + ) : null} +
+ ) : null} +
+ + {hasQuery || familyMode || activeChapters.length === 0 + ? renderCardGrid(visibleItems) + : activeChapters.map((chapter) => { + const chapterItems = chapter.itemIds + .map((id) => itemById.get(id)) + .filter((item): item is DifferentialStreamItem => Boolean(item)) + .filter((item) => visibleItems.some((visible) => visible.id === item.id)); + if (chapterItems.length === 0) return null; + return ( +
+
+

{chapter.title}

+

{chapter.description}

+
+ {renderCardGrid(chapterItems)} +
+ ); + })} +
+ +
+
+

Keep exploring

+

+ Return to the differentials home to start from a different presentation, or open search to look up another + differential. +

+
+
+ + + Back to differential home + + + + Open differential search + +
+
+
+ + {showSelect ? ( + + ) : null} +
+ ); +} diff --git a/src/lib/differential-stream-model.ts b/src/lib/differential-stream-model.ts new file mode 100644 index 0000000000..c40bfb2a0e --- /dev/null +++ b/src/lib/differential-stream-model.ts @@ -0,0 +1,56 @@ +/** + * Client-safe serializable types for the differentials stream workspace. + * Keep this module free of `@/lib/differentials` / snapshot loaders. + */ + +import type { DifferentialLikelihood, DifferentialRecord } from "@/lib/differential-snapshot"; + +export type DifferentialStreamType = "presentations" | "diagnoses"; + +export type DifferentialStreamRelatedRef = { + slug: string; + label: string; + likelihood: DifferentialLikelihood; +}; + +export type DifferentialStreamItem = { + id: string; + slug: string; + title: string; + description: string; + examples: string[]; + href: string; + status: DifferentialRecord["status"]; + matchReasons: string[]; + isMatch: boolean; + score: number; + related: DifferentialStreamRelatedRef[]; + exclusionPreview: string | null; + chapterId: string; + chapterTitle: string; +}; + +export type DifferentialStreamChapter = { + id: string; + title: string; + description: string; + itemIds: string[]; +}; + +export type DifferentialStreamPresetChip = { + id: string; + label: string; + query: string; +}; + +export type DifferentialStreamModel = { + stream: DifferentialStreamType; + items: DifferentialStreamItem[]; + matchCount: number; + chapters: DifferentialStreamChapter[]; + presentationChapters: DifferentialStreamChapter[]; + presets: DifferentialStreamPresetChip[]; + safetyShelfIds: string[]; + /** Diagnosis slugs safe to auto-tick for compare (share one presentation workflow). */ + compareSeedIds: string[]; +}; diff --git a/src/lib/differential-stream.ts b/src/lib/differential-stream.ts new file mode 100644 index 0000000000..3e16be0e8e --- /dev/null +++ b/src/lib/differential-stream.ts @@ -0,0 +1,321 @@ +/** + * Server-side builders for the Diagnoses/Presentations stream workspace. + * Keeps the full differentials snapshot off the client by shipping a trimmed + * serializable model (match flags, related refs, browse chapters). + */ + +import { normalizeSearchText } from "@/lib/catalog-search"; +import { + differentialPresentations, + differentialRecords, + differentialScenarioPresets, + rankDifferentialRecords, + rankPresentationWorkflows, + type DifferentialRecord, + getPresentationWorkflowSelectionForDiagnosisIds, +} from "@/lib/differentials"; +import type { + DifferentialStreamChapter, + DifferentialStreamItem, + DifferentialStreamModel, + DifferentialStreamPresetChip, + DifferentialStreamRelatedRef, + DifferentialStreamType, +} from "@/lib/differential-stream-model"; + +export type { + DifferentialStreamChapter, + DifferentialStreamItem, + DifferentialStreamModel, + DifferentialStreamPresetChip, + DifferentialStreamRelatedRef, + DifferentialStreamType, +} from "@/lib/differential-stream-model"; + +const statusChapterOrder: DifferentialRecord["status"][] = ["emergent", "urgent", "routine"]; + +const statusChapterCopy: Record = { + emergent: { + title: "Emergent", + description: "Time-critical differentials to exclude first.", + }, + urgent: { + title: "Urgent", + description: "High-priority causes that need prompt work-up.", + }, + routine: { + title: "Routine", + description: "Lower-acuity differentials for structured comparison.", + }, +}; + +function exclusionPreviewForRecord(record: DifferentialRecord): string | null { + const overlap = record.sections.find((section) => section.tone === "overlap"); + if (!overlap) return null; + const item = overlap.items.map((entry) => entry.trim()).find(Boolean); + if (item) return item; + const summary = overlap.summary.trim(); + return summary || null; +} + +function presentationChapterForSlug(slug: string): { id: string; title: string } | null { + for (const presentation of differentialPresentations()) { + if (presentation.candidates.some((candidate) => candidate.slug === slug)) { + return { id: presentation.id, title: presentation.title }; + } + } + return null; +} + +function relatedRefs(record: DifferentialRecord, knownSlugs: Set): DifferentialStreamRelatedRef[] { + const seen = new Set(); + const refs: DifferentialStreamRelatedRef[] = []; + for (const node of record.related) { + const slug = node.id.trim().toLowerCase(); + if (!slug || !knownSlugs.has(slug) || slug === record.slug || seen.has(slug)) continue; + seen.add(slug); + refs.push({ + slug, + label: node.label, + likelihood: node.likelihood, + }); + } + return refs; +} + +function diagnosisItemFromRecord( + record: DifferentialRecord, + knownSlugs: Set, + match?: { score: number; reasons: string[] }, +): DifferentialStreamItem { + const chapter = presentationChapterForSlug(record.slug); + return { + id: `diagnosis-${record.slug}`, + slug: record.slug, + title: record.title, + description: record.clinicalHinge, + examples: record.related.slice(0, 3).map((node) => node.label), + href: `/differentials/diagnoses/${record.slug}`, + status: record.status, + matchReasons: match?.reasons ?? [], + isMatch: Boolean(match), + score: match?.score ?? 0, + related: relatedRefs(record, knownSlugs), + exclusionPreview: exclusionPreviewForRecord(record), + chapterId: chapter?.id ?? `status-${record.status}`, + chapterTitle: chapter?.title ?? statusChapterCopy[record.status].title, + }; +} + +function urgencyChapters(items: DifferentialStreamItem[]): DifferentialStreamChapter[] { + return statusChapterOrder + .map((status) => { + const itemIds = items.filter((item) => item.status === status).map((item) => item.id); + return { + id: `status-${status}`, + title: statusChapterCopy[status].title, + description: statusChapterCopy[status].description, + itemIds, + }; + }) + .filter((chapter) => chapter.itemIds.length > 0); +} + +function presentationChapters(items: DifferentialStreamItem[]): DifferentialStreamChapter[] { + const byChapter = new Map(); + const ungrouped: string[] = []; + for (const item of items) { + if (!item.chapterId.startsWith("status-")) { + const existing = byChapter.get(item.chapterId); + if (existing) { + existing.itemIds.push(item.id); + } else { + byChapter.set(item.chapterId, { + id: item.chapterId, + title: item.chapterTitle, + description: "Diagnoses linked to this presentation workflow.", + itemIds: [item.id], + }); + } + } else { + ungrouped.push(item.id); + } + } + const chapters = [...byChapter.values()].sort((left, right) => left.title.localeCompare(right.title)); + if (ungrouped.length > 0) { + chapters.push({ + id: "ungrouped", + title: "Other diagnoses", + description: "Not yet attached to a presentation workflow.", + itemIds: ungrouped, + }); + } + return chapters; +} + +function presetChips(): DifferentialStreamPresetChip[] { + return differentialScenarioPresets() + .slice(0, 7) + .map((preset) => ({ + id: preset.id, + label: preset.query.replace(/\s+/g, " ").trim(), + query: preset.query.trim(), + })) + .filter((preset) => preset.label.length > 0); +} + +/** + * Build the query-lit / browse model for a differentials stream page. + * With a query: relevance-ranked matches first (emergent tie-break already in + * the shared ranker), then dimmed non-matches. Without a query: urgency chapters + * for diagnoses, presentation list for presentations. + */ + +function compareSeedIdsForMatches(matchSlugs: string[]): string[] { + const slugs = matchSlugs.map((slug) => slug.trim().toLowerCase()).filter(Boolean); + if (slugs.length < 2) return slugs.slice(0, 1); + // Prefer the highest-ranked pair that shares one presentation workflow so the + // compare CTA never auto-ticks a set the presentations redirect will trim. + for (let end = 1; end < Math.min(slugs.length, 8); end += 1) { + for (let start = 0; start < end; start += 1) { + const candidate = [slugs[start]!, slugs[end]!]; + const selection = getPresentationWorkflowSelectionForDiagnosisIds(candidate); + if (selection && selection.diagnosisIds.length >= 2) { + return selection.diagnosisIds.slice(0, 2); + } + } + } + return slugs.slice(0, 1); +} + +export function buildDifferentialStreamModel(stream: DifferentialStreamType, query: string): DifferentialStreamModel { + const trimmedQuery = query.trim(); + const hasQuery = Boolean(normalizeSearchText(trimmedQuery)); + const presets = stream === "diagnoses" ? presetChips() : []; + + if (stream === "presentations") { + const presentations = differentialPresentations(); + const ranked = hasQuery ? rankPresentationWorkflows(presentations, trimmedQuery, presentations.length) : []; + const matchById = new Map(ranked.map((match) => [match.workflow.id, match])); + const matchedItems = ranked.map((match) => { + const workflow = match.workflow; + return { + id: `presentation-${workflow.id}`, + slug: workflow.id, + title: workflow.title, + description: workflow.subtitle, + examples: workflow.safetySnapshot.tags.slice(0, 3), + href: `/differentials/presentations/${workflow.id}`, + status: workflow.status, + matchReasons: match.reasons, + isMatch: true, + score: match.score, + related: [] as DifferentialStreamRelatedRef[], + exclusionPreview: null, + chapterId: `status-${workflow.status}`, + chapterTitle: statusChapterCopy[workflow.status].title, + } satisfies DifferentialStreamItem; + }); + const unmatchedItems = presentations + .filter((workflow) => !matchById.has(workflow.id)) + .map( + (workflow) => + ({ + id: `presentation-${workflow.id}`, + slug: workflow.id, + title: workflow.title, + description: workflow.subtitle, + examples: workflow.safetySnapshot.tags.slice(0, 3), + href: `/differentials/presentations/${workflow.id}`, + status: workflow.status, + matchReasons: [], + isMatch: false, + score: 0, + related: [], + exclusionPreview: null, + chapterId: `status-${workflow.status}`, + chapterTitle: statusChapterCopy[workflow.status].title, + }) satisfies DifferentialStreamItem, + ) + .sort((left, right) => left.title.localeCompare(right.title)); + + const items = hasQuery + ? [...matchedItems, ...unmatchedItems] + : [...presentations] + .sort( + (left, right) => + statusChapterOrder.indexOf(left.status) - statusChapterOrder.indexOf(right.status) || + left.title.localeCompare(right.title), + ) + .map((workflow) => ({ + id: `presentation-${workflow.id}`, + slug: workflow.id, + title: workflow.title, + description: workflow.subtitle, + examples: workflow.safetySnapshot.tags.slice(0, 3), + href: `/differentials/presentations/${workflow.id}`, + status: workflow.status, + matchReasons: [], + isMatch: false, + score: 0, + related: [], + exclusionPreview: null, + chapterId: `status-${workflow.status}`, + chapterTitle: statusChapterCopy[workflow.status].title, + })); + + return { + stream, + items, + matchCount: matchedItems.length, + chapters: hasQuery ? [] : urgencyChapters(items), + presentationChapters: [], + presets, + safetyShelfIds: items + .filter((item) => item.status === "emergent") + .slice(0, 6) + .map((item) => item.id), + compareSeedIds: [], + }; + } + + const knownSlugs = new Set(differentialRecords.map((record) => record.slug)); + const ranked = hasQuery ? rankDifferentialRecords(differentialRecords, trimmedQuery, differentialRecords.length) : []; + const matchBySlug = new Map(ranked.map((match) => [match.record.slug, match])); + const matchedItems = ranked.map((match) => + diagnosisItemFromRecord(match.record, knownSlugs, { score: match.score, reasons: match.reasons }), + ); + const unmatchedItems = differentialRecords + .filter((record) => !matchBySlug.has(record.slug)) + .map((record) => diagnosisItemFromRecord(record, knownSlugs)) + .sort((left, right) => left.title.localeCompare(right.title)); + + const browseItems = [...differentialRecords] + .sort( + (left, right) => + statusChapterOrder.indexOf(left.status) - statusChapterOrder.indexOf(right.status) || + left.title.localeCompare(right.title), + ) + .map((record) => diagnosisItemFromRecord(record, knownSlugs)); + + const items = hasQuery ? [...matchedItems, ...unmatchedItems] : browseItems; + + return { + stream, + items, + matchCount: matchedItems.length, + chapters: hasQuery ? [] : urgencyChapters(items), + presentationChapters: hasQuery ? [] : presentationChapters(browseItems), + presets, + safetyShelfIds: hasQuery + ? matchedItems + .filter((item) => item.status === "emergent") + .slice(0, 6) + .map((item) => item.id) + : browseItems + .filter((item) => item.status === "emergent") + .slice(0, 6) + .map((item) => item.id), + compareSeedIds: hasQuery ? compareSeedIdsForMatches(matchedItems.map((item) => item.slug)) : [], + }; +} diff --git a/src/lib/differentials-navigation.ts b/src/lib/differentials-navigation.ts index 11d3357739..115314b3c4 100644 --- a/src/lib/differentials-navigation.ts +++ b/src/lib/differentials-navigation.ts @@ -4,12 +4,19 @@ * clinical-dashboard client bundle stays fixture-free. */ -export function differentialRouteWithQuery(path: string, query: string, selectedIds?: Iterable) { +export function differentialRouteWithQuery( + path: string, + query: string, + selectedIds?: Iterable, + focus?: string, +) { const params = new URLSearchParams(); const trimmedQuery = query.trim(); if (trimmedQuery) params.set("q", trimmedQuery); const ids = selectedIds ? Array.from(selectedIds, (id) => id.trim()).filter(Boolean) : []; if (ids.length > 0) params.set("ids", ids.join(",")); + const trimmedFocus = focus?.trim(); + if (trimmedFocus) params.set("focus", trimmedFocus); const suffix = params.toString(); return suffix ? `${path}?${suffix}` : path; } diff --git a/tests/differential-stream-page.dom.test.tsx b/tests/differential-stream-page.dom.test.tsx index 719acf1106..a4eca5ca4f 100644 --- a/tests/differential-stream-page.dom.test.tsx +++ b/tests/differential-stream-page.dom.test.tsx @@ -1,9 +1,31 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; import { DifferentialStreamPage } from "@/components/differentials/differential-stream-page"; import { differentialPresentationsCards } from "@/lib/differentials"; +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + }), + usePathname: () => "/differentials/presentations", + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + describe("DifferentialStreamPage presentations stream", () => { it("renders the presentations catalogue heading and entry cards", () => { render(); @@ -14,6 +36,6 @@ describe("DifferentialStreamPage presentations stream", () => { const firstCard = differentialPresentationsCards[0]; expect(firstCard).toBeTruthy(); - expect(screen.getByRole("link", { name: new RegExp(firstCard!.title) })).toHaveAttribute("href", firstCard!.href); + expect(screen.getByRole("button", { name: firstCard!.title })).toBeInTheDocument(); }); }); diff --git a/tests/differential-stream.test.ts b/tests/differential-stream.test.ts new file mode 100644 index 0000000000..12b2e5a0c9 --- /dev/null +++ b/tests/differential-stream.test.ts @@ -0,0 +1,113 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { buildDifferentialStreamModel } from "@/lib/differential-stream"; +import { differentialRecords, getDifferentialRecord } from "@/lib/differentials"; +import { differentialRouteWithQuery } from "@/lib/differentials-navigation"; + +describe("differential stream model", () => { + it("ranks and flags matches when a query is present", () => { + const model = buildDifferentialStreamModel("diagnoses", "delirium"); + expect(model.matchCount).toBeGreaterThan(0); + expect(model.items.some((item) => item.isMatch)).toBe(true); + expect(model.items[0]?.isMatch).toBe(true); + expect( + model.items + .filter((item) => item.isMatch) + .every((item, index, list) => { + if (index === 0) return true; + return list[index - 1]!.score >= item.score; + }), + ).toBe(true); + expect(model.chapters).toEqual([]); + expect(model.items.find((item) => item.slug === "delirium")?.matchReasons.length).toBeGreaterThan(0); + }); + + it("keeps non-matches after matches so the stream can dim rather than drop them", () => { + const model = buildDifferentialStreamModel("diagnoses", "delirium"); + const firstNonMatch = model.items.findIndex((item) => !item.isMatch); + expect(firstNonMatch).toBeGreaterThan(0); + expect(model.items.slice(0, firstNonMatch).every((item) => item.isMatch)).toBe(true); + expect(model.items.length).toBe(differentialRecords.length); + }); + + it("ships related refs for cluster highlighting without the full record graph", () => { + const dystonia = getDifferentialRecord("acute-dystonia"); + expect(dystonia).not.toBeNull(); + const model = buildDifferentialStreamModel("diagnoses", "acute dystonia"); + const card = model.items.find((item) => item.slug === "acute-dystonia"); + expect(card?.related.map((node) => node.slug)).toEqual( + expect.arrayContaining(["akathisia", "drug-induced-parkinsonism"]), + ); + }); + + it("groups empty-query browse by urgency and presentation families", () => { + const model = buildDifferentialStreamModel("diagnoses", ""); + expect(model.matchCount).toBe(0); + expect(model.chapters.map((chapter) => chapter.id)).toEqual(["status-emergent", "status-urgent", "status-routine"]); + expect(model.presentationChapters.length).toBeGreaterThan(0); + expect(model.safetyShelfIds.length).toBeGreaterThan(0); + expect(model.presets.length).toBeGreaterThan(0); + const chapterIds = new Set(model.chapters.flatMap((chapter) => chapter.itemIds)); + expect(chapterIds.size).toBe(model.items.length); + }); + + it("exposes exclusion previews from overlap sections when present", () => { + const model = buildDifferentialStreamModel("diagnoses", "delirium"); + const delirium = model.items.find((item) => item.slug === "delirium"); + expect(delirium?.exclusionPreview).toBeTruthy(); + }); + it("auto-seeds compare ticks only for diagnosis pairs that share a presentation workflow", async () => { + const { getPresentationWorkflowSelectionForDiagnosisIds } = await import("@/lib/differentials"); + const model = buildDifferentialStreamModel("diagnoses", "pain"); + expect(model.compareSeedIds.length).toBeGreaterThan(0); + expect(model.compareSeedIds.length).toBeLessThanOrEqual(2); + if (model.compareSeedIds.length === 2) { + const selection = getPresentationWorkflowSelectionForDiagnosisIds(model.compareSeedIds); + expect(selection?.diagnosisIds.length).toBeGreaterThanOrEqual(2); + for (const id of model.compareSeedIds) { + expect(selection?.diagnosisIds).toContain(id); + } + } + }); + + it("treats punctuation-only queries as empty browse mode", () => { + const model = buildDifferentialStreamModel("diagnoses", "!!!"); + expect(model.matchCount).toBe(0); + expect(model.presets.length).toBeGreaterThan(0); + expect(model.chapters.length).toBeGreaterThan(0); + expect(model.compareSeedIds).toEqual([]); + }); +}); + +describe("differential stream navigation helpers", () => { + it("can deep-link a focused diagnosis on the diagnoses stream", () => { + expect(differentialRouteWithQuery("/differentials/diagnoses", "Pain", undefined, "acute-dystonia")).toBe( + "/differentials/diagnoses?q=Pain&focus=acute-dystonia", + ); + }); + + it("keeps the stream workspace client-safe from the snapshot loader", () => { + const workspace = readFileSync( + new URL("../src/components/differentials/differential-stream-workspace.tsx", import.meta.url), + "utf8", + ); + const modelTypes = readFileSync(new URL("../src/lib/differential-stream-model.ts", import.meta.url), "utf8"); + expect(workspace).not.toMatch(/from\s+["']@\/lib\/differentials["']/); + expect(workspace).not.toMatch(/loadDifferentialSnapshot|differentials-snapshot/); + expect(modelTypes).not.toMatch(/from\s+["']@\/lib\/differentials["']/); + expect(modelTypes).not.toMatch(/loadDifferentialSnapshot/); + }); +}); + +describe("differential stream compare CTA contracts", () => { + it("requires two selections before enabling the mobile compare link", () => { + const workspace = readFileSync( + new URL("../src/components/differentials/differential-stream-workspace.tsx", import.meta.url), + "utf8", + ); + expect(workspace).toContain("const canCompare = selectedCount >= 2"); + expect(workspace).toContain("normalizeSearchText(query)"); + expect(workspace).toContain("model.compareSeedIds"); + }); +}); diff --git a/tests/differentials-navigation.test.ts b/tests/differentials-navigation.test.ts index d57325d7b1..e5ab789462 100644 --- a/tests/differentials-navigation.test.ts +++ b/tests/differentials-navigation.test.ts @@ -68,4 +68,15 @@ describe("differentials navigation", () => { ), ).toBe("?q=Pain&run=1&ids=medical-gi-endocrine-painful-organic-cause%2Cbpsd-as-unmet-need-delirium-pain-mimic"); }); + + it("preserves every selected diagnosis id across the presentations redirect", () => { + const handoff = resolveDifferentialCompareHandoff( + ["anorexia-nervosa", "bulimia-nervosa-binge-purge-pattern"], + "pain", + ); + + expect(handoff.kind).toBe("presentation"); + expect(handoff.href).toContain("anorexia-nervosa"); + expect(handoff.href).toContain("bulimia-nervosa-binge-purge-pattern"); + }); }); diff --git a/tests/mobile-interaction-regressions.test.ts b/tests/mobile-interaction-regressions.test.ts index 2f57af9fa8..4bb0b0627a 100644 --- a/tests/mobile-interaction-regressions.test.ts +++ b/tests/mobile-interaction-regressions.test.ts @@ -13,11 +13,12 @@ describe("mobile interaction regressions", () => { it("keys diagnosis cards by their unique stable identity", () => { const ids = differentialDiagnosesCards.map((card) => card.id); const titles = differentialDiagnosesCards.map((card) => card.title); - const streamSource = source("src/components/differentials/differential-stream-page.tsx"); + const streamSource = source("src/components/differentials/differential-stream-workspace.tsx"); expect(new Set(ids).size).toBe(ids.length); expect(new Set(titles).size).toBeLessThan(titles.length); - expect(streamSource).toContain("key={card.id}"); + expect(streamSource).toContain("key={item.id}"); + expect(streamSource).not.toContain("key={item.title}"); expect(streamSource).not.toContain("key={card.title}"); });