diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 700f44c1ce..0836fbd32a 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,25 +744,42 @@ 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 | 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/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 | 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/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 | 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/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 | +| 2026-08-08 | cursor/confirm-checklist-polish-195c | 32474bcd20d5fa39097a3f75b85d3af81b404320 | form-detail Confirm checklist polish | shipped spacing/typography polish + DOM guard | vitest form-confirm-callout.dom; visual Form 1A Confirm | +| 2026-08-08 | cursor/confirm-checklist-polish-195c | 3cf0ed99a1a90f46cb7c6aff7e8b7f7bfd6212b8 | form-detail Confirm checklist polish | shipped spacing/typography polish + DOM guard | vitest form-confirm-callout.dom; visual Form 1A Confirm | +| 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 | | 2026-08-08 | claude/document-viewer-optimization-tu8tnj (PR #1754) | 41b4bccf7d7229920c33344bec0d46e0f2e48b97 | heavy review-and-fix | CONFLICT merge-tree on docs/outstanding-issues.md resolved: kept main #285 (Node/jsdom floor) + renumbered authorizationHeader trap to #286, next-id=287; merged origin/main; 1 unresolved review thread (comments 403 — skipped); no product code change | check:outstanding-issues PASS (284 rows, next-id=287); prettier --check docs/outstanding-issues.md PASS; ledger:dedupe none; no provider-backed checks | @@ -788,20 +803,8 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-08 | codex/lighthouse-hardening (PR #1746) | d4af6a6356de3327906936a237405e4e7ccba0cb | Run PR sweep: CI fix + threads + drift | DIRTY + 3 Codex P2 + Static contract fail → main sync; process-group kill; suite deadline; normalized samples; contract test updated; threads unreplied (API 403) | vitest live-web-vitals-inputs+check-lighthouse-budget 61; ci-cache-safety 32; no provider-backed checks | | 2026-08-08 | cursor/phone-mode-dense-production-05c0 (PR #1648) | d2a0c8b12bcf3ea6b56c3d8291e4aa09da3e603e | Run PR sweep: CI fix + threads + drift | post-merge Unit coverage: restored usesPhoneSearchLayout min-h ternary for audit-navigation contract; CI re-running | vitest audit-navigation+mode-nav+header-scroll-hide 66 passed; no provider-backed checks | | 2026-08-08 | cursor/site-testing-speed-08c1 (PR #1686) | 11e35727a6a9b3b776e890e6a63e43fea9b0a437 | Run PR sweep: CI fix + threads + drift | post-merge Unit coverage: added ui-phone-scroll-document-rail.spec.ts to playwright PR shard 1; CI re-running | vitest playwright-pr-shards 4 passed; playwright-pr-shards --validate OK; 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 | origin/main (PR #1722 follow-up) | eda8fe872de040e304621bce49535e1dfebb091e | Lighthouse testing thorough review | P1 fixed locally: stale Chrome 150 baseline, unbounded runner phases, and live input/process limits hardened; advisory policy retained | offline review; check:ci-scope PASS; test:ci-workflows 13 files 248 passed 11 skipped; check:github-actions PASS; verify:lighthouse dry-run PASS; no provider-backed checks run | | 2026-08-08 | codex/lighthouse-hardening (PR #1746) | 038058ea63837e7c4a90e84b7f8f860aacc51e13 | heavy review-and-fix | CONFLICT merge-tree on lighthouse-budget.json resolved: kept main baseline measurements + this PR Lighthouse hardening (scripts/workflows/ci-change-scope); visual-baseline policy matches main; 3 unresolved threads (comments 403 — skipped) | ci-change-scope --self-test PASS; vitest check-lighthouse-budget+ci-cache-safety 84/84 PASS; no provider-backed checks | -| 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 | -| 2026-08-08 | cursor/confirm-checklist-polish-195c | 32474bcd20d5fa39097a3f75b85d3af81b404320 | form-detail Confirm checklist polish | shipped spacing/typography polish + DOM guard | vitest form-confirm-callout.dom; visual Form 1A Confirm | -| 2026-08-08 | cursor/confirm-checklist-polish-195c | 3cf0ed99a1a90f46cb7c6aff7e8b7f7bfd6212b8 | form-detail Confirm checklist polish | shipped spacing/typography polish + DOM guard | vitest form-confirm-callout.dom; visual Form 1A Confirm | -| 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 | cursor/presentations-catalogue-tab-fb39 | 3872ea0854da2ce4e3b99ec182bb94a4cb807958 | differentials presentations catalogue ModeNav tab | shipped Presentations catalogue at /differentials/presentations; Compare entry moved to /differentials/compare; verify:pr-local passed; UI smoke confirmed 4 tabs | verify:pr-local; vitest design-system-adoption; curl presentations+compare; browser ModeNav QA | | 2026-08-08 | cursor/presentations-catalogue-tab-fb39 | 59dceae612315e95a1114a215d2d8319e439880d | differentials presentations catalogue ModeNav tab | shipped Presentations catalogue at /differentials/presentations; Compare entry moved to /differentials/compare; verify:pr-local passed; UI smoke confirmed 4 tabs | verify:pr-local; vitest design-system-adoption; curl presentations+compare; browser ModeNav QA | | 2026-08-08 | cursor/forms-results-bar-documents-style-13dc | fd148ca931ab9c023619deed98bae5af787e4253 | sync | PR #1751 unblock: CONFLICTING→MERGEABLE; 11 behind main; merge-tree clean; merge commit fd148ca9 pushed; all static+unit+build+critical CI green; Production UI (1/2/3) pending | merge-tree:clean,static-pr:pass,unit-coverage:pass,build:pass,production-ui-critical:pass,safety:pass,pr-policy:pass,production-ui-1/2/3:pending | diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 6372413972..f52464424f 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -328,7 +328,7 @@ One shared composer (`master-search-header.tsx`) serves every mode. Placement: - **Information (detail) pages**: catalogue/record routes under each mode (`/services/[slug]`, `/forms/[slug]`, `/medications/[slug]`, `/specifiers/[slug]`, `/formulation/[slug]`, `/factsheets/[slug]`, `/dsm/diagnoses/[slug]`, …). Route detection: `src/lib/information-pages.ts` (`isInformationPage`). Shared outer chrome: `src/components/information-page-shell.tsx` (`InformationPageShell`, breadcrumbs, optional footer). Specifier/formulation mode shells re-export that primitive. Intentional opt-outs: document viewer, therapy-compass CSS workspace, differentials presentation workflow. - **Result and detail views**: fixed bottom dock on phone (compact variant on submitted searches), sticky top from `sm` up. - **Results routing**: standalone routes own their submitted searches via `?q=…&run=1` (`/services` → `ServicesNavigatorPage`, `/forms` → `FormsSearchResultsPage`, `/differentials` → `DifferentialsHome` results view, `/formulation` → local mechanism results, `/favourites` filters the command library in place). Answer, Documents, and Prescribing submitted searches render inside `ClinicalDashboard` — intentional, since they need retrieval/answer state. Bare `/?mode=` always renders the shared home with that mode preselected; only a submitted deep link (`q` plus `run=1`) resolves to the mode's own search surface (proxy early-redirect still covers favourites/differentials/specifiers for those submitted aliases). -- **Intentionally composer-free routes**: `/differentials/presentations/*` (comparison workflow owns its chrome), `/documents/[id]` viewer (has its own in-document ask composer), `/documents/source/*` (document flow owns mobile chrome). Do not re-flag these in search-consistency audits. +- **Intentionally composer-free routes**: `/differentials/presentations/*` and `/differentials/compare` (comparison workflow owns its chrome), `/documents/[id]` viewer (has its own in-document ask composer), `/documents/source/*` (document flow owns mobile chrome). Do not re-flag these in search-consistency audits. - **Shared secondary navigation**: `src/components/secondary-navigation.tsx` (`SecondaryNavigation`, route/section/action items, roving tablist, fragment section tracking) and `src/components/page-secondary-navigation.tsx` (`PageSecondaryNavigation`, per-route selection of mode destinations vs. "On this page" section anchors). Mode destinations come from `src/lib/mode-secondary-navigation.ts` (`modeSecondaryNavigationRegistry`, no "Home" item). `GlobalSearchShell` renders it in normal flow at the top of `#main-content` for its owned namespaced modes; it self-suppresses on clean mode homes, locally-owned detail routes (medications, factsheets, differentials diagnoses) and Therapy Compass, and Specifiers/Formulation keep their existing local `Subnav` (so the shared mode bar is skipped for those two modes to avoid a duplicate row). - **Local filter fields** (sidebar "Search chats", document drawer "Find a document"/"Find a source PDF") are scoped filters, not global search; they share the `fieldControlWithIcon`/`fieldIcon` primitives. - **Wiring conventions** for buttons and route navigation (and the gates that enforce them — the dead-button ESLint rule and the orphan-route reachability test) live in `docs/wiring-conventions.md`. diff --git a/docs/design-system/ADOPTION.md b/docs/design-system/ADOPTION.md index 63038c9f8a..eee8fc5069 100644 --- a/docs/design-system/ADOPTION.md +++ b/docs/design-system/ADOPTION.md @@ -327,12 +327,12 @@ product exclusions; the only route-only disposition is the documented legacy doc redirect. Shared shell/component roots carry their own explicit `shared-shell` disposition. Registered public components: 53 -Declared product roots: 58 +Declared product roots: 59 Roots with a literal `.ckb-v2` opt-in: 1 -Roots inheriting `.ckb-v2` from the global ``: 57 +Roots inheriting `.ckb-v2` from the global ``: 58 Production surfaces observed under v2: 14/14 Dynamic `ckb-v2` constructions: 0 -Declared production page routes: 50/50 +Declared production page routes: 51/51 Source observation and contract declaration are independent. A literal `ckb-v2` on the global `` makes every production surface inherit v2, but it does not approve that adoption. The Proof column summarizes each surface's dark, forced-colours, 320px, print and browser declarations; exact statuses and evidence paths live in the manifest. @@ -342,7 +342,7 @@ Observed v2 under a compatibility declaration fails closed. A declared v2 shell | ---------------------------------- | --------------- | -----: | ----: | -------------- | -------------------------- | -------------- | -------------- | | `root-shell-and-settings` | shared-shell | 3 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | | `catalogues-forms-and-info` | owned | 13 | 13 | v2 | v2 (inherited-global-root) | passed | not-committed | -| `differentials` | owned | 5 | 5 | v2 | v2 (inherited-global-root) | passed | not-committed | +| `differentials` | owned | 6 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | | `formulation` | owned | 5 | 5 | v2 | v2 (inherited-global-root) | passed | not-committed | | `specifiers` | owned | 5 | 5 | v2 | v2 (inherited-global-root) | passed | not-committed | | `therapy-compass` | owned | 9 | 10 | v2 | v2 (inherited-global-root) | passed | not-committed | diff --git a/docs/design-system/adoption-contract.json b/docs/design-system/adoption-contract.json index c91bc1e594..c0ee72000d 100644 --- a/docs/design-system/adoption-contract.json +++ b/docs/design-system/adoption-contract.json @@ -168,6 +168,7 @@ "id": "differentials", "disposition": "owned", "routes": [ + "src/app/(search-app)/differentials/compare/page.tsx", "src/app/(search-app)/differentials/diagnoses/[slug]/page.tsx", "src/app/(search-app)/differentials/diagnoses/page.tsx", "src/app/(search-app)/differentials/page.tsx", diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 49bc0d847a..dbfd5d14a5 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -2205,6 +2205,7 @@ "disposition": "owned", "documentedDisposition": null, "routes": [ + "src/app/(search-app)/differentials/compare/page.tsx", "src/app/(search-app)/differentials/diagnoses/[slug]/page.tsx", "src/app/(search-app)/differentials/diagnoses/page.tsx", "src/app/(search-app)/differentials/page.tsx", @@ -2249,6 +2250,16 @@ "permittedComponentFamilies": ["controls", "feedback", "layout", "source"], "sanctionedSpecialPatterns": ["Differential"], "roots": [ + { + "file": "src/app/(search-app)/differentials/compare/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["Differential"] + }, { "file": "src/app/(search-app)/differentials/diagnoses/[slug]/page.tsx", "exists": true, @@ -3182,6 +3193,7 @@ "routeCoverage": { "discovered": [ "src/app/(search-app)/calculators/page.tsx", + "src/app/(search-app)/differentials/compare/page.tsx", "src/app/(search-app)/differentials/diagnoses/[slug]/page.tsx", "src/app/(search-app)/differentials/diagnoses/page.tsx", "src/app/(search-app)/differentials/page.tsx", @@ -3234,6 +3246,7 @@ ], "declared": [ "src/app/(search-app)/calculators/page.tsx", + "src/app/(search-app)/differentials/compare/page.tsx", "src/app/(search-app)/differentials/diagnoses/[slug]/page.tsx", "src/app/(search-app)/differentials/diagnoses/page.tsx", "src/app/(search-app)/differentials/page.tsx", @@ -3290,7 +3303,7 @@ }, "adoption": { "literalCkbV2RootCount": 1, - "inheritedCkbV2RootCount": 57, + "inheritedCkbV2RootCount": 58, "dynamicCkbV2RootCount": 0, "v2MountedSurfaceCount": 14, "declaredV2SurfaceCount": 14 @@ -3299,8 +3312,8 @@ "registeredComponentCount": 53, "previewCount": 53, "productImportedComponentCount": 31, - "rootCount": 58, - "productionRouteCount": 50, - "nextUiEntryCount": 102 + "rootCount": 59, + "productionRouteCount": 51, + "nextUiEntryCount": 103 } } diff --git a/docs/site-map.md b/docs/site-map.md index 76223d3cbe..556ab35cba 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -7,6 +7,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/` - Main Clinical KB shell. Source: `src/app/(search-app)/page.tsx`. - `/calculators` - Route discovered from app directory Source: `src/app/(search-app)/calculators/page.tsx`. - `/differentials` - Differentials home and search surface. Source: `src/app/(search-app)/differentials/page.tsx`. +- `/differentials/compare` - Compare entry: same-presentation selections redirect into a catalogue workflow; cross-presentation selections render an ad-hoc comparison. Source: `src/app/(search-app)/differentials/compare/page.tsx`. - `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/(search-app)/differentials/diagnoses/page.tsx`. - `/differentials/presentations` - Presentation catalogue stream. Source: `src/app/(search-app)/differentials/presentations/page.tsx`. - `/differentials/presentations/[slug]` - Presentation comparison workflow. Source: `src/app/(search-app)/differentials/presentations/[slug]/page.tsx`. @@ -1101,7 +1102,6 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir ## Redirects - `/applications` - Redirects to `/tools`. Source: `src/app/applications/route.ts`. -- `/differentials/compare` - Redirects to `/differentials/presentations/[workflow-slug]`. Source: `src/app/(search-app)/differentials/compare/route.ts`. - `/documents/source` - Redirects to `/documents/[id]`. Source: `src/app/(search-app)/documents/source/page.tsx`. - `/mockups/favourites-hub` - Redirects to `/favourites`. Source: `src/app/mockups/favourites-hub/page.tsx`. - `/mockups/medication-prescribing` - Redirects to `/medications/acamprosate`. Source: `src/app/mockups/medication-prescribing/page.tsx`. diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index a44ac2a5c0..ad3f01f19f 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -38,11 +38,10 @@ type SiteMapData = { nonRoutedMockupArtifacts: string[]; }; -const productRouteHandlerPaths = new Set(["/applications", "/differentials/compare"]); +const productRouteHandlerPaths = new Set(["/applications"]); const documentedRedirectTargets: Record = { "/applications": "/tools", - "/differentials/compare": "/differentials/presentations/[workflow-slug]", // The source page redirects a valid id to the canonical `/documents/[id]` viewer // (page.tsx line 20) and only falls back to `/documents/search` for an invalid id // (line 14). Pin the canonical target here so the generated map does not report the @@ -54,11 +53,12 @@ const routeDescriptions: Record = { "/": "Main Clinical KB shell.", "/applications": "Legacy application launcher redirect to Tools.", "/differentials": "Differentials home and search surface.", + "/differentials/compare": + "Compare entry: same-presentation selections redirect into a catalogue workflow; cross-presentation selections render an ad-hoc comparison.", "/differentials/diagnoses": "Diagnosis stream.", "/differentials/diagnoses/[slug]": "Differential diagnosis detail.", "/differentials/presentations": "Presentation catalogue stream.", "/differentials/presentations/[slug]": "Presentation comparison workflow.", - "/differentials/compare": "Compare entry redirect into a presentation workflow.", "/dsm": "DSM-5 Diagnosis home.", "/dsm/search": "DSM diagnosis search and catalogue browser.", "/dsm/compare": "DSM diagnosis comparison.", diff --git a/src/app/(search-app)/differentials/compare/page.tsx b/src/app/(search-app)/differentials/compare/page.tsx new file mode 100644 index 0000000000..e30783cc33 --- /dev/null +++ b/src/app/(search-app)/differentials/compare/page.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { DifferentialPresentationWorkflowPage } from "@/components/differentials/differential-presentation-workflow-page"; +import { resolveDifferentialCompareHandoff } from "@/lib/differentials"; + +export const metadata: Metadata = { + title: "Compare differentials | Clinical KB", + description: "Compare selected differential diagnoses side by side with safety and bedside context.", +}; + +type DifferentialCompareRouteProps = { + searchParams?: Promise<{ query?: string | string[]; q?: string | string[]; ids?: string | string[] }>; +}; + +function firstSearchParam(value?: string | string[]) { + return Array.isArray(value) ? value[0] : value; +} + +/** + * Compare entry page. + * + * Same-presentation selections (and bare/unknown ids) redirect into a catalogue + * presentation workflow. Cross-presentation selections render an ad-hoc compare + * view here so every valid id is preserved. A competing `route.ts` at this path + * is invalid in the App Router — handoff lives in the page instead. + */ +export default async function DifferentialCompareRoute({ searchParams }: DifferentialCompareRouteProps) { + const resolvedSearchParams = searchParams ? await searchParams : {}; + const query = firstSearchParam(resolvedSearchParams.query ?? resolvedSearchParams.q)?.trim() ?? ""; + const selectedIds = (firstSearchParam(resolvedSearchParams.ids) ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + + const handoff = resolveDifferentialCompareHandoff(selectedIds, query); + if (handoff.kind === "presentation") { + redirect(handoff.href); + } + + return ( + + ); +} diff --git a/src/app/(search-app)/differentials/compare/route.ts b/src/app/(search-app)/differentials/compare/route.ts deleted file mode 100644 index 1c6c7da65d..0000000000 --- a/src/app/(search-app)/differentials/compare/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { getPresentationWorkflowSelectionForDiagnosisIds } from "@/lib/differentials"; - -function compareRedirectLocation(request: NextRequest) { - const query = (request.nextUrl.searchParams.get("query") ?? request.nextUrl.searchParams.get("q"))?.trim(); - const selectedIds = (request.nextUrl.searchParams.get("ids") ?? "") - .split(",") - .map((id) => id.trim()) - .filter(Boolean); - const selection = getPresentationWorkflowSelectionForDiagnosisIds(selectedIds); - const params = new URLSearchParams(); - if (query) params.set("q", query); - if (selection?.diagnosisIds.length) params.set("ids", selection.diagnosisIds.join(",")); - const pathname = `/differentials/presentations/${selection?.workflow.id ?? "acute-confusion-encephalopathy"}`; - const suffix = params.toString(); - // Relative Location so redirects stay same-origin in the browser even when - // the server request URL uses a bind address like 0.0.0.0. - return suffix ? `${pathname}?${suffix}` : pathname; -} - -export function GET(request: NextRequest) { - return new NextResponse(null, { - status: 307, - headers: { - Location: compareRedirectLocation(request), - }, - }); -} - -export const HEAD = GET; diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 9d008b755d..1bf2753357 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -36,7 +36,12 @@ import { useResultSort } from "@/components/use-result-sort"; import { Chip as DesignChip } from "@/components/ui/chip"; import { cn } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; -import { differentialRouteWithQuery, differentialSelectedCompareHref } from "@/lib/differentials-navigation"; +import { + differentialIdsFromSearchParams, + differentialRouteWithQuery, + differentialSelectedCompareHref, + syncDifferentialSelectionIdsToUrl, +} from "@/lib/differentials-navigation"; import { differentialsMobileCompareAddonSlotId } from "@/lib/mode-home-composer"; import { composeDifferentialSearchResults, @@ -819,6 +824,12 @@ function SearchResultsView({ const filterPanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); const [selectedIds, setSelectedIds] = useState>(() => new Set()); + // Capture cold-load URL ids in state so a loading catalogue cannot wipe `ids` + // via replaceState before the first result set hydrates selection. + const [initialUrlIds] = useState(() => + typeof window === "undefined" ? [] : differentialIdsFromSearchParams(window.location.search), + ); + const [urlHydrationPending, setUrlHydrationPending] = useState(() => initialUrlIds.length > 0); // Selection, filter, and sort follow the ranked result set: seed the top two // for comparison and drop stale ids whenever a new query changes the results // (render-time sync, matching the repo's set-state-in-render pattern). @@ -827,14 +838,16 @@ function SearchResultsView({ if (lastResultSignature !== resultSignature) { setLastResultSignature(resultSignature); setKindFilter("all"); - setSelectedIds( - new Set( - results - .filter((result) => result.kind === "diagnosis") - .slice(0, 2) - .map((result) => result.id), - ), - ); + const diagnosisIds = results.filter((result) => result.kind === "diagnosis").map((result) => result.id); + const diagnosisIdSet = new Set(diagnosisIds); + // First result set may hydrate shareable URL ids; later query changes always + // re-seed so a new scope never silently inherits the previous ticks. + const urlIds = urlHydrationPending ? initialUrlIds.filter((id) => diagnosisIdSet.has(id)) : []; + const nextIds = lastResultSignature === "" && urlIds.length > 0 ? urlIds : diagnosisIds.slice(0, 2); + if (urlHydrationPending && lastResultSignature === "" && resultSignature !== "") { + setUrlHydrationPending(false); + } + setSelectedIds(new Set(nextIds)); } const presentationCount = results.filter((result) => result.kind === "presentation").length; @@ -861,6 +874,16 @@ function SearchResultsView({ [results, selectedIds], ); const selectedCount = comparisonIds.size; + const comparisonIdsKey = Array.from(comparisonIds).join(","); + + // Publish selection into the URL so ModeNav Compare can forward the same ids. + // Defer while the catalogue is still loading so a cold submitted-search load + // does not delete bookmarked `ids` before matches arrive for hydration. + useEffect(() => { + if (typeof window === "undefined") return; + if (catalog.status === "loading") return; + syncDifferentialSelectionIdsToUrl(comparisonIdsKey ? comparisonIdsKey.split(",") : []); + }, [catalog.status, comparisonIdsKey]); // Catalogue results follow composer edits live, but document evidence only // updates on an executed source search — treat evidence fetched for a // different query as pending so the two panels never claim to be in sync. diff --git a/src/components/differentials/differential-presentation-workflow-page.tsx b/src/components/differentials/differential-presentation-workflow-page.tsx index 1765dd7af1..ec2757e0c4 100644 --- a/src/components/differentials/differential-presentation-workflow-page.tsx +++ b/src/components/differentials/differential-presentation-workflow-page.tsx @@ -21,6 +21,7 @@ import { CopyAfterReviewButton } from "@/components/differentials/differential-p import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-footer-layer-portal"; import { cn } from "@/components/ui-primitives"; import { + AD_HOC_DIFFERENTIAL_COMPARE_ID, acuteConfusionPresentationWorkflow, getDifferentialRecord, getPresentationWorkflow, @@ -29,6 +30,7 @@ import { type DifferentialRecord, type DifferentialSection, } from "@/lib/differentials"; +import { differentialRouteWithQuery } from "@/lib/differentials-navigation"; type CandidateView = { record: DifferentialRecord; @@ -592,12 +594,24 @@ function MobileComparison({ ); } -function MobileTabs({ workflow }: { workflow: DifferentialPresentationWorkflow }) { +function MobileTabs({ + workflow, + query, + selectedIds, +}: { + workflow: DifferentialPresentationWorkflow; + query: string; + selectedIds: string[]; +}) { const firstCandidate = workflow.candidates[0]?.slug ?? "delirium"; const diagnosisBase = `/differentials/diagnoses/${firstCandidate}`; + const compareHref = + workflow.id === AD_HOC_DIFFERENTIAL_COMPARE_ID + ? differentialRouteWithQuery("/differentials/compare", query, selectedIds) + : differentialRouteWithQuery(`/differentials/presentations/${workflow.id}`, query, selectedIds); const tabs = [ { label: "Overview", href: diagnosisBase }, - { label: "Compare", href: `/differentials/presentations/${workflow.id}` }, + { label: "Compare", href: compareHref }, { label: "Map", href: `${diagnosisBase}?tab=map` }, { label: "Related", href: `${diagnosisBase}?tab=related` }, ] as const; @@ -633,24 +647,32 @@ export function DifferentialPresentationWorkflowPage({ query = "", presentationSlug = "acute-confusion-encephalopathy", selectedIds = [], + workflow: workflowOverride, }: { query?: string; presentationSlug?: string; selectedIds?: string[]; + /** Prebuilt workflow (ad-hoc or already resolved). When set, skips catalogue slug lookup. */ + workflow?: DifferentialPresentationWorkflow; }) { - const baseWorkflow = getPresentationWorkflow(presentationSlug) ?? acuteConfusionPresentationWorkflow; - const requestedIds = new Set(selectedIds); - const workflow = requestedIds.size - ? (() => { - let selectedCount = 0; - const candidates = baseWorkflow.candidates.map((candidate) => { - const selected = requestedIds.has(candidate.slug); - if (selected) selectedCount += 1; - return { ...candidate, selected }; - }); - return { ...baseWorkflow, candidates, selectedCount }; - })() - : baseWorkflow; + const baseWorkflow = + workflowOverride ?? getPresentationWorkflow(presentationSlug) ?? acuteConfusionPresentationWorkflow; + const requestedIds = new Set(selectedIds.map((id) => id.trim().toLowerCase()).filter(Boolean)); + // Ad-hoc workflows already encode selection; overlay only applies to catalogue presentations. + const workflow = + workflowOverride?.id === AD_HOC_DIFFERENTIAL_COMPARE_ID + ? workflowOverride + : requestedIds.size + ? (() => { + let selectedCount = 0; + const candidates = baseWorkflow.candidates.map((candidate) => { + const selected = requestedIds.has(candidate.slug); + if (selected) selectedCount += 1; + return { ...candidate, selected }; + }); + return { ...baseWorkflow, candidates, selectedCount }; + })() + : baseWorkflow; const candidates = getCandidates(workflow); return ( @@ -741,7 +763,7 @@ export function DifferentialPresentationWorkflowPage({ - + {/* Tablet / mid (md–lg): safety leads, then the scrollable table, then the review panels reflow into a grid below — no fixed side rail. */}
diff --git a/src/lib/differentials-navigation.ts b/src/lib/differentials-navigation.ts index 9bf6b64ee6..11d3357739 100644 --- a/src/lib/differentials-navigation.ts +++ b/src/lib/differentials-navigation.ts @@ -15,10 +15,56 @@ export function differentialRouteWithQuery(path: string, query: string, selected } /** - * Compare-selected CTA href. Resolves the presentation workflow on the server - * via `/differentials/compare` (see compare/route.ts) so the client never loads - * the differentials snapshot just to build a link. + * Compare-selected CTA href. The compare page resolves presentation vs ad-hoc + * workflows on the server (see compare/page.tsx) so the client never loads the + * differentials snapshot just to build a link. Legacy `/differentials/presentations?ids=` + * still redirects when hit directly. */ export function differentialSelectedCompareHref(query: string, selectedIds: Iterable) { return differentialRouteWithQuery("/differentials/compare", query, selectedIds); } + +/** Parse `ids` from a query string (comma-separated diagnosis slugs). */ +export function differentialIdsFromSearchParams(searchParams: URLSearchParams | string): string[] { + const params = typeof searchParams === "string" ? new URLSearchParams(searchParams) : searchParams; + const seen = new Set(); + const ids: string[] = []; + for (const raw of (params.get("ids") ?? "").split(",")) { + // Lowercase to match server-side `normalizeRequestedDiagnosisIds` and catalogue slugs. + const id = raw.trim().toLowerCase(); + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; +} + +/** Pure helper: next `?…` suffix (including leading `?`) after writing `ids`. */ +export function differentialSelectionIdsSearch(selectedIds: Iterable, currentSearch: string): string { + const params = new URLSearchParams(currentSearch.startsWith("?") ? currentSearch.slice(1) : currentSearch); + const nextIds = Array.from(selectedIds, (id) => id.trim()).filter(Boolean); + const current = params.get("ids") ?? ""; + const next = nextIds.join(","); + if (current === next) { + const existing = params.toString(); + return existing ? `?${existing}` : ""; + } + if (next) params.set("ids", next); + else params.delete("ids"); + const query = params.toString(); + return query ? `?${query}` : ""; +} + +/** + * Keep the current URL's `ids` param aligned with the active compare selection + * without a Next navigation (ModeNav reads this on Compare handoff). + */ +export function syncDifferentialSelectionIdsToUrl( + selectedIds: Iterable, + location: Pick = window.location, +) { + const nextSearch = differentialSelectionIdsSearch(selectedIds, location.search); + const currentSearch = location.search.startsWith("?") || !location.search ? location.search : `?${location.search}`; + if (currentSearch === nextSearch) return; + window.history.replaceState(null, "", `${location.pathname}${nextSearch}${location.hash}`); +} diff --git a/src/lib/differentials.ts b/src/lib/differentials.ts index 49750c0375..a35592295f 100644 --- a/src/lib/differentials.ts +++ b/src/lib/differentials.ts @@ -86,33 +86,232 @@ export function getPresentationWorkflow(slug: string | null | undefined) { return differentialPresentations().find((presentation) => presentation.id === normalizedSlug) ?? null; } +/** Reserved workflow id for cross-presentation compare selections. */ +export const AD_HOC_DIFFERENTIAL_COMPARE_ID = "selected-differentials"; + +const AD_HOC_COMPARE_CRITERIA: DifferentialComparisonCriterion[] = [ + { id: "why-it-fits", title: "Why it fits", tone: "fit" }, + // Catalogue diagnoses do not ship a `what-argues-against` section; omit it so + // ad-hoc compare rows never render an all-placeholder criterion. + { id: "must-not-miss", title: "Must-not-miss", tone: "warning" }, + { id: "bedside-question", title: "Bedside question", tone: "question" }, + { id: "immediate-action", title: "Immediate action", tone: "action" }, + { id: "investigations", title: "Investigations", tone: "test" }, + { id: "mimics-overlap", title: "Mimics / overlap", tone: "overlap" }, +]; + +export type DifferentialCompareSelection = { + kind: "presentation" | "ad-hoc"; + workflow: DifferentialPresentationWorkflow; + diagnosisIds: string[]; +}; + +function normalizeRequestedDiagnosisIds(ids: Iterable): string[] { + const seen = new Set(); + const diagnosisIds: string[] = []; + for (const id of ids) { + const normalized = id.trim().toLowerCase(); + if (!normalized || seen.has(normalized)) continue; + // Drop unknown slugs so redirects never advertise IDs the catalogue cannot + // compare. Presentation membership is decided only among real records. + if (!getDifferentialRecord(normalized)) continue; + seen.add(normalized); + diagnosisIds.push(normalized); + } + return diagnosisIds; +} + +function presentationContainsAllDiagnosisIds( + presentation: DifferentialPresentationWorkflow, + diagnosisIds: readonly string[], +) { + if (!diagnosisIds.length) return false; + const candidateIds = new Set(presentation.candidates.map((candidate) => candidate.slug)); + return diagnosisIds.every((id) => candidateIds.has(id)); +} + +function comparisonCellFromSection(section: DifferentialSection | undefined) { + if (!section) return "Review locally."; + const summary = section.summary.trim(); + if (summary) return summary; + const items = section.items.map((item) => item.trim()).filter(Boolean); + return items.length ? items.join("; ") : "Review locally."; +} + +/** Build a presentation-shaped workflow from diagnosis records so selections + * that span multiple catalogue presentations still compare side by side. */ +export function buildAdHocPresentationWorkflow(ids: Iterable): DifferentialPresentationWorkflow | null { + const diagnosisIds = normalizeRequestedDiagnosisIds(ids); + if (!diagnosisIds.length) return null; + + const records = diagnosisIds + .map((id) => getDifferentialRecord(id)) + .filter((record): record is DifferentialRecord => Boolean(record)); + if (!records.length) return null; + + const status = records.some((record) => record.status === "emergent") + ? "emergent" + : records.some((record) => record.status === "urgent") + ? "urgent" + : "routine"; + + const candidates: DifferentialComparisonCandidate[] = records.map((record) => { + const sectionsById = new Map(record.sections.map((section) => [section.id, section])); + const comparison: Record = {}; + for (const criterion of AD_HOC_COMPARE_CRITERIA) { + if (criterion.id === "investigations" && !sectionsById.has("investigations")) { + comparison[criterion.id] = record.investigations.filter(Boolean).join("; ") || "Review locally."; + continue; + } + if (criterion.id === "immediate-action" && !sectionsById.has("immediate-action")) { + comparison[criterion.id] = record.immediateActions.filter(Boolean).join("; ") || "Review locally."; + continue; + } + comparison[criterion.id] = comparisonCellFromSection(sectionsById.get(criterion.id)); + } + return { slug: record.slug, selected: true, comparison }; + }); + + const safetyTags = Array.from( + new Set(records.flatMap((record) => record.safetySnapshot.tags.map((tag) => tag.trim()).filter(Boolean))), + ).slice(0, 6); + + return { + id: AD_HOC_DIFFERENTIAL_COMPARE_ID, + title: "Selected differentials", + status, + subtitle: "Side-by-side comparison of the diagnoses you selected from search.", + selectedCount: candidates.length, + totalCount: candidates.length, + safetySnapshot: { + summary: + records + .map((record) => record.safetySnapshot.summary.trim()) + .filter(Boolean) + .join(" ") || "Review safety notes for each selected diagnosis.", + tags: safetyTags, + }, + criteria: AD_HOC_COMPARE_CRITERIA, + candidates, + reviewChecklist: [ + "Confirm each selected diagnosis still fits the current presentation", + "Review must-not-miss risks across the selected set", + "Choose immediate actions and investigations that discriminate", + "Document and handoff the working differential", + ], + highestUrgencyNote: + status === "emergent" + ? "At least one selected diagnosis is emergent. Act early." + : status === "urgent" + ? "At least one selected diagnosis is urgent. Prioritise safety." + : "Review selected differentials before acting.", + sourceStatus: { + label: "Selected from catalogue", + version: catalog().governance.version || "Local content only", + lastUpdated: catalog().exportedAt || "Pending review", + }, + }; +} + +/** + * Prefer a presentation that contains every requested diagnosis. When IDs span + * presentations (or none match), fall back to the highest partial overlap so + * callers that only need "a related workflow" still get a useful default. + */ export function getPresentationWorkflowForDiagnosisIds(ids: Iterable) { - const requestedIds = new Set(Array.from(ids, (id) => id.trim().toLowerCase()).filter(Boolean)); + const requestedIds = new Set(normalizeRequestedDiagnosisIds(ids)); if (!requestedIds.size) return null; let bestMatch: DifferentialPresentationWorkflow | null = null; let bestMatchCount = 0; + let fullCoverage: DifferentialPresentationWorkflow | null = null; + for (const presentation of differentialPresentations()) { const matchCount = presentation.candidates.reduce( (count, candidate) => count + (requestedIds.has(candidate.slug) ? 1 : 0), 0, ); + // First full-coverage host in catalogue order wins ties (stable with the + // historical single-ID redirect, e.g. delirium → acute-confusion). + if (!fullCoverage && matchCount === requestedIds.size) { + fullCoverage = presentation; + } if (matchCount > bestMatchCount) { bestMatch = presentation; bestMatchCount = matchCount; } } - return bestMatch; + return fullCoverage ?? bestMatch; } -export function getPresentationWorkflowSelectionForDiagnosisIds(ids: Iterable) { - const diagnosisIds = Array.from(new Set(Array.from(ids, (id) => id.trim().toLowerCase()).filter(Boolean))); - const workflow = getPresentationWorkflowForDiagnosisIds(diagnosisIds); +/** + * Resolve compare handoff for selected diagnosis IDs. + * - `presentation`: every ID belongs to one catalogue presentation (keep rich cells). + * - `ad-hoc`: IDs span presentations (or have no host); keep every valid ID. + */ +export function getPresentationWorkflowSelectionForDiagnosisIds( + ids: Iterable, +): DifferentialCompareSelection | null { + const diagnosisIds = normalizeRequestedDiagnosisIds(ids); + if (!diagnosisIds.length) return null; + + const fullCoverage = differentialPresentations().find((presentation) => + presentationContainsAllDiagnosisIds(presentation, diagnosisIds), + ); + if (fullCoverage) { + return { kind: "presentation", workflow: fullCoverage, diagnosisIds }; + } + + const workflow = buildAdHocPresentationWorkflow(diagnosisIds); if (!workflow) return null; - const candidateIds = new Set(workflow.candidates.map((candidate) => candidate.slug)); + return { kind: "ad-hoc", workflow, diagnosisIds }; +} + +function differentialCompareHref(path: string, query: string, selectedIds: readonly string[]) { + const params = new URLSearchParams(); + const trimmedQuery = query.trim(); + if (trimmedQuery) params.set("q", trimmedQuery); + if (selectedIds.length > 0) params.set("ids", selectedIds.join(",")); + const suffix = params.toString(); + return suffix ? `${path}?${suffix}` : path; +} + +export type DifferentialCompareHandoff = + | { + kind: "ad-hoc"; + href: string; + selection: DifferentialCompareSelection; + } + | { + kind: "presentation"; + href: string; + selection: DifferentialCompareSelection | null; + }; + +/** + * Resolve `/differentials/compare` handoff without a competing route handler. + * Same-presentation (or empty/unknown) selections redirect into a catalogue + * presentation; cross-presentation selections stay on the compare page. + */ +export function resolveDifferentialCompareHandoff(ids: Iterable, query = ""): DifferentialCompareHandoff { + const selection = getPresentationWorkflowSelectionForDiagnosisIds(ids); + if (selection?.kind === "ad-hoc") { + return { + kind: "ad-hoc", + href: differentialCompareHref("/differentials/compare", query, selection.diagnosisIds), + selection, + }; + } + + const workflowId = + selection?.kind === "presentation" && selection.workflow.id !== AD_HOC_DIFFERENTIAL_COMPARE_ID + ? selection.workflow.id + : "acute-confusion-encephalopathy"; + const diagnosisIds = selection?.diagnosisIds ?? []; return { - workflow, - diagnosisIds: diagnosisIds.filter((id) => candidateIds.has(id)), + kind: "presentation", + href: differentialCompareHref(`/differentials/presentations/${workflowId}`, query, diagnosisIds), + selection, }; } diff --git a/src/lib/mode-secondary-navigation.ts b/src/lib/mode-secondary-navigation.ts index cfff4ebdf9..e9216a74ea 100644 --- a/src/lib/mode-secondary-navigation.ts +++ b/src/lib/mode-secondary-navigation.ts @@ -253,6 +253,8 @@ export function modeSecondaryNavigationHref(params: { if (modeId === "differentials") { const entries: Array = query ? [["q", query]] : []; if (itemId === "search" && currentSearchParams.get("run") === "1") entries.push(["run", "1"]); + // Compare (and other in-mode tabs) reuse URL-backed selection so ticks on + // search survive ModeNav handoff without a second client store. if (currentSearchParams.get("ids")) { entries.push(["ids", currentSearchParams.get("ids") ?? ""]); } diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index 0e06575bf1..ae304bf214 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -5,7 +5,7 @@ import { NextRequest } from "next/server"; import { describe, expect, it } from "vitest"; import { GET as redirectApplications, HEAD as headApplications } from "@/app/applications/route"; -import { GET as redirectCompare, HEAD as headCompare } from "@/app/(search-app)/differentials/compare/route"; +import { resolveDifferentialCompareHandoff } from "@/lib/differentials"; import { legacyHomeRedirectUrl } from "@/lib/legacy-home-redirect"; function source(relativePath: string) { @@ -33,15 +33,19 @@ describe("audit navigation and auth regressions", () => { expect(applications.status).toBe(307); expect(applications.headers.get("location")).toBe("/tools?q=acute+care&tag=one&tag=two"); - const compare = redirectCompare( - new NextRequest( - "https://clinical-kb.test/differentials/compare?query=+acute+confusion+&q=ignored&ids=DELIRIUM,unknown,delirium", - ), - ); - expect(compare.status).toBe(307); - expect(compare.headers.get("location")).toBe( + // `/differentials/compare` is a real page (no competing route.ts). Same- + // presentation/bare selections redirect into a catalogue workflow; the + // page source must keep that handoff via next/navigation redirect(). + const compareHandoff = resolveDifferentialCompareHandoff(["DELIRIUM", "unknown", "delirium"], "acute confusion"); + expect(compareHandoff.kind).toBe("presentation"); + expect(compareHandoff.href).toBe( "/differentials/presentations/acute-confusion-encephalopathy?q=acute+confusion&ids=delirium", ); + const comparePage = source("src/app/(search-app)/differentials/compare/page.tsx"); + expect(comparePage).toContain('from "next/navigation"'); + expect(comparePage).toContain("resolveDifferentialCompareHandoff"); + expect(comparePage).toContain("redirect(handoff.href)"); + expect(comparePage).not.toContain("NextResponse"); // `/medications` is a real Medication mode home (no blanket 307). Submitted // deep links (`q` + `run=1`) still redirect to the dashboard prescribing @@ -52,7 +56,7 @@ describe("audit navigation and auth regressions", () => { expect(medicationsPage).toContain("readSearchNavigationContext"); expect(medicationsPage).toContain("redirect("); expect(medicationsPage).not.toContain('redirect("/?mode=prescribing")'); - expect([headApplications, headCompare]).toEqual([redirectApplications, redirectCompare]); + expect(headApplications).toBe(redirectApplications); }); it("only redirects submitted root legacy mode aliases, leaving bare /?mode= on the shared home", () => { diff --git a/tests/design-system-adoption.test.ts b/tests/design-system-adoption.test.ts index 74479bb21f..90522f941d 100644 --- a/tests/design-system-adoption.test.ts +++ b/tests/design-system-adoption.test.ts @@ -1235,9 +1235,9 @@ describe("design-system adoption manifest", () => { ["committed", "not-committed", "not-applicable"].includes(surface.baseline.status), ), ).toBe(true); - // 50 since Differentials gained a Presentations catalogue page at - // /differentials/presentations (Compare entry moved to /differentials/compare). - expect(manifest.routeCoverage.discovered).toHaveLength(50); + // 51: Presentations catalogue page plus a real Compare page (no longer a + // route-handler redirect) on top of Documents/Medication mode homes. + expect(manifest.routeCoverage.discovered).toHaveLength(51); expect(manifest.routeCoverage.declared).toEqual(manifest.routeCoverage.discovered); expect(manifest.routeCoverage.undeclared).toEqual([]); expect(manifest.routeCoverage.missing).toEqual([]); diff --git a/tests/differentials-compare-selection.dom.test.tsx b/tests/differentials-compare-selection.dom.test.tsx new file mode 100644 index 0000000000..e217618aac --- /dev/null +++ b/tests/differentials-compare-selection.dom.test.tsx @@ -0,0 +1,104 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getDifferentialRecord } from "@/lib/differentials"; + +const catalogState = vi.hoisted(() => ({ + status: "loading" as "loading" | "ready" | "error" | "unauthorized" | "refetching", + matches: { + diagnoses: [] as Array<{ + record: NonNullable>; + score: number; + reasons: string[]; + }>, + presentations: [] as Array, + }, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +vi.mock("@/components/clinical-dashboard/use-differential-catalog", () => ({ + useDifferentialSearch: () => ({ + status: catalogState.status, + matches: catalogState.matches, + demoMode: true, + error: null, + }), +})); + +vi.mock("@/components/use-result-sort", () => ({ + useResultSort: () => ["relevance", vi.fn()] as const, +})); + +vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ({ + UniversalSearchAlsoMatches: () => null, +})); + +import { DifferentialsHome } from "@/components/clinical-dashboard/differentials-home"; + +describe("DifferentialsHome compare selection URL handoff", () => { + beforeEach(() => { + catalogState.status = "loading"; + catalogState.matches = { diagnoses: [], presentations: [] }; + window.history.replaceState( + null, + "", + "/differentials?q=Pain&run=1&ids=medical-gi-endocrine-painful-organic-cause,bpsd-as-unmet-need-delirium-pain-mimic", + ); + }); + + afterEach(() => { + window.history.replaceState(null, "", "/"); + }); + + it("keeps bookmarked ids while the catalogue is loading, then hydrates them", async () => { + const topNoise = getDifferentialRecord("anorexia-nervosa"); + const keptA = getDifferentialRecord("medical-gi-endocrine-painful-organic-cause"); + const keptB = getDifferentialRecord("bpsd-as-unmet-need-delirium-pain-mimic"); + expect(topNoise && keptA && keptB).toBeTruthy(); + + const { rerender } = render( + , + ); + + expect(screen.getByTestId("differentials-results-loading")).toBeVisible(); + expect(window.location.search).toContain("ids=medical-gi-endocrine-painful-organic-cause"); + expect(window.location.search).toContain("bpsd-as-unmet-need-delirium-pain-mimic"); + + catalogState.status = "ready"; + catalogState.matches = { + diagnoses: [ + { record: topNoise!, score: 20, reasons: ["title"] }, + { record: keptA!, score: 12, reasons: ["title"] }, + { record: keptB!, score: 11, reasons: ["title"] }, + ], + presentations: [], + }; + + await act(async () => { + rerender(); + }); + + await waitFor(() => { + expect(screen.getByTestId("differentials-search-results")).toBeVisible(); + }); + + await waitFor(() => { + const params = new URLSearchParams(window.location.search); + expect(params.get("ids")?.split(",").sort()).toEqual( + ["bpsd-as-unmet-need-delirium-pain-mimic", "medical-gi-endocrine-painful-organic-cause"].sort(), + ); + }); + }); +}); diff --git a/tests/differentials-navigation.test.ts b/tests/differentials-navigation.test.ts index c8b775f292..d57325d7b1 100644 --- a/tests/differentials-navigation.test.ts +++ b/tests/differentials-navigation.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; -import { NextRequest } from "next/server"; -import { GET as redirectCompare } from "@/app/(search-app)/differentials/compare/route"; -import { differentialRouteWithQuery, differentialSelectedCompareHref } from "@/lib/differentials-navigation"; +import { resolveDifferentialCompareHandoff } from "@/lib/differentials"; +import { + differentialIdsFromSearchParams, + differentialRouteWithQuery, + differentialSelectedCompareHref, + differentialSelectionIdsSearch, +} from "@/lib/differentials-navigation"; describe("differentials navigation", () => { it("builds same-origin relative query routes", () => { @@ -29,18 +33,39 @@ describe("differentials navigation", () => { expect(source).not.toMatch(/differentials-snapshot|loadDifferentialSnapshot/); }); - it("redirects compare selection with a relative Location even when the request host is a bind address", () => { - const response = redirectCompare( - new NextRequest( - "http://0.0.0.0:4461/differentials/compare?q=Pain&ids=anorexia-nervosa,bulimia-nervosa-binge-purge-pattern", - ), + it("redirects same-presentation compare selection to the hosting workflow", () => { + const handoff = resolveDifferentialCompareHandoff( + ["anorexia-nervosa", "bulimia-nervosa-binge-purge-pattern"], + "Pain", ); - expect(response.status).toBe(307); - const location = response.headers.get("location"); - expect(location).toMatch(/^\/differentials\/presentations\/[^/?]+/); - expect(location).toContain("q=Pain"); - expect(location).toContain("ids="); - expect(location).not.toContain("0.0.0.0"); + expect(handoff.kind).toBe("presentation"); + expect(handoff.href).toMatch(/^\/differentials\/presentations\/[^/?]+/); + expect(handoff.href).toContain("q=Pain"); + expect(handoff.href).toContain("ids="); + expect(handoff.href).not.toContain("0.0.0.0"); + }); + + it("keeps cross-presentation compare selection on the ad-hoc compare page with every id", () => { + const handoff = resolveDifferentialCompareHandoff( + ["medical-gi-endocrine-painful-organic-cause", "bpsd-as-unmet-need-delirium-pain-mimic"], + "Pain", + ); + + expect(handoff.kind).toBe("ad-hoc"); + expect(handoff.href).toBe( + "/differentials/compare?q=Pain&ids=medical-gi-endocrine-painful-organic-cause%2Cbpsd-as-unmet-need-delirium-pain-mimic", + ); + }); + + it("parses and builds compare selection ids on the current URL search", () => { + expect(differentialIdsFromSearchParams("q=Pain&ids=a%2Cb")).toEqual(["a", "b"]); + expect(differentialIdsFromSearchParams("ids=DELIRIUM,Unknown,delirium")).toEqual(["delirium", "unknown"]); + expect( + differentialSelectionIdsSearch( + ["medical-gi-endocrine-painful-organic-cause", "bpsd-as-unmet-need-delirium-pain-mimic"], + "?q=Pain&run=1", + ), + ).toBe("?q=Pain&run=1&ids=medical-gi-endocrine-painful-organic-cause%2Cbpsd-as-unmet-need-delirium-pain-mimic"); }); }); diff --git a/tests/differentials.test.ts b/tests/differentials.test.ts index 67c0e80ea1..a1289bd837 100644 --- a/tests/differentials.test.ts +++ b/tests/differentials.test.ts @@ -9,6 +9,7 @@ import { import { staleSeededPresentations } from "@/lib/differential-seed"; import { isDifferentialMetadataArtifactTitle } from "@/lib/differential-snapshot"; import { + buildAdHocPresentationWorkflow, composeDifferentialSearchResults, differentialDiagnosesCards, differentialPresentations, @@ -37,13 +38,55 @@ describe("presentation workflow routing", () => { expect(getPresentationWorkflowForDiagnosisIds([])).toBeNull(); }); - it("forwards only diagnoses supported by the selected workflow", () => { + it("keeps every valid diagnosis when selections span presentations", () => { const selection = getPresentationWorkflowSelectionForDiagnosisIds([ "wernicke-encephalopathy", "major-depressive-disorder", ]); - expect(selection?.workflow.id).toBe("acute-confusion-encephalopathy"); - expect(selection?.diagnosisIds).toEqual(["wernicke-encephalopathy"]); + expect(selection?.kind).toBe("ad-hoc"); + expect(selection?.workflow.id).toBe("selected-differentials"); + expect(selection?.diagnosisIds).toEqual(["wernicke-encephalopathy", "major-depressive-disorder"]); + expect(selection?.workflow.candidates.every((candidate) => candidate.selected)).toBe(true); + }); + + it("preserves the Pain search cross-presentation pair into an ad-hoc compare", () => { + const selection = getPresentationWorkflowSelectionForDiagnosisIds([ + "medical-gi-endocrine-painful-organic-cause", + "bpsd-as-unmet-need-delirium-pain-mimic", + ]); + expect(selection?.kind).toBe("ad-hoc"); + expect(selection?.diagnosisIds).toEqual([ + "medical-gi-endocrine-painful-organic-cause", + "bpsd-as-unmet-need-delirium-pain-mimic", + ]); + expect(selection?.workflow.candidates.map((candidate) => candidate.slug)).toEqual([ + "medical-gi-endocrine-painful-organic-cause", + "bpsd-as-unmet-need-delirium-pain-mimic", + ]); + }); + + it("routes same-presentation selections to the hosting catalogue workflow", () => { + const selection = getPresentationWorkflowSelectionForDiagnosisIds([ + "anorexia-nervosa", + "bulimia-nervosa-binge-purge-pattern", + ]); + expect(selection?.kind).toBe("presentation"); + expect(selection?.workflow.id).toBe("food-refusal-not-eating-eating-disorder-spectrum"); + expect(selection?.diagnosisIds).toEqual(["anorexia-nervosa", "bulimia-nervosa-binge-purge-pattern"]); + }); + + it("omits unsupported ad-hoc compare criteria so cross-presentation rows are not placeholders", () => { + const workflow = buildAdHocPresentationWorkflow([ + "medical-gi-endocrine-painful-organic-cause", + "bpsd-as-unmet-need-delirium-pain-mimic", + ]); + expect(workflow).not.toBeNull(); + expect(workflow?.criteria.map((criterion) => criterion.id)).not.toContain("what-argues-against"); + for (const candidate of workflow?.candidates ?? []) { + expect(candidate.comparison["what-argues-against"]).toBeUndefined(); + const nonPlaceholder = Object.values(candidate.comparison).filter((cell) => cell !== "Review locally."); + expect(nonPlaceholder.length).toBeGreaterThan(0); + } }); }); diff --git a/tests/mode-secondary-navigation.test.ts b/tests/mode-secondary-navigation.test.ts index 573b9fe3e2..074b5308af 100644 --- a/tests/mode-secondary-navigation.test.ts +++ b/tests/mode-secondary-navigation.test.ts @@ -275,6 +275,10 @@ describe("mode secondary navigation registry", () => { expect(activeModeSecondaryNavigationId("specifiers", "/specifiers/builder-notes")).toBeNull(); expect(activeModeSecondaryNavigationId("formulation", "/formulation/map")).toBe("map"); expect(activeModeSecondaryNavigationId("specifiers", "/specifiers/compare")).toBe("compare"); + expect(activeModeSecondaryNavigationId("differentials", "/differentials/compare")).toBe("compare"); + expect( + activeModeSecondaryNavigationId("differentials", "/differentials/presentations/acute-confusion-encephalopathy"), + ).toBe("compare"); }); }); diff --git a/tests/route-reachability.test.ts b/tests/route-reachability.test.ts index 27ef8f372d..c3f7dededf 100644 --- a/tests/route-reachability.test.ts +++ b/tests/route-reachability.test.ts @@ -5,6 +5,7 @@ import { parse } from "@babel/parser"; import { describe, expect, it } from "vitest"; import { appModeDefinitions, appModeHomeHref } from "@/lib/app-modes"; +import { modeSecondaryNavigationRegistry } from "@/lib/mode-secondary-navigation"; import { tools } from "@/components/tools-page-mockups/tool-fixtures"; import { collectSiteMapData } from "../scripts/generate-site-map"; @@ -471,6 +472,15 @@ for (const mode of appModeDefinitions) { } for (const tool of tools) builderTargets.add(pathOnly(tool.href)); +// ModeNav destinations are data (registry href strings rendered as s), so +// a JSX/router scan never sees `/differentials/compare` and peers as literals. +// Treat the registry as a canonical nav builder, same as app-mode homes. +for (const entries of Object.values(modeSecondaryNavigationRegistry)) { + for (const entry of entries) { + if ("href" in entry && entry.href) builderTargets.add(pathOnly(entry.href)); + } +} + // Therapy Compass owns a self-contained route family whose fixed screens are // navigated via a local `screenHref(screen)` builder (`go*()` → router.push), // so the sub-route paths never appear as literals a static scan could see. Read diff --git a/tests/site-map.test.ts b/tests/site-map.test.ts index 2eb96b01b3..69310efbed 100644 --- a/tests/site-map.test.ts +++ b/tests/site-map.test.ts @@ -70,14 +70,9 @@ describe("tracked sitemap", () => { const apiSection = siteMap.slice(siteMap.indexOf("## API routes"), siteMap.indexOf("## Redirects")); const expectedProductHandlers = [ ["/applications", "src/app/applications/route.ts", "/tools"], - [ - "/differentials/compare", - "src/app/(search-app)/differentials/compare/route.ts", - "/differentials/presentations/[workflow-slug]", - ], - // `/medications` is deliberately absent: it became a real Medication mode - // home when `/` turned into the single shared home for every mode, so it is - // a page route now rather than a redirect handler. + // `/differentials/compare` and `/medications` are deliberately absent: + // compare is a real page that conditionally redirects same-presentation + // selections, and medications is a real Medication mode home. ] as const; const redirectSection = siteMap.slice(siteMap.indexOf("## Redirects"));