diff --git a/.github/actions/setup-ui-e2e/action.yml b/.github/actions/setup-ui-e2e/action.yml index 571d7a8c59..2faae6d790 100644 --- a/.github/actions/setup-ui-e2e/action.yml +++ b/.github/actions/setup-ui-e2e/action.yml @@ -23,13 +23,27 @@ runs: playwright-chromium-${{ runner.os }}- # The browser archive is cached, but apt libraries are runner-local and must be - # installed even on a browser-cache hit. + # installed even on a browser-cache hit. `install-deps` shells out to `apt-get + # update`, which occasionally hangs indefinitely against the runner's default + # azure.archive.ubuntu.com mirror (no timeout of its own) and silently burns the + # whole job until something external cancels it. Bound each attempt and retry so a + # stuck mirror fails fast instead of stalling the job for the better part of an hour. - name: Install Chromium shell: bash run: | - if [ "${{ steps.pw-cache.outputs.cache-hit }}" = "true" ]; then - npx playwright install-deps chromium - npx playwright install chromium - else - npx playwright install --with-deps chromium - fi + install_chromium() { + if [ "${{ steps.pw-cache.outputs.cache-hit }}" = "true" ]; then + npx playwright install-deps chromium + npx playwright install chromium + else + npx playwright install --with-deps chromium + fi + } + for attempt in 1 2 3; do + if timeout 180 bash -c "$(declare -f install_chromium); install_chromium"; then + exit 0 + fi + echo "Chromium install attempt $attempt timed out or failed, retrying..." >&2 + sleep 5 + done + exit 1 diff --git a/bundle-budget.json b/bundle-budget.json index 4027866bb0..a3454a034e 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -14,21 +14,9 @@ "gzipBytes": 203889, "tolerancePct": 10 }, - "/therapy-compass": { - "gzipBytes": 210999, - "tolerancePct": 10 - }, "/documents/search": { "gzipBytes": 206572, "tolerancePct": 10 - }, - "/dsm": { - "gzipBytes": 206586, - "tolerancePct": 10 - }, - "/forms": { - "gzipBytes": 230048, - "tolerancePct": 10 } }, "totalGzipBytes": 1708296, diff --git a/docs/branch-review-records/8422113687c1731c49a6de6c7be1262f126e2360c2246fc0f1ef223327f14e67.record.md b/docs/branch-review-records/8422113687c1731c49a6de6c7be1262f126e2360c2246fc0f1ef223327f14e67.record.md new file mode 100644 index 0000000000..1dc35e262c --- /dev/null +++ b/docs/branch-review-records/8422113687c1731c49a6de6c7be1262f126e2360c2246fc0f1ef223327f14e67.record.md @@ -0,0 +1 @@ +| 2026-08-19 | claude/lightweight-mode-homes | d2d7f3d361f89fe45791b5b79074804d718e4b9f | PR #2157 lightweight mode homes consolidation | Reviewed and fixed: PR policy body was empty, blocking merge. Restored Documents mode's dedicated workspace after another automated pass folded it into the generic shared home and deleted real functionality (recent documents, browse library, open-a-source-PDF) with no equivalent replacement -- confirmed live via browser and Playwright, and confirmed the account owner never actually approved that loss. Reverted a redirect-on-empty-query regression on differentials/formulation/specifiers search routes that two separate automated passes introduced, both breaking a pinned Playwright phone-scroll spec that neither pass's Vitest-only verification could catch. Fixed stale docs/comments (Therapy devOnly rationale, route tables, site-map descriptions). Full unit suite green (683 files), targeted Playwright verification on Chromium green for the restored Documents spec. | npm run test (683 files, full suite green); 187 targeted vitest re-runs across every touched file; npx tsc typecheck clean; npx eslint clean; npx prettier --check clean; Playwright tests/ui-smoke.spec.ts Documents @critical spec run live against dev server -- chromium passes end to end, webkit fails one narrow cross-browser focus-restoration assertion (non-functional) | diff --git a/docs/branch-review-records/f3e3e6440a98a6ad538046cf6ae0558d2f323059a1a570a19d995a15c9b844a5.record.md b/docs/branch-review-records/f3e3e6440a98a6ad538046cf6ae0558d2f323059a1a570a19d995a15c9b844a5.record.md new file mode 100644 index 0000000000..2012e78a89 --- /dev/null +++ b/docs/branch-review-records/f3e3e6440a98a6ad538046cf6ae0558d2f323059a1a570a19d995a15c9b844a5.record.md @@ -0,0 +1 @@ +| 2026-08-18 | claude/lightweight-mode-homes (PR #2157) | 1cffafbc172fc22625aa3cba62d5d685b7720f25 | Run PR sweep: CI fix + threads + drift | Before: PR mergeability/PR policy transiently failed (self-healed on retry, no real conflict); Production UI (3) failed 3 Chromium specs (ui-specifiers.spec.ts mobile composer, 2x ui-tools.spec.ts stale /differentials and /services route assertions) caused by this PR's own consolidated-mode-home routing change, not main drift. Fixed: updated the 3 stale test assertions to match the new /differentials/search, /?mode=services, and /specifiers/search routes already established elsewhere in this PR's own diff. After: fixes pushed, fresh CI re-running. Still blocked on a blank Clinical Governance Preflight in the PR body (human-owned, not auto-fillable) and a real Lighthouse TBT regression pattern matching known main-wide drift from a prior merge. | npx tsc --noEmit (clean), npx eslint tests/ui-tools.spec.ts tests/ui-specifiers.spec.ts (clean), npx prettier --write (no changes needed); Playwright not run locally (browser revision mismatch, chromium-1194 vs CI's 1234) — delegated to CI Production UI. No provider-backed checks run. | diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 2b345f221f..d0a6f575d2 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -85,7 +85,7 @@ Smaller top-level directories that are easy to miss: | `/privacy` | `src/app/privacy/page.tsx` → `privacy-quiet-signal-page.tsx` + `privacy-page-content.tsx` | | `/reference/colour-coding` | `src/app/reference/` | | `/safety-plan` | `src/app/safety-plan/page.tsx` | -| `/calculators` | `src/app/(search-app)/calculators/page.tsx` | +| `/calculators`, `/calculators/search` | `src/app/(search-app)/calculators/` | | `/services`, `/services/[slug]` | `src/app/(search-app)/services/` | | `/therapy-compass` | `src/app/(search-app)/therapy-compass/` | | `/tools` | `src/app/(search-app)/tools/` | @@ -348,10 +348,10 @@ visible reasons and a human confirms or overrides. One shared composer (`master-search-header.tsx`) serves every mode. Placement: -- **Mode homes** (`/services`, `/forms`, `/favourites`, `/differentials`, `/dsm`, `/specifiers`, `/formulation`, `/factsheets`, `/dictionary`, `/therapy-compass`, `/tools`, and dashboard homes): inline in the hero via the `mode-home-composer-slot` portal, on phone and tablet+ alike. (`/applications` is a redirect to `/tools`, not a composer surface.) +- **Mode homes**: the one shared home at `/?mode=`, plus the three routes that still own a home of their own — `/medications` (the prescribing workspace, with dose/safety/monitoring checks), `/favourites` (a hub) and `/tools` (a launcher). None of those three is a duplicate of the shared home; each is its mode's only functional surface. Composer inline in the hero via the `mode-home-composer-slot` portal, on phone and tablet+ alike. The other eleven modes were consolidated onto the shared home: `/services`, `/forms`, `/differentials`, `/dsm`, `/specifiers`, `/formulation`, `/calculators`, `/factsheets`, `/dictionary`, `/therapy-compass` and `/documents` are now `redirect()` stubs (`src/lib/consolidated-mode-home-redirect.ts`, resolved in `src/proxy.ts` so they emit a real 307 rather than a streamed meta-refresh). Their per-mode copy is `sharedHomePresentation` in `src/lib/ui-copy.ts`; the retired detailed pages are preserved off the live routes at `/mockups/-home-detailed`. (`/applications` is a redirect to `/tools`, not a composer surface.) - **Information (detail) pages**: catalogue/record routes under each mode (`/services/[slug]`, `/forms/[slug]`, `/medications/[slug]`, `/specifiers/[slug]`, `/formulation/[slug]`, `/factsheets/[slug]`, `/dictionary/[slug]`, `/dictionary/topics/[slug]`, `/therapy-compass/[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 and the 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). +- **Results routing**: each consolidated mode owns its submitted searches at `/search` (`/services/search` → `ServicesNavigatorPage`, `/forms/search` → `FormsSearchResultsPage`, `/differentials/search` → `DifferentialsHome` results view, `/formulation/search` → local mechanism results, and the same shape for dsm, dictionary, factsheets, specifiers, calculators, therapy-compass and documents). That split is not cosmetic: the bare path redirects to the shared home, so routing a submitted query back at it would loop — `consolidatedModeHomeModeIds` drives both halves from one list, and `tests/consolidated-mode-home-redirect.test.ts` pins the no-loop property. `/favourites` and `/tools` keep filtering in place on their own routes. 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 onward to the mode's own search surface. - **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; `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 `dictionary/dictionary-term-page.tsx`, Dictionary topic detail in `dictionary/dictionary-catalogue-pages.tsx`, `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) and focused Dictionary DOM contracts. - **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. diff --git a/docs/design-system/ADOPTION.md b/docs/design-system/ADOPTION.md index 68f9a634f0..0d9b3bd65c 100644 --- a/docs/design-system/ADOPTION.md +++ b/docs/design-system/ADOPTION.md @@ -359,12 +359,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: 54 -Declared product roots: 82 +Declared product roots: 88 Roots with a literal `.ckb-v2` opt-in: 1 -Roots inheriting `.ckb-v2` from the global ``: 81 +Roots inheriting `.ckb-v2` from the global ``: 87 Production surfaces observed under v2: 15/15 Dynamic `ckb-v2` constructions: 0 -Declared production page routes: 69/69 +Declared production page routes: 75/75 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. @@ -373,15 +373,15 @@ Observed v2 under a compatibility declaration fails closed. A declared v2 shell | Surface | Disposition | Routes | Roots | Declared shell | Observed shell (mount) | Proof | Baseline | | ---------------------------------- | --------------- | -----: | ----: | -------------- | -------------------------- | -------------- | -------------- | | `root-shell-and-settings` | shared-shell | 3 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | -| `catalogues-forms-and-info` | owned | 21 | 21 | 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 | +| `catalogues-forms-and-info` | owned | 23 | 23 | v2 | v2 (inherited-global-root) | passed | not-committed | +| `differentials` | owned | 7 | 7 | v2 | v2 (inherited-global-root) | passed | not-committed | +| `formulation` | owned | 6 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | +| `specifiers` | owned | 6 | 6 | v2 | v2 (inherited-global-root) | passed | not-committed | | `therapy-compass` | owned | 9 | 10 | v2 | v2 (inherited-global-root) | passed | not-committed | | `documents-and-source-evidence` | owned | 3 | 4 | v2 | v2 (inherited-global-root) | passed | not-committed | | `documents-source-legacy-redirect` | legacy-redirect | 1 | 0 | v2 | v2 (inherited-global-root) | not-applicable | not-applicable | | `favourites` | owned | 1 | 1 | v2 | v2 (inherited-global-root) | passed | not-committed | -| `tools-and-calculators` | owned | 2 | 2 | v2 | v2 (inherited-global-root) | passed | not-committed | +| `tools-and-calculators` | owned | 3 | 3 | v2 | v2 (inherited-global-root) | passed | not-committed | | `ward-management` | owned | 10 | 15 | v2 | v2 (inherited-global-root) | passed | not-committed | | `privacy-safety-and-reference` | owned | 3 | 3 | v2 | v2 (inherited-global-root) | passed | not-committed | | `search-results-shared` | shared-shell | 0 | 1 | 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 71e9293e0e..f2a3cf587d 100644 --- a/docs/design-system/adoption-contract.json +++ b/docs/design-system/adoption-contract.json @@ -136,9 +136,11 @@ "src/app/(search-app)/factsheets/search/page.tsx", "src/app/(search-app)/forms/[slug]/page.tsx", "src/app/(search-app)/forms/page.tsx", + "src/app/(search-app)/forms/search/page.tsx", "src/app/(search-app)/medications/[slug]/page.tsx", "src/app/(search-app)/services/[slug]/page.tsx", - "src/app/(search-app)/services/page.tsx" + "src/app/(search-app)/services/page.tsx", + "src/app/(search-app)/services/search/page.tsx" ], "routeRoots": true, "roots": [], @@ -181,7 +183,8 @@ "src/app/(search-app)/differentials/diagnoses/page.tsx", "src/app/(search-app)/differentials/page.tsx", "src/app/(search-app)/differentials/presentations/[slug]/page.tsx", - "src/app/(search-app)/differentials/presentations/page.tsx" + "src/app/(search-app)/differentials/presentations/page.tsx", + "src/app/(search-app)/differentials/search/page.tsx" ], "routeRoots": true, "roots": [], @@ -223,7 +226,8 @@ "src/app/(search-app)/formulation/builder/page.tsx", "src/app/(search-app)/formulation/compare/page.tsx", "src/app/(search-app)/formulation/map/page.tsx", - "src/app/(search-app)/formulation/page.tsx" + "src/app/(search-app)/formulation/page.tsx", + "src/app/(search-app)/formulation/search/page.tsx" ], "routeRoots": true, "roots": [], @@ -265,7 +269,8 @@ "src/app/(search-app)/specifiers/builder/page.tsx", "src/app/(search-app)/specifiers/compare/page.tsx", "src/app/(search-app)/specifiers/map/page.tsx", - "src/app/(search-app)/specifiers/page.tsx" + "src/app/(search-app)/specifiers/page.tsx", + "src/app/(search-app)/specifiers/search/page.tsx" ], "routeRoots": true, "roots": [], @@ -461,7 +466,11 @@ { "id": "tools-and-calculators", "disposition": "owned", - "routes": ["src/app/(search-app)/calculators/page.tsx", "src/app/(search-app)/tools/page.tsx"], + "routes": [ + "src/app/(search-app)/calculators/page.tsx", + "src/app/(search-app)/calculators/search/page.tsx", + "src/app/(search-app)/tools/page.tsx" + ], "routeRoots": true, "roots": [], "permittedComponentFamilies": ["controls", "feedback", "layout"], diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 0977e7567a..7312ed6923 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -2226,9 +2226,11 @@ "src/app/(search-app)/factsheets/search/page.tsx", "src/app/(search-app)/forms/[slug]/page.tsx", "src/app/(search-app)/forms/page.tsx", + "src/app/(search-app)/forms/search/page.tsx", "src/app/(search-app)/medications/[slug]/page.tsx", "src/app/(search-app)/services/[slug]/page.tsx", - "src/app/(search-app)/services/page.tsx" + "src/app/(search-app)/services/page.tsx", + "src/app/(search-app)/services/search/page.tsx" ], "routeRoots": true, "proofApplicability": "required", @@ -2306,7 +2308,7 @@ "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": [] + "sanctionedPatternsPresent": ["ModeHome"] }, { "file": "src/app/(search-app)/dictionary/search/page.tsx", @@ -2386,7 +2388,7 @@ "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": [] + "sanctionedPatternsPresent": ["ModeHome"] }, { "file": "src/app/(search-app)/dsm/search/page.tsx", @@ -2416,7 +2418,7 @@ "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": [] + "sanctionedPatternsPresent": ["ModeHome"] }, { "file": "src/app/(search-app)/factsheets/search/page.tsx", @@ -2446,7 +2448,17 @@ "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": [] + "sanctionedPatternsPresent": ["ModeHome"] + }, + { + "file": "src/app/(search-app)/forms/search/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["ModeHome"] }, { "file": "src/app/(search-app)/medications/[slug]/page.tsx", @@ -2476,7 +2488,17 @@ "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": [] + "sanctionedPatternsPresent": ["ModeHome"] + }, + { + "file": "src/app/(search-app)/services/search/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["ModeHome"] } ] }, @@ -2490,7 +2512,8 @@ "src/app/(search-app)/differentials/diagnoses/page.tsx", "src/app/(search-app)/differentials/page.tsx", "src/app/(search-app)/differentials/presentations/[slug]/page.tsx", - "src/app/(search-app)/differentials/presentations/page.tsx" + "src/app/(search-app)/differentials/presentations/page.tsx", + "src/app/(search-app)/differentials/search/page.tsx" ], "routeRoots": true, "proofApplicability": "required", @@ -2589,6 +2612,16 @@ "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", "sanctionedPatternsPresent": ["Differential"] + }, + { + "file": "src/app/(search-app)/differentials/search/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["Differential"] } ] }, @@ -2601,7 +2634,8 @@ "src/app/(search-app)/formulation/builder/page.tsx", "src/app/(search-app)/formulation/compare/page.tsx", "src/app/(search-app)/formulation/map/page.tsx", - "src/app/(search-app)/formulation/page.tsx" + "src/app/(search-app)/formulation/page.tsx", + "src/app/(search-app)/formulation/search/page.tsx" ], "routeRoots": true, "proofApplicability": "required", @@ -2690,6 +2724,16 @@ "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", "sanctionedPatternsPresent": ["Formulation"] + }, + { + "file": "src/app/(search-app)/formulation/search/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["Formulation"] } ] }, @@ -2702,7 +2746,8 @@ "src/app/(search-app)/specifiers/builder/page.tsx", "src/app/(search-app)/specifiers/compare/page.tsx", "src/app/(search-app)/specifiers/map/page.tsx", - "src/app/(search-app)/specifiers/page.tsx" + "src/app/(search-app)/specifiers/page.tsx", + "src/app/(search-app)/specifiers/search/page.tsx" ], "routeRoots": true, "proofApplicability": "required", @@ -2791,6 +2836,16 @@ "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", "sanctionedPatternsPresent": ["Specifier"] + }, + { + "file": "src/app/(search-app)/specifiers/search/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["Specifier"] } ] }, @@ -3154,7 +3209,11 @@ "id": "tools-and-calculators", "disposition": "owned", "documentedDisposition": null, - "routes": ["src/app/(search-app)/calculators/page.tsx", "src/app/(search-app)/tools/page.tsx"], + "routes": [ + "src/app/(search-app)/calculators/page.tsx", + "src/app/(search-app)/calculators/search/page.tsx", + "src/app/(search-app)/tools/page.tsx" + ], "routeRoots": true, "proofApplicability": "required", "nonVisualRoute": null, @@ -3201,6 +3260,16 @@ "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [] + }, + { + "file": "src/app/(search-app)/calculators/search/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", "sanctionedPatternsPresent": ["CalculatorsSearchPage"] }, { @@ -3692,6 +3761,7 @@ "routeCoverage": { "discovered": [ "src/app/(search-app)/calculators/page.tsx", + "src/app/(search-app)/calculators/search/page.tsx", "src/app/(search-app)/dictionary/[slug]/page.tsx", "src/app/(search-app)/dictionary/browse/page.tsx", "src/app/(search-app)/dictionary/compare/page.tsx", @@ -3706,6 +3776,7 @@ "src/app/(search-app)/differentials/page.tsx", "src/app/(search-app)/differentials/presentations/[slug]/page.tsx", "src/app/(search-app)/differentials/presentations/page.tsx", + "src/app/(search-app)/differentials/search/page.tsx", "src/app/(search-app)/documents/[id]/page.tsx", "src/app/(search-app)/documents/page.tsx", "src/app/(search-app)/documents/search/page.tsx", @@ -3722,21 +3793,25 @@ "src/app/(search-app)/favourites/page.tsx", "src/app/(search-app)/forms/[slug]/page.tsx", "src/app/(search-app)/forms/page.tsx", + "src/app/(search-app)/forms/search/page.tsx", "src/app/(search-app)/formulation/[slug]/page.tsx", "src/app/(search-app)/formulation/builder/page.tsx", "src/app/(search-app)/formulation/compare/page.tsx", "src/app/(search-app)/formulation/map/page.tsx", "src/app/(search-app)/formulation/page.tsx", + "src/app/(search-app)/formulation/search/page.tsx", "src/app/(search-app)/medications/[slug]/page.tsx", "src/app/(search-app)/medications/page.tsx", "src/app/(search-app)/page.tsx", "src/app/(search-app)/services/[slug]/page.tsx", "src/app/(search-app)/services/page.tsx", + "src/app/(search-app)/services/search/page.tsx", "src/app/(search-app)/specifiers/[slug]/page.tsx", "src/app/(search-app)/specifiers/builder/page.tsx", "src/app/(search-app)/specifiers/compare/page.tsx", "src/app/(search-app)/specifiers/map/page.tsx", "src/app/(search-app)/specifiers/page.tsx", + "src/app/(search-app)/specifiers/search/page.tsx", "src/app/(search-app)/therapy-compass/[slug]/brief/page.tsx", "src/app/(search-app)/therapy-compass/[slug]/page.tsx", "src/app/(search-app)/therapy-compass/[slug]/sheet/page.tsx", @@ -3763,6 +3838,7 @@ ], "declared": [ "src/app/(search-app)/calculators/page.tsx", + "src/app/(search-app)/calculators/search/page.tsx", "src/app/(search-app)/dictionary/[slug]/page.tsx", "src/app/(search-app)/dictionary/browse/page.tsx", "src/app/(search-app)/dictionary/compare/page.tsx", @@ -3777,6 +3853,7 @@ "src/app/(search-app)/differentials/page.tsx", "src/app/(search-app)/differentials/presentations/[slug]/page.tsx", "src/app/(search-app)/differentials/presentations/page.tsx", + "src/app/(search-app)/differentials/search/page.tsx", "src/app/(search-app)/documents/[id]/page.tsx", "src/app/(search-app)/documents/page.tsx", "src/app/(search-app)/documents/search/page.tsx", @@ -3793,21 +3870,25 @@ "src/app/(search-app)/favourites/page.tsx", "src/app/(search-app)/forms/[slug]/page.tsx", "src/app/(search-app)/forms/page.tsx", + "src/app/(search-app)/forms/search/page.tsx", "src/app/(search-app)/formulation/[slug]/page.tsx", "src/app/(search-app)/formulation/builder/page.tsx", "src/app/(search-app)/formulation/compare/page.tsx", "src/app/(search-app)/formulation/map/page.tsx", "src/app/(search-app)/formulation/page.tsx", + "src/app/(search-app)/formulation/search/page.tsx", "src/app/(search-app)/medications/[slug]/page.tsx", "src/app/(search-app)/medications/page.tsx", "src/app/(search-app)/page.tsx", "src/app/(search-app)/services/[slug]/page.tsx", "src/app/(search-app)/services/page.tsx", + "src/app/(search-app)/services/search/page.tsx", "src/app/(search-app)/specifiers/[slug]/page.tsx", "src/app/(search-app)/specifiers/builder/page.tsx", "src/app/(search-app)/specifiers/compare/page.tsx", "src/app/(search-app)/specifiers/map/page.tsx", "src/app/(search-app)/specifiers/page.tsx", + "src/app/(search-app)/specifiers/search/page.tsx", "src/app/(search-app)/therapy-compass/[slug]/brief/page.tsx", "src/app/(search-app)/therapy-compass/[slug]/page.tsx", "src/app/(search-app)/therapy-compass/[slug]/sheet/page.tsx", @@ -3838,7 +3919,7 @@ }, "adoption": { "literalCkbV2RootCount": 1, - "inheritedCkbV2RootCount": 81, + "inheritedCkbV2RootCount": 87, "dynamicCkbV2RootCount": 0, "v2MountedSurfaceCount": 15, "declaredV2SurfaceCount": 15 @@ -3847,8 +3928,8 @@ "registeredComponentCount": 54, "previewCount": 54, "productImportedComponentCount": 34, - "rootCount": 82, - "productionRouteCount": 69, - "nextUiEntryCount": 122 + "rootCount": 88, + "productionRouteCount": 75, + "nextUiEntryCount": 128 } } diff --git a/docs/site-map.md b/docs/site-map.md index d27de43d4e..58df1f6c94 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -5,8 +5,9 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir ## Main product routes - `/` - 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`. -- `/dictionary` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/page.tsx`. +- `/calculators` - Compatibility redirect to the shared home at `/?mode=calculators`; a submitted `?q=…&run=1` forwards to `/calculators/search`. Source: `src/app/(search-app)/calculators/page.tsx`. +- `/calculators/search` - Submitted calculators results. An empty query forwards to `/?mode=calculators` so the retired mode home is not rendered a second time. Source: `src/app/(search-app)/calculators/search/page.tsx`. +- `/dictionary` - Compatibility redirect to the shared home at `/?mode=dictionary`; a submitted `?q=…&run=1` forwards to `/dictionary/search`. Source: `src/app/(search-app)/dictionary/page.tsx`. - `/dictionary/[slug]` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/[slug]/page.tsx`. - `/dictionary/browse` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/browse/page.tsx`. - `/dictionary/compare` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/compare/page.tsx`. @@ -14,37 +15,42 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/dictionary/sources` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/sources/page.tsx`. - `/dictionary/topics` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/topics/page.tsx`. - `/dictionary/topics/[slug]` - Route discovered from app directory Source: `src/app/(search-app)/dictionary/topics/[slug]/page.tsx`. -- `/differentials` - Differentials home and search surface. Source: `src/app/(search-app)/differentials/page.tsx`. +- `/differentials` - Compatibility redirect to the shared home at `/?mode=differentials`; a submitted `?q=…&run=1` forwards to `/differentials/search`. Source: `src/app/(search-app)/differentials/page.tsx`. - `/differentials/compare` - Compare queue: empty state or selected diagnosis ids (Search edit links preserve ids); Open comparison launches a catalogue presentation workflow or an ad-hoc workspace (`workspace=1`). 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`. +- `/differentials/search` - Route discovered from app directory Source: `src/app/(search-app)/differentials/search/page.tsx`. - `/documents` - Route discovered from app directory Source: `src/app/(search-app)/documents/page.tsx`. - `/documents/search` - Documents search command centre. Source: `src/app/(search-app)/documents/search/page.tsx`. - `/documents/source` - Compatibility redirect to the canonical live document viewer when a valid id is supplied. Source: `src/app/(search-app)/documents/source/page.tsx`. - `/documents/source/evidence` - Compatibility redirect sharing the canonical live document viewer handoff. Source: `src/app/(search-app)/documents/source/evidence/page.tsx`. -- `/dsm` - DSM-5 Diagnosis home. Source: `src/app/(search-app)/dsm/page.tsx`. +- `/dsm` - Compatibility redirect to the shared home at `/?mode=dsm`; a submitted `?q=…&run=1` forwards to `/dsm/search`. Source: `src/app/(search-app)/dsm/page.tsx`. - `/dsm/compare` - DSM diagnosis comparison. Source: `src/app/(search-app)/dsm/compare/page.tsx`. - `/dsm/search` - DSM diagnosis search and catalogue browser. Source: `src/app/(search-app)/dsm/search/page.tsx`. -- `/factsheets` - Route discovered from app directory Source: `src/app/(search-app)/factsheets/page.tsx`. +- `/factsheets` - Compatibility redirect to the shared home at `/?mode=factsheets`; a submitted `?q=…&run=1` forwards to `/factsheets/search`. Source: `src/app/(search-app)/factsheets/page.tsx`. - `/factsheets/[slug]` - Route discovered from app directory Source: `src/app/(search-app)/factsheets/[slug]/page.tsx`. - `/factsheets/search` - Route discovered from app directory Source: `src/app/(search-app)/factsheets/search/page.tsx`. - `/favourites` - Saved clinical items and sets. Source: `src/app/(search-app)/favourites/page.tsx`. -- `/forms` - Forms home and search surface. Source: `src/app/(search-app)/forms/page.tsx`. -- `/formulation` - Clinical formulation home and local mechanism search surface. Source: `src/app/(search-app)/formulation/page.tsx`. +- `/forms` - Compatibility redirect to the shared home at `/?mode=forms`; a submitted `?q=…&run=1` forwards to `/forms/search`. Source: `src/app/(search-app)/forms/page.tsx`. +- `/forms/search` - Route discovered from app directory Source: `src/app/(search-app)/forms/search/page.tsx`. +- `/formulation` - Compatibility redirect to the shared home at `/?mode=formulation`; a submitted `?q=…&run=1` forwards to `/formulation/search`. Source: `src/app/(search-app)/formulation/page.tsx`. - `/formulation/builder` - Structured clinical formulation builder. Source: `src/app/(search-app)/formulation/builder/page.tsx`. - `/formulation/compare` - Side-by-side mechanism comparison. Source: `src/app/(search-app)/formulation/compare/page.tsx`. - `/formulation/map` - Formulation mechanism domain map. Source: `src/app/(search-app)/formulation/map/page.tsx`. +- `/formulation/search` - Route discovered from app directory Source: `src/app/(search-app)/formulation/search/page.tsx`. - `/medications` - Medication mode home. Source: `src/app/(search-app)/medications/page.tsx`. - `/privacy` - Privacy and data-processing governance draft. Source: `src/app/privacy/page.tsx`. - `/reference/colour-coding` - Route discovered from app directory Source: `src/app/reference/colour-coding/page.tsx`. - `/safety-plan` - Patient safety plan generator (Stanley-Brown six steps) — a Tools-page clinical tool. Source: `src/app/safety-plan/page.tsx`. -- `/services` - Services home and search surface. Source: `src/app/(search-app)/services/page.tsx`. -- `/specifiers` - Psychiatric specifier home and local search surface. Source: `src/app/(search-app)/specifiers/page.tsx`. +- `/services` - Compatibility redirect to the shared home at `/?mode=services`; a submitted `?q=…&run=1` forwards to `/services/search`. Source: `src/app/(search-app)/services/page.tsx`. +- `/services/search` - Route discovered from app directory Source: `src/app/(search-app)/services/search/page.tsx`. +- `/specifiers` - Compatibility redirect to the shared home at `/?mode=specifiers`; a submitted `?q=…&run=1` forwards to `/specifiers/search`. Source: `src/app/(search-app)/specifiers/page.tsx`. - `/specifiers/builder` - Structured diagnostic wording builder. Source: `src/app/(search-app)/specifiers/builder/page.tsx`. - `/specifiers/compare` - Side-by-side psychiatric specifier comparison. Source: `src/app/(search-app)/specifiers/compare/page.tsx`. - `/specifiers/map` - Psychiatric specifier family map. Source: `src/app/(search-app)/specifiers/map/page.tsx`. -- `/therapy-compass` - Therapy home (source-grounded therapy decision support). Source: `src/app/(search-app)/therapy-compass/page.tsx`. +- `/specifiers/search` - Route discovered from app directory Source: `src/app/(search-app)/specifiers/search/page.tsx`. +- `/therapy-compass` - Compatibility redirect to the shared home at `/?mode=therapy-compass`; a submitted `?q=…&run=1` forwards to `/therapy-compass/search`. Source: `src/app/(search-app)/therapy-compass/page.tsx`. - `/therapy-compass/[slug]/brief` - Therapy brief-intervention view. Source: `src/app/(search-app)/therapy-compass/[slug]/brief/page.tsx`. - `/therapy-compass/[slug]/sheet` - Therapy patient-sheet builder. Source: `src/app/(search-app)/therapy-compass/[slug]/sheet/page.tsx`. - `/therapy-compass/compare` - Side-by-side therapy comparison. Source: `src/app/(search-app)/therapy-compass/compare/page.tsx`. @@ -68,35 +74,39 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/?mode=answer` - Answer mode. Search kind: `answer`. Query example: `/?mode=answer&q=example+question&focus=1&run=1`. - `/documents` - Documents mode. Search kind: `documents`. Query example: `/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1`. -- `/services` - Services mode. Search kind: `services`. Query example: `/services?q=13YARN&focus=1&run=1`. -- `/forms` - Forms mode. Search kind: `forms`. Query example: `/forms?q=transport+forms&focus=1&run=1`. +- `/services` - Services mode. Search kind: `services`. Query example: `/services/search?q=13YARN&focus=1&run=1`. +- `/forms` - Forms mode. Search kind: `forms`. Query example: `/forms/search?q=transport+forms&focus=1&run=1`. - `/favourites` - Favourites mode. Search kind: `favourites`. Query example: `/favourites?q=clozapine+set&focus=1&run=1`. -- `/differentials` - Differentials mode. Search kind: `differentials`. Query example: `/differentials?q=acute+confusion&focus=1&run=1`. +- `/differentials` - Differentials mode. Search kind: `differentials`. Query example: `/differentials/search?q=acute+confusion&focus=1&run=1`. - `/dsm` - DSM-5 Diagnosis mode. Search kind: `dsm`. Query example: `/dsm/search?q=major+depressive+disorder&focus=1&run=1`. -- `/specifiers` - Specifiers mode. Search kind: `specifiers`. Query example: `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1`. -- `/formulation` - Formulation mode. Search kind: `formulation`. Query example: `/formulation?q=I+keep+going+over+it&focus=1&run=1`. +- `/specifiers` - Specifiers mode. Search kind: `specifiers`. Query example: `/specifiers/search?q=depressed+but+racing+thoughts&focus=1&run=1`. +- `/formulation` - Formulation mode. Search kind: `formulation`. Query example: `/formulation/search?q=I+keep+going+over+it&focus=1&run=1`. - `/medications` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. - `/tools` - Tools mode. Search kind: `tools`. Query example: `/tools?q=medications&focus=1&run=1`. -- `/calculators` - Calculators mode. Search kind: `calculators`. Query example: `/calculators?q=PHQ-9&focus=1&run=1`. +- `/calculators` - Calculators mode. Search kind: `calculators`. Query example: `/calculators/search?q=PHQ-9&focus=1&run=1`. - `/therapy-compass` - Therapy mode. Search kind: `therapies`. Query example: `/therapy-compass/search?q=behavioural+activation&focus=1&run=1`. - `/factsheets` - Factsheets mode. Search kind: `tools`. Query example: `/factsheets/search?q=sertraline&focus=1&run=1`. - `/dictionary` - Dictionary mode. Search kind: `tools`. Query example: `/dictionary/search?q=mental+state+examination&focus=1&run=1`. ## Mode page index -| Mode | Home page | Search/results page | Information/detail pages | -| --------------- | ---------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Answer | `/?mode=answer` | `/?mode=answer&q=example+question&focus=1&run=1` | Answer, citations, evidence, and source panels render inside the root dashboard shell. | -| Documents | `/documents` | `/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1` | `/documents/search` live results and `/documents/[id]` canonical viewer; `/documents/source*` are compatibility redirects. | -| Services | `/services` | `/services?q=13YARN&focus=1&run=1` | `/services/[slug]` service record pages. | -| Forms | `/forms` | `/forms?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | -| Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | -| Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, `/differentials/presentations`, `/differentials/presentations/[slug]`, and `/differentials/compare`. | -| DSM-5 Diagnosis | `/dsm` | `/dsm/search?q=major+depressive+disorder&focus=1&run=1` | `/dsm/diagnoses/[slug]`, `/dsm/compare`, and `/dsm/diagnoses/[slug]/differentials`. | -| Specifiers | `/specifiers` | `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1` | `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`. | -| Formulation | `/formulation` | `/formulation?q=I+keep+going+over+it&focus=1&run=1` | `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, and `/formulation/map`. | -| Medication | `/medications` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; submitted searches resolve to `/?mode=prescribing&q=…&run=1`. | -| Tools | `/tools` | `/tools?q=medications&focus=1&run=1` | Canonical all-tools results directory at `/tools`; the universal mode picker opens it directly. `/?mode=tools` remains a dashboard-mode alias. | +| Mode | Home page | Search/results page | Information/detail pages | +| --------------- | ------------------------ | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Answer | `/?mode=answer` | `/?mode=answer&q=example+question&focus=1&run=1` | Answer, citations, evidence, and source panels render inside the root dashboard shell. | +| Documents | `/documents` | `/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1` | `/documents/search` live results and `/documents/[id]` canonical viewer; `/documents/source*` are compatibility redirects. | +| Services | `/?mode=services` | `/services/search?q=13YARN&focus=1&run=1` | `/services/[slug]` service record pages. | +| Forms | `/?mode=forms` | `/forms/search?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | +| Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | +| Differentials | `/?mode=differentials` | `/differentials/search?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, `/differentials/presentations`, `/differentials/presentations/[slug]`, and `/differentials/compare`. | +| DSM-5 Diagnosis | `/?mode=dsm` | `/dsm/search?q=major+depressive+disorder&focus=1&run=1` | `/dsm/diagnoses/[slug]`, `/dsm/compare`, and `/dsm/diagnoses/[slug]/differentials`. | +| Specifiers | `/?mode=specifiers` | `/specifiers/search?q=depressed+but+racing+thoughts&focus=1&run=1` | `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`. | +| Formulation | `/?mode=formulation` | `/formulation/search?q=I+keep+going+over+it&focus=1&run=1` | `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, and `/formulation/map`. | +| Medication | `/medications` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; submitted searches resolve to `/?mode=prescribing&q=…&run=1`. | +| Tools | `/tools` | `/tools?q=medications&focus=1&run=1` | Canonical all-tools results directory at `/tools`; the universal mode picker opens it directly. `/?mode=tools` remains a dashboard-mode alias. | +| Calculators | `/?mode=calculators` | `/calculators/search?q=PHQ-9&focus=1&run=1` | `/calculators/search` scored results; an empty query forwards back to the shared home. | +| Factsheets | `/?mode=factsheets` | `/factsheets/search?q=sertraline&focus=1&run=1` | `/factsheets/search` is also a query-free browse surface linked from the mode nav; `/factsheets/[slug]` records. | +| Dictionary | `/?mode=dictionary` | `/dictionary/search?q=MSE&focus=1&run=1` | `/dictionary/search` is also a query-free browse surface; `/dictionary/browse`, `/topics`, `/topics/[slug]`, `/compare`, `/sources` and `/dictionary/[slug]` records. | +| Therapy | `/?mode=therapy-compass` | `/therapy-compass/search?q=CBT&focus=1&run=1` | Keeps a home of its own at `/therapy-compass`; `/search` (query-free browse), `/recommend`, `/compare`, `/pathways`, `/review`, and `/[slug]` records with `/brief` and `/sheet` outputs. | ## Documents flow index @@ -1011,6 +1021,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/calculators-clinical-console` - Route discovered from app directory Source: `src/app/mockups/calculators-clinical-console/page.tsx`. - `/mockups/calculators-directory-grid` - Route discovered from app directory Source: `src/app/mockups/calculators-directory-grid/page.tsx`. - `/mockups/calculators-guided-flow` - Route discovered from app directory Source: `src/app/mockups/calculators-guided-flow/page.tsx`. +- `/mockups/calculators-home-detailed` - Route discovered from app directory Source: `src/app/mockups/calculators-home-detailed/page.tsx`. - `/mockups/calculators-popup-sheet` - Route discovered from app directory Source: `src/app/mockups/calculators-popup-sheet/page.tsx`. - `/mockups/calculators-search` - Route discovered from app directory Source: `src/app/mockups/calculators-search/page.tsx`. - `/mockups/calculators-search-page` - Route discovered from app directory Source: `src/app/mockups/calculators-search-page/page.tsx`. @@ -1030,6 +1041,8 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/development` - Route discovered from app directory Source: `src/app/mockups/development/page.tsx`. - `/mockups/dictionary-browse-header` - Route discovered from app directory Source: `src/app/mockups/dictionary-browse-header/page.tsx`. - `/mockups/dictionary-browse-header-compact` - Route discovered from app directory Source: `src/app/mockups/dictionary-browse-header-compact/page.tsx`. +- `/mockups/dictionary-home-detailed` - Route discovered from app directory Source: `src/app/mockups/dictionary-home-detailed/page.tsx`. +- `/mockups/differentials-home-detailed` - Route discovered from app directory Source: `src/app/mockups/differentials-home-detailed/page.tsx`. - `/mockups/document-navigation-contract` - Route discovered from app directory Source: `src/app/mockups/document-navigation-contract/page.tsx`. - `/mockups/document-navigation-final` - Route discovered from app directory Source: `src/app/mockups/document-navigation-final/page.tsx`. - `/mockups/document-navigation-final-review` - Route discovered from app directory Source: `src/app/mockups/document-navigation-final-review/page.tsx`. @@ -1047,7 +1060,9 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/document-search/source-overlays` - Route discovered from app directory Source: `src/app/mockups/document-search/source-overlays/page.tsx`. - `/mockups/document-search/source/evidence` - Route discovered from app directory Source: `src/app/mockups/document-search/source/evidence/page.tsx`. - `/mockups/document-top-navigation` - Route discovered from app directory Source: `src/app/mockups/document-top-navigation/page.tsx`. +- `/mockups/dsm-home-detailed` - Route discovered from app directory Source: `src/app/mockups/dsm-home-detailed/page.tsx`. - `/mockups/factsheets-compact-view` - Route discovered from app directory Source: `src/app/mockups/factsheets-compact-view/page.tsx`. +- `/mockups/factsheets-home-detailed` - Route discovered from app directory Source: `src/app/mockups/factsheets-home-detailed/page.tsx`. - `/mockups/favourites-command-console` - Route discovered from app directory Source: `src/app/mockups/favourites-command-console/page.tsx`. - `/mockups/favourites-command-desk` - Route discovered from app directory Source: `src/app/mockups/favourites-command-desk/page.tsx`. - `/mockups/favourites-hub` - Route discovered from app directory Source: `src/app/mockups/favourites-hub/page.tsx`. @@ -1056,6 +1071,8 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/favourites-set-board` - Route discovered from app directory Source: `src/app/mockups/favourites-set-board/page.tsx`. - `/mockups/favourites-set-navigator` - Route discovered from app directory Source: `src/app/mockups/favourites-set-navigator/page.tsx`. - `/mockups/filter-sheet-restyle` - Route discovered from app directory Source: `src/app/mockups/filter-sheet-restyle/page.tsx`. +- `/mockups/forms-home-detailed` - Route discovered from app directory Source: `src/app/mockups/forms-home-detailed/page.tsx`. +- `/mockups/formulation-home-detailed` - Route discovered from app directory Source: `src/app/mockups/formulation-home-detailed/page.tsx`. - `/mockups/medication-prescribing` - Route discovered from app directory Source: `src/app/mockups/medication-prescribing/page.tsx`. - `/mockups/mode-dropdown` - Route discovered from app directory Source: `src/app/mockups/mode-dropdown/page.tsx`. - `/mockups/phone-inpage-navigation` - Route discovered from app directory Source: `src/app/mockups/phone-inpage-navigation/page.tsx`. @@ -1070,10 +1087,13 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/search-refine-adaptive` - Route discovered from app directory Source: `src/app/mockups/search-refine-adaptive/page.tsx`. - `/mockups/services-filter-options` - Route discovered from app directory Source: `src/app/mockups/services-filter-options/page.tsx`. - `/mockups/services-filter-refined` - Route discovered from app directory Source: `src/app/mockups/services-filter-refined/page.tsx`. +- `/mockups/services-home-detailed` - Route discovered from app directory Source: `src/app/mockups/services-home-detailed/page.tsx`. - `/mockups/settings-search-clinical` - Route discovered from app directory Source: `src/app/mockups/settings-search-clinical/page.tsx`. - `/mockups/settings-search-general` - Route discovered from app directory Source: `src/app/mockups/settings-search-general/page.tsx`. - `/mockups/settings-search-privacy` - Route discovered from app directory Source: `src/app/mockups/settings-search-privacy/page.tsx`. - `/mockups/sidebar-live` - Route discovered from app directory Source: `src/app/mockups/sidebar-live/page.tsx`. +- `/mockups/specifiers-home-detailed` - Route discovered from app directory Source: `src/app/mockups/specifiers-home-detailed/page.tsx`. +- `/mockups/therapy-compass-home-detailed` - Route discovered from app directory Source: `src/app/mockups/therapy-compass-home-detailed/page.tsx`. - `/mockups/therapy-navigation-context` - Route discovered from app directory Source: `src/app/mockups/therapy-navigation-context/page.tsx`. - `/mockups/therapy-navigation-dock` - Route discovered from app directory Source: `src/app/mockups/therapy-navigation-dock/page.tsx`. - `/mockups/therapy-navigation-rail` - Route discovered from app directory Source: `src/app/mockups/therapy-navigation-rail/page.tsx`. @@ -1147,9 +1167,19 @@ 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`. +- `/calculators` - Redirects to `/?mode=calculators`. Source: `src/app/(search-app)/calculators/page.tsx`. +- `/dictionary` - Redirects to `/?mode=dictionary`. Source: `src/app/(search-app)/dictionary/page.tsx`. +- `/differentials` - Redirects to `/?mode=differentials`. Source: `src/app/(search-app)/differentials/page.tsx`. - `/documents/source` - Redirects to `/documents/[id]`. Source: `src/app/(search-app)/documents/source/page.tsx`. +- `/dsm` - Redirects to `/?mode=dsm`. Source: `src/app/(search-app)/dsm/page.tsx`. +- `/factsheets` - Redirects to `/?mode=factsheets`. Source: `src/app/(search-app)/factsheets/page.tsx`. +- `/forms` - Redirects to `/?mode=forms`. Source: `src/app/(search-app)/forms/page.tsx`. +- `/formulation` - Redirects to `/?mode=formulation`. Source: `src/app/(search-app)/formulation/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`. +- `/services` - Redirects to `/?mode=services`. Source: `src/app/(search-app)/services/page.tsx`. +- `/specifiers` - Redirects to `/?mode=specifiers`. Source: `src/app/(search-app)/specifiers/page.tsx`. +- `/therapy-compass` - Redirects to `/?mode=therapy-compass`. Source: `src/app/(search-app)/therapy-compass/page.tsx`. ## Known caveats and stale-path flags diff --git a/lighthouse-budget.json b/lighthouse-budget.json index 5330144559..df1f922fcd 100644 --- a/lighthouse-budget.json +++ b/lighthouse-budget.json @@ -3,7 +3,7 @@ "enforce": true, "$lighthouseVersion": "Pinned exactly, not `lighthouse@12`: a patch published between a baseline run and its follow-up would silently change the measurement. Kept in step with LIGHTHOUSE_VERSION in .github/workflows/live-web-vitals.yml by tests/check-lighthouse-budget.test.ts so the two Lighthouse entry points cannot drift apart.", "lighthouseVersion": "12.8.2", - "routes": ["/", "/therapy-compass", "/documents/search", "/dsm", "/forms"], + "routes": ["/", "/documents/search"], "strategies": ["mobile", "desktop"], "tolerance": { "lcpMs": { @@ -26,20 +26,6 @@ "fcpMs": 340.1535, "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36" }, - "desktop-dsm": { - "lcpMs": 848.5132499999996, - "cls": 0.0137707091571825, - "tbtMs": 0, - "fcpMs": 385.2895, - "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36" - }, - "desktop-forms": { - "lcpMs": 756.1562, - "cls": 0.0633683272433484, - "tbtMs": 0, - "fcpMs": 360.5781, - "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36" - }, "desktop-root": { "lcpMs": 822.2584000000003, "cls": 0.007423798266351459, @@ -47,13 +33,6 @@ "fcpMs": 335.5646, "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36" }, - "desktop-therapy-compass": { - "lcpMs": 849.4647500000004, - "cls": 0, - "tbtMs": 0, - "fcpMs": 400.9859, - "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36" - }, "mobile-documents-search": { "lcpMs": 2271.699, "cls": 0, @@ -61,34 +40,14 @@ "fcpMs": 2271.699, "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36" }, - "mobile-dsm": { - "lcpMs": 2274.153, - "cls": 0.03530337546450838, - "tbtMs": 318.44699999999875, - "fcpMs": 2274.153, - "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36" - }, - "mobile-forms": { - "lcpMs": 2259.639, - "cls": 0.08828831243753035, - "tbtMs": 305.04699999999957, - "fcpMs": 2259.639, - "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36" - }, "mobile-root": { "lcpMs": 2252.856, "cls": 0.01575010076343992, "tbtMs": 296.28300000000127, "fcpMs": 2252.856, "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36" - }, - "mobile-therapy-compass": { - "lcpMs": 2274.772, - "cls": 0, - "tbtMs": 325.6850000000004, - "fcpMs": 2274.772, - "chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36" } }, - "updatedAt": "2026-08-18T16:00:09.578Z" + "updatedAt": "2026-08-18T16:00:09.578Z", + "$routes": "Only surfaces that still render. `/therapy-compass`, `/dsm` and `/forms` were removed when home consolidation turned them into redirect stubs: Lighthouse followed the 307 and graded `/?mode=` against a baseline captured on the retired detailed home, which the checker reports as \"measured a different page than requested\". All three now render the same shared home as `/`, so removing them costs duplication rather than coverage. Restoring per-mode coverage means measuring the `/search` results routes, and those need baseline rows the dispatch-only refresh job records \u2014 do that rather than hand-writing numbers." } diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 7d46982b30..52f042def7 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -4,6 +4,10 @@ import { pathToFileURL } from "node:url"; import { format } from "prettier"; import { appModeDefinitions, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; +import { + consolidatedModeHomeRedirectEntries, + unsubmittedModeSearchRedirectEntries, +} from "@/lib/consolidated-mode-home-redirect"; import { documentsSearchHref, DOCUMENTS_MODE_HOME_ROUTE } from "@/lib/document-flow-routes"; import { differentialRecords } from "@/lib/differentials"; import { dsmDiagnoses } from "@/lib/dsm"; @@ -40,7 +44,30 @@ type SiteMapData = { const productRouteHandlerPaths = new Set(["/applications"]); +/* + * Consolidated homes are read from the redirect map, not scraped out of page + * bodies. `discoverRedirects` finds a redirect by matching `redirect("literal")`, + * and these stubs compute their target so the query survives the hop — so the + * regex stopped seeing them and the map described them as pages rendering a home. + */ +const consolidatedRedirectTargets = Object.fromEntries( + consolidatedModeHomeRedirectEntries.map(([route, modeId]) => [route, `/?mode=${modeId}`]), +); + +/* + * The four `/search` routes with no browse view. Conditional, not absolute: + * they forward only when the query is empty, and render results otherwise — so + * they are described rather than listed as plain redirects. + */ +const unsubmittedSearchRedirectDescriptions = Object.fromEntries( + unsubmittedModeSearchRedirectEntries.map(([route, modeId]) => [ + route, + `Submitted ${modeId} results. An empty query forwards to \`/?mode=${modeId}\` so the retired mode home is not rendered a second time.`, + ]), +); + const documentedRedirectTargets: Record = { + ...consolidatedRedirectTargets, "/applications": "/tools", // 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 @@ -207,16 +234,40 @@ function discoverRoutes(kind: RouteKind): DiscoveredRoute[] { .sort((left, right) => left.route.localeCompare(right.route) || left.file.localeCompare(right.file)); } +/* + * Applied last, so a derived entry wins over any hand-written description left + * behind for a path that has since become a redirect. `/dsm` read "DSM-5 + * Diagnosis home." long after it stopped rendering one. + */ +Object.assign( + routeDescriptions, + Object.fromEntries( + consolidatedModeHomeRedirectEntries.map(([route, modeId]) => [ + route, + `Compatibility redirect to the shared home at \`/?mode=${modeId}\`; a submitted \`?q=…&run=1\` forwards to \`${route}/search\`.`, + ]), + ), + unsubmittedSearchRedirectDescriptions, +); + +const conditionalRedirectRoutes = new Set(unsubmittedModeSearchRedirectEntries.map(([route]) => route)); + function discoverRedirects(routes: DiscoveredRoute[]): RedirectRoute[] { - return routes - .map((route) => { - const source = readFileSync(path.join(process.cwd(), route.file), "utf8"); - const target = - documentedRedirectTargets[route.route] ?? source.match(/\bredirect\(\s*["']([^"']+)["']\s*\)/)?.[1]; - return target ? { ...route, target } : null; - }) - .filter((value): value is RedirectRoute => Boolean(value)) - .sort((left, right) => left.route.localeCompare(right.route)); + return ( + routes + // The `/search` routes forward only an EMPTY query and render results + // otherwise, so listing them here would claim they never render anything. + // Their conditional behaviour is stated in `routeDescriptions` instead. + .filter((route) => !conditionalRedirectRoutes.has(route.route)) + .map((route) => { + const source = readFileSync(path.join(process.cwd(), route.file), "utf8"); + const target = + documentedRedirectTargets[route.route] ?? source.match(/\bredirect\(\s*["']([^"']+)["']\s*\)/)?.[1]; + return target ? { ...route, target } : null; + }) + .filter((value): value is RedirectRoute => Boolean(value)) + .sort((left, right) => left.route.localeCompare(right.route)) + ); } function discoverNonRoutedMockupArtifacts() { @@ -379,6 +430,33 @@ function renderModePageIndex() { detail: "Canonical all-tools results directory at `/tools`; the universal mode picker opens it directly. `/?mode=tools` remains a dashboard-mode alias.", }, + { + mode: "Calculators", + home: appModeHomeHref("calculators"), + search: appModeHomeHref("calculators", { query: "PHQ-9", focus: true, run: true }), + detail: "`/calculators/search` scored results; an empty query forwards back to the shared home.", + }, + { + mode: "Factsheets", + home: appModeHomeHref("factsheets"), + search: appModeHomeHref("factsheets", { query: "sertraline", focus: true, run: true }), + detail: + "`/factsheets/search` is also a query-free browse surface linked from the mode nav; `/factsheets/[slug]` records.", + }, + { + mode: "Dictionary", + home: appModeHomeHref("dictionary"), + search: appModeHomeHref("dictionary", { query: "MSE", focus: true, run: true }), + detail: + "`/dictionary/search` is also a query-free browse surface; `/dictionary/browse`, `/topics`, `/topics/[slug]`, `/compare`, `/sources` and `/dictionary/[slug]` records.", + }, + { + mode: "Therapy", + home: appModeHomeHref("therapy-compass"), + search: appModeHomeHref("therapy-compass", { query: "CBT", focus: true, run: true }), + detail: + "Keeps a home of its own at `/therapy-compass`; `/search` (query-free browse), `/recommend`, `/compare`, `/pathways`, `/review`, and `/[slug]` records with `/brief` and `/sheet` outputs.", + }, ]); } diff --git a/src/app/(search-app)/calculators/page.tsx b/src/app/(search-app)/calculators/page.tsx index 802403f7a8..35d1b0ff79 100644 --- a/src/app/(search-app)/calculators/page.tsx +++ b/src/app/(search-app)/calculators/page.tsx @@ -1,45 +1,29 @@ -import type { Metadata } from "next"; import { redirect } from "next/navigation"; -import { CalculatorsHomePage, CalculatorsSearchPage } from "@/components/calculators"; - -export const metadata: Metadata = { - title: "Calculators - Clinical KB", - description: "Psychiatry clinical decision calculators and rating scales with source-cited scoring guidance.", +import { appModeSelectionHref } from "@/lib/app-modes"; +import { consolidatedModeHomeTargetForSearchParams } from "@/lib/consolidated-mode-home-redirect"; + +/** + * `Clinical Calculators` has no home page of its own any more. + * + * Every mode shares one lightweight home at `/?mode=`, whose per-mode copy + * lives in `sharedHomePresentation` (src/lib/ui-copy.ts). This path stays so + * bookmarks and external deep links keep resolving, and forwards to that shared + * home. Submitted searches render at `/calculators/search`; the proxy carries the + * query across, so a deep link never lands here without one. + * + * The previous detailed page is preserved, off the live routes, at + * `/mockups/calculators-home-detailed`. + */ +type CalculatorsHomeRouteProps = { + searchParams?: Promise>; }; -type CalculatorsSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; - -function readFirstSearchParam(value: string | string[] | undefined) { - return Array.isArray(value) ? value[0] : value; -} - -function toURLSearchParams(params: Awaited) { - const normalized = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (Array.isArray(value)) value.forEach((item) => normalized.append(key, item)); - else if (value !== undefined) normalized.set(key, value); - } - return normalized; -} - -export default async function CalculatorsRoute({ searchParams }: { searchParams: CalculatorsSearchParams }) { - const resolvedSearchParams = await searchParams; - const hasSubmittedSearch = readFirstSearchParam(resolvedSearchParams.run) === "1"; - const primaryQuery = readFirstSearchParam(resolvedSearchParams.q)?.trim(); - const legacyQuery = readFirstSearchParam(resolvedSearchParams.query)?.trim(); - const query = primaryQuery || legacyQuery; - - if (resolvedSearchParams.query !== undefined) { - const canonicalSearchParams = toURLSearchParams(resolvedSearchParams); - if (query) canonicalSearchParams.set("q", query); - else canonicalSearchParams.delete("q"); - canonicalSearchParams.delete("query"); - const suffix = canonicalSearchParams.toString(); - redirect(suffix ? `/calculators?${suffix}` : "/calculators"); - } - - if (!hasSubmittedSearch || !query) return ; - - return ; +export default async function CalculatorsHomeRoute({ searchParams }: CalculatorsHomeRouteProps) { + // Resolved through the same helper the proxy uses, so a request that reaches + // this backstop lands where the proxy would have sent it — including a + // submitted `?q=…&run=1`, which goes on to /calculators/search rather than + // arriving at the home with its query dropped. + const params = searchParams ? await searchParams : {}; + redirect(consolidatedModeHomeTargetForSearchParams("/calculators", params) ?? appModeSelectionHref("calculators")); } diff --git a/src/app/(search-app)/calculators/search/page.tsx b/src/app/(search-app)/calculators/search/page.tsx new file mode 100644 index 0000000000..74003befea --- /dev/null +++ b/src/app/(search-app)/calculators/search/page.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { CalculatorsSearchPage } from "@/components/calculators"; + +export const metadata: Metadata = { + title: "Search clinical calculators | Clinical KB", + description: "Search source-cited psychiatry scores and clinical decision calculators by indication and name.", +}; + +type CalculatorsSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; + +function readFirstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +function toURLSearchParams(params: Awaited) { + const normalized = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) value.forEach((item) => normalized.append(key, item)); + else if (value !== undefined) normalized.set(key, value); + } + return normalized; +} + +/** + * Submitted calculator searches. + * + * Split out of the bare `/calculators` path when that became a redirect onto the + * shared home: results need a route of their own, or `appModeHomeHref` would send + * a submitted query back through the redirect and loop. + * + * The legacy `?query=` canonicalisation moves with the results rather than staying + * behind on the redirect stub, so an old deep link still lands on `?q=` here + * instead of being normalised against a path that no longer renders anything. + */ +export default async function CalculatorsSearchRoute({ searchParams }: { searchParams: CalculatorsSearchParams }) { + const resolvedSearchParams = await searchParams; + const primaryQuery = readFirstSearchParam(resolvedSearchParams.q)?.trim(); + const legacyQuery = readFirstSearchParam(resolvedSearchParams.query)?.trim(); + const query = primaryQuery || legacyQuery; + + if (resolvedSearchParams.query !== undefined) { + const canonicalSearchParams = toURLSearchParams(resolvedSearchParams); + if (query) canonicalSearchParams.set("q", query); + else canonicalSearchParams.delete("q"); + canonicalSearchParams.delete("query"); + const suffix = canonicalSearchParams.toString(); + redirect(suffix ? `/calculators/search?${suffix}` : "/calculators/search"); + } + + if (!query) redirect("/?mode=calculators"); + + return ; +} diff --git a/src/app/(search-app)/dictionary/page.tsx b/src/app/(search-app)/dictionary/page.tsx index fc714f56c1..cba194d74e 100644 --- a/src/app/(search-app)/dictionary/page.tsx +++ b/src/app/(search-app)/dictionary/page.tsx @@ -1,12 +1,28 @@ -import type { Metadata } from "next"; +import { redirect } from "next/navigation"; -import { DictionaryHomePage } from "@/components/dictionary/dictionary-home-page"; +import { appModeSelectionHref } from "@/lib/app-modes"; +import { consolidatedModeHomeTargetForSearchParams } from "@/lib/consolidated-mode-home-redirect"; -export const metadata: Metadata = { - title: "Clinical Dictionary | Clinical KB", - description: "Search source-governed psychiatric terminology, abbreviations, topics, and distinctions.", +/** + * `Clinical Dictionary` has no home page of its own any more. + * + * Every mode shares one lightweight home at `/?mode=`, whose per-mode copy + * lives in `sharedHomePresentation` (src/lib/ui-copy.ts). This route stays so + * bookmarks and external deep links to `/dictionary` keep working, and forwards to + * that shared home rather than rendering a second one. + * + * The previous detailed page is preserved, off the live routes, at + * `/mockups/dictionary-home-detailed`. + */ +type DictionaryHomeRouteProps = { + searchParams?: Promise>; }; -export default function DictionaryRoute() { - return ; +export default async function DictionaryHomeRoute({ searchParams }: DictionaryHomeRouteProps) { + // Resolved through the same helper the proxy uses, so a request that reaches + // this backstop lands where the proxy would have sent it — including a + // submitted `?q=…&run=1`, which goes on to /dictionary/search rather than + // arriving at the home with its query dropped. + const params = searchParams ? await searchParams : {}; + redirect(consolidatedModeHomeTargetForSearchParams("/dictionary", params) ?? appModeSelectionHref("dictionary")); } diff --git a/src/app/(search-app)/differentials/page.tsx b/src/app/(search-app)/differentials/page.tsx index 7c3137a01b..8a6f482db0 100644 --- a/src/app/(search-app)/differentials/page.tsx +++ b/src/app/(search-app)/differentials/page.tsx @@ -1,21 +1,31 @@ -import { DifferentialsHomePage } from "@/components/differentials/differentials-home-page"; +import { redirect } from "next/navigation"; -type DifferentialsRouteProps = { - searchParams?: Promise<{ query?: string | string[]; q?: string | string[]; run?: string | string[] }>; -}; +import { appModeSelectionHref } from "@/lib/app-modes"; +import { consolidatedModeHomeTargetForSearchParams } from "@/lib/consolidated-mode-home-redirect"; -function firstSearchParam(value?: string | string[]) { - return Array.isArray(value) ? value[0] : value; -} +/** + * `Differential Diagnosis` has no home page of its own any more. + * + * Every mode shares one lightweight home at `/?mode=`, whose per-mode copy + * lives in `sharedHomePresentation` (src/lib/ui-copy.ts). This path stays so + * bookmarks and external deep links keep resolving, and forwards to that shared + * home. Submitted searches render at `/differentials/search`; the proxy carries the + * query across, so a deep link never lands here without one. + * + * The previous detailed page is preserved, off the live routes, at + * `/mockups/differentials-home-detailed`. + */ +type DifferentialsHomeRouteProps = { + searchParams?: Promise>; +}; -export default async function DifferentialsHomeRoute({ searchParams }: DifferentialsRouteProps) { +export default async function DifferentialsHomeRoute({ searchParams }: DifferentialsHomeRouteProps) { + // Resolved through the same helper the proxy uses, so a request that reaches + // this backstop lands where the proxy would have sent it — including a + // submitted `?q=…&run=1`, which goes on to /differentials/search rather than + // arriving at the home with its query dropped. const params = searchParams ? await searchParams : {}; - const query = (firstSearchParam(params.q) ?? firstSearchParam(params.query) ?? "").trim(); - const hasSubmittedSearch = firstSearchParam(params.run) === "1" && query.length > 0; - - if (!hasSubmittedSearch) { - return ; - } - - return ; + redirect( + consolidatedModeHomeTargetForSearchParams("/differentials", params) ?? appModeSelectionHref("differentials"), + ); } diff --git a/src/app/(search-app)/differentials/search/page.tsx b/src/app/(search-app)/differentials/search/page.tsx new file mode 100644 index 0000000000..2a60c298cc --- /dev/null +++ b/src/app/(search-app)/differentials/search/page.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; + +import { DifferentialsHomePage } from "@/components/differentials/differentials-home-page"; + +export const metadata: Metadata = { + title: "Search differential diagnoses | Clinical KB", + description: "Compare differential causes and clinical clues against the indexed library.", +}; + +type RouteProps = { + searchParams?: Promise<{ q?: string | string[]; query?: string | string[]; run?: string | string[] }>; +}; + +function firstValue(value?: string | string[]) { + return Array.isArray(value) ? value[0] : value; +} + +/** + * Submitted differential searches — and the browsable diagnosis catalogue when + * nothing is submitted yet. + * + * Split out of the bare `/differentials` path when that became a redirect onto the shared + * home: results need a route of their own, or `appModeHomeHref` would send a + * submitted query back through the redirect and loop. An empty query renders the + * same browse experience `/differentials` used to hold before consolidation — this + * is where it lives now, not a duplicate of it (`tests/ui-phone-scroll-routes.spec.ts` + * pins the long list rendering here with no query). + */ +export default async function DifferentialsSearchRoute(props: RouteProps) { + const params = props.searchParams ? await props.searchParams : {}; + const query = (firstValue(params.q) ?? firstValue(params.query) ?? "").trim(); + + return 0} />; +} diff --git a/src/app/(search-app)/documents/page.tsx b/src/app/(search-app)/documents/page.tsx index f857224898..1a04ea82aa 100644 --- a/src/app/(search-app)/documents/page.tsx +++ b/src/app/(search-app)/documents/page.tsx @@ -10,11 +10,15 @@ export const metadata: Metadata = { /** * The Documents mode home. * - * `/` is the single shared home for every mode — the mode pill retargets the - * composer rather than navigating — so Documents needs a home of its own, the same - * way /dsm, /services and /tools do. The body comes from ClinicalDashboard, which - * the shared shell mounts for this pathname (see `shouldRenderClinicalDashboard`); - * this route is the content slot, mirroring the root `home-page-client.tsx`. + * `/` is the single shared home for every other mode — the mode pill retargets + * the composer rather than navigating — but Documents is a real workspace, not a + * duplicate landing page: it has its own browse/recent-documents/open-a-source-PDF + * affordances that don't exist anywhere else, the same way `/medications` has its + * own prescribing workspace. Folding it into the generic shared home silently + * deleted those affordances; this route keeps them. The body comes from + * ClinicalDashboard, which the shared shell mounts for this pathname (see + * `shouldRenderClinicalDashboard`); this route is the content slot, mirroring the + * root `home-page-client.tsx`. */ export default function DocumentsHomeRoute() { return ; diff --git a/src/app/(search-app)/dsm/page.tsx b/src/app/(search-app)/dsm/page.tsx index 664ddb5689..42e592fb6d 100644 --- a/src/app/(search-app)/dsm/page.tsx +++ b/src/app/(search-app)/dsm/page.tsx @@ -1,12 +1,28 @@ -import type { Metadata } from "next"; +import { redirect } from "next/navigation"; -import { DsmHomePage } from "@/components/dsm/dsm-home-page"; +import { appModeSelectionHref } from "@/lib/app-modes"; +import { consolidatedModeHomeTargetForSearchParams } from "@/lib/consolidated-mode-home-redirect"; -export const metadata: Metadata = { - title: "DSM-5 Diagnosis | Clinical KB", - description: "Search, compare, and review structured DSM diagnosis criteria and differential considerations.", +/** + * `DSM-5 Diagnosis` has no home page of its own any more. + * + * Every mode shares one lightweight home at `/?mode=`, whose per-mode copy + * lives in `sharedHomePresentation` (src/lib/ui-copy.ts). This route stays so + * bookmarks and external deep links to `/dsm` keep working, and forwards to + * that shared home rather than rendering a second one. + * + * The previous detailed page is preserved, off the live routes, at + * `/mockups/dsm-home-detailed`. + */ +type DsmHomeRouteProps = { + searchParams?: Promise>; }; -export default function DsmHomeRoute() { - return ; +export default async function DsmHomeRoute({ searchParams }: DsmHomeRouteProps) { + // Resolved through the same helper the proxy uses, so a request that reaches + // this backstop lands where the proxy would have sent it — including a + // submitted `?q=…&run=1`, which goes on to /dsm/search rather than + // arriving at the home with its query dropped. + const params = searchParams ? await searchParams : {}; + redirect(consolidatedModeHomeTargetForSearchParams("/dsm", params) ?? appModeSelectionHref("dsm")); } diff --git a/src/app/(search-app)/factsheets/page.tsx b/src/app/(search-app)/factsheets/page.tsx index 958b7f05de..4bcd53b5bb 100644 --- a/src/app/(search-app)/factsheets/page.tsx +++ b/src/app/(search-app)/factsheets/page.tsx @@ -1,12 +1,28 @@ -import type { Metadata } from "next"; +import { redirect } from "next/navigation"; -import { FactsheetsHomePage } from "@/components/factsheets/factsheets-home-page"; +import { appModeSelectionHref } from "@/lib/app-modes"; +import { consolidatedModeHomeTargetForSearchParams } from "@/lib/consolidated-mode-home-redirect"; -export const metadata: Metadata = { - title: "Patient Information Sheets | Clinical KB", - description: "Browse patient information sheet layouts and approved local resources.", +/** + * `Patient Factsheets` has no home page of its own any more. + * + * Every mode shares one lightweight home at `/?mode=`, whose per-mode copy + * lives in `sharedHomePresentation` (src/lib/ui-copy.ts). This route stays so + * bookmarks and external deep links to `/factsheets` keep working, and forwards to + * that shared home rather than rendering a second one. + * + * The previous detailed page is preserved, off the live routes, at + * `/mockups/factsheets-home-detailed`. + */ +type FactsheetsHomeRouteProps = { + searchParams?: Promise>; }; -export default function FactsheetsPage() { - return ; +export default async function FactsheetsHomeRoute({ searchParams }: FactsheetsHomeRouteProps) { + // Resolved through the same helper the proxy uses, so a request that reaches + // this backstop lands where the proxy would have sent it — including a + // submitted `?q=…&run=1`, which goes on to /factsheets/search rather than + // arriving at the home with its query dropped. + const params = searchParams ? await searchParams : {}; + redirect(consolidatedModeHomeTargetForSearchParams("/factsheets", params) ?? appModeSelectionHref("factsheets")); } diff --git a/src/app/(search-app)/forms/page.tsx b/src/app/(search-app)/forms/page.tsx index 41f7beef82..64e91e8a9b 100644 --- a/src/app/(search-app)/forms/page.tsx +++ b/src/app/(search-app)/forms/page.tsx @@ -1,26 +1,29 @@ -import { FormsHomePage } from "@/components/forms/forms-home-page"; -import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; -import { defaultFormSlug } from "@/lib/forms"; +import { redirect } from "next/navigation"; -type FormsSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; +import { appModeSelectionHref } from "@/lib/app-modes"; +import { consolidatedModeHomeTargetForSearchParams } from "@/lib/consolidated-mode-home-redirect"; -function readFirstSearchParam(value: string | string[] | undefined) { - return Array.isArray(value) ? value[0] : value; -} - -export default async function FormsPage({ searchParams }: { searchParams: FormsSearchParams }) { - const resolvedSearchParams = await searchParams; - const query = ( - readFirstSearchParam(resolvedSearchParams.q) ?? - readFirstSearchParam(resolvedSearchParams.query) ?? - "" - ).trim(); - const hasSubmittedSearch = readFirstSearchParam(resolvedSearchParams.run) === "1" && query.length > 0; - - if (!hasSubmittedSearch) { - // Computed server-side so the client route chunk never bundles the forms catalog. - return ; - } +/** + * `Clinical Forms` has no home page of its own any more. + * + * Every mode shares one lightweight home at `/?mode=`, whose per-mode copy + * lives in `sharedHomePresentation` (src/lib/ui-copy.ts). This path stays so + * bookmarks and external deep links keep resolving, and forwards to that shared + * home. Submitted searches render at `/forms/search`; the proxy carries the + * query across, so a deep link never lands here without one. + * + * The previous detailed page is preserved, off the live routes, at + * `/mockups/forms-home-detailed`. + */ +type FormsHomeRouteProps = { + searchParams?: Promise>; +}; - return ; +export default async function FormsHomeRoute({ searchParams }: FormsHomeRouteProps) { + // Resolved through the same helper the proxy uses, so a request that reaches + // this backstop lands where the proxy would have sent it — including a + // submitted `?q=…&run=1`, which goes on to /forms/search rather than + // arriving at the home with its query dropped. + const params = searchParams ? await searchParams : {}; + redirect(consolidatedModeHomeTargetForSearchParams("/forms", params) ?? appModeSelectionHref("forms")); } diff --git a/src/app/(search-app)/forms/search/page.tsx b/src/app/(search-app)/forms/search/page.tsx new file mode 100644 index 0000000000..b4b7f5f1f3 --- /dev/null +++ b/src/app/(search-app)/forms/search/page.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; + +import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; + +export const metadata: Metadata = { + title: "Search clinical forms | Clinical KB", + description: "Search the WA MHA 2014 forms register by code, title and clinical purpose.", +}; + +type RouteProps = { + searchParams?: Promise<{ q?: string | string[]; query?: string | string[]; run?: string | string[] }>; +}; + +function firstValue(value?: string | string[]) { + return Array.isArray(value) ? value[0] : value; +} + +/** + * Submitted forms searches. + * + * Split out of the bare `/forms` path when that became a redirect onto the shared + * home: results need a route of their own, or `appModeHomeHref` would send a + * submitted query back through the redirect and loop. + */ +export default async function FormsSearchRoute(props: RouteProps) { + const params = props.searchParams ? await props.searchParams : {}; + const query = (firstValue(params.q) ?? firstValue(params.query) ?? "").trim(); + return ; +} diff --git a/src/app/(search-app)/formulation/page.tsx b/src/app/(search-app)/formulation/page.tsx index a4a58ecc4c..e467601706 100644 --- a/src/app/(search-app)/formulation/page.tsx +++ b/src/app/(search-app)/formulation/page.tsx @@ -1,16 +1,29 @@ -import { FormulationHomePage } from "@/components/formulation/formulation-home-page"; +import { redirect } from "next/navigation"; -type FormulationRouteProps = { - searchParams?: Promise<{ q?: string | string[]; run?: string | string[] }>; -}; +import { appModeSelectionHref } from "@/lib/app-modes"; +import { consolidatedModeHomeTargetForSearchParams } from "@/lib/consolidated-mode-home-redirect"; -function firstParam(value: string | string[] | undefined) { - return Array.isArray(value) ? value[0] : value; -} +/** + * `Clinical Formulation` has no home page of its own any more. + * + * Every mode shares one lightweight home at `/?mode=`, whose per-mode copy + * lives in `sharedHomePresentation` (src/lib/ui-copy.ts). This path stays so + * bookmarks and external deep links keep resolving, and forwards to that shared + * home. Submitted searches render at `/formulation/search`; the proxy carries the + * query across, so a deep link never lands here without one. + * + * The previous detailed page is preserved, off the live routes, at + * `/mockups/formulation-home-detailed`. + */ +type FormulationHomeRouteProps = { + searchParams?: Promise>; +}; -export default async function FormulationRoute({ searchParams }: FormulationRouteProps) { +export default async function FormulationHomeRoute({ searchParams }: FormulationHomeRouteProps) { + // Resolved through the same helper the proxy uses, so a request that reaches + // this backstop lands where the proxy would have sent it — including a + // submitted `?q=…&run=1`, which goes on to /formulation/search rather than + // arriving at the home with its query dropped. const params = searchParams ? await searchParams : {}; - const query = firstParam(params.q)?.trim() ?? ""; - const hasSubmittedSearch = firstParam(params.run) === "1" && query.length > 0; - return ; + redirect(consolidatedModeHomeTargetForSearchParams("/formulation", params) ?? appModeSelectionHref("formulation")); } diff --git a/src/app/(search-app)/formulation/search/page.tsx b/src/app/(search-app)/formulation/search/page.tsx new file mode 100644 index 0000000000..cc202329cb --- /dev/null +++ b/src/app/(search-app)/formulation/search/page.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; + +import { FormulationHomePage } from "@/components/formulation/formulation-home-page"; + +export const metadata: Metadata = { + title: "Search clinical formulation | Clinical KB", + description: "Search formulation mechanisms by pattern, clinical clue and hypothesis.", +}; + +type RouteProps = { + searchParams?: Promise<{ q?: string | string[]; query?: string | string[]; run?: string | string[] }>; +}; + +function firstValue(value?: string | string[]) { + return Array.isArray(value) ? value[0] : value; +} + +/** + * Submitted formulation searches — and the browsable mechanism list when nothing + * is submitted yet. + * + * Split out of the bare `/formulation` path when that became a redirect onto the shared + * home: results need a route of their own, or `appModeHomeHref` would send a + * submitted query back through the redirect and loop. An empty query renders the + * same browse experience `/formulation` used to hold before consolidation — this is + * where it lives now, not a duplicate of it (`tests/ui-phone-scroll-routes.spec.ts` + * pins the long mechanism list rendering here with no query). + */ +export default async function FormulationSearchRoute(props: RouteProps) { + const params = props.searchParams ? await props.searchParams : {}; + const query = (firstValue(params.q) ?? firstValue(params.query) ?? "").trim(); + + return 0} />; +} diff --git a/src/app/(search-app)/services/page.tsx b/src/app/(search-app)/services/page.tsx index 53c9a7a1d0..514fb8ac36 100644 --- a/src/app/(search-app)/services/page.tsx +++ b/src/app/(search-app)/services/page.tsx @@ -1,52 +1,29 @@ import { redirect } from "next/navigation"; -import { Suspense } from "react"; -import { ServicesHomePage } from "@/components/services/services-home-page"; -import { ServicesNavigatorPage } from "@/components/services/services-navigator-page"; -import { defaultServiceSlug } from "@/lib/services"; - -import ServicesLoading from "./loading"; - -type ServicesSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; - -function readFirstSearchParam(value: string | string[] | undefined) { - return Array.isArray(value) ? value[0] : value; -} - -function toURLSearchParams(params: Awaited) { - const normalized = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (Array.isArray(value)) { - value.forEach((item) => normalized.append(key, item)); - } else if (value !== undefined) { - normalized.set(key, value); - } - } - return normalized; -} - -export default async function ServicesIndexRoute({ searchParams }: { searchParams: ServicesSearchParams }) { - const resolvedSearchParams = await searchParams; - const hasSubmittedSearch = readFirstSearchParam(resolvedSearchParams.run) === "1"; - const primaryQuery = readFirstSearchParam(resolvedSearchParams.q)?.trim(); - const legacyQuery = readFirstSearchParam(resolvedSearchParams.query)?.trim(); - - if (hasSubmittedSearch && !primaryQuery && legacyQuery) { - const canonicalSearchParams = toURLSearchParams(resolvedSearchParams); - canonicalSearchParams.set("q", legacyQuery); - canonicalSearchParams.delete("query"); - redirect(`/services?${canonicalSearchParams.toString()}`); - } - - if (!hasSubmittedSearch) { - // Computed server-side so the client route chunk never bundles the - // services snapshot behind @/lib/services. - return ; - } - - return ( - }> - - - ); +import { appModeSelectionHref } from "@/lib/app-modes"; +import { consolidatedModeHomeTargetForSearchParams } from "@/lib/consolidated-mode-home-redirect"; + +/** + * `Clinical Services` has no home page of its own any more. + * + * Every mode shares one lightweight home at `/?mode=`, whose per-mode copy + * lives in `sharedHomePresentation` (src/lib/ui-copy.ts). This path stays so + * bookmarks and external deep links keep resolving, and forwards to that shared + * home. Submitted searches render at `/services/search`; the proxy carries the + * query across, so a deep link never lands here without one. + * + * The previous detailed page is preserved, off the live routes, at + * `/mockups/services-home-detailed`. + */ +type ServicesHomeRouteProps = { + searchParams?: Promise>; +}; + +export default async function ServicesHomeRoute({ searchParams }: ServicesHomeRouteProps) { + // Resolved through the same helper the proxy uses, so a request that reaches + // this backstop lands where the proxy would have sent it — including a + // submitted `?q=…&run=1`, which goes on to /services/search rather than + // arriving at the home with its query dropped. + const params = searchParams ? await searchParams : {}; + redirect(consolidatedModeHomeTargetForSearchParams("/services", params) ?? appModeSelectionHref("services")); } diff --git a/src/app/(search-app)/services/search/page.tsx b/src/app/(search-app)/services/search/page.tsx new file mode 100644 index 0000000000..e80322d7b7 --- /dev/null +++ b/src/app/(search-app)/services/search/page.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { Suspense } from "react"; + +import { ServicesNavigatorPage } from "@/components/services/services-navigator-page"; + +import ServicesLoading from "../loading"; + +export const metadata: Metadata = { + title: "Search clinical services | Clinical KB", + description: "Search the private services registry by need, catchment, eligibility and referral route.", +}; + +type ServicesSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; + +function readFirstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +function toURLSearchParams(params: Awaited) { + const normalized = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) value.forEach((item) => normalized.append(key, item)); + else if (value !== undefined) normalized.set(key, value); + } + return normalized; +} + +/** + * Submitted services searches. + * + * Split out of the bare `/services` path when that became a redirect onto the + * shared home: results need a route of their own, or `appModeHomeHref` would + * send a submitted query back through the redirect and loop. + * + * The legacy `?query=` canonicalisation moves with the results rather than + * staying behind on the redirect stub. `ServicesNavigatorPage` reads the query + * from the URL, so an old deep link that still says `query=` has to be rewritten + * to `q=` here or it reaches the results surface with nothing to search for. + */ +export default async function ServicesSearchRoute({ searchParams }: { searchParams: ServicesSearchParams }) { + const resolvedSearchParams = await searchParams; + const primaryQuery = readFirstSearchParam(resolvedSearchParams.q)?.trim(); + const legacyQuery = readFirstSearchParam(resolvedSearchParams.query)?.trim(); + + if (!primaryQuery && legacyQuery) { + const canonicalSearchParams = toURLSearchParams(resolvedSearchParams); + canonicalSearchParams.set("q", legacyQuery); + canonicalSearchParams.delete("query"); + redirect(`/services/search?${canonicalSearchParams.toString()}`); + } + + return ( + }> + + + ); +} diff --git a/src/app/(search-app)/specifiers/page.tsx b/src/app/(search-app)/specifiers/page.tsx index b685ade9fb..dd1b18b371 100644 --- a/src/app/(search-app)/specifiers/page.tsx +++ b/src/app/(search-app)/specifiers/page.tsx @@ -1,17 +1,29 @@ -import { SpecifiersHomePage } from "@/components/specifiers/specifiers-home-page"; +import { redirect } from "next/navigation"; -type SpecifiersRouteProps = { - searchParams?: Promise<{ query?: string | string[]; q?: string | string[]; run?: string | string[] }>; -}; +import { appModeSelectionHref } from "@/lib/app-modes"; +import { consolidatedModeHomeTargetForSearchParams } from "@/lib/consolidated-mode-home-redirect"; -function firstSearchParam(value?: string | string[]) { - return Array.isArray(value) ? value[0] : value; -} +/** + * `Diagnostic Specifiers` has no home page of its own any more. + * + * Every mode shares one lightweight home at `/?mode=`, whose per-mode copy + * lives in `sharedHomePresentation` (src/lib/ui-copy.ts). This path stays so + * bookmarks and external deep links keep resolving, and forwards to that shared + * home. Submitted searches render at `/specifiers/search`; the proxy carries the + * query across, so a deep link never lands here without one. + * + * The previous detailed page is preserved, off the live routes, at + * `/mockups/specifiers-home-detailed`. + */ +type SpecifiersHomeRouteProps = { + searchParams?: Promise>; +}; -export default async function SpecifiersRoute({ searchParams }: SpecifiersRouteProps) { +export default async function SpecifiersHomeRoute({ searchParams }: SpecifiersHomeRouteProps) { + // Resolved through the same helper the proxy uses, so a request that reaches + // this backstop lands where the proxy would have sent it — including a + // submitted `?q=…&run=1`, which goes on to /specifiers/search rather than + // arriving at the home with its query dropped. const params = searchParams ? await searchParams : {}; - const query = (firstSearchParam(params.q) ?? firstSearchParam(params.query) ?? "").trim(); - const hasSubmittedSearch = firstSearchParam(params.run) === "1" && query.length > 0; - - return ; + redirect(consolidatedModeHomeTargetForSearchParams("/specifiers", params) ?? appModeSelectionHref("specifiers")); } diff --git a/src/app/(search-app)/specifiers/search/page.tsx b/src/app/(search-app)/specifiers/search/page.tsx new file mode 100644 index 0000000000..21d29ff82d --- /dev/null +++ b/src/app/(search-app)/specifiers/search/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; + +import { SpecifiersHomePage } from "@/components/specifiers/specifiers-home-page"; + +export const metadata: Metadata = { + title: "Search diagnostic specifiers | Clinical KB", + description: "Search diagnostic specifiers by presentation, episode pattern, course and severity.", +}; + +type RouteProps = { + searchParams?: Promise<{ q?: string | string[]; query?: string | string[]; run?: string | string[] }>; +}; + +function firstValue(value?: string | string[]) { + return Array.isArray(value) ? value[0] : value; +} + +/** + * Submitted specifier searches — and the browsable specifier list when nothing is + * submitted yet. + * + * Split out of the bare `/specifiers` path when that became a redirect onto the shared + * home: results need a route of their own, or `appModeHomeHref` would send a + * submitted query back through the redirect and loop. An empty query renders the + * same browse experience `/specifiers` used to hold before consolidation — this is + * where it lives now, not a duplicate of it (`tests/ui-phone-scroll-routes.spec.ts` + * exercises this same browse-without-a-query pattern for the sibling formulation + * route). + */ +export default async function SpecifiersSearchRoute(props: RouteProps) { + const params = props.searchParams ? await props.searchParams : {}; + const query = (firstValue(params.q) ?? firstValue(params.query) ?? "").trim(); + + return 0} />; +} diff --git a/src/app/(search-app)/therapy-compass/page.tsx b/src/app/(search-app)/therapy-compass/page.tsx index 7eae10099f..c3f89653f9 100644 --- a/src/app/(search-app)/therapy-compass/page.tsx +++ b/src/app/(search-app)/therapy-compass/page.tsx @@ -1,28 +1,26 @@ -import type { Metadata } from "next"; import { redirect } from "next/navigation"; -import { HomeScreen } from "@/components/therapy-compass/screens/home-screen"; +import { appModeSelectionHref } from "@/lib/app-modes"; -export const metadata: Metadata = { - title: "Therapy - Clinical KB", - description: - "Source-grounded therapy decision support: search, compare, recommend, pathways, brief interventions and patient sheets.", -}; - -type TherapyCompassHomeProps = { - searchParams?: Promise<{ q?: string | string[]; run?: string | string[] }>; -}; - -function firstParam(value: string | string[] | undefined) { - return Array.isArray(value) ? value[0] : value; -} - -export default async function TherapyCompassHome({ searchParams }: TherapyCompassHomeProps) { - const params = searchParams ? await searchParams : {}; - const query = firstParam(params.q)?.trim() ?? ""; - const autoRun = firstParam(params.run) === "1" && query.length > 0; - // A run-enabled mode search resolves to the home href (appModeHomeHref); send it - // on to the dedicated, deep-linkable search route so the workspace opens on results. - if (autoRun) redirect(`/therapy-compass/search?q=${encodeURIComponent(query)}&run=1`); - return ; +/** + * `Therapy` has no home page of its own any more. + * + * Every mode shares one lightweight home at `/?mode=`, whose per-mode copy + * lives in `sharedHomePresentation` (src/lib/ui-copy.ts). This path stays so + * bookmarks and external deep links keep resolving, and forwards to that shared + * home. Submitted searches render at `/therapy-compass/search`; the proxy carries + * the query across, so a deep link never lands here without one. + * + * The rest of the Therapy workspace is untouched — search, compare, recommend, + * pathways and every record route still render themselves. Only the home screen + * retired, and it is preserved off the live routes at + * `/mockups/therapy-compass-home-detailed`. + * + * Consolidating this mode was blocked while it was `devOnly`: the shared home + * hides devOnly modes in production, so `/?mode=therapy-compass` came back as + * mode Answer. PR #2150 shipped Therapy in production with its review state + * disclosed, which lifted that gate. + */ +export default function TherapyCompassHomeRoute() { + redirect(appModeSelectionHref("therapy-compass")); } diff --git a/src/app/mockups/calculators-home-detailed/page.tsx b/src/app/mockups/calculators-home-detailed/page.tsx new file mode 100644 index 0000000000..0d52a4ed90 --- /dev/null +++ b/src/app/mockups/calculators-home-detailed/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { CalculatorsHomePage } from "@/components/calculators"; + +export const metadata: Metadata = { + title: "Clinical Calculators home, detailed (retired) - Clinical KB", + description: + "The pre-consolidation Clinical Calculators home page, kept as design scratch after every mode moved to the shared lightweight home.", +}; + +export default function CalculatorsDetailedHomeMockupPage() { + return ; +} diff --git a/src/app/mockups/dictionary-home-detailed/page.tsx b/src/app/mockups/dictionary-home-detailed/page.tsx new file mode 100644 index 0000000000..c1fdbf043c --- /dev/null +++ b/src/app/mockups/dictionary-home-detailed/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { DictionaryHomePage } from "@/components/dictionary/dictionary-home-page"; + +export const metadata: Metadata = { + title: "Clinical Dictionary home, detailed (retired) - Clinical KB", + description: + "The pre-consolidation Clinical Dictionary home page, kept as design scratch after every mode moved to the shared lightweight home.", +}; + +export default function DictionaryDetailedHomeMockupPage() { + return ; +} diff --git a/src/app/mockups/differentials-home-detailed/page.tsx b/src/app/mockups/differentials-home-detailed/page.tsx new file mode 100644 index 0000000000..7d28efc8c8 --- /dev/null +++ b/src/app/mockups/differentials-home-detailed/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { DifferentialsHomePage } from "@/components/differentials/differentials-home-page"; + +export const metadata: Metadata = { + title: "Differential Diagnosis home, detailed (retired) - Clinical KB", + description: + "The pre-consolidation Differential Diagnosis home page, kept as design scratch after every mode moved to the shared lightweight home.", +}; + +export default function DifferentialsDetailedHomeMockupPage() { + return ; +} diff --git a/src/app/mockups/dsm-home-detailed/page.tsx b/src/app/mockups/dsm-home-detailed/page.tsx new file mode 100644 index 0000000000..63cd168599 --- /dev/null +++ b/src/app/mockups/dsm-home-detailed/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { DsmHomePage } from "@/components/dsm/dsm-home-page"; + +export const metadata: Metadata = { + title: "DSM-5 Diagnosis home, detailed (retired) - Clinical KB", + description: + "The pre-consolidation DSM-5 Diagnosis home page, kept as design scratch after every mode moved to the shared lightweight home.", +}; + +export default function DsmDetailedHomeMockupPage() { + return ; +} diff --git a/src/app/mockups/factsheets-home-detailed/page.tsx b/src/app/mockups/factsheets-home-detailed/page.tsx new file mode 100644 index 0000000000..548939bca2 --- /dev/null +++ b/src/app/mockups/factsheets-home-detailed/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { FactsheetsHomePage } from "@/components/factsheets/factsheets-home-page"; + +export const metadata: Metadata = { + title: "Patient Factsheets home, detailed (retired) - Clinical KB", + description: + "The pre-consolidation Patient Factsheets home page, kept as design scratch after every mode moved to the shared lightweight home.", +}; + +export default function FactsheetsDetailedHomeMockupPage() { + return ; +} diff --git a/src/app/mockups/forms-home-detailed/page.tsx b/src/app/mockups/forms-home-detailed/page.tsx new file mode 100644 index 0000000000..1868eff223 --- /dev/null +++ b/src/app/mockups/forms-home-detailed/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; + +import { defaultFormSlug } from "@/lib/forms"; +import { FormsHomePage } from "@/components/forms/forms-home-page"; + +export const metadata: Metadata = { + title: "Clinical Forms home, detailed (retired) - Clinical KB", + description: + "The pre-consolidation Clinical Forms home page, kept as design scratch after every mode moved to the shared lightweight home.", +}; + +export default async function FormsDetailedHomeMockupPage() { + return ; +} diff --git a/src/app/mockups/formulation-home-detailed/page.tsx b/src/app/mockups/formulation-home-detailed/page.tsx new file mode 100644 index 0000000000..07cb36f01c --- /dev/null +++ b/src/app/mockups/formulation-home-detailed/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { FormulationHomePage } from "@/components/formulation/formulation-home-page"; + +export const metadata: Metadata = { + title: "Clinical Formulation home, detailed (retired) - Clinical KB", + description: + "The pre-consolidation Clinical Formulation home page, kept as design scratch after every mode moved to the shared lightweight home.", +}; + +export default function FormulationDetailedHomeMockupPage() { + return ; +} diff --git a/src/app/mockups/services-home-detailed/page.tsx b/src/app/mockups/services-home-detailed/page.tsx new file mode 100644 index 0000000000..cf65502392 --- /dev/null +++ b/src/app/mockups/services-home-detailed/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; + +import { defaultServiceSlug } from "@/lib/services"; +import { ServicesHomePage } from "@/components/services/services-home-page"; + +export const metadata: Metadata = { + title: "Clinical Services home, detailed (retired) - Clinical KB", + description: + "The pre-consolidation Clinical Services home page, kept as design scratch after every mode moved to the shared lightweight home.", +}; + +export default async function ServicesDetailedHomeMockupPage() { + return ; +} diff --git a/src/app/mockups/specifiers-home-detailed/page.tsx b/src/app/mockups/specifiers-home-detailed/page.tsx new file mode 100644 index 0000000000..c447fbe0c7 --- /dev/null +++ b/src/app/mockups/specifiers-home-detailed/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { SpecifiersHomePage } from "@/components/specifiers/specifiers-home-page"; + +export const metadata: Metadata = { + title: "Diagnostic Specifiers home, detailed (retired) - Clinical KB", + description: + "The pre-consolidation Diagnostic Specifiers home page, kept as design scratch after every mode moved to the shared lightweight home.", +}; + +export default function SpecifiersDetailedHomeMockupPage() { + return ; +} diff --git a/src/app/mockups/therapy-compass-home-detailed/page.tsx b/src/app/mockups/therapy-compass-home-detailed/page.tsx new file mode 100644 index 0000000000..419cbb33c7 --- /dev/null +++ b/src/app/mockups/therapy-compass-home-detailed/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { HomeScreen } from "@/components/therapy-compass/screens/home-screen"; + +export const metadata: Metadata = { + title: "Therapy home, detailed (retired) - Clinical KB", + description: + "The pre-consolidation Therapy home screen, kept as design scratch after every mode moved to the shared lightweight home.", +}; + +export default function TherapyCompassDetailedHomeMockupPage() { + return ; +} diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 8b7d67d98b..a7e2cdaeaf 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -69,11 +69,15 @@ function accountProfileLabel(identity: SidebarIdentity) { const sidebarToolItems = [ { id: "answer", label: "Answer", icon: Sparkles, href: "/?mode=answer" }, - // Documents and Medication now have real homes, like every other mode. + // Documents owns a real home: the shell mounts ClinicalDashboard for /documents, + // so it paints browse and recent documents rather than the shared hero. { id: "documents", label: "Documents", icon: FileText, href: "/documents" }, - { id: "services", label: "Services", icon: appModeIcons.services, href: "/services" }, + // Every consolidated mode links to the one shared home; their bare paths are now + // redirects onto it, so pointing a pinned entry at `/services` or `/factsheets` + // would spend a round trip arriving at the same place. + { id: "services", label: "Services", icon: appModeIcons.services, href: "/?mode=services" }, { id: "prescribing", label: appModeDefinition("prescribing").label, icon: Pill, href: "/medications" }, - { id: "factsheets", label: "Factsheets", icon: appModeIcons.factsheets, href: "/factsheets" }, + { id: "factsheets", label: "Factsheets", icon: appModeIcons.factsheets, href: "/?mode=factsheets" }, // PT-11: standalone /tools is the canonical entry; /?mode=tools remains a dashboard-mode alias. { id: "tools", label: "Tools", icon: Wrench, href: "/tools" }, ] as const; diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 7e7b1d3cf9..0c9294a905 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -419,7 +419,9 @@ function GlobalStandaloneSearchShellBody({ const useCompactBottomSearch = hasSubmittedModeSearch || isDocumentCommandSearchView; const differentialsCompareAddonActive = searchMode === "differentials" && - (pathname === "/differentials/diagnoses" || (pathname === "/differentials" && hasSubmittedModeSearch)); + // `/differentials` is absent on purpose: it redirects to the shared home, so a + // branch naming it can never be true and would only read as live ownership. + (pathname === "/differentials/diagnoses" || pathname === "/differentials/search"); // No shell-owned route claims the Patient details dock addon. `/medications` // is a standalone mode home (composer in the hero, no dock to portal into), // and `/medications/[slug]` already opens the same sheet from its own nav diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index 9162919a62..bb44ae2d2d 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -1,5 +1,6 @@ import type { ClinicalQueryMode } from "@/lib/types"; import { documentsSearchHref } from "@/lib/document-flow-routes"; +import { consolidatedModeHomeModeIds } from "@/lib/consolidated-mode-home-redirect"; import { appendSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; export const appModeIds = [ @@ -494,6 +495,13 @@ export function appModeHomeHref(modeId: AppModeId, options: SearchNavigationOpti return documentsSearchHref({ ...options, query }); } + // A consolidated mode has no home of its own: its bare path only redirects to + // the shared home. An unsubmitted href therefore targets `/?mode=` directly + // rather than routing in-app navigation through that redirect for nothing. + if (consolidatedModeHomeModeIds.has(modeId) && !query) { + return appModeSelectionHref(modeId, options); + } + if (namespaceIsolatedModes.has(modeId) && "href" in mode && mode.href) { const namespacedParams = new URLSearchParams(); if (query) namespacedParams.set("q", query); @@ -502,16 +510,12 @@ export function appModeHomeHref(modeId: AppModeId, options: SearchNavigationOpti appendSearchNavigationContext(namespacedParams, options); const suffix = namespacedParams.toString(); - const namespacedHref = - query && modeId === "dsm" - ? "/dsm/search" - : query && modeId === "factsheets" - ? "/factsheets/search" - : query && modeId === "dictionary" - ? "/dictionary/search" - : query && modeId === "therapy-compass" - ? "/therapy-compass/search" - : mode.href; + // A submitted search resolves to the mode's own `/search` route. Every + // consolidated mode has one, because its bare path is now a redirect onto the + // shared home: routing a submitted query back to the bare path would bounce + // through that redirect and return here, an infinite loop + // (tests/app-modes.test.ts pins the no-loop property for every mode). + const namespacedHref = query && consolidatedModeHomeModeIds.has(modeId) ? `${mode.href}/search` : mode.href; return suffix ? `${namespacedHref}?${suffix}` : namespacedHref; } diff --git a/src/lib/consolidated-mode-home-redirect.ts b/src/lib/consolidated-mode-home-redirect.ts new file mode 100644 index 0000000000..5b871595b2 --- /dev/null +++ b/src/lib/consolidated-mode-home-redirect.ts @@ -0,0 +1,213 @@ +import type { AppModeId } from "@/lib/app-modes"; + +/** + * Bare mode paths that no longer render a home of their own. + * + * Every mode shares one lightweight home at `/?mode=`, whose per-mode copy + * lives in `sharedHomePresentation` (src/lib/ui-copy.ts). These paths stay so + * bookmarks, the sitemap and external deep links keep resolving; they forward + * to that shared home instead of rendering a second one. The retired detailed + * pages are preserved off the live routes under `/mockups/-home-detailed`. + * + * Five modes are deliberately absent, because none of them is a duplicate of the + * shared home — each is its mode's only functional surface, so folding it in + * would delete a feature rather than de-duplicate a page: + * /tools the launcher (categories, filters, saved) + * /favourites the hub (Continue, Recent, sets, sort/view) + * /medications the prescribing workspace (dose/safety/monitoring checks) + * /documents dashboard-owned: the shell mounts ClinicalDashboard for that + * pathname, so `/documents` renders a real Documents home — + * browse, recent documents and the document-search empty state + * — not a duplicate of the generic shared home. Folding it in + * here silently deleted those three affordances (`/issues` + * tracked this as a Production UI regression); restored. + * / the shared home itself + * + * Sub-routes are deliberately NOT listed: `/dsm/search`, `/factsheets/[slug]` + * and friends are real surfaces and must keep rendering themselves. + */ +const consolidatedModeHomePaths = { + "/dsm": "dsm", + "/dictionary": "dictionary", + "/factsheets": "factsheets", + "/services": "services", + "/forms": "forms", + "/calculators": "calculators", + "/specifiers": "specifiers", + "/formulation": "formulation", + "/differentials": "differentials", + "/therapy-compass": "therapy-compass", +} as const satisfies Record; + +type ConsolidatedModeHomePath = keyof typeof consolidatedModeHomePaths; + +function isConsolidatedPath(pathname: string): pathname is ConsolidatedModeHomePath { + return Object.hasOwn(consolidatedModeHomePaths, pathname); +} + +export function isConsolidatedModeHomePath(pathname: string): boolean { + return isConsolidatedPath(pathname); +} + +export function consolidatedModeHomeModeId(pathname: string): AppModeId | null { + return isConsolidatedPath(pathname) ? consolidatedModeHomePaths[pathname] : null; +} + +/** + * Where a consolidated bare path forwards to. + * + * Resolved in the proxy rather than by the page's own `redirect()`, because the + * `(search-app)` layout streams: Next 16 documents that `redirect()` in a + * streaming context "will insert a meta tag to emit the redirect on the client + * side" instead of serving a 307 (`redirect.md`). Measured here, that produced + * `` — an empty shell for + * a whole second on a primary navigation path. The same reasoning already put + * the document-source fallbacks in this proxy (issue #024). + * + * The destination depends on whether the link was submitted, because the bare + * path used to serve both roles: + * + * - Unsubmitted (`/dsm`) forwards to the shared home, `/?mode=dsm`. + * - Submitted (`/dsm?q=…&run=1`) forwards to the mode's own results surface, + * `/dsm/search?q=…&run=1`, which is exactly where it rendered before. + * + * Sending a submitted link to the shared home instead is what broke four phone + * journeys: the dashboard rendered its own in-place results for some modes and + * nothing at all for others, so `/forms?q=transport&run=1` stopped reaching + * `FormsSearchResultsPage`. Forwarding straight to `/search` restores the + * pre-consolidation destination for every deep link and bookmark. + * + * Every other query parameter rides along untouched, so navigation context + * (`queryMode`, scope filters, `focus`) survives the hop. `mode` is the one + * exception: it is always overwritten from the pathname, so a crafted + * `/dsm?mode=…` cannot redirect the visitor to an unrelated mode. + */ +export function consolidatedModeHomeTarget(pathname: string, search: URLSearchParams): string | null { + const modeId = consolidatedModeHomeModeId(pathname); + if (!modeId) return null; + + const params = new URLSearchParams(search); + params.set("mode", modeId); + + // `query` is the legacy alias several of these paths accepted before `q`. It + // has to count as a query here too, or `/services?q=%20&query=13YARN&run=1` + // reads as unsubmitted and lands on the home — the old deep link silently + // stops finding anything. The search routes canonicalise it back to `q`. + const query = (params.get("q")?.trim() || params.get("query")?.trim()) ?? ""; + const submitted = query.length > 0 && params.get("run") === "1"; + // `pathname` is the key that resolved `modeId`, and every consolidated mode's + // route namespace is that same path — so this is the mode's own search route, + // never a path built from unvalidated input. + return submitted ? `${pathname}/search?${params.toString()}` : `/?${params.toString()}`; +} + +/** + * The modes whose bare path redirects — the same set, keyed by mode instead of path. + * + * `app-modes.ts` reads this to route submitted searches to `/search` and + * unsubmitted ones straight to `/?mode=`. Deriving it here rather than + * restating the list there is what stops the two drifting: a mode added to the + * redirect map without a `/search` route would otherwise send a submitted query + * back through its own redirect and loop. + */ +export const consolidatedModeHomeModeIds: ReadonlySet = new Set( + Object.values(consolidatedModeHomePaths), +); + +/** + * The same decision as `consolidatedModeHomeTarget`, for a page's own + * `searchParams` rather than a `URLSearchParams`. + * + * Each consolidated bare path keeps a page-level `redirect()` as a backstop for + * any request the proxy matcher misses. Without this the backstop resolved to a + * bare `/?mode=`, so if it ever fired, `/forms?q=transport&run=1` arrived at + * the home having silently dropped the query, the submission and the navigation + * context — a worse answer than the proxy gives for the same URL. Routing both + * through one resolver means the fallback cannot disagree with the proxy. + */ +export function consolidatedModeHomeTargetForSearchParams( + pathname: string, + searchParams: Record, +): string | null { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(searchParams)) { + if (Array.isArray(value)) value.forEach((entry) => params.append(key, entry)); + else if (value !== undefined) params.set(key, value); + } + return consolidatedModeHomeTarget(pathname, params); +} + +/** + * `/search` routes that have no browse view of their own. + * + * Only `/calculators/search`: its component has no fallback content for an empty + * query, so an unsubmitted visit rendered nothing useful there. Redirecting it + * home is correct and has its own passing coverage + * (`tests/calculators-mode.dom.test.tsx`). + * + * `/differentials/search`, `/formulation/search` and `/specifiers/search` are + * deliberately NOT here, despite looking like the same shape: their components + * render a real browsable catalogue on an empty query — the same content + * `/differentials`, `/formulation` and `/specifiers` held before consolidation, + * relocated here rather than duplicated. That is pinned by + * `tests/ui-phone-scroll-routes.spec.ts` ("phone scroll stays smooth on + * /formulation/search"), which navigates there with no query and asserts the + * long mechanism list renders and scrolls. Two earlier passes added these three + * anyway, reasoning from the code shape alone without running that Playwright + * spec (`npm run test` doesn't cover `.spec.ts` files) — both broke the pinned + * behavior. Confirmed live a third time before writing this comment: with these + * three included, `/formulation/search` 307s to `/?mode=formulation` and the + * mechanism list never renders. Do not re-add them without first running + * `tests/ui-phone-scroll-routes.spec.ts` and confirming it still passes. + * + * `/factsheets/search`, `/dictionary/search` and `/therapy-compass/search` are + * absent for a different, unrelated reason: they're linked from their mode nav + * with no query at all (`modeSecondaryNavigationRegistry`), so they're browse + * surfaces too — redirecting them would break the tab that points at them. + */ +const modeSearchRoutesWithoutBrowseView = { + "/calculators/search": "calculators", +} as const satisfies Record; + +/** + * The shared-home URL an unsubmitted mode-search route forwards to, if any. + * + * Resolved in the proxy for the same reason as the bare paths: a page-level + * `redirect()` under the streaming `(search-app)` layout emits a client-side meta + * refresh rather than a 307, which is a second of empty shell. The pages keep + * their own redirect as a backstop, exactly as the bare paths do. + * + * `query` counts alongside `q` so a legacy `?query=` deep link is treated as + * submitted and reaches the route's own canonicalisation rather than bouncing home. + */ +export function unsubmittedModeSearchTarget(pathname: string, search: URLSearchParams): string | null { + if (!Object.hasOwn(modeSearchRoutesWithoutBrowseView, pathname)) return null; + const modeId = modeSearchRoutesWithoutBrowseView[pathname as keyof typeof modeSearchRoutesWithoutBrowseView]; + + const query = (search.get("q")?.trim() || search.get("query")?.trim()) ?? ""; + if (query) return null; + + const params = new URLSearchParams(search); + params.delete("q"); + params.delete("query"); + params.set("mode", modeId); + return `/?${params.toString()}`; +} + +/** + * `[bare path, mode]` for every consolidated home, and `[search path, mode]` for + * every unsubmitted mode-search forward. + * + * Exported for `scripts/generate-site-map.ts`. The generator used to learn which + * routes redirect by regex-scraping `redirect("…")` out of page bodies, which + * stopped seeing these the moment the stubs began computing their target instead + * of naming it as a literal — so the site map described the bare paths as pages + * that render a home. Reading the map itself cannot drift from the map. + */ +export const consolidatedModeHomeRedirectEntries = Object.entries(consolidatedModeHomePaths) as ReadonlyArray< + readonly [string, AppModeId] +>; + +export const unsubmittedModeSearchRedirectEntries = Object.entries(modeSearchRoutesWithoutBrowseView) as ReadonlyArray< + readonly [string, AppModeId] +>; diff --git a/src/lib/differentials-navigation.ts b/src/lib/differentials-navigation.ts index abebc64df3..3ead3f364c 100644 --- a/src/lib/differentials-navigation.ts +++ b/src/lib/differentials-navigation.ts @@ -9,16 +9,29 @@ import { appModeHomeHref } from "@/lib/app-modes"; /** Search home/results href that preserves compare-queue diagnosis ids. */ export function differentialCompareSearchHref(query: string, selectedIds: Iterable = []) { const trimmedQuery = query.trim(); - const base = appModeHomeHref("differentials", { + const ids = Array.from(selectedIds, (id) => id.trim()).filter(Boolean); + + // When there are selected IDs, always target /differentials/search so the + // edit-selection link lands on the search page regardless of whether there is + // a query. /differentials is now a consolidated redirect — appModeHomeHref + // with no query returns /?mode=differentials (the shared home), which is the + // wrong destination for an edit-selection link. + if (ids.length) { + const params = new URLSearchParams(); + if (trimmedQuery) { + params.set("q", trimmedQuery); + params.set("run", "1"); + } + params.set("focus", "1"); + params.set("ids", ids.join(",")); + return `/differentials/search?${params.toString()}`; + } + + return appModeHomeHref("differentials", { query: trimmedQuery || undefined, run: Boolean(trimmedQuery), focus: true, }); - const ids = Array.from(selectedIds, (id) => id.trim()).filter(Boolean); - if (!ids.length) return base; - const url = new URL(base, "http://differential-compare.local"); - url.searchParams.set("ids", ids.join(",")); - return `${url.pathname}${url.search}${url.hash}`; } export function differentialRouteWithQuery( diff --git a/src/lib/information-pages.ts b/src/lib/information-pages.ts index 608dce9557..0eae1e3a8c 100644 --- a/src/lib/information-pages.ts +++ b/src/lib/information-pages.ts @@ -21,7 +21,14 @@ export type InformationPageMode = | "dsm" | "documents"; -const TOOL_SUFFIXES = new Set(["builder", "compare", "map"]); +// Reserved route suffixes, not record slugs. `search` is here because home +// consolidation gave every consolidated mode a `/search` results route: +// without it, `/formulation/search` reads as the record `search`, the route is +// classified as an information page, and information pages suppress the +// composer — so a submitted search rendered its results with no way to refine +// them. `/factsheets` and `/dictionary` had already hand-excluded "search" for +// the same reason, which is the signal this belonged in the shared set. +const TOOL_SUFFIXES = new Set(["builder", "compare", "map", "search"]); /** * `/services/some-slug`, but not `/services`, a tool suffix, or a deeper path. diff --git a/src/lib/mode-secondary-navigation.ts b/src/lib/mode-secondary-navigation.ts index 29d0cbc0e6..939ab3c672 100644 --- a/src/lib/mode-secondary-navigation.ts +++ b/src/lib/mode-secondary-navigation.ts @@ -61,11 +61,15 @@ export const modeSecondaryNavigationRegistry = { { id: "compare", label: "Compare", href: "/therapy-compass/compare" }, { id: "pathways", label: "Pathways", href: "/therapy-compass/pathways" }, ], - // Two genuinely distinct surfaces: `/factsheets` is the browse home (category - // chips + a featured grid) and `/factsheets/search` is a separate component - // with filters, a view toggle and result rows. `/factsheets/[slug]` is a - // record and never reaches here — `hasLocalInformationPageNavigation` returns - // null for it first. + // Two genuinely distinct surfaces: the mode home and `/factsheets/search`, a + // separate component with filters, a view toggle and result rows. + // `/factsheets/[slug]` is a record and never reaches here — + // `hasLocalInformationPageNavigation` returns null for it first. + // + // Topics resolves through `appModeHomeHref`, so it followed factsheets onto the + // shared lightweight home when `/factsheets` became a redirect. The label still + // says Topics while the destination is that home; renaming it is a copy decision + // left to the owner rather than folded into the consolidation. // No `focus: true` on Topics, unlike the Search/Find entry of every mode // above. Those tabs are the mode's search affordance, so focusing the composer // on arrival is the point. Topics is a browse destination — autofocusing there diff --git a/src/lib/search-route-ownership.ts b/src/lib/search-route-ownership.ts index b078f52908..9a2c941487 100644 --- a/src/lib/search-route-ownership.ts +++ b/src/lib/search-route-ownership.ts @@ -26,22 +26,14 @@ const routeOwnedSubmittedSearchModes = new Set([ * navigation cannot flip the shell into dock reserve mid-transition. */ const standaloneModeHomePaths = new Set([ - "/services", - "/forms", + // The four modes that still own a home of their own. Every other mode was + // consolidated onto the shared home at `/?mode=`, whose composer the + // dashboard owns; their bare paths redirect and render nothing to reserve + // geometry for (`consolidatedModeHomePaths`). "/favourites", - "/differentials", - "/dsm", - "/specifiers", - "/formulation", - "/factsheets", - "/dictionary", - "/therapy-compass", "/tools", - "/calculators", - // Documents and Medication gained real homes when `/` became the single shared - // home for every mode. Like the others they own an in-flow hero composer. - "/documents", "/medications", + "/documents", ]); /** diff --git a/src/lib/ui-copy.ts b/src/lib/ui-copy.ts index d8f2150432..9a600aa4d2 100644 --- a/src/lib/ui-copy.ts +++ b/src/lib/ui-copy.ts @@ -78,7 +78,10 @@ export const sharedHomePresentation = { subtitle: "Validated psychiatry scores with the indication, items, and next actions in one place.", }, "therapy-compass": { - title: "Therapy Compass", + // "Therapy", not "Therapy Compass": the mode's own copy rule, pinned by + // tests/therapy-compass-mode-wiring.test.ts, which the retired detailed home + // followed. This title became user-visible when that home was consolidated here. + title: "Therapy", subtitle: "Source-grounded therapy records.", }, factsheets: { diff --git a/src/proxy.ts b/src/proxy.ts index 4d791e50ce..df47cbbfa1 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,6 +1,7 @@ import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; +import { consolidatedModeHomeTarget, unsubmittedModeSearchTarget } from "@/lib/consolidated-mode-home-redirect"; import { documentSourceRedirectTarget, isDocumentSourcePath } from "@/lib/document-source-redirect"; import { env } from "@/lib/env"; import { legacyHomeRedirectUrl } from "@/lib/legacy-home-redirect"; @@ -101,6 +102,35 @@ export async function proxy(request: NextRequest) { // sanitised components of this request's own query, so it cannot become an // open redirect. The page remains as a backstop for any request the matcher // misses. + // Consolidated mode homes: every mode but Favourites, Tools and Medication now + // shares one home, so its bare path forwards — to `/?mode=` unsubmitted, or + // to `/search` when the link carries a submitted query. Resolved here for + // the same reason as the document-source fallbacks below — a page `redirect()` + // under the streaming `(search-app)` layout emits a client-side meta refresh (a + // full second of empty shell) rather than a 307. The page keeps its own redirect + // as a backstop for anything this misses. + const consolidatedHomeTarget = consolidatedModeHomeTarget(pathname, request.nextUrl.searchParams); + + if (consolidatedHomeTarget) { + const url = request.nextUrl.clone(); + const [targetPathname, targetSearch = ""] = consolidatedHomeTarget.split("?"); + url.pathname = targetPathname; + url.search = targetSearch; + return withCsp(NextResponse.redirect(url)); + } + + // The same forward for an unsubmitted `/search`: those four routes have no + // browse view, so an empty query would render the retired mode home a second time. + const unsubmittedSearchTarget = unsubmittedModeSearchTarget(pathname, request.nextUrl.searchParams); + + if (unsubmittedSearchTarget) { + const url = request.nextUrl.clone(); + const [targetPathname, targetSearch = ""] = unsubmittedSearchTarget.split("?"); + url.pathname = targetPathname; + url.search = targetSearch; + return withCsp(NextResponse.redirect(url)); + } + if (isDocumentSourcePath(pathname)) { const url = request.nextUrl.clone(); const target = documentSourceRedirectTarget(url.searchParams); diff --git a/tests/app-modes.test.ts b/tests/app-modes.test.ts index 0986369a41..1f822ea9ca 100644 --- a/tests/app-modes.test.ts +++ b/tests/app-modes.test.ts @@ -165,7 +165,7 @@ describe("app mode search contract", () => { expect(isSearchableAppMode("dsm")).toBe(true); expect(config.kind).toBe("dsm"); expect(config.resultKind).toBe("dsm"); - expect(appModeHomeHref("dsm")).toBe("/dsm"); + expect(appModeHomeHref("dsm")).toBe("/?mode=dsm"); expect(appModeHomeHref("dsm", { query: " bipolar II ", run: true, focus: true })).toBe( "/dsm/search?q=bipolar+II&focus=1&run=1", ); @@ -181,7 +181,7 @@ describe("app mode search contract", () => { // Borrows the benign "tools" kind (like Therapy Compass) while keeping the shared composer. expect(config.kind).toBe("tools"); expect(config.resultKind).toBe("tools"); - expect(appModeHomeHref("factsheets")).toBe("/factsheets"); + expect(appModeHomeHref("factsheets")).toBe("/?mode=factsheets"); expect(appModeHomeHref("factsheets", { query: " sertraline ", run: true, focus: true })).toBe( "/factsheets/search?q=sertraline&focus=1&run=1", ); @@ -221,31 +221,31 @@ describe("app mode search contract", () => { expect(appModeHomeHref("documents", { query: "lithium monitoring", run: true, focus: true })).toBe( "/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1", ); - expect(appModeHomeHref("services")).toBe("/services"); - expect(appModeHomeHref("services", { focus: true })).toBe("/services?focus=1"); + expect(appModeHomeHref("services")).toBe("/?mode=services"); + expect(appModeHomeHref("services", { focus: true })).toBe("/?mode=services&focus=1"); expect(appModeHomeHref("services", { query: " 13YARN ", run: true, focus: true })).toBe( - "/services?q=13YARN&focus=1&run=1", + "/services/search?q=13YARN&focus=1&run=1", ); - expect(appModeHomeHref("forms")).toBe("/forms"); - expect(appModeHomeHref("forms", { focus: true })).toBe("/forms?focus=1"); + expect(appModeHomeHref("forms")).toBe("/?mode=forms"); + expect(appModeHomeHref("forms", { focus: true })).toBe("/?mode=forms&focus=1"); expect(appModeHomeHref("forms", { query: " transport forms ", run: true, focus: true })).toBe( - "/forms?q=transport+forms&focus=1&run=1", + "/forms/search?q=transport+forms&focus=1&run=1", ); expect(appModeHomeHref("favourites")).toBe("/favourites"); expect(appModeHomeHref("favourites", { query: " clozapine set ", run: true, focus: true })).toBe( "/favourites?q=clozapine+set&focus=1&run=1", ); expect(appModeHomeHref("differentials", { query: " acute confusion ", focus: true })).toBe( - "/differentials?q=acute+confusion&focus=1", + "/differentials/search?q=acute+confusion&focus=1", ); expect(appModeHomeHref("specifiers", { query: " depressed but racing thoughts ", run: true, focus: true })).toBe( - "/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1", + "/specifiers/search?q=depressed+but+racing+thoughts&focus=1&run=1", ); expect(appModeHomeHref("formulation", { query: " I keep going over it ", run: true, focus: true })).toBe( - "/formulation?q=I+keep+going+over+it&focus=1&run=1", + "/formulation/search?q=I+keep+going+over+it&focus=1&run=1", ); expect(appModeHomeHref("specifiers", { query: " racing thoughts ", run: true, focus: true })).toBe( - "/specifiers?q=racing+thoughts&focus=1&run=1", + "/specifiers/search?q=racing+thoughts&focus=1&run=1", ); expect(appModeHomeHref("prescribing", { query: " acamprosate renal dose " })).toBe( "/?mode=prescribing&q=acamprosate+renal+dose", @@ -257,7 +257,7 @@ describe("app mode search contract", () => { "/tools?q=medications&focus=1&run=1", ); expect(appModeHomeHref("calculators", { query: " PHQ-9 ", run: true, focus: true })).toBe( - "/calculators?q=PHQ-9&focus=1&run=1", + "/calculators/search?q=PHQ-9&focus=1&run=1", ); }); @@ -411,17 +411,17 @@ describe("app mode search contract", () => { dsm: "/dsm/search?q=clozapine&run=1", factsheets: "/factsheets/search?q=clozapine&run=1", dictionary: "/dictionary/search?q=clozapine&run=1", - // Same route, submitted branch. - services: "/services?q=clozapine&run=1", - forms: "/forms?q=clozapine&run=1", - favourites: "/favourites?q=clozapine&run=1", - differentials: "/differentials?q=clozapine&run=1", - specifiers: "/specifiers?q=clozapine&run=1", - formulation: "/formulation?q=clozapine&run=1", + services: "/services/search?q=clozapine&run=1", + forms: "/forms/search?q=clozapine&run=1", + differentials: "/differentials/search?q=clozapine&run=1", + specifiers: "/specifiers/search?q=clozapine&run=1", + formulation: "/formulation/search?q=clozapine&run=1", "therapy-compass": "/therapy-compass/search?q=clozapine&run=1", + calculators: "/calculators/search?q=clozapine&run=1", + // Same route, submitted branch: these still own a home of their own. + favourites: "/favourites?q=clozapine&run=1", // Tools has no search route by design: it filters its launcher in place. tools: "/tools?q=clozapine&run=1", - calculators: "/calculators?q=clozapine&run=1", }); }); diff --git a/tests/bundle-budget.test.ts b/tests/bundle-budget.test.ts index 558ee2141c..8d610ae2f7 100644 --- a/tests/bundle-budget.test.ts +++ b/tests/bundle-budget.test.ts @@ -279,7 +279,7 @@ describe("bundle baseline provenance", () => { }); describe("committed route bundle budgets", () => { - it("covers the same five journeys as Lighthouse with enforced numeric baselines", () => { + it("covers the same journeys as Lighthouse with enforced numeric baselines", () => { const bundle = JSON.parse(readFileSync(path.resolve("bundle-budget.json"), "utf8")); const lighthouse = JSON.parse(readFileSync(path.resolve("lighthouse-budget.json"), "utf8")); diff --git a/tests/calculators-mode.dom.test.tsx b/tests/calculators-mode.dom.test.tsx index 9aabe09ca2..6d6fd5aec5 100644 --- a/tests/calculators-mode.dom.test.tsx +++ b/tests/calculators-mode.dom.test.tsx @@ -17,6 +17,7 @@ vi.mock("next/navigation", () => ({ })); import CalculatorsRoute from "@/app/(search-app)/calculators/page"; +import CalculatorsSearchRoute from "@/app/(search-app)/calculators/search/page"; import { calculatorDomainCandidateCount, calculatorProgressCandidateCount, @@ -49,15 +50,39 @@ const emptyFilters = (): CalculatorFilterState => ({ }); describe("calculator mode routing", () => { - it("renders the shared-composer home until a non-empty search is submitted", async () => { - const home = await CalculatorsRoute({ searchParams: Promise.resolve({}) }); - const emptyRun = await CalculatorsRoute({ searchParams: Promise.resolve({ run: "1", q: " " }) }); - const results = await CalculatorsRoute({ + /* + * Calculators has no home page of its own any more: `/calculators` redirects to + * the shared lightweight home and results moved to `/calculators/search`. These + * cases pin that split, because routing a submitted query back at the bare path + * would bounce through the redirect and loop. + */ + it("forwards the bare path to the shared home, carrying a submitted query on", async () => { + navigation.redirect.mockClear(); + await expect(CalculatorsRoute({ searchParams: Promise.resolve({}) })).rejects.toThrow("NEXT_REDIRECT"); + expect(navigation.redirect).toHaveBeenLastCalledWith("/?mode=calculators"); + + // The page-level backstop resolves through the same helper as the proxy, so a + // request that reaches it keeps its query instead of arriving at the home + // having silently dropped the search the user actually submitted. + await expect(CalculatorsRoute({ searchParams: Promise.resolve({ q: "PHQ-9", run: "1" }) })).rejects.toThrow( + "NEXT_REDIRECT", + ); + expect(navigation.redirect).toHaveBeenLastCalledWith("/calculators/search?q=PHQ-9&run=1&mode=calculators"); + }); + + it("renders results only once a non-empty search is submitted", async () => { + navigation.redirect.mockClear(); + await expect(CalculatorsSearchRoute({ searchParams: Promise.resolve({}) })).rejects.toThrow("NEXT_REDIRECT"); + expect(navigation.redirect).toHaveBeenLastCalledWith("/?mode=calculators"); + + await expect(CalculatorsSearchRoute({ searchParams: Promise.resolve({ run: "1", q: " " }) })).rejects.toThrow( + "NEXT_REDIRECT", + ); + expect(navigation.redirect).toHaveBeenLastCalledWith("/?mode=calculators"); + + const results = await CalculatorsSearchRoute({ searchParams: Promise.resolve({ run: "1", q: " depression " }), }); - - expect(home.type).toBe(CalculatorsHomePage); - expect(emptyRun.type).toBe(CalculatorsHomePage); expect(results.type).toBe(CalculatorsSearchPage); expect(results.props.initialQuery).toBe("depression"); }); @@ -65,24 +90,24 @@ describe("calculator mode routing", () => { it("normalizes the legacy query parameter to the canonical q URL", async () => { navigation.redirect.mockClear(); await expect( - CalculatorsRoute({ + CalculatorsSearchRoute({ searchParams: Promise.resolve({ run: "1", query: "PHQ-9", focus: "1" }), }), ).rejects.toThrow("NEXT_REDIRECT"); - expect(navigation.redirect).toHaveBeenCalledWith("/calculators?run=1&focus=1&q=PHQ-9"); + expect(navigation.redirect).toHaveBeenCalledWith("/calculators/search?run=1&focus=1&q=PHQ-9"); }); it("removes empty and redundant legacy query parameters", async () => { navigation.redirect.mockClear(); - await expect(CalculatorsRoute({ searchParams: Promise.resolve({ query: " " }) })).rejects.toThrow( + await expect(CalculatorsSearchRoute({ searchParams: Promise.resolve({ query: " " }) })).rejects.toThrow( "NEXT_REDIRECT", ); - expect(navigation.redirect).toHaveBeenLastCalledWith("/calculators"); + expect(navigation.redirect).toHaveBeenLastCalledWith("/calculators/search"); - await expect(CalculatorsRoute({ searchParams: Promise.resolve({ q: "GAD-7", query: "" }) })).rejects.toThrow( + await expect(CalculatorsSearchRoute({ searchParams: Promise.resolve({ q: "GAD-7", query: "" }) })).rejects.toThrow( "NEXT_REDIRECT", ); - expect(navigation.redirect).toHaveBeenLastCalledWith("/calculators?q=GAD-7"); + expect(navigation.redirect).toHaveBeenLastCalledWith("/calculators/search?q=GAD-7"); }); it("mounts the universal hero composer slot and canonical starter searches", () => { @@ -92,9 +117,9 @@ describe("calculator mode routing", () => { expect(container.querySelector(".mode-home-composer-slot")).toBeInTheDocument(); expect(screen.getByRole("link", { name: /Depression severity/ })).toHaveAttribute( "href", - "/calculators?q=depression&run=1", + "/calculators/search?q=depression&run=1", ); - expect(screen.getByRole("link", { name: "PHQ-9" })).toHaveAttribute("href", "/calculators?q=PHQ-9&run=1"); + expect(screen.getByRole("link", { name: "PHQ-9" })).toHaveAttribute("href", "/calculators/search?q=PHQ-9&run=1"); }); }); @@ -213,7 +238,7 @@ describe("calculator results surface", () => { ); expect(screen.getByText("No matches for “no-such-calculator”")).toBeVisible(); await user.click(screen.getByTestId("search-results-empty-clear-search")); - expect(navigation.push).toHaveBeenCalledWith("/calculators?focus=1"); + expect(navigation.push).toHaveBeenCalledWith("/?mode=calculators&focus=1"); }); it("opens, updates, completes, closes, and resumes a scoring session", async () => { diff --git a/tests/check-lighthouse-budget.test.ts b/tests/check-lighthouse-budget.test.ts index f5fddb6da3..356d5a24e9 100644 --- a/tests/check-lighthouse-budget.test.ts +++ b/tests/check-lighthouse-budget.test.ts @@ -20,9 +20,22 @@ import { import { measurementFailureReason } from "../scripts/lighthouse-measurement-outcome.mjs"; import { deadlineAfter, processTimeoutMs, remainingMs } from "../scripts/lighthouse-time-budget.mjs"; -/** Kept in step with lighthouse-budget.json. */ +/** + * Synthetic fixture routes for the unit cases below — deliberately more than the + * committed budget measures, so run-expansion and completeness have several rows + * to work with. The committed list is asserted separately as COMMITTED_ROUTES. + */ const ROUTES = ["/", "/therapy-compass", "/documents/search", "/dsm", "/forms"]; +/** + * What lighthouse-budget.json actually measures. `/therapy-compass`, `/dsm` and + * `/forms` left the budget when home consolidation turned them into redirect + * stubs — Lighthouse followed the 307 and graded `/?mode=` against a baseline + * captured on the retired detailed home. All three now render the same shared home + * as `/`, so their removal costs duplication rather than coverage. + */ +const COMMITTED_ROUTES = ["/", "/documents/search"]; + const budget = (overrides: Record = {}) => ({ enforce: true, routes: ROUTES, @@ -425,7 +438,7 @@ describe("committed lighthouse-budget.json", () => { }; it("measures the routes this suite grades", () => { - expect(committed.routes).toEqual(ROUTES); + expect(committed.routes).toEqual(COMMITTED_ROUTES); }); it("pins the same Lighthouse version as the live-domain workflow", () => { diff --git a/tests/consolidated-mode-home-redirect.test.ts b/tests/consolidated-mode-home-redirect.test.ts new file mode 100644 index 0000000000..1561786c1b --- /dev/null +++ b/tests/consolidated-mode-home-redirect.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; + +import { appModeHomeHref, appModeIds } from "@/lib/app-modes"; +import { modeSecondaryNavigationRegistry } from "@/lib/mode-secondary-navigation"; +import { + consolidatedModeHomeModeId, + unsubmittedModeSearchTarget, + consolidatedModeHomeModeIds, + consolidatedModeHomeTarget, + isConsolidatedModeHomePath, +} from "@/lib/consolidated-mode-home-redirect"; + +const target = (pathname: string, search = "") => consolidatedModeHomeTarget(pathname, new URLSearchParams(search)); + +describe("consolidated mode home redirects", () => { + it("forwards each consolidated bare path to the shared home for its own mode", () => { + expect(target("/dsm")).toBe("/?mode=dsm"); + expect(target("/dictionary")).toBe("/?mode=dictionary"); + expect(target("/factsheets")).toBe("/?mode=factsheets"); + expect(target("/services")).toBe("/?mode=services"); + expect(target("/forms")).toBe("/?mode=forms"); + expect(target("/calculators")).toBe("/?mode=calculators"); + expect(target("/specifiers")).toBe("/?mode=specifiers"); + expect(target("/formulation")).toBe("/?mode=formulation"); + expect(target("/differentials")).toBe("/?mode=differentials"); + expect(target("/therapy-compass")).toBe("/?mode=therapy-compass"); + }); + + it("leaves every other path alone", () => { + for (const pathname of [ + "/", + "/favourites", + "/tools", + "/medications", + // Documents is dashboard-owned: the shell paints a real Documents home there. + "/documents", + "/mockups/dsm-home-detailed", + ]) { + expect(target(pathname)).toBeNull(); + expect(isConsolidatedModeHomePath(pathname)).toBe(false); + } + }); + + /* + * Sub-routes are real surfaces, not aliases of the home. Matching them here + * would redirect `/dsm/search` to `/?mode=dsm` and make the mode's own results + * unreachable — the exact failure a prefix match would introduce. + */ + it("never matches a sub-route of a consolidated namespace", () => { + for (const pathname of [ + "/dsm/search", + "/dsm/compare", + "/dsm/diagnoses/major-depressive-disorder", + "/dictionary/browse", + "/dictionary/topics", + "/factsheets/search", + "/factsheets/sertraline", + "/services/search", + "/forms/search", + "/calculators/search", + ]) { + expect(target(pathname)).toBeNull(); + expect(isConsolidatedModeHomePath(pathname)).toBe(false); + } + }); + + /* + * A submitted deep link goes straight to the mode's own results surface, which + * is where the bare path rendered it before consolidation. Routing it to the + * shared home instead was a real regression: the dashboard renders its own + * in-place results for some modes and nothing for others, so `/forms?q=…&run=1` + * stopped reaching FormsSearchResultsPage (caught by verify:phone-chrome). + */ + it("forwards a submitted deep link to the mode's own results surface", () => { + expect(target("/dsm", "q=panic+disorder&run=1")).toBe("/dsm/search?q=panic+disorder&run=1&mode=dsm"); + expect(target("/forms", "q=transport&run=1&focus=1")).toBe("/forms/search?q=transport&run=1&focus=1&mode=forms"); + // Navigation context rides along, so a scoped or mode-qualified deep link + // does not silently lose its filters crossing the hop. + expect(target("/differentials", "q=acute&run=1&queryMode=compare_guidance&scope.medications=lithium")).toBe( + "/differentials/search?q=acute&run=1&queryMode=compare_guidance&scope.medications=lithium&mode=differentials", + ); + }); + + /* + * `query` is the legacy alias for `q` on several of these paths. Treating it as + * "no query" here read `/services?q=%20&query=13YARN&run=1` as unsubmitted and + * dropped an old deep link onto the home, where it found nothing. + */ + it("counts the legacy query alias as a submitted search", () => { + expect(target("/services", "q=%20&query=13YARN&run=1")).toBe( + "/services/search?q=+&query=13YARN&run=1&mode=services", + ); + expect(target("/services", "query=13YARN&run=1")).toBe("/services/search?query=13YARN&run=1&mode=services"); + }); + + /* + * Unsubmitted is the other half: a query alone is a draft, not a search, so it + * belongs on the shared home with the composer seeded — same as before. + */ + it("keeps an unsubmitted query on the shared home", () => { + expect(target("/factsheets", "q=sertraline&focus=1")).toBe("/?q=sertraline&focus=1&mode=factsheets"); + // `run=1` with a blank query is not a submitted search either. + expect(target("/dsm", "q=++&run=1")).toBe("/?q=++&run=1&mode=dsm"); + }); + + it("overwrites a crafted mode parameter with the one the pathname names", () => { + // Without this, `/dsm?mode=favourites` would bounce a visitor into an + // unrelated mode — a redirect whose destination the query controls. + expect(target("/dsm", "mode=favourites")).toBe("/?mode=dsm"); + expect(target("/factsheets", "mode=answer&q=x")).toBe("/?mode=factsheets&q=x"); + // Including on the submitted branch, where the destination path is the + // mode's own namespace rather than anything the query could name. + expect(target("/dsm", "mode=favourites&q=x&run=1")).toBe("/dsm/search?mode=dsm&q=x&run=1"); + }); + + it("resolves every consolidated path to a real app mode", () => { + for (const modeId of consolidatedModeHomeModeIds) { + expect(appModeIds).toContain(modeId); + expect(consolidatedModeHomeModeId(`/${modeId}`)).toBe(modeId); + } + }); + + /* + * The loop guard. A consolidated bare path redirects to the shared home, and the + * shared home routes a submitted query back to the mode's own surface — so that + * surface must never be the bare path again. `appModeHomeHref` is the only thing + * that builds it, so asserting on its output is asserting on the real hop. + */ + it("never routes a submitted search back at a path that redirects", () => { + for (const modeId of consolidatedModeHomeModeIds) { + const submitted = appModeHomeHref(modeId, { query: "clozapine", run: true }); + expect(target(new URL(submitted, "https://clinical.test").pathname)).toBeNull(); + expect(submitted.startsWith(`/${modeId}/search?`)).toBe(true); + } + }); + + /* + * An unsubmitted `/calculators/search` has nothing to show: its component + * falls back to the mode home, which the page consolidation retired. Resolved + * in the proxy so it is a 307 rather than the streamed meta refresh a + * page-level redirect would emit. + */ + it("forwards an unsubmitted mode search to the shared home", () => { + const search = (pathname: string, query = "") => unsubmittedModeSearchTarget(pathname, new URLSearchParams(query)); + + expect(search("/calculators/search")).toBe("/?mode=calculators"); + expect(search("/calculators/search", "q=+++")).toBe("/?mode=calculators"); + }); + + /* + * The scope is deliberately narrow — only `/calculators/search`. Differentials, + * Formulation and Specifiers search routes are NOT here even though they look + * like the same shape: their components render a real browsable catalogue on an + * empty query — the same content their bare mode paths held before + * consolidation, relocated rather than duplicated + * (`tests/ui-phone-scroll-routes.spec.ts` pins the long list rendering at + * `/formulation/search` with no query). Factsheets, Dictionary and Therapy are + * absent for the separate reason below: they are linked from their own mode + * nav with no query at all, so redirecting them would break the tab pointing + * at them. + */ + it("leaves query-free browse surfaces alone", () => { + const search = (pathname: string, query = "") => unsubmittedModeSearchTarget(pathname, new URLSearchParams(query)); + + for (const pathname of [ + "/differentials/search", + "/formulation/search", + "/specifiers/search", + "/factsheets/search", + "/dictionary/search", + "/therapy-compass/search", + ]) { + expect(search(pathname)).toBeNull(); + } + // A submitted search is never redirected, and `query` counts as submitted so a + // legacy deep link reaches the route's own canonicalisation instead of bouncing. + expect(search("/differentials/search", "q=pain&run=1")).toBeNull(); + expect(search("/calculators/search", "query=PHQ-9")).toBeNull(); + }); + + /* + * Every mode nav destination must survive the proxy. A future addition to the + * no-browse-view set that collides with a linked tab would strand that tab on + * the shared home; this walks the real registry rather than a copy of it. + */ + it("never redirects a route the mode nav links to without a query", () => { + for (const entries of Object.values(modeSecondaryNavigationRegistry)) { + for (const entry of entries) { + if (!("href" in entry) || !entry.href || entry.href.includes("?")) continue; + expect(unsubmittedModeSearchTarget(entry.href, new URLSearchParams()), entry.href).toBeNull(); + } + } + }); +}); diff --git a/tests/cross-mode-links.test.ts b/tests/cross-mode-links.test.ts index cc79b5c871..794facde79 100644 --- a/tests/cross-mode-links.test.ts +++ b/tests/cross-mode-links.test.ts @@ -69,7 +69,7 @@ describe("buildCrossModeLinks", () => { subtitle: "Intensive home-based acute care", }); expect(links[0]!.badges).toEqual([{ label: "Acute", tone: "info" }]); - expect(links[0]!.modeSearchHref).toContain("/services?"); + expect(links[0]!.modeSearchHref).toContain("/services/search?"); expect(links[0]!.modeSearchHref).toContain("run=1"); }); diff --git a/tests/design-system-adoption.test.ts b/tests/design-system-adoption.test.ts index c1407cb252..74e72cfa92 100644 --- a/tests/design-system-adoption.test.ts +++ b/tests/design-system-adoption.test.ts @@ -1238,11 +1238,14 @@ describe("design-system adoption manifest", () => { ["committed", "not-committed", "not-applicable"].includes(surface.baseline.status), ), ).toBe(true); - // 69: the previous 59 production pages (51 plus the eight-route Dictionary - // surface) plus the ten-route Ward Flow synthetic patient-flow prototype - // (mode home, six workspace routes, governance, transport, and the - // per-patient detail route). - expect(manifest.routeCoverage.discovered).toHaveLength(69); + // 75 = 59 + 6 + 10: the 59 production pages that preceded both changes, the + // six `/search` routes home consolidation split out of the bare paths, + // and the ten-route Ward Flow synthetic patient-flow prototype (mode home, + // six workspace routes, governance, transport, and the per-patient detail + // route). Redirect stubs keep legacy deep links resolving and still count as + // declared routes. The conflict resolution on this branch kept the pre-merge + // 69, which counted Ward Flow but not the six search routes. + expect(manifest.routeCoverage.discovered).toHaveLength(75); expect(manifest.routeCoverage.declared).toEqual(manifest.routeCoverage.discovered); expect(manifest.routeCoverage.undeclared).toEqual([]); expect(manifest.routeCoverage.missing).toEqual([]); diff --git a/tests/differentials-navigation.test.ts b/tests/differentials-navigation.test.ts index f6f4ee0aaf..5dc353a203 100644 --- a/tests/differentials-navigation.test.ts +++ b/tests/differentials-navigation.test.ts @@ -124,7 +124,7 @@ describe("differentials navigation", () => { it("preserves compare-queue ids when returning to Search", () => { const href = differentialCompareSearchHref("Pain", ["wernicke-encephalopathy", "delirium"]); - expect(href).toContain("/differentials?"); + expect(href).toContain("/differentials/search?"); expect(href).toContain("q=Pain"); expect(href).toContain("run=1"); expect(href).toMatch(/ids=wernicke-encephalopathy%2Cdelirium|ids=wernicke-encephalopathy,delirium/); diff --git a/tests/favourites-auth-gate.dom.test.tsx b/tests/favourites-auth-gate.dom.test.tsx index 6176adbfba..a23d18f7bf 100644 --- a/tests/favourites-auth-gate.dom.test.tsx +++ b/tests/favourites-auth-gate.dom.test.tsx @@ -108,9 +108,9 @@ describe("favourites auth gate DOM", () => { ).toEqual([ { name: "Answer", href: "/?mode=answer" }, { name: "Documents", href: "/documents" }, - { name: "Services", href: "/services" }, + { name: "Services", href: "/?mode=services" }, { name: "Medication", href: "/medications" }, - { name: "Factsheets", href: "/factsheets" }, + { name: "Factsheets", href: "/?mode=factsheets" }, { name: "Tools", href: "/tools" }, ]); expect(screen.queryByRole("link", { name: "Favourites" })).toBeNull(); diff --git a/tests/forms-back-navigation.dom.test.tsx b/tests/forms-back-navigation.dom.test.tsx index 7e9d7eefd1..906a6423c4 100644 --- a/tests/forms-back-navigation.dom.test.tsx +++ b/tests/forms-back-navigation.dom.test.tsx @@ -20,6 +20,6 @@ describe("Form detail back navigation", () => { it("links to the canonical Forms home with focus", () => { render(); const back = screen.getByRole("link", { name: /Forms/i }); - expect(back).toHaveAttribute("href", "/forms?focus=1"); + expect(back).toHaveAttribute("href", "/?mode=forms&focus=1"); }); }); diff --git a/tests/page-secondary-navigation.dom.test.tsx b/tests/page-secondary-navigation.dom.test.tsx index 3431cc78df..c7437e6476 100644 --- a/tests/page-secondary-navigation.dom.test.tsx +++ b/tests/page-secondary-navigation.dom.test.tsx @@ -137,10 +137,10 @@ describe("PageSecondaryNavigation", () => { "href", "/factsheets/search?q=sertraline&category=Medicines&run=1", ); - // Topics is the browse home: it reads neither param, and carries no - // focus=1 either — autofocusing the composer there would open the phone - // keyboard over the topics the user just asked to browse. - expect(screen.getByRole("link", { name: "Topics" })).toHaveAttribute("href", "/factsheets"); + // Topics is the mode home — the shared lightweight one since `/factsheets` + // became a redirect. It reads neither param, and carries no focus=1 either: + // autofocusing the composer there would open the phone keyboard unbidden. + expect(screen.getByRole("link", { name: "Topics" })).toHaveAttribute("href", "/?mode=factsheets"); }); it("keeps the newly adopted factsheets bar off its record routes", () => { diff --git a/tests/playwright-settlement-contract.test.ts b/tests/playwright-settlement-contract.test.ts index 02089cdf0c..262f8fc339 100644 --- a/tests/playwright-settlement-contract.test.ts +++ b/tests/playwright-settlement-contract.test.ts @@ -14,8 +14,10 @@ import { describe, expect, it } from "vitest"; const ROUTE_COVERAGE = "tests/ui-route-coverage.spec.ts"; const PAGE_ROOT_TEST_IDS = [ - "dsm-home-main", - "dsm-home-compare", + // `dsm-home-main` and `dsm-home-compare` left this list with the DSM detailed + // home: `/dsm` redirects onto the shared home, so the route proves + // `shared-home-empty-state` instead and Compare is reached from the mode nav. + "shared-home-empty-state", "dsm-comparison-page", "dsm-differential-considerations-page", "search-query-ribbon", diff --git a/tests/search-results-band-adoption.test.ts b/tests/search-results-band-adoption.test.ts index 3d7df9b4f0..a457b40cc0 100644 --- a/tests/search-results-band-adoption.test.ts +++ b/tests/search-results-band-adoption.test.ts @@ -6,6 +6,7 @@ import { parse } from "@babel/parser"; import { describe, expect, it } from "vitest"; import { appModeDefinitions } from "@/lib/app-modes"; +import { consolidatedModeHomeModeIds } from "@/lib/consolidated-mode-home-redirect"; import { isAlwaysStandaloneShellPath, isStandaloneModeHomePath } from "@/lib/search-route-ownership"; /** @@ -95,22 +96,22 @@ const BAND_ROUTE_ALLOWLIST = new Map([ "src/app/(search-app)/medications/page.tsx", "Medication mode home is a content slot; the band is mounted by medication-prescribing-workspace.tsx inside the dashboard shell.", ], - [ - "src/app/(search-app)/dsm/page.tsx", - "DSM mode home is a catalogue landing; result lists (and the band) live on /dsm/search.", - ], - [ - "src/app/(search-app)/factsheets/page.tsx", - "Factsheets mode home is a catalogue landing; result lists (and the band) live on /factsheets/search.", - ], - [ - "src/app/(search-app)/dictionary/page.tsx", - "Dictionary mode home uses the shared in-flow composer; its mixed result list and band live on /dictionary/search.", - ], - [ - "src/app/(search-app)/therapy-compass/page.tsx", - "Therapy home is the library landing; the search results band lives on /therapy-compass/search.", - ], + /* + * Every consolidated mode home. These bare paths render nothing at all any more + * — each is a `redirect()` onto the shared lightweight home — so there is no + * result list for the band to head. Each mode's results (and the band) live at + * `/search`, which this inventory still checks. + * + * Derived from the redirect map rather than listed, so consolidating another + * mode cannot leave a stale reason behind claiming it still renders a landing. + */ + ...[...consolidatedModeHomeModeIds].map( + (modeId) => + [ + `src/app/(search-app)/${modeId}/page.tsx`, + `${modeId} has no home of its own: the bare path redirects to /?mode=${modeId} and its result list (with the band) lives on /${modeId}/search.`, + ] as const, + ), ]); const ROOT_DASHBOARD_ROUTE = path.join(APP_DIR, "(search-app)", "page.tsx"); diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index fe526519e3..f9f9ed661b 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest"; import { sourceFrom, sourceSegment } from "./helpers/source-contract"; +import { consolidatedModeHomeModeIds } from "@/lib/consolidated-mode-home-redirect"; + import { isAlwaysStandaloneShellPath, isDashboardModeHref, @@ -57,21 +59,7 @@ describe("shared-search route ownership", () => { }); it("classifies standalone mode homes from pathname alone", () => { - for (const pathname of [ - "/services", - "/forms", - "/favourites", - "/differentials", - "/dsm", - "/specifiers", - "/formulation", - "/factsheets", - "/therapy-compass", - "/tools", - "/calculators", - "/documents", - "/medications", - ]) { + for (const pathname of ["/favourites", "/tools", "/medications", "/documents"]) { expect(isStandaloneModeHomePath(pathname)).toBe(true); } expect(isStandaloneModeHomePath("/")).toBe(false); @@ -79,6 +67,25 @@ describe("shared-search route ownership", () => { expect(isStandaloneModeHomePath("/dsm/search")).toBe(false); }); + /* + * Consolidated modes own no composer at their bare path. + * + * The ten consolidated modes no longer render a home of their own — + * they redirect onto the one shared home at `/?mode=`, whose composer the + * dashboard owns. Claiming standalone ownership for a path that renders nothing + * would reserve hero composer geometry on a route that never paints, so these + * must classify false while their SUB-routes keep standalone shell treatment. + */ + it("does not claim composer ownership for consolidated mode homes", () => { + for (const modeId of consolidatedModeHomeModeIds) { + expect(isStandaloneModeHomePath(`/${modeId}`)).toBe(false); + } + // Each namespace still needs the standalone shell for its own sub-routes. + for (const modeId of consolidatedModeHomeModeIds) { + expect(isAlwaysStandaloneShellPath(`/${modeId}`)).toBe(true); + } + }); + it("marks route-owned namespaced paths as always-standalone shell (no searchParams gate)", () => { expect(isAlwaysStandaloneShellPath("/forms")).toBe(true); expect(isAlwaysStandaloneShellPath("/favourites")).toBe(true); diff --git a/tests/shared-home-empty-state.dom.test.tsx b/tests/shared-home-empty-state.dom.test.tsx index ad68c54ec2..5948ca836e 100644 --- a/tests/shared-home-empty-state.dom.test.tsx +++ b/tests/shared-home-empty-state.dom.test.tsx @@ -85,7 +85,7 @@ const expectedPresentations = [ }, { modeId: "therapy-compass", - title: "Therapy Compass", + title: "Therapy", subtitle: "Source-grounded therapy records.", iconClass: "lucide-compass", }, diff --git a/tests/therapy-compass-mode-wiring.test.ts b/tests/therapy-compass-mode-wiring.test.ts index f123d0c0d3..fde4b07f4a 100644 --- a/tests/therapy-compass-mode-wiring.test.ts +++ b/tests/therapy-compass-mode-wiring.test.ts @@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { appModeHomeHref } from "@/lib/app-modes"; import { isStandaloneModeHomePath, shouldRenderDashboardSearch } from "@/lib/search-route-ownership"; import { THERAPY_CATALOGUE_ASSETS, @@ -236,9 +237,11 @@ describe("Therapy Compass production-mode wiring", () => { new URL("../src/components/therapy-compass/bindings.tsx", import.meta.url), "utf8", ); - // The home route reads q/run and redirects a run-enabled deep link to the dedicated search route... - expect(routeSrc).toMatch(/searchParams/); - expect(routeSrc).toMatch(/redirect\(`\/therapy-compass\/search/); + // The bare path no longer renders a home: it forwards to the shared lightweight + // one, which resolves a submitted query onward to the dedicated search route. + // The query survives that hop because the proxy carries it (consolidatedModeHomeTarget). + expect(routeSrc).toMatch(/redirect\(appModeSelectionHref\("therapy-compass"\)\)/); + expect(appModeHomeHref("therapy-compass", { query: "CBT", run: true })).toBe("/therapy-compass/search?q=CBT&run=1"); // ...and the provider derives the active screen from the pathname and seeds the query from ?q. expect(bindingsSrc).toMatch(/resolveTherapyRoute\(pathname\)/); expect(bindingsSrc).toMatch(/searchParams\.get\("q"\)/); @@ -275,7 +278,10 @@ describe("Therapy Compass production-mode wiring", () => { expect(homeSrc).toContain("desktopComposerSlotId={modeHomeDesktopComposerSlotId}"); // Mode homes are pathname-gated so optimistic searchMode cannot flip hero→dock mid-nav. expect(shellSrc).toContain("isStandaloneModeHomePath(pathname)"); - expect(isStandaloneModeHomePath("/therapy-compass")).toBe(true); + // `/therapy-compass` is no longer one of them — it redirects to the shared home, + // so there is no therapy-owned home render for the gate to protect. The screen + // itself is preserved as design scratch at /mockups/therapy-compass-home-detailed. + expect(isStandaloneModeHomePath("/therapy-compass")).toBe(false); }); it("keeps the results-band shelf Clear filter-only so it cannot delete the query", () => { diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index 6cf8391cc1..839622bb8f 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -558,7 +558,9 @@ test.describe("Clinical KB accessibility coverage", () => { test.setTimeout(300_000); await page.emulateMedia({ colorScheme: "dark" }); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto("/therapy-compass", { waitUntil: "domcontentloaded" }); + // `/therapy-compass` redirects to the shared home since consolidation; going + // there directly keeps this on the surface it is asserting about. + await page.goto("/?mode=therapy-compass", { waitUntil: "domcontentloaded" }); await expect(page.getByRole("heading", { name: "Therapy", exact: true })).toBeVisible({ timeout: 60_000, diff --git a/tests/ui-dictionary.spec.ts b/tests/ui-dictionary.spec.ts index 0bd7e3e183..0a45bd53a4 100644 --- a/tests/ui-dictionary.spec.ts +++ b/tests/ui-dictionary.spec.ts @@ -1,8 +1,14 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test, type Page, type TestInfo } from "playwright/test"; +/* + * `/dictionary` is deliberately absent: it has no home of its own any more. The + * bare path redirects to the shared lightweight home at `/?mode=dictionary`, + * which is covered by the shared-home suites rather than here. The retired + * detailed home lives at `/mockups/dictionary-home-detailed`, which 404s in + * production and is out of scope for a production-route sweep. + */ const routes = [ - { path: "/dictionary", testId: "dictionary-home-main" }, { path: "/dictionary/search?q=MSE", testId: "dictionary-search-main" }, { path: "/dictionary/browse", testId: "dictionary-browse-main" }, { path: "/dictionary/topics", testId: "dictionary-topics-main" }, @@ -72,7 +78,7 @@ for (const viewport of [ { name: "phone", width: 390, height: 844 }, { name: "compact phone", width: 320, height: 760 }, ] as const) { - test(`renders all eight routes without overflow at ${viewport.name}`, async ({ page }) => { + test(`renders every Dictionary route without overflow at ${viewport.name}`, async ({ page }) => { await page.setViewportSize({ width: viewport.width, height: viewport.height }); for (const route of routes) await expectDictionaryRoute(page, route); }); diff --git a/tests/ui-formulation.spec.ts b/tests/ui-formulation.spec.ts index 952532d0c1..1c00c4905f 100644 --- a/tests/ui-formulation.spec.ts +++ b/tests/ui-formulation.spec.ts @@ -58,10 +58,12 @@ test.beforeEach(async ({ page }) => { test("searches patient language, opens a mechanism guide, and carries it into the builder", async ({ page, }, testInfo) => { - await gotoApp(page, "/formulation"); + // `/formulation` is a 307 onto the shared home now; going there directly keeps + // the test on the surface it is actually asserting about. + await gotoApp(page, "/?mode=formulation"); - await expect(page.getByRole("heading", { name: "Formulation", exact: true })).toBeVisible(); - await expect(page.getByTestId("formulation-home")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Clinical Formulation", exact: true })).toBeVisible(); + await expect(page.getByTestId("shared-home-empty-state")).toBeVisible(); const search = page.getByTestId("global-search-input").filter({ visible: true }).first(); await expect(search).toHaveAccessibleName( @@ -70,7 +72,9 @@ test("searches patient language, opens a mechanism guide, and carries it into th await search.fill("I keep going over it"); await page.getByRole("button", { name: "Find matching formulation mechanisms" }).click(); - await expect(page).toHaveURL(/\/formulation\?.*q=I(?:\+|%20)keep(?:\+|%20)going(?:\+|%20)over(?:\+|%20)it.*run=1/); + await expect(page).toHaveURL( + /\/formulation\/search\?.*q=I(?:\+|%20)keep(?:\+|%20)going(?:\+|%20)over(?:\+|%20)it.*run=1/, + ); const queryRibbon = page.getByTestId("search-query-ribbon"); await expect(queryRibbon.getByRole("heading", { level: 1, name: "I keep going over it" })).toBeVisible(); await expect(queryRibbon.getByRole("group", { name: "Filter formulation mechanisms" })).toBeVisible(); diff --git a/tests/ui-phone-scroll-routes.spec.ts b/tests/ui-phone-scroll-routes.spec.ts index 7a7b9936c8..73f9bc247a 100644 --- a/tests/ui-phone-scroll-routes.spec.ts +++ b/tests/ui-phone-scroll-routes.spec.ts @@ -104,10 +104,14 @@ for (const route of [...modeHomeRoutes, ...dashboardRoutes, ...longRoutes]) { // One larger-phone pass over the worst offender to catch viewport-dependent // regressions (the 390x844 sweep above is the canonical size). -test("phone scroll stays smooth on /formulation at 430x932", async ({ page }) => { +// +// The long mechanism list moved to `/formulation/search` when `/formulation` +// became a redirect onto the shared home. The runway is the point of this test — +// it deliberately does NOT call addPhoneScrollRunway — so it follows the content. +test("phone scroll stays smooth on /formulation/search at 430x932", async ({ page }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); await page.setViewportSize({ width: 430, height: 932 }); - await gotoPhoneSurface(page, "/formulation"); + await gotoPhoneSurface(page, "/formulation/search"); await installFlipCounter(page); const initial = await readGeometry(page); diff --git a/tests/ui-route-coverage.spec.ts b/tests/ui-route-coverage.spec.ts index 4f9cfccb95..d757a9682c 100644 --- a/tests/ui-route-coverage.spec.ts +++ b/tests/ui-route-coverage.spec.ts @@ -249,20 +249,24 @@ test.describe("previously uncovered production routes", () => { page, "/therapy-compass", async (currentPage) => { + // `/therapy-compass` redirects onto the shared home, whose per-mode title + // is a level-2 heading under the page's sr-only "Clinical Guide" h1. await expect(currentPage.getByRole("main")).toBeVisible(); - await expect(currentPage.getByRole("heading", { name: "Therapy", level: 1, exact: true })).toBeVisible({ + await expect(currentPage.getByRole("heading", { name: "Therapy", level: 2, exact: true })).toBeVisible({ timeout: 30_000, }); }, async (currentPage) => { - const search = currentPage - .getByRole("region", { name: "Common therapy searches" }) - .getByRole("button", { name: "Anxiety in outpatient care", exact: true }); - await expect(search).toBeEnabled(); - await search.click(); + // The "Common therapy searches" pills lived on the retired detailed home, + // which moved to /mockups when Therapy joined the shared home. The + // destination they opened is what this step is really about, so go there: + // a submitted therapy search, which is also where the mode nav renders. + await currentPage.goto("/therapy-compass/search?q=Anxiety+in+outpatient+care&run=1", { + waitUntil: "domcontentloaded", + }); await expect( currentPage.getByRole("heading", { name: "Anxiety in outpatient care", level: 1, exact: true }), - ).toBeVisible(); + ).toBeVisible({ timeout: 30_000 }); await expect(visibleByTestId(currentPage, "search-query-ribbon")).toBeVisible(); // The common-search pill lands on `/therapy-compass/search`, which is the // shared `ModeNav`. It portals into the header collapse host (outside @@ -374,6 +378,9 @@ test.describe("previously uncovered production routes", () => { await expect(page.getByRole("heading", { name: "Therapy Comparison", level: 1 })).toBeVisible(); }); + // `/dsm` redirects onto the shared home, so the route this proves is the shared + // one with DSM preselected. Comparison moved with it: the Compare action was on + // the retired detailed home, and its live entry point is the DSM mode nav. test("DSM home renders responsively and opens comparison", async ({ page }) => { await proveRenderedRoute( page, @@ -382,12 +389,15 @@ test.describe("previously uncovered production routes", () => { // Scope to the visible owner: Next streaming can leave a hidden duplicate // page root (#093), and bare getByTestId then fails Playwright strict mode // (Production UI shard 2 on PR #1729). - await expect(visibleByTestId(currentPage, "dsm-home-main")).toBeVisible(); - await expect(currentPage.getByRole("heading", { name: "DSM-5 Diagnosis", level: 1 })).toBeVisible(); + await expect(visibleByTestId(currentPage, "shared-home-empty-state")).toBeVisible(); + await expect(currentPage.getByRole("heading", { name: "DSM-5 Diagnosis", level: 2 })).toBeVisible(); }, async (currentPage) => { - const compare = visibleByTestId(currentPage, "dsm-home-compare"); - await expect(compare).toBeEnabled(); + await currentPage.goto("/dsm/search?q=major+depressive&run=1", { waitUntil: "domcontentloaded" }); + const compare = currentPage + .getByRole("navigation", { name: "DSM-5 Diagnosis pages" }) + .getByRole("link", { name: "Compare", exact: true }); + await expect(compare).toBeVisible(); await compare.click(); await expect(currentPage).toHaveURL(/\/dsm\/compare$/); await expect(currentPage.getByRole("heading", { name: "Compare DSM diagnoses", level: 1 })).toBeVisible(); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 645269dd67..9b8f323bf5 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -808,10 +808,15 @@ async function openMobileClinicalGuideMenu(page: Page) { .evaluateAll((links) => links.map((link) => ({ name: link.textContent, href: link.getAttribute("href") }))), ).toEqual([ { name: "Answer", href: "/?mode=answer" }, + // Documents owns a real home: the shell mounts ClinicalDashboard for + // /documents, so it paints browse and recent documents rather than the + // shared hero. Every other consolidated mode links at the shared home + // directly — pointing a pinned entry at its old bare path would spend a + // 307 arriving in the same place. { name: "Documents", href: "/documents" }, - { name: "Services", href: "/services" }, + { name: "Services", href: "/?mode=services" }, { name: "Medication", href: "/medications" }, - { name: "Factsheets", href: "/factsheets" }, + { name: "Factsheets", href: "/?mode=factsheets" }, { name: "Tools", href: "/tools" }, ]); await expect(navigation.getByRole("button", { name: "More modes" })).toBeVisible(); @@ -1356,9 +1361,9 @@ test.describe("Clinical KB UI smoke coverage", () => { ).toEqual([ { name: "Answer", href: "/?mode=answer" }, { name: "Documents", href: "/documents" }, - { name: "Services", href: "/services" }, + { name: "Services", href: "/?mode=services" }, { name: "Medication", href: "/medications" }, - { name: "Factsheets", href: "/factsheets" }, + { name: "Factsheets", href: "/?mode=factsheets" }, { name: "Tools", href: "/tools" }, ]); expect( @@ -1444,9 +1449,12 @@ test.describe("Clinical KB UI smoke coverage", () => { await page.setViewportSize({ width: 1280, height: 900 }); await mockDemoApi(page); - await gotoApp(page, "/documents"); + // The Documents workspace lives at its search route since consolidation, and + // the dashboard only mounts it for a submitted query; `/documents` itself + // redirects to the shared home. + await gotoApp(page, "/documents/search?q=lithium+monitoring&run=1"); await expect(page.getByRole("button", { name: "Mode Documents" })).toBeVisible(); - await expect(page.getByTestId("document-search-workspace")).toBeVisible(); + await expect(page.getByTestId("document-search-workspace")).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("heading", { name: "Something went wrong" })).toHaveCount(0); }); @@ -3276,7 +3284,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByRole("button", { name: "Mode Differentials" })).toBeVisible(); await gotoApp(page, "/?mode=differentials&q=acute+confusion&focus=1&run=1"); - await expect(page).toHaveURL(/\/differentials\?q=acute\+confusion&focus=1&run=1$/); + await expect(page).toHaveURL(/\/differentials\/search\?q=acute\+confusion&focus=1&run=1\b/); // Submitted differentials deep links resolve to the standalone results // surface (`autoRunSearch`), not the mode-home template. Production // hydration can briefly overlap the outgoing server tree and the settled @@ -3313,7 +3321,8 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByRole("heading", { level: 1, name: "Major depressive disorder" })).toBeVisible(); // The breadcrumb row went with the in-page header: its back control is the // one route out to the mode home, and a breadcrumb under it is a second. - await expect(page.getByRole("link", { name: "Back to dsm-5" })).toHaveAttribute("href", "/dsm"); + // The one route out of a record is the mode home, which is the shared home now. + await expect(page.getByRole("link", { name: "Back to dsm-5" })).toHaveAttribute("href", "/?mode=dsm"); await expectNoPageHorizontalOverflow(page); }); @@ -3384,7 +3393,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await gotoApp(page, "/?mode=specifiers&q=anxious+distress&focus=1&run=1"); // /?mode=specifiers → /specifiers (Specifiers is its own mode, distinct from Formulation) - await expect(page).toHaveURL(/\/specifiers\?q=anxious\+distress&focus=1&run=1$/); + await expect(page).toHaveURL(/\/specifiers\/search\?q=anxious\+distress&focus=1&run=1\b/); const queryRibbon = page.getByTestId("search-query-ribbon"); await expect(queryRibbon.getByRole("heading", { level: 1, name: "anxious distress" })).toBeVisible(); await expect(queryRibbon.getByRole("group", { name: "Filter specifier results" })).toBeVisible(); @@ -3395,7 +3404,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await mockDemoApi(page); await gotoApp(page, "/?mode=formulation&q=I+keep+going+over+it&focus=1&run=1"); - await expect(page).toHaveURL(/\/formulation\?q=I\+keep\+going\+over\+it&focus=1&run=1$/); + await expect(page).toHaveURL(/\/formulation\/search\?q=I\+keep\+going\+over\+it&focus=1&run=1\b/); const queryRibbon = page.getByTestId("search-query-ribbon"); await expect(queryRibbon.getByRole("heading", { level: 1, name: "I keep going over it" })).toBeVisible(); await expect(queryRibbon.getByRole("group", { name: "Filter formulation mechanisms" })).toBeVisible(); @@ -5059,7 +5068,7 @@ test.describe("Clinical KB UI smoke coverage", () => { test("non-answer phone header keeps the in-flow collapse hide", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockDemoApi(page); - await gotoApp(page, "/documents"); + await gotoApp(page, "/documents/search?q=lithium+monitoring&run=1"); const header = page.locator("header.universal-header"); const collapseHost = page.getByTestId("universal-header-collapse"); diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts index aa4f75e72b..3cfee82ac1 100644 --- a/tests/ui-specifiers.spec.ts +++ b/tests/ui-specifiers.spec.ts @@ -61,10 +61,12 @@ test.beforeEach(async ({ page }) => { test("searches clinical language without provenance fields and carries a result into wording", async ({ page, }, testInfo) => { - await gotoApp(page, "/specifiers"); + // `/specifiers` is a 307 onto the shared home now; going there directly keeps + // the test on the surface it is actually asserting about. + await gotoApp(page, "/?mode=specifiers"); - await expect(page.getByRole("heading", { name: "Specifiers", exact: true })).toBeVisible(); - await expect(page.getByTestId("specifiers-home")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Diagnostic Specifiers", exact: true })).toBeVisible(); + await expect(page.getByTestId("shared-home-empty-state")).toBeVisible(); const search = page.getByTestId("global-search-input").filter({ visible: true }).first(); await expect(search).toHaveAccessibleName( @@ -73,7 +75,9 @@ test("searches clinical language without provenance fields and carries a result await search.fill("depressed but racing thoughts"); await page.getByRole("button", { name: "Find matching psychiatric specifiers" }).click(); - await expect(page).toHaveURL(/\/specifiers\?.*q=depressed(?:\+|%20)but(?:\+|%20)racing(?:\+|%20)thoughts.*run=1/); + await expect(page).toHaveURL( + /\/specifiers\/search\?.*q=depressed(?:\+|%20)but(?:\+|%20)racing(?:\+|%20)thoughts.*run=1/, + ); const queryRibbon = page.getByTestId("search-query-ribbon"); await expect(queryRibbon.getByRole("heading", { level: 1, name: "depressed but racing thoughts" })).toBeVisible(); await expect(queryRibbon.getByRole("group", { name: "Filter specifier results" })).toBeVisible(); @@ -118,7 +122,7 @@ test("searches clinical language without provenance fields and carries a result test("keeps mobile search, filters, results, and the fixed composer usable", async ({ page }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); - await gotoApp(page, "/specifiers?q=returns+every+winter&run=1"); + await gotoApp(page, "/specifiers/search?q=returns+every+winter&run=1"); const queryRibbon = page.getByTestId("search-query-ribbon"); await expect(queryRibbon.getByRole("heading", { level: 1, name: "returns every winter" })).toBeVisible(); diff --git a/tests/ui-style-contract.spec.ts b/tests/ui-style-contract.spec.ts index 31d6a133a2..d61dc30345 100644 --- a/tests/ui-style-contract.spec.ts +++ b/tests/ui-style-contract.spec.ts @@ -244,9 +244,13 @@ test.describe("unlayered style rules render their effect", () => { throw new Error(`tap-carrier enumeration did not stabilise after 20 polls; last read: ${JSON.stringify(shapes)}`); }; + // Forms results, not `/forms`: that path is a redirect onto the shared home + // since consolidation, and the shared home carries no `min-h-tap` element at + // all — the audit would enumerate an empty set and pass vacuously. The result + // rows are where this repo's tap carriers actually live. const runAudit = async (): Promise => { - await page.goto("/forms", { waitUntil: "domcontentloaded" }); - await page.getByRole("region", { name: "Forms tasks" }).waitFor({ state: "visible", timeout: 20_000 }); + await page.goto("/forms/search?q=transport&run=1", { waitUntil: "domcontentloaded" }); + await page.getByTestId("form-search-mobile-results").waitFor({ state: "visible", timeout: 20_000 }); return waitForStableEnumeration(); }; diff --git a/tests/ui-tools-search-mode-mockup.spec.ts b/tests/ui-tools-search-mode-mockup.spec.ts index b7459e618e..5e079d0006 100644 --- a/tests/ui-tools-search-mode-mockup.spec.ts +++ b/tests/ui-tools-search-mode-mockup.spec.ts @@ -124,8 +124,14 @@ test.describe("Perfected Tools results mode mockup @mockup", () => { const mockup = await gotoMockup(page, 1440); await page.locator('[data-testid="global-search-input"]:visible').fill(""); - await expect(mockup.getByText("14 tools", { exact: true })).toBeVisible(); - await expect(mockup.locator('section[aria-label="Tool results"] article')).toHaveCount(14); + // The claim is self-consistency — the headline count matches the rows actually + // rendered — so it reads the rendered count rather than pinning an absolute. + // A hard-coded total silently rots the moment the catalogue gains a tool, which + // is what "Add Ward Flow" (#2140) did to the 14 this line used to carry. + const results = mockup.locator('section[aria-label="Tool results"] article'); + const rendered = await results.count(); + expect(rendered, "the unfiltered mockup must render at least one tool").toBeGreaterThan(0); + await expect(mockup.getByText(`${rendered} tools`, { exact: true })).toBeVisible(); }); test("phone keeps results visible until Details opens the preferred bottom sheet", async ({ page }) => { diff --git a/tests/ui-tools-task-directory.spec.ts b/tests/ui-tools-task-directory.spec.ts index eff47743bb..f7621f0f55 100644 --- a/tests/ui-tools-task-directory.spec.ts +++ b/tests/ui-tools-task-directory.spec.ts @@ -1,10 +1,24 @@ import { expect, test, type Page } from "playwright/test"; +import { tools, type ToolArea } from "../src/components/tools-page-mockups/tool-fixtures"; + // Dedicated coverage for the additive "Concept 4 — Task directory" hybrid route. // Kept in its own file so it doesn't collide with the shared tests/ui-tools.spec.ts. const PATH = "/mockups/tools-task-directory"; +/* + * Counts come from the fixture the mockup itself renders, not from literals. + * "Add Ward Flow" (#2140) took the set from 9 to 10 and Admin from 3 to 4, which + * broke three assertions here — silently, because the Advisory UI lane that runs + * @mockup is `continue-on-error`. Deriving them means the next tool cannot. + */ +const CLINICAL_AREAS = new Set(["reference", "assessment", "care"]); +const ADMIN_AREAS = new Set(["coordination", "personal"]); +const TOTAL_TOOLS = tools.length; +const CLINICAL_TOOLS = tools.filter((tool) => CLINICAL_AREAS.has(tool.area)).length; +const ADMIN_TOOLS = tools.filter((tool) => ADMIN_AREAS.has(tool.area)).length; + async function goto(page: Page, path: string) { await page.goto(path, { waitUntil: "domcontentloaded" }); await expect(page.locator("#main-content").first()).toBeVisible({ timeout: 15_000 }); @@ -28,7 +42,9 @@ test.describe("Tools task directory mockup (Concept 4) @mockup", () => { await expect(page.getByRole("heading", { level: 1, name: "Task directory" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Assess" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Coordinate" })).toBeVisible(); - await expect(page.getByTestId("tools-visible-count")).toContainText("Showing 9 of 9 tools"); + await expect(page.getByTestId("tools-visible-count")).toContainText( + `Showing ${TOTAL_TOOLS} of ${TOTAL_TOOLS} tools`, + ); await expectNoHorizontalOverflow(page); }); @@ -40,7 +56,7 @@ test.describe("Tools task directory mockup (Concept 4) @mockup", () => { await search.fill("medication"); await expect(page.getByLabel("Open Medication Prescribing")).toBeVisible(); await expect(page.getByLabel("Open Documents")).toHaveCount(0); - await expect(page.getByTestId("tools-visible-count")).toContainText("Showing 1 of 9 tools"); + await expect(page.getByTestId("tools-visible-count")).toContainText(`Showing 1 of ${TOTAL_TOOLS} tools`); await search.fill("zzzzznotarealtool"); await expect(page.getByTestId("tools-empty-state")).toBeVisible(); @@ -54,12 +70,15 @@ test.describe("Tools task directory mockup (Concept 4) @mockup", () => { await page.setViewportSize({ width: 1280, height: 900 }); await goto(page, PATH); - // Clinical = reference/assessment/care (6, incl. Safety plan + Calculators), Admin = coordination/personal (3). - await expect(page.getByRole("button", { name: /Clinical/ })).toContainText("6"); - await expect(page.getByRole("button", { name: /Admin/ })).toContainText("3"); + // Clinical = reference/assessment/care (incl. Safety plan + Calculators), + // Admin = coordination/personal. Both derived from the fixture above. + await expect(page.getByRole("button", { name: /Clinical/ })).toContainText(String(CLINICAL_TOOLS)); + await expect(page.getByRole("button", { name: /Admin/ })).toContainText(String(ADMIN_TOOLS)); await page.getByRole("button", { name: /Admin/ }).click(); - await expect(page.getByTestId("tools-visible-count")).toContainText("Showing 3 of 9 tools"); + await expect(page.getByTestId("tools-visible-count")).toContainText( + `Showing ${ADMIN_TOOLS} of ${TOTAL_TOOLS} tools`, + ); await expect(page.getByLabel("Open Services").first()).toBeVisible(); await expect(page.getByLabel("Open Differentials")).toHaveCount(0); await expectNoHorizontalOverflow(page); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 824e90be7c..3a9e617fc3 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -831,9 +831,10 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { // Re-selecting the current mode from its old home also returns to the shared // home; same-mode picks must not be mistaken for no-ops on deeper routes. + // `/forms` is that old home, and now redirects onto the shared one. await gotoLauncher(page, "/forms"); await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible(); - await expect(visibleByTestId(page, "forms-home")).toBeVisible(); + await expect(visibleByTestId(page, "shared-home-empty-state")).toBeVisible(); menu = await openAppModeMenu(page, "Forms"); const currentFormsMode = menu.getByRole("menuitemradio", { name: /^Forms\b/ }); @@ -1005,17 +1006,19 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await page.setViewportSize({ width: 390, height: 820 }); for (const home of [ - { path: "/services", testId: "services-home" }, - { path: "/forms", testId: "forms-home" }, - { path: "/differentials", testId: "differentials-home" }, - { path: "/factsheets", testId: "factsheets-home-main" }, - { path: "/favourites", testId: "favourites-hub" }, - // The shared home at `/` is where the mode pill actually lands, so it gets - // the same hero treatment for every mode. These two borrow - // `resultKind: "tools"` as a benign search kind and used to inherit the - // Tools dock exception here, losing the hero pill, ticker and privacy line. + // Consolidated modes: the shared home is now the only home they have, and + // it must keep the in-flow hero pill for each of them. `factsheets` and + // `dictionary` borrow `resultKind: "tools"` as a benign search kind and + // used to inherit the Tools dock exception here, losing the hero pill, + // ticker and privacy line — which is why they are named individually. + { path: "/?mode=services", testId: "shared-home-empty-state" }, + { path: "/?mode=forms", testId: "shared-home-empty-state" }, + { path: "/?mode=differentials", testId: "shared-home-empty-state" }, { path: "/?mode=factsheets", testId: "shared-home-empty-state" }, { path: "/?mode=dictionary", testId: "shared-home-empty-state" }, + // Still route-owned homes, and still held to the same contract. + { path: "/favourites", testId: "favourites-hub" }, + { path: "/?mode=therapy-compass", testId: "shared-home-empty-state" }, // /tools is the documented exception: phones use the shared footer dock // instead of the in-flow hero pill (docs/search-chrome-behaviour.md). ] as const) { @@ -1070,7 +1073,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { ] as const) { for (const home of [ { path: "/?mode=answer", testId: "shared-home-empty-state" }, - { path: "/services", testId: "services-home" }, + { path: "/?mode=services", testId: "shared-home-empty-state" }, ] as const) { test(`mode home search composer is present at ${viewport.name} width on ${home.path} @critical`, async ({ page, @@ -1116,11 +1119,22 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { for (const home of [ { path: "/?mode=answer", testId: "shared-home-empty-state", heroTestId: "shared-home-empty-state" }, - { path: "/documents", testId: "document-search-empty-state", heroTestId: "document-search-empty-state" }, { path: "/medications", testId: "medication-home", heroTestId: "medication-home" }, - { path: "/services", testId: "services-home", heroTestId: "services-home-template" }, - { path: "/forms", testId: "forms-home", heroTestId: "forms-home-template" }, - { path: "/differentials", testId: "differentials-home", heroTestId: "differentials-home-template" }, + // Consolidated modes share one hero, so each is checked through the shared + // home its bare path now redirects to. The copy differs per mode, which is + // what makes more than one row worth running. + { + path: "/?mode=documents", + testId: "shared-home-empty-state", + heroTestId: "shared-home-empty-state", + }, + { path: "/?mode=services", testId: "shared-home-empty-state", heroTestId: "shared-home-empty-state" }, + { path: "/?mode=forms", testId: "shared-home-empty-state", heroTestId: "shared-home-empty-state" }, + { + path: "/?mode=differentials", + testId: "shared-home-empty-state", + heroTestId: "shared-home-empty-state", + }, ] as const) { test(`mode home hero uses the shared mobile sizing on ${home.path}`, async ({ page }) => { await mockAnswerDashboardApi(page); @@ -1190,7 +1204,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(page.getByTestId("service-quick-search-suggestions")).toHaveCount(0); await input.fill("crisis"); await input.press("Enter"); - await expect(page).toHaveURL(/\/services\?.*q=crisis/); + await expect(page).toHaveURL(/\/services\/search\?.*q=crisis/); // Phones keep the full search results in the page instead of opening a // command sheet over the small viewport. @@ -1244,16 +1258,33 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { heading: "Clinical Answers", headingLevel: 2, }, - { path: "/documents", testId: "document-search-empty-state", heading: "Documents", headingLevel: 2 }, + { + path: "/?mode=documents", + testId: "shared-home-empty-state", + heading: "Clinical Documents", + headingLevel: 2, + }, { path: "/medications", testId: "medication-home", heading: "Medication", headingLevel: 2, }, - { path: "/services", testId: "services-home", heading: "Services", headingLevel: 1 }, - { path: "/forms", testId: "forms-home", heading: "Forms", headingLevel: 1 }, - { path: "/differentials", testId: "differentials-home", heading: "Differentials", headingLevel: 1 }, + // Consolidated modes reach the same hero through the shared home; the + // heading is the mode's own `sharedHomePresentation` title at level 2. + { + path: "/?mode=services", + testId: "shared-home-empty-state", + heading: "Clinical Services", + headingLevel: 2, + }, + { path: "/?mode=forms", testId: "shared-home-empty-state", heading: "Clinical Forms", headingLevel: 2 }, + { + path: "/?mode=differentials", + testId: "shared-home-empty-state", + heading: "Differential Diagnosis", + headingLevel: 2, + }, ] as const) { test(`mode home search is centered at ${viewport.name} width on ${home.path}`, async ({ page }) => { await mockAnswerDashboardApi(page); @@ -1383,13 +1414,15 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { test("mode home deep links preserve focus=1 on initial load", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); - await gotoLauncher(page, "/services?focus=1"); - await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeVisible(); - await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeFocused(); - - await gotoLauncher(page, "/forms?focus=1"); - await expect(visibleByTestId(page, "forms-home").getByTestId("global-search-input")).toBeVisible(); - await expect(visibleByTestId(page, "forms-home").getByTestId("global-search-input")).toBeFocused(); + // `focus=1` has to survive the consolidation hop: `/services?focus=1` is a + // 307 onto the shared home, and dropping the parameter there would land the + // visitor on a home with an unfocused composer for no visible reason. + for (const path of ["/services?focus=1", "/forms?focus=1"]) { + await gotoLauncher(page, path); + const composer = visibleByTestId(page, "shared-home-empty-state").getByTestId("global-search-input"); + await expect(composer, path).toBeVisible(); + await expect(composer, path).toBeFocused(); + } }); test("services mode shows source-backed records in search results", async ({ page }) => { @@ -1578,7 +1611,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(formsSearchButton).toBeEnabled(); await waitForReactEventHandler(formsSearchButton.locator("xpath=ancestor::form[1]"), "onSubmit"); await formsSearchButton.click(); - await expect(page).toHaveURL(/\/forms\?.*\bq=transport(?:\+|%20)forms\b/, { timeout: 20_000 }); + await expect(page).toHaveURL(/\/forms\/search\?.*\bq=transport(?:\+|%20)forms\b/, { timeout: 20_000 }); await expect(page.getByTestId("form-search-results")).toBeVisible({ timeout: 20_000 }); await expect(page.getByTestId("form-search-result-transport-crisis-form")).toContainText("Transport order"); await expect( @@ -1844,38 +1877,39 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expectNoPageHorizontalOverflow(page); }); - // The mode pill no longer opens mode homes, so reach /differentials the way a - // user now does: the sidebar's "More modes" sheet, or a direct link. - test("the differentials home renders inside the dashboard when opened directly", async ({ page }) => { + // `/differentials` no longer renders a home of its own: it redirects onto the + // shared one, which is where a direct link, a bookmark and the sidebar's "More + // modes" sheet all now land. The detailed home this used to assert (Recent + // work, Library matches, the Compare action row) is design scratch at + // /mockups/differentials-home-detailed and 404s in production. + test("a direct link to the differentials home lands on the shared home", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page, "/differentials"); + await expect(page).toHaveURL(/\/\?mode=differentials\b/, { timeout: 20_000 }); await expect(page.getByRole("button", { name: "Mode Differentials" })).toBeVisible(); - await expect(page.getByTestId("differentials-home")).toBeVisible(); - await expect(page.getByRole("heading", { level: 1, name: "Differentials" })).toBeVisible(); - await expect(page.locator('input[placeholder="Ask or search a presentation"]:visible').first()).toBeVisible(); - await expect(page.getByRole("button", { name: "Search presentations" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Compare differentials" })).toBeVisible(); - await expect(page.getByRole("region", { name: "Differential actions" }).getByRole("button")).toHaveCount(2); - await expect( - page.getByTestId("differentials-home").getByRole("region", { name: /^(Recent work|Library matches)$/ }), - ).toBeVisible(); - await expect(page.getByTestId("global-search-input")).toHaveCount(1); - const differentialsHomeSearch = page.getByTestId("differentials-home").getByTestId("global-search-input"); - await expect(differentialsHomeSearch).toBeVisible(); - // The shell's Suspense swap after client navigation can briefly detach the - // hero heading, so retry the geometry probe instead of measuring once. + const sharedHome = visibleByTestId(page, "shared-home-empty-state"); + await expect(sharedHome).toBeVisible(); + await expect(sharedHome.getByRole("heading", { level: 2, name: "Differential Diagnosis" })).toBeVisible(); + await expect(page.getByTestId("differentials-home")).toHaveCount(0); + + // One composer, in the hero, carrying the mode's own placeholder. + await expect(visibleGlobalSearchInput(page)).toHaveCount(1); + const heroSearch = sharedHome.getByTestId("global-search-input"); + await expect(heroSearch).toBeVisible(); + await expect(heroSearch).toHaveAttribute("placeholder", "Ask or search a presentation"); + + // The hero heading sits above the composer, and the composer stays in the + // upper two thirds — the same geometry contract every mode home is held to. await expect(async () => { - const differentialsSearchBox = await differentialsHomeSearch.boundingBox(); - const differentialsHeadingBox = await page - .getByRole("heading", { level: 1, name: "Differentials" }) + const searchBox = await heroSearch.boundingBox(); + const headingBox = await sharedHome + .getByRole("heading", { level: 2, name: "Differential Diagnosis" }) .boundingBox(); - expect(differentialsSearchBox).not.toBeNull(); - expect(differentialsHeadingBox).not.toBeNull(); - expect((differentialsHeadingBox?.y ?? 0) + (differentialsHeadingBox?.height ?? 0)).toBeLessThan( - differentialsSearchBox?.y ?? 0, - ); - expect((differentialsSearchBox?.y ?? 0) + (differentialsSearchBox?.height ?? 0) / 2).toBeLessThan(900 * 0.62); + expect(searchBox).not.toBeNull(); + expect(headingBox).not.toBeNull(); + expect((headingBox?.y ?? 0) + (headingBox?.height ?? 0)).toBeLessThan(searchBox?.y ?? 0); + expect((searchBox?.y ?? 0) + (searchBox?.height ?? 0) / 2).toBeLessThan(900 * 0.62); }).toPass({ timeout: 10_000 }); await expectNoPageHorizontalOverflow(page); }); @@ -2633,7 +2667,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(queue.getByRole("link", { name: "Wernicke encephalopathy", exact: true })).toBeVisible(); await expect(page.getByTestId("differential-compare-edit-selection")).toHaveAttribute( "href", - /\/differentials\?.*ids=wernicke-encephalopathy/, + /\/differentials\/search\?.*ids=wernicke-encephalopathy/, ); await expect(page.getByTestId("differential-compare-open")).toBeVisible(); @@ -2698,7 +2732,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { const mobileComparison = page.getByLabel("Mobile differential comparison"); const editSelection = mobileComparison.getByRole("link", { name: "Edit" }); await expect(editSelection).toBeVisible(); - await expect(editSelection).toHaveAttribute("href", /\/differentials\?.*ids=wernicke-encephalopathy/); + await expect(editSelection).toHaveAttribute("href", /\/differentials\/search\?.*ids=wernicke-encephalopathy/); await expect(mobileComparison.getByText("Wernicke encephalopathy", { exact: true }).first()).toBeVisible(); const languageControl = page.getByRole("button", { name: "Language and region settings (coming soon)" }); await expect(languageControl).toBeVisible(); @@ -2872,7 +2906,7 @@ test.describe("Clinical KB service detail page", () => { await page.getByTestId("service-actions-trigger").click(); await page.getByTestId("service-actions-sheet").getByRole("button", { name: "Use in navigator" }).click(); - await expect(page).toHaveURL(/\/services\?/); + await expect(page).toHaveURL(/\/services\/search\?/); await expect(page).toHaveURL(/run=1/); await expect(page).toHaveURL(/focus=1/); }); @@ -2909,7 +2943,9 @@ test.describe("Clinical KB service detail page", () => { await page.keyboard.press("Escape"); await page.getByRole("link", { name: "Back to services" }).click(); - await expect(page).toHaveURL(/\/services(?:\?|$)/); + // "/services" is a consolidated mode path: consolidatedModeHomeTarget redirects + // the bare path onto the shared home rather than rendering a standalone page. + await expect(page).toHaveURL(/\?mode=services(?:&|$)/); }); }); @@ -3145,12 +3181,15 @@ test.describe("Responsive layout guards", () => { expect(appRequests).toEqual([]); }); - test("differentials recent work remains touch-sized inside its mobile scroll row", async ({ page }) => { + // Tagged @mockup because the surface moved, not because the contract lapsed: + // the detailed differentials home was retired to /mockups when the mode moved + // onto the shared home, and /mockups 404s in production. The scroll row and its + // touch targets still ship in the bundle, so they are still worth guarding — + // just in the project that can reach them. + test("differentials recent work remains touch-sized inside its mobile scroll row @mockup", async ({ page }) => { await page.setViewportSize({ width: 320, height: 760 }); await mockAnswerDashboardApi(page); - // Differentials home content lives on /differentials. Bare /?mode=differentials - // is the shared home with the mode preselected, not this template. - await gotoLauncher(page, "/differentials"); + await gotoLauncher(page, "/mockups/differentials-home-detailed"); const recentWork = page.getByTestId("differentials-home-template").getByRole("region", { name: "Recent work" }); await expect(recentWork).toBeVisible();