From bb93feac5d5ea6c5ec95c497c86951b3da3dfff9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 19:47:24 +0000 Subject: [PATCH 01/12] fix: stop choppy screen resize when switching modes Mode switches animated phone composer reserve because searchMode updated before the pathname landed, briefly leaving isStandaloneModeHome false and running the 200ms padding transition. Detect mode homes from pathname only, navigate without optimistic mode state, and limit padding transitions to scroll-hide. Co-authored-by: BigSimmo --- docs/search-chrome-behaviour.md | 2 + src/app/globals.css | 19 ++----- src/components/ClinicalDashboard.tsx | 12 ++++- .../global-search-shell.tsx | 29 +++++------ src/lib/search-route-ownership.ts | 27 ++++++++++ tests/search-route-ownership.test.ts | 51 ++++++++++++++++++- tests/ui-phone-scroll.spec.ts | 2 +- 7 files changed, 107 insertions(+), 35 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 54835a095..fb168643a 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -22,6 +22,8 @@ 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. ## Change checklist 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 b8bb4a620..d73bd6af7 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -158,6 +158,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, @@ -2588,6 +2589,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(""); @@ -2607,7 +2617,7 @@ export function ClinicalDashboard({ setSourceGovernanceWarnings([]); setDocumentMatches([]); setSearchMode(mode); - router.push(appModeHomeHref(mode, { queryMode, scopeFilters })); + router.push(href); } function focusComposerInput() { diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 4a1461679..bdcc692ca 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -50,7 +50,11 @@ 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"; @@ -328,19 +332,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 +500,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 +530,6 @@ function GlobalStandaloneSearchShellClient({ } setQuery(crossQuery); setCommandScopes([]); - setSearchMode(mode); setMobileMenuOpen(false); navigateToMode(mode, { query: crossQuery, focus: true, run: true }); } 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/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index aab3f2147..9804db81b 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,46 @@ 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\);/, + ); + }); }); 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", From 54d45f687e3723f51fa3d7e9940692c9a6e3b52c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 19:49:34 +0000 Subject: [PATCH 02/12] test: align therapy-compass wiring with pathname mode-home gate Co-authored-by: BigSimmo --- tests/therapy-compass-mode-wiring.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/therapy-compass-mode-wiring.test.ts b/tests/therapy-compass-mode-wiring.test.ts index a7277a6c5..43878eb49 100644 --- a/tests/therapy-compass-mode-wiring.test.ts +++ b/tests/therapy-compass-mode-wiring.test.ts @@ -122,6 +122,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"'); }); }); From 5b7b0340c9ee02433052155db9bfe23aa39fa8ed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:41:24 +0000 Subject: [PATCH 03/12] docs(ledger): record mode-switch lag same-class bug hunt Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 01d05a904..b057a10f6 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -778,3 +778,4 @@ 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. | From b9484396347defaaa934571604b9d165ae6d8b98 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:44:06 +0000 Subject: [PATCH 04/12] fix: close same-class mode-switch layout thrash bugs After the reserve-flip fix, related choppiness remained from eager crossModeSearch chrome updates, inherited phone scroll/hide across mode homes, a hero-portal null gap while slots rebound, a taller mode-home loading skeleton, and services/forms contentAlign jumping after registry load. Navigate out of the dashboard without rewriting chrome, reset scroll-hide on pathname change, keep the default composer until the portal attaches, align the skeleton to the shell header token, and keep loading homes top-aligned on phone. Co-authored-by: BigSimmo --- docs/search-chrome-behaviour.md | 3 ++ src/components/ClinicalDashboard.tsx | 24 +++++++++++- .../global-search-shell.tsx | 12 +++++- .../master-search-header.tsx | 8 ++-- .../clinical-dashboard/use-hide-on-scroll.ts | 15 +++++++- src/components/forms/forms-home-page.tsx | 6 +-- src/components/mode-home-page-skeleton.tsx | 5 ++- .../services/services-home-page.tsx | 6 +-- tests/mode-home-main-align.test.ts | 8 ++-- tests/search-route-ownership.test.ts | 37 +++++++++++++++++++ 10 files changed, 107 insertions(+), 17 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index fb168643a..842ec5e8a 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -24,6 +24,9 @@ This repo uses one shared search experience across the global shell, dashboard r 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. ## Change checklist diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index d73bd6af7..ccbc2d2a1 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -2298,6 +2298,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 +2334,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) { @@ -2618,6 +2635,11 @@ export function ClinicalDashboard({ setDocumentMatches([]); setSearchMode(mode); 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() { diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index bdcc692ca..4e59ab25c 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -252,10 +252,20 @@ 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); 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(); + setBottomComposerHidden(false); + 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; diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 99ce5174c..a9648fb98 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1896,10 +1896,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/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index fe0fd5a2c..2ce8d26ee 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -277,7 +277,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/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index 8cc577ba2..36584e6ba 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -111,9 +111,9 @@ export function FormsHomePage() { return ( + // Match the live shell canvas (header token + idle content pad), not a + // larger 13.5rem chrome budget — that taller skeleton jumped when the + // real mode home mounted inside GlobalSearchShell. +
); 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 ( { 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 9804db81b..20511d531 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -91,4 +91,41 @@ describe("shared-search route ownership", () => { /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("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;/, + ); + }); }); From a32c1a2d704f3aac3fcf65cc09043638ba65207f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:44:19 +0000 Subject: [PATCH 05/12] docs(ledger): record mode-switch thrash review fixes Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index b057a10f6..8b14aa9d1 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -779,3 +779,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 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. | From 775d15adef8155aba68e43f0e9354adf60f1ea8d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:45:10 +0000 Subject: [PATCH 06/12] fix: satisfy lint for mode-switch scroll and portal cleanup Reset bottom-composer hidden state during render on pathname change instead of setState-in-effect, and drop the unused hero-portal fallback flag now that the default composer stays mounted until the host attaches. Co-authored-by: BigSimmo --- .../global-search-shell.tsx | 8 ++++++- .../master-search-header.tsx | 21 ++++++------------- tests/search-route-ownership.test.ts | 1 + 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 4e59ab25c..a4c50cf51 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -254,6 +254,13 @@ function GlobalStandaloneSearchShellClient({ 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; resetPhoneScrollHideRef.current = phoneScrollHide.reset; @@ -262,7 +269,6 @@ function GlobalStandaloneSearchShellClient({ // the route changes so /services → /dsm does not open mid-page with a hidden header. useEffect(() => { resetPhoneScrollHideRef.current(); - setBottomComposerHidden(false); const main = document.getElementById("main-content"); if (main instanceof HTMLElement) main.scrollTop = 0; }, [pathname]); diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index a9648fb98..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]); diff --git a/tests/search-route-ownership.test.ts b/tests/search-route-ownership.test.ts index 20511d531..eec3ce905 100644 --- a/tests/search-route-ownership.test.ts +++ b/tests/search-route-ownership.test.ts @@ -112,6 +112,7 @@ describe("shared-search route ownership", () => { 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"\)/, From 0ef62ff521fb9f0457f685e9d2f50a24d640e663 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:46:34 +0000 Subject: [PATCH 07/12] docs(ledger): record mode-switch thrash lint closeout Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 8b14aa9d1..a7db558e8 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -780,3 +780,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 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. | From 7e80c2b8d23e8cdb88d16f0f2528e7db10e7b9df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:56:17 +0000 Subject: [PATCH 08/12] docs(ledger): record frontend-architecture loading/nav review Append review of mode/page loading and navigation architecture at HEAD 0ef62ff5: P1 shell bundle + hydration blanking; residual remount/tools dual. Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index a7db558e8..32ba3e061 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -781,3 +781,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 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. | From 876d7ecfa1ae8ec79fc0f0bdf1198c6640ad89b8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 14:16:44 +0000 Subject: [PATCH 09/12] fix: reduce mode/page loading blanking and layout rework Parallel loading review found hard-load blanking from ClientHydrationBoundary, mismatched/missing mode-home loading skeletons, forms catalog in the client chunk, ClinicalDashboard static weight on namespaced routes, sidebar column animation on remount, forms query remounts, and document viewer remounts on page flips. Paint RSC children immediately, align ModeHomeRouteLoading, wire mode-home loading.tsx files, server-pass the default form slug, dynamic-import ClinicalDashboard, gate sidebar transitions after mount, and stop unnecessary remount keys. Co-authored-by: BigSimmo --- docs/search-chrome-behaviour.md | 3 + .../(search-app)/differentials/loading.tsx | 25 +--- src/app/(search-app)/documents/[id]/page.tsx | 4 +- src/app/(search-app)/dsm/loading.tsx | 17 +-- src/app/(search-app)/factsheets/loading.tsx | 5 + src/app/(search-app)/favourites/loading.tsx | 24 +-- src/app/(search-app)/forms/loading.tsx | 20 +-- src/app/(search-app)/forms/page.tsx | 4 +- src/app/(search-app)/formulation/loading.tsx | 5 + src/app/(search-app)/page.tsx | 12 ++ src/app/(search-app)/specifiers/loading.tsx | 5 + .../(search-app)/therapy-compass/loading.tsx | 5 + src/components/ClinicalDashboard.tsx | 8 +- .../global-search-shell.tsx | 27 +++- .../shared-search-app-shell.tsx | 3 +- src/components/forms/forms-home-page.tsx | 57 +++---- .../forms/forms-search-results-page.tsx | 3 +- src/components/mode-home-page-skeleton.tsx | 7 +- tests/forms-client-boundary.test.ts | 139 ++++++++++++++++++ tests/mode-home-loading-contract.test.ts | 63 ++++++++ 20 files changed, 316 insertions(+), 120 deletions(-) create mode 100644 src/app/(search-app)/factsheets/loading.tsx create mode 100644 src/app/(search-app)/formulation/loading.tsx create mode 100644 src/app/(search-app)/specifiers/loading.tsx create mode 100644 src/app/(search-app)/therapy-compass/loading.tsx create mode 100644 tests/forms-client-boundary.test.ts create mode 100644 tests/mode-home-loading-contract.test.ts diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 842ec5e8a..5ea9b6aac 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -27,6 +27,9 @@ This repo uses one shared search experience across the global shell, dashboard r 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/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 ( -
-
-
- -
- - - -
-
- -
- - - - -
-
- Loading library -
- ); + 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 ( -
-
-
- - -
-
- - - - -
-
- Loading services -
- ); + 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/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 03d026867..2747b6f9c 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -508,6 +508,11 @@ export function ClinicalDashboard({ const [settingsOpen, setSettingsOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); + const [sidebarColumnTransitionReady, setSidebarColumnTransitionReady] = useState(false); + useEffect(() => { + const frame = window.requestAnimationFrame(() => setSidebarColumnTransitionReady(true)); + return () => window.cancelAnimationFrame(frame); + }, []); const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false); const documentsDrawerReturnFocusRef = useRef(null); const [documentScopeOpen, setDocumentScopeOpen] = useState(false); @@ -3327,7 +3332,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 a4c50cf51..d39998aac 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"; @@ -37,7 +38,6 @@ import { readChromeCollapseBudget, useScrollHideReporter } from "@/components/cl import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { ClientHydrationBoundary } from "@/components/client-hydration-boundary"; import { cn } from "@/components/ui-primitives"; import { appModeHomeHref, @@ -46,6 +46,13 @@ 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"; @@ -312,6 +319,13 @@ function GlobalStandaloneSearchShellClient({ ); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); + // Skip grid-template-columns transition on first paint / remount so Answer ↔ + // namespaced mode swaps do not animate the sidebar width from the default track. + const [sidebarColumnTransitionReady, setSidebarColumnTransitionReady] = useState(false); + useEffect(() => { + const frame = window.requestAnimationFrame(() => setSidebarColumnTransitionReady(true)); + return () => window.cancelAnimationFrame(frame); + }, []); const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [recentQueries, setRecentQueries] = useState([]); @@ -614,6 +628,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)]"), @@ -788,11 +803,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/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/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index 36584e6ba..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"; diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index 22e43b1c5..1c45e7483 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -608,7 +608,8 @@ function RegistryStatusNotice({ status }: { status: RegistryRequestStatus }) { } export function FormsSearchResultsPage(props: FormsSearchResultsPageProps) { - 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 8fc48e5aa..6c9eaa295 100644 --- a/src/components/mode-home-page-skeleton.tsx +++ b/src/components/mode-home-page-skeleton.tsx @@ -25,10 +25,9 @@ export function ModeHomePageSkeleton() { export function ModeHomeRouteLoading() { return ( - // Match the live shell canvas (header token + idle content pad), not a - // larger 13.5rem chrome budget — that taller skeleton jumped when the - // real mode home mounted inside GlobalSearchShell. -
+ // 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/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); + }); +}); From c64a2f02ca00911087da1c96ea052f50da93a34b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 14:16:44 +0000 Subject: [PATCH 10/12] docs(ledger): record parallel loading behaviour review fixes Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 6119157dd..38ea95eb1 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -803,3 +803,4 @@ 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. | From 905b7a5755d363ed8104a70f626b0aca301b5f43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 14:18:03 +0000 Subject: [PATCH 11/12] refactor: extract sidebar transition hook and private-scope URL helper Keeps ClinicalDashboard inside the maintainability budget after the loading-performance pass, and shares the remount-safe sidebar transition gate with GlobalSearchShell. Co-authored-by: BigSimmo --- scripts/check-maintainability-budgets.mjs | 4 +++- src/components/ClinicalDashboard.tsx | 20 +++++++------------ .../global-search-shell.tsx | 9 ++------- .../use-sidebar-column-transition.ts | 17 ++++++++++++++++ src/lib/private-search-scope.ts | 8 ++++++++ 5 files changed, 37 insertions(+), 21 deletions(-) create mode 100644 src/components/clinical-dashboard/use-sidebar-column-transition.ts 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/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2747b6f9c..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, @@ -169,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"; @@ -508,11 +513,7 @@ export function ClinicalDashboard({ const [settingsOpen, setSettingsOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); - const [sidebarColumnTransitionReady, setSidebarColumnTransitionReady] = useState(false); - useEffect(() => { - const frame = window.requestAnimationFrame(() => setSidebarColumnTransitionReady(true)); - return () => window.cancelAnimationFrame(frame); - }, []); + const sidebarColumnTransitionReady = useSidebarColumnTransitionReady(); const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false); const documentsDrawerReturnFocusRef = useRef(null); const [documentScopeOpen, setDocumentScopeOpen] = useState(false); @@ -3304,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"); diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index d39998aac..2db58e9ce 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -37,6 +37,7 @@ 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 { cn } from "@/components/ui-primitives"; import { @@ -319,13 +320,7 @@ function GlobalStandaloneSearchShellClient({ ); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); - // Skip grid-template-columns transition on first paint / remount so Answer ↔ - // namespaced mode swaps do not animate the sidebar width from the default track. - const [sidebarColumnTransitionReady, setSidebarColumnTransitionReady] = useState(false); - useEffect(() => { - const frame = window.requestAnimationFrame(() => setSidebarColumnTransitionReady(true)); - return () => window.cancelAnimationFrame(frame); - }, []); + const sidebarColumnTransitionReady = useSidebarColumnTransitionReady(); const [guideOpen, setGuideOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [recentQueries, setRecentQueries] = useState([]); 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/lib/private-search-scope.ts b/src/lib/private-search-scope.ts index 9a143eb6f..d2146a245 100644 --- a/src/lib/private-search-scope.ts +++ b/src/lib/private-search-scope.ts @@ -71,3 +71,11 @@ 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}` : ""}`); +} From 7bceebf562dc6700091964996a6e2749b0d63df6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 14:19:49 +0000 Subject: [PATCH 12/12] test: allow DocumentViewer identity-only remount key Co-authored-by: BigSimmo --- tests/document-detail-performance.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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", () => {