From 3d7d00376924122200e2ab2d4eed1f95fbc9495e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:17:48 +0000 Subject: [PATCH 1/8] Make clinical results feel instant: row placeholders, quiet Also matches, catalogue warm-up - Medication and Documents results show rows shaped like results (aria-busy on the results region) instead of a loading sentence or spinner over an empty page. - The Also matches strip is no longer drawn while it is still searching; it fills in under the main results once it has matches, and never shows a Searching row. - The first medication request of a submitted search goes without the 250 ms typing debounce; later query changes are still debounced. - Opening the Medication home warms its results chunk and the local catalogue route (no query text, nothing model-backed); Documents warms its results chunk only. Display timing only: which results appear and their order are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- docs/design-system/COMPONENTS.md | 4 +- docs/design-system/adoption-manifest.json | 4 +- .../clinical-dashboard/answer-status.tsx | 4 + .../document-search-results.tsx | 16 +-- .../medication-prescribing-workspace.tsx | 12 +- .../result-rows-placeholder.tsx | 50 +++++++ .../universal-search-also-matches.tsx | 53 ++------ .../use-medication-catalog.ts | 62 +++++---- .../use-mode-results-warmup.ts | 78 +++++++++++ .../audit-navigation-auth-regressions.test.ts | 16 ++- tests/instant-results.dom.test.tsx | 122 ++++++++++++++++++ ...ication-prescribing-workspace.dom.test.tsx | 46 +++++-- 12 files changed, 363 insertions(+), 104 deletions(-) create mode 100644 src/components/clinical-dashboard/result-rows-placeholder.tsx create mode 100644 src/components/clinical-dashboard/use-mode-results-warmup.ts create mode 100644 tests/instant-results.dom.test.tsx diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index ff20513ef4..a1e5b57de7 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1080,7 +1080,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `IconButton` | controls | yes | yes | inherited-global-root | yes | no | 3 | | `InlineNotice` | feedback | yes | yes | inherited-global-root | yes | no | 33 | | `LinkAction` | controls | yes | yes | no | yes | no | 0 | -| `LoadingPanel` | feedback | yes | yes | inherited-global-root | yes | no | 11 | +| `LoadingPanel` | feedback | yes | yes | inherited-global-root | yes | no | 10 | | `MissingValue` | feedback | yes | yes | inherited-global-root | yes | no | 11 | | `OverlayRoot` | layout | yes | yes | inherited-global-root | yes | no | 1 | | `PageHeader` | layout | yes | yes | inherited-global-root | yes | no | 16 | @@ -1095,7 +1095,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 33 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 16 | | `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 136 | -| `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 8 | +| `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 9 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 4 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | | `SourceStatusBadge` | source | yes | yes | inherited-global-root | yes | no | 4 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index ae498bc793..d9d2a0c356 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1560,7 +1560,6 @@ "src/app/(search-app)/dictionary/topics/page.tsx", "src/components/clinical-dashboard/clinical-dashboard-lazy.tsx", "src/components/clinical-dashboard/dashboard-nav.tsx", - "src/components/clinical-dashboard/document-search-results.tsx", "src/components/clinical-dashboard/global-search-shell.tsx", "src/components/clinical-dashboard/medication-record-page.tsx", "src/components/document-viewer/document-visuals-panel.tsx", @@ -1573,7 +1572,6 @@ "src/app/(search-app)/dictionary/topics/page.tsx", "src/components/clinical-dashboard/clinical-dashboard-lazy.tsx", "src/components/clinical-dashboard/dashboard-nav.tsx", - "src/components/clinical-dashboard/document-search-results.tsx", "src/components/clinical-dashboard/global-search-shell.tsx", "src/components/clinical-dashboard/medication-record-page.tsx", "src/components/document-viewer/document-visuals-panel.tsx", @@ -2609,6 +2607,7 @@ "src/app/(search-app)/open-shifts/loading.tsx", "src/app/(search-app)/roster/loading.tsx", "src/components/clinical-dashboard/document-search-results.tsx", + "src/components/clinical-dashboard/result-rows-placeholder.tsx", "src/components/clinical-dashboard/signed-image.tsx", "src/components/mode-home-page-skeleton.tsx" ], @@ -2619,6 +2618,7 @@ "src/app/(search-app)/open-shifts/loading.tsx", "src/app/(search-app)/roster/loading.tsx", "src/components/clinical-dashboard/document-search-results.tsx", + "src/components/clinical-dashboard/result-rows-placeholder.tsx", "src/components/clinical-dashboard/signed-image.tsx", "src/components/mode-home-page-skeleton.tsx" ], diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index 1d978d5aa4..308400574d 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -17,6 +17,7 @@ import { import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract"; import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; +import { useModeResultsWarmup } from "@/components/clinical-dashboard/use-mode-results-warmup"; import { ModeHomeTemplate } from "@/components/mode-home-template"; import { LazyMyDayHomeCard } from "@/components/my-day/my-day-home-card-lazy"; import { ShowAllChip } from "@/components/show-all-chip"; @@ -68,6 +69,9 @@ export function SharedHomeEmptyState({ : []; const presentation: SharedHomePresentation = sharedHomePresentation[modeId]; const catalogueChip = sharedHomeCatalogueChips[modeId]; + // Load what this mode's results page needs while the home is open, so the first + // search does not wait for it. Local chunks and catalogue only; never a query. + useModeResultsWarmup(modeId); return ( +
{showIdentityHeader ? ( + // Rows shaped like the document results on their way, not a spinner over an empty page. + ) : matches.length === 0 ? ( // A services or forms search that matched records but no documents. // This branch used to render `null`, which stranded the reader: moving diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 332240dcbc..8d68fa4e50 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -33,6 +33,7 @@ import { resultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; +import { ResultRowsPlaceholder } from "@/components/clinical-dashboard/result-rows-placeholder"; import { medicationVerdictRingClass, PatientVerdictSignal, @@ -787,6 +788,9 @@ function MedicationResults({
{/* The error branch moved into the band's fault panel, which carries the - same message and announces it once. Loading copy stays here. */} + same message and announces it once. While the first response is on its way the + list shows rows shaped like the results it is waiting for, not a line of text + over an empty page. */} {initialCatalogLoading ? ( -
-

Loading medication catalogue…

-
+ ) : null} {!initialCatalogLoading && !catalog.error && resultCount === 0 ? ( diff --git a/src/components/clinical-dashboard/result-rows-placeholder.tsx b/src/components/clinical-dashboard/result-rows-placeholder.tsx new file mode 100644 index 0000000000..71647f8cea --- /dev/null +++ b/src/components/clinical-dashboard/result-rows-placeholder.tsx @@ -0,0 +1,50 @@ +import { Skeleton, cn } from "@/components/ui-primitives"; + +/** + * Placeholder rows shaped like the result cards they stand in for. + * + * A results page used to show a spinner or one muted sentence ("Loading medication + * catalogue…") over an otherwise empty page until the first response landed, and then + * snap to a full list. These rows hold the list's real geometry instead, an icon tile + * beside a title, a subtitle and a short detail line, so the page reads as "results + * arriving" and nothing below it jumps when they do. + * + * Accessibility follows `LoadingPanel`'s skeleton variant (COMPONENTS.md §9.29): one + * `role="status"` node named by `label`, an `sr-only` twin of the label, every shimmer + * bar `aria-hidden`, and no `aria-live` on visible content (§9.2). The caller marks its + * own results region `aria-busy` while this is mounted. + */ +export function ResultRowsPlaceholder({ + label, + rows = 4, + className, + testId, +}: { + label: string; + rows?: number; + className?: string; + testId?: string; +}) { + return ( +
+ {Array.from({ length: rows }).map((_, index) => { + const delay = `${index * 60}ms`; + return ( +
+ +
+ + + +
+
+ ); + })} + {label} +
+ ); +} diff --git a/src/components/clinical-dashboard/universal-search-also-matches.tsx b/src/components/clinical-dashboard/universal-search-also-matches.tsx index 371dfc6d30..a9c9475d99 100644 --- a/src/components/clinical-dashboard/universal-search-also-matches.tsx +++ b/src/components/clinical-dashboard/universal-search-also-matches.tsx @@ -25,35 +25,6 @@ function matchCountLabel(count: number) { return count === 1 ? "1 related mode" : `${count} related modes`; } -/** - * Loading placeholder that occupies the real card geometry. - * - * Replaces a single muted sentence, which collapsed the tray to one text line - * and then snapped it open to a four-card grid the moment results landed. The - * skeletons keep the tray the height it is about to be, so nothing below the - * panel jumps. Purely decorative — announcement is owned by the visually - * hidden `role="status"` node in the panel body (SPEC §9.2: a live region is - * never visible content). - */ -function AlsoMatchesSkeletonCard({ className }: { className?: string }) { - return ( -
-
- - -
- - -
- ); -} - export function UniversalSearchAlsoMatches({ modeId, query, @@ -183,14 +154,23 @@ export function UniversalSearchAlsoMatches({ : matchCount > 0 ? `${withCatalogueDegradedNotice(`${matchCountLabel(matchCount)} also match this search`, catalogueDegraded)}.` : emptyMessage; - const headerMeta = searchPending ? "Searching…" : matchCount > 0 ? matchCountLabel(matchCount) : "No other matches"; + // No pending arm. The strip is not drawn at all until this query's matches are in (see the + // gate below), so a "Searching…" here could only ever show during a background refetch, + // where the count already on screen is the honest thing to keep saying. + const headerMeta = matchCount > 0 ? matchCountLabel(matchCount) : "No other matches"; if (!submissionActive) return null; if (!viewportReady || trimmedQuery.length < 2) return null; if (modeId === "answer" && currentGroups.length === 0) return null; // At every width now, not just from sm up: the phone lookup is eager, so a // header that would open onto nothing is dropped instead of offered. - if (!searchPending && currentGroups.length === 0) return null; + // + // And not while the lookup is still running either. This strip used to mount as soon as + // the search was submitted and sit there saying "Searching…" — over an otherwise empty + // page before the main results arrived, and still saying it after they had. It is + // secondary to the results the search asked for, so it now fills in quietly underneath + // them once it has something to show, and never holds or competes with the main list. + if (currentGroups.length === 0) return null; return (
- {searchPending ? ( - <> - - - - - - ) : null} - {!searchPending && currentGroups.length === 0 ? ( -

{emptyMessage}

- ) : null} {currentGroups.map((group) => { const targetModeId = group.modeId; const targetMode = appModeDefinition(targetModeId); diff --git a/src/components/clinical-dashboard/use-medication-catalog.ts b/src/components/clinical-dashboard/use-medication-catalog.ts index 4c4f5e85b1..2c0eae2996 100644 --- a/src/components/clinical-dashboard/use-medication-catalog.ts +++ b/src/components/clinical-dashboard/use-medication-catalog.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { authSessionFingerprint, createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; import type { MedicationRecord, MedicationSearchResult } from "@/lib/medications"; @@ -233,6 +233,12 @@ export function useMedicationCatalog( const [prevAuthIdentity, setPrevAuthIdentity] = useState(authIdentity); const [prevAuthorizationHeader, setPrevAuthorizationHeader] = useState(authorizationHeader); const [requestLifecycle] = useState(() => createAuthRequestLifecycle()); + // The debounce exists to coalesce keystrokes, so it only applies once this hook has + // actually sent a request. The first request of a freshly mounted results view (a + // submitted search) goes immediately instead of waiting out a typing pause that never + // happened. Set when the timer fires, not when it is scheduled, so a dev StrictMode + // mount/unmount/mount (which clears the first timer) still sends the first one at once. + const requestSentRef = useRef(false); const [state, setState] = useState>({ data: null, loading: enabled, @@ -269,32 +275,36 @@ export function useMedicationCatalog( const suffix = params.toString(); const url = suffix ? `/api/medications?${suffix}` : "/api/medications"; - const timer = window.setTimeout(() => { - fetchJson(url, authorizationHeader, controller.signal, parseMedicationCatalogResponse) - .then((data) => { - if (!controller.signal.aborted && isCurrentRequest()) setState({ data, loading: false, error: null }); - }) - .catch((error) => { - if ( - controller.signal.aborted || - !isCurrentRequest() || - (error instanceof DOMException && error.name === "AbortError") - ) - return; - const isOffline = - (typeof navigator !== "undefined" && !navigator.onLine) || - (error instanceof TypeError && error.message.includes("fetch")); - setState({ - data: null, - loading: false, - error: isOffline - ? "You are offline. Connect to search the live medication registry." - : error instanceof Error - ? error.message - : "Could not load medications.", + const timer = window.setTimeout( + () => { + requestSentRef.current = true; + fetchJson(url, authorizationHeader, controller.signal, parseMedicationCatalogResponse) + .then((data) => { + if (!controller.signal.aborted && isCurrentRequest()) setState({ data, loading: false, error: null }); + }) + .catch((error) => { + if ( + controller.signal.aborted || + !isCurrentRequest() || + (error instanceof DOMException && error.name === "AbortError") + ) + return; + const isOffline = + (typeof navigator !== "undefined" && !navigator.onLine) || + (error instanceof TypeError && error.message.includes("fetch")); + setState({ + data: null, + loading: false, + error: isOffline + ? "You are offline. Connect to search the live medication registry." + : error instanceof Error + ? error.message + : "Could not load medications.", + }); }); - }); - }, debounceMs); + }, + requestSentRef.current ? debounceMs : 0, + ); return () => { window.clearTimeout(timer); diff --git a/src/components/clinical-dashboard/use-mode-results-warmup.ts b/src/components/clinical-dashboard/use-mode-results-warmup.ts new file mode 100644 index 0000000000..e2c2eb518a --- /dev/null +++ b/src/components/clinical-dashboard/use-mode-results-warmup.ts @@ -0,0 +1,78 @@ +"use client"; + +import { useEffect } from "react"; + +import type { AppModeId } from "@/lib/app-modes"; + +/** + * Starts loading what a mode's results page needs while its home is still on screen, + * so the first search does not pay for it after Enter. + * + * Only local work is warmed: the results surface's JavaScript chunk, and for + * Medication the app's own catalogue route, which loads the medication list into the + * server's short-lived catalogue cache. Nothing here sends a query, calls a paid or + * model-backed service (no search, embedding or answer request), or stores anything. + * What is warmed never changes which results a search returns or their order; it only + * moves when the shared pieces are fetched. + * + * Each mode is warmed at most once per page load, after the browser goes idle, and + * every failure is ignored: a warm-up that fails leaves the search exactly as it was. + */ + +/** + * The same URL `cross-mode-links` reads (`useMedicationCatalog(undefined, { fields: "index" })`), + * so an anonymous warm-up also lands in the browser cache that reader uses. The slim index + * projection keeps the warm-up small; the server-side catalogue read it triggers is the same + * one a submitted search makes. + */ +export const medicationCatalogueWarmupUrl = "/api/medications?fields=index"; + +type ModeWarmup = () => void; + +const modeWarmups: Partial> = { + prescribing: () => { + void import("@/components/clinical-dashboard/medication-prescribing-workspace").catch(() => undefined); + if (typeof fetch === "function") { + void fetch(medicationCatalogueWarmupUrl, { priority: "low" } as RequestInit).catch(() => undefined); + } + }, + documents: () => { + // Chunk only. Document search is retrieval-backed, so there is no query-free data + // request worth making here. + void import("@/components/clinical-dashboard/document-search-results").catch(() => undefined); + }, +}; + +const warmedModes = new Set(); + +/** Runs a mode's warm-up once per page load. Exported for tests. */ +export function warmModeResults(modeId: AppModeId) { + const warmup = modeWarmups[modeId]; + if (!warmup || warmedModes.has(modeId)) return false; + warmedModes.add(modeId); + warmup(); + return true; +} + +/** Test seam: forget which modes were warmed. */ +export function resetModeResultsWarmupForTests() { + warmedModes.clear(); +} + +type IdleWindow = Window & { + requestIdleCallback?: (run: () => void, options?: { timeout: number }) => number; + cancelIdleCallback?: (handle: number) => void; +}; + +export function useModeResultsWarmup(modeId: AppModeId) { + useEffect(() => { + if (!modeWarmups[modeId] || warmedModes.has(modeId)) return; + const idleWindow = window as IdleWindow; + if (typeof idleWindow.requestIdleCallback === "function") { + const handle = idleWindow.requestIdleCallback(() => warmModeResults(modeId), { timeout: 2000 }); + return () => idleWindow.cancelIdleCallback?.(handle); + } + const timer = window.setTimeout(() => warmModeResults(modeId), 600); + return () => window.clearTimeout(timer); + }, [modeId]); +} diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index 94c948893e..14c41d3c34 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -302,15 +302,21 @@ describe("audit navigation and auth regressions", () => { expect(universalAlsoMatchesSource).toContain("const searchActive = submissionActive;"); expect(universalAlsoMatchesSource).not.toContain('modeId !== "prescribing"'); expect(universalAlsoMatchesSource).not.toContain('(isWide || modeId === "answer" || expanded)'); - // The header now says pending / a count / nothing found. The "Tap to open" - // arm it replaced survives only in the comment above the searchActive gate, - // which is why this pins the expression rather than searching for the string. + // The header says a count or nothing found. The "Tap to open" arm it replaced + // survives only in the comment above the searchActive gate, which is why this + // pins the expression rather than searching for the string. The pending arm + // ("Searching…") went on 2026-10-12: the strip is no longer drawn until this + // query's matches are in, so it fills in quietly under the main results instead + // of announcing a search over an empty page. expect(universalAlsoMatchesSource).toContain( - 'const headerMeta = searchPending ? "Searching…" : matchCount > 0 ? matchCountLabel(matchCount) : "No other matches";', + 'const headerMeta = matchCount > 0 ? matchCountLabel(matchCount) : "No other matches";', ); + expect(universalAlsoMatchesSource).not.toContain('searchPending ? "Searching…"'); expect(universalAlsoMatchesSource).toContain("enabled: trimmedQuery.length >= 2 && searchActive"); expect(universalAlsoMatchesSource).toContain('if (modeId === "answer" && currentGroups.length === 0) return null;'); - expect(universalAlsoMatchesSource).toContain("if (!searchPending && currentGroups.length === 0) return null;"); + // Not drawn while pending, and not drawn when nothing matched: one gate for both. + expect(universalAlsoMatchesSource).toContain("if (currentGroups.length === 0) return null;"); + expect(universalAlsoMatchesSource).not.toContain("if (!searchPending && currentGroups.length === 0) return null;"); expect(universalAlsoMatchesSource).toContain("const [viewportReady, setViewportReady] = useState(false);"); expect(universalAlsoMatchesSource).toContain("setViewportReady(true);"); // The panel status is a three-way now — pending / a count / nothing found — diff --git a/tests/instant-results.dom.test.tsx b/tests/instant-results.dom.test.tsx new file mode 100644 index 0000000000..0eb58800a0 --- /dev/null +++ b/tests/instant-results.dom.test.tsx @@ -0,0 +1,122 @@ +/** @vitest-environment jsdom */ + +import { act, render, renderHook, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ResultRowsPlaceholder } from "@/components/clinical-dashboard/result-rows-placeholder"; +import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; +import { + medicationCatalogueWarmupUrl, + resetModeResultsWarmupForTests, + useModeResultsWarmup, + warmModeResults, +} from "@/components/clinical-dashboard/use-mode-results-warmup"; + +/** + * Results that feel instant (site recommendations 2026-10-12, item 6). These change WHEN + * results appear, never which results or their order, so every case here is about timing + * and loading presentation only. + */ + +const authSession = vi.hoisted(() => ({ + authorizationHeader: undefined as Record | undefined, + markSessionExpired: vi.fn(), + session: null, + status: "signed_out" as const, +})); + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => authSession, +})); + +let fetchMock: ReturnType>; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +beforeEach(() => { + fetchMock = vi.fn(() => Promise.resolve(jsonResponse({ records: [], total: 0 }))); + vi.stubGlobal("fetch", fetchMock); + resetModeResultsWarmupForTests(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("medication catalogue request timing", () => { + it("sends the first request of a freshly opened results view without the typing debounce", async () => { + vi.useFakeTimers(); + renderHook(() => useMedicationCatalog("lithium")); + // Zero-delay timer: nothing waits out the 250 ms keystroke pause on a submitted search. + await act(async () => vi.advanceTimersByTimeAsync(0)); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/medications?q=lithium"); + }); + + it("still debounces later query changes, so typing does not send a request per keystroke", async () => { + vi.useFakeTimers(); + const { rerender } = renderHook(({ query }) => useMedicationCatalog(query), { + initialProps: { query: "lithium" }, + }); + await act(async () => vi.advanceTimersByTimeAsync(0)); + expect(fetchMock).toHaveBeenCalledTimes(1); + + rerender({ query: "lithium l" }); + rerender({ query: "lithium le" }); + await act(async () => vi.advanceTimersByTimeAsync(200)); + expect(fetchMock).toHaveBeenCalledTimes(1); + await act(async () => vi.advanceTimersByTimeAsync(100)); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1]?.[0]).toBe("/api/medications?q=lithium+le"); + }); +}); + +describe("mode results warm-up", () => { + it("warms the medication catalogue once, with no query text and no model-backed route", () => { + expect(warmModeResults("prescribing")).toBe(true); + expect(warmModeResults("prescribing")).toBe(false); + const urls = fetchMock.mock.calls.map(([url]) => String(url)); + expect(urls).toEqual([medicationCatalogueWarmupUrl]); + expect(medicationCatalogueWarmupUrl).not.toMatch(/[?&]q=/); + expect(urls.some((url) => url.startsWith("/api/search") || url.startsWith("/api/answer"))).toBe(false); + }); + + it("makes no data request for documents, whose search is retrieval-backed", () => { + expect(warmModeResults("documents")).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does nothing for a mode with no warm-up", () => { + expect(warmModeResults("dsm")).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("starts after the page goes idle and is cancelled if the home closes first", async () => { + vi.useFakeTimers(); + const first = renderHook(() => useModeResultsWarmup("prescribing")); + first.unmount(); + await act(async () => vi.advanceTimersByTimeAsync(5_000)); + expect(fetchMock).not.toHaveBeenCalled(); + + renderHook(() => useModeResultsWarmup("prescribing")); + expect(fetchMock).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTimeAsync(5_000)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("result rows placeholder", () => { + it("is one named status region whose shimmer rows are hidden from assistive technology", () => { + render(); + const region = screen.getByRole("status", { name: "Loading medication results" }); + expect(region).toBe(screen.getByTestId("placeholder")); + expect(region).not.toHaveAttribute("aria-live"); + const rows = region.querySelectorAll(":scope > [aria-hidden]"); + expect(rows).toHaveLength(3); + expect(screen.getByText("Loading medication results")).toHaveClass("sr-only"); + }); +}); diff --git a/tests/medication-prescribing-workspace.dom.test.tsx b/tests/medication-prescribing-workspace.dom.test.tsx index b3d55fe045..dc9b1a5726 100644 --- a/tests/medication-prescribing-workspace.dom.test.tsx +++ b/tests/medication-prescribing-workspace.dom.test.tsx @@ -13,6 +13,8 @@ vi.mock("next/navigation", () => ({ // two records: the "nothing found" state. const catalogEmpty = vi.hoisted(() => ({ current: false })); const catalogLoading = vi.hoisted(() => ({ current: false })); +// When set, the first response for this query has not landed yet: no data at all. +const catalogNoData = vi.hoisted(() => ({ current: false })); const catalogueRecords = [ { slug: "lithium", @@ -110,18 +112,20 @@ vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ( vi.mock("@/components/clinical-dashboard/use-medication-catalog", () => ({ useMedicationCatalog: () => ({ - data: { - records: catalogEmpty.current ? catalogueRecords : [], - matches: (catalogEmpty.current ? [] : [clozapine, lithium, sertraline]).map((result) => ({ - medication: undefined, - result, - score: 1, - reasons: [], - })), - interpretation: catalogInterpretation.current, - total: 3, - governance: {}, - }, + data: catalogNoData.current + ? null + : { + records: catalogEmpty.current ? catalogueRecords : [], + matches: (catalogEmpty.current ? [] : [clozapine, lithium, sertraline]).map((result) => ({ + medication: undefined, + result, + score: 1, + reasons: [], + })), + interpretation: catalogInterpretation.current, + total: 3, + governance: {}, + }, loading: catalogLoading.current, error: null, }), @@ -159,6 +163,7 @@ afterEach(() => { catalogInterpretation.current = undefined; catalogEmpty.current = false; catalogLoading.current = false; + catalogNoData.current = false; window.history.replaceState(null, "", "/"); cleanup(); vi.restoreAllMocks(); @@ -253,6 +258,23 @@ describe("MedicationPrescribingWorkspace — query interpretation", () => { expect(note.closest('[role="status"]')).not.toBeNull(); }); + it("shows rows shaped like results, marked busy, while the first response is on its way", () => { + catalogLoading.current = true; + catalogNoData.current = true; + const { container } = renderWorkspace({ query: "lithium", showHome: false }); + expect(screen.queryByText(/Loading medication catalogue/)).not.toBeInTheDocument(); + expect(screen.getByRole("status", { name: "Loading medication results" })).toBe( + screen.getByTestId("medication-results-placeholder"), + ); + expect(container.querySelector(".medication-results-workspace")).toHaveAttribute("aria-busy", "true"); + }); + + it("drops the placeholder and the busy flag once results are in", () => { + const { container } = renderWorkspace({ query: "lithium", showHome: false }); + expect(screen.queryByTestId("medication-results-placeholder")).not.toBeInTheDocument(); + expect(container.querySelector(".medication-results-workspace")).toHaveAttribute("aria-busy", "false"); + }); + it("hides a kept correction while the catalogue is still answering the new words", () => { catalogLoading.current = true; catalogInterpretation.current = { From c3d3a1204b64ad7e6d848681c6443e1f30128a66 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:28:47 +0000 Subject: [PATCH 2/8] Keep Also matches out of view until medication results land; firmer placeholder bars Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- .../medication-prescribing-workspace.tsx | 6 +++++- .../clinical-dashboard/result-rows-placeholder.tsx | 12 ++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 8d68fa4e50..9888cc5475 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -1121,7 +1121,11 @@ function MedicationResults({ })}
- + {/* Mounted (so its lookup runs in parallel) but kept out of view until the medication + list itself has arrived: it fills in underneath the results, never ahead of them. */} + - +
- - - + + +
); From 9348790de9f5aeb08780f3b1620d9d2ee6300a91 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:43:32 +0000 Subject: [PATCH 3/8] fix(search): keep the medication catalogue pause while sign-in resolves The zero-delay first request no longer fires as an anonymous call while the session is still loading; the resolved request still goes at once. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- .../use-medication-catalog.ts | 10 ++++++--- tests/instant-results.dom.test.tsx | 22 ++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/components/clinical-dashboard/use-medication-catalog.ts b/src/components/clinical-dashboard/use-medication-catalog.ts index 2c0eae2996..e755e213f3 100644 --- a/src/components/clinical-dashboard/use-medication-catalog.ts +++ b/src/components/clinical-dashboard/use-medication-catalog.ts @@ -274,10 +274,14 @@ export function useMedicationCatalog( if (fields) params.set("fields", fields); const suffix = params.toString(); const url = suffix ? `/api/medications?${suffix}` : "/api/medications"; + // While sign-in is still resolving, keep the typing pause and don't count the request as + // the first: a quick sign-in cancels this anonymous timer, and the authenticated request + // that follows still goes at once rather than waiting a debounce. + const authResolving = authStatus === "loading"; const timer = window.setTimeout( () => { - requestSentRef.current = true; + if (!authResolving) requestSentRef.current = true; fetchJson(url, authorizationHeader, controller.signal, parseMedicationCatalogResponse) .then((data) => { if (!controller.signal.aborted && isCurrentRequest()) setState({ data, loading: false, error: null }); @@ -303,7 +307,7 @@ export function useMedicationCatalog( }); }); }, - requestSentRef.current ? debounceMs : 0, + requestSentRef.current || authResolving ? debounceMs : 0, ); return () => { @@ -311,7 +315,7 @@ export function useMedicationCatalog( controller.abort(); registration.release(); }; - }, [trimmed, enabled, fields, debounceMs, authIdentity, authorizationHeader, requestLifecycle]); + }, [trimmed, enabled, fields, debounceMs, authIdentity, authorizationHeader, authStatus, requestLifecycle]); return state; } diff --git a/tests/instant-results.dom.test.tsx b/tests/instant-results.dom.test.tsx index 0eb58800a0..b5438d2db8 100644 --- a/tests/instant-results.dom.test.tsx +++ b/tests/instant-results.dom.test.tsx @@ -22,7 +22,7 @@ const authSession = vi.hoisted(() => ({ authorizationHeader: undefined as Record | undefined, markSessionExpired: vi.fn(), session: null, - status: "signed_out" as const, + status: "signed_out" as "signed_out" | "loading" | "authenticated", })); vi.mock("@/lib/supabase/client", () => ({ @@ -75,6 +75,26 @@ describe("medication catalogue request timing", () => { }); }); +describe("medication catalogue request while sign-in resolves", () => { + afterEach(() => { + authSession.status = "signed_out"; + }); + + it("waits out the pause while sign-in is loading, then sends the resolved request at once", async () => { + vi.useFakeTimers(); + authSession.status = "loading"; + const { rerender } = renderHook(() => useMedicationCatalog("lithium")); + await act(async () => vi.advanceTimersByTimeAsync(100)); + // No anonymous zero-delay request while the session is still resolving. + expect(fetchMock).not.toHaveBeenCalled(); + + authSession.status = "signed_out"; + rerender(); + await act(async () => vi.advanceTimersByTimeAsync(0)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + describe("mode results warm-up", () => { it("warms the medication catalogue once, with no query text and no model-backed route", () => { expect(warmModeResults("prescribing")).toBe(true); From d6027c821933eecf982a979673f8efa61298748e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 14:05:31 +0000 Subject: [PATCH 4/8] test(ui): wait for the cross-mode lookup before reading tap carriers The Also matches strip now mounts only once its lookup answers, so its header could land after the main rows settled and differ between runs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- tests/ui-style-contract.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/ui-style-contract.spec.ts b/tests/ui-style-contract.spec.ts index 97b80f8a9d..d967820f34 100644 --- a/tests/ui-style-contract.spec.ts +++ b/tests/ui-style-contract.spec.ts @@ -250,8 +250,15 @@ test.describe("unlayered style rules render their effect", () => { // all — the audit would enumerate an empty set and pass vacuously. The result // rows are where this repo's tap carriers actually live. const runAudit = async (): Promise => { + // The "Also matches" strip below the results only mounts once its cross-mode lookup has + // answered, so it can arrive after the main rows have settled. Wait for that lookup too, + // or a slow first answer would leave its header out of one run and in the next. + const crossModeLookup = page + .waitForResponse((response) => response.url().includes("/api/search/universal"), { timeout: 20_000 }) + .catch(() => null); await page.goto("/forms/search?q=transport&run=1", { waitUntil: "domcontentloaded" }); await page.getByTestId("form-search-mobile-results").waitFor({ state: "visible", timeout: 20_000 }); + await crossModeLookup; return waitForStableEnumeration(); }; From bd2014d16b229902b19b2f7a333dc00c29c669cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 15:55:30 +0000 Subject: [PATCH 5/8] Clinical results: keep loading status announceable, no catalogue warm-up fetch, Also matches waits for the full lookup - Drop aria-busy from the medication and document results regions: a busy parent can hold back the placeholder's status until it has gone. - Medication warm-up now preloads the results code only; the catalogue route shares the search rate limit, so a background fetch could spend it. - Also matches stays hidden until every mode's group has streamed in, so a partial count is never shown. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- .../document-search-results.tsx | 2 +- .../medication-prescribing-workspace.tsx | 3 --- .../result-rows-placeholder.tsx | 4 ++-- .../universal-search-also-matches.tsx | 8 +++---- .../use-mode-results-warmup.ts | 21 ++++--------------- tests/instant-results.dom.test.tsx | 15 +++++-------- ...ication-prescribing-workspace.dom.test.tsx | 9 ++++---- 7 files changed, 21 insertions(+), 41 deletions(-) diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index 1d2d7247ed..1ff33edbd0 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -1453,7 +1453,7 @@ function DocumentSearchResultsPanelImpl({ (unavailableMessage && !shouldShowHome); return ( -
+
{showIdentityHeader ? ( 0 ? `${withCatalogueDegradedNotice(`${matchCountLabel(matchCount)} also match this search`, catalogueDegraded)}.` : emptyMessage; - // No pending arm. The strip is not drawn at all until this query's matches are in (see the - // gate below), so a "Searching…" here could only ever show during a background refetch, - // where the count already on screen is the honest thing to keep saying. + // No pending arm. The strip is not drawn at all while this query's lookup is running (see + // the gate below), so a "Searching…" here could never show. const headerMeta = matchCount > 0 ? matchCountLabel(matchCount) : "No other matches"; if (!submissionActive) return null; @@ -170,7 +169,8 @@ export function UniversalSearchAlsoMatches({ // page before the main results arrived, and still saying it after they had. It is // secondary to the results the search asked for, so it now fills in quietly underneath // them once it has something to show, and never holds or competes with the main list. - if (currentGroups.length === 0) return null; + // Streamed groups arrive one mode at a time, so a partial count is never shown either. + if (currentGroups.length === 0 || searchPending) return null; return (
void; const modeWarmups: Partial> = { prescribing: () => { void import("@/components/clinical-dashboard/medication-prescribing-workspace").catch(() => undefined); - if (typeof fetch === "function") { - void fetch(medicationCatalogueWarmupUrl, { priority: "low" } as RequestInit).catch(() => undefined); - } }, documents: () => { - // Chunk only. Document search is retrieval-backed, so there is no query-free data - // request worth making here. void import("@/components/clinical-dashboard/document-search-results").catch(() => undefined); }, }; diff --git a/tests/instant-results.dom.test.tsx b/tests/instant-results.dom.test.tsx index b5438d2db8..8802a1ac1c 100644 --- a/tests/instant-results.dom.test.tsx +++ b/tests/instant-results.dom.test.tsx @@ -6,7 +6,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ResultRowsPlaceholder } from "@/components/clinical-dashboard/result-rows-placeholder"; import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; import { - medicationCatalogueWarmupUrl, resetModeResultsWarmupForTests, useModeResultsWarmup, warmModeResults, @@ -96,13 +95,10 @@ describe("medication catalogue request while sign-in resolves", () => { }); describe("mode results warm-up", () => { - it("warms the medication catalogue once, with no query text and no model-backed route", () => { + it("warms medication once and makes no data request, so it never spends the search rate limit", () => { expect(warmModeResults("prescribing")).toBe(true); expect(warmModeResults("prescribing")).toBe(false); - const urls = fetchMock.mock.calls.map(([url]) => String(url)); - expect(urls).toEqual([medicationCatalogueWarmupUrl]); - expect(medicationCatalogueWarmupUrl).not.toMatch(/[?&]q=/); - expect(urls.some((url) => url.startsWith("/api/search") || url.startsWith("/api/answer"))).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); }); it("makes no data request for documents, whose search is retrieval-backed", () => { @@ -120,12 +116,11 @@ describe("mode results warm-up", () => { const first = renderHook(() => useModeResultsWarmup("prescribing")); first.unmount(); await act(async () => vi.advanceTimersByTimeAsync(5_000)); - expect(fetchMock).not.toHaveBeenCalled(); - renderHook(() => useModeResultsWarmup("prescribing")); - expect(fetchMock).not.toHaveBeenCalled(); await act(async () => vi.advanceTimersByTimeAsync(5_000)); - expect(fetchMock).toHaveBeenCalledTimes(1); + // The cancelled home never warmed; the second one did, so a third call has nothing to do. + expect(warmModeResults("prescribing")).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/tests/medication-prescribing-workspace.dom.test.tsx b/tests/medication-prescribing-workspace.dom.test.tsx index dc9b1a5726..97b45414ef 100644 --- a/tests/medication-prescribing-workspace.dom.test.tsx +++ b/tests/medication-prescribing-workspace.dom.test.tsx @@ -258,7 +258,7 @@ describe("MedicationPrescribingWorkspace — query interpretation", () => { expect(note.closest('[role="status"]')).not.toBeNull(); }); - it("shows rows shaped like results, marked busy, while the first response is on its way", () => { + it("shows rows shaped like results, announced outside any busy region, while the first response is on its way", () => { catalogLoading.current = true; catalogNoData.current = true; const { container } = renderWorkspace({ query: "lithium", showHome: false }); @@ -266,13 +266,14 @@ describe("MedicationPrescribingWorkspace — query interpretation", () => { expect(screen.getByRole("status", { name: "Loading medication results" })).toBe( screen.getByTestId("medication-results-placeholder"), ); - expect(container.querySelector(".medication-results-workspace")).toHaveAttribute("aria-busy", "true"); + // No busy parent: it would hold back the placeholder's status until the rows had gone. + expect(container.querySelector(".medication-results-workspace")).not.toHaveAttribute("aria-busy"); }); - it("drops the placeholder and the busy flag once results are in", () => { + it("drops the placeholder once results are in", () => { const { container } = renderWorkspace({ query: "lithium", showHome: false }); expect(screen.queryByTestId("medication-results-placeholder")).not.toBeInTheDocument(); - expect(container.querySelector(".medication-results-workspace")).toHaveAttribute("aria-busy", "false"); + expect(container.querySelector(".medication-results-workspace")).not.toHaveAttribute("aria-busy"); }); it("hides a kept correction while the catalogue is still answering the new words", () => { From 059c130257769cf71b6ad5f44284284c1f4ae244 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:17:47 +0000 Subject: [PATCH 6/8] Tests: pin the Also matches gate that now also waits for the lookup Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- tests/audit-navigation-auth-regressions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index 14c41d3c34..56cd420fc2 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -315,7 +315,7 @@ describe("audit navigation and auth regressions", () => { expect(universalAlsoMatchesSource).toContain("enabled: trimmedQuery.length >= 2 && searchActive"); expect(universalAlsoMatchesSource).toContain('if (modeId === "answer" && currentGroups.length === 0) return null;'); // Not drawn while pending, and not drawn when nothing matched: one gate for both. - expect(universalAlsoMatchesSource).toContain("if (currentGroups.length === 0) return null;"); + expect(universalAlsoMatchesSource).toContain("if (currentGroups.length === 0 || searchPending) return null;"); expect(universalAlsoMatchesSource).not.toContain("if (!searchPending && currentGroups.length === 0) return null;"); expect(universalAlsoMatchesSource).toContain("const [viewportReady, setViewportReady] = useState(false);"); expect(universalAlsoMatchesSource).toContain("setViewportReady(true);"); From 67bf944c2fb00b08792f9afda55075099940e294 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:05:34 +0000 Subject: [PATCH 7/8] Also matches: keep the screen-reader status mounted through the lookup so the count is announced Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- .../universal-search-also-matches.tsx | 372 +++++++++--------- .../audit-navigation-auth-regressions.test.ts | 18 +- 2 files changed, 204 insertions(+), 186 deletions(-) diff --git a/src/components/clinical-dashboard/universal-search-also-matches.tsx b/src/components/clinical-dashboard/universal-search-also-matches.tsx index 40de373e17..714a153a8d 100644 --- a/src/components/clinical-dashboard/universal-search-also-matches.tsx +++ b/src/components/clinical-dashboard/universal-search-also-matches.tsx @@ -149,9 +149,9 @@ export function UniversalSearchAlsoMatches({ // What the announcer says must be what the panel is showing. Rendering one // fixed "searching / nothing found" string unconditionally would announce // "No additional matches" over a grid of four populated mode cards. - const panelStatus = searchPending - ? "Searching other modes" - : matchCount > 0 + // Read only once the lookup has landed (see liveStatus below), so it has no pending arm. + const panelStatus = + matchCount > 0 ? `${withCatalogueDegradedNotice(`${matchCountLabel(matchCount)} also match this search`, catalogueDegraded)}.` : emptyMessage; // No pending arm. The strip is not drawn at all while this query's lookup is running (see @@ -170,7 +170,6 @@ export function UniversalSearchAlsoMatches({ if (!submissionActive) return null; if (!viewportReady || trimmedQuery.length < 2) return null; - if (modeId === "answer" && currentGroups.length === 0) return null; // At every width now, not just from sm up: the phone lookup is eager, so a // header that would open onto nothing is dropped instead of offered. // @@ -180,209 +179,222 @@ export function UniversalSearchAlsoMatches({ // secondary to the results the search asked for, so it now fills in quietly underneath // them once it has something to show, and never holds or competes with the main list. // Streamed groups arrive one mode at a time, so a partial count is never shown either. - if (currentGroups.length === 0 || searchPending) return null; + const stripShown = currentGroups.length > 0 && !searchPending; + + // The live region is mounted from the moment the search is submitted, empty while the + // lookup runs, and only then filled with the count. Screen readers announce a change to a + // live region that is already on the page, not the starting text of one that appears with + // its message already in it, so mounting it together with the strip would silence the count. + // It stays outside the collapsible panel for the same reason (see the note below). + const liveStatus = ( + + {stripShown ? panelStatus : ""} + + ); return ( -
- - {/* The live region sits OUTSIDE the collapsible panel. SPEC §9.2 makes this + + {headerMeta} + + + + + {/* The live region sits OUTSIDE the collapsible panel. SPEC §9.2 makes this node the announcer and keeps the visible count `aria-hidden`; inside the panel it would be `display: none` whenever the tray is closed, so from the moment this became a real disclosure at every width the count would have reached no screen reader at all — the closed control would be a blind door for exactly the users who cannot see the number beside it. */} - - {panelStatus} - -
- {currentGroups.map((group) => { - const targetModeId = group.modeId; - const targetMode = appModeDefinition(targetModeId); - const accent = APP_MODE_ACCENT[targetModeId]; - return ( -
- - - - View all in - {withCatalogueDegradedNotice(targetMode.label, group.degraded)} - - {/* Short code beside the full mode name it abbreviates — decorative + // EVERY display utility lives in the open branch, and that is + // load-bearing. A `sm:grid` sitting in the base list beside `hidden` + // does NOT collapse this panel from 640px up: both are display + // utilities of equal specificity and Tailwind emits the `sm:` rule + // later, inside a media query, so `display: grid` wins. The panel + // would sit open on every desktop while its own trigger reported + // `aria-expanded="false"`. `cross-mode-links.tsx` carries the same + // warning after hitting it. + "grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4", + expanded ? "mt-1.5 grid" : "hidden", + )} + > + {currentGroups.map((group) => { + const targetModeId = group.modeId; + const targetMode = appModeDefinition(targetModeId); + const accent = APP_MODE_ACCENT[targetModeId]; + return ( +
+ + + + View all in + {withCatalogueDegradedNotice(targetMode.label, group.degraded)} + + {/* Short code beside the full mode name it abbreviates — decorative in the accessible name, which already says the name in full. */} - {/* Hidden on phones: the row already names the mode in full one + {/* Hidden on phones: the row already names the mode in full one span to the left, so "Meds" beside "MEDICATION" is the same word twice, and it puts a second block of colour in a header whose icon tile already carries the accent. Kept from sm up, where the row has room and the short code helps scanning a 2-up or 4-up. Same call as the sibling tray in cross-mode-links.tsx (PR #2661). */} - - {targetMode.search.statusLabel} - - - - {/* A degraded mode with nothing in it still gets its card, and the card has to say + + {targetMode.search.statusLabel} + + + + {/* A degraded mode with nothing in it still gets its card, and the card has to say what it found rather than showing an empty box: the built-in list held no match, and the published list was never read. */} - {group.items.length === 0 ? ( -

- No matches in the retained copy. -

- ) : ( -
    - {group.items.map((item) => ( -
  • - - {item.title} - - -
  • - ))} -
- )} -
- ); - })} -
-
+ {group.items.length === 0 ? ( +

+ No matches in the retained copy. +

+ ) : ( +
    + {group.items.map((item) => ( +
  • + + {item.title} + + +
  • + ))} +
+ )} +
+ ); + })} +
+ + ) : null} + ); } diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index 13fd2d9152..c4a6ddb665 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -312,16 +312,22 @@ describe("audit navigation and auth regressions", () => { ); expect(universalAlsoMatchesSource).not.toContain('searchPending ? "Searching…"'); expect(universalAlsoMatchesSource).toContain("enabled: trimmedQuery.length >= 2 && searchActive"); - expect(universalAlsoMatchesSource).toContain('if (modeId === "answer" && currentGroups.length === 0) return null;'); + // Answer mode needs no gate of its own now: with no groups the strip is simply not drawn. + expect(universalAlsoMatchesSource).not.toContain( + 'if (modeId === "answer" && currentGroups.length === 0) return null;', + ); // Not drawn while pending, and not drawn when nothing matched: one gate for both. - expect(universalAlsoMatchesSource).toContain("if (currentGroups.length === 0 || searchPending) return null;"); + expect(universalAlsoMatchesSource).toContain("const stripShown = currentGroups.length > 0 && !searchPending;"); + // The live region is mounted empty through the lookup and filled once, so the count is + // announced as a change rather than lost as a region's starting text. + expect(universalAlsoMatchesSource).toContain('{stripShown ? panelStatus : ""}'); expect(universalAlsoMatchesSource).not.toContain("if (!searchPending && currentGroups.length === 0) return null;"); expect(universalAlsoMatchesSource).toContain("const [viewportReady, setViewportReady] = useState(false);"); expect(universalAlsoMatchesSource).toContain("setViewportReady(true);"); - // The panel status is a three-way now — pending / a count / nothing found — - // because one fixed string announced "No additional matches" over a grid of - // populated mode cards. The pending arm is the one this contract is about. - expect(universalAlsoMatchesSource).toMatch(/const panelStatus = searchPending\s*\n?\s*\? "Searching other modes"/); + // The panel status is a count or nothing found, because one fixed string announced + // "No additional matches" over a grid of populated mode cards. Its pending arm went + // on 2026-10-12: the region stays empty while the lookup runs. + expect(universalAlsoMatchesSource).not.toContain('"Searching other modes"'); // Since #3PW9TY the empty message has two arms, and the contract is about which one // speaks. "No additional matches" is a claim about the catalogue's contents; a catalogue // that could not be read has no contents to report, and saying there are none is the From f5ca8346dd0238592f76731d599044731b951489 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:16:24 +0000 Subject: [PATCH 8/8] Also matches: keep the status region mounted before the search too, so a cached count is still announced Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Mws6schjKQbpCQqQzPMKSL --- .../universal-search-also-matches.tsx | 14 +++++++------- tests/audit-navigation-auth-regressions.test.ts | 4 +++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/clinical-dashboard/universal-search-also-matches.tsx b/src/components/clinical-dashboard/universal-search-also-matches.tsx index 714a153a8d..6cd95f8a36 100644 --- a/src/components/clinical-dashboard/universal-search-also-matches.tsx +++ b/src/components/clinical-dashboard/universal-search-also-matches.tsx @@ -168,8 +168,6 @@ export function UniversalSearchAlsoMatches({ "No other matches" ); - if (!submissionActive) return null; - if (!viewportReady || trimmedQuery.length < 2) return null; // At every width now, not just from sm up: the phone lookup is eager, so a // header that would open onto nothing is dropped instead of offered. // @@ -179,12 +177,14 @@ export function UniversalSearchAlsoMatches({ // secondary to the results the search asked for, so it now fills in quietly underneath // them once it has something to show, and never holds or competes with the main list. // Streamed groups arrive one mode at a time, so a partial count is never shown either. - const stripShown = currentGroups.length > 0 && !searchPending; + const stripShown = + submissionActive && viewportReady && trimmedQuery.length >= 2 && currentGroups.length > 0 && !searchPending; - // The live region is mounted from the moment the search is submitted, empty while the - // lookup runs, and only then filled with the count. Screen readers announce a change to a - // live region that is already on the page, not the starting text of one that appears with - // its message already in it, so mounting it together with the strip would silence the count. + // The live region is always mounted, empty until the strip is drawn, and only then filled + // with the count; a cached lookup can be complete on the very render a search is submitted. + // Screen readers announce a change to a live region that is already on the page, not the + // starting text of one that appears with its message already in it, so mounting it together + // with the strip would silence the count. // It stays outside the collapsible panel for the same reason (see the note below). const liveStatus = ( diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index c4a6ddb665..c58bad7929 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -317,7 +317,9 @@ describe("audit navigation and auth regressions", () => { 'if (modeId === "answer" && currentGroups.length === 0) return null;', ); // Not drawn while pending, and not drawn when nothing matched: one gate for both. - expect(universalAlsoMatchesSource).toContain("const stripShown = currentGroups.length > 0 && !searchPending;"); + expect(universalAlsoMatchesSource).toMatch( + /const stripShown =\s*submissionActive && viewportReady && trimmedQuery\.length >= 2 && currentGroups\.length > 0 && !searchPending;/, + ); // The live region is mounted empty through the lookup and filled once, so the count is // announced as a change rather than lost as a region's starting text. expect(universalAlsoMatchesSource).toContain('{stripShown ? panelStatus : ""}');