-
-
+
+
+
+
+
- {hasWithheldDefinition ? (
-
-
- The generated definition for this specifier is withheld pending qualified clinician review. Confirm the
- specifier against current DSM-5-TR / ICD-11 materials before documenting.
-
-
- ) : null}
+
+
+
- {enrichment ? (
-
-
-
-
-
-
-
Curated clinical reasoning
-
Fit and exclusions
-
-
-
-
-
-
- {enrichment.checks.length ? (
-
- ) : null}
-
- ) : null}
+ {hasWithheldDefinition ? (
+
+
+ The generated definition for this specifier is withheld pending qualified clinician review. Confirm
+ the specifier against current DSM-5-TR / ICD-11 materials before documenting.
+
+
+ ) : null}
- {item.icd11Context ? (
-
- {item.icd11Context}
-
- ) : null}
-
-
-
-
-
-
- {[
- ["Source", sourceStatusLabel[item.review.sourceVerificationStatus]],
- ["Clinician review", "Pending qualified review"],
- ["Source family", item.definition?.sourceFamily ?? item.review.sourceFamily ?? "—"],
- ["Content hash", item.review.contentHash],
- ].map(([label, body]) => (
-
-
{label}
-
{body}
+ {enrichment ? (
+
+
+
+
+
+
+
Curated clinical reasoning
+
Fit and exclusions
+
+
+
+
+
- ))}
-
-
+ {enrichment.checks.length ? (
+
+ ) : null}
+
+ ) : null}
-
- Diagnosis
- {item.disorderName}
-
-
-
-
+ {item.icd11Context ? (
+
+ {item.icd11Context}
+
+ ) : null}
+
- {related.length ? (
+
-
More in this diagnosis
+
Review status
-
- {related.map((entry) => (
-
-
- {entry.label}
-
- {entry.groupLabel}
-
-
-
-
+
+ {[
+ ["Source", sourceStatusLabel[item.review.sourceVerificationStatus]],
+ ["Clinician review", "Pending qualified review"],
+ ["Source family", item.definition?.sourceFamily ?? item.review.sourceFamily ?? "—"],
+ ["Content hash", item.review.contentHash],
+ ].map(([label, body]) => (
+
+
{label}
+ {body}
+
))}
+
+
+
+
+ Diagnosis
+ {item.disorderName}
+
+
- ) : null}
-
-
-
-
+ {related.length ? (
+
+
+
More in this diagnosis
+
+
+ {related.map((entry) => (
+
+
+ {entry.label}
+
+ {entry.groupLabel}
+
+
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+ >
);
}
diff --git a/src/components/specifiers/specifier-ui.tsx b/src/components/specifiers/specifier-ui.tsx
index 0c3a55c73..6c260bb46 100644
--- a/src/components/specifiers/specifier-ui.tsx
+++ b/src/components/specifiers/specifier-ui.tsx
@@ -193,13 +193,22 @@ export function DiagnosisChips({ values }: { values: string[] }) {
);
}
-export function SpecifierSafetyNote({ compact = false, id }: { compact?: boolean; id?: string }) {
+export function SpecifierSafetyNote({
+ compact = false,
+ id,
+ className,
+}: {
+ compact?: boolean;
+ id?: string;
+ className?: string;
+}) {
return (
diff --git a/src/components/sticky-chrome-metrics.ts b/src/components/sticky-chrome-metrics.ts
new file mode 100644
index 000000000..9fa1b46e9
--- /dev/null
+++ b/src/components/sticky-chrome-metrics.ts
@@ -0,0 +1,136 @@
+"use client";
+
+import { useEffect, useState, type RefObject } from "react";
+
+/**
+ * The measurement shared by every page that owns a sticky navigation header
+ * under the universal phone/desktop chrome.
+ *
+ * `master-search-header` already publishes its hide state as `data-scroll-hidden`
+ * on the collapse wrapper, and the wrapper's height is the real anchor offset.
+ * Both are observed here so a page can react locally — shared chrome stays
+ * exactly as it is.
+ *
+ * Extracted from `use-document-chrome-metrics.ts`, which is now the document
+ * viewer's binding of these options. Nothing here names a document: the selector
+ * for the page-owned sticky header, the custom-property names, and where those
+ * properties are published are all caller-supplied, because the document viewer
+ * publishes onto its own `` while an information page publishes onto
+ * `documentElement` (its header is portaled out of the page subtree on phones,
+ * so there is no single page root that contains both header and anchors).
+ */
+export type StickyChromeMetricsOptions = {
+ /** The universal collapse row, whose height and hide state drive everything. */
+ collapseSelector: string;
+ /** The page-owned sticky header that jumps must clear on sm+. */
+ stickyHeaderSelector: string;
+ phoneMediaQuery: string;
+ /** Custom property carrying the scroll-margin an anchored section needs. */
+ anchorOffsetProperty: string;
+ /** Custom property carrying the page-owned header's measured height. */
+ stickyHeaderHeightProperty: string;
+ /** Optional custom property carrying the live collapse-row height. */
+ collapseHeightProperty?: string;
+ /**
+ * Where the sticky header is looked up and where the properties are published.
+ * A ref scopes both to that page root; `"document"` looks the header up
+ * globally and publishes on `documentElement`.
+ */
+ scope: RefObject | "document";
+};
+
+export function useStickyChromeMetrics({
+ collapseSelector,
+ stickyHeaderSelector,
+ phoneMediaQuery,
+ anchorOffsetProperty,
+ stickyHeaderHeightProperty,
+ collapseHeightProperty,
+ scope,
+}: StickyChromeMetricsOptions) {
+ const [headerHidden, setHeaderHidden] = useState(false);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+
+ const collapse = window.document.querySelector(collapseSelector);
+ const documentScoped = scope === "document";
+ const root = documentScoped ? window.document.documentElement : scope.current;
+ const isPhone = () => window.matchMedia(phoneMediaQuery).matches;
+
+ const stickyHeader = () =>
+ (documentScoped
+ ? window.document.querySelector(stickyHeaderSelector)
+ : root?.querySelector(stickyHeaderSelector)) ?? null;
+
+ const measureStickyHeader = () => stickyHeader()?.offsetHeight ?? 0;
+
+ const syncOffset = () => {
+ if (!root) return;
+ const collapseHidden = collapse?.getAttribute("data-scroll-hidden") === "true";
+ const collapseHeight = collapse && !collapseHidden ? collapse.offsetHeight : 0;
+ // On phones the page-owned header is portaled into the collapse row, so its
+ // height is already folded into `collapseHeight` and must not be added
+ // again. On sm+ both sticky bands pin at top:0 and overlap while the
+ // universal bar is visible; only when that bar hides must the page-owned
+ // sticky header be added so jumps do not land underneath it.
+ const stickyHeaderHeight = isPhone() ? 0 : measureStickyHeader();
+ const anchorClearance = collapseHeight + (collapseHidden ? stickyHeaderHeight : 0);
+ root.style.setProperty(anchorOffsetProperty, `${Math.round(anchorClearance) + 16}px`);
+ root.style.setProperty(stickyHeaderHeightProperty, `${Math.round(stickyHeaderHeight)}px`);
+ // The live height of the universal collapse row, so page-owned sticky
+ // chrome can offset from what is actually on screen rather than from a
+ // hand-copied pixel value that drifts with type size and chrome changes.
+ if (collapseHeightProperty) {
+ root.style.setProperty(collapseHeightProperty, `${Math.round(collapseHeight)}px`);
+ }
+ };
+
+ const syncHidden = () => {
+ setHeaderHidden(collapse?.getAttribute("data-scroll-hidden") === "true");
+ syncOffset();
+ };
+
+ syncHidden();
+
+ const cleanup: Array<() => void> = [];
+
+ if (collapse) {
+ const attributes = new MutationObserver(syncHidden);
+ attributes.observe(collapse, { attributes: true, attributeFilter: ["data-scroll-hidden"] });
+ cleanup.push(() => attributes.disconnect());
+
+ if (typeof ResizeObserver !== "undefined") {
+ const resize = new ResizeObserver(() => window.requestAnimationFrame(syncOffset));
+ resize.observe(collapse);
+ cleanup.push(() => resize.disconnect());
+ }
+ }
+
+ const headerEl = stickyHeader();
+ if (headerEl && typeof ResizeObserver !== "undefined") {
+ const headerResize = new ResizeObserver(() => window.requestAnimationFrame(syncOffset));
+ headerResize.observe(headerEl);
+ cleanup.push(() => headerResize.disconnect());
+ }
+
+ const phoneMedia = window.matchMedia(phoneMediaQuery);
+ const onPhoneChange = () => syncOffset();
+ phoneMedia.addEventListener("change", onPhoneChange);
+ cleanup.push(() => phoneMedia.removeEventListener("change", onPhoneChange));
+
+ return () => cleanup.forEach((dispose) => dispose());
+ // Destructured primitives rather than the options object: an inline object
+ // literal would be a new reference every render and re-run the effect.
+ }, [
+ collapseSelector,
+ stickyHeaderSelector,
+ phoneMediaQuery,
+ anchorOffsetProperty,
+ stickyHeaderHeightProperty,
+ collapseHeightProperty,
+ scope,
+ ]);
+
+ return { headerHidden };
+}
diff --git a/tests/audit-content-services-regressions.test.ts b/tests/audit-content-services-regressions.test.ts
index cfe694e0a..219d691fd 100644
--- a/tests/audit-content-services-regressions.test.ts
+++ b/tests/audit-content-services-regressions.test.ts
@@ -201,7 +201,13 @@ describe("content and services audit regressions", () => {
});
it("claims and renders a form source link only when the record has a URL", () => {
- expect(normalizedFormDetailSource).toContain("sourceHref={form.source?.url ?? null}");
+ // The duplicated `ActionPanel` that used to carry this moved into the
+ // in-page header's actions sheet, so the URL is resolved once, up front —
+ // and the sheet still renders an explicit disabled placeholder rather than
+ // an "Open official source" control that goes nowhere.
+ expect(normalizedFormDetailSource).toContain("const sourceHref = form.source?.url ?? null;");
+ expect(normalizedFormDetailSource).toContain("{sourceHref ? (");
+ expect(normalizedFormDetailSource).toContain("No official source URL is recorded for this form.");
expect(normalizedFormDetailSource).toContain("href={form.source.url}");
expect(normalizedFormDetailSource).toContain('target="_blank"');
expect(normalizedFormDetailSource).toContain('rel="noopener noreferrer"');
diff --git a/tests/in-page-nav-header.dom.test.tsx b/tests/in-page-nav-header.dom.test.tsx
index 2c3868897..f6f791d82 100644
--- a/tests/in-page-nav-header.dom.test.tsx
+++ b/tests/in-page-nav-header.dom.test.tsx
@@ -1,4 +1,5 @@
import { render, screen, waitFor, within } from "@testing-library/react";
+import Link from "next/link";
import userEvent from "@testing-library/user-event";
import { Compass, ShieldCheck, Stethoscope } from "lucide-react";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -7,6 +8,10 @@ import { InPageNavHeader } from "@/components/in-page-nav/in-page-nav-header";
import { toDocumentSections, type PageSection } from "@/components/in-page-nav/page-section-index";
import { phoneHeaderCollapseAddonSlotId } from "@/lib/mode-home-composer";
+/** Reassigned per case so a rerender can simulate a route change. */
+let pathname: string | null = null;
+vi.mock("next/navigation", () => ({ usePathname: () => pathname }));
+
/** `PhoneHeaderCollapsePortal` resolves its host only below this breakpoint. */
function stubPhoneBreakpoint(isPhone: boolean) {
vi.spyOn(window, "matchMedia").mockImplementation(
@@ -196,6 +201,49 @@ describe("InPageNavHeader", () => {
await waitFor(() => expect(screen.queryByTestId("service-actions-sheet")).toBeNull());
});
+ it("accepts plain node actions from a Server Component page", async () => {
+ // Four of the seven converted pages are Server Components. React cannot pass
+ // a function across that boundary, so the render-prop form alone would fail
+ // to build there; server-rendered JSX passed as a slot is fine.
+ const user = userEvent.setup();
+ renderHeader({
+ actionsNoun: "specifier",
+ actions: Compare,
+ });
+
+ await user.click(screen.getByRole("button", { name: "Open specifier actions" }));
+ const sheet = await screen.findByTestId("service-actions-sheet");
+ expect(within(sheet).getByRole("link", { name: "Compare" })).toHaveAttribute("href", "/specifiers/compare");
+ });
+
+ it("closes both sheets when the route changes under them", async () => {
+ // Server-passed action JSX is mostly ` `s and has no way to call
+ // `close()`, so navigation has to close the sheet generically. Both sheets
+ // record the route they were opened on rather than a bare boolean.
+ const user = userEvent.setup();
+ pathname = "/specifiers/with-anxious-distress";
+ const view = renderHeader({ actionsNoun: "specifier", actions: Compare });
+
+ await user.click(screen.getByRole("button", { name: "Open specifier actions" }));
+ expect(await screen.findByTestId("service-actions-sheet")).toBeInTheDocument();
+
+ pathname = "/specifiers/compare";
+ view.rerender(
+ undefined}
+ testIdPrefix="service"
+ actionsNoun="specifier"
+ actions={ Compare}
+ />,
+ );
+
+ await waitFor(() => expect(screen.queryByTestId("service-actions-sheet")).toBeNull());
+ });
+
it("portals into the universal collapse slot on phones rather than owning a second scroll-hide header", async () => {
// The contract is one collapse owner per phone header: below `sm` this
// subtree belongs to the universal header's collapse row.
diff --git a/tests/in-page-nav-route-sections.dom.test.tsx b/tests/in-page-nav-route-sections.dom.test.tsx
new file mode 100644
index 000000000..8f18d4fee
--- /dev/null
+++ b/tests/in-page-nav-route-sections.dom.test.tsx
@@ -0,0 +1,200 @@
+import { cleanup, render } from "@testing-library/react";
+import type { ReactElement } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { DsmDiagnosisPage } from "@/components/dsm/dsm-diagnosis-page";
+import { dsmDiagnosisNavSections } from "@/components/dsm/dsm-diagnosis-nav-header";
+import {
+ DsmDifferentialConsiderationsPage,
+ dsmDifferentialNavSections,
+ type DsmDifferentialConsideration,
+} from "@/components/dsm/dsm-differential-considerations-page";
+import { FormDetailPage, formNavSections } from "@/components/forms/form-detail-page";
+import { FormulationMechanismPage } from "@/components/formulation/formulation-mechanism-page";
+import { formulationNavSections } from "@/components/formulation/formulation-nav-header";
+import { inPageAnchor } from "@/components/in-page-nav/in-page-nav-classes";
+import { sectionTargetIds, type PageSection } from "@/components/in-page-nav/page-section-index";
+import { ServiceDetailPage, serviceNavSections } from "@/components/services/service-detail-page";
+import { specifierNavSections } from "@/components/specifiers/specifier-nav-header";
+import { SpecifierRecordPage } from "@/components/specifiers/specifier-record-page";
+import { SpecifierReferencePage } from "@/components/specifiers/specifier-reference-page";
+import { dsmDiagnoses } from "@/lib/dsm";
+import { formRecords } from "@/lib/forms";
+import { formulationMechanisms } from "@/lib/formulation";
+import { serviceRecords } from "@/lib/services";
+import { specifierCatalogItems, curatedEnrichmentFor } from "@/lib/specifiers-content";
+import { specifierRecords } from "@/lib/specifiers";
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/",
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }),
+}));
+
+vi.mock("@/components/account-data-provider", () => ({
+ useAccountData: () => ({
+ isSaved: () => false,
+ isAuthenticated: true,
+ setFavourite: vi.fn(async () => true),
+ }),
+}));
+
+afterEach(cleanup);
+
+/**
+ * The guard `/issues #256` asks for, and the one the pill rail never had.
+ *
+ * The rail declared section sets in a table beside the pages rather than inside
+ * them, and `AvailableInformationPageNavigation` silently dropped any section
+ * whose anchor was missing — so a set could rot to nothing and the only symptom
+ * was a route quietly drawing no navigation. Two routes were in exactly that
+ * state when this was written: `/dsm/diagnoses/` rendered three of five
+ * declared anchors, and its `/differentials` child rendered one of four.
+ *
+ * The stop rule from that issue is what shapes this file: **assert against the
+ * rendered DOM, never a source grep**. Several anchors reach the DOM through a
+ * prop (`SpecifierSafetyNote id=…`, the local `Section` helper on the services
+ * page), so a scan for `id="…"` reports live sections as dead.
+ */
+type RouteCase = {
+ name: string;
+ sections: readonly PageSection[];
+ render: () => ReactElement;
+ /**
+ * Anchors that legitimately depend on the record. Each is asserted present on
+ * a fixture that has the data and absent on one that does not, rather than
+ * being skipped.
+ */
+ conditional?: readonly string[];
+};
+
+const specifierRecord = specifierRecords[0];
+const specifierCatalogItem = specifierCatalogItems().find((item) => curatedEnrichmentFor(item));
+const dsmDiagnosisWithKeyFeatures = dsmDiagnoses.find(
+ (diagnosis) => diagnosis.key_features.length > 0 && diagnosis.criteria_display.length > 0,
+);
+const dsmDiagnosisWithDifferentials = dsmDiagnoses.find((diagnosis) => diagnosis.differentials.length > 0);
+
+function buildConsiderations(values: string[]): DsmDifferentialConsideration[] {
+ return values.map((value, index) => ({
+ id: `${index}-consideration`,
+ title: value,
+ fullText: value,
+ rationale: "Listed as a differential consideration in the supplied diagnosis record.",
+ group: "overlap",
+ }));
+}
+
+const routes: RouteCase[] = [
+ {
+ name: "/services/[slug]",
+ sections: serviceNavSections,
+ render: () => ,
+ },
+ {
+ name: "/forms/[slug]",
+ sections: formNavSections,
+ render: () => ,
+ },
+ {
+ name: "/specifiers/[slug] (curated record)",
+ sections: specifierNavSections,
+ render: () => ,
+ },
+ {
+ name: "/specifiers/[slug] (catalogue reference)",
+ sections: specifierNavSections,
+ render: () => ,
+ // Only the enriched reference page renders the fit panel.
+ conditional: ["specifier-fit"],
+ },
+ {
+ name: "/formulation/[slug]",
+ sections: formulationNavSections,
+ render: () => ,
+ },
+ {
+ name: "/dsm/diagnoses/[slug]",
+ sections: dsmDiagnosisNavSections,
+ render: () => ,
+ conditional: ["key-features"],
+ },
+ {
+ name: "/dsm/diagnoses/[slug]/differentials",
+ sections: dsmDifferentialNavSections,
+ render: () => (
+
+ ),
+ },
+];
+
+describe("in-page navigation section contracts", () => {
+ it.each(routes.map((route) => [route.name, route] as const))(
+ "%s renders an anchor for every declared section",
+ (_name, route) => {
+ const { container } = render(route.render());
+
+ for (const section of route.sections) {
+ // A section may declare several breakpoint copies; jsdom applies no
+ // Tailwind, so both are in the DOM here and any one of them proves the
+ // anchor exists. Which copy is *displayed* is resolved at runtime by
+ // `useResolvedPageSections`, and covered by the Playwright pair check.
+ const found = sectionTargetIds(section).some((id) => container.querySelector(`#${CSS.escape(id)}`));
+ expect(found, `${route.name}: no element renders an anchor for "${section.id}"`).toBe(true);
+ }
+ },
+ );
+
+ it.each(routes.map((route) => [route.name, route] as const))(
+ "%s gives every anchor the shared in-page scroll margin",
+ (_name, route) => {
+ // Information-page sections carried no scroll-mt at all before the shared
+ // header existed, so without this every jump lands underneath it.
+ const { container } = render(route.render());
+
+ for (const section of route.sections) {
+ const anchor = sectionTargetIds(section)
+ .map((id) => container.querySelector(`#${CSS.escape(id)}`))
+ .find((element): element is Element => element !== null);
+ expect(anchor?.className, `${route.name}: "${section.id}" has no in-page scroll margin`).toContain(
+ inPageAnchor,
+ );
+ }
+ },
+ );
+
+ it("drops a conditional section rather than declaring a dead anchor", () => {
+ // The other half of the conditional cases above: with the data absent the
+ // anchor must be absent too, so `useResolvedPageSections` filters the entry
+ // out instead of the header offering a jump that goes nowhere.
+ const withoutKeyFeatures = dsmDiagnoses.find((diagnosis) => diagnosis.key_features.length === 0);
+ if (withoutKeyFeatures) {
+ const { container } = render( );
+ expect(container.querySelector("#key-features")).toBeNull();
+ // The unconditional ones still render, so this is a filtered section and
+ // not a page that failed to render at all.
+ expect(container.querySelector("#criteria")).not.toBeNull();
+ cleanup();
+ }
+
+ const withoutEnrichment = specifierCatalogItems().find((item) => !curatedEnrichmentFor(item));
+ if (withoutEnrichment) {
+ const { container } = render( );
+ expect(container.querySelector("#specifier-fit")).toBeNull();
+ expect(container.querySelector("#specifier-overview")).not.toBeNull();
+ }
+ });
+
+ it("covers every route that mounts the shared header", () => {
+ // A seventh component converted without a case here would leave its
+ // declared sections unguarded, which is the whole failure mode.
+ expect(routes).toHaveLength(7);
+ });
+});
diff --git a/tests/mode-nav-addon-slot.dom.test.tsx b/tests/mode-nav-addon-slot.dom.test.tsx
index 564ab94ee..2b94dacec 100644
--- a/tests/mode-nav-addon-slot.dom.test.tsx
+++ b/tests/mode-nav-addon-slot.dom.test.tsx
@@ -1,5 +1,5 @@
import { readdirSync, readFileSync } from "node:fs";
-import { join } from "node:path";
+import { join, relative as relativePath, sep } from "node:path";
import { render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -40,12 +40,26 @@ describe("header addon slot ownership", () => {
it("recognises the routes whose page portals its own header", () => {
expect(isHeaderAddonSlotOwnedRoute("/differentials/diagnoses/delirium")).toBe(true);
expect(isHeaderAddonSlotOwnedRoute("/documents/11111111-1111-4111-8111-111111111111")).toBe(true);
+ // The six information routes converted onto `InPageNavHeader`.
+ expect(isHeaderAddonSlotOwnedRoute("/services/community-team")).toBe(true);
+ expect(isHeaderAddonSlotOwnedRoute("/forms/transport-crisis-form")).toBe(true);
+ expect(isHeaderAddonSlotOwnedRoute("/specifiers/with-anxious-distress")).toBe(true);
+ expect(isHeaderAddonSlotOwnedRoute("/formulation/rumination")).toBe(true);
+ expect(isHeaderAddonSlotOwnedRoute("/dsm/diagnoses/major-depressive-disorder")).toBe(true);
+ expect(isHeaderAddonSlotOwnedRoute("/dsm/diagnoses/major-depressive-disorder/differentials")).toBe(true);
// The presentations workflow page renders no portal, and the shell index is
// not a document detail route.
expect(isHeaderAddonSlotOwnedRoute("/differentials/presentations/acute-confusion-encephalopathy")).toBe(false);
expect(isHeaderAddonSlotOwnedRoute("/documents/search")).toBe(false);
expect(isHeaderAddonSlotOwnedRoute("/differentials/diagnoses")).toBe(false);
+ // Mode homes and the builder/compare/map/search surfaces keep the mode bar,
+ // so they must stay outside the claimant set.
+ expect(isHeaderAddonSlotOwnedRoute("/services")).toBe(false);
+ expect(isHeaderAddonSlotOwnedRoute("/specifiers/compare")).toBe(false);
+ expect(isHeaderAddonSlotOwnedRoute("/formulation/builder")).toBe(false);
+ expect(isHeaderAddonSlotOwnedRoute("/dsm/search")).toBe(false);
+ expect(isHeaderAddonSlotOwnedRoute("/dsm/compare")).toBe(false);
});
it("is covered, route for route, by the locally-owned early return", () => {
@@ -62,6 +76,12 @@ describe("header addon slot ownership", () => {
"/differentials/diagnoses/delirium",
"/documents/11111111-1111-4111-8111-111111111111",
"/documents/11111111-1111-4111-8111-111111111111/source",
+ "/services/community-team",
+ "/forms/transport-crisis-form",
+ "/specifiers/with-anxious-distress",
+ "/formulation/rumination",
+ "/dsm/diagnoses/major-depressive-disorder",
+ "/dsm/diagnoses/major-depressive-disorder/differentials",
]) {
expect(isHeaderAddonSlotOwnedRoute(pathname)).toBe(true);
expect(hasLocalInformationPageNavigation(pathname)).toBe(true);
@@ -123,7 +143,10 @@ describe("header addon slot ownership", () => {
if (!entry.name.endsWith(".tsx")) continue;
// Design-scratch routes 404 in production and own no header.
if (entry.name.includes("-mockups")) continue;
- const relative = path.replace(`${process.cwd()}/`, "");
+ // Posix separators regardless of host: a `String.replace` of
+ // `${cwd}/` matches nothing on Windows, so both the shared-header
+ // exclusion below and the comparison at the end silently missed.
+ const relative = relativePath(process.cwd(), path).split(sep).join("/");
if (relative === sharedHeader) continue;
if (claimsSlot.test(readFileSync(path, "utf8"))) {
claimants.push(relative);
@@ -132,9 +155,19 @@ describe("header addon slot ownership", () => {
};
walk(join(process.cwd(), "src/components"));
+ // The four `*-nav-header.tsx` modules are the client halves of Server
+ // Component pages: sections carry `LucideIcon` values and the header needs
+ // hooks, neither of which crosses the RSC boundary, so the route's claim is
+ // registered in a sibling module rather than in the page itself.
expect(claimants.sort()).toEqual([
"src/components/DocumentViewer.tsx",
"src/components/differentials/differential-detail-page.tsx",
+ "src/components/dsm/dsm-diagnosis-nav-header.tsx",
+ "src/components/dsm/dsm-differential-considerations-page.tsx",
+ "src/components/forms/form-detail-page.tsx",
+ "src/components/formulation/formulation-nav-header.tsx",
+ "src/components/services/service-detail-page.tsx",
+ "src/components/specifiers/specifier-nav-header.tsx",
]);
});
diff --git a/tests/page-secondary-navigation.dom.test.tsx b/tests/page-secondary-navigation.dom.test.tsx
index 3d56d90ab..0faf195a7 100644
--- a/tests/page-secondary-navigation.dom.test.tsx
+++ b/tests/page-secondary-navigation.dom.test.tsx
@@ -1,6 +1,3 @@
-import { readFileSync } from "node:fs";
-import { join } from "node:path";
-
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -9,11 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
// needs a router here.
vi.mock("next/navigation", () => ({ usePathname: () => "/" }));
-import {
- hasLocalInformationPageNavigation,
- informationPageSectionDefinitions,
- PageSecondaryNavigation,
-} from "@/components/page-secondary-navigation";
+import { hasLocalInformationPageNavigation, PageSecondaryNavigation } from "@/components/page-secondary-navigation";
describe("PageSecondaryNavigation", () => {
beforeEach(() => {
@@ -22,126 +15,45 @@ describe("PageSecondaryNavigation", () => {
);
});
- it.each([
- ["/services/community-team", ["Overview", "Quick facts", "Referral", "Criteria", "Verification"]],
- [
- "/forms/form-1",
- ["Overview", "Decision context", "Priority facts", "Legal boundary", "Form information", "Source / verification"],
- ],
- ["/specifiers/with-anxious-distress", ["Overview", "Fit & exclusions", "Wording / coding", "Evidence / source"]],
- [
- "/formulation/avoidance",
- ["Overview", "What matters now", "Fit", "5 Ps", "Treatment leverage", "Evidence / source"],
- ],
- [
- "/dsm/diagnoses/major-depressive-disorder",
- ["Criteria", "Key features", "Specifiers", "Documentation", "Record summary"],
- ],
- [
- "/dsm/diagnoses/major-depressive-disorder/differentials",
- ["Overview", "Filters", "Considerations", "Clarify / review"],
- ],
- ["/documents/11111111-1111-4111-8111-111111111111", ["PDF", "Evidence", "Text", "Summary", "Images"]],
- ] as const)("defines the approved section set for %s", (pathname, labels) => {
- expect(informationPageSectionDefinitions(pathname).map((section) => section.label)).toEqual(labels);
- });
-
it.each([
"/medications/sertraline",
"/differentials/diagnoses/delirium",
+ "/differentials/presentations/acute-confusion-encephalopathy",
"/factsheets/sertraline",
"/therapy-compass/cbt",
"/documents/11111111-1111-4111-8111-111111111111",
+ // The six routes converted onto `InPageNavHeader`. They used to reach the
+ // shell's "On this page" rail instead; now they own their navigation like
+ // every other information page, which is what collapsed this predicate to
+ // `isInformationPage`.
+ "/services/community-team",
+ "/forms/form-1",
+ "/specifiers/with-anxious-distress",
+ "/formulation/avoidance",
+ "/dsm/diagnoses/major-depressive-disorder",
+ "/dsm/diagnoses/major-depressive-disorder/differentials",
])("recognises %s as locally controlled information navigation", (pathname) => {
expect(hasLocalInformationPageNavigation(pathname)).toBe(true);
});
- it("does not treat /documents/search as locally owned document-viewer navigation", () => {
- expect(hasLocalInformationPageNavigation("/documents/search")).toBe(false);
- });
-
- it("uses stable semantic fragments for breakpoint-specific section targets", () => {
- // A fragmentId is the stable href when a section's target differs across
- // breakpoints; SecondaryNavigation still scrolls via the targetId, so the
- // fragment never needs to exist as an element.
- const formDecisionContext = informationPageSectionDefinitions("/forms/form-1").find(
- (section) => section.id === "decision-context",
- );
- const formSourceVerification = informationPageSectionDefinitions("/forms/form-1").find(
- (section) => section.id === "verification",
- );
-
- expect(formDecisionContext?.fragmentId).toBe("form-decision-context");
- expect(formSourceVerification?.fragmentId).toBe("form-source-verification");
- });
-
- it("claims no section set for the differentials presentation workflow", () => {
- // It declared six sections whose targetIds no component rendered, so
- // AvailableInformationPageNavigation filtered them all out and returned
- // null — the route claimed and nothing drawn (/issues #256). The set is
- // deleted rather than wired: the page owns navigation at every width
- // already (MobileTabs below xl, the "Differential review sidebar" aside at
- // xl showing every panel at once), and three of the six declared a -mobile
- // variant that no render could ever satisfy.
- expect(informationPageSectionDefinitions("/differentials/presentations/acute-confusion-encephalopathy")).toEqual(
- [],
- );
- expect(hasLocalInformationPageNavigation("/differentials/presentations/acute-confusion-encephalopathy")).toBe(true);
- });
-
- 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");
- for (const targetId of informationPageSectionDefinitions("/services/community-team").flatMap(
- (section) => section.targetIds,
- )) {
- expect(servicePage).toContain(`id="${targetId}"`);
- }
- });
-
- it("binds form section targets to IDs rendered by form-detail-page", () => {
- // The gap /issues #256 was about: this page declared six sections and
- // rendered zero `id=` attributes, so its nav filtered to nothing and drew
- // nothing at all. Every literal below is at a call site in this file, so a
- // vacuous pass is not possible — a target moved behind a variable would
- // fail here and needs a rendered-DOM assertion instead.
- const formPage = readFileSync(join(process.cwd(), "src/components/forms/form-detail-page.tsx"), "utf8");
- for (const targetId of informationPageSectionDefinitions("/forms/form-1").flatMap((section) => section.targetIds)) {
- expect(formPage).toContain(`id="${targetId}"`);
- }
- });
-
- it("binds specifier section targets to IDs rendered by specifier record pages", () => {
- const recordPage = readFileSync(join(process.cwd(), "src/components/specifiers/specifier-record-page.tsx"), "utf8");
- const referencePage = readFileSync(
- join(process.cwd(), "src/components/specifiers/specifier-reference-page.tsx"),
- "utf8",
- );
- for (const targetId of informationPageSectionDefinitions("/specifiers/with-anxious-distress").flatMap(
- (section) => section.targetIds,
- )) {
- // Fit is enrichment-gated on the catalogue reference page; the curated
- // record page must always expose every declared target.
- expect(recordPage).toContain(`id="${targetId}"`);
- if (targetId !== "specifier-fit") {
- expect(referencePage).toContain(`id="${targetId}"`);
- }
- }
- expect(referencePage).toContain('id="specifier-fit"');
- });
-
- it("binds formulation section targets to IDs rendered by formulation-mechanism-page", () => {
- const mechanismPage = readFileSync(
- join(process.cwd(), "src/components/formulation/formulation-mechanism-page.tsx"),
- "utf8",
- );
- for (const targetId of informationPageSectionDefinitions("/formulation/avoidance").flatMap(
- (section) => section.targetIds,
- )) {
- expect(mechanismPage).toContain(`id="${targetId}"`);
- }
- });
-
- it("renders On this page for a specifier record when its section targets are present", async () => {
+ it.each([
+ // Search and tool surfaces are mode routes, not records: they keep the mode
+ // bar, so the predicate must not swallow them along with their siblings.
+ "/documents/search",
+ "/services",
+ "/specifiers/compare",
+ "/specifiers/builder",
+ "/formulation/map",
+ "/dsm/search",
+ "/dsm/compare",
+ "/factsheets/search",
+ ])("does not treat %s as locally owned information navigation", (pathname) => {
+ expect(hasLocalInformationPageNavigation(pathname)).toBe(false);
+ });
+
+ it("draws no shell navigation on a route that owns an InPageNavHeader", async () => {
+ // The header these routes mount is page-owned and portals into the phone
+ // collapse slot; a shell bar as well would be the second one.
render(
,
);
- const onThisPage = await screen.findByRole("navigation", { name: "On this page" });
- expect(onThisPage).toBeVisible();
- expect(screen.getByRole("link", { name: "Overview" })).toHaveAttribute("href", "#specifier-overview");
- expect(screen.getByRole("link", { name: "Fit & exclusions" })).toHaveAttribute("href", "#specifier-fit");
+ await waitFor(() => expect(screen.queryByTestId("secondary-navigation")).toBeNull());
+ expect(screen.queryByRole("navigation", { name: "On this page" })).toBeNull();
expect(screen.queryByTestId("mode-nav")).toBeNull();
});
@@ -181,30 +89,6 @@ describe("PageSecondaryNavigation", () => {
expect(screen.queryByTestId("mode-nav")).toBeNull();
});
- it("still gives an empty-registry mode its On this page nav", async () => {
- // Branch order guard. `forms` registers no destinations but its route still
- // claims a section set, and the informationDefinitions branch sits above
- // the mode branch precisely so that survives. Hoisting the empty-registry
- // return up with the therapy/locally-owned early returns would silently
- // strip navigation from every /services/*, /forms/*, /medications/* and
- // /documents/ record.
- //
- // The anchors are planted here rather than taken from the real page on
- // purpose: this asserts branch ORDER only. That form-detail-page.tsx now
- // renders these ids for real is the separate binding guard above — jsdom
- // applies no Tailwind, so `lg:hidden` produces no `display:none` here and
- // this could not tell a -mobile target from its -desktop twin anyway.
- render(
- ,
- );
- expect(await screen.findByRole("navigation", { name: "On this page" })).toBeVisible();
- });
-
it("gives an adopted mode the shared header bar on its workflow routes", () => {
render( );
const bar = screen.getByTestId("mode-nav");
@@ -259,23 +143,6 @@ describe("PageSecondaryNavigation", () => {
expect(screen.queryByTestId("secondary-navigation")).toBeNull();
});
- it("replaces mode navigation with only the information sections present in the record", async () => {
- render(
- ,
- );
-
- const onThisPage = await screen.findByRole("navigation", { name: "On this page" });
- expect(onThisPage).toBeVisible();
- expect(screen.getByRole("link", { name: "Overview" })).toHaveAttribute("href", "#service-overview");
- 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();
- });
-
it("leaves locally controlled information and Therapy workflow navigation to their page owners", async () => {
const { rerender } = render(
,
diff --git a/tests/secondary-navigation.dom.test.tsx b/tests/secondary-navigation.dom.test.tsx
index b7c403756..5b659cade 100644
--- a/tests/secondary-navigation.dom.test.tsx
+++ b/tests/secondary-navigation.dom.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -36,9 +36,8 @@ function ShellPlacedNavigation() {
-
);
@@ -96,8 +95,8 @@ describe("SecondaryNavigation", () => {
sticky={false}
activeId="one"
items={[
- { kind: "section", id: "one", label: "Overview", targetId: "section-one" },
- { kind: "section", id: "two", label: "Safety", targetId: "section-two" },
+ { kind: "action", id: "one", label: "Overview", onSelect: vi.fn() },
+ { kind: "action", id: "two", label: "Safety", onSelect: vi.fn() },
]}
/>,
);
@@ -110,8 +109,8 @@ describe("SecondaryNavigation", () => {
sticky={false}
activeId="two"
items={[
- { kind: "section", id: "one", label: "Overview", targetId: "section-one" },
- { kind: "section", id: "two", label: "Safety", targetId: "section-two" },
+ { kind: "action", id: "one", label: "Overview", onSelect: vi.fn() },
+ { kind: "action", id: "two", label: "Safety", onSelect: vi.fn() },
]}
/>,
);
@@ -120,46 +119,6 @@ describe("SecondaryNavigation", () => {
expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled();
});
- it("uses fragment links, location semantics, history, and reduced-motion-aware scrolling", async () => {
- vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function getRect(this: HTMLElement) {
- const top = this.id === "section-two" ? 500 : 0;
- return { x: 0, y: top, top, left: 0, right: 100, bottom: top + 40, width: 100, height: 40, toJSON() {} };
- });
- document.documentElement.setAttribute("data-motion", "reduced");
- window.history.replaceState({ nextRouteTree: "preserved" }, "", "/");
-
- render(
-
-
-
-
-
,
- );
-
- await waitFor(() =>
- expect(screen.getByRole("link", { name: "Overview" })).toHaveAttribute("aria-current", "location"),
- );
- const safety = screen.getByRole("link", { name: "Safety" });
- expect(safety).toHaveAttribute("href", "#safety");
- fireEvent.click(safety);
- expect(window.location.hash).toBe("#safety");
- expect(window.history.state).toEqual({ nextRouteTree: "preserved" });
- expect(safety).toHaveAttribute("aria-current", "location");
- expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: "auto", block: "start" });
- });
-
it("implements roving tab focus with Arrow, Home, and End keys", async () => {
const user = userEvent.setup();
render(
);
@@ -184,63 +143,6 @@ describe("SecondaryNavigation", () => {
expect(summary).toHaveAttribute("aria-selected", "true");
});
- it("aligns an initial deep-linked fragment below the pinned bar", async () => {
- window.history.replaceState(null, "", "/#section-two");
- render(
-
-
-
-
-
,
- );
-
- await waitFor(() =>
- expect(screen.getByRole("link", { name: "Safety" })).toHaveAttribute("aria-current", "location"),
- );
- await waitFor(() =>
- expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: "auto", block: "start" }),
- );
- });
-
- it("keeps a fragment-selected section current at the shared scroll-margin offset", async () => {
- let safetyTop = 500;
- vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function getRect(this: HTMLElement) {
- const top = this.id === "section-two" ? safetyTop : this.tagName === "NAV" ? 0 : -200;
- const height = this.tagName === "NAV" ? 56 : 40;
- return { x: 0, y: top, top, left: 0, right: 100, bottom: top + height, width: 100, height, toJSON() {} };
- });
- vi.mocked(Element.prototype.scrollIntoView).mockImplementation(function scrollIntoView(this: Element) {
- if (this.id === "section-two") safetyTop = 136;
- });
-
- render(
-
-
-
-
-
,
- );
-
- fireEvent.click(screen.getByRole("link", { name: "Safety" }));
- fireEvent.scroll(window);
-
- await waitFor(() =>
- expect(screen.getByRole("link", { name: "Safety" })).toHaveAttribute("aria-current", "location"),
- );
- });
-
it("reveals the active item horizontally without overriding motion preferences", async () => {
document.documentElement.setAttribute("data-motion", "reduced");
const scrollTo = vi.fn();
diff --git a/tests/ui-forms-section-nav.spec.ts b/tests/ui-forms-section-nav.spec.ts
index b2fab3f40..ed8d72396 100644
--- a/tests/ui-forms-section-nav.spec.ts
+++ b/tests/ui-forms-section-nav.spec.ts
@@ -3,18 +3,17 @@ import { expect, test } from "playwright/test";
/**
* `/issues` #256, proven where it actually failed: in the browser.
*
- * `informationPageSectionDefinitions` claims `/forms/
` before the mode
- * branch in `PageSecondaryNavigation`, then `AvailableInformationPageNavigation`
- * drops every section whose `targetIds` are absent from the DOM and returns
- * `null` when all are dropped. `form-detail-page.tsx` rendered no `id`
- * attributes at all, so the route was claimed and nothing was drawn — silently,
- * with no error and no failing test.
+ * The route used to declare its sections in a table beside the page, and the
+ * shell dropped every section whose target was absent from the DOM — so a set
+ * could rot to nothing and the only symptom was a route silently drawing no
+ * navigation, with no error and no failing test. `/forms/` now mounts the
+ * shared `InPageNavHeader` and declares its own sections, but the failure mode
+ * is the same one: `useResolvedPageSections` still drops what it cannot see.
*
- * The unit-level binding guard in `tests/page-secondary-navigation.dom.test.tsx`
- * matches `id="…"` in the source, which is necessary but not sufficient: it
- * cannot see whether an anchor is inside a branch that never renders, and jsdom
- * applies no Tailwind, so it cannot distinguish a `-mobile` target from its
- * `-desktop` twin. Only a real browser at a real width can.
+ * `tests/in-page-nav-route-sections.dom.test.tsx` asserts every declared anchor
+ * renders. That is necessary but not sufficient: jsdom applies no Tailwind, so
+ * it cannot distinguish a `-mobile` target from its `-desktop` twin. Only a real
+ * browser at a real width can.
*/
/**
* A real catalogue slug, not the `/forms/form-1` placeholder the unit tests
@@ -24,22 +23,40 @@ import { expect, test } from "playwright/test";
const FORM_ROUTE = "/forms/transport-crisis-form";
test.describe("Forms section navigation", () => {
- test("renders On this page against anchors the form record actually paints", async ({ page }) => {
+ test("opens a section sheet listing the anchors the form record actually paints", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(FORM_ROUTE, { waitUntil: "domcontentloaded" });
- const onThisPage = page.getByRole("navigation", { name: "On this page" });
- await expect(onThisPage).toBeVisible({ timeout: 20_000 });
+ const trigger = page.getByTestId("form-section-trigger");
+ await expect(trigger).toBeVisible({ timeout: 20_000 });
+ await trigger.click();
- // Every declared section resolves. Before the fix this nav did not exist.
- await expect(onThisPage.getByRole("link")).toHaveCount(6);
- await expect(onThisPage.getByRole("link", { name: "Overview" })).toBeVisible();
- await expect(onThisPage.getByRole("link", { name: "Legal boundary" })).toBeVisible();
+ const sheet = page.getByTestId("form-section-sheet");
+ await expect(sheet).toBeVisible();
+ // Every declared section resolves. Before the header existed this route drew
+ // no in-page navigation at all.
+ await expect(sheet.getByRole("button", { name: "Overview" })).toBeVisible();
+ await expect(sheet.getByRole("button", { name: "Legal boundary" })).toBeVisible();
+ await expect(sheet.getByRole("button", { name: "Decision context" })).toBeVisible();
+ await expect(sheet.getByRole("button", { name: "Source / verification" })).toBeVisible();
+ });
+
+ test("moves the reader to a section and records the stable fragment", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await page.goto(FORM_ROUTE, { waitUntil: "domcontentloaded" });
+
+ await page.getByTestId("form-section-trigger").click();
+ await page.getByTestId("form-section-sheet").getByRole("button", { name: "Source / verification" }).click();
+
+ // The breakpoint-independent fragment, not whichever copy is displayed, so
+ // a link copied on a phone still resolves on a desktop.
+ await expect(page).toHaveURL(/#form-source-verification$/);
+ await expect(page.getByTestId("form-section-sheet")).toBeHidden();
});
test("resolves each breakpoint variant to the side that is actually visible", async ({ page }) => {
// The -mobile/-desktop pairs are the part a source scan cannot check.
- // `AvailableInformationPageNavigation` takes the FIRST VISIBLE target, and
+ // `useResolvedPageSections` takes the FIRST VISIBLE target, and
// `lg:hidden` / `hidden lg:block` make exactly one side display:none per
// width. If both resolved, or neither, the section would silently drop.
for (const [label, width] of [
@@ -48,7 +65,7 @@ test.describe("Forms section navigation", () => {
] as const) {
await page.setViewportSize({ width, height: 900 });
await page.goto(FORM_ROUTE, { waitUntil: "domcontentloaded" });
- await expect(page.getByRole("navigation", { name: "On this page" })).toBeVisible({ timeout: 20_000 });
+ await expect(page.getByTestId("form-detail-header")).toBeVisible({ timeout: 20_000 });
const visible = await page.evaluate(() =>
[
diff --git a/tests/ui-formulation.spec.ts b/tests/ui-formulation.spec.ts
index afb2a463d..45ee9be11 100644
--- a/tests/ui-formulation.spec.ts
+++ b/tests/ui-formulation.spec.ts
@@ -81,14 +81,16 @@ test("searches patient language, opens a mechanism guide, and carries it into th
await page.getByRole("link", { name: "Open Rumination" }).click();
await expect(page).toHaveURL(/\/formulation\/rumination$/, { timeout: 30_000 });
await expect(page.getByRole("heading", { name: "Rumination", exact: true })).toBeVisible();
- // The record's own section label, addressed by id rather than by text. Since
- // the declared section anchors were wired up, `/formulation/` also
- // renders an "On this page" nav whose link carries the same words, and a bare
- // text locator matches both under Playwright strict mode.
+ // The record's own section label, addressed by id rather than by text: the
+ // in-page section sheet carries the same words, and a bare text locator
+ // matches both under Playwright strict mode.
await expect(page.locator("#formulation-what-matters-now-label")).toBeVisible();
+ await page.getByTestId("formulation-section-trigger").click();
await expect(
- page.getByRole("navigation", { name: "On this page" }).getByRole("link", { name: "What matters now", exact: true }),
+ page.getByTestId("formulation-section-sheet").getByRole("button", { name: "What matters now", exact: true }),
).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("formulation-section-sheet")).toBeHidden();
await page.getByRole("link", { name: "Use in formulation", exact: true }).first().click();
await expect(page.getByRole("heading", { name: "Build a formulation that can be tested" })).toBeVisible();
@@ -128,8 +130,13 @@ test("keeps mobile search, domain filtering, record actions, and universal chrom
await expect(page).toHaveURL(/\/formulation\/worry$/);
}).toPass({ timeout: 30_000 });
await expect(page.getByRole("heading", { name: "Worry", exact: true })).toBeVisible({ timeout: 30_000 });
- await expect(page.getByRole("link", { name: "Compare", exact: true }).last()).toBeVisible();
- await expect(page.getByRole("link", { name: "Use in formulation", exact: true }).first()).toBeVisible();
+ // The record's actions moved into the in-page header's ellipsis sheet.
+ await page.getByTestId("formulation-actions-trigger").click();
+ const mechanismActions = page.getByTestId("formulation-actions-sheet");
+ await expect(mechanismActions.getByRole("link", { name: "Compare", exact: true })).toBeVisible();
+ await expect(mechanismActions.getByRole("link", { name: "Use in formulation", exact: true })).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(mechanismActions).toBeHidden();
await expectNoHorizontalOverflow(page);
await expectNoBlockingAxeViolations(page, testInfo);
});
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index 68d38e8a8..d2190f491 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -3031,7 +3031,9 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(page).toHaveURL(/\/dsm\/diagnoses\/major-depressive-disorder$/, { timeout: 30_000 });
await expect(page.getByTestId("dsm-diagnosis-page")).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("heading", { level: 1, name: "Major depressive disorder" })).toBeVisible();
- await expect(page.getByRole("link", { name: "DSM-5 Diagnosis home" })).toHaveAttribute("href", "/dsm");
+ // The breadcrumb row went with the in-page header: its back control is the
+ // one route out to the mode home, and a breadcrumb under it is a second.
+ await expect(page.getByRole("link", { name: "Back to dsm-5" })).toHaveAttribute("href", "/dsm");
await expectNoPageHorizontalOverflow(page);
});
diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts
index aec4945c9..22ac5c677 100644
--- a/tests/ui-specifiers.spec.ts
+++ b/tests/ui-specifiers.spec.ts
@@ -90,9 +90,9 @@ test("searches clinical language without provenance fields and carries a result
await page.getByRole("link", { name: "Open With mixed features" }).click();
await expect(page).toHaveURL(/\/specifiers\/with-mixed-features$/, { timeout: 30_000 });
await expect(page.getByRole("heading", { name: "With mixed features", exact: true })).toBeVisible();
- // Address the record label by id. Specifier "On this page" labels do not
+ // Address the record label by id. The in-page section sheet's labels do not
// currently include these words, but the formulation sibling failed CI under
- // Playwright strict mode once a nav link shared the same text — pin the
+ // Playwright strict mode once a nav control shared the same text — pin the
// unique target rather than relying on that coincidence.
await expect(page.locator("#what-matters-now")).toBeVisible();
@@ -137,8 +137,13 @@ test("keeps mobile search, filters, results, and the fixed composer usable", asy
await page.getByRole("link", { name: "Open With seasonal pattern" }).click();
await expect(page.getByRole("heading", { name: "With seasonal pattern", exact: true })).toBeVisible();
- await expect(page.getByRole("link", { name: "Compare", exact: true }).last()).toBeVisible();
- await expect(page.getByRole("link", { name: "Build wording", exact: true }).last()).toBeVisible();
+ // The record's actions moved into the in-page header's ellipsis sheet.
+ await page.getByTestId("specifier-actions-trigger").click();
+ const recordActions = page.getByTestId("specifier-actions-sheet");
+ await expect(recordActions.getByRole("link", { name: "Compare", exact: true })).toBeVisible();
+ await expect(recordActions.getByRole("link", { name: "Build wording", exact: true })).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(recordActions).toBeHidden();
await expectNoHorizontalOverflow(page);
await expectNoBlockingAxeViolations(page, testInfo);
});
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts
index e8da24945..5cb1800b3 100644
--- a/tests/ui-tools.spec.ts
+++ b/tests/ui-tools.spec.ts
@@ -2235,14 +2235,20 @@ test.describe("Clinical KB service detail page", () => {
await gotoLauncher(page, "/services/13yarn");
const servicePage = page.locator('[data-testid="service-detail-page"]:visible').last();
- const copyContactButton = servicePage.getByRole("button", { name: "Copy contact" }).last();
await expect(servicePage).toBeVisible();
await expect(servicePage.getByRole("heading", { level: 1, name: "13YARN" })).toBeVisible();
- await expect(servicePage.getByRole("link", { name: "Services" })).toBeVisible();
- await expect(servicePage.getByRole("button", { name: "Save service" })).toBeVisible();
- await expect(copyContactButton).toBeVisible();
- await expect(servicePage.getByRole("link", { name: "Call" })).toHaveAttribute("href", "tel:139276");
- await expect(servicePage.getByRole("button", { name: "Use in navigator" })).toBeVisible();
+ // The record's controls live in the in-page header's actions sheet, and
+ // the header is a sibling of the shell rather than inside it — one page
+ // header per route, portaled into the phone collapse row below `sm`.
+ await expect(page.getByRole("link", { name: "Back to services" })).toBeVisible();
+ await page.getByTestId("service-actions-trigger").click();
+ const actions = page.getByTestId("service-actions-sheet");
+ await expect(actions.getByRole("button", { name: "Save service" })).toBeVisible();
+ await expect(actions.getByRole("button", { name: "Copy contact" })).toBeVisible();
+ await expect(actions.getByRole("link", { name: "Call" })).toHaveAttribute("href", "tel:139276");
+ await expect(actions.getByRole("button", { name: "Use in navigator" })).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(actions).toBeHidden();
await expect(page.getByTestId("global-search-input")).toHaveCount(1);
await expect(page.getByTestId("global-search-input")).toBeVisible();
await expect(servicePage.locator('[data-testid="global-search-input"]')).toHaveCount(0);
@@ -2318,7 +2324,8 @@ test.describe("Clinical KB service detail page", () => {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoLauncher(page, "/services/13yarn");
- await page.getByTestId("service-detail-page").getByRole("button", { name: "Use in navigator" }).click();
+ await page.getByTestId("service-actions-trigger").click();
+ await page.getByTestId("service-actions-sheet").getByRole("button", { name: "Use in navigator" }).click();
await expect(page).toHaveURL(/\/services\?/);
await expect(page).toHaveURL(/run=1/);
await expect(page).toHaveURL(/focus=1/);
@@ -2329,15 +2336,22 @@ test.describe("Clinical KB service detail page", () => {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoLauncher(page, "/services/13yarn");
- const servicePage = page.getByTestId("service-detail-page");
- await servicePage.getByRole("button", { name: "Save service" }).click();
+ const actionsTrigger = page.getByTestId("service-actions-trigger");
+ const actions = page.getByTestId("service-actions-sheet");
+
+ // Each action closes the sheet, so the feedback banner it writes has to stay
+ // mounted on the page behind it — that banner is the only confirmation a
+ // save or a copy ever gets.
+ await actionsTrigger.click();
+ await actions.getByRole("button", { name: "Save service" }).click();
await expect(page.getByRole("status")).toContainText("Service saved");
- await expect(servicePage.getByRole("button", { name: "Remove saved service" })).toBeVisible();
- await servicePage.getByRole("button", { name: "Copy contact" }).last().click();
+ await actionsTrigger.click();
+ await expect(actions.getByRole("button", { name: "Remove saved service" })).toBeVisible();
+ await actions.getByRole("button", { name: "Copy contact" }).click();
await expect(page.getByRole("status")).toContainText("Contact copied");
- await servicePage.getByRole("link", { name: "Services" }).click();
+ await page.getByRole("link", { name: "Back to services" }).click();
await expect(page).toHaveURL(/\/services(?:\?|$)/);
});
});
From 5f2c308d181a43d16480a608447b73102de1bc6e Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 9 Aug 2026 09:57:12 +0800
Subject: [PATCH 2/3] docs(ledger): record the in-page-nav PR 2 review
Co-Authored-By: Claude Opus 5
---
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 4d5d32a14..9e5e14a13 100644
--- a/docs/branch-review-ledger.md
+++ b/docs/branch-review-ledger.md
@@ -761,3 +761,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie
| 2026-08-08 | claude/document-viewer-optimization-tu8tnj | 2359e158cb7bca5954e9c5ee84ca0766964ad901 | PR #1741 document-viewer phone/PWA review-and-fix | supersede: fixed Production UI phone Zoom/section-trigger; handlePdfLoadSuccess clamp; prior P1/P2 fixes retained; merge-tree clean | prior verify:cheap+pr-local green; ui-smoke selectors fixed for overflow Zoom + revealPhoneHeaderControl; no provider gates |
| 2026-08-08 | claude/mode-routing-search-pages-jabe17 | 6d1099b479358caa05c92f236848117feb920d4e | shared-home mode-routed search navigation | no high-confidence P0-P2 PR-introduced defects; prior bug-hunt P1/P2s appear fixed on tip; residual: prescribing submit-from-shared-home URL omits run=1 (pre-existing path), seed effect untested behaviourally, no browser/UI proof this pass | vitest app-modes+search-route-ownership+audit-navigation+pwa-manifest 61 pass; static read of focus files vs origin/main; ledger:lookup NOT REVIEWED; no provider/UI |
| 2026-08-08 | cursor/safety-plan-phone-safe-area-624a (PR #1711) | ad1b1f5db24ed68ee4c0d5963620e4562829884e | heavy review-and-fix PR #1711 | fixed CodeRabbit sm:py guard parity; late-synced #1720 behind-but-clean; no P0/P1; Bugbot none; threads cleared; merge-tree clean; required CI green on 78c14205 pre-sync | vitest safety-plan+standalone 18p; verify:cheap 523/5582; verify:pr-local format+lint+typecheck+test+build+rag-fixtures; Production UI critical+(1)(2)(3)+PR required SUCCESS on 78c14205; no provider gates |
+| 2026-08-09 | claude/inpage-nav-pr-2-6d32f9 | 249526988ea3d65c54e69ee7ca05e514bff50ed8 | in-page-nav PR 2: convert six information routes onto InPageNavHeader; delete the shell-owned pill rail | Shipped as PR #1766. Seven components converted; actions API widened for Server Components; two DSM routes' missing anchors wired; rail and section kind removed; new per-route rendered-DOM section contract added. | verify:pr-local 527/530 files 5708 tests; verify:cheap 529/530 5710 tests; verify:phone-chrome escalated to full Chromium 398 passed then 13/13 updated specs pass; typecheck + prettier clean; Playwright production build compiled. Residual failures proven pre-existing on pristine base 9ab3b73a (issues #285, pr-handoff-stop env). |
From f99c22e1d24cee3f8aa6b7c7e09f1e956831c782 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sun, 9 Aug 2026 10:44:52 +0800
Subject: [PATCH 3/3] fix(in-page-nav): repair the service-detail test mock and
share the slug-detail test
`InPageNavHeader` calls `usePathname()`, so every test that renders a converted
page needs it on the `next/navigation` mock. `tests/service-detail-page.dom.test.tsx`
mocked only `useRouter`, which failed three cases in CI's Unit coverage job with
`No "usePathname" export is defined on the "next/navigation" mock`. It was the
only such gap: the other eight test files mocking `next/navigation` without
`usePathname` render no converted page.
Review follow-ups:
- `tests/in-page-nav-header.dom.test.tsx` resets its module-level `pathname` in
`beforeEach`. The route-change case left it at `/specifiers/compare`, so
outcomes depended on file order.
- `header-addon-slot.ts` now calls the exported `isSlugDetail` from
`information-pages.ts` instead of its own hand-copied equivalent. Every
claimant route it names must also be `isInformationPage`, and two slug tests
are how that agreement would silently diverge.
Co-Authored-By: Claude Opus 5
---
src/components/mode-nav/header-addon-slot.ts | 17 +++++------------
src/lib/information-pages.ts | 9 ++++++++-
tests/in-page-nav-header.dom.test.tsx | 8 +++++++-
tests/service-detail-page.dom.test.tsx | 3 +++
4 files changed, 23 insertions(+), 14 deletions(-)
diff --git a/src/components/mode-nav/header-addon-slot.ts b/src/components/mode-nav/header-addon-slot.ts
index f71a1dc3e..70977b594 100644
--- a/src/components/mode-nav/header-addon-slot.ts
+++ b/src/components/mode-nav/header-addon-slot.ts
@@ -1,4 +1,5 @@
import { isDocumentViewerOwnedRoute } from "@/components/clinical-dashboard/mobile-composer-reserve";
+import { isSlugDetail } from "@/lib/information-pages";
/**
* Routes whose page already portals a header into the header's single addon
@@ -32,21 +33,13 @@ export function isHeaderAddonSlotOwnedRoute(pathname: string): boolean {
// which portals through `PhoneHeaderCollapsePortal` exactly as the two above
// do. Each is a slug detail page, never the mode home or a
// builder/compare/map/search surface.
- if (isSlugDetailRoute(pathname, "/services")) return true;
- if (isSlugDetailRoute(pathname, "/forms")) return true;
- if (isSlugDetailRoute(pathname, "/specifiers")) return true;
- if (isSlugDetailRoute(pathname, "/formulation")) return true;
+ if (isSlugDetail(pathname, "/services", ["search"])) return true;
+ if (isSlugDetail(pathname, "/forms", ["search"])) return true;
+ if (isSlugDetail(pathname, "/specifiers", ["search"])) return true;
+ if (isSlugDetail(pathname, "/formulation", ["search"])) return true;
// dsm/dsm-diagnosis-page.tsx and dsm/dsm-differential-considerations-page.tsx
// — the record and its `/differentials` child, but not /dsm/search or
// /dsm/compare.
if (pathname.startsWith("/dsm/diagnoses/")) return true;
return false;
}
-
-/** `/services/some-slug`, but not `/services`, `/services/compare` or a deeper path. */
-function isSlugDetailRoute(pathname: string, home: string): boolean {
- if (!pathname.startsWith(`${home}/`)) return false;
- const rest = pathname.slice(home.length + 1);
- if (!rest || rest.includes("/")) return false;
- return !["builder", "compare", "map", "search"].includes(rest);
-}
diff --git a/src/lib/information-pages.ts b/src/lib/information-pages.ts
index 0f045d04e..2389db9ec 100644
--- a/src/lib/information-pages.ts
+++ b/src/lib/information-pages.ts
@@ -22,7 +22,14 @@ export type InformationPageMode =
const TOOL_SUFFIXES = new Set(["builder", "compare", "map"]);
-function isSlugDetail(pathname: string, home: string, extraExcluded: string[] = []): boolean {
+/**
+ * `/services/some-slug`, but not `/services`, a tool suffix, or a deeper path.
+ *
+ * Exported so `isHeaderAddonSlotOwnedRoute` can share the one implementation:
+ * every claimant route it names must also be `isInformationPage`, and a second
+ * hand-copied slug test is how that agreement would silently diverge.
+ */
+export function isSlugDetail(pathname: string, home: string, extraExcluded: string[] = []): boolean {
if (!pathname.startsWith(`${home}/`) || pathname === home) return false;
const rest = pathname.slice(home.length + 1);
if (!rest || rest.includes("/")) return false;
diff --git a/tests/in-page-nav-header.dom.test.tsx b/tests/in-page-nav-header.dom.test.tsx
index f6f791d82..d92d88bcf 100644
--- a/tests/in-page-nav-header.dom.test.tsx
+++ b/tests/in-page-nav-header.dom.test.tsx
@@ -2,7 +2,7 @@ import { render, screen, waitFor, within } from "@testing-library/react";
import Link from "next/link";
import userEvent from "@testing-library/user-event";
import { Compass, ShieldCheck, Stethoscope } from "lucide-react";
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InPageNavHeader } from "@/components/in-page-nav/in-page-nav-header";
import { toDocumentSections, type PageSection } from "@/components/in-page-nav/page-section-index";
@@ -12,6 +12,12 @@ import { phoneHeaderCollapseAddonSlotId } from "@/lib/mode-home-composer";
let pathname: string | null = null;
vi.mock("next/navigation", () => ({ usePathname: () => pathname }));
+// Module state, so a case that navigates would otherwise hand its final
+// pathname to whichever case runs next.
+beforeEach(() => {
+ pathname = null;
+});
+
/** `PhoneHeaderCollapsePortal` resolves its host only below this breakpoint. */
function stubPhoneBreakpoint(isPhone: boolean) {
vi.spyOn(window, "matchMedia").mockImplementation(
diff --git a/tests/service-detail-page.dom.test.tsx b/tests/service-detail-page.dom.test.tsx
index 44a81aade..ce1e7690f 100644
--- a/tests/service-detail-page.dom.test.tsx
+++ b/tests/service-detail-page.dom.test.tsx
@@ -6,6 +6,9 @@ import type { ServiceRecord } from "@/lib/service-ranker";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
+ // InPageNavHeader derives its sheet state from the pathname; without this the
+ // mock throws before the page renders.
+ usePathname: () => "/services/test-service",
}));
vi.mock("@/components/account-data-provider", () => ({