From 901c336e0b6a9aec7f78574b82c1e7b1dd29e016 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:50:52 +0800 Subject: [PATCH 01/17] test(mode-nav): define multi-page-only route eligibility --- tests/mode-secondary-navigation.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/mode-secondary-navigation.test.ts b/tests/mode-secondary-navigation.test.ts index 2030994814..35b7f80859 100644 --- a/tests/mode-secondary-navigation.test.ts +++ b/tests/mode-secondary-navigation.test.ts @@ -40,6 +40,14 @@ const cleanLandingPath: Record = { factsheets: "/factsheets", }; +const multiPageModes = new Set([ + "differentials", + "dsm", + "specifiers", + "formulation", + "therapy-compass", +]); + describe("mode secondary navigation registry", () => { it("covers all 13 modes with the approved destinations and no Home item", () => { expect(Object.keys(modeSecondaryNavigationRegistry).sort()).toEqual([...appModeIds].sort()); @@ -52,14 +60,14 @@ describe("mode secondary navigation registry", () => { } }); - it("suppresses clean landing pages but renders after a submitted mode search", () => { + it("suppresses clean landing pages and submitted searches for one-destination modes", () => { for (const modeId of appModeIds) { expect( isModeSecondaryNavigationRoute({ modeId, pathname: cleanLandingPath[modeId], hasSubmittedSearch: false }), ).toBe(false); expect( isModeSecondaryNavigationRoute({ modeId, pathname: cleanLandingPath[modeId], hasSubmittedSearch: true }), - ).toBe(true); + ).toBe(multiPageModes.has(modeId)); } }); @@ -87,7 +95,7 @@ describe("mode secondary navigation registry", () => { ).toBe(false); }); - it("suppresses /documents/search until a query is submitted", () => { + it("never adds a redundant menu to the single-page Documents workflow", () => { expect( isModeSecondaryNavigationRoute({ modeId: "documents", @@ -101,7 +109,7 @@ describe("mode secondary navigation registry", () => { pathname: "/documents/search", hasSubmittedSearch: true, }), - ).toBe(true); + ).toBe(false); }); it("translates compatible workflow selection state into each destination URL", () => { From 4dddce2a2c6ddbcef87b4d61e4a6c771fd8d3e66 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:52:18 +0800 Subject: [PATCH 02/17] test(mode-nav): require shared navigation for every multi-page workflow --- tests/page-secondary-navigation.dom.test.tsx | 70 +++++++++++++++----- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/tests/page-secondary-navigation.dom.test.tsx b/tests/page-secondary-navigation.dom.test.tsx index f46a73de78..20eacc02ae 100644 --- a/tests/page-secondary-navigation.dom.test.tsx +++ b/tests/page-secondary-navigation.dom.test.tsx @@ -2,8 +2,21 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("next/navigation", () => ({ + usePathname: () => "/", +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + import { hasLocalInformationPageNavigation, informationPageSectionDefinitions, @@ -85,26 +98,49 @@ describe("PageSecondaryNavigation", () => { , ); expect(screen.queryByTestId("secondary-navigation")).toBeNull(); + expect(screen.queryByTestId("mode-nav")).toBeNull(); }); - it("renders mode navigation after submission and on explicit workflow routes", () => { - const { rerender } = render( - , - ); - expect(screen.getByRole("button", { name: "Ask" })).toHaveAttribute("aria-current", "page"); + it.each([ + ["answer", "/"], + ["documents", "/documents/search"], + ["services", "/services"], + ["forms", "/forms"], + ["favourites", "/favourites"], + ["prescribing", "/medications"], + ["tools", "/tools"], + ["factsheets", "/factsheets/search"], + ] as const)("keeps the one-destination %s mode free of a redundant menu", (modeId, pathname) => { + render(); - rerender( - , - ); - expect(screen.getByRole("link", { name: "Compare" })).toHaveAttribute("aria-current", "page"); - expect(screen.getByRole("link", { name: "Build" })).toHaveAttribute("href", "/specifiers/builder"); + expect(screen.queryByTestId("secondary-navigation")).toBeNull(); + expect(screen.queryByTestId("mode-nav")).toBeNull(); }); + it.each([ + ["differentials", "/differentials/diagnoses", "Differentials pages", "Diagnoses", "/differentials/diagnoses"], + ["dsm", "/dsm/compare", "DSM-5 Diagnosis pages", "Compare", "/dsm/compare"], + ["specifiers", "/specifiers/builder", "Specifiers pages", "Build", "/specifiers/builder"], + ["formulation", "/formulation/map", "Formulation pages", "Map", "/formulation/map"], + ] as const)( + "renders the %s workflow as the shared header-integrated mode nav", + (modeId, pathname, ariaLabel, activeLabel, activeHref) => { + render( + , + ); + + expect(screen.getByRole("navigation", { name: ariaLabel })).toHaveAttribute("data-testid", "mode-nav"); + expect(screen.getByRole("link", { name: activeLabel })).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("link", { name: activeLabel })).toHaveAttribute("href", activeHref); + expect(screen.queryByTestId("secondary-navigation")).toBeNull(); + }, + ); + it("replaces mode navigation with only the information sections present in the record", async () => { render(
@@ -125,6 +161,7 @@ describe("PageSecondaryNavigation", () => { expect(screen.getByRole("link", { name: "Criteria" })).toHaveAttribute("href", "#service-criteria"); expect(screen.queryByRole("link", { name: "Quick facts" })).toBeNull(); expect(screen.queryByRole("button", { name: "Search" })).toBeNull(); + expect(screen.queryByTestId("mode-nav")).toBeNull(); }); it("leaves locally controlled information and Therapy workflow navigation to their page owners", async () => { @@ -137,6 +174,7 @@ describe("PageSecondaryNavigation", () => { />, ); await waitFor(() => expect(screen.queryByTestId("secondary-navigation")).toBeNull()); + expect(screen.queryByTestId("mode-nav")).toBeNull(); rerender( { />, ); expect(screen.queryByTestId("secondary-navigation")).toBeNull(); + expect(screen.queryByTestId("mode-nav")).toBeNull(); rerender( { />, ); expect(screen.queryByTestId("secondary-navigation")).toBeNull(); + expect(screen.queryByTestId("mode-nav")).toBeNull(); }); }); From 2e91af974941015ca589746f4c13d30e02139c05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:05:47 +0000 Subject: [PATCH 03/17] fix: apply Prettier formatting to failing test files Co-authored-by: BigSimmo <87357024+BigSimmo@users.noreply.github.com> --- tests/mode-secondary-navigation.test.ts | 8 +------- tests/page-secondary-navigation.dom.test.tsx | 7 +------ 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/tests/mode-secondary-navigation.test.ts b/tests/mode-secondary-navigation.test.ts index 35b7f80859..a0c0524d9d 100644 --- a/tests/mode-secondary-navigation.test.ts +++ b/tests/mode-secondary-navigation.test.ts @@ -40,13 +40,7 @@ const cleanLandingPath: Record = { factsheets: "/factsheets", }; -const multiPageModes = new Set([ - "differentials", - "dsm", - "specifiers", - "formulation", - "therapy-compass", -]); +const multiPageModes = new Set(["differentials", "dsm", "specifiers", "formulation", "therapy-compass"]); describe("mode secondary navigation registry", () => { it("covers all 13 modes with the approved destinations and no Home item", () => { diff --git a/tests/page-secondary-navigation.dom.test.tsx b/tests/page-secondary-navigation.dom.test.tsx index 20eacc02ae..6814e7a9b2 100644 --- a/tests/page-secondary-navigation.dom.test.tsx +++ b/tests/page-secondary-navigation.dom.test.tsx @@ -126,12 +126,7 @@ describe("PageSecondaryNavigation", () => { "renders the %s workflow as the shared header-integrated mode nav", (modeId, pathname, ariaLabel, activeLabel, activeHref) => { render( - , + , ); expect(screen.getByRole("navigation", { name: ariaLabel })).toHaveAttribute("data-testid", "mode-nav"); From e135e458bec7b1de332601f530b4ef00d660a263 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:07:58 +0800 Subject: [PATCH 04/17] feat(mode-nav): add registry-backed header navigation adapter --- src/components/mode-nav/registry-mode-nav.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/components/mode-nav/registry-mode-nav.tsx diff --git a/src/components/mode-nav/registry-mode-nav.tsx b/src/components/mode-nav/registry-mode-nav.tsx new file mode 100644 index 0000000000..d14d8d0fa5 --- /dev/null +++ b/src/components/mode-nav/registry-mode-nav.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { FileText, GitCompareArrows, ListChecks, Network, Search, type LucideIcon } from "lucide-react"; + +import { ModeNav, type ModeNavItem } from "@/components/mode-nav/mode-nav"; +import { appModeDefinition, type AppModeId } from "@/lib/app-modes"; +import { + modeSecondaryNavigationEntries, + modeSecondaryNavigationHref, +} from "@/lib/mode-secondary-navigation"; + +const iconByItemId: Record = { + search: Search, + diagnoses: FileText, + compare: GitCompareArrows, + builder: ListChecks, + map: Network, +}; + +/** + * Adapts the canonical route registry to the universal header-integrated mode + * navigation. Keeping this mapping in one component prevents the page shell, + * Specifiers, and Formulation from drifting onto different labels or URLs. + */ +export function RegistryModeNav({ + modeId, + activeId, + searchParamString = "", +}: { + modeId: AppModeId; + activeId: string; + searchParamString?: string; +}) { + const currentSearchParams = new URLSearchParams(searchParamString); + const items = modeSecondaryNavigationEntries(modeId).flatMap((entry) => { + if (!entry.href) return []; + return [ + { + id: entry.id, + label: entry.label, + href: modeSecondaryNavigationHref({ + modeId, + itemId: entry.id, + href: entry.href, + currentSearchParams, + }), + icon: iconByItemId[entry.id] ?? FileText, + }, + ]; + }); + + return ; +} From d730792b52d1d522e743381fdaa88f84bf82c2a9 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:09:08 +0800 Subject: [PATCH 05/17] feat(mode-nav): suppress single-page menus and include multi-page homes --- src/lib/mode-secondary-navigation.ts | 41 ++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/lib/mode-secondary-navigation.ts b/src/lib/mode-secondary-navigation.ts index 13c04bc32f..2a1cb10242 100644 --- a/src/lib/mode-secondary-navigation.ts +++ b/src/lib/mode-secondary-navigation.ts @@ -91,25 +91,42 @@ export function isModeSecondaryNavigationRoute(params: { hasSubmittedSearch: boolean; }): boolean { const { modeId, pathname, hasSubmittedSearch } = params; + + // A one-destination mode has no meaningful secondary choice. Suppress it + // even after search submission rather than rendering a redundant one-item + // strip beneath the universal header. + if (modeSecondaryNavigationRegistry[modeId].length < 2) return false; if (hasSubmittedSearch) return true; - // /documents/search is the documents mode home (composer already visible); do - // not add a lone Search focus control until a query has been submitted. - if (modeId === "documents") return false; if (modeId === "differentials") { - return pathname === "/differentials/diagnoses" || pathname === "/differentials/presentations"; + return ( + pathname === "/differentials" || + pathname === "/differentials/diagnoses" || + pathname === "/differentials/presentations" + ); + } + if (modeId === "dsm") { + return pathname === "/dsm" || pathname === "/dsm/search" || pathname === "/dsm/compare"; } - if (modeId === "dsm") return pathname === "/dsm/search" || pathname === "/dsm/compare"; if (modeId === "specifiers") { - return pathname === "/specifiers/builder" || pathname === "/specifiers/compare" || pathname === "/specifiers/map"; + return ( + pathname === "/specifiers" || + pathname === "/specifiers/builder" || + pathname === "/specifiers/compare" || + pathname === "/specifiers/map" + ); } if (modeId === "formulation") { return ( - pathname === "/formulation/builder" || pathname === "/formulation/compare" || pathname === "/formulation/map" + pathname === "/formulation" || + pathname === "/formulation/builder" || + pathname === "/formulation/compare" || + pathname === "/formulation/map" ); } - if (modeId === "factsheets") return pathname === "/factsheets/search"; - if (modeId === "therapy-compass") return pathname !== "/therapy-compass"; + if (modeId === "therapy-compass") { + return pathname === "/therapy-compass" || pathname.startsWith("/therapy-compass/"); + } return false; } @@ -172,11 +189,12 @@ export function modeSecondaryNavigationHref(params: { currentSearchParams.get("b"), currentSearchParams.get("selected"), ]); - if (itemId === "builder") + if (itemId === "builder") { return navigationHrefWithParams( href, selections.map((value) => ["specifier", value] as const), ); + } if (itemId === "compare") { return navigationHrefWithParams( href, @@ -203,11 +221,12 @@ export function modeSecondaryNavigationHref(params: { ]); const template = currentSearchParams.get("template"); const templateEntry: Array = template ? [["template", template]] : []; - if (itemId === "builder") + if (itemId === "builder") { return navigationHrefWithParams(href, [ ...selections.map((value) => ["mechanism", value] as const), ...templateEntry, ]); + } if (itemId === "compare") { return navigationHrefWithParams(href, [ ...selections.slice(0, 2).map((value, index) => [index === 0 ? "a" : "b", value] as const), From a8b4e106a3d5f7a8b3b5a8cf5c59a6373018d86b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:10:10 +0800 Subject: [PATCH 06/17] feat(mode-nav): render route-level menus through the shared header nav --- src/components/page-secondary-navigation.tsx | 60 ++++---------------- 1 file changed, 11 insertions(+), 49 deletions(-) diff --git a/src/components/page-secondary-navigation.tsx b/src/components/page-secondary-navigation.tsx index 54bc1115ea..e498a20d13 100644 --- a/src/components/page-secondary-navigation.tsx +++ b/src/components/page-secondary-navigation.tsx @@ -1,20 +1,18 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { isDocumentViewerOwnedRoute } from "@/components/clinical-dashboard/mobile-composer-reserve"; +import { RegistryModeNav } from "@/components/mode-nav/registry-mode-nav"; import { SecondaryNavigation, - type SecondaryNavigationItem, type SecondaryNavigationSectionItem, } from "@/components/secondary-navigation"; -import { appModeDefinition, type AppModeId } from "@/lib/app-modes"; +import type { AppModeId } from "@/lib/app-modes"; import { isInformationPage } from "@/lib/information-pages"; import { activeModeSecondaryNavigationId, isModeSecondaryNavigationRoute, - modeSecondaryNavigationEntries, - modeSecondaryNavigationHref, } from "@/lib/mode-secondary-navigation"; export type InformationPageSectionDefinition = { @@ -152,15 +150,19 @@ export function informationPageSectionDefinitions(pathname: string): readonly In if ( pathname.startsWith("/specifiers/") && !["/specifiers/builder", "/specifiers/compare", "/specifiers/map"].includes(pathname) - ) + ) { return specifierSections; + } if ( pathname.startsWith("/formulation/") && !["/formulation/builder", "/formulation/compare", "/formulation/map"].includes(pathname) - ) + ) { return formulationSections; + } if (pathname.startsWith("/differentials/presentations/")) return differentialPresentationSections; - if (pathname.endsWith("/differentials") && pathname.startsWith("/dsm/diagnoses/")) return dsmDifferentialSections; + if (pathname.endsWith("/differentials") && pathname.startsWith("/dsm/diagnoses/")) { + return dsmDifferentialSections; + } if (pathname.startsWith("/dsm/diagnoses/")) return dsmDiagnosisSections; if (pathname.startsWith("/documents/") && pathname !== "/documents/search") return documentSections; return []; @@ -248,7 +250,6 @@ export function PageSecondaryNavigation({ modeId, pathname, hasSubmittedSearch, - onSearch, /** * Bridged query string from GlobalStandaloneSearchShellBody. Must not call * useSearchParams here — that reintroduces a nested Suspense boundary under @@ -256,7 +257,6 @@ export function PageSecondaryNavigation({ */ searchParamString = "", sticky = true, - stickyTop, }: { modeId: AppModeId; pathname: string; @@ -269,36 +269,6 @@ export function PageSecondaryNavigation({ const informationDefinitions = informationPageSectionDefinitions(pathname); const locallyOwnedInformationNavigation = hasLocalInformationPageNavigation(pathname); const activeId = activeModeSecondaryNavigationId(modeId, pathname); - const modeLabel = appModeDefinition(modeId).label; - const modeAriaLabel = modeLabel.toLowerCase().endsWith("mode") ? modeLabel : `${modeLabel} mode`; - const modeItems = useMemo( - () => - modeSecondaryNavigationEntries(modeId).map((entry) => - entry.href - ? { - kind: "route" as const, - id: entry.id, - label: entry.label, - shortLabel: entry.shortLabel, - href: modeSecondaryNavigationHref({ - modeId, - itemId: entry.id, - href: entry.href, - currentSearchParams: new URLSearchParams(searchParamString), - }), - current: entry.id === activeId, - } - : { - kind: "action" as const, - id: entry.id, - label: entry.label, - shortLabel: entry.shortLabel, - onSelect: onSearch, - current: entry.id === activeId, - }, - ), - [activeId, modeId, onSearch, searchParamString], - ); // Therapy Compass owns both its workflow bindings and its dynamic detail // sections inside TcProvider; rendering the shell registry as well would @@ -309,13 +279,5 @@ export function PageSecondaryNavigation({ return ; } if (!isModeSecondaryNavigationRoute({ modeId, pathname, hasSubmittedSearch })) return null; - return ( - - ); + return ; } From 7fdda1c5a158d5161f82f3a0e04875c3055abb81 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:10:37 +0800 Subject: [PATCH 07/17] feat(mode-nav): move Formulation workflow navigation into the header --- src/components/formulation/formulation-ui.tsx | 33 ++----------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/src/components/formulation/formulation-ui.tsx b/src/components/formulation/formulation-ui.tsx index 9a48393103..4700e5c9cc 100644 --- a/src/components/formulation/formulation-ui.tsx +++ b/src/components/formulation/formulation-ui.tsx @@ -1,8 +1,8 @@ -import Link from "next/link"; import type { ReactNode } from "react"; import { Info, Network, ShieldCheck } from "lucide-react"; import { InformationPageBreadcrumbs, InformationPageShell } from "@/components/information-page-shell"; +import { RegistryModeNav } from "@/components/mode-nav/registry-mode-nav"; import { cn, eyebrowText } from "@/components/ui-primitives"; export const formulationCard = @@ -17,36 +17,7 @@ export function FormulationBreadcrumbs({ current }: { current?: string }) { } export function FormulationSubnav({ active }: { active: "search" | "builder" | "compare" | "map" }) { - const items = [ - { id: "search" as const, label: "Find mechanisms", shortLabel: "Find", href: "/formulation" }, - { id: "builder" as const, label: "Build formulation", shortLabel: "Build", href: "/formulation/builder" }, - { id: "compare" as const, label: "Compare", shortLabel: "Compare", href: "/formulation/compare" }, - { id: "map" as const, label: "Mechanism map", shortLabel: "Map", href: "/formulation/map" }, - ]; - - return ( - - ); + return ; } export function MechanismDomainChips({ values, limit }: { values: string[]; limit?: number }) { From 50a1bdb3f3076b7cecc0d7caf05c62cdef87c049 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:12:02 +0800 Subject: [PATCH 08/17] feat(mode-nav): move Specifiers workflow navigation into the header --- src/components/specifiers/specifier-ui.tsx | 37 ++-------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/src/components/specifiers/specifier-ui.tsx b/src/components/specifiers/specifier-ui.tsx index ea26732919..1dfc832c5d 100644 --- a/src/components/specifiers/specifier-ui.tsx +++ b/src/components/specifiers/specifier-ui.tsx @@ -3,6 +3,7 @@ import type { ComponentType, CSSProperties, ReactNode } from "react"; import { ArrowRight, CheckCircle2, ChevronsUpDown, Info, Minus, ShieldAlert, Tags } from "lucide-react"; import { InformationPageBreadcrumbs, InformationPageShell } from "@/components/information-page-shell"; +import { RegistryModeNav } from "@/components/mode-nav/registry-mode-nav"; import { cn, eyebrowText } from "@/components/ui-primitives"; import type { SpecifierFamily, SpecifierRecord } from "@/lib/specifiers"; import { specifierFamilies } from "@/lib/specifiers"; @@ -20,41 +21,7 @@ export function SpecifierBreadcrumbs({ current }: { current?: string }) { } export function SpecifierSubnav({ active }: { active: "search" | "builder" | "compare" | "map" }) { - const items = [ - { id: "search" as const, label: "Find", shortLabel: "Find", href: "/specifiers" }, - { id: "builder" as const, label: "Build wording", shortLabel: "Build", href: "/specifiers/builder" }, - { id: "compare" as const, label: "Compare", shortLabel: "Compare", href: "/specifiers/compare" }, - { id: "map" as const, label: "Map", shortLabel: "Map", href: "/specifiers/map" }, - ]; - - return ( - - ); + return ; } const familyChipBase = From c956fc3a737993c727621ba6304a05f07053a1b7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:13:05 +0800 Subject: [PATCH 09/17] test(mode-nav): align clean-home coverage with shared navigation --- tests/mode-secondary-navigation.test.ts | 36 ++++++++++++++++++++----- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/mode-secondary-navigation.test.ts b/tests/mode-secondary-navigation.test.ts index a0c0524d9d..cda5fc2bd3 100644 --- a/tests/mode-secondary-navigation.test.ts +++ b/tests/mode-secondary-navigation.test.ts @@ -20,7 +20,14 @@ const expectedLabels: Record = { formulation: ["Find", "Build", "Compare", "Map"], prescribing: ["Search"], tools: ["Search"], - "therapy-compass": ["Search", "Recommend", "Compare", "Pathways", "Brief Intervention", "Patient Sheets"], + "therapy-compass": [ + "Search", + "Recommend", + "Compare", + "Pathways", + "Brief Intervention", + "Patient Sheets", + ], factsheets: ["Search"], }; @@ -40,7 +47,13 @@ const cleanLandingPath: Record = { factsheets: "/factsheets", }; -const multiPageModes = new Set(["differentials", "dsm", "specifiers", "formulation", "therapy-compass"]); +const multiPageModes = new Set([ + "differentials", + "dsm", + "specifiers", + "formulation", + "therapy-compass", +]); describe("mode secondary navigation registry", () => { it("covers all 13 modes with the approved destinations and no Home item", () => { @@ -54,14 +67,23 @@ describe("mode secondary navigation registry", () => { } }); - it("suppresses clean landing pages and submitted searches for one-destination modes", () => { + it("shows clean homes only for multi-page modes and suppresses every one-destination mode", () => { for (const modeId of appModeIds) { + const expected = multiPageModes.has(modeId); expect( - isModeSecondaryNavigationRoute({ modeId, pathname: cleanLandingPath[modeId], hasSubmittedSearch: false }), - ).toBe(false); + isModeSecondaryNavigationRoute({ + modeId, + pathname: cleanLandingPath[modeId], + hasSubmittedSearch: false, + }), + ).toBe(expected); expect( - isModeSecondaryNavigationRoute({ modeId, pathname: cleanLandingPath[modeId], hasSubmittedSearch: true }), - ).toBe(multiPageModes.has(modeId)); + isModeSecondaryNavigationRoute({ + modeId, + pathname: cleanLandingPath[modeId], + hasSubmittedSearch: true, + }), + ).toBe(expected); } }); From 075f0f5f71e410f2ed03fd32853a9acf45e7089d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:14:05 +0800 Subject: [PATCH 10/17] test(mode-nav): cover clean homes and format shared navigation tests --- tests/page-secondary-navigation.dom.test.tsx | 37 ++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/tests/page-secondary-navigation.dom.test.tsx b/tests/page-secondary-navigation.dom.test.tsx index 6814e7a9b2..1ebd92892f 100644 --- a/tests/page-secondary-navigation.dom.test.tsx +++ b/tests/page-secondary-navigation.dom.test.tsx @@ -34,7 +34,14 @@ describe("PageSecondaryNavigation", () => { ["/services/community-team", ["Overview", "Quick facts", "Referral", "Criteria", "Verification"]], [ "/forms/form-1", - ["Overview", "Decision context", "Priority facts", "Legal boundary", "Form information", "Source / verification"], + [ + "Overview", + "Decision context", + "Priority facts", + "Legal boundary", + "Form information", + "Source / verification", + ], ], ["/specifiers/with-anxious-distress", ["Overview", "Fit & exclusions", "Wording / coding", "Evidence / source"]], [ @@ -85,7 +92,10 @@ describe("PageSecondaryNavigation", () => { }); it("binds service section targets to IDs rendered by service-detail-page", () => { - const servicePage = readFileSync(join(process.cwd(), "src/components/services/service-detail-page.tsx"), "utf8"); + const servicePage = readFileSync( + join(process.cwd(), "src/components/services/service-detail-page.tsx"), + "utf8", + ); for (const targetId of informationPageSectionDefinitions("/services/community-team").flatMap( (section) => section.targetIds, )) { @@ -93,7 +103,7 @@ describe("PageSecondaryNavigation", () => { } }); - it("does not add a navigation row to a clean no-query landing page", () => { + it("does not add a navigation row to a clean one-destination landing page", () => { render( , ); @@ -111,14 +121,24 @@ describe("PageSecondaryNavigation", () => { ["tools", "/tools"], ["factsheets", "/factsheets/search"], ] as const)("keeps the one-destination %s mode free of a redundant menu", (modeId, pathname) => { - render(); + render( + , + ); expect(screen.queryByTestId("secondary-navigation")).toBeNull(); expect(screen.queryByTestId("mode-nav")).toBeNull(); }); it.each([ - ["differentials", "/differentials/diagnoses", "Differentials pages", "Diagnoses", "/differentials/diagnoses"], + ["differentials", "/differentials", "Differentials pages", "Search", "/differentials?focus=1"], + [ + "differentials", + "/differentials/diagnoses", + "Differentials pages", + "Diagnoses", + "/differentials/diagnoses", + ], + ["dsm", "/dsm", "DSM-5 Diagnosis pages", "Search", "/dsm?focus=1"], ["dsm", "/dsm/compare", "DSM-5 Diagnosis pages", "Compare", "/dsm/compare"], ["specifiers", "/specifiers/builder", "Specifiers pages", "Build", "/specifiers/builder"], ["formulation", "/formulation/map", "Formulation pages", "Map", "/formulation/map"], @@ -126,7 +146,12 @@ describe("PageSecondaryNavigation", () => { "renders the %s workflow as the shared header-integrated mode nav", (modeId, pathname, ariaLabel, activeLabel, activeHref) => { render( - , + , ); expect(screen.getByRole("navigation", { name: ariaLabel })).toHaveAttribute("data-testid", "mode-nav"); From fe56d989455ef484566a0cc73c868f0b478a8a87 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:14:41 +0800 Subject: [PATCH 11/17] docs(mode-nav): update shared header slot ownership contract --- src/components/mode-nav/mode-nav-portal.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/mode-nav/mode-nav-portal.tsx b/src/components/mode-nav/mode-nav-portal.tsx index 63913ca14b..1484ecb5d2 100644 --- a/src/components/mode-nav/mode-nav-portal.tsx +++ b/src/components/mode-nav/mode-nav-portal.tsx @@ -25,11 +25,10 @@ import { phoneHeaderCollapseAddonSlotId } from "@/lib/mode-home-composer"; * Falls back to normal flow when no host exists — routes rendered without the * universal header, and the server pass — so navigation is never lost. * - * Slot ownership: the addon slot holds ONE page-owned header. `DocumentViewer` - * and the differentials detail page already claim it on phones, so a mode whose - * routes include those pages must not also mount a bar there. `ModeNav` renders - * nothing below two destinations, which is what keeps those modes clear today; - * `tests/mode-nav-contract.test.ts` fails if that stops being true. + * Slot ownership: the addon slot holds ONE page-owned header. Document and + * detail-page owners claim it only on routes where the shared shell suppresses + * ModeNav first; one-destination modes also render nothing below the two-item + * minimum. Contract and route-level tests fail if those ownership gates drift. */ export function ModeNavHeaderPortal({ children }: { children: ReactNode }) { const [host, setHost] = useState(null); From 43a8318b0db01cb891617c65ee0d29853bc0e9af Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:16:18 +0800 Subject: [PATCH 12/17] test(mode-nav): exercise real workflow owners and forward Link refs --- tests/page-secondary-navigation.dom.test.tsx | 42 ++++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/tests/page-secondary-navigation.dom.test.tsx b/tests/page-secondary-navigation.dom.test.tsx index 1ebd92892f..1cd13950c1 100644 --- a/tests/page-secondary-navigation.dom.test.tsx +++ b/tests/page-secondary-navigation.dom.test.tsx @@ -2,26 +2,36 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { render, screen, waitFor } from "@testing-library/react"; -import type { ReactNode } from "react"; +import type { AnchorHTMLAttributes, ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("next/navigation", () => ({ usePathname: () => "/", })); -vi.mock("next/link", () => ({ - default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( - +type MockLinkProps = AnchorHTMLAttributes & { + children: ReactNode; + href: string; +}; + +vi.mock("next/link", async () => { + const { forwardRef } = await import("react"); + const MockLink = forwardRef(({ children, href, ...rest }, ref) => ( + {children} - ), -})); + )); + MockLink.displayName = "MockNextLink"; + return { __esModule: true, default: MockLink }; +}); +import { FormulationSubnav } from "@/components/formulation/formulation-ui"; import { hasLocalInformationPageNavigation, informationPageSectionDefinitions, PageSecondaryNavigation, } from "@/components/page-secondary-navigation"; +import { SpecifierSubnav } from "@/components/specifiers/specifier-ui"; describe("PageSecondaryNavigation", () => { beforeEach(() => { @@ -140,8 +150,6 @@ describe("PageSecondaryNavigation", () => { ], ["dsm", "/dsm", "DSM-5 Diagnosis pages", "Search", "/dsm?focus=1"], ["dsm", "/dsm/compare", "DSM-5 Diagnosis pages", "Compare", "/dsm/compare"], - ["specifiers", "/specifiers/builder", "Specifiers pages", "Build", "/specifiers/builder"], - ["formulation", "/formulation/map", "Formulation pages", "Map", "/formulation/map"], ] as const)( "renders the %s workflow as the shared header-integrated mode nav", (modeId, pathname, ariaLabel, activeLabel, activeHref) => { @@ -161,6 +169,24 @@ describe("PageSecondaryNavigation", () => { }, ); + it.each([ + ["specifiers", "Build", "/specifiers/builder"], + ["formulation", "Map", "/formulation/map"], + ] as const)("renders the real %s workflow owner through ModeNav", (modeId, activeLabel, activeHref) => { + render( + modeId === "specifiers" ? : , + ); + + expect(screen.getByTestId("mode-nav")).toHaveAttribute( + "aria-label", + modeId === "specifiers" ? "Specifiers pages" : "Formulation pages", + ); + expect(screen.getByRole("link", { name: activeLabel })).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("link", { name: activeLabel })).toHaveAttribute("href", activeHref); + expect(screen.queryByRole("navigation", { name: /tools/i })).toBeNull(); + expect(screen.queryByTestId("secondary-navigation")).toBeNull(); + }); + it("replaces mode navigation with only the information sections present in the record", async () => { render(
From 2a71d46ca639e5fbd50579f2b7e285ee3b6f676c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:33:40 +0800 Subject: [PATCH 13/17] style(mode-nav): apply repository formatting --- src/components/mode-nav/registry-mode-nav.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/mode-nav/registry-mode-nav.tsx b/src/components/mode-nav/registry-mode-nav.tsx index d14d8d0fa5..4b4ab12675 100644 --- a/src/components/mode-nav/registry-mode-nav.tsx +++ b/src/components/mode-nav/registry-mode-nav.tsx @@ -4,10 +4,7 @@ import { FileText, GitCompareArrows, ListChecks, Network, Search, type LucideIco import { ModeNav, type ModeNavItem } from "@/components/mode-nav/mode-nav"; import { appModeDefinition, type AppModeId } from "@/lib/app-modes"; -import { - modeSecondaryNavigationEntries, - modeSecondaryNavigationHref, -} from "@/lib/mode-secondary-navigation"; +import { modeSecondaryNavigationEntries, modeSecondaryNavigationHref } from "@/lib/mode-secondary-navigation"; const iconByItemId: Record = { search: Search, From af3a89d1013ce1ced8f4454994ace641800f9620 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:34:18 +0800 Subject: [PATCH 14/17] style(mode-nav): format route eligibility tests --- tests/mode-secondary-navigation.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/mode-secondary-navigation.test.ts b/tests/mode-secondary-navigation.test.ts index cda5fc2bd3..29f817da73 100644 --- a/tests/mode-secondary-navigation.test.ts +++ b/tests/mode-secondary-navigation.test.ts @@ -47,13 +47,7 @@ const cleanLandingPath: Record = { factsheets: "/factsheets", }; -const multiPageModes = new Set([ - "differentials", - "dsm", - "specifiers", - "formulation", - "therapy-compass", -]); +const multiPageModes = new Set(["differentials", "dsm", "specifiers", "formulation", "therapy-compass"]); describe("mode secondary navigation registry", () => { it("covers all 13 modes with the approved destinations and no Home item", () => { From 9a91b5c4c2005623aee0cf635ff0936c2437a3a7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:35:22 +0800 Subject: [PATCH 15/17] style(mode-nav): format shared page navigation --- src/components/page-secondary-navigation.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/components/page-secondary-navigation.tsx b/src/components/page-secondary-navigation.tsx index e498a20d13..c5b4abc8f6 100644 --- a/src/components/page-secondary-navigation.tsx +++ b/src/components/page-secondary-navigation.tsx @@ -4,16 +4,10 @@ import { useEffect, useState } from "react"; import { isDocumentViewerOwnedRoute } from "@/components/clinical-dashboard/mobile-composer-reserve"; import { RegistryModeNav } from "@/components/mode-nav/registry-mode-nav"; -import { - SecondaryNavigation, - type SecondaryNavigationSectionItem, -} from "@/components/secondary-navigation"; +import { SecondaryNavigation, type SecondaryNavigationSectionItem } from "@/components/secondary-navigation"; import type { AppModeId } from "@/lib/app-modes"; import { isInformationPage } from "@/lib/information-pages"; -import { - activeModeSecondaryNavigationId, - isModeSecondaryNavigationRoute, -} from "@/lib/mode-secondary-navigation"; +import { activeModeSecondaryNavigationId, isModeSecondaryNavigationRoute } from "@/lib/mode-secondary-navigation"; export type InformationPageSectionDefinition = { id: string; From 9958a5b66176e844d7e74b247495b5f4f9925f97 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:36:16 +0800 Subject: [PATCH 16/17] style(mode-nav): format navigation DOM tests --- tests/page-secondary-navigation.dom.test.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/page-secondary-navigation.dom.test.tsx b/tests/page-secondary-navigation.dom.test.tsx index 1cd13950c1..2f3c9957a6 100644 --- a/tests/page-secondary-navigation.dom.test.tsx +++ b/tests/page-secondary-navigation.dom.test.tsx @@ -102,10 +102,7 @@ describe("PageSecondaryNavigation", () => { }); it("binds service section targets to IDs rendered by service-detail-page", () => { - const servicePage = readFileSync( - join(process.cwd(), "src/components/services/service-detail-page.tsx"), - "utf8", - ); + const servicePage = readFileSync(join(process.cwd(), "src/components/services/service-detail-page.tsx"), "utf8"); for (const targetId of informationPageSectionDefinitions("/services/community-team").flatMap( (section) => section.targetIds, )) { @@ -131,9 +128,7 @@ describe("PageSecondaryNavigation", () => { ["tools", "/tools"], ["factsheets", "/factsheets/search"], ] as const)("keeps the one-destination %s mode free of a redundant menu", (modeId, pathname) => { - render( - , - ); + render(); expect(screen.queryByTestId("secondary-navigation")).toBeNull(); expect(screen.queryByTestId("mode-nav")).toBeNull(); @@ -173,9 +168,7 @@ describe("PageSecondaryNavigation", () => { ["specifiers", "Build", "/specifiers/builder"], ["formulation", "Map", "/formulation/map"], ] as const)("renders the real %s workflow owner through ModeNav", (modeId, activeLabel, activeHref) => { - render( - modeId === "specifiers" ? : , - ); + render(modeId === "specifiers" ? : ); expect(screen.getByTestId("mode-nav")).toHaveAttribute( "aria-label", From c8c3c88453954a407be510147078eb6d5c269fca Mon Sep 17 00:00:00 2001 From: joshsimpson Date: Thu, 6 Aug 2026 15:00:05 +0000 Subject: [PATCH 17/17] fix(mode-nav): narrow page navigation props to honoured behaviour Prettier-format the navigation contracts and drop the onSearch/stickyTop props that the header-integrated mode nav never honours. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../global-search-shell.tsx | 4 +- src/components/page-secondary-navigation.tsx | 7 ++- tests/mode-secondary-navigation.test.ts | 9 +--- tests/page-secondary-navigation.dom.test.tsx | 48 +++---------------- 4 files changed, 15 insertions(+), 53 deletions(-) diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 0f7451bb31..7c05bc1c98 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -999,7 +999,8 @@ function GlobalStandaloneSearchShellBody({ section ids. Specifiers and Formulation keep their existing local Subnav (SpecifierSubnav / FormulationSubnav), so the shared mode bar is skipped for them to avoid a duplicate row on their workflow routes. - Rendered in normal flow (sticky={false}) so it never contends with + Mode navigation portals into the collapsing header; the "On this page" + bar renders in normal flow (sticky={false}) so it never contends with the universal collapsing header or page-flow search chrome. */} {!pendingModeNavigation && searchMode !== "specifiers" && searchMode !== "formulation" ? ( @@ -1008,7 +1009,6 @@ function GlobalStandaloneSearchShellBody({ pathname={pathname} hasSubmittedSearch={hasSubmittedModeSearch} searchParamString={searchParamString} - onSearch={() => inputRef.current?.focus({ preventScroll: true })} sticky={false} /> ) : null} diff --git a/src/components/page-secondary-navigation.tsx b/src/components/page-secondary-navigation.tsx index c5b4abc8f6..a506f9cf3c 100644 --- a/src/components/page-secondary-navigation.tsx +++ b/src/components/page-secondary-navigation.tsx @@ -250,15 +250,18 @@ export function PageSecondaryNavigation({ * the standalone shell body (search-chrome invariant 17). */ searchParamString = "", + /** + * Only reaches the "On this page" bar. The mode bar is the header-integrated + * `ModeNav`, which portals into the collapsing header and owns its own + * placement, so no positioning prop is accepted for it. + */ sticky = true, }: { modeId: AppModeId; pathname: string; hasSubmittedSearch: boolean; - onSearch: () => void; searchParamString?: string; sticky?: boolean; - stickyTop?: number | string; }) { const informationDefinitions = informationPageSectionDefinitions(pathname); const locallyOwnedInformationNavigation = hasLocalInformationPageNavigation(pathname); diff --git a/tests/mode-secondary-navigation.test.ts b/tests/mode-secondary-navigation.test.ts index 29f817da73..4328f936aa 100644 --- a/tests/mode-secondary-navigation.test.ts +++ b/tests/mode-secondary-navigation.test.ts @@ -20,14 +20,7 @@ const expectedLabels: Record = { formulation: ["Find", "Build", "Compare", "Map"], prescribing: ["Search"], tools: ["Search"], - "therapy-compass": [ - "Search", - "Recommend", - "Compare", - "Pathways", - "Brief Intervention", - "Patient Sheets", - ], + "therapy-compass": ["Search", "Recommend", "Compare", "Pathways", "Brief Intervention", "Patient Sheets"], factsheets: ["Search"], }; diff --git a/tests/page-secondary-navigation.dom.test.tsx b/tests/page-secondary-navigation.dom.test.tsx index 2f3c9957a6..acb3e9853c 100644 --- a/tests/page-secondary-navigation.dom.test.tsx +++ b/tests/page-secondary-navigation.dom.test.tsx @@ -44,14 +44,7 @@ describe("PageSecondaryNavigation", () => { ["/services/community-team", ["Overview", "Quick facts", "Referral", "Criteria", "Verification"]], [ "/forms/form-1", - [ - "Overview", - "Decision context", - "Priority facts", - "Legal boundary", - "Form information", - "Source / verification", - ], + ["Overview", "Decision context", "Priority facts", "Legal boundary", "Form information", "Source / verification"], ], ["/specifiers/with-anxious-distress", ["Overview", "Fit & exclusions", "Wording / coding", "Evidence / source"]], [ @@ -111,9 +104,7 @@ describe("PageSecondaryNavigation", () => { }); it("does not add a navigation row to a clean one-destination landing page", () => { - render( - , - ); + render(); expect(screen.queryByTestId("secondary-navigation")).toBeNull(); expect(screen.queryByTestId("mode-nav")).toBeNull(); }); @@ -128,7 +119,7 @@ describe("PageSecondaryNavigation", () => { ["tools", "/tools"], ["factsheets", "/factsheets/search"], ] as const)("keeps the one-destination %s mode free of a redundant menu", (modeId, pathname) => { - render(); + render(); expect(screen.queryByTestId("secondary-navigation")).toBeNull(); expect(screen.queryByTestId("mode-nav")).toBeNull(); @@ -136,26 +127,13 @@ describe("PageSecondaryNavigation", () => { it.each([ ["differentials", "/differentials", "Differentials pages", "Search", "/differentials?focus=1"], - [ - "differentials", - "/differentials/diagnoses", - "Differentials pages", - "Diagnoses", - "/differentials/diagnoses", - ], + ["differentials", "/differentials/diagnoses", "Differentials pages", "Diagnoses", "/differentials/diagnoses"], ["dsm", "/dsm", "DSM-5 Diagnosis pages", "Search", "/dsm?focus=1"], ["dsm", "/dsm/compare", "DSM-5 Diagnosis pages", "Compare", "/dsm/compare"], ] as const)( "renders the %s workflow as the shared header-integrated mode nav", (modeId, pathname, ariaLabel, activeLabel, activeHref) => { - render( - , - ); + render(); expect(screen.getByRole("navigation", { name: ariaLabel })).toHaveAttribute("data-testid", "mode-nav"); expect(screen.getByRole("link", { name: activeLabel })).toHaveAttribute("aria-current", "page"); @@ -183,12 +161,7 @@ describe("PageSecondaryNavigation", () => { it("replaces mode navigation with only the information sections present in the record", async () => { render(
- +
, @@ -205,12 +178,7 @@ describe("PageSecondaryNavigation", () => { it("leaves locally controlled information and Therapy workflow navigation to their page owners", async () => { const { rerender } = render( - , + , ); await waitFor(() => expect(screen.queryByTestId("secondary-navigation")).toBeNull()); expect(screen.queryByTestId("mode-nav")).toBeNull(); @@ -220,7 +188,6 @@ describe("PageSecondaryNavigation", () => { modeId="therapy-compass" pathname="/therapy-compass/search" hasSubmittedSearch={false} - onSearch={vi.fn()} />, ); expect(screen.queryByTestId("secondary-navigation")).toBeNull(); @@ -231,7 +198,6 @@ describe("PageSecondaryNavigation", () => { modeId="documents" pathname="/documents/11111111-1111-4111-8111-111111111111" hasSubmittedSearch - onSearch={vi.fn()} />, ); expect(screen.queryByTestId("secondary-navigation")).toBeNull();