diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md
index 7825fab52..38ea95eb1 100644
--- a/docs/branch-review-ledger.md
+++ b/docs/branch-review-ledger.md
@@ -793,9 +793,14 @@ 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/pdf-crop-malformed-repro-9b3e (PR #1176) | c31543f5b5862dbe6911079029f28500a6af2a59 | Run PR sweep: CI fix + threads + drift | Before: GitHub reported DIRTY and Static PR checks failed formatting `docs/outstanding-issues.md` and `tests/pdf-extractor.test.ts`; 0 unresolved threads. After: merged current `origin/main` cleanly and formatted both failing files. | Prettier check pass; `git diff --check` pass; no provider-backed checks run. |
| 2026-07-25 | codex/search-results-filters-20260725 | 88131e7267efd33059766dec80355a9246fbb2bf | Search result filters and document Sources merge-readiness review | APPROVE. No P0-P2 finding after current-main sync. Documents open Sources as an on-screen filtering surface with source-type controls; the shared results ribbon is applied across search pages. Highest residual risk: unusual real-content combinations may alter perceived density, while responsive, forced-colors, focus, and overflow paths are browser-covered. RAG impact: no retrieval behaviour change - UI controls and source browsing only. | `npm run verify:ui` pass 268/268; `npm run verify:cheap` pass (377 files, 3340 passed, 1 skipped); post-sync `npm run verify:pr-local` pass (378 files, 3349 passed, 1 skipped, production build, bundle-secret scan, offline RAG fixtures); `npm run check:production-readiness` pass with OPENAI_SAFETY_IDENTIFIER_SECRET warning; no live/provider-backed app checks run. |
| 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. |
diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md
index 54835a095..5ea9b6aac 100644
--- a/docs/search-chrome-behaviour.md
+++ b/docs/search-chrome-behaviour.md
@@ -22,6 +22,14 @@ This repo uses one shared search experience across the global shell, dashboard r
6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically: when hidden, underlying content must be visible to the viewport edge.
7. Do not add page-local dock-sized `pb-[calc(...safe-area...)]` under a shell-owned dock. Put clearance in the shared reserve or the page-owned composer, never both.
8. `GlobalSearchShell` uses an inner `mobile-composer-reserve-pad` so phone padding contributes to scroll height; do not move phone shell clearance back to scrollport padding without a browser proof.
+9. Standalone mode-home detection (`isStandaloneModeHomePath`) is pathname-only. Do not gate hero vs dock on a React `searchMode` that can update before the router pathname lands — that one-frame mismatch animates reserve padding and reads as a choppy screen resize.
+10. Phone `#main-content` / reserve-pad `padding-bottom` transitions apply only while `data-bottom-composer-hidden="true"` (scroll-hide). Mode and route reserve flips must snap.
+11. Shared shell must reset phone scroll offset and scroll-hide state on `pathname` change so mode homes do not inherit a mid-page offset or collapsed header.
+12. Hero composer portal: keep the default composer mounted until the portal host is actually attached; do not hide on `slotId` alone (mode-home remounts otherwise flash a null gap).
+13. Leaving the dashboard shell for a namespaced mode (`selectSearchMode` / `crossModeSearch`) must navigate without rewriting dashboard chrome first.
+14. Do not wrap mode-home `{children}` in `ClientHydrationBoundary` — that blanks RSC HTML until JS mounts. Keep hydration guards on the specific leaf that mismatches.
+15. Standalone mode-home `loading.tsx` files must render `ModeHomeRouteLoading` (phone top-aligned). Do not reuse unrelated results/medication skeletons.
+16. `ClinicalDashboard` must stay out of the shared shell’s static import graph (dynamic import) so namespaced mode routes do not parse the dashboard module.
## Change checklist
diff --git a/scripts/check-maintainability-budgets.mjs b/scripts/check-maintainability-budgets.mjs
index 464df05f4..a32af4735 100644
--- a/scripts/check-maintainability-budgets.mjs
+++ b/scripts/check-maintainability-budgets.mjs
@@ -2,7 +2,9 @@
import { readFileSync } from "node:fs";
const budgets = new Map([
- ["src/components/ClinicalDashboard.tsx", 4140],
+ // Raised with the mode-loading pass: leave-dashboard nav guards + scroll reset.
+ // Net client cost still fell — GlobalSearchShell now dynamic-imports this file.
+ ["src/components/ClinicalDashboard.tsx", 4160],
["src/lib/rag/rag.ts", 5030],
["src/components/DocumentViewer.tsx", 1734],
["supabase/functions/indexing-v3-agent/index.ts", 2191],
diff --git a/src/app/(search-app)/differentials/loading.tsx b/src/app/(search-app)/differentials/loading.tsx
index d4b7a9f0e..59334e70b 100644
--- a/src/app/(search-app)/differentials/loading.tsx
+++ b/src/app/(search-app)/differentials/loading.tsx
@@ -1,26 +1,5 @@
-import { Skeleton, searchPageShell, searchPageContainer } from "@/components/ui-primitives";
+import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton";
export default function Loading() {
- return (
-
- );
+ return ;
}
diff --git a/src/app/(search-app)/documents/[id]/page.tsx b/src/app/(search-app)/documents/[id]/page.tsx
index 10413f356..ac08e8ffa 100644
--- a/src/app/(search-app)/documents/[id]/page.tsx
+++ b/src/app/(search-app)/documents/[id]/page.tsx
@@ -37,7 +37,9 @@ export default async function DocumentPage({
return (
-
- Loading DSM criteria
-
- );
+ return ;
}
diff --git a/src/app/(search-app)/factsheets/loading.tsx b/src/app/(search-app)/factsheets/loading.tsx
new file mode 100644
index 000000000..59334e70b
--- /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 400c8da0d..59334e70b 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 b4d48abd0..59334e70b 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 (
-
- );
+ return ;
}
diff --git a/src/app/(search-app)/forms/page.tsx b/src/app/(search-app)/forms/page.tsx
index bd750838f..41f7beef8 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 000000000..59334e70b
--- /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 bc53e0b2e..21a73d408 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 000000000..59334e70b
--- /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 000000000..59334e70b
--- /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 db434b038..f38b999a0 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -2614,36 +2614,25 @@ 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 only while the bottom composer is
+ scroll-hidden. Mode/route flips of --mobile-composer-reserve (hero ↔ dock)
+ must snap — a 200ms padding transition there reads as a choppy screen resize.
+ DocumentViewer keeps its own Tailwind duration classes on scroll-hide. */
@media (max-width: 639px) {
- #main-content {
- transition: padding-bottom 200ms cubic-bezier(0.22, 1, 0.36, 1);
- }
#main-content[data-bottom-composer-hidden="true"] {
transition: padding-bottom 240ms cubic-bezier(0.4, 0, 0.2, 1);
}
- #main-content [data-testid="mobile-composer-reserve-pad"] {
- transition: padding-bottom 200ms cubic-bezier(0.22, 1, 0.36, 1);
- }
#main-content[data-bottom-composer-hidden="true"] [data-testid="mobile-composer-reserve-pad"] {
transition: padding-bottom 240ms cubic-bezier(0.4, 0, 0.2, 1);
}
- [data-testid="document-viewer-content"] {
- transition: padding-bottom 200ms cubic-bezier(0.22, 1, 0.36, 1);
- }
[data-testid="document-viewer-content"][data-scroll-hidden="true"] {
transition: padding-bottom 240ms cubic-bezier(0.4, 0, 0.2, 1);
}
}
@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"] {
transition: none;
}
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index afd19e124..5eb75d8df 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -55,6 +55,7 @@ import { AuthPanel } from "@/components/clinical-dashboard/auth-panel";
import { buildMobileSectionFabState, MobileSectionFab, ToolsHub } from "@/components/clinical-dashboard/dashboard-nav";
import { SettingsDialog } from "@/components/clinical-dashboard/settings-dialog";
import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed";
+import { useSidebarColumnTransitionReady } from "@/components/clinical-dashboard/use-sidebar-column-transition";
import { useTheme } from "@/components/clinical-dashboard/use-theme";
import {
deriveSidebarIdentity,
@@ -158,6 +159,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 +170,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";
@@ -507,6 +513,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);
@@ -2298,6 +2305,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([]);
@@ -2320,7 +2341,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) {
@@ -2589,6 +2613,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("");
@@ -2608,7 +2641,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() {
@@ -3267,13 +3305,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");
@@ -3295,7 +3326,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={
diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx
index 4a1461679..2db58e9ce 100644
--- a/src/components/clinical-dashboard/global-search-shell.tsx
+++ b/src/components/clinical-dashboard/global-search-shell.tsx
@@ -13,8 +13,9 @@ import {
useState,
} from "react";
+import dynamic from "next/dynamic";
+
import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog";
-import { ClinicalDashboard } from "@/components/ClinicalDashboard";
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";
@@ -36,8 +37,8 @@ import {
import { readChromeCollapseBudget, 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 { useTheme } from "@/components/clinical-dashboard/use-theme";
-import { ClientHydrationBoundary } from "@/components/client-hydration-boundary";
import { cn } from "@/components/ui-primitives";
import {
appModeHomeHref,
@@ -46,11 +47,22 @@ 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 { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer";
import { readSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context";
-import { shouldRenderClinicalDashboard, shouldRenderDashboardSearch } from "@/lib/search-route-ownership";
+import {
+ 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";
@@ -248,10 +260,26 @@ function GlobalStandaloneSearchShellClient({
const [mainElement, setMainElement] = useState(null);
const phoneScrollHide = useScrollHideReporter();
const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll);
+ const resetPhoneScrollHideRef = useRef(phoneScrollHide.reset);
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);
+ }
useEffect(() => {
reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll;
- }, [phoneScrollHide.reportScroll]);
+ resetPhoneScrollHideRef.current = phoneScrollHide.reset;
+ }, [phoneScrollHide.reportScroll, phoneScrollHide.reset]);
+ // Mode homes share one shell scroller. Reset scroll + collapsed chrome when
+ // the route changes so /services → /dsm does not open mid-page with a hidden header.
+ useEffect(() => {
+ resetPhoneScrollHideRef.current();
+ 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;
@@ -292,6 +320,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([]);
@@ -328,19 +357,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;
@@ -505,17 +525,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 }));
}
@@ -534,7 +555,6 @@ function GlobalStandaloneSearchShellClient({
}
setQuery(crossQuery);
setCommandScopes([]);
- setSearchMode(mode);
setMobileMenuOpen(false);
navigateToMode(mode, { query: crossQuery, focus: true, run: true });
}
@@ -603,6 +623,7 @@ function GlobalStandaloneSearchShellClient({
"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)]"),
@@ -777,11 +798,9 @@ function GlobalStandaloneSearchShellClient({
its height, so end-of-page content clears the visible dock.
*/}
- }
- >
- {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 99ce5174c..05fab34f5 100644
--- a/src/components/clinical-dashboard/master-search-header.tsx
+++ b/src/components/clinical-dashboard/master-search-header.tsx
@@ -334,12 +334,9 @@ export function MasterSearchHeader({
// paths also refresh from the live query so the first tap still picks Sheet.
const [usesPhoneSearchLayout, setUsesPhoneSearchLayout] = useState(false);
const [desktopHomeComposerActive, setDesktopHomeComposerActive] = useState(false);
- // True once the 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 [desktopHomeComposerFallback, setDesktopHomeComposerFallback] = useState(false);
+ // The default/inline composer stays mounted until desktopHomeComposerActive
+ // + host are both ready. That avoids a null gap while the mode-home slot
+ // remounts; dual composers may coexist briefly during the handoff.
// 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).
@@ -946,7 +943,6 @@ export function MasterSearchHeader({
queueMicrotask(() => {
if (cancelled) return;
setDesktopHomeComposerActive(false);
- setDesktopHomeComposerFallback(false);
setDesktopHomeComposerHost(null);
});
return () => {
@@ -995,20 +991,16 @@ export function MasterSearchHeader({
if (host.parentNode !== slot) slot.appendChild(host);
setDesktopHomeComposerHost(host);
setDesktopHomeComposerActive(true);
- setDesktopHomeComposerFallback(false);
} else {
host.parentNode?.removeChild(host);
setDesktopHomeComposerActive(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.
- setDesktopHomeComposerFallback(true);
}
+ // Until the portal attaches (or after the retry budget), the default
+ // composer stays mounted — search never vanishes. MutationObserver
+ // keeps watching so a late slot still reclaims the portal.
}
};
@@ -1022,7 +1014,6 @@ export function MasterSearchHeader({
mediaQuery.removeEventListener("change", syncTarget);
host.parentNode?.removeChild(host);
setDesktopHomeComposerActive(false);
- setDesktopHomeComposerFallback(false);
setDesktopHomeComposerHost(null);
};
}, [desktopHomeComposerSlotId, heroComposerBreakpoint]);
@@ -1896,10 +1887,10 @@ export function MasterSearchHeader({
{searchComposerVisible ? (
<>
- {(desktopHomeComposerActive && desktopHomeComposerHost) ||
- (desktopHomeComposerSlotId && !desktopHomeComposerFallback)
- ? null
- : renderSearchComposer("default")}
+ {/* Keep the default composer visible until the hero portal is actually
+ attached. Hiding on slotId alone left a null gap while the mode-home
+ slot remounted / MutationObserver rebound (mode-switch flicker). */}
+ {desktopHomeComposerActive && desktopHomeComposerHost ? null : renderSearchComposer("default")}
{desktopHomeComposerActive && desktopHomeComposerHost
? createPortal(renderSearchComposer("desktop-home"), desktopHomeComposerHost)
: null}
diff --git a/src/components/clinical-dashboard/shared-search-app-shell.tsx b/src/components/clinical-dashboard/shared-search-app-shell.tsx
index 198c61ede..0f8a41def 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-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts
index 24033c964..ed907e8de 100644
--- a/src/components/clinical-dashboard/use-hide-on-scroll.ts
+++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts
@@ -276,7 +276,20 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa
return () => window.cancelAnimationFrame(frame);
}, [allowAllBreakpoints]);
- 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 };
}
interface UseHideOnScrollOptions {
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 000000000..f3546c4af
--- /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/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx
index 8cc577ba2..2a2018d12 100644
--- a/src/components/forms/forms-home-page.tsx
+++ b/src/components/forms/forms-home-page.tsx
@@ -22,34 +22,38 @@ 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() ?? ""}`,
- },
- {
- title: "Check source status",
- description: "Find records that still need local confirmation.",
- icon: ShieldAlert,
- href: appModeHomeHref("forms", {
- query: "local confirmation required",
- focus: true,
- run: true,
- }),
- },
-];
+// 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[] {
+ return [
+ {
+ 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 ?? ""}`,
+ },
+ {
+ title: "Check source status",
+ description: "Find records that still need local confirmation.",
+ icon: ShieldAlert,
+ href: appModeHomeHref("forms", {
+ query: "local confirmation required",
+ focus: true,
+ run: true,
+ }),
+ },
+ ];
+}
const commonTasks: ModeHomePill[] = [
{
@@ -74,7 +78,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 +116,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 efc0c40ba..6c9eaa295 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/services/services-home-page.tsx b/src/components/services/services-home-page.tsx
index d5d412c08..1f41d1ec2 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 (
= window.location) {
+ const params = new URLSearchParams(location.search);
+ params.delete("scopeRef");
+ const next = params.toString();
+ window.history.replaceState(null, "", `${location.pathname}${next ? `?${next}` : ""}`);
+}
diff --git a/src/lib/search-route-ownership.ts b/src/lib/search-route-ownership.ts
index 1c1ef4892..1f4796759 100644
--- a/src/lib/search-route-ownership.ts
+++ b/src/lib/search-route-ownership.ts
@@ -17,6 +17,33 @@ 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);
+}
+
+/** Dashboard-owned hrefs stay on `/` with `?mode=`; everything else leaves the dashboard shell. */
+export function isDashboardModeHref(href: string): boolean {
+ return href === "/" || href.startsWith("/?");
+}
+
export function shouldRenderDashboardSearch({
hasSubmittedSearch,
mode,
diff --git a/tests/document-detail-performance.test.ts b/tests/document-detail-performance.test.ts
index 265893fc7..c7e598d6b 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/forms-client-boundary.test.ts b/tests/forms-client-boundary.test.ts
new file mode 100644
index 000000000..e46c776d8
--- /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/mode-home-loading-contract.test.ts b/tests/mode-home-loading-contract.test.ts
new file mode 100644
index 000000000..8e8f2eee7
--- /dev/null
+++ b/tests/mode-home-loading-contract.test.ts
@@ -0,0 +1,63 @@
+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("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 522c0cc32..7c9ba0f4d 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 aab3f2147..eec3ce905 100644
--- a/tests/search-route-ownership.test.ts
+++ b/tests/search-route-ownership.test.ts
@@ -1,6 +1,13 @@
+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 {
+ 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 +49,84 @@ 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("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("/services")).toBe(false);
+ expect(isDashboardModeHref("/differentials?q=mania&run=1")).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("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 \}/);
+ expect(shellSource).toContain("resetPhoneScrollHideRef.current()");
+ 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("desktopHomeComposerFallback");
+ expect(headerSource).not.toContain("desktopHomeComposerSlotId && !desktopHomeComposerFallback");
+ expect(headerSource).toMatch(
+ /desktopHomeComposerActive && desktopHomeComposerHost\s*\?\s*null\s*:\s*renderSearchComposer\("default"\)/,
+ );
+ });
+
+ 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 b16ecf8a9..9340788ab 100644
--- a/tests/therapy-compass-mode-wiring.test.ts
+++ b/tests/therapy-compass-mode-wiring.test.ts
@@ -132,6 +132,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 495289765..c39377efa 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",