diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index fb043ed6b3..624df654eb 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -906,6 +906,10 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-24 | open-PR conflict sync (20 PRs) | multi-head | Conflict resolution pass | Before: 8 PRs behind/dirty (#1124 #1131 #1162 #1169 #1174-1177). After: merged origin/main into all; all 20 open PRs MERGEABLE behind=0 (BLOCKED only by CI/reviews). | merge origin/main per branch; no provider-backed checks run | | 2026-07-24 | open-PR conflict sync (22 PRs) | multi-head | Conflict resolution pass | Before: all 22 open PRs behind/dirty vs main (several CONFLICTING/DIRTY). After: merged origin/main into every open head; all pushes OK; merge-tree classified 22/22 clean. | merge origin/main per branch; check:branch-review-ledger on #1172; no provider-backed checks run | | 2026-07-24 | cursor/pr-queue-hygiene-72ec | pending-push | PR queue hygiene | Add pr-branch-sync workflow + sync:pr-branches helper; bump postcss to clear npm audit high; document anti-churn guidance in AGENTS/process-hardening/pr-babysit/run-pr. | check:github-actions PASS; docs:check-scripts/index PASS; vitest sync-open-pr-branches 3/3; npm audit high clean; no provider-backed checks run | +| 2026-07-25 | cursor/fix-mode-switch-lag-22f6 | 54d45f687e3723f51fa3d7e9940692c9a6e3b52c | Same-class bug hunt: mode-switch/layout thrash after reserve-flip fix | No P0. Branch fix mitigates pathname∧searchMode gate, shell changeMode optimistic setSearchMode, selectSearchMode leaving dashboard, and always-on padding-bottom transitions. Still open P2s: (1) ClinicalDashboard.crossModeSearch still setSearchMode before router.push without isDashboardModeHref guard; (2) dashboard-internal Answer↔/?mode=* still eager setSearchMode → overlay/collapse + heroBreakpoint + portal rebind; (3) standalone shell persists #main-content scrollTop + phoneScrollHide across mode homes; (4) ClinicalDashboard↔GlobalStandaloneSearchShellClient remount + grid-template-columns transition; (5) hero portal null gap while slot/MutationObserver rebinds; (6) ModeHomeRouteLoading phone min-h 13.5rem vs idle-reserve mode homes; (7) /tools vs /?mode=tools dual shell (#007). P3: services/forms contentAlign center→startOnPhone after registry load. | Static source audit of shell/dashboard/header/reserve/CSS/app-modes/skeletons; no browser/provider checks. | +| 2026-07-25 | `cursor/fix-mode-switch-lag-22f6` | `b9484396347defaaa934571604b9d165ae6d8b98` | Same-class mode-switch thrash review + fixes | FIXED prior open P2s from 54d45f68 hunt: crossModeSearch now mirrors selectSearchMode (navigate-only when leaving dashboard); shared shell resets phone scrollTop + scroll-hide on pathname; hero portal keeps default composer until host attaches; ModeHomeRouteLoading uses --shell-header-h; services/forms contentAlign stays startOnPhone while loading. No P0/P1. Residual P2: ClinicalDashboard↔standalone shell remount + sidebar grid-template-columns transition; dashboard-internal Answer↔Documents still eager setSearchMode (overlay/hero flip by design, scroll now reset). Residual P3/#007: /tools vs /?mode=tools dual entry. | Focused Vitest ownership/align/hide-on-scroll/merge-artifacts/overlay 46/46; typecheck; static contracts for portal/scroll reset/crossModeSearch. No verify:ui / provider-backed checks. | +| 2026-07-25 | `cursor/fix-mode-switch-lag-22f6` / PR #1187 | `775d15adef8155aba68e43f0e9354adf60f1ea8d` | Same-class thrash fixes lint closeout | Supersedes b9484396 row for lint follow-up: pathname bottomComposerHidden reset moved to render-time; removed unused desktopHomeComposerFallback. verify:cheap green. Residual unchanged (dashboard↔standalone remount; #007 Tools dual entry). | verify:cheap 3345 passed / 3 skipped; focused ownership tests; eslint clean on touched shell/header. No provider checks. | +| 2026-07-25 | `cursor/fix-mode-switch-lag-22f6` | `0ef62ff521fb9f0457f685e9d2f50a24d640e663` | Frontend-architecture review: loading/navigation performance for modes/pages | No P0. P1: (1) GlobalSearchShell statically imports ClinicalDashboard into shared (search-app) layout client graph — every namespaced mode pays dashboard module weight; (2) ClientHydrationBoundary blanks standalone mode children until hydrate — defeats RSC paint for DSM/factsheets/etc. P2: ClinicalDashboard↔standalone remount + grid-template-columns transition; dashboard URL sync still useEffect+rAF (shell already render-time); ToolsHub ssr:false on /?mode=tools; MutationObserver portal rebind cost on mode-home slot changes; SearchCommandProvider value tied to query in shell. P3/#007: /tools vs /?mode=tools dual entry (PT-11 vs nav). Quick wins: dynamic() ClinicalDashboard; remove/narrow ClientHydrationBoundary; point tools href to /tools. Larger: unify shells / extract shared chrome. | Static architecture review of SharedSearchAppShell/GlobalSearchShell/ClinicalDashboard/MasterSearchHeader/app-modes/search-route-ownership/mode homes/tools routes; prior mode-switch ledger rows at b9484396/775d15ad noted. No browser, verify:ui, bundle measure, or provider-backed checks. | | 2026-07-24 | codex/apply-phone-layout-to-all-home-pages (PR #1124) | pending | Babysit: ledger dedupe + merge readiness | Before: Static PR failed on exact duplicate ledger rows after main sync. After: removed duplicate rows; squash auto-merge armed. | check:branch-review-ledger PASS; no provider-backed checks run | | 2026-07-25 | cursor/local-presence-054-7cf3 (PR #1178) | b438cd872286c831c6d9c8db49b017745f98abcc | Run PR sweep: CI fix + threads + drift | Before: GitHub reported DIRTY; Static PR checks found three stale `npm run check:local-presence` references because the implemented script was not registered; Production UI had one focus-restoration failure after 266 passes; 0 unresolved threads. After: merged current `origin/main` cleanly and registered the missing local script, so all 348 docs script references resolve. | `node scripts/check-docs-script-refs.mjs` pass; Prettier and `git diff --check` pass; focused Vitest/UI rerun deferred while another worktree owns the heavyweight lock; environment-reading presence mode and provider-backed checks not run. | | 2026-07-25 | cursor/sidebar-six-item-land-cfa0 (PR #1174) | c44a5c536e43f300feb1b88892d0b2a8ca2c4feb | Run PR sweep: CI fix + threads + drift | Before: GitHub reported DIRTY and Static PR checks failed formatting `docs/outstanding-issues.md`; 0 unresolved threads. After: merged current `origin/main` cleanly and formatted the failing document. | Prettier check pass; `git diff --check` pass; no provider-backed checks run. | @@ -914,6 +918,7 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | `codex/therapy-page-polish-ad78b4` | `157559aa0678f02de09c14f66d544b62a5138c4a` | Targeted release review: Therapy naming, centred navigation, and white canvas | APPROVE. No P0-P3 findings. The production Therapy route consistently uses the title Therapy, the shared page background token, and a centred overflow-safe section navigation. The latest `origin/main` merge was clean and retained both upstream responsive/home-composer assertions. Highest residual risk is visual drift at an untested browser engine; exact desktop and phone Chromium measurements were stable. | Focused Vitest 40/40; pre-sync `verify:cheap` 378 files / 3342 passed / 1 skipped; pre-sync `verify:ui` passed; integrated runtime, Prettier, lint, and typecheck passed; integrated Vitest was interrupted by the shared heavyweight-test queue after an independent 378-file / 3342-pass run. Required hosted checks must pass on the published exact head before merge. No clinical/provider workflow ran. | | 2026-07-25 | codex/search-results-filters-20260725 (PR #1184) | 8f74d8bd40810ede34ad4b155973b598c1be0101 | Superseding merge-readiness review after Sources focus repair | APPROVE. Supersedes the 88131e72 row: the automated P2 showed a transient Daily Actions menu item could disconnect before Sources restored focus. Closing Sources now falls back after unmount to the currently rendered action trigger, and the regression requires the visible Documents trigger to own focus. No P0-P2 finding remains. RAG impact: no retrieval behaviour change - UI focus restoration only. | Post-fix isolated production Chromium 1/1; post-current-main local Chromium 1/1; `npm run verify:cheap` pass (378 files, 3350 passed, 1 skipped); targeted Prettier and ESLint pass; required hosted checks must rerun on the published exact head; no live clinical/provider workflow ran. | | 2026-07-25 | `codex/mobile-search-filter-dropdowns-dcbb32` | `d9f0051ef8335817e7fa29aeb02ee6324331df39` | Release review: responsive search-result filter dropdowns across production modes | APPROVE with verification note. No P0-P2 finding. Phone result-type rails are replaced by page-specific native selects while desktop controls remain intact; shared controls own a real 44px interactive target and retain forced-colors/focus behavior. Highest residual risk is browser-specific native-select rendering outside Chromium. RAG impact: no retrieval behaviour change - result filtering and sort presentation only. | `npm run verify:cheap` pass twice (378 files, 3351 passed, 1 skipped); focused production Chromium routes and accessibility/stress guards pass; production `npm run build` and client-bundle secret scan pass; offline RAG fixtures 36/36 pass. `verify:pr-local` reached unit tests but local infrastructure tests timed out under heavy worktree contention; two remained timeout-only on focused retry. Required hosted checks must pass on the exact published head before merge. No live clinical/provider workflow ran. | +| 2026-07-25 | `cursor/fix-mode-switch-lag-22f6` / PR #1187 | `876d7ecfa1ae8ec79fc0f0bdf1198c6640ad89b8` | Parallel loading UX + frontend-architecture review + quick-win fixes | No P0. Confirmed live: H1 dashboard↔standalone remount dominant (~0.6–1.2s settle); H4 hero portal rebind; H3 registry post-paint. FIXED quick wins: remove ClientHydrationBoundary blanking; ModeHomeRouteLoading startOnPhone; mode-home loading.tsx alignment/additions; forms server defaultFormSlug + client-boundary test; dynamic ClinicalDashboard; sidebar grid transition mount-gate; forms drop key=query; DocumentViewer key=id; therapy Suspense ModeHomeRouteLoading; redirect /?mode=services|forms. Residual P2: unify shells (H1), stable hero slot, registry abort+LRU/summary fields, Tools dual entry #007, prescribing full-catalogue cliff. | Parallel explore×3 + debug measurement; focused Vitest loading/forms/ownership/align contracts; typecheck; eslint touched shell. No verify:ui / provider-backed checks. | | 2026-07-24 | PR #1142 / `fix-physics-animation-audit` | `880f2acf23f35a94c2e5245c2df586012bd6a350` | Spring physics animation audit remediation against main (globals.css + answer-evidence-popups mockup page) | APPROVE. No P0-P2 finding. Centralized spring dynamics tokens registered, generic ease timing replaced with --ease-out-soft and --ease-spring tokens, GPU compositing layer hints added to loading skeletons and bottom reserve pads, dynamic velocity duration supported for gesture keyframes, and reduced-motion presets added. Zero regression risk across design tokens or component interactions. | `node scripts/check-design-system-contract.mjs` passed (534 production files; 0 token violations); `npm run typecheck:internal` passed (0 TypeScript errors); Vitest `tests/route-reachability.test.ts` passed (5/5 tests). No OpenAI, Supabase, Railway, or provider-backed services called. | | 2026-07-24 | fix-physics-animation-audit (PR #1142) | 67f1d7aee5f9f43295482b2a877c9cb691d774e6 | Run PR sweep: CI fix + threads + drift | Before: CONFLICTING, CI green, 0 threads. After: merged origin/main cleanly (ledger auto-merge); pushed 67f1d7aee. Threads: none. Residual: CI re-running. | merge origin/main only; no provider-backed checks run | | 2026-07-24 | fix-physics-animation-audit (PR #1142) | ff31b9417eca4e8726bd096fd9559b618b153181 | User-requested merge conflict resolution | Before: CONFLICTING/DIRTY vs main (behind after #1138 search-interactive-perf). After: merged origin/main with ort auto-merge (no conflict markers; ledger + search-perf files landed cleanly); pushed ff31b941. GitHub now MERGEABLE (BLOCKED only on branch-protection/CI). | merge origin/main only; no provider-backed checks run | @@ -1061,6 +1066,14 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-26 | cursor/formulation-a11y-linear2-14d4 (PR #1250) | head `14b4e80ee41b16a80c974b6a1f8201407a0df05b` / squash `b91b4600171be08198e92bcf19b7d67e8207cb2f` | prlanded after squash merge | LANDED. Formulation disabled-state accessibility (#064) on main; product two-dot diff empty vs pre-merge tip. Superseded conflicted PRs #1219, #1223, #1226, #1231, #1249 closed. Remote feature branch deleted at merge. | Focused Chromium formulation 7/7; verify:cheap 3473 tests; hosted Production UI + PR required SUCCESS; `git diff 14b4e80e origin/main -- formulation-builder-page.tsx ui-formulation.spec.ts` empty. No provider-backed checks. | | 2026-07-26 | PR #1241 / `cursor/imp04-prune-dead-exports-01f2` | merge `origin/main` `6fd8902b` | Main sync after GitHub CONFLICTING | SIMPLE. Staleness behind #1256/#1246/#1252/#1251. `git merge-tree` clean; only overlapping path was append-only ledger. Dropped 2 exact-duplicate #1238 rows re-appended by union driver (kept earlier copies). No product conflict markers; IMP-04 prune surface unchanged. | merge-tree clean; merge ort; `check:branch-review-ledger`; no provider calls. | | 2026-07-26 | `codex/test-concurrency-20260726` | `1b1f4817b0cf932d8b43f8715725770045528f8c` | Protected-main release-readiness review of cross-worktree test concurrency | APPROVE. Shared admission is fail-closed to explicit focused Vitest selections and isolated typechecks; full suites, lint, builds, coverage and Playwright stay exclusive with queue priority and legacy-lock compatibility. Review found and fixed one blocker before approval: junctioned worktrees would have raced the shared `node_modules/.cache` TypeScript build-info file, so shared typechecks now receive a worktree-hashed temporary `.tsbuildinfo` path. Highest residual risk is Windows cross-process filesystem timing, covered by coordinator race/recovery tests and the full local gate. | `npm run verify:pr-local` PASS: format, lint, isolated typecheck, 391 files / 3489 tests passed / 2 skipped, production build (1680 static pages), client-secret scan, and 36-case offline RAG fixtures. Focused coordinator/tooling 32/32; PDF portability 3 passed / 2 platform-or-dependency skips. No provider-backed checks. | + +| 2026-07-26 | PR #1248 / `cursor/fix-mode-switch-lag-22f6` | `af4908bb9bdbf7a30fc1f8ed031ef9bd75f292ef` | Authorized babysit sweep | Fixed P1 documents-search ownership + P2 reserve-reveal transition; forms readiness null-slug + private-scope hash; merged remote Suspense standalone paths. 6/6 threads replied+resolved (1 deferred boundary scan). | Focused Vitest search-route-ownership + clinical-dashboard-merge-artifacts PASS before final push; hosted CI re-running. No provider-backed checks. | +| 2026-07-26 | PR #1254 / `apply-audit-remediation-fixes` | `b3b1eb7e7084859cd18c05152be1b9f8968592ff` | Authorized babysit sweep | Fixed P1 locality-audit-out-of-pr-local + comparator-direction conflicts; typed locality accumulator; hardened citationTelemetry schema; clozapine mg-gated span. Merged `origin/main` (verify-pr-local conflict resolved to main shape). 10/10 threads replied+resolved (2 deferred: unit normalize, query-context wiring). | Focused Vitest evidence + verify-pr-local 24/24 PASS. No provider-backed checks. | +| 2026-07-26 | PR #1257 / `cursor/therapy-search-trim-e63e` | `b80a3810819846760e862e1d0d4aa746ae6b0237` (merged) | Authorized babysit sweep | Already MERGED to main before code changes needed; tip had correct sidebar absence assertion; 0 unresolved threads at close. | Hosted PR required + Production UI SUCCESS on merged tip. No provider-backed checks. | +| 2026-07-26 | PR #1253 / `remediate-system-audit-findings` | `cc1fa9d48434c809e2cf73dfeb3124d2b36f0968` | Authorized babysit peek | CLOSED before sweep actions. CONFLICTING vs main with clinical/answer/privacy paths; non-trivial — no merge attempted. Policy failure was missing Clinical Governance Preflight (body not agent-managed). | merge-tree conflict inventory only; no provider-backed checks. | +| 2026-07-26 | PR #1255 / `execute-audit-remediation-plan` | `cc645928cefb18e4c08870ddc1c483bc051a92b9` | Authorized babysit peek | CLOSED before sweep actions. CONFLICTING vs main including `api/answer`, ClinicalDashboard, privacy routes; non-trivial — no merge attempted. Policy failure was missing Clinical Governance Preflight. | merge-tree conflict inventory only; no provider-backed checks. | | 2026-07-26 | PR #1241 / `cursor/imp04-prune-dead-exports-01f2` | `720fd19879f6463a87ad61309b91148f90efa23e` | PR babysit: sync main, supersede stale READY row, close review thread | APPROVE pending hosted required CI. `git merge-tree --write-tree origin/main ba799d4a3cfdcb20eb1e040b5d6e328e2fbbc147` was clean, so GitHub DIRTY/CONFLICTING was stale branch drift after main advanced to `a9920e3fc29fce9ad2ffb547811e085a708680b9`; merged `origin/main` with no content conflicts. This supersedes the older 2026-07-25 READY row rather than editing append-only history; the remaining CodeRabbit ledger-check thread is dispositioned by this row and the final merge remains gated on exact-head required CI. | `npm run check:branch-review-ledger` PASS; hosted PR required, PR policy, and GitGuardian to be waited on exact pushed head; no provider-backed evals/checks. | | 2026-07-26 | PR #1241 / `cursor/imp04-prune-dead-exports-01f2` | `5de2f4cdfa707ed53145b2e39a7f283995887f85` | Authorized babysit sweep | Threads: 1 CodeRabbit ledger rewrite request dispositioned (append-only policy; hosted CI already green). Merged `origin/main` (mechanical). 0 unresolved left. | Hosted required CI previously SUCCESS on prior tip; no provider-backed checks. | +| 2026-07-26 | PR #1248 / `cursor/fix-mode-switch-lag-22f6` | pending final pushed head after ledger append | PR babysit: sync main + Codex P2 submitted-param seed | Before: GitHub reported DIRTY/CONFLICTING while `git merge-tree --write-tree origin/main 7250d6d38269b734d903f9995782a4eedeaeebcb` was clean; branch was 1 behind main with 1 unresolved Codex P2. Merged `origin/main` cleanly, dropped one exact-duplicate PR #1241 ledger row reintroduced by the union driver, and fixed the P2 by deriving standalone shell chrome from `window.location.search` via `useSyncExternalStore` before the delayed `useSearchParams` bridge hydrates. Hosted CI, thread reply/resolution, and squash merge to main remain the final babysit gates. | `npm run test -- --run tests/search-route-ownership.test.ts` PASS (12/12); `npm run lint` PASS; `npm run check:branch-review-ledger` PASS; `npm run verify:cheap` PASS (393 files; 3505 passed / 5 skipped); no provider-backed checks. | +| 2026-07-26 | PR #1248 / `cursor/fix-mode-switch-lag-22f6` | pending final pushed head after UI CI fix | PR babysit: Production UI failure fix | Supersedes the prior PR #1248 babysit row for hosted CI closeout. Production UI failed only on two branch-adjacent chrome tests after the P2 fix: restricted the browser query fallback to submitted searches (`run=1` + `q/query`) so route-owned query pages do not adopt submitted chrome, and aligned the phone-scroll reserve-transition assertion with the `data-reserve-transitioning` marker for no-dock/expired-marker states. | Hosted log diagnosis from run `30187649755`; local exact Chromium production rerun `npm run test:e2e:pr -- tests/ui-phone-scroll.spec.ts tests/ui-tools.spec.ts --grep "formulation/builder\\?mechanism=rumination|differentials presentation comparison"` PASS (2/2); no provider-backed checks. | diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 70f47468fd..983d3ea853 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -222,6 +222,12 @@ passes `p_worker_id`. Ordered apply steps, R17 manual `CONCURRENTLY` index, and - `GlobalMockupSearchShell` (aka `GlobalSearchShell`, used by the `forms`/`services`/`favourites`/`medications` layouts) wrapped `GlobalMockupSearchShellClient` in a `` whose **fallback also rendered `props.children` inside `#main-content`** — the same subtree the client body renders. Because `useSearchParams()` forces that boundary to the fallback on the server, the page subtree was emitted twice and both copies could persist, producing duplicate `id="main-content"` and duplicate `data-testid` on every shell page. It surfaced as `ui-smoke.spec.ts:1103` failing with a strict-mode violation (two `data-testid="acamprosate-medication-page"` `
` elements on `/medications/acamprosate`). - Fix: the Suspense fallback renders a **neutral placeholder only** — never `props.children`. Rule: do not render the resolved content inside its own Suspense fallback; the fallback is a loading state, not a second copy of the page. +## useSearchParams Suspense must not wrap route children (2026-07-26) + +- Removing `ClientHydrationBoundary` around standalone shell `{children}` restored mode-home RSC paint, but left route segments nested inside the shell’s `useSearchParams()` Suspense. Next streamed the page root into a hidden completion template (` - ); + return ; } diff --git a/src/app/(search-app)/factsheets/loading.tsx b/src/app/(search-app)/factsheets/loading.tsx new file mode 100644 index 0000000000..59334e70b5 --- /dev/null +++ b/src/app/(search-app)/factsheets/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/(search-app)/favourites/loading.tsx b/src/app/(search-app)/favourites/loading.tsx index 400c8da0d1..59334e70b5 100644 --- a/src/app/(search-app)/favourites/loading.tsx +++ b/src/app/(search-app)/favourites/loading.tsx @@ -1,25 +1,5 @@ -import { Skeleton } from "@/components/ui-primitives"; +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; export default function Loading() { - return ( -
- - - - {/* Tabs */} -
- - - -
- - {/* Grid */} -
- - - -
- Loading medication -
- ); + return ; } diff --git a/src/app/(search-app)/forms/loading.tsx b/src/app/(search-app)/forms/loading.tsx index b4d48abd0e..59334e70b5 100644 --- a/src/app/(search-app)/forms/loading.tsx +++ b/src/app/(search-app)/forms/loading.tsx @@ -1,21 +1,5 @@ -import { Skeleton, searchPageShell, searchPageContainer } from "@/components/ui-primitives"; +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; export default function Loading() { - return ( -
-
-
- - -
-
- - - - -
-
- Loading services -
- ); + return ; } diff --git a/src/app/(search-app)/forms/page.tsx b/src/app/(search-app)/forms/page.tsx index bd750838fd..41f7beef82 100644 --- a/src/app/(search-app)/forms/page.tsx +++ b/src/app/(search-app)/forms/page.tsx @@ -1,5 +1,6 @@ import { FormsHomePage } from "@/components/forms/forms-home-page"; import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; +import { defaultFormSlug } from "@/lib/forms"; type FormsSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; @@ -17,7 +18,8 @@ export default async function FormsPage({ searchParams }: { searchParams: FormsS const hasSubmittedSearch = readFirstSearchParam(resolvedSearchParams.run) === "1" && query.length > 0; if (!hasSubmittedSearch) { - return ; + // Computed server-side so the client route chunk never bundles the forms catalog. + return ; } return ; diff --git a/src/app/(search-app)/formulation/loading.tsx b/src/app/(search-app)/formulation/loading.tsx new file mode 100644 index 0000000000..59334e70b5 --- /dev/null +++ b/src/app/(search-app)/formulation/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/(search-app)/page.tsx b/src/app/(search-app)/page.tsx index bc53e0b2ef..21a73d4089 100644 --- a/src/app/(search-app)/page.tsx +++ b/src/app/(search-app)/page.tsx @@ -76,5 +76,17 @@ export default async function Home({ searchParams }: HomeProps) { redirect(suffix ? `/formulation?${suffix}` : "/formulation"); } + // Services/forms are namespaced mode homes; keep /?mode=* from mounting the + // full ClinicalDashboard + registry path for those surfaces. + if (initialSearchMode === "services" || initialSearchMode === "forms") { + redirect( + appModeHomeHref(initialSearchMode, { + query: firstSearchParam(params.q)?.trim(), + focus: firstSearchParam(params.focus) === "1", + run: firstSearchParam(params.run) === "1", + }), + ); + } + return ; } diff --git a/src/app/(search-app)/specifiers/loading.tsx b/src/app/(search-app)/specifiers/loading.tsx new file mode 100644 index 0000000000..59334e70b5 --- /dev/null +++ b/src/app/(search-app)/specifiers/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/(search-app)/therapy-compass/loading.tsx b/src/app/(search-app)/therapy-compass/loading.tsx new file mode 100644 index 0000000000..59334e70b5 --- /dev/null +++ b/src/app/(search-app)/therapy-compass/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css index ad09367b11..e8a35e9266 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2664,43 +2664,29 @@ html[data-motion="reduced"] .source-capsule-hit[aria-expanded="true"]:hover .sou } } -/* iOS Safari bottom reserve transitions to match the composer's hide/show motion. - Shell routes animate the inner reserve pad; dashboard/answer animates - #main-content padding; DocumentViewer animates its own content pad. */ +/* Phone reserve padding animates while the short-lived reserve-transition + marker is active (hide and reveal). Mode/route flips of + --mobile-composer-reserve (hero ↔ dock) must snap — those clear the marker + immediately. DocumentViewer keeps its own Tailwind duration classes. */ @media (max-width: 639px) { - #main-content { - will-change: padding-bottom; - transition: padding-bottom 200ms var(--ease-out-soft); - } - #main-content[data-bottom-composer-hidden="true"] { + #main-content[data-reserve-transitioning="true"] { will-change: padding-bottom; transition: padding-bottom 240ms var(--ease-out-soft); } - #main-content [data-testid="mobile-composer-reserve-pad"] { - will-change: padding-bottom; - transition: padding-bottom 200ms var(--ease-out-soft); - } - #main-content[data-bottom-composer-hidden="true"] [data-testid="mobile-composer-reserve-pad"] { + #main-content[data-reserve-transitioning="true"] [data-testid="mobile-composer-reserve-pad"] { will-change: padding-bottom; transition: padding-bottom 240ms var(--ease-out-soft); } - [data-testid="document-viewer-content"] { - will-change: padding-bottom; - transition: padding-bottom 200ms var(--ease-out-soft); - } - [data-testid="document-viewer-content"][data-scroll-hidden="true"] { + [data-testid="document-viewer-content"][data-reserve-transitioning="true"] { will-change: padding-bottom; transition: padding-bottom 240ms var(--ease-out-soft); } } @media (max-width: 639px) and (prefers-reduced-motion: reduce) { - #main-content, - #main-content[data-bottom-composer-hidden="true"], - #main-content [data-testid="mobile-composer-reserve-pad"], - #main-content[data-bottom-composer-hidden="true"] [data-testid="mobile-composer-reserve-pad"], - [data-testid="document-viewer-content"], - [data-testid="document-viewer-content"][data-scroll-hidden="true"] { + #main-content[data-reserve-transitioning="true"], + #main-content[data-reserve-transitioning="true"] [data-testid="mobile-composer-reserve-pad"], + [data-testid="document-viewer-content"][data-reserve-transitioning="true"] { transition: none; } } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index fbffa31dba..bc965ec286 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -52,6 +52,7 @@ import { useEventCallback } from "@/components/clinical-dashboard/use-event-call import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { buildMobileSectionFabState, MobileSectionFab, ToolsHub } from "@/components/clinical-dashboard/dashboard-nav"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; +import { useSidebarColumnTransitionReady } from "@/components/clinical-dashboard/use-sidebar-column-transition"; import * as SidebarDialogs from "@/components/clinical-dashboard/lazy-sidebar-dialogs"; import { useSettingsGuideFlow } from "@/components/clinical-dashboard/use-settings-guide-flow"; import { @@ -86,7 +87,11 @@ import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/univ import { FavouritesGuestGate } from "@/components/clinical-dashboard/favourites-guest-gate"; import { useDashboardShellActions } from "@/components/clinical-dashboard/use-dashboard-shell-actions"; import { focusComposerInput as scheduleComposerFocus } from "@/components/clinical-dashboard/focus-composer-input"; -import { readChromeCollapseMetrics, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; +import { + readChromeCollapseMetrics, + useReserveTransitionMarker, + useScrollHideReporter, +} from "@/components/clinical-dashboard/use-hide-on-scroll"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { answerReferencesDocument, @@ -158,6 +163,7 @@ import { type AppModeId, type AppModeSearchKind, } from "@/lib/app-modes"; +import { isDashboardModeHref } from "@/lib/search-route-ownership"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { privateScopeReadyForRoute, @@ -168,7 +174,11 @@ import { type PrivateScopeRestorationStatus, type SearchNavigationContext, } from "@/lib/search-navigation-context"; -import { persistPrivateSearchScope, restorePrivateSearchScope } from "@/lib/private-search-scope"; +import { + persistPrivateSearchScope, + removePrivateScopeRefFromUrl, + restorePrivateSearchScope, +} from "@/lib/private-search-scope"; import { parseApiErrorResponse } from "@/lib/api-client-error"; import { answerLifecycleReducer, initialAnswerLifecycle } from "@/lib/answer-lifecycle"; import { useDeferredRegistrySearch } from "@/components/clinical-dashboard/use-deferred-registry-search"; @@ -201,6 +211,7 @@ import type { DocumentLabel, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; +import { DashboardDesktopResultComposerSlot } from "@/components/clinical-dashboard/dashboard-desktop-result-composer-slot"; import { desktopPageComposerSlotId, differentialsMobileCompareAddonSlotId, @@ -354,6 +365,7 @@ export function ClinicalDashboard({ // mode swaps
's header reserve, so it also rebases the reporter. const chromeScrollHide = useScrollHideReporter(false, true, searchMode); const [bottomComposerHidden, setBottomComposerHidden] = useState(false); + const reserveTransitioning = useReserveTransitionMarker(bottomComposerHidden, searchMode); const reportChromeScrollHideRef = useRef(chromeScrollHide.reportScroll); reportChromeScrollHideRef.current = chromeScrollHide.reportScroll; const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => @@ -510,6 +522,7 @@ export function ClinicalDashboard({ const [settingsOpen, setSettingsOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); + const sidebarColumnTransitionReady = useSidebarColumnTransitionReady(); const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false); const documentsDrawerReturnFocusRef = useRef(null); const [documentScopeOpen, setDocumentScopeOpen] = useState(false); @@ -2311,6 +2324,20 @@ export function ClinicalDashboard({ openAccountSetup("favourites"); return; } + const href = appModeHomeHref(mode, { + query: crossQuery, + focus: true, + run: true, + queryMode, + scopeFilters, + }); + // Leaving the dashboard shell: navigate only — eager setSearchMode flipped + // overlay/hero/dock chrome for a frame before ClinicalDashboard unmounted. + if (!isDashboardModeHref(href)) { + modeChangeFromUiRef.current = true; + router.push(href); + return; + } modeChangeFromUiRef.current = true; if (mode === "differentials") clearDifferentialModeResultState(); setCommandScopes([]); @@ -2333,7 +2360,10 @@ export function ClinicalDashboard({ setMedicationSearchQuery(crossQuery); } setSearchMode(mode); - router.push(appModeHomeHref(mode, { query: crossQuery, focus: true, run: true, queryMode, scopeFilters })); + router.push(href); + window.requestAnimationFrame(() => { + mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() }); + }); } async function submitAnswerFeedback(feedbackType: AnswerFeedbackType) { @@ -2602,6 +2632,15 @@ export function ClinicalDashboard({ openAccountSetup("favourites"); return; } + const href = appModeHomeHref(mode, { queryMode, scopeFilters }); + // Leaving the dashboard shell (e.g. Answer → Services): navigate without + // rewriting local chrome first. Eager setSearchMode flipped overlay/hero + // and reserved dock padding for a frame before ClinicalDashboard unmounted. + if (!isDashboardModeHref(href)) { + modeChangeFromUiRef.current = true; + router.push(href); + return; + } modeChangeFromUiRef.current = true; if (mode === "differentials") clearDifferentialModeResultState(); setQuery(""); @@ -2621,7 +2660,12 @@ export function ClinicalDashboard({ setSourceGovernanceWarnings([]); setDocumentMatches([]); setSearchMode(mode); - router.push(appModeHomeHref(mode, { queryMode, scopeFilters })); + router.push(href); + // Dashboard-internal mode flips keep the same scroller; jump to top so + // Answer ↔ Documents does not inherit a mid-page offset + collapsed chrome. + window.requestAnimationFrame(() => { + mainRef.current?.scrollTo({ top: 0, behavior: resolveScrollBehavior() }); + }); } function focusComposerInput(retainTarget = false) { @@ -3251,13 +3295,6 @@ export function ClinicalDashboard({ [priorAnswerTurns, latestAnswerQuery], ); - function removePrivateScopeRefFromUrl() { - const params = new URLSearchParams(window.location.search); - params.delete("scopeRef"); - const next = params.toString(); - window.history.replaceState(null, "", `${window.location.pathname}${next ? `?${next}` : ""}`); - } - function reselectUnavailablePrivateScope() { removePrivateScopeRefFromUrl(); setPrivateScopeStatus("none"); @@ -3279,7 +3316,8 @@ export function ClinicalDashboard({ appBackdrop, // Phone: fixed inset-0 (not 100dvh) — matches GlobalSearchShell; avoids Safari toolbar dead band. "mobile-app-shell flex flex-col overflow-hidden text-[color:var(--text)] max-sm:fixed max-sm:inset-0 max-sm:h-auto max-sm:min-h-0 max-sm:overflow-hidden md:grid md:grid-cols-[5.25rem_minmax(0,1fr)] md:overflow-hidden", - "motion-safe:transition-[grid-template-columns] motion-safe:duration-200 motion-safe:ease-out", + sidebarColumnTransitionReady && + "motion-safe:transition-[grid-template-columns] motion-safe:duration-200 motion-safe:ease-out", sidebarCollapsed ? "lg:grid-cols-[5.25rem_minmax(0,1fr)]" : "lg:grid-cols-[20rem_minmax(0,1fr)]", )} style={ @@ -3391,6 +3429,7 @@ export function ClinicalDashboard({ // prettier-ignore onScroll={handleMainScroll} data-bottom-composer-hidden={bottomComposerHidden ? "true" : undefined} + data-reserve-transitioning={reserveTransitioning ? "true" : undefined} className={cn( "min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]", // Answer view: the glass header is absolute over this scroll container, @@ -3479,13 +3518,7 @@ export function ClinicalDashboard({ : "pb-8 sm:pb-10 lg:pb-12", )} > - {desktopResultComposerSlotId ? ( -
- ) : null} + {actionNotice && ( setActionNotice(null)} animated> {actionNotice.message} diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index f8306b4493..ee6465c1d0 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -20,7 +20,16 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; -import { useHideOnScroll } from "@/components/clinical-dashboard/use-hide-on-scroll"; +import { PdfPreviewLoading } from "@/components/document-viewer/pdf-preview-loading"; +import { + getDefaultPdfViewerMode, + getInitialPdfViewerMode, + pdfViewerModeNativeValue, + pdfViewerModeStorageKey, + pdfViewerModeValue, + pdfViewerNativeModeBreakpoint, +} from "@/components/document-viewer/pdf-viewer-mode"; +import { useDocumentViewerChromeScroll } from "@/components/clinical-dashboard/use-document-viewer-chrome-scroll"; import { AnswerProgressStepper } from "@/components/clinical-dashboard/answer-status"; import type { TimedAnswerProgressUpdate } from "@/components/clinical-dashboard/answer-progress"; import { readAnswerStream } from "@/components/clinical-dashboard/search-utils"; @@ -102,30 +111,7 @@ const NativePdfEmbed = dynamic( { ssr: false, loading: () => }, ); -function PdfPreviewLoading() { - return ( -
- Loading PDF reader… -
- ); -} - const secondaryButton = floatingControl; -const pdfViewerModeStorageKey = "clinical-kb:pdf-viewer-mode"; -const pdfViewerNativeModeBreakpoint = 820; -const pdfViewerModeValue = { - native: "native", - canvas: "canvas", -} as const; -const pdfViewerModeNativeValue = pdfViewerModeValue.native; - -function getDefaultPdfViewerMode(): boolean { - return false; -} type SignedUrlResponsePayload = { url?: string; @@ -155,33 +141,6 @@ async function requestSignedUrlPayload( return payload; } -function getInitialPdfViewerMode() { - if (typeof window === "undefined") { - return { - useNativePdfViewer: getDefaultPdfViewerMode(), - hasExplicitPdfViewerMode: false, - }; - } - - try { - const savedMode = window.localStorage.getItem(pdfViewerModeStorageKey); - if (savedMode === pdfViewerModeNativeValue) { - return { useNativePdfViewer: true, hasExplicitPdfViewerMode: true }; - } - - if (savedMode === pdfViewerModeValue.canvas) { - return { useNativePdfViewer: false, hasExplicitPdfViewerMode: true }; - } - } catch { - // window.localStorage may be unavailable in strict or private-browser contexts. - } - - return { - useNativePdfViewer: getDefaultPdfViewerMode(), - hasExplicitPdfViewerMode: false, - }; -} - function rowsById(incoming: T[]) { const rows = new Map(); for (const row of incoming) rows.set(row.id, row); @@ -326,11 +285,14 @@ export function DocumentViewer({ observer.disconnect(); }; }, []); - const scrollHidden = useHideOnScroll({ - ...(shellScrollContainer ? { scrollContainer: shellScrollContainer } : {}), - resetKey: `${documentId}:${activePage}:${activeChunkId ?? ""}`, - }); - const composerScrollHidden = scrollHidden && !mobileActionsOpen && !composerChromeFocused; + const { composerScrollHidden, reserveTransitioning } = useDocumentViewerChromeScroll( + shellScrollContainer, + documentId, + activePage, + activeChunkId, + mobileActionsOpen, + composerChromeFocused, + ); const [useNativePdfViewer, setUseNativePdfViewer] = useState(getDefaultPdfViewerMode); const [hasExplicitPdfViewerMode, setHasExplicitPdfViewerMode] = useState(false); const [viewerModeInitialized, setViewerModeInitialized] = useState(false); @@ -1246,6 +1208,7 @@ export function DocumentViewer({
{desktopComposerSlotId ? ( -
diff --git a/src/components/clinical-dashboard/dashboard-desktop-result-composer-slot.tsx b/src/components/clinical-dashboard/dashboard-desktop-result-composer-slot.tsx new file mode 100644 index 0000000000..c9622a1329 --- /dev/null +++ b/src/components/clinical-dashboard/dashboard-desktop-result-composer-slot.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; + +export function DashboardDesktopResultComposerSlot({ slotId }: { slotId?: string }) { + if (!slotId) return null; + return ( + + ); +} diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 13fc3a2f94..a46e61d777 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -47,6 +47,7 @@ import { useSearchCommand } from "@/components/clinical-dashboard/search-command import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface"; import { appModeIcons } from "@/lib/app-mode-icons"; import { canAccessFavouritesMode } from "@/lib/app-modes"; +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { useAuthSession } from "@/lib/supabase/client"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; @@ -1185,7 +1186,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?:

) : null} -
diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index f27e5e890e..b97db3cb8e 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { ArrowUpDown, ChevronDown, Filter, Folder, Heart, Plus, Search, ShieldCheck, X } from "lucide-react"; import { useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { ModeHomeHero, ModeHomeVerificationFooter } from "@/components/mode-home-template"; import { cn, @@ -169,7 +170,10 @@ export function FavouritesHub({ /> {desktopComposerSlotId ? ( -
+ ) : null}
diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 59d59b8dd3..23f9257f13 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -8,12 +8,15 @@ import { type UIEvent, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, + useSyncExternalStore, } from "react"; -import { ClinicalDashboard } from "@/components/ClinicalDashboard"; +import dynamic from "next/dynamic"; + import { clearLegacyRecentQueries, demoRecentQueryOwnerId, loadRecentQueries } from "@/lib/recent-query-storage"; import { PatientProfileProvider } from "@/components/clinical-dashboard/patient-profile-context"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; @@ -34,10 +37,12 @@ import { import { readChromeCollapseMetrics, useDocumentScrollHideReporter, + useReserveTransitionMarker, useScrollHideReporter, } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; +import { useSidebarColumnTransitionReady } from "@/components/clinical-dashboard/use-sidebar-column-transition"; import { loadSettingsDialog, prefetchAccountDialog, @@ -45,7 +50,6 @@ import { SidebarSettingsDialog, } from "@/components/clinical-dashboard/lazy-sidebar-dialogs"; import { useSettingsGuideFlow } from "@/components/clinical-dashboard/use-settings-guide-flow"; -import { ClientHydrationBoundary } from "@/components/client-hydration-boundary"; import { cn } from "@/components/ui-primitives"; import { appModeHomeHref, @@ -54,9 +58,17 @@ import { visibleAppModeDefinitions, type AppModeId, } from "@/lib/app-modes"; + +// Namespaced mode homes share this client shell but never render the dashboard +// body — keep ClinicalDashboard out of their parse/eval path until `/` needs it. +const ClinicalDashboard = dynamic( + () => import("@/components/ClinicalDashboard").then((mod) => ({ default: mod.ClinicalDashboard })), + { ssr: true, loading: () => }, +); import { isLocalNoAuthMode, resolveClientDemoMode } from "@/lib/client-env"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { isInformationPage } from "@/lib/information-pages"; +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { desktopPageComposerSlotId, differentialsMobileCompareAddonSlotId, @@ -64,7 +76,12 @@ import { therapyHeaderCollapseAddonSlotId, } from "@/lib/mode-home-composer"; import { readSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; -import { shouldRenderClinicalDashboard, shouldRenderDashboardSearch } from "@/lib/search-route-ownership"; +import { + isAlwaysStandaloneShellPath, + isStandaloneModeHomePath, + shouldRenderClinicalDashboard, + shouldRenderDashboardSearch, +} from "@/lib/search-route-ownership"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { useAuthSession } from "@/lib/supabase/client"; import type { ClinicalQueryMode } from "@/lib/types"; @@ -98,6 +115,20 @@ type GlobalSearchShellProps = { }; export function GlobalSearchShell(props: GlobalSearchShellProps) { + const pathname = usePathname() ?? "/"; + + // Pathname-only gate: never wrap always-standalone routes in the outer + // useSearchParams Suspense. That nested the route segment (loading.tsx + page) + // inside an incomplete streaming `S:` boundary and left a persistent hidden + // duplicate page-root data-testid under CI load. + if (isAlwaysStandaloneShellPath(pathname)) { + return ( + + + + ); + } + return ( - + + + ); } -function GlobalSearchShellClient(props: GlobalSearchShellProps) { - const pathname = usePathname(); +function GlobalSearchShellDashboardGate(props: GlobalSearchShellProps) { + const pathname = usePathname() ?? "/"; const router = useRouter(); const searchParams = useSearchParams(); const landingPreferenceAppliedRef = useRef(false); @@ -158,23 +191,55 @@ function GlobalSearchShellClient(props: GlobalSearchShellProps) { pathname, }); - // Wrap both render paths so the patient-considerations profile is shared - // between the prescribing workspace (ClinicalDashboard) and the medication - // detail pages (standalone shell), backed by sessionStorage across navigation. - return ( - - {rendersClinicalDashboard ? ( - - ) : ( - - )} - + if (rendersClinicalDashboard) { + return ( + + ); + } + + return ; +} + +function subscribeNoop() { + return () => undefined; +} + +/** + * Isolates `useSearchParams()` so the standalone shell body (and route children) + * are not descendants of that Suspense boundary. Client-only via + * useSyncExternalStore so SSR never calls `useSearchParams` on always-standalone + * routes (avoids an outer incomplete `S:` boundary wrapping the page segment). + */ +function ShellSearchParamsBridge({ onParamString }: { onParamString: (value: string) => void }) { + const ready = useSyncExternalStore( + subscribeNoop, + () => true, + () => false, ); + if (!ready) return null; + return ; +} + +function ShellSearchParamsBridgeInner({ onParamString }: { onParamString: (value: string) => void }) { + const searchParams = useSearchParams(); + const paramString = searchParams.toString(); + useLayoutEffect(() => { + onParamString(paramString); + }, [onParamString, paramString]); + return null; +} + +function readInitialBrowserSubmittedSearchParamString(): string { + if (typeof window === "undefined") return ""; + const search = window.location.search.startsWith("?") ? window.location.search.slice(1) : window.location.search; + const params = new URLSearchParams(search); + const query = (params.get("q") ?? params.get("query") ?? "").trim(); + return params.get("run") === "1" && query ? search : ""; } export function infoPageBackHref(pathname: string): string | null { @@ -209,7 +274,30 @@ function isToolDetailWithFooterSearch(pathname: string): boolean { ); } -function GlobalStandaloneSearchShellClient({ +function GlobalStandaloneSearchShellClient(props: GlobalSearchShellProps) { + // Empty until the bridge resolves — matches SSR/hydration, then syncs. Keeps + // `{children}` outside the useSearchParams Suspense (no nested S: page clone). + const [searchParamString, setSearchParamString] = useState(""); + // Hard loads of always-standalone submitted routes need the browser query + // before the Suspense-delayed bridge hydrates, otherwise the shell briefly + // paints the mode-home hero before snapping to the submitted bottom dock. + const browserSearchParamString = useSyncExternalStore( + subscribeNoop, + readInitialBrowserSubmittedSearchParamString, + () => "", + ); + const effectiveSearchParamString = searchParamString || browserSearchParamString; + return ( + <> + + + + + + ); +} + +function GlobalStandaloneSearchShellBody({ children, initialMode = "answer", availableModeIds, @@ -218,23 +306,39 @@ function GlobalStandaloneSearchShellClient({ hideDesktopSidebar = false, chromeVisible = true, mobileChromeVisible = true, -}: GlobalSearchShellProps) { + searchParamString, +}: GlobalSearchShellProps & { searchParamString: string }) { const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); + const pathname = usePathname() ?? "/"; + const searchParams = useMemo(() => new URLSearchParams(searchParamString), [searchParamString]); const inputRef = useRef(null); const [mainElement, setMainElement] = useState(null); // The header hides at every breakpoint; only the phone bottom dock stays // phone-gated (MasterSearchHeader keeps that behind its own phone layout // check). #main-content is the scrollport on phones and the document is the // scrollport above them, so both sources feed the same reporter. - const chromeScrollHide = useScrollHideReporter(false, true); + // resetKey=pathname clears carried-over hide state across shared mode homes. + const chromeScrollHide = useScrollHideReporter(false, true, pathname); const reportChromeScrollHideRef = useRef(chromeScrollHide.reportScroll); const [bottomComposerHidden, setBottomComposerHidden] = useState(false); + const [bottomComposerHiddenPathname, setBottomComposerHiddenPathname] = useState(pathname); + // Render-time reset (not an effect): pathname-only mode homes share one scroller, + // so a carried-over hidden dock pad would open the next mode mid-collapse. + if (pathname !== bottomComposerHiddenPathname) { + setBottomComposerHiddenPathname(pathname); + setBottomComposerHidden(false); + } + const reserveTransitioning = useReserveTransitionMarker(bottomComposerHidden, pathname); useDocumentScrollHideReporter(chromeScrollHide.reportScroll); useEffect(() => { reportChromeScrollHideRef.current = chromeScrollHide.reportScroll; }, [chromeScrollHide.reportScroll]); + // Mode homes share one shell scroller. Reset scroll when the route changes so + // /services → /dsm does not open mid-page with a stuck offset. + useEffect(() => { + const main = document.getElementById("main-content"); + if (main instanceof HTMLElement) main.scrollTop = 0; + }, [pathname]); const visibleShellModes = useMemo(() => { const modes = visibleAppModeDefinitions(); if (!availableModeIds?.length) return modes; @@ -249,7 +353,6 @@ function GlobalStandaloneSearchShellClient({ const currentUrlHasQuery = searchParams.has("q") || searchParams.has("query"); const requestedQuery = (searchParams.get("q") ?? searchParams.get("query") ?? "").trim(); const requestedMode = searchParams.get("mode"); - const searchParamString = searchParams.toString(); // Mode resolved from the URL (?mode=), falling back to this shell's default when // the param is missing, unknown, or not offered here. Seeds the initial mode and // re-syncs it after a navigation. @@ -275,6 +378,7 @@ function GlobalStandaloneSearchShellClient({ ); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); + const sidebarColumnTransitionReady = useSidebarColumnTransitionReady(); const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [recentQueries, setRecentQueries] = useState([]); @@ -310,19 +414,10 @@ function GlobalStandaloneSearchShellClient({ mode: resolvedSearchMode, pathname, }); - const isStandaloneModeHome = - !hasSubmittedModeSearch && - !rendersDashboardSearch && - ((searchMode === "services" && pathname === "/services") || - (searchMode === "forms" && pathname === "/forms") || - (searchMode === "favourites" && pathname === "/favourites") || - (searchMode === "differentials" && pathname === "/differentials") || - (searchMode === "dsm" && pathname === "/dsm") || - (searchMode === "specifiers" && pathname === "/specifiers") || - (searchMode === "formulation" && pathname === "/formulation") || - (searchMode === "factsheets" && pathname === "/factsheets") || - (searchMode === "therapy-compass" && pathname === "/therapy-compass") || - (searchMode === "tools" && pathname === "/tools")); + // Pathname-only: do not require searchMode === route. changeMode used to set + // searchMode before router.push landed, which made isStandaloneModeHome false + // for one frame (dock reserve + 200ms padding transition = choppy resize). + const isStandaloneModeHome = !hasSubmittedModeSearch && !rendersDashboardSearch && isStandaloneModeHomePath(pathname); const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations"); const shouldShowDesktopSidebar = !hideDesktopSidebar; const effectiveSidebarCollapsed = isDifferentialPresentationWorkflow ? true : sidebarCollapsed; @@ -516,17 +611,18 @@ function GlobalStandaloneSearchShellClient({ } setQuery(""); setCommandScopes([]); - setSearchMode(mode); setMobileMenuOpen(false); + // Let the URL sync (render-time) own searchMode. Optimistic setSearchMode + // before pathname updates was the namespaced mode-switch reserve flip. navigateToMode(mode); } function startNewAnswerChat() { setQuery(""); setMobileMenuOpen(false); - setSearchMode("answer"); setQueryMode("auto"); setScopeFilters({}); + // URL sync sets searchMode after navigation; avoid eager chrome thrash. router.push(appModeHomeHref("answer", { focus: true })); } @@ -545,7 +641,6 @@ function GlobalStandaloneSearchShellClient({ } setQuery(crossQuery); setCommandScopes([]); - setSearchMode(mode); setMobileMenuOpen(false); navigateToMode(mode, { query: crossQuery, focus: false, run: true }); } @@ -622,6 +717,9 @@ function GlobalStandaloneSearchShellClient({ // viewport through the whole transition, so content stays edge to edge. "sm:min-h-dvh max-sm:fixed max-sm:inset-0 max-sm:overflow-hidden bg-[color:var(--background)] text-[color:var(--text)]", shouldShowDesktopSidebar && "md:grid md:grid-cols-[5.25rem_minmax(0,1fr)]", + shouldShowDesktopSidebar && + sidebarColumnTransitionReady && + "motion-safe:transition-[grid-template-columns] motion-safe:duration-200 motion-safe:ease-out", shouldShowDesktopSidebar && (effectiveSidebarCollapsed ? "lg:grid-cols-[5.25rem_minmax(0,1fr)]" : "lg:grid-cols-[20rem_minmax(0,1fr)]"), )} @@ -770,6 +868,7 @@ function GlobalStandaloneSearchShellClient({ tabIndex={-1} onScroll={handleMainScroll} data-bottom-composer-hidden={bottomComposerHidden ? "true" : undefined} + data-reserve-transitioning={reserveTransitioning ? "true" : undefined} className={cn( // sm+ uses overflow-x-clip (not hidden): hidden forces overflow-y to // auto, which turns #main-content into the sticky scrollport while the @@ -798,17 +897,15 @@ function GlobalStandaloneSearchShellClient({ */}
{shouldShowSearchComposer && !isStandaloneModeHome ? ( -
) : null} - } - > - {children} - + {/* Paint RSC mode-home HTML immediately. A ClientHydrationBoundary here + blanked every standalone mode until JS mounted (hard-load LCP hit). */} + {children}
diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index e0c5a01569..3338c5ebd7 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -70,6 +70,7 @@ import { appModeIcons } from "@/lib/app-mode-icons"; import { resolveScrollBehavior } from "@/lib/scroll-behavior"; import type { ClinicalDocument, ClinicalQueryMode } from "@/lib/types"; import { type SearchScopeFilters } from "@/lib/search-scope"; +import { desktopComposerSlotReadyAttr, isDesktopComposerSlotReady } from "@/lib/mode-home-composer"; import { tagSearchText } from "@/lib/document-tags"; // Shared between the composer input's aria-describedby and the rendered @@ -361,12 +362,8 @@ export function MasterSearchHeader({ // paths also refresh from the live query so the first tap still picks Sheet. const [usesPhoneSearchLayout, setUsesPhoneSearchLayout] = useState(false); const [desktopComposerPortalActive, setDesktopComposerPortalActive] = useState(false); - // True once the requested page/hero portal is conclusively unavailable — the media query - // does not match, or the slot never appeared after the retry budget. While a - // slot id is present and this is false the inline composer stays suppressed - // (no flash while the portal mounts); once it flips true the inline composer - // renders, so the search can never vanish from the page at any width. - const [desktopComposerPortalFallback, setDesktopComposerPortalFallback] = useState(false); + // The default/inline composer stays mounted until the portal host is attached. + // Dual composers may coexist briefly during handoff; search never vanishes. // Phone-only hide-on-scroll: never hide while a header-owned surface is open // or while focus sits inside the header chrome (keyboard users must not tab // into invisible controls). @@ -999,7 +996,6 @@ export function MasterSearchHeader({ queueMicrotask(() => { if (cancelled) return; setDesktopComposerPortalActive(false); - setDesktopComposerPortalFallback(false); setDesktopComposerPortalHost(null); }); return () => { @@ -1035,36 +1031,38 @@ export function MasterSearchHeader({ // compositing, which stalled portal activation for seconds and made the // hero composer flake out of the mode-home slot. A microtask-driven sync // settles the portal on the same tick the slot mounts, no frame required. + // + // Ready-gate: page-owned slots mark `data-composer-slot-ready` only after + // their React segment hydrates. Adopting the slot before that injects a + // display:contents host into still-unhydrated RSC HTML (React #418). const syncTarget = () => { if (retryTimeout !== null) { window.clearTimeout(retryTimeout); retryTimeout = null; } const slot = mediaQuery.matches ? document.getElementById(composerSlotId) : null; - if (slot) { + if (slot && isDesktopComposerSlotReady(slot)) { portalRetryCount = 0; if (host.parentNode !== slot) slot.appendChild(host); setDesktopComposerPortalHost(host); setDesktopComposerPortalActive(true); - setDesktopComposerPortalFallback(false); } else { host.parentNode?.removeChild(host); setDesktopComposerPortalActive(false); if (mediaQuery.matches && portalRetryCount < 24) { portalRetryCount += 1; retryTimeout = window.setTimeout(syncTarget, Math.min(40 * portalRetryCount, 400)); - } else { - // The composer belongs inline at this width, or the slot never - // appeared within the retry budget: release the inline fallback so - // the search cannot vanish. The MutationObserver keeps watching, so - // a slot that shows up later still reclaims the portal. - setDesktopComposerPortalFallback(true); } } }; const observer = new MutationObserver(syncTarget); - observer.observe(document.body, { childList: true, subtree: true }); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: [desktopComposerSlotReadyAttr], + }); syncTarget(); mediaQuery.addEventListener("change", syncTarget); return () => { @@ -1073,7 +1071,6 @@ export function MasterSearchHeader({ mediaQuery.removeEventListener("change", syncTarget); host.parentNode?.removeChild(host); setDesktopComposerPortalActive(false); - setDesktopComposerPortalFallback(false); setDesktopComposerPortalHost(null); }; }, [desktopHomeComposerSlotId, desktopPageComposerSlotId, heroComposerBreakpoint]); @@ -2009,14 +2006,13 @@ export function MasterSearchHeader({ ); - const composerPortalRequested = desktopHomeComposerSlotId ?? desktopPageComposerSlotId; const portalPlacement = desktopHomeComposerSlotId ? "desktop-home" : "desktop-page"; const searchComposer = searchComposerVisible ? ( <> - {(desktopComposerPortalActive && desktopComposerPortalHost) || - (composerPortalRequested && !desktopComposerPortalFallback) - ? null - : renderSearchComposer("default")} + {/* Keep the default composer visible until the portal host is actually + attached. Suppressing on slotId alone left a null gap while the + mode-home/page slot remounted or MutationObserver rebound. */} + {desktopComposerPortalActive && desktopComposerPortalHost ? null : renderSearchComposer("default")} {desktopComposerPortalActive && desktopComposerPortalHost ? createPortal(renderSearchComposer(portalPlacement), desktopComposerPortalHost) : null} diff --git a/src/components/clinical-dashboard/shared-search-app-shell.tsx b/src/components/clinical-dashboard/shared-search-app-shell.tsx index 198c61ede0..0f8a41def6 100644 --- a/src/components/clinical-dashboard/shared-search-app-shell.tsx +++ b/src/components/clinical-dashboard/shared-search-app-shell.tsx @@ -4,6 +4,7 @@ import { Suspense, type ReactNode } from "react"; import { usePathname } from "next/navigation"; import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; import { TherapyCompassWorkspace } from "@/components/therapy-compass"; import { searchShellPropsForPathname } from "@/lib/search-shell-props"; @@ -15,7 +16,7 @@ export function SharedSearchAppShell({ children }: { children: ReactNode }) { const pathname = usePathname() ?? "/"; const shellProps = searchShellPropsForPathname(pathname); const content = pathname.startsWith("/therapy-compass") ? ( - + }> {children} ) : ( diff --git a/src/components/clinical-dashboard/use-document-viewer-chrome-scroll.ts b/src/components/clinical-dashboard/use-document-viewer-chrome-scroll.ts new file mode 100644 index 0000000000..4db9127d5c --- /dev/null +++ b/src/components/clinical-dashboard/use-document-viewer-chrome-scroll.ts @@ -0,0 +1,22 @@ +"use client"; + +import { useHideOnScroll, useReserveTransitionMarker } from "@/components/clinical-dashboard/use-hide-on-scroll"; + +/** Document viewer phone chrome: scroll-hide + short-lived reserve transition marker. */ +export function useDocumentViewerChromeScroll( + shellScrollContainer: HTMLElement | null, + documentId: string, + activePage: number, + activeChunkId: string | null | undefined, + mobileActionsOpen: boolean, + composerChromeFocused: boolean, +) { + const resetKey = `${documentId}:${activePage}:${activeChunkId ?? ""}`; + const scrollHidden = useHideOnScroll({ + ...(shellScrollContainer ? { scrollContainer: shellScrollContainer } : {}), + resetKey, + }); + const composerScrollHidden = scrollHidden && !mobileActionsOpen && !composerChromeFocused; + const reserveTransitioning = useReserveTransitionMarker(composerScrollHidden, resetKey); + return { composerScrollHidden, reserveTransitioning }; +} diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index 36d3b27476..0d8c0cccf8 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -246,6 +246,36 @@ function useScrollHideActive(disabled = false, allowAllBreakpoints = false) { return (allowAllBreakpoints || isPhone) && !disabled; } +/** Matches phone reserve padding transition duration in `globals.css`. */ +export const reserveTransitionMs = 240; + +/** + * Keeps a short-lived transition marker active through both composer hide and + * reveal so reserve padding can animate in both directions. Route/mode geometry + * changes (via `resetKey`) clear the marker immediately so those flips snap. + */ +export function useReserveTransitionMarker(hidden: boolean, resetKey?: unknown) { + const [transitioning, setTransitioning] = useState(false); + const previousHiddenRef = useRef(hidden); + const previousResetKeyRef = useRef(resetKey); + + useEffect(() => { + if (resetKey !== previousResetKeyRef.current) { + previousResetKeyRef.current = resetKey; + previousHiddenRef.current = hidden; + setTransitioning(false); + return; + } + if (hidden === previousHiddenRef.current) return; + previousHiddenRef.current = hidden; + setTransitioning(true); + const timer = window.setTimeout(() => setTransitioning(false), reserveTransitionMs); + return () => window.clearTimeout(timer); + }, [hidden, resetKey]); + + return transitioning; +} + /** * Imperative scroll-offset reporter for hosts that already own a React `onScroll` * handler on the scrolling element (for example ClinicalDashboard `
`). @@ -345,7 +375,20 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa return () => window.cancelAnimationFrame(frame); }, [allowAllBreakpoints, resetKey]); - return { hidden: active && hidden, reportScroll }; + // Shared shell keeps this reporter across namespaced mode homes. Without an + // explicit reset, a scrolled/collapsed phone surface carries scrollTop + + // hidden chrome into the next mode and reads as a stuck mid-page resize. + const reset = useCallback(() => { + hiddenRef.current = false; + lastOffsetRef.current = 0; + directionRef.current = null; + directionTravelRef.current = 0; + scrollSourceRef.current = null; + hasScrollSourceRef.current = false; + setHidden(false); + }, []); + + return { hidden: active && hidden, reportScroll, reset }; } /** diff --git a/src/components/clinical-dashboard/use-sidebar-column-transition.ts b/src/components/clinical-dashboard/use-sidebar-column-transition.ts new file mode 100644 index 0000000000..f3546c4afe --- /dev/null +++ b/src/components/clinical-dashboard/use-sidebar-column-transition.ts @@ -0,0 +1,17 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Enables sidebar grid-template-columns transitions only after mount so a + * remounted shell (Answer ↔ namespaced mode) does not animate from the default + * track width. + */ +export function useSidebarColumnTransitionReady() { + const [ready, setReady] = useState(false); + useEffect(() => { + const frame = window.requestAnimationFrame(() => setReady(true)); + return () => window.cancelAnimationFrame(frame); + }, []); + return ready; +} diff --git a/src/components/desktop-composer-portal-slot.tsx b/src/components/desktop-composer-portal-slot.tsx new file mode 100644 index 0000000000..c234109fd2 --- /dev/null +++ b/src/components/desktop-composer-portal-slot.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect, useRef, type ComponentPropsWithoutRef } from "react"; + +import { desktopComposerSlotReadyAttr, desktopComposerSlotReadyValue } from "@/lib/mode-home-composer"; + +type DesktopComposerPortalSlotProps = { + id: string; +} & Omit, "id" | "children">; + +/** + * Page-owned host for the desktop/hero search composer portal. + * + * The slot must stay empty through SSR and the page segment's first client + * paint so React hydration matches. MasterSearchHeader only adopts the slot + * after this attribute flips in useEffect (post-hydration). Adopting earlier + * injects a `display:contents` host into still-unhydrated RSC HTML and trips + * React #418 on mode homes. + */ +export function DesktopComposerPortalSlot({ id, className, ...rest }: DesktopComposerPortalSlotProps) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + el.setAttribute(desktopComposerSlotReadyAttr, desktopComposerSlotReadyValue); + return () => { + el.removeAttribute(desktopComposerSlotReadyAttr); + }; + }, []); + + return
; +} diff --git a/src/components/document-viewer/pdf-preview-loading.tsx b/src/components/document-viewer/pdf-preview-loading.tsx new file mode 100644 index 0000000000..20ecf2b2d1 --- /dev/null +++ b/src/components/document-viewer/pdf-preview-loading.tsx @@ -0,0 +1,11 @@ +export function PdfPreviewLoading() { + return ( +
+ Loading PDF reader… +
+ ); +} diff --git a/src/components/document-viewer/pdf-viewer-mode.ts b/src/components/document-viewer/pdf-viewer-mode.ts new file mode 100644 index 0000000000..bac095f33d --- /dev/null +++ b/src/components/document-viewer/pdf-viewer-mode.ts @@ -0,0 +1,38 @@ +export const pdfViewerModeStorageKey = "clinical-kb:pdf-viewer-mode"; +export const pdfViewerNativeModeBreakpoint = 820; +export const pdfViewerModeValue = { + native: "native", + canvas: "canvas", +} as const; +export const pdfViewerModeNativeValue = pdfViewerModeValue.native; + +export function getDefaultPdfViewerMode(): boolean { + return false; +} + +export function getInitialPdfViewerMode() { + if (typeof window === "undefined") { + return { + useNativePdfViewer: getDefaultPdfViewerMode(), + hasExplicitPdfViewerMode: false, + }; + } + + try { + const savedMode = window.localStorage.getItem(pdfViewerModeStorageKey); + if (savedMode === pdfViewerModeNativeValue) { + return { useNativePdfViewer: true, hasExplicitPdfViewerMode: true }; + } + + if (savedMode === pdfViewerModeValue.canvas) { + return { useNativePdfViewer: false, hasExplicitPdfViewerMode: true }; + } + } catch { + // window.localStorage may be unavailable in strict or private-browser contexts. + } + + return { + useNativePdfViewer: getDefaultPdfViewerMode(), + hasExplicitPdfViewerMode: false, + }; +} diff --git a/src/components/factsheets/factsheets-home-page.tsx b/src/components/factsheets/factsheets-home-page.tsx index 184470dc92..ae2fe4705a 100644 --- a/src/components/factsheets/factsheets-home-page.tsx +++ b/src/components/factsheets/factsheets-home-page.tsx @@ -8,6 +8,7 @@ import { featuredFactsheets, } from "@/components/factsheets/factsheets-data"; import { factsheetCategoryGlyph, factsheetGlyph } from "@/components/factsheets/factsheets-icons"; +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { ModeHomeHero, ModeHomeVerificationFooter } from "@/components/mode-home-template"; import { cn, eyebrowText } from "@/components/ui-primitives"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; @@ -29,7 +30,7 @@ export function FactsheetsHomePage() { /> {/* The universal composer portals itself into this slot on the mode home (hero placement). */} -
diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index 8cc577ba2d..55769e80a8 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -22,24 +22,30 @@ import { type ModeHomePill, } from "@/components/mode-home-template"; import { appModeHomeHref } from "@/lib/app-modes"; -import { defaultFormSlug } from "@/lib/forms"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { countVerifiedRegistryRecords, useRegistryRecords } from "@/lib/use-registry-records"; -const taskCards: ModeHomeAction[] = [ - { - title: "Find a form", - description: "Title, purpose, or workflow detail.", - icon: Search, - href: appModeHomeHref("forms", { focus: true }), - }, - { - title: "Readiness checks", - description: "Review status, source, and local confirmation.", - icon: ClipboardCheck, - href: `/forms/${defaultFormSlug() ?? ""}`, - }, - { +// The default form slug is computed server-side (app/forms/page.tsx) and passed +// as a prop: a direct `@/lib/forms` value import here would compile the full +// forms catalog into this client route chunk. +function buildTaskCards(defaultFormSlug: string | null): ModeHomeAction[] { + const cards: ModeHomeAction[] = [ + { + title: "Find a form", + description: "Title, purpose, or workflow detail.", + icon: Search, + href: appModeHomeHref("forms", { focus: true }), + }, + ]; + if (defaultFormSlug) { + cards.push({ + title: "Readiness checks", + description: "Review status, source, and local confirmation.", + icon: ClipboardCheck, + href: `/forms/${defaultFormSlug}`, + }); + } + cards.push({ title: "Check source status", description: "Find records that still need local confirmation.", icon: ShieldAlert, @@ -48,8 +54,9 @@ const taskCards: ModeHomeAction[] = [ focus: true, run: true, }), - }, -]; + }); + return cards; +} const commonTasks: ModeHomePill[] = [ { @@ -74,7 +81,8 @@ const commonTasks: ModeHomePill[] = [ }, ]; -export function FormsHomePage() { +export function FormsHomePage({ defaultFormSlug = null }: { defaultFormSlug?: string | null }) { + const taskCards = buildTaskCards(defaultFormSlug); const registry = useRegistryRecords("form"); const verifiedCount = countVerifiedRegistryRecords(registry); const registryReady = registry.status === "ready"; @@ -111,9 +119,9 @@ export function FormsHomePage() { return ( ; + // No key={query} remount: query is a pure prop (favourites already documents this). + return ; } function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { diff --git a/src/components/mode-home-page-skeleton.tsx b/src/components/mode-home-page-skeleton.tsx index efc0c40ba9..6c9eaa2955 100644 --- a/src/components/mode-home-page-skeleton.tsx +++ b/src/components/mode-home-page-skeleton.tsx @@ -25,7 +25,9 @@ export function ModeHomePageSkeleton() { export function ModeHomeRouteLoading() { return ( -
+ // Match ModeHomeMain startOnPhone: top-align on phones, centre from sm up. + // A phone-centred skeleton jumped when content-rich homes mounted top-aligned. +
); diff --git a/src/components/mode-home-template.tsx b/src/components/mode-home-template.tsx index 76fa14b000..fb7e2af9c2 100644 --- a/src/components/mode-home-template.tsx +++ b/src/components/mode-home-template.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { type ReactNode } from "react"; import { type LucideIcon, ArrowRight } from "lucide-react"; +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { cn, eyebrowText } from "@/components/ui-primitives"; export type ModeHomeAction = { @@ -252,7 +253,7 @@ export function ModeHomeTemplate({ {desktopComposerSlotId ? ( -
diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx index d5d412c08a..1f41d1ec22 100644 --- a/src/components/services/services-home-page.tsx +++ b/src/components/services/services-home-page.tsx @@ -122,9 +122,9 @@ export function ServicesHomePage({ defaultServiceSlug = null }: { defaultService return ( -
diff --git a/src/lib/mode-home-composer.ts b/src/lib/mode-home-composer.ts index 096532af1a..cb1554cdc7 100644 --- a/src/lib/mode-home-composer.ts +++ b/src/lib/mode-home-composer.ts @@ -1,6 +1,14 @@ export const modeHomeDesktopComposerSlotId = "mode-home-desktop-composer-slot"; export const desktopPageComposerSlotId = "desktop-page-search-composer-slot"; +/** Set on page-owned composer slots only after the owning segment has hydrated. */ +export const desktopComposerSlotReadyAttr = "data-composer-slot-ready"; +export const desktopComposerSlotReadyValue = "true"; + +export function isDesktopComposerSlotReady(slot: Element | null | undefined): boolean { + return slot?.getAttribute(desktopComposerSlotReadyAttr) === desktopComposerSlotReadyValue; +} + /** Mobile/tablet search-composer slot for differentials compare actions. */ export const differentialsMobileCompareAddonSlotId = "differentials-mobile-compare-addon-slot"; diff --git a/src/lib/private-search-scope.ts b/src/lib/private-search-scope.ts index 9a143eb6f7..ad49650e0c 100644 --- a/src/lib/private-search-scope.ts +++ b/src/lib/private-search-scope.ts @@ -71,3 +71,13 @@ export function restorePrivateSearchScope( return { kind: "unavailable", reason: "invalid" }; } } + +/** Drop a restored private-scope ref from the current URL without a navigation. */ +export function removePrivateScopeRefFromUrl( + location: Pick = window.location, +) { + const params = new URLSearchParams(location.search); + params.delete("scopeRef"); + const next = params.toString(); + window.history.replaceState(null, "", `${location.pathname}${next ? `?${next}` : ""}${location.hash}`); +} diff --git a/src/lib/search-route-ownership.ts b/src/lib/search-route-ownership.ts index 1c1ef4892c..5f003df229 100644 --- a/src/lib/search-route-ownership.ts +++ b/src/lib/search-route-ownership.ts @@ -17,6 +17,61 @@ const routeOwnedSubmittedSearchModes = new Set([ "factsheets", ]); +/** + * Exact pathnames that own an in-flow hero composer (no phone bottom dock). + * Derived from the URL alone so an optimistic mode-state update during + * navigation cannot flip the shell into dock reserve mid-transition. + */ +const standaloneModeHomePaths = new Set([ + "/services", + "/forms", + "/favourites", + "/differentials", + "/dsm", + "/specifiers", + "/formulation", + "/factsheets", + "/therapy-compass", + "/tools", +]); + +export function isStandaloneModeHomePath(pathname: string): boolean { + return standaloneModeHomePaths.has(pathname); +} + +/** + * Pathnames that never mount ClinicalDashboard, regardless of `?mode=` / `?run=`. + * Used to keep `{children}` out of a `useSearchParams()` Suspense boundary so the + * route segment is not streamed as a nested incomplete `S:` template inside the + * shell boundary (duplicate page-root `data-testid`s under CI load). + */ +const alwaysStandaloneShellPathPrefixes = [ + "/services", + "/forms", + "/favourites", + "/differentials", + "/dsm", + "/specifiers", + "/formulation", + "/factsheets", + "/therapy-compass", + "/medications", + "/calculators", +] as const; + +export function isAlwaysStandaloneShellPath(pathname: string): boolean { + return alwaysStandaloneShellPathPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)); +} + +/** Dashboard-owned hrefs stay on `/` with `?mode=`, or the submitted documents search route. */ +export function isDashboardModeHref(href: string): boolean { + if (href === "/" || href.startsWith("/?")) return true; + // Submitted document searches still render ClinicalDashboard; treat them as + // in-shell so cross-mode navigation can sync searchMode/query before push. + const path = href.split(/[?#]/, 1)[0] ?? href; + return path === "/documents/search"; +} + export function shouldRenderDashboardSearch({ hasSubmittedSearch, mode, diff --git a/tests/clinical-dashboard-merge-artifacts.test.ts b/tests/clinical-dashboard-merge-artifacts.test.ts index a17d2f8da7..98b1032f08 100644 --- a/tests/clinical-dashboard-merge-artifacts.test.ts +++ b/tests/clinical-dashboard-merge-artifacts.test.ts @@ -165,10 +165,15 @@ describe("ClinicalDashboard merge-artifact guards", () => { expect(documentViewerSource).toContain("max-sm:duration-[240ms]"); expect(documentViewerSource).toContain("max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]"); expect(globalStylesSource).toContain("@media (max-width: 639px) and (prefers-reduced-motion: reduce)"); - expect(globalStylesSource).toContain('#main-content[data-bottom-composer-hidden="true"]'); + expect(globalStylesSource).toContain('#main-content[data-reserve-transitioning="true"]'); expect(globalStylesSource).toContain('[data-testid="mobile-composer-reserve-pad"]'); - expect(globalStylesSource).toContain("transition: padding-bottom 200ms var(--ease-out-soft)"); + // Mode/route reserve flips must snap; scroll hide/reveal animates via marker. + expect(globalStylesSource).not.toMatch( + /#main-content\s*\{\s*will-change: padding-bottom;\s*transition: padding-bottom 200ms/, + ); expect(globalStylesSource).toContain("transition: padding-bottom 240ms var(--ease-out-soft)"); + expect(globalSearchShellSource).toContain("useReserveTransitionMarker"); + expect(clinicalDashboardSource).toContain("useReserveTransitionMarker"); expect(globalStylesSource).toContain("--phone-dock-differentials-compare-clearance: 12.5rem"); expect(globalStylesSource).toContain("var(--phone-dock-differentials-compare-clearance)"); // Child pages must not stack a second dock-sized safe-area pad under the diff --git a/tests/document-detail-performance.test.ts b/tests/document-detail-performance.test.ts index ac27717de2..8f96ab19e5 100644 --- a/tests/document-detail-performance.test.ts +++ b/tests/document-detail-performance.test.ts @@ -18,7 +18,10 @@ describe("document detail loading contract", () => { expect(page).toContain("loadAuthorizedDocumentDetail"); expect(page).toContain("initialDetail={initialDetail}"); expect(page).toContain("initialError={initialError}"); - expect(page).toContain('key={`${id}:${initialPage}:${query.chunk ?? ""}`}'); + // Page/chunk updates stay inside DocumentViewer via URL sync — remounting + // on every page flip reloaded the PDF and felt like loading lag. + expect(page).toContain("key={id}"); + expect(page).not.toContain('key={`${id}:${initialPage}:${query.chunk ?? ""}`}'); }); it("supports document and window asset scopes and starts independent detail reads together", () => { diff --git a/tests/document-viewer-pdf-reader-lazy.test.ts b/tests/document-viewer-pdf-reader-lazy.test.ts index cca001374e..0e1a5a7715 100644 --- a/tests/document-viewer-pdf-reader-lazy.test.ts +++ b/tests/document-viewer-pdf-reader-lazy.test.ts @@ -22,14 +22,14 @@ describe("DocumentViewer PDF reader loading", () => { '() => import("@/components/document-viewer/pdf-canvas-viewer").then((module) => module.NativePdfEmbed)', ); - const canvasBlock = viewerSource.slice( - viewerSource.indexOf("const PdfCanvasViewer = dynamic("), - viewerSource.indexOf("const NativePdfEmbed = dynamic("), - ); - const nativeBlock = viewerSource.slice( - viewerSource.indexOf("const NativePdfEmbed = dynamic("), - viewerSource.indexOf("function PdfPreviewLoading"), + expect(viewerSource).toContain( + 'import { PdfPreviewLoading } from "@/components/document-viewer/pdf-preview-loading"', ); + + const canvasStart = viewerSource.indexOf("const PdfCanvasViewer = dynamic("); + const nativeStart = viewerSource.indexOf("const NativePdfEmbed = dynamic("); + const canvasBlock = viewerSource.slice(canvasStart, nativeStart); + const nativeBlock = viewerSource.slice(nativeStart, viewerSource.indexOf("const secondaryButton", nativeStart)); expect(canvasBlock).toContain("ssr: false"); expect(nativeBlock).toContain("ssr: false"); expect(canvasBlock).toContain("loading: () => "); diff --git a/tests/forms-client-boundary.test.ts b/tests/forms-client-boundary.test.ts new file mode 100644 index 0000000000..e46c776d84 --- /dev/null +++ b/tests/forms-client-boundary.test.ts @@ -0,0 +1,139 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +// Mirror tests/services-client-boundary.test.ts: the forms catalog must not +// enter any client module graph via a value import of @/lib/forms. +const SRC_ROOT = join(process.cwd(), "src"); +const TARGET_SPECIFIER = "@/lib/forms"; +const SIDE_EFFECT_IMPORT_PATTERN = /^import\s+["']([^"']+)["']/gm; +const DYNAMIC_IMPORT_PATTERN = /\bimport\s*\(\s*(?:\/\*[\s\S]*?\*\/\s*)*["']([^"']+)["'][\s\S]*?\)/g; +const FROM_STATEMENT_PATTERN = /^(import|export)\s+([\s\S]+?)\s+from\s+["']([^"']+)["']/gm; + +function hasRuntimeBindings(kind: string, clause: string): boolean { + const trimmed = clause.trim(); + if (/^type\b/.test(trimmed)) return false; + const named = trimmed.match(/^\{([\s\S]*)\}$/); + if (named) { + return named[1] + .split(",") + .map((specifier) => specifier.trim()) + .filter(Boolean) + .some((specifier) => !/^type\b/.test(specifier)); + } + return kind === "import" || kind === "export"; +} + +function collectSourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const fullPath = join(dir, entry); + if (statSync(fullPath).isDirectory()) return collectSourceFiles(fullPath); + return /\.(?:ts|tsx)$/.test(entry) ? [fullPath] : []; + }); +} + +function resolveImport(specifier: string, fromFile: string): string | null { + let base: string; + if (specifier.startsWith("@/")) base = join(SRC_ROOT, specifier.slice(2)); + else if (specifier.startsWith(".")) base = resolve(dirname(fromFile), specifier); + else return null; + + for (const candidate of [base, `${base}.ts`, `${base}.tsx`, join(base, "index.ts"), join(base, "index.tsx")]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + return null; +} + +const FORMS_MODULE_PATHS = new Set( + ["lib/forms.ts", "lib/forms.tsx", "lib/forms/index.ts", "lib/forms/index.tsx"].map((candidate) => + join(SRC_ROOT, candidate), + ), +); + +function isClientEntry(source: string): boolean { + const prologue = source.replace(/^(?:\s+|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/, ""); + return /^["']use client["']/.test(prologue); +} + +interface ModuleInfo { + importsForms: boolean; + isClientEntry: boolean; + valueImports: string[]; +} + +function buildModuleGraph(): Map { + const graph = new Map(); + + for (const filePath of collectSourceFiles(SRC_ROOT)) { + const source = readFileSync(filePath, "utf8"); + const valueImports: string[] = []; + let importsForms = false; + + const recordRuntimeSpecifier = (specifier: string) => { + const resolved = resolveImport(specifier, filePath); + if ( + specifier === TARGET_SPECIFIER || + specifier.startsWith(`${TARGET_SPECIFIER}/`) || + (resolved !== null && FORMS_MODULE_PATHS.has(resolved)) + ) { + importsForms = true; + } + if (resolved) valueImports.push(resolved); + }; + + for (const match of source.matchAll(SIDE_EFFECT_IMPORT_PATTERN)) recordRuntimeSpecifier(match[1]); + for (const match of source.matchAll(DYNAMIC_IMPORT_PATTERN)) recordRuntimeSpecifier(match[1]); + for (const match of source.matchAll(FROM_STATEMENT_PATTERN)) { + if (hasRuntimeBindings(match[1], match[2])) recordRuntimeSpecifier(match[3]); + } + + graph.set(filePath, { + importsForms, + isClientEntry: isClientEntry(source), + valueImports, + }); + } + + return graph; +} + +describe("forms catalog client boundary", () => { + it("keeps @/lib/forms value-imports out of every client module graph", () => { + const graph = buildModuleGraph(); + const offenders: string[] = []; + + for (const [entryPath, entry] of graph) { + if (!entry.isClientEntry) continue; + + const cameFrom = new Map([[entryPath, ""]]); + const queue = [entryPath]; + while (queue.length > 0) { + const currentPath = queue.shift() as string; + const current = graph.get(currentPath); + if (!current) continue; + + if (current.importsForms) { + const chain: string[] = []; + for (let step: string | undefined = currentPath; step; step = cameFrom.get(step) || undefined) { + chain.unshift(relative(process.cwd(), step)); + } + offenders.push(chain.join(" -> ")); + break; + } + for (const next of current.valueImports) { + if (!cameFrom.has(next)) { + cameFrom.set(next, currentPath); + queue.push(next); + } + } + } + } + + expect( + offenders, + "Client module graphs must not value-import @/lib/forms: it compiles the full forms " + + "catalog into their chunk. Compute what you need server-side and pass it as a prop " + + "(see src/app/(search-app)/forms/page.tsx), or use `import type` for types only.", + ).toEqual([]); + }); +}); diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts index c4c9acdf4c..f9e437048f 100644 --- a/tests/header-scroll-hide-contract.test.ts +++ b/tests/header-scroll-hide-contract.test.ts @@ -21,6 +21,9 @@ const hookSource = read("src/components/clinical-dashboard/use-hide-on-scroll.ts const headerSource = read("src/components/clinical-dashboard/master-search-header.tsx"); const shellSource = read("src/components/clinical-dashboard/global-search-shell.tsx"); const dashboardSource = read("src/components/ClinicalDashboard.tsx"); +const dashboardResultComposerSlotSource = read( + "src/components/clinical-dashboard/dashboard-desktop-result-composer-slot.tsx", +); const composerSlotSource = read("src/lib/mode-home-composer.ts"); const behaviourDocSource = read("docs/search-chrome-behaviour.md"); @@ -28,7 +31,9 @@ describe("shared header hide/reveal wiring", () => { it("widens both app shells past the phone media gate", () => { // Second argument is `allowAllBreakpoints`; leaving it off is what pinned // hide-on-scroll to phones. - expect(shellSource).toContain("useScrollHideReporter(false, true)"); + // GlobalSearchShell also passes pathname as resetKey so shared mode homes + // do not inherit a collapsed top bar across routes. + expect(shellSource).toContain("useScrollHideReporter(false, true, pathname)"); expect(dashboardSource).toContain("useScrollHideReporter(false, true, searchMode)"); expect(hookSource).toContain("export function useScrollHideReporter(disabled = false, allowAllBreakpoints = false"); }); @@ -62,9 +67,12 @@ describe("shared header hide/reveal wiring", () => { '"document-mobile-search-edge universal-top-search-edge relative z-20 mx-auto w-full max-w-3xl px-4 py-3 lg:max-w-4xl"', ); expect(shellSource).toContain('data-testid="desktop-page-search-composer-slot"'); - expect(dashboardSource).toContain('data-testid="desktop-page-search-composer-slot"'); expect(shellSource).toContain('className="hidden lg:block lg:empty:hidden"'); - expect(dashboardSource).toContain('className="hidden lg:block lg:empty:hidden"'); + // Dashboard result slot lives in a budget-extracted helper so ClinicalDashboard + // stays under the maintainability no-growth ceiling. + expect(dashboardSource).toContain("DashboardDesktopResultComposerSlot"); + expect(dashboardResultComposerSlotSource).toContain('data-testid="desktop-page-search-composer-slot"'); + expect(dashboardResultComposerSlotSource).toContain('className="hidden lg:block lg:empty:hidden"'); expect(behaviourDocSource).toContain("Desktop search is page-owned"); }); diff --git a/tests/mode-home-loading-contract.test.ts b/tests/mode-home-loading-contract.test.ts new file mode 100644 index 0000000000..66a67c29e3 --- /dev/null +++ b/tests/mode-home-loading-contract.test.ts @@ -0,0 +1,86 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SEARCH_APP_ROOT = join(process.cwd(), "src/app/(search-app)"); + +const MODE_HOME_LOADING_ROUTES = [ + "services", + "forms", + "favourites", + "differentials", + "dsm", + "specifiers", + "formulation", + "therapy-compass", + "factsheets", + "tools", +] as const; + +describe("mode-home loading contract", () => { + it("uses ModeHomeRouteLoading for every standalone mode home", () => { + for (const route of MODE_HOME_LOADING_ROUTES) { + const loadingPath = join(SEARCH_APP_ROOT, route, "loading.tsx"); + expect(existsSync(loadingPath), `missing ${route}/loading.tsx`).toBe(true); + const source = readFileSync(loadingPath, "utf8"); + expect(source).toContain("ModeHomeRouteLoading"); + expect(source).not.toMatch(/Loading services|Loading medication|Loading library/); + } + }); + + it("does not blank standalone mode children behind ClientHydrationBoundary", () => { + const shellSource = readFileSync( + join(process.cwd(), "src/components/clinical-dashboard/global-search-shell.tsx"), + "utf8", + ); + expect(shellSource).not.toMatch(/import\s*\{[^}]*ClientHydrationBoundary/); + expect(shellSource).not.toMatch(/{children}"); + }); + + it("keeps route children outside useSearchParams Suspense on standalone shells", () => { + const shellSource = readFileSync( + join(process.cwd(), "src/components/clinical-dashboard/global-search-shell.tsx"), + "utf8", + ); + const ownershipSource = readFileSync(join(process.cwd(), "src/lib/search-route-ownership.ts"), "utf8"); + expect(ownershipSource).toContain("export function isAlwaysStandaloneShellPath"); + // Outer shell skips the useSearchParams Suspense for always-standalone paths. + expect(shellSource).toMatch( + /export function GlobalSearchShell[\s\S]*?if \(isAlwaysStandaloneShellPath\(pathname\)\) \{[\s\S]*?GlobalStandaloneSearchShellClient/, + ); + expect(shellSource).toContain("ShellSearchParamsBridge"); + // Bridge is the only Suspense child that may call useSearchParams in the + // standalone path; the body that renders {children} must not. + expect(shellSource).toMatch( + /function GlobalStandaloneSearchShellClient[\s\S]*?[\s\S]*?ShellSearchParamsBridge/, + ); + expect(shellSource).toMatch( + /function GlobalStandaloneSearchShellBody[\s\S]*?SearchCommandProvider value=\{searchCommandContextValue\}>\{children\}/, + ); + expect(shellSource).not.toMatch(/function GlobalStandaloneSearchShellBody[\s\S]*?useSearchParams\(\)/); + }); + + it("lazy-loads ClinicalDashboard so namespaced homes skip its module graph", () => { + const shellSource = readFileSync( + join(process.cwd(), "src/components/clinical-dashboard/global-search-shell.tsx"), + "utf8", + ); + expect(shellSource).toMatch(/dynamic\(\s*\(\)\s*=>\s*import\("@\/components\/ClinicalDashboard"\)/); + expect(shellSource).not.toMatch(/^import \{ ClinicalDashboard \} from/m); + }); + + it("keeps mode-home route loading top-aligned on phones", () => { + const skeletonSource = readFileSync(join(process.cwd(), "src/components/mode-home-page-skeleton.tsx"), "utf8"); + expect(skeletonSource).toContain("items-start"); + expect(skeletonSource).toContain("sm:items-center"); + }); +}); + +// Keep the suite from being deleted as unused if the route inventory drifts. +describe("search-app route inventory smoke", () => { + it("still has a (search-app) tree", () => { + expect(statSync(SEARCH_APP_ROOT).isDirectory()).toBe(true); + expect(readdirSync(SEARCH_APP_ROOT).length).toBeGreaterThan(5); + }); +}); diff --git a/tests/mode-home-main-align.test.ts b/tests/mode-home-main-align.test.ts index 522c0cc32b..7c9ba0f4d8 100644 --- a/tests/mode-home-main-align.test.ts +++ b/tests/mode-home-main-align.test.ts @@ -70,14 +70,16 @@ describe("ModeHomeMain alignment contract", () => { expect(source).not.toMatch(/ModeHomeMain[^>]*className="[^"]*justify-/); } - // Forms/services only top-align when the registry is seeded; short empty / - // loading notices stay centred so the phone canvas does not look sparse. + // Forms/services top-align while loading or seeded so the registry ready + // flip does not jump center → start; confirmed empty/error stay centred. for (const path of [ resolve(SRC_ROOT, "components/forms/forms-home-page.tsx"), resolve(SRC_ROOT, "components/services/services-home-page.tsx"), ]) { const source = readFileSync(path, "utf8"); - expect(source).toMatch(/contentAlign=\{hasRegistryRecords \? "startOnPhone" : "center"\}/); + expect(source).toMatch( + /contentAlign=\{registry\.status === "loading" \|\| hasRegistryRecords \? "startOnPhone" : "center"\}/, + ); expect(source).not.toMatch(/ModeHomeMain[^>]*className="[^"]*justify-/); } }); diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index aab3f21472..f249bae280 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -1,6 +1,14 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { shouldRenderClinicalDashboard, shouldRenderDashboardSearch } from "@/lib/search-route-ownership"; +import { + isAlwaysStandaloneShellPath, + isDashboardModeHref, + isStandaloneModeHomePath, + shouldRenderClinicalDashboard, + shouldRenderDashboardSearch, +} from "@/lib/search-route-ownership"; describe("shared-search route ownership", () => { it("keeps submitted searches in route-owned mode workflows", () => { @@ -42,4 +50,127 @@ describe("shared-search route ownership", () => { }), ).toBe(false); }); + + it("classifies standalone mode homes from pathname alone", () => { + for (const pathname of [ + "/services", + "/forms", + "/favourites", + "/differentials", + "/dsm", + "/specifiers", + "/formulation", + "/factsheets", + "/therapy-compass", + "/tools", + ]) { + expect(isStandaloneModeHomePath(pathname)).toBe(true); + } + expect(isStandaloneModeHomePath("/")).toBe(false); + expect(isStandaloneModeHomePath("/services/crisis")).toBe(false); + expect(isStandaloneModeHomePath("/dsm/search")).toBe(false); + }); + + it("marks route-owned namespaced paths as always-standalone shell (no searchParams gate)", () => { + expect(isAlwaysStandaloneShellPath("/forms")).toBe(true); + expect(isAlwaysStandaloneShellPath("/favourites")).toBe(true); + expect(isAlwaysStandaloneShellPath("/differentials/presentations/acute-confusion-encephalopathy")).toBe(true); + expect(isAlwaysStandaloneShellPath("/medications/acamprosate")).toBe(true); + // `/` and documents/tools may still need searchParams for the dashboard gate. + expect(isAlwaysStandaloneShellPath("/")).toBe(false); + expect(isAlwaysStandaloneShellPath("/documents/search")).toBe(false); + expect(isAlwaysStandaloneShellPath("/tools")).toBe(false); + }); + + it("classifies dashboard mode hrefs without parsing the destination page", () => { + expect(isDashboardModeHref("/")).toBe(true); + expect(isDashboardModeHref("/?mode=answer")).toBe(true); + expect(isDashboardModeHref("/?mode=documents&focus=1")).toBe(true); + expect(isDashboardModeHref("/documents/search")).toBe(true); + expect(isDashboardModeHref("/documents/search?q=lithium&run=1")).toBe(true); + expect(isDashboardModeHref("/services")).toBe(false); + expect(isDashboardModeHref("/differentials?q=mania&run=1")).toBe(false); + expect(isDashboardModeHref("/documents/searching")).toBe(false); + }); + + it("keeps shell mode-home detection pathname-gated (no searchMode∧pathname AND)", () => { + const shellSource = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/global-search-shell.tsx"), + "utf8", + ); + expect(shellSource).toContain("isStandaloneModeHomePath(pathname)"); + expect(shellSource).not.toMatch(/searchMode === "services" && pathname === "\/services"/); + // changeMode must not optimistic-set searchMode before navigation. + expect(shellSource).toMatch(/function changeMode\(mode: AppModeId\) \{[\s\S]*?navigateToMode\(mode\);\n \}/); + expect(shellSource).not.toMatch( + /function changeMode\(mode: AppModeId\) \{[\s\S]*?setSearchMode\(mode\);[\s\S]*?navigateToMode\(mode\);/, + ); + }); + + it("seeds always-standalone submitted params before the bridge hydrates", () => { + const shellSource = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/global-search-shell.tsx"), + "utf8", + ); + expect(shellSource).toContain("function readInitialBrowserSubmittedSearchParamString()"); + expect(shellSource).toContain("window.location.search.slice(1)"); + expect(shellSource).toContain('params.get("run") === "1" && query ? search : ""'); + expect(shellSource).toMatch( + /useSyncExternalStore\(\s*subscribeNoop,\s*readInitialBrowserSubmittedSearchParamString,\s*\(\) => "",\s*\)/, + ); + expect(shellSource).toContain("searchParamString || browserSearchParamString"); + }); + + it("resets shared phone scroll chrome when the pathname changes", () => { + const shellSource = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/global-search-shell.tsx"), + "utf8", + ); + const hideSource = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/use-hide-on-scroll.ts"), + "utf8", + ); + expect(hideSource).toContain("const reset = useCallback"); + expect(hideSource).toMatch(/return \{ hidden: active && hidden, reportScroll, reset \}/); + // Hide state resets via the reporter resetKey; scroll offset resets explicitly. + expect(shellSource).toContain("useScrollHideReporter(false, true, pathname)"); + expect(shellSource).toMatch(/main\.scrollTop = 0[\s\S]*\}, \[pathname\]\)/); + }); + + it("keeps the default composer until the hero portal host attaches", () => { + const headerSource = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/master-search-header.tsx"), + "utf8", + ); + expect(headerSource).not.toContain("desktopComposerPortalFallback"); + expect(headerSource).not.toContain("composerPortalRequested && !desktopComposerPortalFallback"); + expect(headerSource).toMatch( + /desktopComposerPortalActive && desktopComposerPortalHost\s*\?\s*null\s*:\s*renderSearchComposer\("default"\)/, + ); + }); + + it("waits for page-owned composer slots to hydrate before portal adoption", () => { + const headerSource = readFileSync( + resolve(process.cwd(), "src/components/clinical-dashboard/master-search-header.tsx"), + "utf8", + ); + const slotSource = readFileSync(resolve(process.cwd(), "src/components/desktop-composer-portal-slot.tsx"), "utf8"); + const composerLibSource = readFileSync(resolve(process.cwd(), "src/lib/mode-home-composer.ts"), "utf8"); + expect(composerLibSource).toContain('desktopComposerSlotReadyAttr = "data-composer-slot-ready"'); + expect(slotSource).toContain("desktopComposerSlotReadyAttr"); + expect(slotSource).toContain("useEffect"); + expect(headerSource).toContain("isDesktopComposerSlotReady(slot)"); + expect(headerSource).toContain("attributeFilter: [desktopComposerSlotReadyAttr]"); + }); + + it("leaves the dashboard shell without eager chrome thrash", () => { + const dashboardSource = readFileSync(resolve(process.cwd(), "src/components/ClinicalDashboard.tsx"), "utf8"); + expect(dashboardSource).toContain("isDashboardModeHref"); + expect(dashboardSource).toMatch( + /function selectSearchMode\(mode: AppModeId\) \{[\s\S]*?if \(!isDashboardModeHref\(href\)\) \{[\s\S]*?router\.push\(href\);\n return;/, + ); + expect(dashboardSource).toMatch( + /function crossModeSearch\(mode: AppModeId, crossQuery: string\) \{[\s\S]*?if \(!isDashboardModeHref\(href\)\) \{[\s\S]*?router\.push\(href\);\n return;/, + ); + }); }); diff --git a/tests/therapy-compass-mode-wiring.test.ts b/tests/therapy-compass-mode-wiring.test.ts index f2f172886f..e0ce2984e9 100644 --- a/tests/therapy-compass-mode-wiring.test.ts +++ b/tests/therapy-compass-mode-wiring.test.ts @@ -137,6 +137,8 @@ describe("Therapy Compass production-mode wiring", () => { "utf8", ); expect(homeSrc).toContain("desktopComposerSlotId={modeHomeDesktopComposerSlotId}"); - expect(shellSrc).toContain('searchMode === "therapy-compass" && pathname === "/therapy-compass"'); + // Mode homes are pathname-gated so optimistic searchMode cannot flip hero→dock mid-nav. + expect(shellSrc).toContain("isStandaloneModeHomePath(pathname)"); + expect(shellSrc).toContain('"/therapy-compass"'); }); }); diff --git a/tests/ui-phone-scroll.spec.ts b/tests/ui-phone-scroll.spec.ts index 0c228c4d6c..2aefd64e85 100644 --- a/tests/ui-phone-scroll.spec.ts +++ b/tests/ui-phone-scroll.spec.ts @@ -22,7 +22,7 @@ import { expect, test, type Page } from "playwright/test"; // in-flow hero pill on phones (the composer sits in the hero and scrolls with the // content — no bottom dock), while the sticky header still collapses on scroll; // this sweep guards that the scroll geometry stays stable through that collapse. -// (list mirrors global-search-shell.tsx isStandaloneModeHome). +// (list mirrors isStandaloneModeHomePath in search-route-ownership.ts). const modeHomeRoutes = [ "/formulation", "/dsm", @@ -227,7 +227,9 @@ for (const route of [...modeHomeRoutes, ...dashboardRoutes, ...longRoutes]) { expect(initial.horizontalOverflow, "no horizontal overflow").toBeLessThanOrEqual(2); expect(initial.scrollTop).toBe(0); expect(initial.headerHidden, "header visible at the top").toBe(false); - expect(initial.reserveTransitionDuration, "phone reserve transition remains exercised").toContain("0.2s"); + // Mode/route reserve flips snap (0s). Padding only animates while + // data-bottom-composer-hidden="true" (scroll-hide), asserted below. + expect(initial.reserveTransitionDuration, "visible reserve must snap on mode/route flips").toBe("0s"); // Drag to the bottom in deliberate 24px steps, then let transitions settle. await dragScrollBy(page, initial.maxOffset + 400, 24); @@ -242,6 +244,10 @@ for (const route of [...modeHomeRoutes, ...dashboardRoutes, ...longRoutes]) { Math.abs(atBottom.scrollTop - atBottom.maxOffset), "settled scroll sits on the true bottom edge", ).toBeLessThanOrEqual(2); + // Reserve padding only animates while the short-lived + // data-reserve-transitioning marker is on (hide/reveal). Once settled, + // transitionDuration returns to 0s — the 240ms rule is pinned in + // clinical-dashboard-merge-artifacts / globals.css. // At most one chrome transition on a pure descent (hide, when the page is // long enough to afford it) — more means hide/reveal oscillation. expect(flipsAfterDescent, "no chrome oscillation while scrolling down").toBeLessThanOrEqual(1); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 30eeab207e..5085802a6f 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1948,7 +1948,9 @@ test.describe("Clinical KB tools launcher", () => { await expect(page).toHaveURL(/\/differentials\/presentations\/acute-confusion-encephalopathy/, { timeout: 30_000 }); await expect(page.getByRole("button", { name: "Mode Differentials" })).toBeVisible(); - await expect(page.getByTestId("differential-presentation-page")).toBeVisible(); + await expect( + page.getByTestId("mobile-composer-reserve-pad").getByTestId("differential-presentation-page"), + ).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: workflow.title })).toBeVisible(); await expect( page @@ -1989,8 +1991,15 @@ test.describe("Clinical KB tools launcher", () => { await page.setViewportSize({ width: 390, height: 844 }); await gotoLauncher(page, "/differentials/presentations"); + await expect(page).toHaveURL(/\/differentials\/presentations\/acute-confusion-encephalopathy/, { timeout: 30_000 }); - await expect(page.getByTestId("differential-presentation-page")).toBeVisible({ timeout: 30_000 }); + // Scope to the live shell scrollport: Next may briefly retain a hidden + // streaming `S:` clone of the page root under CI load, which would make a + // document-wide getByTestId strict-mode fail. + const presentationPage = page + .getByTestId("mobile-composer-reserve-pad") + .getByTestId("differential-presentation-page"); + await expect(presentationPage).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("link", { name: "Back to differentials" })).toBeVisible(); await expect(page.getByRole("link", { name: "Compare", exact: true })).toHaveAttribute("aria-current", "page"); await expect(page.getByRole("heading", { level: 1, name: workflow.title })).toBeVisible();