diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index c5ad7eedf2..1d27ef0012 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -837,12 +837,13 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-09 | cursor/differentials-diagnosis-links-9f18 | 0e77e7bc0842bef4ffc045c6dbea4626152490d1 | differentials diagnosis term links | implemented exact+alias termLinks chips on diagnosis+presentation pages; vitest 58/58; verify:pr-local green | vitest differential-diagnosis-links+detail+section-nav+route; verify:pr-local; ensure spot-check | | 2026-08-09 | cursor/differentials-diagnosis-links-9f18 | 0daa9e2f9fc84e879fd661da94203568309234a6 | PR #1768 Autopilot+Bugbot review-and-fix | Merged origin/main (DIRTY was ledger+detail-page staleness; merge-tree clean). Fixed SEGMENT_SPLIT to spaced-slash only so Delirium / medical psychosis links while alcohol/benzo, DVT/PE, food/fluid stay intact. Dispositioned: Copilot termLinks ??{} + Fragment key already fixed; CodeRabbit clean-keys moot (visibleSectionItems already cleans); CodeRabbit bare-slash split rejected (clinical harm). No Bugbot findings. Threads cleared on push. Merge left to user. | vitest differential-diagnosis-links+detail+route 49/49; verify:cheap exit 0 (543 files, 5828 passed/4 skipped); verify:pr-local exit 0 (lint/typecheck/test/build/rag-fixtures); merge-tree clean vs origin/main; no provider gates | | 2026-08-09 | cursor/differentials-diagnosis-links-9f18 | f784e81bcc0b53ef76b3da07a8e81f96d9bf0c71 | pr-1768 unblock | merged origin/main onto ba590f9; merge-tree clean; DIRTY mergeability cleared; push tip follows amend with this ledger | merge-tree clean; threads resolved; auto-merge was armed | -| 2026-08-09 | claude/document-viewer-phase-3-bj5k5v | 156db63f1b60f09791e426b043ea90d427b789ab | post-#1772 test simplification: replace the viewer perf source-text grep with behavioural coverage; de-literalise rail window and keyboard label assertions | PR #1777 opened. Self-review of #1772's own tests against an excessive-strictness challenge. Finding: the client-performance-boundaries grep for resolveLiveCanvasWindow / resolveRenderAheadPages / liveCanvasLimit / requestIdleCallback was not merely brittle, it was INEFFECTIVE - replacing the budget call with a hardcoded 3 leaves every identifier in the file, so it stayed green while the viewer retained three full-zoom canvases (measured both ways). Replaced by a DOM case that binds the budget (VIEWER_MAX_ZOOM at dpr 3 gives ~16.8M backing px against the 24M budget, window collapses to 1) and fails on exactly that substitution. Also exported RAIL_IMAGE_WINDOW so the rail test derives its counts (verified by tuning 6->8: all 7 still pass), and relaxed the keyboard aria-label assertions from exact prose to the key names. Pre-existing greps for disableAutoFetch / canvas.width = 0 / pageToCleanup left alone deliberately - two are now redundant but they are another author's guard. | verify:pr-local (1 pre-existing root-only failure: pr-handoff-stop #291; 5872 passed), build OK 80s + client bundle secret check, eval:rag:offline 36 golden cases / 574 tests, lint + typecheck clean. Sabotage-verified in both directions. Browser gates unrunnable here (#279) - unchanged by this diff. | +| 2026-08-09 | claude/documentviewer-nav-convergence-oddhjx | 1395d533cb13eadc705e47f76aa9f39a7a11c058 | DocumentViewer / in-page-nav convergence (#288): non-adoption decision recorded in docs/search-chrome-behaviour.md; merged duplicated visible-element predicate into resolveVisibleElement; new convergence guard test | Converged what was duplicated; DocumentReviewer header adoption declined on the merits with four blocking reasons recorded. No contract test edited. | verify:pr-local (546/547 files, 5883 tests pass; sole failure tests/pr-handoff-stop.test.ts reproduced on pristine origin/main), verify:phone-chrome (contracts 123 pass; focused Chromium 7 pass), contract set 12 files/151 tests pass, lint, typecheck, format | | 2026-08-09 | claude/planning-build-intelligence-9ot0nm | 3df3cb3993f73cda4dbbc4ac7549f84b3c6ea7ed | Node 24.15 engine floor: engines.node, preinstall hook, check:runtime, session-start provisioning, codex-cloud assertion | Authored and handed off as PR #1771; closes #285; operationalRisk true, clinicalRisk/ragRanking false | test 5800 passed/1 pre-existing root-uid failure (pr-handoff-stop, confirmed on stashed clean tree); lint 0; typecheck 0; prettier --check . pass; check:runtime pass; check:codex-cloud pass; check:outstanding-issues pass; preinstall boundary proof 24.13/24.14.9 reject, 24.15/24.19 accept, 25.0.0 reject; contract test mutation-checked red | | 2026-08-09 | pull/1771 | 466ec4216272c31c5f754db213dbdc529583b167 | PR 1771 runtime floor enforcement | P2: Cloud and Desktop setup paths remain major-only; do not merge until range-aware | static review; check:runtime PASS; check:codex-cloud PASS; ledger PASS; outstanding issues PASS; focused Vitest blocked by active Playwright lease | -| 2026-08-09 | claude/m3-token-debt-262-261 | c6e1fe7fc42ec6f286eb5a3d8f7ddad7dfad2724 | design-system contract: raw padding/radius/line-height ratchets + type-step selection gate (#262 parts 2/3); closed #218/#270 | Authored and self-verified; PR #1780 open, auto-merge deliberately not armed (gate change). Baseline additive: all 15 pre-existing metrics and every debtByPath entry byte-identical; 94/94 new findings verified present at their cited line. Mutation-tested both halves of part 3 and three failure modes of part 2. | check:design-system-contract, check:icon-scale, check:type-scale, check:outstanding-issues, vitest design-system-contract-utils (31 passed), format:check whole-tree, verify:cheap (exit 1 from 5 pre-existing failures, none in this diff; 3 cleared by merging main, remaining 2 byte-identical to origin/main) | -| 2026-08-09 | claude/documentviewer-nav-convergence-oddhjx | 1395d533cb13eadc705e47f76aa9f39a7a11c058 | DocumentViewer / in-page-nav convergence (#288): non-adoption decision recorded in docs/search-chrome-behaviour.md; merged duplicated visible-element predicate into resolveVisibleElement; new convergence guard test | Converged what was duplicated; DocumentReviewer header adoption declined on the merits with four blocking reasons recorded. No contract test edited. | verify:pr-local (546/547 files, 5883 tests pass; sole failure tests/pr-handoff-stop.test.ts reproduced on pristine origin/main), verify:phone-chrome (contracts 123 pass; focused Chromium 7 pass), contract set 12 files/151 tests pass, lint, typecheck, format | +| 2026-08-09 | claude/document-viewer-phase-3-bj5k5v | 156db63f1b60f09791e426b043ea90d427b789ab | post-#1772 test simplification: replace the viewer perf source-text grep with behavioural coverage; de-literalise rail window and keyboard label assertions | PR #1777 opened. Self-review of #1772's own tests against an excessive-strictness challenge. Finding: the client-performance-boundaries grep for resolveLiveCanvasWindow / resolveRenderAheadPages / liveCanvasLimit / requestIdleCallback was not merely brittle, it was INEFFECTIVE - replacing the budget call with a hardcoded 3 leaves every identifier in the file, so it stayed green while the viewer retained three full-zoom canvases (measured both ways). Replaced by a DOM case that binds the budget (VIEWER_MAX_ZOOM at dpr 3 gives ~16.8M backing px against the 24M budget, window collapses to 1) and fails on exactly that substitution. Also exported RAIL_IMAGE_WINDOW so the rail test derives its counts (verified by tuning 6->8: all 7 still pass), and relaxed the keyboard aria-label assertions from exact prose to the key names. Pre-existing greps for disableAutoFetch / canvas.width = 0 / pageToCleanup left alone deliberately - two are now redundant but they are another author's guard. | verify:pr-local (1 pre-existing root-only failure: pr-handoff-stop #291; 5872 passed), build OK 80s + client bundle secret check, eval:rag:offline 36 golden cases / 574 tests, lint + typecheck clean. Sabotage-verified in both directions. Browser gates unrunnable here (#279) - unchanged by this diff. | | 2026-08-09 | claude/disabled-button-accessibility-piclvr | 722abdb780c715c0a89df268ed48f6c741ffd569 | disabled-placeholder buttons -> aria-disabled + inert handler (25 sites, 13 components); controlDisabled/therapy recipe aria-disabled styling; require-button-wiring redundantDisabledPair gate; wiring-conventions contract rewrite (settles #291) | authored — PR #1778 opened | lint (uncached, exit 0); typecheck; test 5878 passed/1 pre-existing root-env failure in pr-handoff-stop; build; check:rag:fixtures 36 golden cases; prettier --check clean; verify:ui not run (no browser in container) | +| 2026-08-09 | claude/in-page-nav-pr-3-i6gi8n | 6651feef4fab63f1181fba57908cb22e2932df3c | in-page-nav PR 3: convert /medications/[slug] (panel-swap) and /factsheets/[slug] (anchors) onto InPageNavHeader; record the differentials-presentations exception; delete orphaned SecondaryNavigation (#271) | converted 2 of 3 routes, 3rd recorded as a reasoned lasting exception; tocFor and SecondaryNavigation deleted; route-sections contract 7 -> 12 routes plus a panel-swap suite | verify:pr-local (1 pre-existing root-permission failure in pr-handoff-stop.test.ts, all else green); test 5932 passed; in-page-nav-route-sections 29 passed; verify:phone-chrome 3/4 stages (focused-browser blocked by #255 Chromium 1194 vs 1234); build + bundle-budget + rag:fixtures green; verify:ui not run (#255, delegated to CI) | +| 2026-08-09 | claude/m3-token-debt-262-261 | c6e1fe7fc42ec6f286eb5a3d8f7ddad7dfad2724 | design-system contract: raw padding/radius/line-height ratchets + type-step selection gate (#262 parts 2/3); closed #218/#270 | Authored and self-verified; PR #1780 open, auto-merge deliberately not armed (gate change). Baseline additive: all 15 pre-existing metrics and every debtByPath entry byte-identical; 94/94 new findings verified present at their cited line. Mutation-tested both halves of part 3 and three failure modes of part 2. | check:design-system-contract, check:icon-scale, check:type-scale, check:outstanding-issues, vitest design-system-contract-utils (31 passed), format:check whole-tree, verify:cheap (exit 1 from 5 pre-existing failures, none in this diff; 3 cleared by merging main, remaining 2 byte-identical to origin/main) | | 2026-08-09 | cursor/differentials-four-page-nav-5ebf | 93ea437610c1f1b681c3a5cbdc72fe8b9b178710 | differentials four-page nav | implemented Search/Diagnoses/Presentations/Compare equal pages; compare queue; kind labels; Search q+run restore | vitest nav+differentials-navigation; typecheck; lint; full unit 5814 passed | | 2026-08-09 | cursor/differentials-four-page-nav-5ebf | 384a1bedd8dd1064fb2fcf26ac845224e2cafdc4 | PR #1774 differentials four-page nav heavy review-and-fix | fixed P1 ids+Playwright; ModeNav route gate; RSC queue clears bundle+shadow; Copilot ModeNav-on-detail dispositioned (info page); ledger reorder dispositioned (merge=ledger) | vitest nav 47p; design-system-contract; typecheck; lint; test 5897p; build+bundle-budget 1543.7 within tol; focused pw compare queue 1p | | 2026-08-09 | claude/m3-token-debt-262-261 | 95221ef4235abd9544158b07b8b8569f00c9ec78 | PR #1780 review-and-fix | fixed P2 ratchet bypasses (arbitrary-property classes, CSS-consumer exemption anti-rot, modern CSS zero units); Bugbot clean; merge-tree clean; required CI was green on prior tip | vitest design-system-contract-utils 32/32; check:design-system-contract; mutation CSS-exemption fail→restore; verify:cheap PASS (549 files / 5933 tests); verify:pr-local stages PASS (test flake in design-system-adoption timed out once then 51/51 + full test 549/549 + check:rag:fixtures PASS); no provider gates | diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 605518a572..06a6bec462 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -329,8 +329,8 @@ One shared composer (`master-search-header.tsx`) serves every mode. Placement: - **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/*` 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 in-page navigation**: `src/components/in-page-nav/` is the default template for section navigation on any mode page (`docs/search-chrome-behaviour.md`). `in-page-nav-header.tsx` (`InPageNavHeader`) owns the header row, both sheets and the `PhoneHeaderCollapsePortal` wrapper; `page-section-index.ts` (`PageSection`, `toDocumentSections`, `sectionTargetIds`) is the declaration shape; `use-resolved-page-sections.ts` narrows a declaration to the anchors actually rendered at this breakpoint; `use-in-page-section-nav.ts` composes that with `useDocumentSectionSpy` and `jumpToDocumentSection`; `use-page-section-weights.ts` measures segment weights; `use-in-page-chrome-metrics.ts` publishes `--inpage-anchor-offset`; `in-page-nav-classes.ts` holds the shared anchor (`inPageAnchor`) and actions-sheet row classes. Anchor measurement itself is `src/components/sticky-chrome-metrics.ts` (`useStickyChromeMetrics`), shared with the document viewer's `use-document-chrome-metrics.ts`. Mounted by `differentials/differential-detail-page.tsx`, `services/service-detail-page.tsx`, `forms/form-detail-page.tsx`, `dsm/dsm-differential-considerations-page.tsx`, and — through a `"use client"` sibling module, because those pages are Server Components — `specifiers/specifier-nav-header.tsx`, `formulation/formulation-nav-header.tsx` and `dsm/dsm-diagnosis-nav-header.tsx`. Every declared section is pinned against rendered DOM by `tests/in-page-nav-route-sections.dom.test.tsx`. -- **Shared secondary navigation**: `src/components/secondary-navigation.tsx` (`SecondaryNavigation`, route/action items, roving tablist) and `src/components/page-secondary-navigation.tsx` (`PageSecondaryNavigation`, mode destinations only). 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, on Therapy Compass, and on every information page — `hasLocalInformationPageNavigation` is now just `isInformationPage`, because each of those routes owns its own in-page navigation. The `section` kind and its "On this page" pill rail were removed once the last six routes moved onto `InPageNavHeader`; `route`/`action` survive as component API with tests but have no production constructor (`/issues #271`). +- **Shared in-page navigation**: `src/components/in-page-nav/` is the default template for section navigation on any mode page (`docs/search-chrome-behaviour.md`). `in-page-nav-header.tsx` (`InPageNavHeader`) owns the header row, both sheets and the `PhoneHeaderCollapsePortal` wrapper; `page-section-index.ts` (`PageSection`, `toDocumentSections`, `sectionTargetIds`) is the declaration shape; `use-resolved-page-sections.ts` narrows a declaration to the anchors actually rendered at this breakpoint; `use-in-page-section-nav.ts` composes that with `useDocumentSectionSpy` and `jumpToDocumentSection`; `use-page-section-weights.ts` measures segment weights; `use-in-page-chrome-metrics.ts` publishes `--inpage-anchor-offset`; `in-page-nav-classes.ts` holds the shared anchor (`inPageAnchor`) and actions-sheet row classes; `in-page-section-rail.tsx` (`InPageSectionRail`) is the optional visible second rail, opted into with `rail={{ label }}` by panel-swap routes with few sections (medications only) in place of the weighted track. Anchor measurement itself is `src/components/sticky-chrome-metrics.ts` (`useStickyChromeMetrics`), shared with the document viewer's `use-document-chrome-metrics.ts`. Mounted by `differentials/differential-detail-page.tsx`, `services/service-detail-page.tsx`, `forms/form-detail-page.tsx`, `dsm/dsm-differential-considerations-page.tsx`, and — through a colocated `"use client"` nav-header sibling that owns and exports the route's section table — `specifiers/specifier-nav-header.tsx`, `formulation/formulation-nav-header.tsx`, `dsm/dsm-diagnosis-nav-header.tsx`, `factsheets/factsheet-nav-header.tsx` and `clinical-dashboard/medication-nav-header.tsx`. The sibling is mandatory for the four Server Component pages (neither `onSelectSection` nor a `LucideIcon` crosses the RSC boundary) and the convention for the rest. Two adopters swap panels instead of scrolling — `differential-detail-page.tsx` and the medication record page — so they pass explicit weights, carry no `inPageAnchor`, and use neither `useResolvedPageSections` nor the scroll spy. Every declared section is pinned against rendered DOM by `tests/in-page-nav-route-sections.dom.test.tsx` (anchors for the scrolling routes, swapped-in panels for the tab routes). +- **Shared secondary navigation**: `src/components/page-secondary-navigation.tsx` (`PageSecondaryNavigation`, mode destinations only). 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, on Therapy Compass, and on every information page — `hasLocalInformationPageNavigation` is now just `isInformationPage`, because each of those routes owns its own in-page navigation. The older shared `SecondaryNavigation` component was deleted here (`/issues #271`): its `section` kind and "On this page" pill rail went when the last six information routes moved onto `InPageNavHeader`, and the surviving `route`/`action` kinds had no production constructor left — `RegistryModeNav` renders `ModeNav`, not `SecondaryNavigation`, so the only remaining caller was its own test file, which went with it. - **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/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 7ee1e9fe6b..161169063e 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -958,7 +958,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `AnswerFooter` | answer | yes | yes | no | yes | no | 0 | | `AsyncButton` | controls | yes | yes | inherited-global-root | yes | no | 4 | | `Breadcrumb` | layout | yes | yes | inherited-global-root | yes | no | 1 | -| `Button` | controls | yes | yes | inherited-global-root | yes | no | 3 | +| `Button` | controls | yes | yes | inherited-global-root | yes | no | 2 | | `Checkbox` | controls | yes | yes | no | yes | no | 0 | | `Chip` | controls | yes | yes | inherited-global-root | yes | no | 3 | | `Citation` | source | yes | yes | no | yes | no | 0 | @@ -993,7 +993,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SearchField` | controls | yes | yes | no | yes | no | 0 | | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 3 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 23 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 24 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 1 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index cc26b9d639..8e7729860b 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -273,13 +273,11 @@ "directImportFiles": [ "src/components/AccessibleTable.tsx", "src/components/clinical-dashboard/signed-image.tsx", - "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/ui/confirm-dialog.tsx" ], "productImportFiles": [ "src/components/AccessibleTable.tsx", - "src/components/clinical-dashboard/signed-image.tsx", - "src/components/in-page-nav/in-page-nav-header.tsx" + "src/components/clinical-dashboard/signed-image.tsx" ], "designSync": { "listedInSourceMap": true, @@ -1457,6 +1455,7 @@ "src/components/clinical-dashboard/document-search-results.tsx", "src/components/clinical-dashboard/image-lightbox.tsx", "src/components/clinical-dashboard/master-search-header.tsx", + "src/components/clinical-dashboard/medication-record-page.tsx", "src/components/clinical-dashboard/mode-action-popup.tsx", "src/components/clinical-dashboard/result-filter-control.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", @@ -1483,6 +1482,7 @@ "src/components/clinical-dashboard/document-search-results.tsx", "src/components/clinical-dashboard/image-lightbox.tsx", "src/components/clinical-dashboard/master-search-header.tsx", + "src/components/clinical-dashboard/medication-record-page.tsx", "src/components/clinical-dashboard/mode-action-popup.tsx", "src/components/clinical-dashboard/result-filter-control.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 1328a95cc6..43ba61f38f 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -151,11 +151,12 @@ so it was left alone. **Adopted so far:** `/differentials/diagnoses/[slug]`, `/services/[slug]`, `/forms/[slug]`, `/specifiers/[slug]` (record and catalogue reference), `/formulation/[slug]`, -`/dsm/diagnoses/[slug]` and its `/differentials` child, and factsheet detail pages -(`/factsheets/[slug]`). Each is also listed in +`/dsm/diagnoses/[slug]` and its `/differentials` child, `/factsheets/[slug]`, and +`/medications/[slug]`. Each is also listed in `isHeaderAddonSlotOwnedRoute` (`src/components/mode-nav/header-addon-slot.ts`), which is how -the one-header-per-slot rule stays checkable. Still on their own patterns: medications -(`SectionTabs`) and differentials presentations. +the one-header-per-slot rule stays checkable. Two routes remain outside the template on +purpose: `DocumentViewer` (decided above) and the differentials presentations workflow +(decided below). **Visual slots (adapt labels, back href, sections, and actions to the mode):** @@ -179,7 +180,7 @@ the one-header-per-slot rule stays checkable. Still on their own patterns: medic - Do not give the in-page header its own scroll-hide hook; share the universal collapse signal described under “Scroll hide/reveal”. -**The breadcrumb shape (pages with no section index).** The eight record pages behind +**The breadcrumb shape (pages with no section index).** The record pages behind `InformationPageBreadcrumbs` have no sections, so the disclosure would open a sheet listing one item and the track would render one full-width segment. Omit `sections` and `InPageNavHeader` drops both and renders the breadcrumb shape instead — same row grammar, @@ -190,22 +191,119 @@ shape that row: - `showBackLabel={false}` keeps the arrow alone at every width when the row also carries an action or a mode, so the title owns the space. `back.label` is still the accessible name and becomes the desktop tooltip. -- `primaryAction` promotes exactly **one** page action, as `Button variant="secondary"` — - not the filled `--command` slab, because a control pinned to every scroll position should - not be the page's heaviest. Its label is `sr-only` below `sm` so the accessible name does - not change with the breakpoint. A second promoted control is what turns the row back into - the wrapping toolbar this shape replaced; everything else belongs in `actions`. +- `primaryAction` promotes exactly **one** page action. It is **not** the filled `--command` + slab, because a control pinned to every scroll position should not be the page's heaviest. + Its label is `sr-only` below `sm` so the accessible name does not change with the + breakpoint; `primaryActionIconOnly` drops the label at every width for a glyph that carries + its own meaning (the patients control on medications). A second promoted control is what + turns the row back into the wrapping toolbar this shape replaced; everything else belongs + in `actions`. +- **`primaryAction` and `actions` render as one joined group**, not two free-standing + controls: a single border and radius around both, with a hairline between them. A bordered + promoted action beside a borderless ellipsis reads as two unrelated things competing at the + end of the row — which is what `/factsheets/[slug]` shipped before this. A lone `actions` + trigger still gets the group's border, so the two cases look like the same control. - `mode` is a page-level **view** mode — how the page renders, not where you are in it — and - uses the shared `SegmentedControl`. Below `sm` it wraps to its own full-width band under - the row; from `sm` it sits inline and costs no extra height (measured on - `/factsheets/sertraline`: 131px phone, 75px from `sm`, 65px with no mode). - -Adopted by `src/components/factsheets/factsheet-detail-page.tsx`. When a page adopts this, + uses the shared `SegmentedControl`. From `sm` it sits inline in the row and costs no extra + height. Below `sm` it renders **inside the actions sheet** under its own label, because a + mode you set once and then read past does not earn permanent pinned chrome on the smallest + screen; the full-width band it used to claim was the only second phone row on any converted + page. Both copies are always in the DOM with CSS choosing one per breakpoint, so there is no + state to keep in step. `mode` therefore **requires `actions`** — with no sheet to move into, + a phone would have no way to reach it. + +Used by `medication-nav-header.tsx` while the record is still loading (no record, no +sections) and by `factsheet-nav-header.tsx` for the seven non-`medRich` sheets, which carry +one reading level and so pass no `mode`. When a page adopts this, register its routes in `isHeaderAddonSlotOwnedRoute` (`src/components/mode-nav/header-addon-slot.ts`) and add the component to the expected claimants in `tests/mode-nav-addon-slot.dom.test.tsx`, or that guard fails: the slot holds exactly one page-owned header. +### Panel-swap adopters: the track drives tabs, and the sections carry no anchor + +Two adopters exchange a panel rather than scrolling: `/differentials/diagnoses/[slug]` +(`differential-detail-page.tsx`) and `/medications/[slug]` +(`medication-nav-header.tsx`). Their `PageSection.id` is the tab id, not a DOM anchor id, +and the rules above change in three specific ways: + +- **Pass explicit `weight`s.** `usePageSectionWeights` measures rendered heights and only + the active panel is ever rendered, so measurement would report one full-width segment + beside three empty ones. Both routes derive weights from what each panel holds — section + counts, with a floor so an empty tab stays visible and a cap so one dense tab does not + squeeze the rest to hairlines. +- **No `inPageAnchor`, and no `useInPageSectionNav`.** There is nothing to scroll to, so + there is no scroll margin to set and no scroll spy to run: `activeId` is the active tab + and `onSelectSection` sets it. `useResolvedPageSections` must not be used either — it + would drop the three tabs whose panels are not currently mounted and collapse the track to + one segment. (`differential-detail-page.tsx`'s existing `scroll-mt-24` values are inside + panel bodies and are unrelated; leave them.) +- **Never claim `collapsible`.** The trailing chevron in `DocumentSectionList` means "this + row opens an accordion". Selecting a tab swaps a panel instead. + +The panel is not a `tabpanel` and its control is not a `tab`: the section list is a list of +buttons, so a `role="tab"` / `aria-controls` pair would name a tablist that no longer exists. +Keep a per-tab `id` on the panel — that is the rendered evidence a declared section resolves +to something real, which is what the panel-swap half of +`tests/in-page-nav-route-sections.dom.test.tsx` asserts in place of the anchor check. + +**The two-rail variant (`rail`).** A panel-swap route with few enough sections to name in a +row may pass `rail={{ label }}` and get `InPageSectionRail` in place of the weighted track: +icon, label and a `count` badge per section, active one underlined. `/medications/[slug]` is +the only adopter, and the prop exists so it stays the only one by choice rather than by +drift — `/factsheets/[slug]`'s eight anchored sections would overflow the row this is meant +to simplify, and a scrolling route already has a spy moving the active state continuously. + +The rail changes three things about the row above it: + +- **From `sm` the title stops being a disclosure.** Every section is already named in the + rail, so the chevron would open a list of the same destinations. The title renders as plain + text and the section sheet is unreachable. Below `sm` the rail scrolls, so the disclosure + returns as its overflow — that is the "two rails" shape. +- **The rail is not a `role="tablist"`.** The same sections are reachable from the sheet on a + phone, so a roving-tabindex group would put half the destinations behind arrow keys and half + behind Tab. Ordinary buttons are reachable both ways. +- **`count` is a separate field from `detail`.** The sheet row has space for "3 sections" and + the badge does not; parsing digits back out of the prose would break the first time a route + worded its detail differently. + +Rail items are `min-h-12` like every other production tap target. Two rails are tall on a +phone and `min-h-11` would buy back 4px per rail — do not take it. That is the substitution +`AGENTS.md` calls out, and it reintroduces a known `ui-smoke` sub-pixel flake. + +### The differentials presentations workflow keeps its own layout — decided, not pending + +`src/components/differentials/differential-presentation-workflow-page.tsx` is **not** being +converted, and this is a decision rather than a backlog item. Do not re-open it without new +facts against the three reasons below. + +The premise that it was a conversion candidate does not survive reading it. It was carried +forward as "a `SectionTabs` page that swaps panels"; it swaps nothing. Its `MobileTabs` is +four ``s to **other routes** — the diagnosis detail page, its `?tab=map` and +`?tab=related` views, and the compare route — with "Compare" hardcoded as the active one. +That is mode-level page switching, which the "Not this template" note below already carves +out for Therapy-style `ModeNav`, and the same carve-out covers this row. + +1. **It is a comparison workspace, not a reading spine.** Every candidate section — + `SafetySnapshot`, the comparison table, `ReviewPanels`, `SourceStatusPanel` — is rendered + two or three times at different breakpoints, in different DOM parents: an `xl` sidebar + `