diff --git a/docs/branch-review-records/677a4c1b47f59748fc185f7ecae41a5fc108392499bb2bdd9cc0f67e0da2df10.record.md b/docs/branch-review-records/677a4c1b47f59748fc185f7ecae41a5fc108392499bb2bdd9cc0f67e0da2df10.record.md new file mode 100644 index 0000000000..9a7c89f675 --- /dev/null +++ b/docs/branch-review-records/677a4c1b47f59748fc185f7ecae41a5fc108392499bb2bdd9cc0f67e0da2df10.record.md @@ -0,0 +1 @@ +| 2026-08-13 | codex/specifier-map-compare-20260813 (PR #1912) | f2c2aa8ab9c881a9647e607ab09b1ebdc772e49b | PR #1912 heavy review follow-up: post-scroll active-state repair | Exact-head Production UI trace showed the jump selection was reasserted in the same frame as the physical scroll, then overwritten by the scroll spy on its following geometry frame. Delayed the hash-guarded reassertion by one additional animation frame and added a focused ordering regression. The existing popstate guard remains unchanged. A separate builder Start over failure touched no PR-modified source and is left for the replacement exact-head run to classify. | Production trace: 177 tests passed and the map active state alone reverted after the target scroll; deterministic frame model changed final state from episode-features to course-onset while suppressing stale reassertion after back navigation; TypeScript transpile PASS for hook and focused regression; git diff --check PASS; no manual production or provider-backed gate run. | diff --git a/docs/branch-review-records/7a2e5d1342d05d71e719e3f8fd535f42faba0eb71278789ddd84ea018fb82e31.record.md b/docs/branch-review-records/7a2e5d1342d05d71e719e3f8fd535f42faba0eb71278789ddd84ea018fb82e31.record.md new file mode 100644 index 0000000000..51f7e7da27 --- /dev/null +++ b/docs/branch-review-records/7a2e5d1342d05d71e719e3f8fd535f42faba0eb71278789ddd84ea018fb82e31.record.md @@ -0,0 +1 @@ +| 2026-08-13 | codex/specifier-map-compare-20260813 (PR #1912) | 7074d65af36ee662450e6dcf340a77d3cc404209 | PR #1912 heavy review follow-up: explicit section intent ownership | Production UI trace proved the phone chrome transition could emit a later geometry result after an explicit jump, so frame-count reassertions were inherently timing-dependent. Replaced the animation-frame workaround with a scoped explicit-fragment override: deliberate click/history navigation remains authoritative through programmatic scrolling, while wheel, touchmove, or non-editable scroll-key intent returns ownership to the geometry spy. History fragment changes replace the override directly. | Fresh Playwright trace reproduced Course and onset briefly becoming active before Episode features overwrote it; deterministic hook regressions cover incidental spy rerenders, user-scroll release, editable keyboard input, and popstate replacement; TypeScript transpile PASS for the hook and both test files; exact-head CI pending; no manual provider-backed or production gate run. | diff --git a/docs/branch-review-records/9487f80818e627bca5cf0e905a4a2eaadc09f22e0e2c221bfd96e51272503b38.record.md b/docs/branch-review-records/9487f80818e627bca5cf0e905a4a2eaadc09f22e0e2c221bfd96e51272503b38.record.md new file mode 100644 index 0000000000..0f91b96766 --- /dev/null +++ b/docs/branch-review-records/9487f80818e627bca5cf0e905a4a2eaadc09f22e0e2c221bfd96e51272503b38.record.md @@ -0,0 +1 @@ +| 2026-08-13 | codex/specifier-map-compare-20260813 (PR #1912) | 3543ad63890686ecf3ff57aaae26a7dabaca4060 | PR #1912 heavy review follow-up: regression test type correction | Exact-head Build on 5910cda failed only because the new regression cast a plain function to LucideIcon. Replaced the fake cast with the repository's real lucide-react Circle export; production hook fix is unchanged. This supersedes the earlier review record's incomplete compile confidence for the regression. | GitHub Build log reproduced TS2352 at tests/use-in-page-section-nav.dom.test.tsx:27; replacement blob 45ad89ad5307588cbd0bb28fb3f89511301030e2 verified from GitHub; previous deterministic history-race model and diff checks remain applicable; exact-head CI rerun pending. | diff --git a/docs/branch-review-records/c5563b0f95d92b6825c837e780c9d5bc350bcdd56fb4ed4a3e13462c1a20cf30.record.md b/docs/branch-review-records/c5563b0f95d92b6825c837e780c9d5bc350bcdd56fb4ed4a3e13462c1a20cf30.record.md new file mode 100644 index 0000000000..11a0b86499 --- /dev/null +++ b/docs/branch-review-records/c5563b0f95d92b6825c837e780c9d5bc350bcdd56fb4ed4a3e13462c1a20cf30.record.md @@ -0,0 +1 @@ +| 2026-08-13 | codex/specifier-map-compare-20260813 (PR #1912) | 10f33d354beb04834c1429bf3aecdb5c7b4d0bc6 | PR #1912 heavy review: stale section selection repair | Validated and fixed one PR-introduced P2 history race: an obsolete animation-frame selection could override browser back navigation; added a focused popstate regression. No other high-confidence defects found in the nine-file PR delta. The latest main was merged independently at aa51b311de8082bd1bda65d5ee74827a164a905a before publication, with no conflict; no further base sync was performed. | Deterministic old/fixed stale-frame model: reproduced then passed; TypeScript transpile PASS for hook and regression; git diff --check PASS; exact-head CI at 90a731e54092ac9b13fff7d90f8a071ac105d8a8 was green before repair; no provider-backed checks run locally. | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index d3b4e60286..01ccf9680f 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -254,6 +254,7 @@ "tests/design-sync-visual-exports.test.ts", "tests/in-page-nav-playwright-contract.test.ts", "tests/information-page-shell.dom.test.tsx", + "tests/ui-route-coverage.spec.ts", "tests/ui-specifiers.spec.ts" ], "baseline": { diff --git a/src/app/globals.css b/src/app/globals.css index e2e1b64419..fc365972f9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -64,6 +64,13 @@ which covers the composer input as well as the icon/send buttons). */ --spacing-tap: 3rem; + /* Specifier-map layout roles: jump target height, numbered step column, and + wide-screen detail rail. These stay together so the page cannot drift back + to unrelated one-off dimensions. */ + --spacing-specifier-map-jump: 5.25rem; + --spacing-specifier-map-step-number: 2.25rem; + --spacing-specifier-map-aside: 22rem; + /* Mode-home composer slots reserve their settled portal geometry before the client host adopts them. Without this structural reserve, Therapy moved the entire action stack when hydration relocated the composer (CLS 0.248). diff --git a/src/components/in-page-nav/use-in-page-section-nav.ts b/src/components/in-page-nav/use-in-page-section-nav.ts index 2f74a8f78b..869c83c63a 100644 --- a/src/components/in-page-nav/use-in-page-section-nav.ts +++ b/src/components/in-page-nav/use-in-page-section-nav.ts @@ -1,12 +1,18 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { jumpToDocumentSection } from "@/components/document-viewer/section-nav"; import { useDocumentSectionSpy } from "@/components/document-viewer/use-section-spy"; import { toDocumentSections, type PageSection } from "@/components/in-page-nav/page-section-index"; import { useResolvedPageSections } from "@/components/in-page-nav/use-resolved-page-sections"; +const scrollIntentKeys = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]); + +function isEditableTarget(target: EventTarget | null): boolean { + return target instanceof Element && Boolean(target.closest('input, textarea, select, [contenteditable="true"]')); +} + /** * Everything an information page needs to drive `InPageNavHeader`: which * declared sections are actually on screen, which one the reader is in, and what @@ -25,12 +31,45 @@ export function useInPageSectionNav(declared: readonly PageSection[]): { } { const sections = useResolvedPageSections(declared); const spySections = useMemo(() => toDocumentSections(sections), [sections]); - const { activeId, selectSection: markActive } = useDocumentSectionSpy(spySections, sections.length > 0); + const { activeId: observedActiveId, selectSection: markActive } = useDocumentSectionSpy( + spySections, + sections.length > 0, + ); + const [explicitFragmentId, setExplicitFragmentId] = useState(null); + const explicitSectionId = explicitFragmentId + ? (sections.find((section) => (section.fragmentId ?? section.id) === explicitFragmentId)?.id ?? null) + : null; + const activeId = explicitSectionId ?? observedActiveId; // The hash this hook has already scrolled to, so a re-resolve (the observer // fires on any `#main-content` mutation) does not yank the reader back. const alignedHash = useRef(null); + // A click or history jump is deliberate navigation, so keep that section + // selected while its programmatic scroll and the phone chrome settle. The raw + // geometry spy takes ownership again only when the reader actually starts + // scrolling, rather than on incidental scroll/resize frames from the jump. + useEffect(() => { + if (explicitFragmentId === null) return; + + const releaseExplicitSelection = () => setExplicitFragmentId(null); + const releaseOnScrollKey = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || isEditableTarget(event.target)) { + return; + } + if (scrollIntentKeys.has(event.key)) releaseExplicitSelection(); + }; + + window.addEventListener("wheel", releaseExplicitSelection, { passive: true }); + window.addEventListener("touchmove", releaseExplicitSelection, { passive: true }); + window.addEventListener("keydown", releaseOnScrollKey); + return () => { + window.removeEventListener("wheel", releaseExplicitSelection); + window.removeEventListener("touchmove", releaseExplicitSelection); + window.removeEventListener("keydown", releaseOnScrollKey); + }; + }, [explicitFragmentId]); + const selectSection = useCallback( (id: string) => { const fragmentId = sections.find((section) => section.id === id)?.fragmentId ?? id; @@ -40,6 +79,7 @@ export function useInPageSectionNav(declared: readonly PageSection[]): { // where the displayed target id differs. window.history.pushState(window.history.state, "", `#${fragmentId}`); alignedHash.current = fragmentId; + setExplicitFragmentId(fragmentId); markActive(id); jumpToDocumentSection(id); }, @@ -50,17 +90,22 @@ export function useInPageSectionNav(declared: readonly PageSection[]): { // section renders once per breakpoint, so the browser cannot resolve a deep // link to one on its own — `#form-source-verification` would simply do // nothing. Resolve it here instead, against whichever copy is displayed. - // Only jumping, never setting active state: the scroll spy reports where the - // reader ends up. useEffect(() => { if (sections.length === 0) return; const align = () => { const hash = window.location.hash.slice(1); - if (!hash || alignedHash.current === hash) return; + if (!hash) { + alignedHash.current = null; + setExplicitFragmentId(null); + return; + } + if (alignedHash.current === hash) return; const section = sections.find((entry) => (entry.fragmentId ?? entry.id) === hash); if (!section) return; alignedHash.current = hash; + setExplicitFragmentId(hash); + markActive(section.id); jumpToDocumentSection(section.id); }; @@ -71,7 +116,7 @@ export function useInPageSectionNav(declared: readonly PageSection[]): { window.removeEventListener("hashchange", align); window.removeEventListener("popstate", align); }; - }, [sections]); + }, [markActive, sections]); return { sections, activeId, selectSection }; } diff --git a/src/components/specifiers/specifier-compare-page.tsx b/src/components/specifiers/specifier-compare-page.tsx index a36a76430f..a454f70d1e 100644 --- a/src/components/specifiers/specifier-compare-page.tsx +++ b/src/components/specifiers/specifier-compare-page.tsx @@ -5,7 +5,6 @@ import { ArrowRight, GitCompareArrows, HelpCircle, Repeat2, Tags } from "lucide- import { useState } from "react"; import { - SpecifierBreadcrumbs, SpecifierFamilyBadge, SpecifierPageShell, SpecifierSafetyNote, @@ -94,16 +93,12 @@ export function SpecifierComparePage({ initialLeft, initialRight }: { initialLef return ( - - -
-

Close-call reasoning

-

- Compare specifiers +
+

+ Compare two specifiers

- Clarify the deciding signal, chronology, and common overcalls. This comparison changes as you choose either - specifier. + Find the deciding clinical difference.

diff --git a/src/components/specifiers/specifier-map-nav-header.tsx b/src/components/specifiers/specifier-map-nav-header.tsx new file mode 100644 index 0000000000..e7938f4f64 --- /dev/null +++ b/src/components/specifiers/specifier-map-nav-header.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Clock3, Gauge, Sparkles } from "lucide-react"; +import { createContext, useContext, type ReactNode } from "react"; + +import { InPageNavHeader } from "@/components/in-page-nav/in-page-nav-header"; +import type { PageSection } from "@/components/in-page-nav/page-section-index"; +import { useInPageSectionNav } from "@/components/in-page-nav/use-in-page-section-nav"; +import { appModeHomeHref } from "@/lib/app-modes"; + +export const specifierMapSteps = [ + { + id: "episode-features", + label: "Episode features", + description: "What is present now", + icon: Sparkles, + }, + { + id: "course-onset", + label: "Course and onset", + description: "When and how it unfolds", + icon: Clock3, + }, + { + id: "severity-remission", + label: "Severity or remission", + description: "Current burden or recovery", + icon: Gauge, + }, +] as const; + +export const specifierMapSections: readonly PageSection[] = specifierMapSteps.map(({ id, label, icon }) => ({ + id, + label, + icon, +})); + +type SpecifierMapNavigation = { + activeId: string | null; + selectSection: (id: string) => void; +}; + +const SpecifierMapNavigationContext = createContext(null); + +/** + * Owns the map route's fixed in-page-navigation declaration and its scroll spy. + * The page body consumes this single source so the header and role buttons stay + * synchronized during ordinary scrolling as well as explicit section jumps. + */ +export function SpecifierMapNavHeader({ children }: { children: ReactNode }) { + const { sections, activeId, selectSection } = useInPageSectionNav(specifierMapSections); + + return ( + + + {children} + + ); +} + +export function useSpecifierMapNavigation(): SpecifierMapNavigation { + const navigation = useContext(SpecifierMapNavigationContext); + if (!navigation) throw new Error("useSpecifierMapNavigation must be used within SpecifierMapNavHeader"); + return navigation; +} diff --git a/src/components/specifiers/specifier-map-page.tsx b/src/components/specifiers/specifier-map-page.tsx index b6413cf652..c31312f7e5 100644 --- a/src/components/specifiers/specifier-map-page.tsx +++ b/src/components/specifiers/specifier-map-page.tsx @@ -1,60 +1,112 @@ "use client"; import Link from "next/link"; -import { ArrowRight, CheckCircle2, GitCompareArrows, Tags, Waypoints } from "lucide-react"; +import { ArrowDown, ArrowRight, CheckCircle2, GitCompareArrows, Tags, Waypoints } from "lucide-react"; import { useState } from "react"; +import { inPageAnchor } from "@/components/in-page-nav/in-page-nav-classes"; +import { + SpecifierMapNavHeader, + specifierMapSteps, + useSpecifierMapNavigation, +} from "@/components/specifiers/specifier-map-nav-header"; import { DiagnosisChips, - SpecifierBreadcrumbs, SpecifierFamilyBadge, SpecifierPageShell, SpecifierSafetyNote, - SpecifierWordingPathway, specifierCard, } from "@/components/specifiers/specifier-ui"; import { cn, eyebrowText } from "@/components/ui-primitives"; import { findSpecifier, specifierFamilies, specifierRecords } from "@/lib/specifiers"; export function SpecifierMapPage({ initialSlug }: { initialSlug?: string }) { + return ( + + + + ); +} + +function SpecifierMapPageContent({ initialSlug }: { initialSlug?: string }) { const [selectedSlug, setSelectedSlug] = useState(findSpecifier(initialSlug ?? "")?.slug ?? specifierRecords[0].slug); const selected = findSpecifier(selectedSlug) ?? specifierRecords[0]; + const { activeId, selectSection } = useSpecifierMapNavigation(); return ( - - -
-

Diagnostic architecture

-

- Specifier map +
+

+ Find the right specifier

- Browse by the job each specifier performs. The sequence keeps diagnostic wording clear without implying that - every diagnosis uses every category. + Choose a clinical role, then select a specifier.

- + -
-
+
+
{specifierFamilies .filter((family) => family.id !== "all") .map((family) => { const records = specifierRecords.filter((record) => record.family === family.id); return ( -
+
-

{family.label}

+

{family.label}

{records.length} options

-
+
{records.map((record) => { const active = selected.slug === record.slug; return ( @@ -64,7 +116,7 @@ export function SpecifierMapPage({ initialSlug }: { initialSlug?: string }) { onClick={() => setSelectedSlug(record.slug)} aria-pressed={active} className={cn( - "group flex min-h-[4.5rem] w-full items-center gap-3 px-4 py-3 text-left transition focus-visible:relative focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]", + "group flex min-h-[4.5rem] w-full items-center gap-3 px-4 py-3 text-left transition motion-reduce:transition-none focus-visible:relative focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]", active ? "bg-[color:var(--clinical-accent-soft)]" : "bg-[color:var(--surface)] hover:bg-[color:var(--surface-subtle)]", @@ -98,7 +150,7 @@ export function SpecifierMapPage({ initialSlug }: { initialSlug?: string }) { ); })}
-
+
); })}
diff --git a/src/lib/tailwind-merge.ts b/src/lib/tailwind-merge.ts index 4574a6fe70..db1d530947 100644 --- a/src/lib/tailwind-merge.ts +++ b/src/lib/tailwind-merge.ts @@ -103,6 +103,9 @@ export const CLINICAL_TWMERGE_THEME = { "mode-home-composer-wide", "safe", "safe-2", + "specifier-map-aside", + "specifier-map-jump", + "specifier-map-step-number", "tap", ], diff --git a/tests/in-page-nav-route-sections.dom.test.tsx b/tests/in-page-nav-route-sections.dom.test.tsx index 9252967e7f..8dc068d1c2 100644 --- a/tests/in-page-nav-route-sections.dom.test.tsx +++ b/tests/in-page-nav-route-sections.dom.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -26,6 +26,8 @@ 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 { specifierMapSections } from "@/components/specifiers/specifier-map-nav-header"; +import { SpecifierMapPage } from "@/components/specifiers/specifier-map-page"; import { SpecifierRecordPage } from "@/components/specifiers/specifier-record-page"; import { SpecifierReferencePage } from "@/components/specifiers/specifier-reference-page"; import { dsmDiagnoses } from "@/lib/dsm"; @@ -153,6 +155,11 @@ const routes: RouteCase[] = [ sections: formNavSections, render: () => , }, + { + name: "/specifiers/map", + sections: specifierMapSections, + render: () => , + }, { name: "/specifiers/[slug] (curated record)", sections: specifierNavSections, @@ -267,10 +274,75 @@ describe("in-page navigation section contracts", () => { it("covers every route that mounts the shared header", () => { // A component converted without a case here would leave its declared - // sections unguarded, which is the whole failure mode. Seven anchor-scrolling + // sections unguarded, which is the whole failure mode. Eight anchor-scrolling // routes plus one factsheet case per `kind`; the medication page swaps // panels rather than scrolling and is guarded by the suite below. - expect(routes).toHaveLength(12); + expect(routes).toHaveLength(13); + }); + + it("keeps the specifier map header and role buttons synchronized during ordinary scrolling", async () => { + class TestIntersectionObserver { + readonly root = null; + readonly rootMargin = ""; + readonly thresholds = []; + + observe() {} + unobserve() {} + disconnect() {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + } + + vi.stubGlobal("IntersectionObserver", TestIntersectionObserver); + let scrollY = 0; + const scrollSpy = vi.spyOn(window, "scrollY", "get").mockImplementation(() => scrollY); + const sectionTops: Record = { + "episode-features": 0, + "course-onset": 320, + "severity-remission": 640, + }; + const clientRectsSpy = vi.spyOn(HTMLElement.prototype, "getClientRects").mockImplementation(function ( + this: HTMLElement, + ) { + return sectionTops[this.id] === undefined + ? ([] as unknown as DOMRectList) + : ([this.getBoundingClientRect()] as unknown as DOMRectList); + }); + const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function ( + this: HTMLElement, + ) { + const top = (sectionTops[this.id] ?? 1_000) - scrollY; + return { + x: 0, + y: top, + top, + right: 100, + bottom: top + 100, + left: 0, + width: 100, + height: 100, + toJSON: () => ({}), + } as DOMRect; + }); + + try { + render(); + await waitFor(() => expect(screen.getByTestId("specifier-map-section-trigger")).toBeInTheDocument()); + + scrollY = 160; + fireEvent.scroll(window); + + await waitFor(() => { + expect(screen.getByTestId("specifier-map-jump-course-onset")).toHaveAttribute("aria-current", "true"); + expect(screen.getByTestId("specifier-map-section-trigger")).toHaveTextContent("Course and onset"); + }); + } finally { + clientRectsSpy.mockRestore(); + rectSpy.mockRestore(); + scrollSpy.mockRestore(); + vi.unstubAllGlobals(); + } }); }); diff --git a/tests/mode-nav-addon-slot.dom.test.tsx b/tests/mode-nav-addon-slot.dom.test.tsx index fa0e9c263d..0af4942f1a 100644 --- a/tests/mode-nav-addon-slot.dom.test.tsx +++ b/tests/mode-nav-addon-slot.dom.test.tsx @@ -244,6 +244,7 @@ describe("header addon slot ownership", () => { "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-map-nav-header.tsx", "src/components/specifiers/specifier-nav-header.tsx", ]); }); diff --git a/tests/ui-route-coverage.spec.ts b/tests/ui-route-coverage.spec.ts index c0e2d022c3..509c2e0edb 100644 --- a/tests/ui-route-coverage.spec.ts +++ b/tests/ui-route-coverage.spec.ts @@ -433,7 +433,9 @@ test.describe("previously uncovered production routes", () => { "/specifiers/compare?a=with-mixed-features&b=with-anxious-distress", async (currentPage) => { await expect(currentPage.getByRole("main")).toBeVisible(); - await expect(currentPage.getByRole("heading", { name: "Compare specifiers", level: 1 })).toBeVisible(); + await expect(currentPage.getByRole("heading", { name: "Compare two specifiers", level: 1 })).toBeVisible(); + await expect(currentPage.getByText("Find the deciding clinical difference.", { exact: true })).toBeVisible(); + await expect(currentPage.getByRole("navigation", { name: "Breadcrumb" })).toHaveCount(0); }, async (currentPage) => { const selects = currentPage.locator("select"); @@ -454,9 +456,15 @@ test.describe("previously uncovered production routes", () => { "/specifiers/map?selected=with-anxious-distress", async (currentPage) => { await expect(currentPage.getByRole("main")).toBeVisible(); - await expect(currentPage.getByRole("heading", { name: "Specifier map", level: 1 })).toBeVisible(); + await expect(currentPage.getByRole("heading", { name: "Find the right specifier", level: 1 })).toBeVisible(); + await expect(currentPage.getByRole("navigation", { name: "Breadcrumb" })).toHaveCount(0); }, async (currentPage) => { + const courseJump = currentPage.getByTestId("specifier-map-jump-course-onset"); + await courseJump.click(); + await expect(courseJump).toHaveAttribute("aria-current", "true"); + await expect(currentPage).toHaveURL(/#course-onset$/); + const mixedFeatures = currentPage.getByRole("button", { name: "Mixed features" }); await expect(mixedFeatures).toBeEnabled(); await mixedFeatures.click(); diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts index 9f815c74c3..c69abaf6de 100644 --- a/tests/ui-specifiers.spec.ts +++ b/tests/ui-specifiers.spec.ts @@ -280,6 +280,36 @@ test("keeps mobile search, filters, results, and the fixed composer usable", asy await expectNoBlockingAxeViolations(page, testInfo); }); +test("keeps the specifier map flow usable with reduced motion and forced colors", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 320, height: 844 }); + await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" }); + await gotoApp(page, "/specifiers/map?selected=with-anxious-distress"); + + await expect(page.getByRole("heading", { name: "Find the right specifier", level: 1 })).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toHaveCount(0); + const roleNavigation = page.getByRole("navigation", { name: "Choose a specifier role" }); + await expect(roleNavigation.getByRole("button")).toHaveCount(3); + await expect(page.getByText("Base diagnosis", { exact: true })).toHaveCount(0); + + const courseJump = page.getByTestId("specifier-map-jump-course-onset"); + await courseJump.focus(); + await expect(courseJump).toBeFocused(); + await expect + .poll(() => courseJump.evaluate((button) => Number.parseFloat(getComputedStyle(button).transitionDuration))) + .toBeLessThanOrEqual(0.001); + await page.keyboard.press("Enter"); + + await expect(page).toHaveURL(/#course-onset$/); + await expect(courseJump).toHaveAttribute("aria-current", "true"); + await expect(page.locator("#course-onset")).toBeInViewport(); + await expectNoHorizontalOverflow(page); + await expectNoBlockingAxeViolations(page, testInfo); + await testInfo.attach("specifier-map-flow-phone-forced-colors", { + body: await page.screenshot(), + contentType: "image/png", + }); +}); + test("keeps the base diagnosis severity-neutral when applying a severity descriptor", async ({ page }) => { await gotoApp(page, "/specifiers/builder?specifier=mild-severity"); diff --git a/tests/use-in-page-section-nav-frame-order.dom.test.tsx b/tests/use-in-page-section-nav-frame-order.dom.test.tsx new file mode 100644 index 0000000000..1c14af86f7 --- /dev/null +++ b/tests/use-in-page-section-nav-frame-order.dom.test.tsx @@ -0,0 +1,65 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { Circle } from "lucide-react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { PageSection } from "@/components/in-page-nav/page-section-index"; +import { useInPageSectionNav } from "@/components/in-page-nav/use-in-page-section-nav"; + +const navigationMocks = vi.hoisted(() => ({ + jumpToDocumentSection: vi.fn(), + markActive: vi.fn(), +})); + +vi.mock("@/components/document-viewer/section-nav", () => ({ + jumpToDocumentSection: navigationMocks.jumpToDocumentSection, +})); + +vi.mock("@/components/document-viewer/use-section-spy", () => ({ + useDocumentSectionSpy: () => ({ + activeId: "episode-features", + selectSection: navigationMocks.markActive, + }), +})); + +vi.mock("@/components/in-page-nav/use-resolved-page-sections", () => ({ + useResolvedPageSections: (declared: readonly PageSection[]) => [...declared], +})); + +const sections: readonly PageSection[] = [ + { id: "episode-features", label: "Episode features", icon: Circle }, + { id: "course-onset", label: "Course and onset", icon: Circle }, +]; + +afterEach(() => { + cleanup(); + navigationMocks.jumpToDocumentSection.mockReset(); + navigationMocks.markActive.mockReset(); + vi.restoreAllMocks(); + window.history.replaceState(window.history.state, "", window.location.pathname); +}); + +describe("useInPageSectionNav explicit ordering", () => { + it("does not let an incidental spy result overwrite a deliberate jump", () => { + const { result, rerender } = renderHook(() => useInPageSectionNav(sections)); + + act(() => result.current.selectSection("course-onset")); + expect(result.current.activeId).toBe("course-onset"); + + rerender(); + + expect(result.current.activeId).toBe("course-onset"); + expect(navigationMocks.markActive).toHaveBeenCalledTimes(1); + }); + + it("does not treat navigation keys inside an input as scroll intent", () => { + const input = document.createElement("input"); + document.body.append(input); + const { result } = renderHook(() => useInPageSectionNav(sections)); + + act(() => result.current.selectSection("course-onset")); + act(() => input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }))); + + expect(result.current.activeId).toBe("course-onset"); + input.remove(); + }); +}); diff --git a/tests/use-in-page-section-nav.dom.test.tsx b/tests/use-in-page-section-nav.dom.test.tsx new file mode 100644 index 0000000000..25a7eea55a --- /dev/null +++ b/tests/use-in-page-section-nav.dom.test.tsx @@ -0,0 +1,74 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { Circle } from "lucide-react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { PageSection } from "@/components/in-page-nav/page-section-index"; +import { useInPageSectionNav } from "@/components/in-page-nav/use-in-page-section-nav"; + +const navigationMocks = vi.hoisted(() => ({ + jumpToDocumentSection: vi.fn(), + markActive: vi.fn(), +})); + +vi.mock("@/components/document-viewer/section-nav", () => ({ + jumpToDocumentSection: navigationMocks.jumpToDocumentSection, +})); + +vi.mock("@/components/document-viewer/use-section-spy", () => ({ + useDocumentSectionSpy: () => ({ + activeId: "episode-features", + selectSection: navigationMocks.markActive, + }), +})); + +vi.mock("@/components/in-page-nav/use-resolved-page-sections", () => ({ + useResolvedPageSections: (declared: readonly PageSection[]) => [...declared], +})); + +const sections: readonly PageSection[] = [ + { id: "episode-features", label: "Episode features", icon: Circle }, + { id: "course-onset", label: "Course and onset", icon: Circle }, +]; + +afterEach(() => { + cleanup(); + navigationMocks.jumpToDocumentSection.mockReset(); + navigationMocks.markActive.mockReset(); + vi.restoreAllMocks(); + window.history.replaceState(window.history.state, "", window.location.pathname); +}); + +describe("useInPageSectionNav", () => { + it("keeps an explicit jump selected until the reader starts scrolling", () => { + const { result } = renderHook(() => useInPageSectionNav(sections)); + + act(() => result.current.selectSection("course-onset")); + + expect(result.current.activeId).toBe("course-onset"); + expect(window.location.hash).toBe("#course-onset"); + expect(navigationMocks.markActive).toHaveBeenLastCalledWith("course-onset"); + expect(navigationMocks.jumpToDocumentSection).toHaveBeenLastCalledWith("course-onset"); + + act(() => window.dispatchEvent(new Event("scroll"))); + expect(result.current.activeId).toBe("course-onset"); + + act(() => window.dispatchEvent(new Event("wheel"))); + expect(result.current.activeId).toBe("episode-features"); + }); + + it("replaces an explicit selection when browser history changes the fragment", () => { + const { result } = renderHook(() => useInPageSectionNav(sections)); + + act(() => result.current.selectSection("course-onset")); + expect(result.current.activeId).toBe("course-onset"); + + act(() => { + window.history.replaceState(window.history.state, "", "#episode-features"); + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + }); + + expect(result.current.activeId).toBe("episode-features"); + expect(navigationMocks.markActive).toHaveBeenLastCalledWith("episode-features"); + expect(navigationMocks.jumpToDocumentSection).toHaveBeenLastCalledWith("episode-features"); + }); +});