From c9c494e068e8c4adb2901eae6ccf500b48a7e2f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 05:11:30 +0000 Subject: [PATCH 01/30] feat(search): give the results band a truthful failure state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared results band had two states, loading or done, and no failure path. When a registry call errored or returned unauthorized, the page handed it matchCount={0} and it rendered a confident "0 matches". On the services navigator that reads as "there are no crisis services" when the truth is "we could not check" — the band asserted a clinical fact it had no basis for. The same held on medications, differentials, documents, forms and favourites. Add a `status` union (ready | loading | refetching | error | unauthorized) so the invariant "a faulted search never asserts a number" lives in one guard rather than being re-derived per call site. A union rather than a second boolean because `loading && error` is otherwise representable and undefined; it also mirrors RegistryRequestStatus, so most callers can pass a near-identity map of the status they already hold. The deprecated `loading` prop stays as a shim so the five pages with no async source keep working untouched and the existing DOM assertions are undisturbed. No call site changes yet — this commit only adds the capability. Accessibility: the spine keeps one unconditional role="status" (Playwright asserts it is visible on every search route), and the fault panel below carries role="alert". While faulted the spine's live region is silenced so the alert makes the single announcement rather than both speaking. Retry goes through the shared AsyncButton busy contract. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- .../search-results-header-band.tsx | 164 ++++++++++++++++-- tests/search-results-header-band.dom.test.tsx | 71 ++++++++ 2 files changed, 220 insertions(+), 15 deletions(-) diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index 1f41848352..f7e47a4d4c 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -1,14 +1,34 @@ "use client"; -import { Bookmark, ChevronsUpDown, LayoutList, LoaderCircle, Search, Table2, X } from "lucide-react"; +import { Bookmark, ChevronsUpDown, CircleAlert, LayoutList, LoaderCircle, Search, Table2, X } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { searchCommandSurfaceConfig } from "@/lib/search-command-surface"; -import { cn } from "@/components/ui-primitives"; +import { AsyncButton, cn } from "@/components/ui-primitives"; import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; -import type { AppModeId } from "@/lib/app-modes"; +import { appModeSearchConfig, type AppModeId } from "@/lib/app-modes"; import { readResultSort, type ResultSortValue } from "@/lib/result-sort"; +/** + * How far the count can be trusted. This is a union rather than a pair of + * booleans because `loading && error` is otherwise representable and undefined, + * and because the clinical invariant — a faulted search must never assert a + * number — is then one guard instead of a boolean-precedence puzzle repeated at + * every call site. It mirrors `RegistryRequestStatus` so most callers can pass a + * near-identity map of the status they already hold. + */ +export type SearchResultsBandStatus = + /** The count is trustworthy. `0` is a real answer. */ + | "ready" + /** First load. There is no trustworthy prior count to show. */ + | "loading" + /** Background refresh. The prior count is still trustworthy. */ + | "refetching" + /** The search failed. The count is NOT trustworthy and must not be rendered. */ + | "error" + /** Sign-in required. The count is NOT trustworthy and must not be rendered. */ + | "unauthorized"; + const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; @@ -63,7 +83,12 @@ export function SearchResultsHeaderBand({ modeId, query, matchCount, + status, loading = false, + faultTitle, + faultBody, + onRetry, + faultAction, view = "table", onViewChange, sortValue = "relevance", @@ -79,7 +104,20 @@ export function SearchResultsHeaderBand({ modeId: AppModeId; query: string; matchCount: number; + /** Result trustworthiness. Defaults to `ready`, or `loading` when the legacy + `loading` prop is set. A faulted status never renders a number. */ + status?: SearchResultsBandStatus; + /** @deprecated Pass `status="loading"`. Retained so the pages with no async + source need no edit; ignored whenever `status` is supplied. */ loading?: boolean; + /** Fault panel heading. Defaults to mode-specific copy. */ + faultTitle?: string; + /** Fault panel body. Defaults to mode-specific copy. */ + faultBody?: string; + /** Renders an in-panel Retry through the shared busy contract. */ + onRetry?: () => void | Promise; + /** Replaces Retry when recovery is not a re-request (e.g. a sign-in link). */ + faultAction?: ReactNode; view?: "table" | "list"; onViewChange?: (view: "table" | "list") => void; sortValue?: ResultSortValue; @@ -104,6 +142,27 @@ export function SearchResultsHeaderBand({ return scope ? [scope] : []; }); const displayQuery = query.trim() || "All"; + // `status` wins when both are passed; `loading` is the deprecated shim. + const resolvedStatus: SearchResultsBandStatus = status ?? (loading ? "loading" : "ready"); + // The clinical invariant, expressed once: a search that failed has no count to + // report, so no number may reach the DOM. "0 matches" on a failed services + // search reads as "there are no crisis services" rather than "we could not check". + const faulted = resolvedStatus === "error" || resolvedStatus === "unauthorized"; + const busy = resolvedStatus === "loading" || resolvedStatus === "refetching"; + // "Service matches" -> "Services", leaving already-plural headings ("Favourites", + // "DSM diagnoses") untouched. Phrasing the title as " could not be loaded" + // rather than "Could not load " avoids having to lower-case the leading + // word, which would mangle the acronym in "DSM diagnoses". + const searchConfig = appModeSearchConfig(modeId); + const resultNoun = searchConfig?.resultHeading?.replace(/ matches$/i, "s") ?? "Results"; + const resolvedFaultTitle = + faultTitle ?? (resolvedStatus === "unauthorized" ? "Sign in to continue" : `${resultNoun} could not be loaded`); + const resolvedFaultBody = + faultBody ?? + (resolvedStatus === "unauthorized" + ? "Your session has expired. Sign in again to run this search." + : "The search could not be completed. Try again shortly."); + const [retrying, setRetrying] = useState(false); const hasUtilities = visibleScopes.length > 0 || Boolean(onSortChange || onViewChange || onSaveSearch || utilityControls || mobileControls); @@ -113,7 +172,8 @@ export function SearchResultsHeaderBand({ return (
- - + + {faulted ? ( + + ) : ( + + )} {/* No eyebrow: the icon already says "search", and the query is the only thing in this band set at heading weight. */} @@ -143,25 +215,47 @@ export function SearchResultsHeaderBand({ {/* Neutral, not a success pill: a count is not a state that was achieved, and green has to keep meaning something where it does appear. */} + {/* One unconditional `role="status"` in every state. Playwright asserts it + is visible on every search route, so it must never be swapped out or + wrapped in a state branch. While faulted the live region is silenced + (`aria-live="off"`) and the freshly-mounted fault `role="alert"` below + makes the single announcement, rather than both speaking. */} - {loading ? ( + {resolvedStatus === "loading" ? ( Searching… + ) : resolvedStatus === "error" ? ( + "Couldn’t search" + ) : resolvedStatus === "unauthorized" ? ( + "Sign in to search" ) : ( - <> - {matchCount}{" "} - {matchCount === 1 ? "match" : "matches"} - + // `refetching` keeps text content identical to `ready` so the atomic + // live region does not re-announce an unchanged count; the dot is + // decorative and the dimming is CSS via `data-status`. + + {resolvedStatus === "refetching" ? ( + + ) : null} + + {matchCount}{" "} + {matchCount === 1 ? "match" : "matches"} + + )}
@@ -293,6 +387,46 @@ export function SearchResultsHeaderBand({ {filterControls}
) : null} + {/* The fault panel carries the announcement and the recovery affordance. + `role="alert"` is a distinct role from the spine's `role="status"`, so + singular role queries in jsdom and Playwright still resolve to exactly + one node each. */} + {faulted ? ( +
+

{resolvedFaultTitle}

+

{resolvedFaultBody}

+ {onRetry || faultAction ? ( +
+ {onRetry ? ( + { + setRetrying(true); + try { + await onRetry(); + } finally { + setRetrying(false); + } + }} + className={cn( + "inline-flex min-h-tap shrink-0 items-center justify-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-xs font-extrabold text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] sm:min-h-10", + focusRing, + )} + > + Retry + + ) : null} + {faultAction} +
+ ) : null} +
+ ) : null}
); } diff --git a/tests/search-results-header-band.dom.test.tsx b/tests/search-results-header-band.dom.test.tsx index 47e9189d4d..a89deeec8a 100644 --- a/tests/search-results-header-band.dom.test.tsx +++ b/tests/search-results-header-band.dom.test.tsx @@ -39,6 +39,77 @@ describe("SearchResultsHeaderBand", () => { expect(screen.queryByText("8 matches")).toBeNull(); }); + // The clinical invariant. A failed services search that renders "0 matches" + // asserts "there are no crisis services" when the truth is "we could not + // check", so no digit may reach the DOM while faulted. + it("asserts no count when the search failed", () => { + render(); + + const region = screen.getByRole("region", { name: "Search results for CMHT" }); + expect(region).toHaveAttribute("aria-busy", "false"); + expect(screen.getByRole("status")).toHaveTextContent("Couldn’t search"); + // No digit may reach the DOM: that is the whole invariant. + expect(within(region).queryByText(/\d/)).toBeNull(); + }); + + it("keeps exactly one status region and one alert while faulted", () => { + render(); + + // Singular role queries throw when they match more than one node, so these + // also prove the spine's status was not duplicated by the fault panel. + expect(screen.getByRole("status")).toBeVisible(); + expect(screen.getByRole("alert")).toHaveTextContent("Services could not be loaded"); + }); + + it("distinguishes an expired session from a broken search", () => { + render(); + + expect(screen.getByRole("status")).toHaveTextContent("Sign in to search"); + expect(screen.getByRole("alert")).toHaveTextContent("Sign in to continue"); + }); + + it("offers retry through the shared busy contract when a recovery handler exists", async () => { + const user = userEvent.setup(); + const onRetry = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it("omits the retry affordance when no recovery handler is supplied", () => { + render(); + + expect(screen.queryByRole("button", { name: "Retry" })).toBeNull(); + }); + + // Zero is a real answer and must stay distinguishable from a failure. + it("reports a genuine zero result without raising a fault", () => { + render(); + + expect(screen.getByRole("status")).toHaveTextContent("0 matches"); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("keeps the prior count visible while refetching", () => { + render(); + + expect(screen.getByRole("region", { name: "Search results for CMHT" })).toHaveAttribute("aria-busy", "true"); + // Text content must match the ready state exactly so the atomic live region + // does not re-announce an unchanged count. + expect(screen.getByRole("status")).toHaveTextContent("8 matches"); + }); + + it("lets an explicit status override the deprecated loading shim", () => { + render(); + + expect(screen.getByRole("region", { name: "Search results for CMHT" })).toHaveAttribute("aria-busy", "false"); + expect(screen.getByRole("status")).toHaveTextContent("4 matches"); + }); + it("does not render an empty utility strip for a stale scope from another mode", () => { render( Date: Tue, 28 Jul 2026 05:15:06 +0000 Subject: [PATCH 02/30] fix(services): stop asserting "0 matches" when the registry never loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The services navigator passed `loading={registryLoading}` and left `registryBlocked` unused by the band, so an errored or unauthorized registry reached the header as matchCount={0} and rendered "0 matches". On the crisis-services surface that states there are no crisis services when the search never ran. Pass the real status instead, with the existing notice copy moved into the band's fault panel and `registry.refetch` — previously computed and never used — wired to Retry. The unauthorized case keeps its distinct "Session expired" copy and the account-setup link rather than being flattened into a generic error. Remove the now-duplicate ModeHomeStatusNotice blocks below the band so the failure is announced once. The mode-home page keeps its own notice; it has no results band. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- .../services/services-navigator-page.tsx | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 7cc81bda05..b6a880d619 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -12,7 +12,6 @@ import { DollarSign, ExternalLink, Phone, - ShieldAlert, ShieldCheck, SlidersHorizontal, Sparkles, @@ -23,7 +22,6 @@ import { import { useMemo, useState, useDeferredValue } from "react"; import { cn } from "@/components/ui-primitives"; -import { ModeHomeStatusNotice } from "@/components/mode-home-template"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; import { MobileResultFilterControl, @@ -634,7 +632,35 @@ export function ServicesNavigatorPage() { modeId="services" query={query} matchCount={displayedMatches.length} - loading={registryLoading} + // A blocked registry must not reach the band as "0 matches" — on this + // page that would assert there are no crisis services when the search + // never ran. The band renders no count while faulted. + status={ + registryBlocked + ? registry.status === "unauthorized" + ? "unauthorized" + : "error" + : registryLoading + ? "loading" + : "ready" + } + faultTitle={registry.status === "unauthorized" ? "Session expired" : "Could not load services"} + faultBody={ + registry.status === "unauthorized" + ? "Your session expired. Sign in again to search private service records and referral pathways." + : "The services registry could not be loaded. Try again shortly." + } + onRetry={registry.status === "unauthorized" ? undefined : registry.refetch} + faultAction={ + registry.status === "unauthorized" ? ( + + Open account setup + + ) : undefined + } sortValue={sortValue} onSortChange={setSortValue} filterLabel="Quick service filters" @@ -705,21 +731,9 @@ export function ServicesNavigatorPage() { {registryLoading ? ( ) : registryBlocked ? ( - registry.status === "unauthorized" ? ( - - ) : ( - - ) + // The band itself now reports the fault, with the retry/sign-in action. + // Repeating it here would announce the same failure twice. + null ) : query.trim() && deferredQuery === query && displayedMatches.length === 0 ? ( Date: Tue, 28 Jul 2026 05:22:37 +0000 Subject: [PATCH 03/30] fix(search): wire the real failure state on the remaining six pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every page that can actually fail now tells the band so, instead of letting an errored fetch arrive as matchCount={0}: - differentials: catalogFailed was computed and used only for a panel below the band; the band itself still printed "0 matches". - prescribing: catalog.error was rendered in a separate danger strip. - therapy-compass: b.error was never referenced on this screen at all, and b.retryData now backs Retry. - documents: the standalone unavailable alert is kept only for routes that render no ribbon, so the message is never lost. - forms: structural. The band was mounted only when the registry was ready, so a failure removed the header entirely. It is now mounted in every state and RegistryStatusNotice, made redundant, is deleted. - favourites: useSavedRegistryFavourites discarded both underlying registry statuses and returned bare items, so the page could not tell "no favourites" from "could not load favourites". It now returns { items, status }, folding only the registries it actually requested — a disabled hook sits in its initial state forever and must not read as a failure. The five pages with no async source are untouched; they are already correct on the default. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- .../clinical-dashboard/differentials-home.tsx | 46 ++++--- .../document-search-results.tsx | 8 +- .../favourites-command-library-page.tsx | 6 +- .../clinical-dashboard/favourites-hub.tsx | 2 +- .../medication-prescribing-workspace.tsx | 15 +-- .../universal-search-command-surface.tsx | 2 +- .../use-saved-registry-favourites.ts | 28 +++- .../forms/forms-search-results-page.tsx | 120 +++++++----------- .../therapy-compass/screens/search-screen.tsx | 4 +- tests/favourites-auth-gate.dom.test.tsx | 2 +- ...ites-hub-unavailable-controls.dom.test.tsx | 2 +- tests/mode-menu-prefetch.dom.test.tsx | 2 +- 12 files changed, 125 insertions(+), 112 deletions(-) diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 246d1cc433..62eeff1d80 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -907,7 +907,21 @@ function SearchResultsView({ modeId="differentials" query={query} matchCount={results.length} - loading={loading || catalogLoading} + status={ + catalogFailed + ? catalog.status === "unauthorized" + ? "unauthorized" + : "error" + : loading || catalogLoading + ? "loading" + : "ready" + } + faultTitle={ + catalog.status === "unauthorized" + ? "Sign in again to search the differentials catalogue" + : "The differentials catalogue could not be searched" + } + faultBody="Retry the search shortly, or browse the catalogue pages directly." sortValue={sortValue} onSortChange={setSortValue} filterLabel="Filter differential result type" @@ -954,30 +968,22 @@ function SearchResultsView({ /> ))} - ) : !best ? ( + ) : /* A failed catalogue search is reported by the band's fault panel, which + also owns the retry copy; this section is now the empty state only. */ + !best && !catalogFailed ? (

- {catalogFailed - ? catalog.status === "unauthorized" - ? "Sign in again to search the differentials catalogue" - : "The differentials catalogue could not be searched" - : `No catalogue matches for “${query}”`} + {`No catalogue matches for “${query}”`}

- {catalogFailed - ? "Retry the search shortly, or browse the catalogue pages directly." - : hasSourceEvidence - ? `No imported differential matched this search, but ${reviewedSourceCount.toLocaleString()} indexed source ${ - reviewedSourceCount === 1 ? "match is" : "matches are" - } available in the library.` - : "Try a symptom, presentation, or diagnosis name — or browse the catalogue directly."} + {hasSourceEvidence + ? `No imported differential matched this search, but ${reviewedSourceCount.toLocaleString()} indexed source ${ + reviewedSourceCount === 1 ? "match is" : "matches are" + } available in the library.` + : "Try a symptom, presentation, or diagnosis name — or browse the catalogue directly."}

0 ? setSortValue : undefined} utilityControls={ @@ -933,7 +934,10 @@ function DocumentSearchResultsPanelImpl({ /> ) : null} - {unavailableMessage ? ( + {/* When the ribbon is shown it owns this message in its fault panel. This + standalone alert remains for the routes that render no ribbon, so the + message is never lost. */} + {!showIdentityHeader && unavailableMessage ? (
[...(demoMode ? prototypeFavouriteItems : []), ...savedRegistryFavourites].map(toCommandItem), [demoMode, savedRegistryFavourites], @@ -1195,6 +1195,10 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: modeId="favourites" query={query} matchCount={scopedItems.length} + // Without this a failed registry read renders as "0 matches", which + // reads as "you have no saved favourites" rather than "we could not + // load them". + status={favouritesRegistryStatus} filterLabel="Active favourites filters" filterControls={ selectedTypeId !== "all" || selectedSet || viewMode !== "all" ? ( diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index b97db3cb8e..77fc70f687 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -47,7 +47,7 @@ export function FavouritesHub({ demoMode: boolean; headingLevel?: 1 | 2; }) { - const savedRegistryFavourites = useSavedRegistryFavourites(); + const savedRegistryFavourites = useSavedRegistryFavourites().items; const allFavouriteItems = useMemo( () => [...(demoMode ? favouriteItems : []), ...savedRegistryFavourites], [demoMode, savedRegistryFavourites], diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index ce18d216a5..8677f25fa2 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -468,7 +468,8 @@ function MedicationResults({ modeId="prescribing" query={query} matchCount={resultCount} - loading={catalog.loading} + status={catalog.error ? "error" : catalog.loading ? "loading" : "ready"} + faultBody={catalog.error ?? undefined} filterLabel="Filter medication results" mobileControls={ - {catalog.loading || catalog.error ? ( + {/* The error branch moved into the band's fault panel, which carries the + same message and announces it once. Loading copy stays here. */} + {catalog.loading ? (
- {catalog.loading ? ( -

Loading medication catalogue…

- ) : ( -

- {catalog.error} -

- )} +

Loading medication catalogue…

) : null} diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index d57a661956..4cac3a78cf 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -443,7 +443,7 @@ export function UniversalSearchCommandSurface({ enabled: dropdownOpen && dropdownDisplayable && Boolean(config), contextMode: modeId, }); - const savedRegistryFavourites = useSavedRegistryFavourites(); + const savedRegistryFavourites = useSavedRegistryFavourites().items; const allFavouriteItems = useMemo( () => [...(demoMode ? favouriteItems : []), ...savedRegistryFavourites], [demoMode, savedRegistryFavourites], diff --git a/src/components/clinical-dashboard/use-saved-registry-favourites.ts b/src/components/clinical-dashboard/use-saved-registry-favourites.ts index 98fd7e3be2..c3da4603f3 100644 --- a/src/components/clinical-dashboard/use-saved-registry-favourites.ts +++ b/src/components/clinical-dashboard/use-saved-registry-favourites.ts @@ -24,7 +24,14 @@ function recordToFavourite(record: ServiceRecord, type: "services" | "forms"): F }; } -export function useSavedRegistryFavourites(): FavouriteItem[] { +export type SavedRegistryFavouritesResult = { + items: FavouriteItem[]; + /** Folded state of the registries this hook actually requested, so the page can + report a failure instead of rendering an empty list as "no favourites". */ + status: "ready" | "loading" | "unauthorized" | "error"; +}; + +export function useSavedRegistryFavourites(): SavedRegistryFavouritesResult { const { favourites } = useAccountData(); const savedServices = favourites.service; const savedForms = favourites.form; @@ -33,7 +40,7 @@ export function useSavedRegistryFavourites(): FavouriteItem[] { const services = useRegistryRecords("service", { enabled: savedServices.length > 0 }); const forms = useRegistryRecords("form", { enabled: savedForms.length > 0 }); - return useMemo(() => { + const items = useMemo(() => { const savedServiceSet = new Set(savedServices); const savedFormSet = new Set(savedForms); const serviceItems = services.records @@ -60,4 +67,21 @@ export function useSavedRegistryFavourites(): FavouriteItem[] { })); return [...serviceItems, ...formItems, ...differentialItems]; }, [services.records, forms.records, savedServices, savedForms, savedDifferentials]); + + // Only a registry that was actually requested can report a fault: a disabled + // hook sits in its initial state forever and must not be read as a failure. + // Unauthorized outranks error because it is the one the reader can act on. + const requested = [ + savedServices.length > 0 ? services.status : null, + savedForms.length > 0 ? forms.status : null, + ]; + const status = requested.includes("unauthorized") + ? "unauthorized" + : requested.includes("error") || requested.includes("not_found") + ? "error" + : requested.includes("loading") + ? "loading" + : "ready"; + + return { items, status }; } diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index 1c45e7483c..8826eaaa8d 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -7,10 +7,8 @@ import { ChevronRight, ExternalLink, FileText, - Loader2, Search, Shield, - ShieldAlert, ShieldCheck, SlidersHorizontal, Workflow, @@ -20,7 +18,7 @@ import { useId, useMemo, useState, useDeferredValue } from "react"; import { appModeHomeHref } from "@/lib/app-modes"; import { formCatalogDetails, rankFormRecords, type FormSearchMatch } from "@/lib/form-ranker"; -import { useRegistryRecords, type RegistryRequestStatus } from "@/lib/use-registry-records"; +import { useRegistryRecords } from "@/lib/use-registry-records"; import { cn, codeText, @@ -561,52 +559,6 @@ function MobilePathway() { ); } -function RegistryStatusNotice({ status }: { status: RegistryRequestStatus }) { - if (status === "ready") return null; - const notice = - status === "loading" - ? { icon: Loader2, spin: true, tone: "info", text: "Loading your forms registry...", action: null } - : status === "unauthorized" - ? { - icon: Shield, - spin: false, - tone: "warning", - text: "Your session expired. Sign in again to search your private forms registry.", - action: { href: "/", label: "Open account setup" }, - } - : { - icon: ShieldAlert, - spin: false, - tone: "danger", - text: "Couldn't load the forms registry. Try again shortly.", - action: null, - }; - const Icon = notice.icon; - const toneClass = - notice.tone === "danger" - ? "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)]/50 text-[color:var(--danger)]" - : notice.tone === "warning" - ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)]/50 text-[color:var(--warning)]" - : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)]"; - return ( -
- - {notice.text} - {notice.action ? ( - - {notice.action.label} - - ) : null} -
- ); -} - export function FormsSearchResultsPage(props: FormsSearchResultsPageProps) { // No key={query} remount: query is a pure prop (favourites already documents this). return ; @@ -642,31 +594,55 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { return (
- + {/* The band is mounted in every registry state, not only when ready. It + previously unmounted on failure, which left the page with no header at + all and pushed the whole burden of reporting onto a separate notice. */} + + Open account setup + + ) : undefined + } + sortValue={sortValue} + onSortChange={setSortValue} + filterLabel="Filter form results" + filterControls={ +
+
+ +
+ {supportsPathwayClaims ? ( + setRefineOpen((open) => !open)} panelId={refinePanelId} /> + ) : null} +
+ } + /> {registryReady ? ( <> - -
- -
- {supportsPathwayClaims ? ( - setRefineOpen((open) => !open)} - panelId={refinePanelId} - /> - ) : null} -
- } - /> {query.trim() && deferredQuery === query && displayedMatches.length === 0 ? ( ({ })); vi.mock("@/components/clinical-dashboard/use-saved-registry-favourites", () => ({ - useSavedRegistryFavourites: () => [], + useSavedRegistryFavourites: () => ({ items: [], status: "ready" }), })); vi.mock("@/components/clinical-dashboard/search-command-context", () => ({ diff --git a/tests/favourites-hub-unavailable-controls.dom.test.tsx b/tests/favourites-hub-unavailable-controls.dom.test.tsx index 9ee196e586..d7b44abd93 100644 --- a/tests/favourites-hub-unavailable-controls.dom.test.tsx +++ b/tests/favourites-hub-unavailable-controls.dom.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { FavouritesHub } from "@/components/clinical-dashboard/favourites-hub"; vi.mock("@/components/clinical-dashboard/use-saved-registry-favourites", () => ({ - useSavedRegistryFavourites: () => [], + useSavedRegistryFavourites: () => ({ items: [], status: "ready" }), })); describe("FavouritesHub unavailable controls", () => { diff --git a/tests/mode-menu-prefetch.dom.test.tsx b/tests/mode-menu-prefetch.dom.test.tsx index d84c8c7241..ae8db4f174 100644 --- a/tests/mode-menu-prefetch.dom.test.tsx +++ b/tests/mode-menu-prefetch.dom.test.tsx @@ -29,7 +29,7 @@ vi.mock("@/lib/supabase/client", () => ({ })); vi.mock("@/components/clinical-dashboard/use-saved-registry-favourites", () => ({ - useSavedRegistryFavourites: () => [], + useSavedRegistryFavourites: () => ({ items: [], status: "ready" }), })); vi.mock("@/components/clinical-dashboard/search-command-context", () => ({ From a868e8ccc421089e1ddcaf4e1af74abc3b79cc41 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 05:24:36 +0000 Subject: [PATCH 04/30] feat(search): make band adoption structural for future search pages Wiring every current page is worth little if the next search page can skip the band and go back to reporting "0 matches" for a failed search. Two layers close that: - `AppModeSearchConfig.resultsSurface` is required and non-optional, so a fourteenth mode fails typecheck until its author declares whether it presents a result list or a synthesised answer. - tests/search-results-band-adoption.test.ts holds them to it: every mode declaring "results-band" must have a production component mounting the band for that modeId, and every production search route must reach the band within one import hop. The modeId scan collects literals recursively because document-search computes its mode via a ternary; a naive matcher would read that page as having no mode. The route assertion carries one documented allowlist entry (the documents search stub, which has no result list of its own) and asserts it found routes at all, so it cannot pass vacuously. Verified by negative control: flipping answer to "results-band" fails the gate with a named mode, and restoring it returns to green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- src/lib/app-modes.ts | 20 +++ tests/search-results-band-adoption.test.ts | 160 +++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 tests/search-results-band-adoption.test.ts diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index 174fce8a8c..289179be37 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -34,6 +34,8 @@ export type AppModeSearchKind = | "tools"; export type AppModeResultKind = AppModeSearchKind; +export type AppModeResultsSurface = "results-band" | "answer"; + export type AppModeSearchConfig = { kind: AppModeSearchKind; placeholder: string; @@ -45,6 +47,11 @@ export type AppModeSearchConfig = { readyTitle: string; progressLabel: string; resultKind: AppModeResultKind; + /** Does this mode present a result LIST (which must wear the shared results + band) or a synthesised answer? Required and non-optional on purpose: a new + mode cannot compile until its author states which surface it is, and the + band-adoption contract test reads this to know what it must find. */ + resultsSurface: AppModeResultsSurface; resultHeading: string; statusLabel: string; nextStep: string; @@ -78,6 +85,7 @@ export const appModeDefinitions = [ progressLabel: "Searching indexed documents.", resultKind: "answer", resultHeading: "Answer", + resultsSurface: "answer", statusLabel: "Answer", nextStep: "Ask a question first", badgeLabel: "?", @@ -99,6 +107,7 @@ export const appModeDefinitions = [ progressLabel: "Finding matching documents.", resultKind: "documents", resultHeading: "Document matches", + resultsSurface: "results-band", statusLabel: "Docs", nextStep: "Open a source document or evidence passage", badgeLabel: null, @@ -121,6 +130,7 @@ export const appModeDefinitions = [ progressLabel: "Searching service records.", resultKind: "services", resultHeading: "Service matches", + resultsSurface: "results-band", statusLabel: "Services", nextStep: "Review matching service records", badgeLabel: null, @@ -145,6 +155,7 @@ export const appModeDefinitions = [ progressLabel: "Searching form records.", resultKind: "forms", resultHeading: "Form matches", + resultsSurface: "results-band", statusLabel: "Forms", nextStep: "Review matching form records", badgeLabel: null, @@ -167,6 +178,7 @@ export const appModeDefinitions = [ progressLabel: "Filtering favourites.", resultKind: "favourites", resultHeading: "Favourites", + resultsSurface: "results-band", statusLabel: "Favourites", nextStep: "Open a saved item", badgeLabel: null, @@ -189,6 +201,7 @@ export const appModeDefinitions = [ progressLabel: "Searching differential source records.", resultKind: "differentials", resultHeading: "Differentials", + resultsSurface: "results-band", statusLabel: "Diffs", nextStep: "Search or compare differentials", badgeLabel: null, @@ -212,6 +225,7 @@ export const appModeDefinitions = [ progressLabel: "Searching the local DSM diagnosis catalogue.", resultKind: "dsm", resultHeading: "DSM diagnoses", + resultsSurface: "results-band", statusLabel: "DSM", nextStep: "Open a diagnosis or compare criteria", badgeLabel: null, @@ -234,6 +248,7 @@ export const appModeDefinitions = [ progressLabel: "Matching presentation features to specifiers.", resultKind: "specifiers", resultHeading: "Specifier matches", + resultsSurface: "results-band", statusLabel: "Specifiers", nextStep: "Check fit and refine the diagnostic wording", badgeLabel: null, @@ -256,6 +271,7 @@ export const appModeDefinitions = [ progressLabel: "Matching clinical clues to formulation mechanisms.", resultKind: "formulation", resultHeading: "Mechanism matches", + resultsSurface: "results-band", statusLabel: "Formulation", nextStep: "Check fit, alternatives, and treatment leverage", badgeLabel: null, @@ -281,6 +297,7 @@ export const appModeDefinitions = [ progressLabel: "Searching medication guidance.", resultKind: "documents", resultHeading: "Medication matches", + resultsSurface: "results-band", statusLabel: "Meds", nextStep: "Review medication guidance", badgeLabel: null, @@ -306,6 +323,7 @@ export const appModeDefinitions = [ progressLabel: "Searching tools.", resultKind: "tools", resultHeading: "Tools", + resultsSurface: "results-band", statusLabel: "Tools", nextStep: "Launch a tool", badgeLabel: null, @@ -334,6 +352,7 @@ export const appModeDefinitions = [ progressLabel: "Loading the therapy library.", resultKind: "tools", resultHeading: "Therapies", + resultsSurface: "results-band", statusLabel: "Therapy", nextStep: "Open a therapy record", badgeLabel: null, @@ -359,6 +378,7 @@ export const appModeDefinitions = [ progressLabel: "Searching patient factsheets.", resultKind: "tools", resultHeading: "Factsheets", + resultsSurface: "results-band", statusLabel: "Factsheets", nextStep: "Open a factsheet to read, save, or print", badgeLabel: null, diff --git a/tests/search-results-band-adoption.test.ts b/tests/search-results-band-adoption.test.ts new file mode 100644 index 0000000000..859da8736c --- /dev/null +++ b/tests/search-results-band-adoption.test.ts @@ -0,0 +1,160 @@ +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { appModeDefinitions } from "@/lib/app-modes"; + +/** + * The shared results band is what stops a search surface asserting "0 matches" + * when the search actually failed. That guarantee is only worth anything if + * every result list wears it, so this file is the gate that a *future* search + * page cannot quietly skip. + * + * It pairs with the required `resultsSurface` field on `AppModeSearchConfig`: + * that field makes a new mode fail `typecheck` until its author declares which + * surface it is, and this test then holds them to the declaration. + */ + +const REPO_ROOT = path.resolve(__dirname, ".."); +const COMPONENTS_DIR = path.join(REPO_ROOT, "src", "components"); +const APP_DIR = path.join(REPO_ROOT, "src", "app"); +const BAND_IDENTIFIER = "SearchResultsHeaderBand"; + +/** Mockups are design scratch and exempt from production wiring gates. */ +function isMockupPath(relativePath: string) { + return /mockup/i.test(relativePath); +} + +function walk(dir: string, extension = ".tsx"): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...walk(abs, extension)); + } else if (entry.name.endsWith(extension)) { + out.push(abs); + } + } + return out; +} + +/** + * Collect every string literal appearing in each `modeId=` initializer on a + * band element. Literals are gathered recursively rather than matched as a + * single `modeId="x"` because at least one production call site computes the + * mode: `document-search-results.tsx` writes + * `modeId={showRecordMatches ? recordMode : "documents"}`, and a naive matcher + * would report that page as having no mode at all. + */ +function collectBandModeIds(source: string): Set { + const found = new Set(); + let cursor = source.indexOf(`<${BAND_IDENTIFIER}`); + while (cursor !== -1) { + const modeIdAt = source.indexOf("modeId", cursor); + if (modeIdAt === -1) break; + // Bound the search to this element's opening tag so we never read props + // belonging to the next component. + const elementEnd = source.indexOf("\n />", cursor); + const window = source.slice(modeIdAt, elementEnd === -1 ? modeIdAt + 400 : Math.min(elementEnd, modeIdAt + 400)); + const attribute = window.slice(0, window.indexOf("\n", window.indexOf("\n") + 1) + 1 || window.length); + for (const match of attribute.matchAll(/["']([a-z-]+)["']/g)) found.add(match[1]); + cursor = source.indexOf(`<${BAND_IDENTIFIER}`, cursor + 1); + } + return found; +} + +/** + * Routes that legitimately render no band. Each entry must say why, so an + * exemption is a reviewed decision rather than a silent hole — the same idiom + * `tests/route-reachability.test.ts` uses for its allowlist. + */ +const BAND_ROUTE_ALLOWLIST = new Map([ + [ + "src/app/(search-app)/documents/search/page.tsx", + "Composer-driven landing stub with no result list of its own; the band is mounted by document-search-results.tsx inside the dashboard shell.", + ], +]); + +describe("search results band adoption", () => { + const productionComponents = walk(COMPONENTS_DIR) + .map((abs) => ({ abs, rel: path.relative(REPO_ROOT, abs) })) + .filter(({ rel }) => !isMockupPath(rel)); + + it("mounts the shared band on every mode that presents a result list", () => { + const mounted = new Set(); + for (const { abs } of productionComponents) { + const source = readFileSync(abs, "utf8"); + if (!source.includes(BAND_IDENTIFIER)) continue; + for (const modeId of collectBandModeIds(source)) mounted.add(modeId); + } + + const expected = appModeDefinitions + .filter((mode) => mode.search.resultsSurface === "results-band") + .map((mode) => mode.id); + const missing = expected.filter((modeId) => !mounted.has(modeId)); + + expect( + missing, + `These modes declare resultsSurface: "results-band" but no production component renders ` + + `<${BAND_IDENTIFIER} modeId="…"> for them. A result list without the band can report ` + + `"0 matches" for a search that failed. Mount the band, or change the mode's resultsSurface.`, + ).toEqual([]); + }); + + it("reaches the band from every production search route", () => { + const searchRoutes = walk(APP_DIR) + .map((abs) => ({ abs, rel: path.relative(REPO_ROOT, abs) })) + .filter(({ rel }) => !isMockupPath(rel)) + .filter(({ rel }) => rel.replaceAll(path.sep, "/").includes("/search/") && rel.endsWith("page.tsx")); + + // If this ever hits zero the assertion below passes vacuously, which would + // make the gate silently useless. + expect(searchRoutes.length).toBeGreaterThan(0); + + const componentSources = new Map( + productionComponents.map(({ abs, rel }) => [rel.replaceAll(path.sep, "/"), readFileSync(abs, "utf8")]), + ); + + const orphans: string[] = []; + for (const { abs, rel } of searchRoutes) { + const key = rel.replaceAll(path.sep, "/"); + if (BAND_ROUTE_ALLOWLIST.has(key)) continue; + const routeSource = readFileSync(abs, "utf8"); + if (routeSource.includes(BAND_IDENTIFIER)) continue; + // One import hop: a route almost always delegates to a client component. + const imported = [...routeSource.matchAll(/from "@\/(components\/[^"]+)"/g)].map((match) => match[1]); + const reaches = imported.some((specifier) => + [...componentSources.entries()].some( + ([componentPath, source]) => + componentPath === `src/${specifier}.tsx` && source.includes(BAND_IDENTIFIER), + ), + ); + if (!reaches) orphans.push(key); + } + + expect( + orphans, + `These search routes never reach <${BAND_IDENTIFIER}>. Mount the band, or add a documented ` + + `entry to BAND_ROUTE_ALLOWLIST in this file explaining why the route has no result list.`, + ).toEqual([]); + }); + + it("keeps the band's forced-colors rules last in the stylesheet", () => { + // At equal specificity a later rule wins, so a forced-colors block placed + // before another one is silently overridden while still reading correctly. + const globals = readFileSync(path.join(REPO_ROOT, "src", "app", "globals.css"), "utf8"); + const forcedColorsOpeners = [...globals.matchAll(/@media \(forced-colors: active\)/g)].map( + (match) => match.index ?? -1, + ); + expect(forcedColorsOpeners.length).toBeGreaterThan(0); + + const bandRule = globals.lastIndexOf(".search-band"); + if (bandRule === -1) return; // The visual phase has not landed yet. + expect( + bandRule, + "The band's forced-colors rules must sit inside the last @media (forced-colors: active) " + + "block, or an earlier block at equal specificity will override them.", + ).toBeGreaterThan(forcedColorsOpeners[forcedColorsOpeners.length - 1]); + }); +}); From 26e258ad6cc5ff462db925f51fcc4d9630a325df Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 05:32:22 +0000 Subject: [PATCH 05/30] feat(search): land the approved band design and adopt the refetch state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual pass to the approved mockup (Option A, Edit 2). Rail: the accent was an absolutely-positioned bar inside an overflow-hidden 12px-radius card, so the corner arc sliced its ends — at y=0 the arc is 12px in, at y=2px it is 5.4px in — and it started short and tapered while the 1px border curved past it. Two lines, two geometries. It is now the card's real border-top, which mitres into the side borders and follows the radius by construction. The desktop left-hand bar goes with it. Type: nothing on this surface is bold any more. The heaviest step is 580 and the query (560) and figure (580) share it, separated by tabular numerals and a hairline rather than by shouting; "matches" drops to 400 and a zero count steps down to 470 and muted. Weights are numeric font-weight on named classes in globals.css rather than Tailwind arbitrary values, because check:type-scale --strict is a zero gate on arbitrary text-[Npx] and that is already the idiom in this file. Breakpoint: the row/stack switch moves from lg to sm so portrait tablets stop getting the phone layout. The rail's overflow, fade mask and trailing spacer stay on lg deliberately — at 640-1023px a page with chips, sort, a mobile filter and utility controls can exceed the width, and containing that in a scrollable rail is what keeps the no-horizontal-overflow assertions green. Forced colors: the rail survives as thickness (3px, 6px double for a fault) because --clinical-accent resolves to LinkText and would otherwise be indistinguishable from the other borders. That is what keeps a failed search distinct from a successful one when colour is gone. The block is appended last in globals.css, since at equal specificity a later rule wins and an earlier block is silently overridden while still reading correctly. --warning-border is not remapped by the token block, so it is pinned here. Formulation adopts the refetch state: its "loading" was useDeferredValue lag over static data, not a network request, so the previous count is still correct and stays visible with a pulse instead of collapsing to a skeleton. Safe there precisely because nothing identity-scoped is held across the gap. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- docs/search-chrome-behaviour.md | 54 ++++++-- src/app/globals.css | 118 ++++++++++++++++++ .../search-results-header-band.tsx | 51 ++++---- .../formulation/formulation-home-page.tsx | 6 +- tests/search-results-header-band.dom.test.tsx | 12 ++ 5 files changed, 205 insertions(+), 36 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index b1ee877d48..5d2a67afc8 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -43,13 +43,27 @@ This repo uses one shared search experience across the global shell, dashboard r The band above every result list is not a composer and owns no dock reserve, but it is shared chrome and changes to it land on every mode at once. Keep these rules: -1. **The query is the only heading-weight thing in the band.** It renders at `text-lg` - `font-extrabold` with no eyebrow — the magnifier tile already says "search", and a `QUERY` / - `RESULTS FOR` label costs a line to repeat it. The query truncates; the count does not. +0. **A faulted search never asserts a count.** This is the band's clinical invariant, not a + style rule. `status` is a union (`ready | loading | refetching | error | unauthorized`); while + faulted the count is absent from the DOM entirely and the spine reads "Couldn't search". A + failed services search rendering "0 matches" states there are no crisis services when the + search never ran. `0` with `status="ready"` is a real answer and still renders. Pages own the + mapping from their own data source; five pages have no async source and are correct on the + default. +1. **The query is the only heading-weight thing in the band.** Nothing here is bold: the heaviest + step is 580 and the query (560) and the figure (580) share it, separated by tabular numerals + and a hairline rather than by shouting. No eyebrow — the magnifier tile already says "search", + and a `QUERY` / `RESULTS FOR` label costs a line to repeat it. The query truncates; the count + does not. Weights live as numeric `font-weight` on `.search-band-*` classes in `globals.css`, + not as Tailwind arbitrary values: `check:type-scale --strict` is a zero gate on arbitrary + `text-[Npx]`, and Geist is a variable face so 470/540/560/580 interpolate rather than snapping + to 700. Judge weights only with the app font loaded. 2. **The count is neutral text, not a success pill.** `text-muted` with the figure itself - `font-extrabold tabular-nums`. Success colour is reserved for states that were actually - achieved, so it still carries meaning where it appears. The `role="status"` / - `aria-live="polite"` announcement stays either way. + `.search-band-count` (580, tabular-nums), stepping down to 470 and muted at zero. Success + colour is reserved for states that were actually achieved, so it still carries meaning where + it appears. The `role="status"` / `aria-live="polite"` announcement stays either way — except + while faulted, when the spine goes `aria-live="off"` and the fault panel's `role="alert"` + makes the single announcement instead of both speaking. 3. **Sort is a segmented control, not a select.** Two values do not justify a menu you must open to read. `ResultSortControl` renders `sortOptions` as `aria-pressed` buttons inside a `role="group"` named "Sort results"; add a third order only if it still fits the rail. @@ -60,16 +74,36 @@ chrome and changes to it land on every mode at once. Keep these rules: read quieter than the query steps down in **weight and colour**, never in size, and any select carrying variable-length values must set `truncate` or it clips mid-word rather than ellipsing (the "Current search" → "Current searcl" defect fixed 2026-07-27). -5. **The utility group is a swipe rail below `lg`, an inline row at `lg+`.** Children are +5. **The utility group is a swipe rail below `lg`, an inline row at `lg+`.** The row/stack switch + moved to `sm` (640) so portrait tablets stop rendering the phone layout, but the rail's + overflow, fade mask and trailing spacer stay on `lg` deliberately: at 640-1023px a page with + chips, sort, a mobile filter and utility controls can exceed the width, and containing that + inside a scrollable rail is what keeps `expectNoPageHorizontalOverflow` green. Children are `shrink-0` so they keep their natural width; overflow scrolls instead of wrapping into a second tinted band. The right-edge fade is applied via `data-overflowing` only while the rail actually overflows — never as a permanent mask. 6. **Active scopes render as removable chips at the head of that group**, in accent tone, so a constraint on the list is one tap from where it is read. Do not move them into a separate strip; `hasUtilities` already suppresses the whole group when nothing is active. - -Coverage: `tests/search-results-header-band.dom.test.tsx` (structure, sort wiring, count tone), -`tests/ui-tools.spec.ts` (phone control pair geometry and tap heights). +7. **The accent is the card's `border-top`, never an overlay.** An absolutely-positioned bar + inside an `overflow-hidden` 12px-radius card is sliced by the corner arc, so it starts short + and tapers while the 1px border curves past it — two lines, two geometries. A border mitres + into the side borders and follows the radius by construction, and forced-colors maps it + automatically. Under forced colors the rail survives as **thickness** (3px, and 6px `double` + for a fault) because `--clinical-accent` resolves to `LinkText` and would otherwise be + indistinguishable from the other borders — that is what keeps a failed search visually + distinct from a successful one when colour is gone. The band's forced-colors rules **must + remain the last block in `globals.css`**: at equal specificity a later rule wins, so an + earlier block is silently overridden while still reading correctly. +8. **A new search page cannot skip the band.** `AppModeSearchConfig.resultsSurface` is required, + so a new mode fails `typecheck` until it declares `results-band` or `answer`, and + `tests/search-results-band-adoption.test.ts` then requires a matching mount plus a documented + allowlist entry for any search route that legitimately has no result list. + +Coverage: `tests/search-results-header-band.dom.test.tsx` (structure, sort wiring, count tone, +fault states and the no-overlay-rail guard), `tests/search-results-band-adoption.test.ts` (mode +and route adoption, forced-colors block ordering), `tests/ui-tools.spec.ts` (phone control pair +geometry and tap heights). ## Scroll hide/reveal diff --git a/src/app/globals.css b/src/app/globals.css index 8b3690130c..ff98132105 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2909,3 +2909,121 @@ html.theme-transitioning *:after { --focus: Highlight; } } + +/* ─── SEARCH RESULTS BAND ────────────────────────────────────────────────── + Weight comes down across this surface: nothing is bold, the heaviest step is + 580, and the query and the figure share it — they are separated by tabular + numerals and a hairline rather than by shouting. Expressed here as numeric + `font-weight` on named classes rather than Tailwind arbitrary values because + (a) that is the existing idiom in this file, and (b) `check:type-scale + --strict` is a zero gate on arbitrary `text-[Npx]` utilities. Geist is a + variable face, so these interpolate instead of snapping to 700. */ +@layer components { + /* The accent is the card's real border-top. As an absolutely-positioned bar + inside an overflow:hidden 12px-radius card the corner arc sliced its ends — + at y=0 the arc is 12px in, at y=2px it is 5.4px in — so the rail started + short and tapered while the 1px border curved past it. A border mitres into + the side borders and follows the radius by construction. */ + .search-band { + border-top: 2px solid var(--clinical-accent); + } + .search-band[data-status="error"], + .search-band[data-status="unauthorized"] { + border-top-color: var(--warning); + } + + .search-band-query { + font-size: 1.21875rem; + font-weight: 560; + letter-spacing: -0.023em; + } + .search-band-count { + font-size: 1.21875rem; + font-weight: 580; + letter-spacing: -0.016em; + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; + } + /* A zero is a real answer, but it is not news; it steps down in weight and + colour rather than shouting a result that is not there. */ + .search-band-count[data-zero="true"] { + font-weight: 470; + color: var(--text-muted); + } + .search-band-count-word { + font-size: 0.8125rem; + font-weight: 400; + } + .search-band-fault { + font-size: 0.84375rem; + font-weight: 520; + } + .search-band-chip { + font-size: 0.78125rem; + font-weight: 470; + } + .search-band-chip[data-selected="true"] { + font-weight: 540; + } + .search-band-sort-option { + font-size: 0.78125rem; + font-weight: 470; + } + .search-band-sort-option[aria-pressed="true"] { + font-weight: 560; + } + .search-band-ghost { + font-size: 0.78125rem; + font-weight: 480; + } + /* A gradient hairline, so it fades out at both ends rather than butting into + the vertical padding. Gradients are not forced-colors mapped; see below. */ + .search-band-rule { + background: linear-gradient(180deg, transparent, var(--border-strong) 50%, transparent); + } +} + +/* MUST remain the last rules in this stylesheet. At equal specificity a later + rule wins, so a forced-colors block placed earlier is silently overridden + while still looking correct in review. Rendering (not reading) exposed three + losses that colour alone was hiding. */ +@media (forced-colors: active) { + /* 1 — the rail survives as thickness, not hue: --clinical-accent resolves to + LinkText and --border-strong to CanvasText, so "accented" would otherwise + collapse into the other three borders. */ + .search-band { + border-top-width: 3px; + } + /* 2 — a fault becomes a doubled top edge. Structural, not chromatic. This is + the one distinction that matters clinically: without it a failed search is + visually identical to a successful one. */ + .search-band[data-status="error"], + .search-band[data-status="unauthorized"] { + border-top-width: 6px; + border-top-style: double; + } + .search-band-fault { + font-weight: 640; + } + /* 3 — selected sort/chip lose their fill, so carry them on the system's own + selection pair. forced-color-adjust is required: Highlight resolves + semi-transparent and Chromium otherwise paints a Canvas backplate straight + over the label. */ + .search-band-chip[data-selected="true"], + .search-band-sort-option[aria-pressed="true"] { + background: Highlight; + color: HighlightText; + border-color: Highlight; + box-shadow: none; + forced-color-adjust: none; + } + .search-band-rule { + background: CanvasText; + opacity: 1; + } + /* --warning-border is not remapped by the token block above, so pin it here + or the fault panel keeps a raw hex border under forced colors. */ + .search-band-fault-panel { + border-color: CanvasText; + } +} diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index f7e47a4d4c..7d98342815 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -176,43 +176,37 @@ export function SearchResultsHeaderBand({ data-status={resolvedStatus} data-testid="search-query-ribbon" className={cn( - "relative overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-[var(--shadow-inset)]", + // `search-band` carries the accent as a real border-top; there is no + // overlay bar to be clipped by the corner arc any more. + "search-band relative overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-[var(--shadow-inset)]", className, )} > - -
-
+
+
{faulted ? ( - + ) : ( - + )} {/* No eyebrow: the icon already says "search", and the query is the only thing in this band set at heading weight. */} {displayQuery} - + {/* Neutral, not a success pill: a count is not a state that was achieved, and green has to keep meaning something where it does appear. */} {/* One unconditional `role="status"` in every state. Playwright asserts it @@ -222,7 +216,8 @@ export function SearchResultsHeaderBand({ makes the single announcement, rather than both speaking. */} ) : null} - {matchCount}{" "} + + {matchCount} + {" "} {matchCount === 1 ? "match" : "matches"} @@ -279,11 +279,12 @@ export function SearchResultsHeaderBand({ type="button" onClick={() => command?.onRemoveScope(scope.id)} aria-label={`Remove ${scope.label} filter`} + data-selected="true" className={cn( // Hover deepens the chip's own accent rather than swapping to the // neutral border the surface controls use — an accent-soft chip // going grey on hover reads as losing its active state. - "inline-flex min-h-tap shrink-0 max-w-[12rem] items-center gap-1 rounded-full border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-3 text-xs font-bold text-[color:var(--clinical-accent)] hover:border-[color:var(--clinical-accent)] hover:text-[color:var(--clinical-accent-hover)] sm:min-h-10", + "inline-flex min-h-tap shrink-0 max-w-[12rem] items-center gap-1 rounded-full border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-3 text-[color:var(--clinical-accent)] search-band-chip hover:border-[color:var(--clinical-accent)] hover:text-[color:var(--clinical-accent-hover)] sm:min-h-10", focusRing, )} > @@ -363,7 +364,7 @@ export function SearchResultsHeaderBand({ type="button" onClick={onSaveSearch} className={cn( - "inline-flex min-h-tap shrink-0 items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 text-xs font-extrabold text-[color:var(--text-muted)] shadow-[var(--shadow-inset)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] sm:min-h-10", + "inline-flex min-h-tap shrink-0 items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 text-[color:var(--text-muted)] search-band-ghost shadow-[var(--shadow-inset)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] sm:min-h-10", focusRing, )} > @@ -395,10 +396,10 @@ export function SearchResultsHeaderBand({
-

{resolvedFaultTitle}

-

{resolvedFaultBody}

+

{resolvedFaultTitle}

+

{resolvedFaultBody}

{onRetry || faultAction ? (
{onRetry ? ( @@ -415,7 +416,7 @@ export function SearchResultsHeaderBand({ } }} className={cn( - "inline-flex min-h-tap shrink-0 items-center justify-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-xs font-extrabold text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] sm:min-h-10", + "inline-flex min-h-tap shrink-0 items-center justify-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-[color:var(--text-muted)] search-band-ghost hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)] sm:min-h-10", focusRing, )} > @@ -458,7 +459,7 @@ export function ResultSortControl({ aria-pressed={selected} onClick={() => onChange(readResultSort(option.value))} className={cn( - "min-h-tap whitespace-nowrap px-3 text-xs font-bold sm:min-h-10", + "search-band-sort-option min-h-tap whitespace-nowrap px-3 sm:min-h-10", index > 0 && "border-l border-[color:var(--border)]", focusRing, selected diff --git a/src/components/formulation/formulation-home-page.tsx b/src/components/formulation/formulation-home-page.tsx index 619d84372c..92f7af0e8d 100644 --- a/src/components/formulation/formulation-home-page.tsx +++ b/src/components/formulation/formulation-home-page.tsx @@ -205,7 +205,11 @@ function FormulationResults({ query }: { query: string }) { modeId="formulation" query={query} matchCount={results.length} - loading={!rankingReady} + // This is `useDeferredValue` lag over static data, not a network request: + // the previous count is still on screen and still correct, so it stays + // visible with a pulse rather than collapsing to a skeleton. Safe here + // precisely because nothing identity-scoped is being held across the gap. + status={rankingReady ? "ready" : "refetching"} headingLevel={1} filterLabel="Filter formulation mechanisms" mobileControls={ diff --git a/tests/search-results-header-band.dom.test.tsx b/tests/search-results-header-band.dom.test.tsx index a89deeec8a..c61b325f91 100644 --- a/tests/search-results-header-band.dom.test.tsx +++ b/tests/search-results-header-band.dom.test.tsx @@ -103,6 +103,18 @@ describe("SearchResultsHeaderBand", () => { expect(screen.getByRole("status")).toHaveTextContent("8 matches"); }); + // The accent used to be an absolutely-positioned bar inside an overflow:hidden + // rounded card, so the corner arc sliced its ends and it tapered away from the + // corner while the 1px border curved past it. It is now the card's own + // border-top, which mitres into the side borders by construction. + it("carries the accent as a border rather than a clipped overlay bar", () => { + render(); + + const region = screen.getByRole("region", { name: "Search results for CMHT" }); + expect(region).toHaveClass("search-band"); + expect(region.querySelector("span.absolute")).toBeNull(); + }); + it("lets an explicit status override the deprecated loading shim", () => { render(); From fee98126923439dd5154cbd36f3bec55500ba001 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 05:51:56 +0000 Subject: [PATCH 06/30] style: apply repo formatter to the search band changes Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- .../clinical-dashboard/use-saved-registry-favourites.ts | 5 +---- src/components/services/services-navigator-page.tsx | 8 +++----- tests/search-results-band-adoption.test.ts | 3 +-- tests/search-results-header-band.dom.test.tsx | 4 +--- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/components/clinical-dashboard/use-saved-registry-favourites.ts b/src/components/clinical-dashboard/use-saved-registry-favourites.ts index c3da4603f3..9dd02298df 100644 --- a/src/components/clinical-dashboard/use-saved-registry-favourites.ts +++ b/src/components/clinical-dashboard/use-saved-registry-favourites.ts @@ -71,10 +71,7 @@ export function useSavedRegistryFavourites(): SavedRegistryFavouritesResult { // Only a registry that was actually requested can report a fault: a disabled // hook sits in its initial state forever and must not be read as a failure. // Unauthorized outranks error because it is the one the reader can act on. - const requested = [ - savedServices.length > 0 ? services.status : null, - savedForms.length > 0 ? forms.status : null, - ]; + const requested = [savedServices.length > 0 ? services.status : null, savedForms.length > 0 ? forms.status : null]; const status = requested.includes("unauthorized") ? "unauthorized" : requested.includes("error") || requested.includes("not_found") diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index b6a880d619..d098d0decf 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -730,11 +730,9 @@ export function ServicesNavigatorPage() { > {registryLoading ? ( - ) : registryBlocked ? ( - // The band itself now reports the fault, with the retry/sign-in action. - // Repeating it here would announce the same failure twice. - null - ) : query.trim() && deferredQuery === query && displayedMatches.length === 0 ? ( + ) : registryBlocked ? // The band itself now reports the fault, with the retry/sign-in action. + // Repeating it here would announce the same failure twice. + null : query.trim() && deferredQuery === query && displayedMatches.length === 0 ? ( { const imported = [...routeSource.matchAll(/from "@\/(components\/[^"]+)"/g)].map((match) => match[1]); const reaches = imported.some((specifier) => [...componentSources.entries()].some( - ([componentPath, source]) => - componentPath === `src/${specifier}.tsx` && source.includes(BAND_IDENTIFIER), + ([componentPath, source]) => componentPath === `src/${specifier}.tsx` && source.includes(BAND_IDENTIFIER), ), ); if (!reaches) orphans.push(key); diff --git a/tests/search-results-header-band.dom.test.tsx b/tests/search-results-header-band.dom.test.tsx index c61b325f91..72adc8e022 100644 --- a/tests/search-results-header-band.dom.test.tsx +++ b/tests/search-results-header-band.dom.test.tsx @@ -72,9 +72,7 @@ describe("SearchResultsHeaderBand", () => { const user = userEvent.setup(); const onRetry = vi.fn(); - render( - , - ); + render(); await user.click(screen.getByRole("button", { name: "Retry" })); expect(onRetry).toHaveBeenCalledTimes(1); From 4a674ab7b223b06864b82acc96385e5c2a44eb0b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 05:55:05 +0000 Subject: [PATCH 07/30] style: keep the services fault branch prettier-stable A comment inside the null branch of the results ternary was reordered by Prettier on every run, so format:check could never converge. Move it above the expression. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- src/components/services/services-navigator-page.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index d098d0decf..fd4865775e 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -728,11 +728,12 @@ export function ServicesNavigatorPage() { /> } > + {/* `registryBlocked` renders nothing here: the band above now reports the + fault and owns the retry/sign-in action, so repeating it would announce + the same failure twice. */} {registryLoading ? ( - ) : registryBlocked ? // The band itself now reports the fault, with the retry/sign-in action. - // Repeating it here would announce the same failure twice. - null : query.trim() && deferredQuery === query && displayedMatches.length === 0 ? ( + ) : registryBlocked ? null : query.trim() && deferredQuery === query && displayedMatches.length === 0 ? ( Date: Tue, 28 Jul 2026 07:17:06 +0000 Subject: [PATCH 08/30] test(ui): scope shell search assertions past the streaming clone Production UI failed twice on this PR with the same signature but a different test each time: a strict-mode violation where a document-wide getByTestId resolved to two identical elements, one of them hidden. First `differentials-search-results`, then `global-search-input`. That is the Next streaming `S:` clone of the page root, which CI load makes observable and which #1294 already documented and fixed the same way on the differentials detail page. It is not caused by this branch: the test that failed first passed untouched on the next run. Scope the affected assertions to the visible element, which is the idiom already used for this exact testid in ui-overlap.spec.ts. Applied to the class rather than the single instance, so the flake does not simply move to whichever spec loses the race next. Deliberately not changed: the `toHaveCount` assertions on this testid. Those count elements on purpose as single-owner guards, and visible-scoping them would change what they assert. Verified locally: ui-chrome-scroll + ui-phone-scroll 65 passed; ui-smoke 91 passed with only the pre-existing PDF-canvas failure, which is a local browser-build artifact and passes in CI. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- tests/ui-chrome-scroll.spec.ts | 7 ++++++- tests/ui-phone-scroll.spec.ts | 4 ++-- tests/ui-smoke.spec.ts | 8 ++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/ui-chrome-scroll.spec.ts b/tests/ui-chrome-scroll.spec.ts index 93aa96e8b4..ac8cbc8c23 100644 --- a/tests/ui-chrome-scroll.spec.ts +++ b/tests/ui-chrome-scroll.spec.ts @@ -177,7 +177,12 @@ test.beforeEach(async ({ page }) => { test("1024px bounded main scrolling preserves focused page search", async ({ page }) => { await page.setViewportSize({ width: 1024, height: 768 }); await page.goto("/?mode=prescribing&q=a&run=1", { waitUntil: "domcontentloaded" }); - const input = page.getByTestId("global-search-input"); + // Scope to the visible composer: Next may briefly retain a hidden streaming + // `S:` clone of the page root under CI load, so a document-wide getByTestId + // strict-mode fails on two identical inputs, one of them hidden. Same class of + // flake the differentials detail page hit, and the same remedy already used + // for this exact testid in ui-overlap.spec.ts. + const input = page.locator('[data-testid="global-search-input"]:visible').first(); await expect(input).toBeVisible({ timeout: 15_000 }); await expect .poll(async () => (await readPrimaryScrollGeometry(page)).maxScrollTop, { timeout: 20_000 }) diff --git a/tests/ui-phone-scroll.spec.ts b/tests/ui-phone-scroll.spec.ts index 40fdd1ee01..8737eb80a3 100644 --- a/tests/ui-phone-scroll.spec.ts +++ b/tests/ui-phone-scroll.spec.ts @@ -657,7 +657,7 @@ test("Services results keep a continuous browser viewport after shared chrome re // relaunched Home Screen PWA's notched-phone safe area. await gotoPhoneSurface(page, "/services?q=clinic&run=1&focus=1", 112); await expect(page.locator("form.answer-footer-search-dock")).toBeVisible({ timeout: 20_000 }); - await expect(page.getByTestId("global-search-input")).not.toBeFocused({ timeout: 5_000 }); + await expect(page.locator('[data-testid="global-search-input"]:visible').first()).not.toBeFocused({ timeout: 5_000 }); await expect(page.getByTestId("services-navigator")).toBeVisible({ timeout: 20_000 }); await expect(page.getByTestId("service-search-results")).toBeVisible({ timeout: 20_000 }); @@ -1206,7 +1206,7 @@ test("phone forms search hides header and footer after submit without stale focu await expect(page.locator("form.answer-footer-search-dock")).toBeVisible({ timeout: 20_000 }); // Stale focus=1 on a submitted result view must not win — the shell blurs. - const input = page.getByTestId("global-search-input"); + const input = page.locator('[data-testid="global-search-input"]:visible').first(); await expect(input).not.toBeFocused({ timeout: 5_000 }); const initial = await readGeometry(page); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 6d7d81f2a2..cd793befd6 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -994,7 +994,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByText("Create your Clinical Guide account")).toHaveCount(0); await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0); - await expect(page.getByTestId("global-search-input")).toBeEnabled(); + await expect(page.locator('[data-testid="global-search-input"]:visible').first()).toBeEnabled(); }); test("anonymous mobile user can search without a forced sign-in gate", async ({ page }) => { @@ -1010,7 +1010,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByText("Service unavailable")).toHaveCount(0); await expect(page.getByText("API unavailable")).toHaveCount(0); await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0); - await expect(page.getByTestId("global-search-input")).toBeEnabled(); + await expect(page.locator('[data-testid="global-search-input"]:visible').first()).toBeEnabled(); }); test("mobile search focus is singular, visible, and contained at clipped edges", async ({ page }) => { @@ -2969,7 +2969,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await page.getByRole("button", { name: "Start a new chat" }).click(); await expect(page).toHaveURL(/\?mode=answer&focus=1$/); await expect(page.getByRole("button", { name: "Mode Answer" })).toBeVisible(); - await expect(page.getByTestId("global-search-input")).toBeFocused(); + await expect(page.locator('[data-testid="global-search-input"]:visible').first()).toBeFocused(); }); test("favourites hub hydrates saved services from the registry", async ({ page }) => { @@ -3134,7 +3134,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await mockDemoApi(page); await gotoApp(page, "/?mode=prescribing&q=acamprosate%20renal%20dose&run=1"); - const globalSearchInput = page.getByTestId("global-search-input"); + const globalSearchInput = page.locator('[data-testid="global-search-input"]:visible').first(); await expect(page.getByRole("button", { name: "Mode Medication" })).toBeVisible({ timeout: 30_000 }); await expect(globalSearchInput).toHaveAttribute("placeholder", "Search medication dosing or safety..."); await expect(globalSearchInput).toHaveValue("acamprosate renal dose"); From bbad2a9a9fb212d27d827f4c9b03fc41b4ceaf48 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 07:33:54 +0000 Subject: [PATCH 09/30] =?UTF-8?q?fix(search):=20address=20Codex=20review?= =?UTF-8?q?=20=E2=80=94=20rail=20was=20inert,=20and=20the=20count=20leaked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, all real, all mine. P1 — forms/medication/differentials: suppressing the count in the spine is worthless while a page-supplied filter chip still renders "Forms 0" or "All (0)" beside it. The reader still saw a zero asserted about a search that never ran, which is the exact defect this branch exists to remove. Fixed at the band rather than per page: a faulted band drops filter and mobile controls entirely, since a filter over a result set that failed to load is meaningless anyway. Test now asserts no digit anywhere in the ribbon, not just in the status node. P1 — documents: on the services/forms path the registry has its own status and can be healthy while the unrelated document API is down. Deriving the ribbon's status from the document API let that invalidate good registry results — announcing "Couldn't search" and hiding a valid recordMatchCount while SearchRecordResults rendered those very matches below. Derive from whichever source the ribbon is actually counting. P2 — the accent rail was not rendering at all. `@layer components` loses to Tailwind's utilities layer, and the band root carries `border` / `border-[color:var(--border)]`, so the 2px accent silently degraded to a neutral 1px. AGENTS.md already says component classes here are deliberately unlayered; this block was not. Verified by computed style: borderTopWidth 1px/rgb(229,231,235) before, 2px/rgb(11,111,134) after. My own test missed it because it asserted class presence, so ui-accessibility now asserts computed border width and colour, plus the forced-colors thickening to 3px. P2 — differentials: the fault copy told the reader to retry or browse the catalogue directly while the removed error section had taken both actions with it. Restore rerunSearch as onRetry and the catalogue links as faultAction. Verified: 409 files / 4148 unit tests; ui-accessibility 14 passed; ui-tools + ui-formulation + ui-specifiers 99 passed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- src/app/globals.css | 129 +++++++++--------- .../clinical-dashboard/differentials-home.tsx | 21 +++ .../document-search-results.tsx | 24 +++- .../search-results-header-band.tsx | 23 ++-- tests/search-results-header-band.dom.test.tsx | 30 ++++ tests/ui-accessibility.spec.ts | 29 ++++ 6 files changed, 183 insertions(+), 73 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index ff98132105..8e12cd984b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2918,69 +2918,72 @@ html.theme-transitioning *:after { (a) that is the existing idiom in this file, and (b) `check:type-scale --strict` is a zero gate on arbitrary `text-[Npx]` utilities. Geist is a variable face, so these interpolate instead of snapping to 700. */ -@layer components { - /* The accent is the card's real border-top. As an absolutely-positioned bar - inside an overflow:hidden 12px-radius card the corner arc sliced its ends — - at y=0 the arc is 12px in, at y=2px it is 5.4px in — so the rail started - short and tapered while the 1px border curved past it. A border mitres into - the side borders and follows the radius by construction. */ - .search-band { - border-top: 2px solid var(--clinical-accent); - } - .search-band[data-status="error"], - .search-band[data-status="unauthorized"] { - border-top-color: var(--warning); - } - - .search-band-query { - font-size: 1.21875rem; - font-weight: 560; - letter-spacing: -0.023em; - } - .search-band-count { - font-size: 1.21875rem; - font-weight: 580; - letter-spacing: -0.016em; - font-variant-numeric: tabular-nums; - font-feature-settings: "tnum" 1; - } - /* A zero is a real answer, but it is not news; it steps down in weight and - colour rather than shouting a result that is not there. */ - .search-band-count[data-zero="true"] { - font-weight: 470; - color: var(--text-muted); - } - .search-band-count-word { - font-size: 0.8125rem; - font-weight: 400; - } - .search-band-fault { - font-size: 0.84375rem; - font-weight: 520; - } - .search-band-chip { - font-size: 0.78125rem; - font-weight: 470; - } - .search-band-chip[data-selected="true"] { - font-weight: 540; - } - .search-band-sort-option { - font-size: 0.78125rem; - font-weight: 470; - } - .search-band-sort-option[aria-pressed="true"] { - font-weight: 560; - } - .search-band-ghost { - font-size: 0.78125rem; - font-weight: 480; - } - /* A gradient hairline, so it fades out at both ends rather than butting into - the vertical padding. Gradients are not forced-colors mapped; see below. */ - .search-band-rule { - background: linear-gradient(180deg, transparent, var(--border-strong) 50%, transparent); - } +/* Deliberately UNLAYERED. Tailwind's utilities layer outranks @layer + components regardless of selector specificity, and the band root also + carries the `border` / `border-[color:var(--border)]` utilities — inside a + layer these rules lose and the accent rail silently renders as a neutral + 1px border. Verify with computed styles, not class presence. */ +/* The accent is the card's real border-top. As an absolutely-positioned bar + inside an overflow:hidden 12px-radius card the corner arc sliced its ends — + at y=0 the arc is 12px in, at y=2px it is 5.4px in — so the rail started + short and tapered while the 1px border curved past it. A border mitres into + the side borders and follows the radius by construction. */ +.search-band { + border-top: 2px solid var(--clinical-accent); +} +.search-band[data-status="error"], +.search-band[data-status="unauthorized"] { + border-top-color: var(--warning); +} + +.search-band-query { + font-size: 1.21875rem; + font-weight: 560; + letter-spacing: -0.023em; +} +.search-band-count { + font-size: 1.21875rem; + font-weight: 580; + letter-spacing: -0.016em; + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; +} +/* A zero is a real answer, but it is not news; it steps down in weight and + colour rather than shouting a result that is not there. */ +.search-band-count[data-zero="true"] { + font-weight: 470; + color: var(--text-muted); +} +.search-band-count-word { + font-size: 0.8125rem; + font-weight: 400; +} +.search-band-fault { + font-size: 0.84375rem; + font-weight: 520; +} +.search-band-chip { + font-size: 0.78125rem; + font-weight: 470; +} +.search-band-chip[data-selected="true"] { + font-weight: 540; +} +.search-band-sort-option { + font-size: 0.78125rem; + font-weight: 470; +} +.search-band-sort-option[aria-pressed="true"] { + font-weight: 560; +} +.search-band-ghost { + font-size: 0.78125rem; + font-weight: 480; +} +/* A gradient hairline, so it fades out at both ends rather than butting into + the vertical padding. Gradients are not forced-colors mapped; see below. */ +.search-band-rule { + background: linear-gradient(180deg, transparent, var(--border-strong) 50%, transparent); } /* MUST remain the last rules in this stylesheet. At equal specificity a later diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 62eeff1d80..c00b2d69cc 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -922,6 +922,27 @@ function SearchResultsView({ : "The differentials catalogue could not be searched" } faultBody="Retry the search shortly, or browse the catalogue pages directly." + // The fault copy promises two recoveries, so both have to exist: rerun the + // search, and the catalogue links that the removed error section used to + // carry. Without these the failed view tells the reader to act and gives + // them nothing to act with. + onRetry={catalog.status === "unauthorized" ? undefined : rerunSearch} + faultAction={ + <> + + Browse presentations + + + Browse diagnoses + + + } sortValue={sortValue} onSortChange={setSortValue} filterLabel="Filter differential result type" diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index ec3d0c6a64..051bfd0a87 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -885,8 +885,28 @@ function DocumentSearchResultsPanelImpl({ modeId={showRecordMatches ? recordMode : "documents"} query={trimmedQuery} matchCount={recordMatchCount + sortedMatches.length} - status={unavailableMessage ? "error" : loading ? "loading" : "ready"} - faultBody={unavailableMessage ?? undefined} + // Derive the fault from whichever source this ribbon is actually + // counting. On the services/forms path the registry has its own status + // and can be perfectly healthy while the unrelated document API is + // down; letting that invalidate the ribbon would announce "Couldn't + // search" and hide a valid recordMatchCount while SearchRecordResults + // renders those very matches below. + status={ + showRecordMatches + ? recordStatus === "unauthorized" + ? "unauthorized" + : recordStatus === "error" || recordStatus === "not_found" + ? "error" + : recordStatus === "loading" + ? "loading" + : "ready" + : unavailableMessage + ? "error" + : loading + ? "loading" + : "ready" + } + faultBody={showRecordMatches ? undefined : (unavailableMessage ?? undefined)} sortValue={sortValue} onSortChange={matches.length > 0 ? setSortValue : undefined} utilityControls={ diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index 7d98342815..57d684f143 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -163,9 +163,16 @@ export function SearchResultsHeaderBand({ ? "Your session has expired. Sign in again to run this search." : "The search could not be completed. Try again shortly."); const [retrying, setRetrying] = useState(false); + // Page-supplied filter/mobile controls carry their own result counts ("Forms 0", + // "All (0)"). Suppressing the number in the spine while those still render it + // defeats the whole invariant — the reader still sees a zero asserted about a + // search that never ran. A filter over a result set that failed to load is + // meaningless anyway, so the faulted band drops them entirely. + const pageControls = faulted ? null : filterControls; + const pageMobileControls = faulted ? null : mobileControls; const hasUtilities = visibleScopes.length > 0 || - Boolean(onSortChange || onViewChange || onSaveSearch || utilityControls || mobileControls); + Boolean(onSortChange || onViewChange || onSaveSearch || utilityControls || pageMobileControls); const QueryHeading = headingLevel === 1 ? "h1" : "h2"; const { ref: railRef, overflowing: railOverflowing } = useRailOverflow(); @@ -295,27 +302,27 @@ export function SearchResultsHeaderBand({ {/* Desktop only: pushes the controls to the trailing edge while the chips stay next to the query. On the phone rail this collapses away. */} - {onSortChange && mobileControls ? ( + {onSortChange && pageMobileControls ? (
- {mobileControls} + {pageMobileControls}
) : ( <> {onSortChange ? : null} - {mobileControls ? ( + {pageMobileControls ? (
- {mobileControls} + {pageMobileControls}
) : null} @@ -375,17 +382,17 @@ export function SearchResultsHeaderBand({
) : null}
- {filterControls ? ( + {pageControls ? (
- {filterControls} + {pageControls}
) : null} {/* The fault panel carries the announcement and the recovery affordance. diff --git a/tests/search-results-header-band.dom.test.tsx b/tests/search-results-header-band.dom.test.tsx index 72adc8e022..e8a342d1c3 100644 --- a/tests/search-results-header-band.dom.test.tsx +++ b/tests/search-results-header-band.dom.test.tsx @@ -52,6 +52,36 @@ describe("SearchResultsHeaderBand", () => { expect(within(region).queryByText(/\d/)).toBeNull(); }); + // Suppressing the count in the spine is worthless if a page-supplied filter + // chip still renders "Forms 0" beside it — the reader still sees a zero + // asserted about a search that never ran. + it("drops count-bearing page controls while faulted", () => { + const { rerender } = render( + Forms 0
} + />, + ); + expect(screen.getByText("Forms 0")).toBeVisible(); + + rerender( + Forms 0
} + />, + ); + const region = screen.getByRole("region", { name: "Search results for transport order" }); + expect(screen.queryByText("Forms 0")).toBeNull(); + // Belt and braces: no digit anywhere in the ribbon, not just in the spine. + expect(within(region).queryByText(/\d/)).toBeNull(); + }); + it("keeps exactly one status region and one alert while faulted", () => { render(); diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index e744d20409..b2b5b3e0f8 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -606,4 +606,33 @@ test.describe("Clinical KB accessibility coverage", () => { expect(editableOutline).not.toBe("none"); await expectNoBlockingAxeViolations(page, testInfo); }); + // The accent rail must be the card's real border-top and must actually win the + // cascade. Tailwind's utilities layer outranks `@layer components`, and the band + // root also carries `border` / `border-[color:var(--border)]`, so a layered rule + // silently degrades to a neutral 1px border while every class-presence assertion + // still passes. Assert computed style, not classes. + test("search results band renders the accent rail and survives forced colors", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/services?q=CMHT&run=1", { waitUntil: "domcontentloaded" }); + const band = page.locator('[data-testid="search-query-ribbon"]:visible').first(); + await expect(band).toBeVisible({ timeout: 20_000 }); + + const rail = await band.evaluate((node) => { + const style = getComputedStyle(node); + return { width: style.borderTopWidth, color: style.borderTopColor, leftWidth: style.borderLeftWidth }; + }); + expect(rail.width, "accent rail must be 2px, not the neutral 1px border").toBe("2px"); + expect(rail.color, "accent rail must use the clinical accent, not --border").not.toBe(rail.leftWidth); + expect(rail.color).not.toBe("rgb(229, 231, 235)"); + + // Under forced colors the rail survives as thickness, since --clinical-accent + // resolves to LinkText and would otherwise match the other three borders. + // Poll: the forced-colors style recalc does not always land on the first + // read after emulateMedia resolves. + await page.emulateMedia({ forcedColors: "active" }); + await expect + .poll(async () => band.evaluate((node) => getComputedStyle(node).borderTopWidth), { timeout: 10_000 }) + .toBe("3px"); + await page.emulateMedia({ forcedColors: "none" }); + }); }); From 8be18c3766048a8588083f5e8a46cfc91fe27aa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 07:42:08 +0000 Subject: [PATCH 10/30] fix(differentials): make the catalogue Retry actually retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Retry I added in bbad2a9a called rerunSearch, which only re-runs the parent document-evidence search. useDifferentialSearch keys on query plus auth identity, neither of which changes when the reader asks to try again, so the catalogue request was never reissued and the band stayed faulted. A Retry that cannot retry is worse than no Retry: it promises recovery and silently does nothing. Give the hook a refetch, mirroring useRegistryRecords. A failed request is never written to the cache, so bumping a retry counter in the effect deps is enough to re-request — no eviction needed, and the identity/query clearing guards are untouched. Test asserts the real behaviour Codex asked for: first catalogue request fails, refetch is called, and both kind=diagnosis and kind=presentation are requested again with the band returning to ready. One existing assertion moved from toEqual to toMatchObject, since the hook's return now carries the refetch callback. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- .../clinical-dashboard/differentials-home.tsx | 6 ++- .../use-differential-catalog.ts | 27 ++++++++--- tests/use-differential-search.dom.test.tsx | 46 ++++++++++++++++++- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index c00b2d69cc..563ec9ad7d 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -926,7 +926,11 @@ function SearchResultsView({ // search, and the catalogue links that the removed error section used to // carry. Without these the failed view tells the reader to act and gives // them nothing to act with. - onRetry={catalog.status === "unauthorized" ? undefined : rerunSearch} + // Retry the request that actually failed. `rerunSearch` only re-runs the + // parent document-evidence search; the catalogue hook keys on query + auth + // identity, neither of which changes when the reader asks to try again, so + // routing Retry through it left the band permanently faulted. + onRetry={catalog.status === "unauthorized" ? undefined : catalog.refetch} faultAction={ <> void }; + +export function useDifferentialSearch(query: string): DifferentialSearchResult { const { authorizationHeader, markSessionExpired, status: authStatus } = useAuthSession(); const requestKey = query.trim().toLowerCase(); const authSignature = JSON.stringify(authorizationHeader ?? {}); @@ -134,6 +139,16 @@ export function useDifferentialSearch(query: string): DifferentialSearchState { } } + // Retry bumps this so the fetch effect re-runs on an unchanged query. Without + // it a Retry button is inert: the hook keys on query + auth identity, neither + // of which changes when the reader asks to try again. + const [retryAttempt, setRetryAttempt] = useState(0); + const refetch = useCallback(() => { + if (!requestKey) return; + setState({ status: "loading", matches: emptyDifferentialMatches, demoMode: false }); + setRetryAttempt((attempt) => attempt + 1); + }, [requestKey]); + useEffect(() => { if (!requestKey || !cacheKey) return undefined; @@ -197,15 +212,15 @@ export function useDifferentialSearch(query: string): DifferentialSearchState { window.clearTimeout(timer); controller.abort(); }; - }, [requestKey, cacheKey, authStatus, authorizationHeader, markSessionExpired]); + }, [requestKey, cacheKey, authStatus, authorizationHeader, markSessionExpired, retryAttempt]); if (!requestKey) { - return { status: "ready", matches: emptyDifferentialMatches, demoMode: false }; + return { status: "ready", matches: emptyDifferentialMatches, demoMode: false, refetch }; } if (cached && state.status !== "unauthorized" && state.status !== "error") { - return { status: "ready", matches: cached.matches, demoMode: cached.demoMode }; + return { status: "ready", matches: cached.matches, demoMode: cached.demoMode, refetch }; } - return state; + return { ...state, refetch }; } export function useDifferentialRecord(slug: string): DifferentialRecordState { diff --git a/tests/use-differential-search.dom.test.tsx b/tests/use-differential-search.dom.test.tsx index a5dfddabea..c60f6d7c12 100644 --- a/tests/use-differential-search.dom.test.tsx +++ b/tests/use-differential-search.dom.test.tsx @@ -127,11 +127,14 @@ describe("useDifferentialSearch debounce/abort/cache", () => { const { result } = renderHook(() => useDifferentialSearch(" ")); await advanceDebounce(); expect(fetchMock).not.toHaveBeenCalled(); - expect(result.current).toEqual({ + // toMatchObject rather than toEqual: the hook also returns a `refetch` + // callback, which this case is not asserting on. + expect(result.current).toMatchObject({ status: "ready", matches: { diagnoses: [], presentations: [] }, demoMode: false, }); + expect(typeof result.current.refetch).toBe("function"); }); it("clears prior matches immediately when the auth identity changes", async () => { @@ -205,3 +208,44 @@ describe("useDifferentialSearch debounce/abort/cache", () => { expect(fetchMock).toHaveBeenCalled(); }); }); + +describe("useDifferentialSearch retry", () => { + // A Retry button that cannot re-issue the failed request is worse than none: + // it promises recovery and silently does nothing. The hook keys on query + + // auth identity, neither of which changes when the reader asks to try again. + it("re-requests both catalogue endpoints when refetch is called after a failure", async () => { + fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ error: "boom" }, 500))); + + const { result } = renderHook(() => useDifferentialSearch("acute confusion")); + await advanceDebounce(); + await flushMicrotasks(); + + expect(result.current.status).toBe("error"); + const callsBeforeRetry = fetchMock.mock.calls.length; + expect(callsBeforeRetry).toBeGreaterThan(0); + + fetchMock.mockImplementation((input) => + Promise.resolve( + jsonResponse( + String(input).includes("kind=diagnosis") + ? { + matches: [{ record: { slug: "delirium", title: "Delirium" }, score: 5, reasons: ["title"] }], + demoMode: true, + } + : { matches: [], demoMode: true }, + ), + ), + ); + + await act(async () => { + result.current.refetch(); + }); + await advanceDebounce(); + await flushMicrotasks(); + + const retryCalls = fetchMock.mock.calls.slice(callsBeforeRetry).map(([input]) => String(input)); + expect(retryCalls.some((url) => url.includes("kind=diagnosis"))).toBe(true); + expect(retryCalls.some((url) => url.includes("kind=presentation"))).toBe(true); + expect(result.current.status).toBe("ready"); + }); +}); From 65ba24283720df2d9030a6450b161325c05db0be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 07:49:37 +0000 Subject: [PATCH 11/30] fix(search): stop a faulted differentials search from crashing the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit found a crash I introduced. `best` is `results[0] ?? null`, and the gate `!best && !catalogFailed` means a faulted catalogue with no results takes neither the loading nor the empty branch — control reaches the results grid, which dereferences `best.id` and `best.kind` unconditionally and throws. That fires on every failed or unauthorized differentials search: exactly the state this branch exists to report truthfully. I had already seen this and misread it. An earlier probe of the failed catalogue path returned zero rendered elements and I recorded that as "the component does not render when the catalogue fails". It was an error boundary swallowing a crashed subtree. `catalogFailed` now short-circuits before the `!best` test. Four more findings in the same family, all confirmed against the code rather than taken on the reviewer's word: - therapy-compass rendered "No therapies match those filters" underneath the fault panel, so the page claimed both failure and emptiness. - favourites (library and hub) treated an empty list as "no favourites" during loading and failure, which reads as data loss rather than an unavailable registry. The hub had also discarded the status entirely. - documents dropped the unavailable-API notice in record mode: the ribbon takes its fault from the registry there, so nothing carried the message. - a rejecting `onRetry` escaped as an unhandled rejection. Also fixed the assertion I added last round to catch the inert rail: it compared `borderTopColor` against `borderLeftWidth` — a colour against a length — so it could never fail. It now compares colour to colour. Verified: 409 files / 4149 unit tests, lint, typecheck. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- .../clinical-dashboard/differentials-home.tsx | 9 ++++++-- .../document-search-results.tsx | 5 +++- .../favourites-command-library-page.tsx | 5 +++- .../clinical-dashboard/favourites-hub.tsx | 23 ++++++++++++++++--- .../search-results-header-band.tsx | 3 +++ .../therapy-compass/screens/search-screen.tsx | 5 +++- tests/ui-accessibility.spec.ts | 4 ++-- 7 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 563ec9ad7d..373354b4fc 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -994,8 +994,13 @@ function SearchResultsView({ ))}
) : /* A failed catalogue search is reported by the band's fault panel, which - also owns the retry copy; this section is now the empty state only. */ - !best && !catalogFailed ? ( + also owns the retry copy, so the whole body is suppressed here. + `catalogFailed` must short-circuit BEFORE the `!best` test: `best` is + `results[0] ?? null`, and a faulted search with no results would + otherwise fall through to the results grid, which dereferences + `best.id` / `best.kind` unconditionally and throws — on exactly the + state this component is meant to report truthfully. */ + catalogFailed ? null : !best ? (
: null} - {query.trim() && scopedItems.length === 0 ? ( + {/* Only a successful read can say "no matches". While loading or + faulted an empty list means we could not look, not that the + library is empty; the band's fault panel reports that. */} + {query.trim() && scopedItems.length === 0 && favouritesRegistryStatus === "ready" ? ( ) : ( [...(demoMode ? favouriteItems : []), ...savedRegistryFavourites], [demoMode, savedRegistryFavourites], @@ -425,9 +427,24 @@ export function FavouritesHub({
diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index 57d684f143..5bde319326 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -418,6 +418,9 @@ export function SearchResultsHeaderBand({ setRetrying(true); try { await onRetry(); + } catch { + // The fault panel is already the failure surface; a rejected + // retry leaves it in place rather than escaping unhandled. } finally { setRetrying(false); } diff --git a/src/components/therapy-compass/screens/search-screen.tsx b/src/components/therapy-compass/screens/search-screen.tsx index ef831a2e35..05a985d413 100644 --- a/src/components/therapy-compass/screens/search-screen.tsx +++ b/src/components/therapy-compass/screens/search-screen.tsx @@ -122,7 +122,10 @@ export function SearchScreen() { } /> - {b.loading ? ( + {/* The band's fault panel owns the failure. Without this guard an error + also renders the empty state, so the page says both "we couldn't + search" and "nothing matched" at once. */} + {b.error ? null : b.loading ? ( ) : ( <> diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index b2b5b3e0f8..68b0e1605f 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -619,10 +619,10 @@ test.describe("Clinical KB accessibility coverage", () => { const rail = await band.evaluate((node) => { const style = getComputedStyle(node); - return { width: style.borderTopWidth, color: style.borderTopColor, leftWidth: style.borderLeftWidth }; + return { width: style.borderTopWidth, color: style.borderTopColor, leftColor: style.borderLeftColor }; }); expect(rail.width, "accent rail must be 2px, not the neutral 1px border").toBe("2px"); - expect(rail.color, "accent rail must use the clinical accent, not --border").not.toBe(rail.leftWidth); + expect(rail.color, "accent rail must use the clinical accent, not --border").not.toBe(rail.leftColor); expect(rail.color).not.toBe("rgb(229, 231, 235)"); // Under forced colors the rail survives as thickness, since --clinical-accent From db592b26dc0663ebc919cc92f396d866c4110df1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 07:56:58 +0000 Subject: [PATCH 12/30] fix(search): close the count leak during loading and harden the adoption gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more from Codex, both mine. The count leak I fixed at the band only covered faults. Forms forces `displayedMatches` to [] until the registry is ready, so ResultTabs still asserted "Forms 0" underneath a "Searching…" spine — the same untrue zero, one state over. Loading now suppresses count-bearing controls too. `refetching` deliberately does not: there the prior count is still correct, which is the whole point of that state. The adoption gate matched the bare identifier, so an import, a comment, or a dead reference counted as adoption — a production search route could drop the band while the gate I built to prevent that stayed green. It now requires a rendered ` Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- .../search-results-header-band.tsx | 9 ++++-- tests/search-results-band-adoption.test.ts | 28 +++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index 5bde319326..02ea4ef8b6 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -168,8 +168,13 @@ export function SearchResultsHeaderBand({ // defeats the whole invariant — the reader still sees a zero asserted about a // search that never ran. A filter over a result set that failed to load is // meaningless anyway, so the faulted band drops them entirely. - const pageControls = faulted ? null : filterControls; - const pageMobileControls = faulted ? null : mobileControls; + // `loading` means no trustworthy count exists yet, exactly like a fault: forms + // forces `displayedMatches` to [] until the registry is ready, so ResultTabs + // would assert "Forms 0" beneath a "Searching…" spine. `refetching` is + // deliberately excluded — there the prior count is still correct. + const countUntrusted = faulted || resolvedStatus === "loading"; + const pageControls = countUntrusted ? null : filterControls; + const pageMobileControls = countUntrusted ? null : mobileControls; const hasUtilities = visibleScopes.length > 0 || Boolean(onSortChange || onViewChange || onSaveSearch || utilityControls || pageMobileControls); diff --git a/tests/search-results-band-adoption.test.ts b/tests/search-results-band-adoption.test.ts index d78c211ff0..07c7485329 100644 --- a/tests/search-results-band-adoption.test.ts +++ b/tests/search-results-band-adoption.test.ts @@ -20,6 +20,13 @@ const REPO_ROOT = path.resolve(__dirname, ".."); const COMPONENTS_DIR = path.join(REPO_ROOT, "src", "components"); const APP_DIR = path.join(REPO_ROOT, "src", "app"); const BAND_IDENTIFIER = "SearchResultsHeaderBand"; +/** A rendered element, not a bare mention. Matching the identifier alone counts + an import, a comment, or a dead reference as adoption, so a production route + could drop the band while this gate stayed green. */ +const BAND_ELEMENT = `<${BAND_IDENTIFIER}`; +function rendersBand(source: string) { + return source.includes(BAND_ELEMENT); +} /** Mockups are design scratch and exempt from production wiring gates. */ function isMockupPath(relativePath: string) { @@ -85,7 +92,7 @@ describe("search results band adoption", () => { const mounted = new Set(); for (const { abs } of productionComponents) { const source = readFileSync(abs, "utf8"); - if (!source.includes(BAND_IDENTIFIER)) continue; + if (!rendersBand(source)) continue; for (const modeId of collectBandModeIds(source)) mounted.add(modeId); } @@ -121,7 +128,7 @@ describe("search results band adoption", () => { const key = rel.replaceAll(path.sep, "/"); if (BAND_ROUTE_ALLOWLIST.has(key)) continue; const routeSource = readFileSync(abs, "utf8"); - if (routeSource.includes(BAND_IDENTIFIER)) continue; + if (rendersBand(routeSource)) continue; // One import hop: a route almost always delegates to a client component. const imported = [...routeSource.matchAll(/from "@\/(components\/[^"]+)"/g)].map((match) => match[1]); const reaches = imported.some((specifier) => @@ -157,3 +164,20 @@ describe("search results band adoption", () => { ).toBeGreaterThan(forcedColorsOpeners[forcedColorsOpeners.length - 1]); }); }); + +describe("band adoption detection", () => { + // Negative fixture: importing or mentioning the identifier is not adoption. + // Without this, the gate above passes on a route that never mounts the band. + it("does not count an import, comment, or dead reference as a mount", () => { + const imported = + 'import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band";'; + const mentioned = "// SearchResultsHeaderBand is rendered by the shell, not here."; + const referenced = "const Band = SearchResultsHeaderBand;"; + const mounted = ''; + + for (const source of [imported, mentioned, referenced]) { + expect(source.includes(" Date: Tue, 28 Jul 2026 08:02:21 +0000 Subject: [PATCH 13/30] fix(search): close remaining review gaps on the results band - Branch differentials unauthorized fault copy so it does not promise Retry. - Keep favourites band ready when unaffected items already exist, and thread registry refetch into command-library and hub recovery actions. - Harden the adoption forced-colors gate to require membership in the final block, and require a real band mount on one-hop route checks. - Cover loading suppression of count-bearing page controls in the band DOM suite. Co-authored-by: BigSimmo --- docs/search-chrome-behaviour.md | 17 +++++------ .../clinical-dashboard/differentials-home.tsx | 9 ++++-- .../favourites-command-library-page.tsx | 11 ++++++-- .../clinical-dashboard/favourites-hub.tsx | 15 +++++++++- .../use-saved-registry-favourites.ts | 27 +++++++++++++++--- tests/favourites-auth-gate.dom.test.tsx | 7 ++++- ...ites-hub-unavailable-controls.dom.test.tsx | 7 ++++- tests/mode-menu-prefetch.dom.test.tsx | 7 ++++- tests/search-results-band-adoption.test.ts | 28 +++++++++++++++---- tests/search-results-header-band.dom.test.tsx | 16 +++++++++++ 10 files changed, 118 insertions(+), 26 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 5d2a67afc8..7156887dda 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -50,14 +50,15 @@ chrome and changes to it land on every mode at once. Keep these rules: search never ran. `0` with `status="ready"` is a real answer and still renders. Pages own the mapping from their own data source; five pages have no async source and are correct on the default. -1. **The query is the only heading-weight thing in the band.** Nothing here is bold: the heaviest - step is 580 and the query (560) and the figure (580) share it, separated by tabular numerals - and a hairline rather than by shouting. No eyebrow — the magnifier tile already says "search", - and a `QUERY` / `RESULTS FOR` label costs a line to repeat it. The query truncates; the count - does not. Weights live as numeric `font-weight` on `.search-band-*` classes in `globals.css`, - not as Tailwind arbitrary values: `check:type-scale --strict` is a zero gate on arbitrary - `text-[Npx]`, and Geist is a variable face so 470/540/560/580 interpolate rather than snapping - to 700. Judge weights only with the app font loaded. +1. **The query is the only heading element in the band.** It is the sole `

`/`

`; the count + is never a heading. Nothing here is bold: the query uses weight 560 (`.search-band-query`) and + the figure uses 580 (`.search-band-count`) — two nearby steps of the same scale, separated by + tabular numerals and a hairline rather than by shouting. No eyebrow — the magnifier tile + already says "search", and a `QUERY` / `RESULTS FOR` label costs a line to repeat it. The + query truncates; the count does not. Weights live as numeric `font-weight` on `.search-band-*` + classes in `globals.css`, not as Tailwind arbitrary values: `check:type-scale --strict` is a + zero gate on arbitrary `text-[Npx]`, and Geist is a variable face so 470/540/560/580 + interpolate rather than snapping to 700. Judge weights only with the app font loaded. 2. **The count is neutral text, not a success pill.** `text-muted` with the figure itself `.search-band-count` (580, tabular-nums), stepping down to 470 and muted at zero. Success colour is reserved for states that were actually achieved, so it still carries meaning where diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index 373354b4fc..295cde92d8 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -921,11 +921,16 @@ function SearchResultsView({ ? "Sign in again to search the differentials catalogue" : "The differentials catalogue could not be searched" } - faultBody="Retry the search shortly, or browse the catalogue pages directly." + faultBody={ + catalog.status === "unauthorized" + ? "Sign in again to search, or browse the catalogue pages directly." + : "Retry the search shortly, or browse the catalogue pages directly." + } // The fault copy promises two recoveries, so both have to exist: rerun the // search, and the catalogue links that the removed error section used to // carry. Without these the failed view tells the reader to act and gives - // them nothing to act with. + // them nothing to act with. Unauthorized omits Retry because a refetch + // cannot mint a session — the body must not promise one either. // Retry the request that actually failed. `rerunSearch` only re-runs the // parent document-evidence search; the catalogue hook keys on query + auth // identity, neither of which changes when the reader asks to try again, so diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 73e9dc972b..786f411359 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -1032,7 +1032,11 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: const [accountSetupDismissed, setAccountSetupDismissed] = useState(false); const accountSetupOpen = authSettled && !favouritesAccessible && !accountSetupDismissed; const [navCollapsed, setNavCollapsed] = useFavouritesNavCollapsed(); - const { items: savedRegistryFavourites, status: favouritesRegistryStatus } = useSavedRegistryFavourites(); + const { + items: savedRegistryFavourites, + status: favouritesRegistryStatus, + refetch: refetchFavouritesRegistry, + } = useSavedRegistryFavourites(); const items = useMemo( () => [...(demoMode ? prototypeFavouriteItems : []), ...savedRegistryFavourites].map(toCommandItem), [demoMode, savedRegistryFavourites], @@ -1197,8 +1201,11 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: matchCount={scopedItems.length} // Without this a failed registry read renders as "0 matches", which // reads as "you have no saved favourites" rather than "we could not - // load them". + // load them". `status` stays ready when unaffected items already + // exist (local differentials, etc.) so a partial registry fault + // does not hide a valid nonzero count. status={favouritesRegistryStatus} + onRetry={favouritesRegistryStatus === "error" ? refetchFavouritesRegistry : undefined} filterLabel="Active favourites filters" filterControls={ selectedTypeId !== "all" || selectedSet || viewMode !== "all" ? ( diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index aa3e018947..82af02c30b 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -49,7 +49,11 @@ export function FavouritesHub({ }) { // Keep the status: discarding it makes a failed registry read indistinguishable // from an empty library. - const { items: savedRegistryFavourites, status: savedRegistryStatus } = useSavedRegistryFavourites(); + const { + items: savedRegistryFavourites, + status: savedRegistryStatus, + refetch: refetchFavouritesRegistry, + } = useSavedRegistryFavourites(); const allFavouriteItems = useMemo( () => [...(demoMode ? favouriteItems : []), ...savedRegistryFavourites], [demoMode, savedRegistryFavourites], @@ -446,6 +450,15 @@ export function FavouritesHub({ ? "Your session expired. Sign in again to see your saved items." : "Your saved items could not be loaded. Try again shortly."}

+ {savedRegistryStatus === "error" ? ( + + ) : null}

) : null} diff --git a/src/components/clinical-dashboard/use-saved-registry-favourites.ts b/src/components/clinical-dashboard/use-saved-registry-favourites.ts index 9dd02298df..26d475e64f 100644 --- a/src/components/clinical-dashboard/use-saved-registry-favourites.ts +++ b/src/components/clinical-dashboard/use-saved-registry-favourites.ts @@ -2,7 +2,7 @@ import { BrainCircuit, ClipboardList } from "lucide-react"; import { appModeIcons } from "@/lib/app-mode-icons"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useAccountData } from "@/components/account-data-provider"; import type { FavouriteItem } from "@/components/clinical-dashboard/favourites-prototype-data"; @@ -27,8 +27,16 @@ function recordToFavourite(record: ServiceRecord, type: "services" | "forms"): F export type SavedRegistryFavouritesResult = { items: FavouriteItem[]; /** Folded state of the registries this hook actually requested, so the page can - report a failure instead of rendering an empty list as "no favourites". */ + report a failure instead of rendering an empty list as "no favourites". + When unaffected sources already produced items (e.g. local differentials + while a service registry failed), this stays `ready` so the band can keep + an honest nonzero count — see `registryStatus` for the raw fold. */ status: "ready" | "loading" | "unauthorized" | "error"; + /** Raw folded registry status before the nonempty-items override. Useful when + a page needs to distinguish a partial registry fault from a clean ready. */ + registryStatus: "ready" | "loading" | "unauthorized" | "error"; + /** Re-runs every registry this hook actually requested. */ + refetch: () => void; }; export function useSavedRegistryFavourites(): SavedRegistryFavouritesResult { @@ -72,13 +80,24 @@ export function useSavedRegistryFavourites(): SavedRegistryFavouritesResult { // hook sits in its initial state forever and must not be read as a failure. // Unauthorized outranks error because it is the one the reader can act on. const requested = [savedServices.length > 0 ? services.status : null, savedForms.length > 0 ? forms.status : null]; - const status = requested.includes("unauthorized") + const registryStatus = requested.includes("unauthorized") ? "unauthorized" : requested.includes("error") || requested.includes("not_found") ? "error" : requested.includes("loading") ? "loading" : "ready"; + // A nonempty list from unaffected sources (local differentials, or one registry + // that succeeded) must not be hidden behind a whole-band fault/loading state — + // that drops a valid nonzero count while the table below still renders items. + const status = items.length > 0 && registryStatus !== "ready" ? "ready" : registryStatus; - return { items, status }; + const refetchServices = services.refetch; + const refetchForms = forms.refetch; + const refetch = useCallback(() => { + if (savedServices.length > 0) refetchServices(); + if (savedForms.length > 0) refetchForms(); + }, [savedServices.length, savedForms.length, refetchServices, refetchForms]); + + return { items, status, registryStatus, refetch }; } diff --git a/tests/favourites-auth-gate.dom.test.tsx b/tests/favourites-auth-gate.dom.test.tsx index 43e8aa7ade..e13ec50329 100644 --- a/tests/favourites-auth-gate.dom.test.tsx +++ b/tests/favourites-auth-gate.dom.test.tsx @@ -31,7 +31,12 @@ vi.mock("next/navigation", () => ({ })); vi.mock("@/components/clinical-dashboard/use-saved-registry-favourites", () => ({ - useSavedRegistryFavourites: () => ({ items: [], status: "ready" }), + useSavedRegistryFavourites: () => ({ + items: [], + status: "ready", + registryStatus: "ready", + refetch: () => undefined, + }), })); vi.mock("@/components/clinical-dashboard/search-command-context", () => ({ diff --git a/tests/favourites-hub-unavailable-controls.dom.test.tsx b/tests/favourites-hub-unavailable-controls.dom.test.tsx index d7b44abd93..97ca75fa3d 100644 --- a/tests/favourites-hub-unavailable-controls.dom.test.tsx +++ b/tests/favourites-hub-unavailable-controls.dom.test.tsx @@ -4,7 +4,12 @@ import { describe, expect, it, vi } from "vitest"; import { FavouritesHub } from "@/components/clinical-dashboard/favourites-hub"; vi.mock("@/components/clinical-dashboard/use-saved-registry-favourites", () => ({ - useSavedRegistryFavourites: () => ({ items: [], status: "ready" }), + useSavedRegistryFavourites: () => ({ + items: [], + status: "ready", + registryStatus: "ready", + refetch: () => undefined, + }), })); describe("FavouritesHub unavailable controls", () => { diff --git a/tests/mode-menu-prefetch.dom.test.tsx b/tests/mode-menu-prefetch.dom.test.tsx index ae8db4f174..15835ed887 100644 --- a/tests/mode-menu-prefetch.dom.test.tsx +++ b/tests/mode-menu-prefetch.dom.test.tsx @@ -29,7 +29,12 @@ vi.mock("@/lib/supabase/client", () => ({ })); vi.mock("@/components/clinical-dashboard/use-saved-registry-favourites", () => ({ - useSavedRegistryFavourites: () => ({ items: [], status: "ready" }), + useSavedRegistryFavourites: () => ({ + items: [], + status: "ready", + registryStatus: "ready", + refetch: () => undefined, + }), })); vi.mock("@/components/clinical-dashboard/search-command-context", () => ({ diff --git a/tests/search-results-band-adoption.test.ts b/tests/search-results-band-adoption.test.ts index 07c7485329..4d63cba43e 100644 --- a/tests/search-results-band-adoption.test.ts +++ b/tests/search-results-band-adoption.test.ts @@ -133,7 +133,7 @@ describe("search results band adoption", () => { const imported = [...routeSource.matchAll(/from "@\/(components\/[^"]+)"/g)].map((match) => match[1]); const reaches = imported.some((specifier) => [...componentSources.entries()].some( - ([componentPath, source]) => componentPath === `src/${specifier}.tsx` && source.includes(BAND_IDENTIFIER), + ([componentPath, source]) => componentPath === `src/${specifier}.tsx` && rendersBand(source), ), ); if (!reaches) orphans.push(key); @@ -146,7 +146,7 @@ describe("search results band adoption", () => { ).toEqual([]); }); - it("keeps the band's forced-colors rules last in the stylesheet", () => { + it("keeps the band's forced-colors rules inside the final forced-colors block", () => { // At equal specificity a later rule wins, so a forced-colors block placed // before another one is silently overridden while still reading correctly. const globals = readFileSync(path.join(REPO_ROOT, "src", "app", "globals.css"), "utf8"); @@ -155,13 +155,29 @@ describe("search results band adoption", () => { ); expect(forcedColorsOpeners.length).toBeGreaterThan(0); - const bandRule = globals.lastIndexOf(".search-band"); - if (bandRule === -1) return; // The visual phase has not landed yet. + const lastOpener = forcedColorsOpeners[forcedColorsOpeners.length - 1]; + let depth = 0; + let lastCloser = -1; + for (let index = lastOpener; index < globals.length; index += 1) { + const char = globals[index]; + if (char === "{") depth += 1; + else if (char === "}") { + depth -= 1; + if (depth === 0) { + lastCloser = index; + break; + } + } + } + expect(lastCloser).toBeGreaterThan(lastOpener); + + const bandRule = globals.indexOf(".search-band", lastOpener); expect( bandRule, - "The band's forced-colors rules must sit inside the last @media (forced-colors: active) " + + "The band's forced-colors rules must exist inside the last @media (forced-colors: active) " + "block, or an earlier block at equal specificity will override them.", - ).toBeGreaterThan(forcedColorsOpeners[forcedColorsOpeners.length - 1]); + ).toBeGreaterThan(lastOpener); + expect(bandRule).toBeLessThan(lastCloser); }); }); diff --git a/tests/search-results-header-band.dom.test.tsx b/tests/search-results-header-band.dom.test.tsx index e8a342d1c3..b08105b458 100644 --- a/tests/search-results-header-band.dom.test.tsx +++ b/tests/search-results-header-band.dom.test.tsx @@ -82,6 +82,22 @@ describe("SearchResultsHeaderBand", () => { expect(within(region).queryByText(/\d/)).toBeNull(); }); + // Initial loading is the same untrue-zero risk: forms forces matches to [] until + // the registry is ready, so ResultTabs would assert "Forms 0" under Searching…. + it("drops count-bearing page controls while loading", () => { + render( + Forms 0} + />, + ); + expect(screen.getByRole("status")).toHaveTextContent("Searching…"); + expect(screen.queryByText("Forms 0")).toBeNull(); + }); + it("keeps exactly one status region and one alert while faulted", () => { render(); From af07e34b5e6ca958206926a05509c1e321dbe862 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 08:02:43 +0000 Subject: [PATCH 14/30] docs(ledger): record PR #1316 review-gap closeout Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 7cc0fcc298..a838a215df 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1165,3 +1165,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-28 | PR #1304 / `fix-test-run-lock` | `2abe39506f9f0ceaf8cf638bfa2b6dc37dc0ed2c` | CI babysit: resync main | FIXED. After green PR required on `f7d86c27`, main advanced by 1 commit (#1305); GitHub DIRTY again but `git merge-tree` CLEAN. Merged origin/main. No product conflicts; unique delta still knip.json (+ ledger). | merge-tree CLEAN; hosted PR required SUCCESS on prior tip; no provider-backed checks. | | 2026-07-28 | PR #1304 / `fix-test-run-lock` | `463e5c0adc77fe722e20376666f5991db3e288d9` | CI babysit closeout | MERGE-READY. Hosted PR required SUCCESS on exact tip; mergeable=MERGEABLE; 0 behind main; merge-tree CLEAN. Unique product delta: knip.json removes unused tailwindcss ignoreDependencies. Prior GitHub DIRTY labels during babysit were main-churn only. | Hosted CI run success on 463e5c0a; no provider-backed checks. | | 2026-07-28 | PR #1304 / fix-test-run-lock | 7cc32c053c752bef19f3de408a1376428e54af74 | CI babysit: sync main | SUPERSEDED (documenting stale-CI ledger error). Prior row for this HEAD incorrectly treated hosted required-CI SUCCESS on earlier tip e6b826ed9150f312c2e7a957f715019e73a7f0be as verification of this later merge commit 7cc32c05. No hosted required-CI result exists for this exact SHA. This ref has since advanced; the later row at 463e5c0adc77fe722e20376666f5991db3e288d9 recorded exact-tip hosted CI SUCCESS, so this commit's status is historical/superseded. | No hosted CI run on this exact HEAD; prior row reused results from e6b826ed; corrective ledger entry only. | +| 2026-07-28 | PR #1316 / `claude/top-search-design-mockups-w53znc` | `8ccd7f481819ae4b41352acf9d867b2b850696be` | CI/review closeout: remaining band review gaps | FIXED. Prior Production UI failure on older tip was Suspense duplicate `global-search-input` (addressed earlier). Tip closes 7 unresolved review threads: favourites partial-status + refetch, differentials unauthorized copy, docs typography, forced-colors adoption gate, forms/loading control suppression confirmation. Merge-tree clean vs main; hosted CI rerunning on this head. | Focused vitest 38/38; tsc + eslint on touched files PASS; no Bugbot MCP available in this environment; no provider-backed checks. | From efea61e827392db1bc1124919f687ec897eff168 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 08:04:03 +0000 Subject: [PATCH 15/30] docs(ledger): record bugbot review of PR #1316 favourites status fold Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index a838a215df..09e498cc74 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1166,3 +1166,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-28 | PR #1304 / `fix-test-run-lock` | `463e5c0adc77fe722e20376666f5991db3e288d9` | CI babysit closeout | MERGE-READY. Hosted PR required SUCCESS on exact tip; mergeable=MERGEABLE; 0 behind main; merge-tree CLEAN. Unique product delta: knip.json removes unused tailwindcss ignoreDependencies. Prior GitHub DIRTY labels during babysit were main-churn only. | Hosted CI run success on 463e5c0a; no provider-backed checks. | | 2026-07-28 | PR #1304 / fix-test-run-lock | 7cc32c053c752bef19f3de408a1376428e54af74 | CI babysit: sync main | SUPERSEDED (documenting stale-CI ledger error). Prior row for this HEAD incorrectly treated hosted required-CI SUCCESS on earlier tip e6b826ed9150f312c2e7a957f715019e73a7f0be as verification of this later merge commit 7cc32c05. No hosted required-CI result exists for this exact SHA. This ref has since advanced; the later row at 463e5c0adc77fe722e20376666f5991db3e288d9 recorded exact-tip hosted CI SUCCESS, so this commit's status is historical/superseded. | No hosted CI run on this exact HEAD; prior row reused results from e6b826ed; corrective ledger entry only. | | 2026-07-28 | PR #1316 / `claude/top-search-design-mockups-w53znc` | `8ccd7f481819ae4b41352acf9d867b2b850696be` | CI/review closeout: remaining band review gaps | FIXED. Prior Production UI failure on older tip was Suspense duplicate `global-search-input` (addressed earlier). Tip closes 7 unresolved review threads: favourites partial-status + refetch, differentials unauthorized copy, docs typography, forced-colors adoption gate, forms/loading control suppression confirmation. Merge-tree clean vs main; hosted CI rerunning on this head. | Focused vitest 38/38; tsc + eslint on touched files PASS; no Bugbot MCP available in this environment; no provider-backed checks. | +| 2026-07-28 | claude/top-search-design-mockups-w53znc | af07e34b5e6ca958206926a05509c1e321dbe862 | bugbot SearchResultsHeaderBand favourites status | P1: favourites status override under-reports registry faults when any items exist; empty/filter guards use overridden status; registryStatus unused. P2: demo prototype merge still over-faults band. No code change. | static review of band/favourites/diff call sites; offline fold proof; PR thread context; no provider/CI | From 1c65a083e8307f108774b0c3a3fb96559c42ee0d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 08:05:14 +0000 Subject: [PATCH 16/30] fix(favourites): fold account-data readiness into band status An authenticated /api/account/favourites pending or failed response left the hook reporting ready with zero items, which looked like an empty library. Account readiness now folds with registry status, demo page items keep a truthful nonzero band count, and focused fold tests pin the contract. Co-authored-by: BigSimmo --- .../favourites-command-library-page.tsx | 6 +- .../saved-registry-favourites-status.ts | 31 +++++++++ .../use-saved-registry-favourites.ts | 34 +++++----- .../saved-registry-favourites-status.test.ts | 64 +++++++++++++++++++ 4 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 src/components/clinical-dashboard/saved-registry-favourites-status.ts create mode 100644 tests/saved-registry-favourites-status.test.ts diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 786f411359..5d8f902b18 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -1034,13 +1034,17 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: const [navCollapsed, setNavCollapsed] = useFavouritesNavCollapsed(); const { items: savedRegistryFavourites, - status: favouritesRegistryStatus, + status: favouritesHookStatus, refetch: refetchFavouritesRegistry, } = useSavedRegistryFavourites(); const items = useMemo( () => [...(demoMode ? prototypeFavouriteItems : []), ...savedRegistryFavourites].map(toCommandItem), [demoMode, savedRegistryFavourites], ); + // Demo prototypes live outside the hook. If they are the only items while a + // registry/account read failed, keep the band ready so the table's nonzero + // list is not contradicted by a whole-band fault. + const favouritesRegistryStatus = items.length > 0 ? "ready" : favouritesHookStatus; const sets = useMemo(() => buildFavouriteSets(items), [items]); const [selectedTypeId, setSelectedTypeId] = useState("all"); const [selectedSetId, setSelectedSetId] = useState(null); diff --git a/src/components/clinical-dashboard/saved-registry-favourites-status.ts b/src/components/clinical-dashboard/saved-registry-favourites-status.ts new file mode 100644 index 0000000000..4c8d48295a --- /dev/null +++ b/src/components/clinical-dashboard/saved-registry-favourites-status.ts @@ -0,0 +1,31 @@ +export type SavedFavouritesBandStatus = "ready" | "loading" | "unauthorized" | "error"; + +/** + * Fold account-favourites readiness with downstream registry status. + * An authenticated account request that has not settled (or failed) must not + * report `ready` with zero items — that reads as "no favourites" when the + * saved-items list was never loaded. + */ +export function foldSavedFavouritesStatus(input: { + isAuthenticated: boolean; + accountReady: boolean; + accountError: string | null; + registryStatus: SavedFavouritesBandStatus; + itemCount: number; +}): { status: SavedFavouritesBandStatus; registryStatus: SavedFavouritesBandStatus } { + const accountStatus: SavedFavouritesBandStatus = + input.isAuthenticated && !input.accountReady + ? "loading" + : input.isAuthenticated && input.accountError + ? "error" + : "ready"; + + const registryStatus = input.registryStatus; + const folded: SavedFavouritesBandStatus = + accountStatus === "loading" ? "loading" : accountStatus === "error" ? "error" : registryStatus; + + // Unaffected items (local differentials, or one registry that succeeded) keep + // an honest nonzero count visible instead of hiding behind a whole-band fault. + const status = input.itemCount > 0 && folded !== "ready" ? "ready" : folded; + return { status, registryStatus }; +} diff --git a/src/components/clinical-dashboard/use-saved-registry-favourites.ts b/src/components/clinical-dashboard/use-saved-registry-favourites.ts index 26d475e64f..418dff8568 100644 --- a/src/components/clinical-dashboard/use-saved-registry-favourites.ts +++ b/src/components/clinical-dashboard/use-saved-registry-favourites.ts @@ -6,6 +6,10 @@ import { useCallback, useMemo } from "react"; import { useAccountData } from "@/components/account-data-provider"; import type { FavouriteItem } from "@/components/clinical-dashboard/favourites-prototype-data"; +import { + foldSavedFavouritesStatus, + type SavedFavouritesBandStatus, +} from "@/components/clinical-dashboard/saved-registry-favourites-status"; import type { ServiceRecord } from "@/lib/services"; import { useRegistryRecords } from "@/lib/use-registry-records"; @@ -26,21 +30,18 @@ function recordToFavourite(record: ServiceRecord, type: "services" | "forms"): F export type SavedRegistryFavouritesResult = { items: FavouriteItem[]; - /** Folded state of the registries this hook actually requested, so the page can - report a failure instead of rendering an empty list as "no favourites". - When unaffected sources already produced items (e.g. local differentials - while a service registry failed), this stays `ready` so the band can keep - an honest nonzero count — see `registryStatus` for the raw fold. */ - status: "ready" | "loading" | "unauthorized" | "error"; - /** Raw folded registry status before the nonempty-items override. Useful when - a page needs to distinguish a partial registry fault from a clean ready. */ - registryStatus: "ready" | "loading" | "unauthorized" | "error"; + /** Combined account + registry status for the band/empty state. When unaffected + sources already produced items, this stays `ready` so a valid nonzero count + remains visible — see `registryStatus` for the raw registry fold. */ + status: SavedFavouritesBandStatus; + /** Raw folded registry status before account/nonempty overrides. */ + registryStatus: SavedFavouritesBandStatus; /** Re-runs every registry this hook actually requested. */ refetch: () => void; }; export function useSavedRegistryFavourites(): SavedRegistryFavouritesResult { - const { favourites } = useAccountData(); + const { favourites, ready: accountReady, error: accountError, isAuthenticated } = useAccountData(); const savedServices = favourites.service; const savedForms = favourites.form; const savedDifferentials = favourites.differential; @@ -80,17 +81,20 @@ export function useSavedRegistryFavourites(): SavedRegistryFavouritesResult { // hook sits in its initial state forever and must not be read as a failure. // Unauthorized outranks error because it is the one the reader can act on. const requested = [savedServices.length > 0 ? services.status : null, savedForms.length > 0 ? forms.status : null]; - const registryStatus = requested.includes("unauthorized") + const rawRegistryStatus: SavedFavouritesBandStatus = requested.includes("unauthorized") ? "unauthorized" : requested.includes("error") || requested.includes("not_found") ? "error" : requested.includes("loading") ? "loading" : "ready"; - // A nonempty list from unaffected sources (local differentials, or one registry - // that succeeded) must not be hidden behind a whole-band fault/loading state — - // that drops a valid nonzero count while the table below still renders items. - const status = items.length > 0 && registryStatus !== "ready" ? "ready" : registryStatus; + const { status, registryStatus } = foldSavedFavouritesStatus({ + isAuthenticated, + accountReady, + accountError, + registryStatus: rawRegistryStatus, + itemCount: items.length, + }); const refetchServices = services.refetch; const refetchForms = forms.refetch; diff --git a/tests/saved-registry-favourites-status.test.ts b/tests/saved-registry-favourites-status.test.ts new file mode 100644 index 0000000000..7ad684f5ba --- /dev/null +++ b/tests/saved-registry-favourites-status.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { foldSavedFavouritesStatus } from "@/components/clinical-dashboard/saved-registry-favourites-status"; + +describe("foldSavedFavouritesStatus", () => { + it("reports loading while an authenticated account favourites request is pending", () => { + expect( + foldSavedFavouritesStatus({ + isAuthenticated: true, + accountReady: false, + accountError: null, + registryStatus: "ready", + itemCount: 0, + }).status, + ).toBe("loading"); + }); + + it("reports error when the account favourites request failed with no items", () => { + expect( + foldSavedFavouritesStatus({ + isAuthenticated: true, + accountReady: true, + accountError: "Saved items could not be loaded.", + registryStatus: "ready", + itemCount: 0, + }).status, + ).toBe("error"); + }); + + it("does not claim ready zero while account data is unsettled", () => { + const folded = foldSavedFavouritesStatus({ + isAuthenticated: true, + accountReady: false, + accountError: null, + registryStatus: "ready", + itemCount: 0, + }); + expect(folded.status).not.toBe("ready"); + }); + + it("keeps ready when unaffected items already exist beside a registry fault", () => { + expect( + foldSavedFavouritesStatus({ + isAuthenticated: true, + accountReady: true, + accountError: null, + registryStatus: "error", + itemCount: 2, + }).status, + ).toBe("ready"); + }); + + it("surfaces registry unauthorized when the account layer is settled and empty", () => { + expect( + foldSavedFavouritesStatus({ + isAuthenticated: true, + accountReady: true, + accountError: null, + registryStatus: "unauthorized", + itemCount: 0, + }).status, + ).toBe("unauthorized"); + }); +}); From 8ae374acd57abc3926aaea367c7d76de654c32c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 08:05:31 +0000 Subject: [PATCH 17/30] docs(ledger): record PR #1316 review closeout complete Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 09e498cc74..f8bd698384 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1167,3 +1167,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-28 | PR #1304 / fix-test-run-lock | 7cc32c053c752bef19f3de408a1376428e54af74 | CI babysit: sync main | SUPERSEDED (documenting stale-CI ledger error). Prior row for this HEAD incorrectly treated hosted required-CI SUCCESS on earlier tip e6b826ed9150f312c2e7a957f715019e73a7f0be as verification of this later merge commit 7cc32c05. No hosted required-CI result exists for this exact SHA. This ref has since advanced; the later row at 463e5c0adc77fe722e20376666f5991db3e288d9 recorded exact-tip hosted CI SUCCESS, so this commit's status is historical/superseded. | No hosted CI run on this exact HEAD; prior row reused results from e6b826ed; corrective ledger entry only. | | 2026-07-28 | PR #1316 / `claude/top-search-design-mockups-w53znc` | `8ccd7f481819ae4b41352acf9d867b2b850696be` | CI/review closeout: remaining band review gaps | FIXED. Prior Production UI failure on older tip was Suspense duplicate `global-search-input` (addressed earlier). Tip closes 7 unresolved review threads: favourites partial-status + refetch, differentials unauthorized copy, docs typography, forced-colors adoption gate, forms/loading control suppression confirmation. Merge-tree clean vs main; hosted CI rerunning on this head. | Focused vitest 38/38; tsc + eslint on touched files PASS; no Bugbot MCP available in this environment; no provider-backed checks. | | 2026-07-28 | claude/top-search-design-mockups-w53znc | af07e34b5e6ca958206926a05509c1e321dbe862 | bugbot SearchResultsHeaderBand favourites status | P1: favourites status override under-reports registry faults when any items exist; empty/filter guards use overridden status; registryStatus unused. P2: demo prototype merge still over-faults band. No code change. | static review of band/favourites/diff call sites; offline fold proof; PR thread context; no provider/CI | +| 2026-07-28 | PR #1316 / `claude/top-search-design-mockups-w53znc` | `1c65a083e8307f108774b0c3a3fb96559c42ee0d` | CI/review closeout complete | READY FOR HOSTED CI. All review threads resolved. Account-favourites pending/failure folded into band status; remaining Codex/CodeRabbit gaps closed; Bugbot residual noted (binary band cannot express partial source failure without a dedicated `partial` status). Merge-tree was clean vs main at prior tip; CI rerunning on this head. | Focused vitest 43/43; tsc/eslint PASS on touched files; Bugbot via pr-bugbot subagent (no MCP Bugbot server); no provider-backed checks. | From a7fadf55288342289bc7782fe4b09fe28125f16a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 08:13:41 +0000 Subject: [PATCH 18/30] fix(documents): map an expired session to unauthorized, not error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Documents-mode fault message and the band status it pairs with were derived from two separate chains. An expired session produced the right message ("Sign in again to view private indexed documents") under the wrong headline — the band's generic "Couldn't search", which tells the reader the search broke when in fact it never ran for want of a session. That is the same class of untruthful failure state this branch exists to remove. Fold both into one derivation so they cannot drift. Precedence is kept deliberate and pinned by a test: an unreachable API outranks an expired session, because signing back in cannot fix an API that is down. Putting the auth arm first would have paired "Clinical KB could not be reached" with a sign-in status. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- .../document-search-results.tsx | 24 +++---- .../document-search-unavailable-status.ts | 41 +++++++++++ ...document-search-unavailable-status.test.ts | 70 +++++++++++++++++++ 3 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 src/components/clinical-dashboard/document-search-unavailable-status.ts create mode 100644 tests/document-search-unavailable-status.test.ts diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index 2b06417ba0..4f01a5c601 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -32,6 +32,7 @@ import { MobileResultFilterControl, SearchResultsHeaderBand, } from "@/components/clinical-dashboard/search-results-header-band"; +import { deriveDocumentSearchUnavailable } from "@/components/clinical-dashboard/document-search-unavailable-status"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { useResultSort } from "@/components/use-result-sort"; import { SafeBoldText } from "@/components/SafeBoldText"; @@ -861,15 +862,14 @@ function DocumentSearchResultsPanelImpl({ }); } - const unavailableMessage = apiUnavailable - ? isDeployedClinicalKb() - ? "Clinical KB could not be reached. Check your connection and try again shortly." - : "The local API is unavailable. Check the app server before searching documents." - : authUnavailable - ? "Your session expired. Sign in again to view private indexed documents." - : !realDataReady - ? setupWarning || "Complete the search setup before using Documents mode." - : null; + const unavailable = deriveDocumentSearchUnavailable({ + apiUnavailable, + authUnavailable, + realDataReady, + setupWarning, + deployedClinicalKb: isDeployedClinicalKb(), + }); + const unavailableMessage = unavailable?.message ?? null; const showResultsControls = matches.length > 0 && !loading; const showIdentityHeader = recordMatchCount > 0 || @@ -900,11 +900,7 @@ function DocumentSearchResultsPanelImpl({ : recordStatus === "loading" ? "loading" : "ready" - : unavailableMessage - ? "error" - : loading - ? "loading" - : "ready" + : (unavailable?.status ?? (loading ? "loading" : "ready")) } faultBody={showRecordMatches ? undefined : (unavailableMessage ?? undefined)} sortValue={sortValue} diff --git a/src/components/clinical-dashboard/document-search-unavailable-status.ts b/src/components/clinical-dashboard/document-search-unavailable-status.ts new file mode 100644 index 0000000000..58bf5c7753 --- /dev/null +++ b/src/components/clinical-dashboard/document-search-unavailable-status.ts @@ -0,0 +1,41 @@ +export type DocumentSearchUnavailableStatus = "error" | "unauthorized"; + +/** + * Derive the Documents-mode fault message and the band status it pairs with. + * + * These two must be produced from one chain. Deriving them separately let an + * expired session ("Sign in again…") render under the band's generic + * "Couldn't search" headline, which tells the reader the search broke when in + * fact it never ran for want of a session. Precedence is deliberate: an + * unreachable API outranks an expired session, because a signed-in retry + * cannot fix an API that is down. + */ +export function deriveDocumentSearchUnavailable(input: { + apiUnavailable: boolean; + authUnavailable: boolean; + realDataReady: boolean; + setupWarning: string | null; + deployedClinicalKb: boolean; +}): { message: string; status: DocumentSearchUnavailableStatus } | null { + if (input.apiUnavailable) { + return { + message: input.deployedClinicalKb + ? "Clinical KB could not be reached. Check your connection and try again shortly." + : "The local API is unavailable. Check the app server before searching documents.", + status: "error", + }; + } + if (input.authUnavailable) { + return { + message: "Your session expired. Sign in again to view private indexed documents.", + status: "unauthorized", + }; + } + if (!input.realDataReady) { + return { + message: input.setupWarning || "Complete the search setup before using Documents mode.", + status: "error", + }; + } + return null; +} diff --git a/tests/document-search-unavailable-status.test.ts b/tests/document-search-unavailable-status.test.ts new file mode 100644 index 0000000000..2ff8942804 --- /dev/null +++ b/tests/document-search-unavailable-status.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { deriveDocumentSearchUnavailable } from "@/components/clinical-dashboard/document-search-unavailable-status"; + +const healthy = { + apiUnavailable: false, + authUnavailable: false, + realDataReady: true, + setupWarning: null, + deployedClinicalKb: false, +}; + +describe("deriveDocumentSearchUnavailable", () => { + it("returns null when documents mode is healthy", () => { + expect(deriveDocumentSearchUnavailable(healthy)).toBeNull(); + }); + + it("maps an expired session to unauthorized, not error", () => { + const result = deriveDocumentSearchUnavailable({ ...healthy, authUnavailable: true }); + expect(result?.status).toBe("unauthorized"); + expect(result?.message).toMatch(/Sign in again/i); + }); + + it("keeps an unreachable API above an expired session", () => { + // Both true: signing back in cannot fix an API that is down, so the + // message and the status must both stay on the API fault. + const result = deriveDocumentSearchUnavailable({ + ...healthy, + apiUnavailable: true, + authUnavailable: true, + deployedClinicalKb: true, + }); + expect(result?.status).toBe("error"); + expect(result?.message).toMatch(/Clinical KB could not be reached/i); + }); + + it("distinguishes the deployed and local API messages", () => { + expect( + deriveDocumentSearchUnavailable({ ...healthy, apiUnavailable: true, deployedClinicalKb: false })?.message, + ).toMatch(/local API is unavailable/i); + }); + + it("treats incomplete setup as an error with the supplied warning", () => { + const result = deriveDocumentSearchUnavailable({ + ...healthy, + realDataReady: false, + setupWarning: "Add a Supabase URL first.", + }); + expect(result).toEqual({ message: "Add a Supabase URL first.", status: "error" }); + }); + + it("falls back to default setup copy when no warning is supplied", () => { + expect(deriveDocumentSearchUnavailable({ ...healthy, realDataReady: false })?.message).toMatch( + /Complete the search setup/i, + ); + }); + + it("pairs every fault with a status, so the two cannot drift", () => { + const cases = [ + { ...healthy, apiUnavailable: true }, + { ...healthy, authUnavailable: true }, + { ...healthy, realDataReady: false }, + ]; + for (const input of cases) { + const result = deriveDocumentSearchUnavailable(input); + expect(result?.message).toBeTruthy(); + expect(["error", "unauthorized"]).toContain(result?.status); + } + }); +}); From abc8eb57179177d7237339e5f0a38d58f911593a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 08:16:51 +0000 Subject: [PATCH 19/30] issues: capture #091-#095 from the results-band rollout Records the five follow-ups that surfaced during PR #1316 and would otherwise be lost with this session's context: the band's inability to express a partial-source failure (and the favourites mask that papers over it), the refetch pulse deferred on auth-backed registries by the identity-clearing invariant, the Next streaming-clone strict-mode flake, the design-system gates that assert structure rather than rendered effect, and `PR required` reporting concurrency cancellations as failures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- docs/outstanding-issues.md | 75 ++++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index bb995dcf81..5d1027328f 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -76,7 +76,7 @@ removed after current-main verification; it is not missing recommended work. | 28 | `#079` | Optional | High — repository hygiene | In explicitly scheduled batches | 30–60 minutes per batch | Disposition at most ten retained worktrees per pass using owner, PR, review-ledger, ancestry, and patch evidence. Preserve every dirty, active, secret-bearing, post-freeze, or ambiguous worktree and stop rather than broad-cleaning. | | 29 | `#086` | A3 | High — repository structure + Specialist | On explicit go-ahead for X3; later packages own their gates | 1 PR per work order | Ship remaining maturity backlog (X3 rag.ts; X7 src/lib reorg; X6 coverage floors; X5 ACL consolidation; L1 one-shot archive; L4 ledger rotation; M1 host hardening) as verified draft PRs from `docs/maturity-backlog-workorders.md`. Start with X3 after go-ahead; stop before RAG edits without the flag or X5 without live-DB approval. | - + ## Open items @@ -90,40 +90,45 @@ removed after current-main verification; it is not missing recommended work. > > **Exact-head release confirmation (2026-07-28):** after final review narrowed expanded chunk selection to the measured clozapine blood-count action shape, `output/rag-retrieval-post-exact-head.json` repeated all 36 cases with document/content recall 1.0, MRR 0.8921, content MRR 0.9406, nDCG 0.9308, irrelevant-at-10 0.0917, zero failures and zero per-case document/content reciprocal-rank regressions versus `rag-retrieval-post-final.json`. Median latency rose 13,563 -> 19,729 ms while p90 improved 56,765 -> 55,660 ms; neither run had a latency-failed case, so no ranking or latency gate changed. Cache-bypassed exact-head answer probes for both admission/discharge cases and the clozapine threshold case were substantive, grounded, expected-source-backed and free of citation/numeric/route failures; all used zero provider requests and $0 generation cost. This was the protected behavior-change merge gate, not a rerun for #023. -| ID | Pri | Type | Summary | Detail / next action | Source | Added | -| ---- | --- | ----- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | -| #059 | P1 | task | Verify containment of every credential reported exposed in chat | **Outcome:** every reported exposed credential is rejected or retired. **Next:** in approved security windows, verify and revoke or rotate the GitHub token, OpenAI key, Supabase service-role JWT, database password, and E2E credential; create replacements only when required and update only intended secret stores. **Success:** provider evidence confirms the old credentials cannot authenticate, replacements are distinct and minimally scoped, presence/readiness checks pass, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never print or paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation; AI Agent Target Manifest | 2026-07-24 | -| #065 | P2 | task | Complete the paused compact document source-text accordion | **Outcome:** the document viewer uses compact nested disclosures while retaining complete text, citation/search navigation, print behavior, and composer clearance. **Next:** only when the user explicitly resumes, reconcile `codex/chat-document-text-accordion-7cb4` with current `main` and complete the focused 320/390/1280 px tests. **Success:** default disclosures are closed; deep links and search open only the active passage; printing expands/restores state; no overflow. **Verify:** focused document-viewer Playwright, `verify:cheap`, `verify:ui`, and static production-readiness. **Stop:** remain paused until explicit user return; no provider calls. | paused document-viewer task; `codex/chat-document-text-accordion-7cb4` | 2026-07-24 | -| #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 | -| #053 | P1 | task | Execute cross-border privacy/legal package | Execute OpenAI and Railway DPAs; decide ZDR and Australian data residency; obtain prompt-cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not represent the release as privacy-approved or alter final public privacy wording before sign-off. | `docs/openai-cross-border-basis.md`; `docs/privacy-impact-assessment.md` | 2026-07-24 | -| #055 | P2 | task | Run one exact-SHA full release and PR gate | Before the next full-confidence release/handoff, record the candidate/PR SHA and run the local/provider release gates, Firefox/WebKit, required hosted CI, and actionable GitHub review-thread closure once. Stop at the first actionable failure and rerun only the repaired smallest gate. | `docs/launch-operator-runbook.md`; `docs/codex-review-protocol.md` | 2026-07-24 | -| #056 | P2 | task | Reconcile the existing staging migration history | `Clinical KB Staging` already exists as a healthy, empty Supabase/Railway tier with distinct secrets and no production clinical data, but it is 23 repository migrations behind. In the next approved staging schema window, apply the exact missing migration chain, then re-run indexing, health, identity and data-boundary proof. Do not recreate the environment or copy production clinical documents. | current-main staging verification; `docs/staging-setup.md`; `docs/operator-backlog.md` | 2026-07-27 | -| #057 | P2 | task | Complete staging soak and rollback rehearsal | After #056, run the documented soak and rollback against an exact candidate; retain latency/error/rollback evidence. Stop on unsafe data, identity mismatch, or an unowned rollback decision. | `docs/launch-operator-runbook.md`; `docs/capacity-review.md` | 2026-07-24 | -| #005 | P3 | rec | `finalScore` saturates at clamp ceiling | Base + ~40 stacked boosts routinely exceed 1.0, so strong matches tie at 1.0 and order by an arbitrary `document_id` tiebreak. If ranking is ever revisited, break ties by the **pre-clamp** score rather than raising the `[0,1]` ceiling (downstream gates assume `[0,1]`). Ordering already sorts by the unbounded pre-clamp `rankScore` (`clinical-search.ts:1735,1927,1950-1955`), so the clamp confines only the reported confidence value, not result order. Not a defect on the current golden set; any change here is a protected RAG surface (canary required). | `docs/rag-hybrid-findings-and-todo.md` P1 item 4; `src/lib/clinical-search.ts:1735` | 2026-07-21 | -| #011 | P3 | task | Auth DB-connection allocation is operator-only | Supabase Auth (GoTrue) is capped at ~10 absolute DB connections (Supabase perf advisor). Switch to **percentage-based** allocation in the Supabase **dashboard** before the first compute scale-up — **not settable via SQL/MCP** (operator-owned). Verify via a staging soak + an approval-gated read-only advisor re-check. | `docs/auth-connection-cap-runbook.md`; `docs/process-hardening.md` (Known follow-up debts) | 2026-07-21 | -| #013 | P3 | rec | Route-chunk + mockup catalogue JSON weight | `build:analyze`: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), `/formulation` ships `formulation-content.json` (~52 KB, client-side local search — needs index/full split or a search endpoint, architectural). All route-scoped (not initial bundle). Also `*-mockups.tsx` (~100 KB across chunks) build though `/mockups` 404s in prod — exclude from the prod artifact. | session 2026-07-21 (build:analyze) | 2026-07-21 | -| #016 | P3 | rec | "Big but not easy" structural + motion perf | Deferred larger levers: (a) nonce-CSP forces every product route to `╞Æ Dynamic` (zero static generation) — evaluate Partial Prerendering / static shells for the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation); (b) sidebar expand/collapse animates `grid-template-columns` (biggest smoothness cost, motion-gated — needs a transform-overlay rethink); (c) Therapy Compass fetches 692 KB / 2.5 MB JSON client-side (defer until interaction + confirm brotli); (d) settings/setup/admin dialogs static-imported into the home chunk (`next/dynamic` them). | session 2026-07-21 (build route table + design audit) | 2026-07-21 | -| #017 | P3 | task | Field Web-Vitals baseline via live Lighthouse | In-sandbox runtime vitals were blocked (prod server hard-requires Supabase secrets; dev-mode CLS measured excellent at 0.00–0.04, content-first pages 0.000). Run Lighthouse against `psychiatry.tools` for real LCP/INP/CLS to prioritize #012–#016 by measured impact rather than reasoning. | session 2026-07-21 (measurement pass) | 2026-07-21 | -| #018 | P2 | task | Split the lithium, ADHD and metabolic residuals by mechanism | Current evidence keeps the mechanisms separate. **Lithium — closed within this item:** the row/atom-aware subject guard, foreign-parameter rejection and query-specific range promotion returned `0.5–1.0 mmol/L` with correct targeting/citation; the full retrieval canary remained 36/36 with recall 1.0 and zero per-case RR regressions, and the full answer canary passed every blocking gate. **ADHD — open corpus debt:** `CG.MHSP.ADHD.pdf` is absent from the hosted corpus and the retrieved chart exposes `accessible_table_count=0`; repair corpus/fixture or ingestion evidence rather than weakening extractive budgets. **Metabolic — open structured-evidence debt:** the standalone plural classifier worsened the live answer and was reverted; obtain auditable schedule text/table evidence before another candidate. | targeted live lithium/ADHD/metabolic evidence 2026-07-27; `docs/evidence/rag-reliability-evidence-2026-07-27.md`; refuted approaches | 2026-07-21 | -| #021 | P3 | rec | E-3d H2 residual: strong/comparison generation discards | approx. 6 generation attempts per full 44-case run still fail the final quality gate and fall to extractive on strong-route comparison/complex shapes (the designed-conservative outcome). PARKED: weakest cost/benefit on the queue — a wave (approx. $2-4 pair + reviewer cycle) to shave seconds off a few hard cases. Revisit only if latency/waste complaints or a cheaper lever appears. | E-3c design record; runs #59-#61 diagnostics | 2026-07-21 | -| #022 | P2 | task | Source-governance metadata refresh (operator) | The selected policy is now encoded locally as auditable `third_party_reference_attested` evidence with policy version, reviewer qualification, evidence references and append-only review history. It deliberately preserves `clinical_validation_status=unverified`; malformed, stale or non-BMJ evidence remains review debt. Migration `20260727010000_bmj_third_party_source_attestation.sql` is prepared but was **not applied**. The ten most visible local-document candidates are captured in `docs/evidence/rag-top-local-review-manifest-2026-07-26.json` with `attestation_applied=false`; qualified human review, deliberate hosted apply/attestation, and warning-rate remeasurement remain operator work. | governance worklist; local policy/migration tests; top-ten evidence manifest | 2026-07-21 | -| #023 | P2 | task | Complete scheduled browser and labeling disposition | The 2026-07-26 retrieval and answer artifacts are read and compared under resolved #051. Scheduled CI run `30216361999` failed its existing production dependency audit before Firefox/WebKit, while production Chromium passed. After that audit is green, capture one scheduled/manual browser-matrix datapoint; separately record the human decision for the stable irrelevant-at-10 set. #084 now makes each top-10 grade and matched signal reproducible, but it does not substitute for the human disposition. Do not rerun or spend on RAG for this item. | runs `30216191889`/`30216361999`; per-rank diagnostics #084; session 2026-07-27 | 2026-07-21 | -| #024 | P2 | issue | WebKit e2e `_rsc` prefetch access-control errors | PR #1205 narrowed catch-all interception and duplicate navigation, but Next 16.2.11 still raises `_rsc` access-control `pageerror`s after document-source fallbacks: `/documents/source?id=&page=2&chunk=safety%20plan` → `/documents/?page=2&chunk=safety+plan`; `/documents/source/evidence?id=not-a-uuid&page=2` → `/documents/search`. The invalid-id failure survived removing every Playwright route; Chromium passed both. **Next:** on a provider-free macOS host, run both URLs in stable Safari and Safari Technology Preview without interception, capture console text plus `_rsc` status/access-control headers, and compare Playwright WebKit with routing on/off. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without native evidence. | PRs #1179/#1205; current-main local WebKit evidence; session 2026-07-28 | 2026-07-28 | -| #025 | P2 | task | Activate the three webhooks (operator secrets) | Merged (#968/#1100) + deployed but inert — verified live: `POST /api/webhooks/railway` returns `503 webhook_not_configured`; the Supabase document-change trigger exists but lacks both activation inputs. To turn on: (1) Railway ΓåÆ set `RAILWAY_WEBHOOK_SECRET` + add the `?token=…` webhook URL; (2) set `SLACK_WEBHOOK_URL`/`DISCORD_WEBHOOK_URL` in BOTH the Railway **app/server env** and **GitHub repo secrets**; (3) set one matching document-change secret in the Railway app env as `SUPABASE_INGESTION_WEBHOOK_SECRET` and in Supabase Vault as `ingestion_webhook_secret`, then set the per-environment database GUC `app.ingestion_webhook_base_url` to the deployed app origin. Each path fails closed until fully configured, so this is pure ops. See `docs/webhooks.md` for verification and rotation. | sessions 2026-07-22/24; PRs #968/#1100; docs/webhooks.md | 2026-07-22 | -| #027 | P3 | rec | External uptime monitor independent of GitHub/Railway | `live-domain-monitor.yml` runs on GitHub's cron, so it won't run in exactly the outage it should catch (Actions or the deploy itself down). Add an off-platform synthetic monitor (UptimeRobot / Better Stack / Checkly) hitting `/api/health` with a webhook alert. Provider setup, not code. | session 2026-07-22 webhook review | 2026-07-22 | -| #028 | P3 | rec | Runtime error tracking (Sentry or similar) | No error tracking in the repo — production exceptions on `psychiatry.tools`, including how often `RAG_PROVIDER_MODE=auto` silently degrades to source-only, are invisible. Weigh adding `@sentry/nextjs` (dependency + DSN secret + instrumentation) vs cost; alert ΓåÆ chat/issue. Provider-backed; needs explicit sign-off before adding the dependency. | session 2026-07-22 webhook review | 2026-07-22 | -| #033 | P3 | rec | Source governance metadata absent from the LLM prompt | `buildRagSourceBlock` omits `document_status`, `clinical_validation_status`, and `extraction_quality`, so the model cannot self-caveat during generation and governance is enforced only post-hoc. Generation-surface change: needs `eval:rag` plus `eval:quality --rag-only` (grounded-supported must not drop, citation-failure 0) and explicit approval. Carries the same "unknown Γëá bad" hazard as #032 — on a partially-enriched corpus the model would likely over-caveat correct sources, so design the prompt wording before spending an eval. | `src/lib/rag/rag-source-block.ts:126-198`; PR #1051 audit item 8 | 2026-07-22 | -| #035 | P3 | rec | Threshold-conflict detection covers only 3 params | `detectThresholdDisagreements` checks only ANC, WBC, and platelets paired with withholding verbs, so cross-source conflicts on medication doses, lithium/thyroid levels, or vital signs go undetected. Deliberately narrow (see the comment at `:469-474`). Broadening changes when an answer is classified `conflicting` and adds warnings — real false-positive risk. Needs new fixtures plus a behaviour review before any change. | `src/lib/evidence.ts:469-574`; PR #1051 audit item 7 | 2026-07-22 | -| #036 | P3 | rec | No explicit `is_public` visibility flag on documents | Public-corpus visibility is implicit: `owner_id IS NULL` on an `indexed` document (`resolveSearchScope`). The `metadata.public_corpus` marker is written by the promotion migrations but never used as a retrieval filter. Promotion is unconditional on `clinical_validation_status`, so unverified documents are publicly searchable — compensated by keeping `unverified_source` in the frontend-visible warning set. A hard schema flag touches RLS and the clinical-risk-gated retrieval RPCs; weigh against the existing compensating control before acting. | `supabase/schema.sql:61-108`; `src/lib/search-scope.ts:181-236`; PR #1051 audit item 3 | 2026-07-22 | -| #037 | P3 | rec | D5 trust-cap-all-claims flag parked OFF | `NEXT_PUBLIC_RAG_TRUST_CAP_ALL_CLAIMS` extends authority gating from high-risk claims to **all** supported claims (`deriveTrust`). Ships OFF by design; flipping it caps trust to `medium` for routine claims across the board — a product/clinical-UX decision, not a defect. Both states are test-pinned. Next action: product decision, then flip and re-baseline the UI expectations. | `src/lib/answer-render-policy.ts:159-177`; PR #1051 audit item 11 | 2026-07-22 | -| #038 | P3 | rec | Consolidate shared comparison behavior | Several clinical modes expose comparison workflows with similar selection, empty-state and mobile-dock needs. Define one shared behavioral contract before another comparison surface is added; keep mode-specific clinical content separate. This is a design-system recommendation, not a current defect. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #039 | P3 | rec | Consolidate catalogue toolbar patterns | Catalogue/search pages have independently evolved filter, sort, result-count and mobile toolbar behavior. Inventory the existing implementations and converge only the repeated interaction contract; do not flatten mode-specific search semantics. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #040 | P3 | rec | Add targeted visual-regression baselines | Keep a small approved baseline set for high-value desktop/mobile surfaces and accessibility modes instead of screenshotting every route. Start with account/settings, document viewer, mode homes and bottom-composer interactions; define an intentional-update workflow before enabling blocking comparisons. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #079 | P3 | task | Disposition retained worktrees in bounded cleanup batches | **Outcome:** the retained reconciliation tail is gradually classified without another disruptive all-worktree sweep. **Next:** process no more than ten worktrees per explicitly scheduled pass using current owner/process metadata, open-PR state, exact review-ledger coverage, ancestry, and cherry-pick-aware content proof. **Success:** remove only clean, inactive, bundled worktrees whose content is merged or explicitly rejected; record every disposition and retain recovery evidence. **Stop:** preserve dirty, active, secret-bearing, post-freeze, paused, or ambiguous work and never use reset, force deletion, broad clean, or process killing. | final reconciliation inventory retained 104 independent worktrees; session 2026-07-24 | 2026-07-24 | -| #085 | P3 | rec | Upload-limit client/server sync is unguarded | `NEXT_PUBLIC_MAX_UPLOAD_MB` (client, build-time inlined) and `MAX_UPLOAD_MB` (server, runtime) default 150/150 but nothing keeps them in sync — no zod link, gate, or test. Lower server-only → the 413-after-full-transfer UX FV-04 (#1064/#1069) was built to prevent; lower client-only → false pre-check rejection of files the server would accept (breaks #1064's client-is-a-strict-superset invariant). Both documented in `.env.example`/`docs/deployment-architecture.md` but unenforced; the client value is also frozen at `next build`, so changing the Railway service var without an image rebuild silently no-ops. Cheapest guard: a `check:*` script (or CI assertion) that fails when the two configured values disagree. | session 2026-07-28 (FV-04 adversarial workflow, PR #1069); ID #085 after #084 claimed on main | 2026-07-27 | -| #086 | P3 | task | Repository maturity backlog — remaining structural work | **Outcome:** the deferred repository-maturity backlog ships as verified draft PRs, one per structural change. **Canonical runbook:** [`docs/maturity-backlog-workorders.md`](maturity-backlog-workorders.md). **Remaining:** X3 `rag.ts` decomposition; X7 finish the `src/lib` domain reorg; X6 clinical/retrieval/answer coverage floors; X5 ACL-migration consolidation (provider-gated); L1 archive the retired `backfill:*` one-shots + the dead `ci-change-scope` token; L4 ledger rotation; M1 repo-host hardening (maintainer, audit §8). **Next:** X3 on user go-ahead. **Stop:** RAG/retrieval items need the flag + go-ahead; X5 is live-DB provider-gated. | `docs/maturity-backlog-workorders.md`; audit §8/§10; session 2026-07-28 | 2026-07-28 | -| #088 | P3 | task | Watch for union-driver duplication as open PRs merge the repaired ledger | **Outcome:** the 2026-07-28 ledger repair does not resurface as duplicated rows. **Next:** on the first few open PRs that merge `origin/main` after the repair, run `npm run check:branch-review-ledger` on the merged head. Ordinary 3-way merges take main's repaired lines cleanly; only a same-hunk conflict would let `merge=union` keep both the corrupted and repaired copies. **Success:** three consecutive post-repair merges pass the guard. **Stop:** if duplication appears, remove exact duplicates only (the ledger contract allows that) and never rewrite surrounding records. | branch-review-ledger hygiene pass rewrote 226 historical lines; session 2026-07-28 | 2026-07-28 | -| #090 | P2 | task | Upgrade the eslint ecosystem to clear remaining dev-scoped high advisories | **Outcome:** full `npm audit` reports zero high advisories from the eslint toolchain. **Next:** in a dedicated dependency pass, upgrade eslint and its plugin/config set together (npm offers `eslint@10.8.0`, `isSemVerMajor`); residual highs (`@eslint/config-array`, `@eslint/eslintrc`, `eslint`, `eslint-config-next`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, plus the advisory's numeric `<=5.0.7` hit on the unused `brace-expansion@1.1.16` / `2.1.2` maintenance lines that still ship an unpatched `main`) cascade from this toolchain. **Success:** `npm run lint` clean, `verify:cheap` green, full-audit highs cleared, no rule-config regressions. **Stop:** an eslint major previously broke `eslint-plugin-react` here — do not bundle into an unrelated PR, and do not force with `npm audit fix --force`. Production `npm audit --omit=dev` is already clean after the exceljs `archiver@8` / `unzipper@0.12.5` overrides on PR #1314; this item is eslint-dev cleanup only. | session 2026-07-28 brace-expansion triage (PR #1314) | 2026-07-28 | +| ID | Pri | Type | Summary | Detail / next action | Source | Added | +| ---- | --- | ----- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | +| #059 | P1 | task | Verify containment of every credential reported exposed in chat | **Outcome:** every reported exposed credential is rejected or retired. **Next:** in approved security windows, verify and revoke or rotate the GitHub token, OpenAI key, Supabase service-role JWT, database password, and E2E credential; create replacements only when required and update only intended secret stores. **Success:** provider evidence confirms the old credentials cannot authenticate, replacements are distinct and minimally scoped, presence/readiness checks pass, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never print or paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation; AI Agent Target Manifest | 2026-07-24 | +| #065 | P2 | task | Complete the paused compact document source-text accordion | **Outcome:** the document viewer uses compact nested disclosures while retaining complete text, citation/search navigation, print behavior, and composer clearance. **Next:** only when the user explicitly resumes, reconcile `codex/chat-document-text-accordion-7cb4` with current `main` and complete the focused 320/390/1280 px tests. **Success:** default disclosures are closed; deep links and search open only the active passage; printing expands/restores state; no overflow. **Verify:** focused document-viewer Playwright, `verify:cheap`, `verify:ui`, and static production-readiness. **Stop:** remain paused until explicit user return; no provider calls. | paused document-viewer task; `codex/chat-document-text-accordion-7cb4` | 2026-07-24 | +| #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 | +| #053 | P1 | task | Execute cross-border privacy/legal package | Execute OpenAI and Railway DPAs; decide ZDR and Australian data residency; obtain prompt-cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not represent the release as privacy-approved or alter final public privacy wording before sign-off. | `docs/openai-cross-border-basis.md`; `docs/privacy-impact-assessment.md` | 2026-07-24 | +| #055 | P2 | task | Run one exact-SHA full release and PR gate | Before the next full-confidence release/handoff, record the candidate/PR SHA and run the local/provider release gates, Firefox/WebKit, required hosted CI, and actionable GitHub review-thread closure once. Stop at the first actionable failure and rerun only the repaired smallest gate. | `docs/launch-operator-runbook.md`; `docs/codex-review-protocol.md` | 2026-07-24 | +| #056 | P2 | task | Reconcile the existing staging migration history | `Clinical KB Staging` already exists as a healthy, empty Supabase/Railway tier with distinct secrets and no production clinical data, but it is 23 repository migrations behind. In the next approved staging schema window, apply the exact missing migration chain, then re-run indexing, health, identity and data-boundary proof. Do not recreate the environment or copy production clinical documents. | current-main staging verification; `docs/staging-setup.md`; `docs/operator-backlog.md` | 2026-07-27 | +| #057 | P2 | task | Complete staging soak and rollback rehearsal | After #056, run the documented soak and rollback against an exact candidate; retain latency/error/rollback evidence. Stop on unsafe data, identity mismatch, or an unowned rollback decision. | `docs/launch-operator-runbook.md`; `docs/capacity-review.md` | 2026-07-24 | +| #005 | P3 | rec | `finalScore` saturates at clamp ceiling | Base + ~40 stacked boosts routinely exceed 1.0, so strong matches tie at 1.0 and order by an arbitrary `document_id` tiebreak. If ranking is ever revisited, break ties by the **pre-clamp** score rather than raising the `[0,1]` ceiling (downstream gates assume `[0,1]`). Ordering already sorts by the unbounded pre-clamp `rankScore` (`clinical-search.ts:1735,1927,1950-1955`), so the clamp confines only the reported confidence value, not result order. Not a defect on the current golden set; any change here is a protected RAG surface (canary required). | `docs/rag-hybrid-findings-and-todo.md` P1 item 4; `src/lib/clinical-search.ts:1735` | 2026-07-21 | +| #011 | P3 | task | Auth DB-connection allocation is operator-only | Supabase Auth (GoTrue) is capped at ~10 absolute DB connections (Supabase perf advisor). Switch to **percentage-based** allocation in the Supabase **dashboard** before the first compute scale-up — **not settable via SQL/MCP** (operator-owned). Verify via a staging soak + an approval-gated read-only advisor re-check. | `docs/auth-connection-cap-runbook.md`; `docs/process-hardening.md` (Known follow-up debts) | 2026-07-21 | +| #013 | P3 | rec | Route-chunk + mockup catalogue JSON weight | `build:analyze`: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), `/formulation` ships `formulation-content.json` (~52 KB, client-side local search — needs index/full split or a search endpoint, architectural). All route-scoped (not initial bundle). Also `*-mockups.tsx` (~100 KB across chunks) build though `/mockups` 404s in prod — exclude from the prod artifact. | session 2026-07-21 (build:analyze) | 2026-07-21 | +| #016 | P3 | rec | "Big but not easy" structural + motion perf | Deferred larger levers: (a) nonce-CSP forces every product route to `╞Æ Dynamic` (zero static generation) — evaluate Partial Prerendering / static shells for the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation); (b) sidebar expand/collapse animates `grid-template-columns` (biggest smoothness cost, motion-gated — needs a transform-overlay rethink); (c) Therapy Compass fetches 692 KB / 2.5 MB JSON client-side (defer until interaction + confirm brotli); (d) settings/setup/admin dialogs static-imported into the home chunk (`next/dynamic` them). | session 2026-07-21 (build route table + design audit) | 2026-07-21 | +| #017 | P3 | task | Field Web-Vitals baseline via live Lighthouse | In-sandbox runtime vitals were blocked (prod server hard-requires Supabase secrets; dev-mode CLS measured excellent at 0.00–0.04, content-first pages 0.000). Run Lighthouse against `psychiatry.tools` for real LCP/INP/CLS to prioritize #012–#016 by measured impact rather than reasoning. | session 2026-07-21 (measurement pass) | 2026-07-21 | +| #018 | P2 | task | Split the lithium, ADHD and metabolic residuals by mechanism | Current evidence keeps the mechanisms separate. **Lithium — closed within this item:** the row/atom-aware subject guard, foreign-parameter rejection and query-specific range promotion returned `0.5–1.0 mmol/L` with correct targeting/citation; the full retrieval canary remained 36/36 with recall 1.0 and zero per-case RR regressions, and the full answer canary passed every blocking gate. **ADHD — open corpus debt:** `CG.MHSP.ADHD.pdf` is absent from the hosted corpus and the retrieved chart exposes `accessible_table_count=0`; repair corpus/fixture or ingestion evidence rather than weakening extractive budgets. **Metabolic — open structured-evidence debt:** the standalone plural classifier worsened the live answer and was reverted; obtain auditable schedule text/table evidence before another candidate. | targeted live lithium/ADHD/metabolic evidence 2026-07-27; `docs/evidence/rag-reliability-evidence-2026-07-27.md`; refuted approaches | 2026-07-21 | +| #021 | P3 | rec | E-3d H2 residual: strong/comparison generation discards | approx. 6 generation attempts per full 44-case run still fail the final quality gate and fall to extractive on strong-route comparison/complex shapes (the designed-conservative outcome). PARKED: weakest cost/benefit on the queue — a wave (approx. $2-4 pair + reviewer cycle) to shave seconds off a few hard cases. Revisit only if latency/waste complaints or a cheaper lever appears. | E-3c design record; runs #59-#61 diagnostics | 2026-07-21 | +| #022 | P2 | task | Source-governance metadata refresh (operator) | The selected policy is now encoded locally as auditable `third_party_reference_attested` evidence with policy version, reviewer qualification, evidence references and append-only review history. It deliberately preserves `clinical_validation_status=unverified`; malformed, stale or non-BMJ evidence remains review debt. Migration `20260727010000_bmj_third_party_source_attestation.sql` is prepared but was **not applied**. The ten most visible local-document candidates are captured in `docs/evidence/rag-top-local-review-manifest-2026-07-26.json` with `attestation_applied=false`; qualified human review, deliberate hosted apply/attestation, and warning-rate remeasurement remain operator work. | governance worklist; local policy/migration tests; top-ten evidence manifest | 2026-07-21 | +| #023 | P2 | task | Complete scheduled browser and labeling disposition | The 2026-07-26 retrieval and answer artifacts are read and compared under resolved #051. Scheduled CI run `30216361999` failed its existing production dependency audit before Firefox/WebKit, while production Chromium passed. After that audit is green, capture one scheduled/manual browser-matrix datapoint; separately record the human decision for the stable irrelevant-at-10 set. #084 now makes each top-10 grade and matched signal reproducible, but it does not substitute for the human disposition. Do not rerun or spend on RAG for this item. | runs `30216191889`/`30216361999`; per-rank diagnostics #084; session 2026-07-27 | 2026-07-21 | +| #024 | P2 | issue | WebKit e2e `_rsc` prefetch access-control errors | PR #1205 narrowed catch-all interception and duplicate navigation, but Next 16.2.11 still raises `_rsc` access-control `pageerror`s after document-source fallbacks: `/documents/source?id=&page=2&chunk=safety%20plan` → `/documents/?page=2&chunk=safety+plan`; `/documents/source/evidence?id=not-a-uuid&page=2` → `/documents/search`. The invalid-id failure survived removing every Playwright route; Chromium passed both. **Next:** on a provider-free macOS host, run both URLs in stable Safari and Safari Technology Preview without interception, capture console text plus `_rsc` status/access-control headers, and compare Playwright WebKit with routing on/off. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without native evidence. | PRs #1179/#1205; current-main local WebKit evidence; session 2026-07-28 | 2026-07-28 | +| #025 | P2 | task | Activate the three webhooks (operator secrets) | Merged (#968/#1100) + deployed but inert — verified live: `POST /api/webhooks/railway` returns `503 webhook_not_configured`; the Supabase document-change trigger exists but lacks both activation inputs. To turn on: (1) Railway ΓåÆ set `RAILWAY_WEBHOOK_SECRET` + add the `?token=…` webhook URL; (2) set `SLACK_WEBHOOK_URL`/`DISCORD_WEBHOOK_URL` in BOTH the Railway **app/server env** and **GitHub repo secrets**; (3) set one matching document-change secret in the Railway app env as `SUPABASE_INGESTION_WEBHOOK_SECRET` and in Supabase Vault as `ingestion_webhook_secret`, then set the per-environment database GUC `app.ingestion_webhook_base_url` to the deployed app origin. Each path fails closed until fully configured, so this is pure ops. See `docs/webhooks.md` for verification and rotation. | sessions 2026-07-22/24; PRs #968/#1100; docs/webhooks.md | 2026-07-22 | +| #027 | P3 | rec | External uptime monitor independent of GitHub/Railway | `live-domain-monitor.yml` runs on GitHub's cron, so it won't run in exactly the outage it should catch (Actions or the deploy itself down). Add an off-platform synthetic monitor (UptimeRobot / Better Stack / Checkly) hitting `/api/health` with a webhook alert. Provider setup, not code. | session 2026-07-22 webhook review | 2026-07-22 | +| #028 | P3 | rec | Runtime error tracking (Sentry or similar) | No error tracking in the repo — production exceptions on `psychiatry.tools`, including how often `RAG_PROVIDER_MODE=auto` silently degrades to source-only, are invisible. Weigh adding `@sentry/nextjs` (dependency + DSN secret + instrumentation) vs cost; alert ΓåÆ chat/issue. Provider-backed; needs explicit sign-off before adding the dependency. | session 2026-07-22 webhook review | 2026-07-22 | +| #033 | P3 | rec | Source governance metadata absent from the LLM prompt | `buildRagSourceBlock` omits `document_status`, `clinical_validation_status`, and `extraction_quality`, so the model cannot self-caveat during generation and governance is enforced only post-hoc. Generation-surface change: needs `eval:rag` plus `eval:quality --rag-only` (grounded-supported must not drop, citation-failure 0) and explicit approval. Carries the same "unknown Γëá bad" hazard as #032 — on a partially-enriched corpus the model would likely over-caveat correct sources, so design the prompt wording before spending an eval. | `src/lib/rag/rag-source-block.ts:126-198`; PR #1051 audit item 8 | 2026-07-22 | +| #035 | P3 | rec | Threshold-conflict detection covers only 3 params | `detectThresholdDisagreements` checks only ANC, WBC, and platelets paired with withholding verbs, so cross-source conflicts on medication doses, lithium/thyroid levels, or vital signs go undetected. Deliberately narrow (see the comment at `:469-474`). Broadening changes when an answer is classified `conflicting` and adds warnings — real false-positive risk. Needs new fixtures plus a behaviour review before any change. | `src/lib/evidence.ts:469-574`; PR #1051 audit item 7 | 2026-07-22 | +| #036 | P3 | rec | No explicit `is_public` visibility flag on documents | Public-corpus visibility is implicit: `owner_id IS NULL` on an `indexed` document (`resolveSearchScope`). The `metadata.public_corpus` marker is written by the promotion migrations but never used as a retrieval filter. Promotion is unconditional on `clinical_validation_status`, so unverified documents are publicly searchable — compensated by keeping `unverified_source` in the frontend-visible warning set. A hard schema flag touches RLS and the clinical-risk-gated retrieval RPCs; weigh against the existing compensating control before acting. | `supabase/schema.sql:61-108`; `src/lib/search-scope.ts:181-236`; PR #1051 audit item 3 | 2026-07-22 | +| #037 | P3 | rec | D5 trust-cap-all-claims flag parked OFF | `NEXT_PUBLIC_RAG_TRUST_CAP_ALL_CLAIMS` extends authority gating from high-risk claims to **all** supported claims (`deriveTrust`). Ships OFF by design; flipping it caps trust to `medium` for routine claims across the board — a product/clinical-UX decision, not a defect. Both states are test-pinned. Next action: product decision, then flip and re-baseline the UI expectations. | `src/lib/answer-render-policy.ts:159-177`; PR #1051 audit item 11 | 2026-07-22 | +| #038 | P3 | rec | Consolidate shared comparison behavior | Several clinical modes expose comparison workflows with similar selection, empty-state and mobile-dock needs. Define one shared behavioral contract before another comparison surface is added; keep mode-specific clinical content separate. This is a design-system recommendation, not a current defect. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #039 | P3 | rec | Consolidate catalogue toolbar patterns | Catalogue/search pages have independently evolved filter, sort, result-count and mobile toolbar behavior. Inventory the existing implementations and converge only the repeated interaction contract; do not flatten mode-specific search semantics. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #040 | P3 | rec | Add targeted visual-regression baselines | Keep a small approved baseline set for high-value desktop/mobile surfaces and accessibility modes instead of screenshotting every route. Start with account/settings, document viewer, mode homes and bottom-composer interactions; define an intentional-update workflow before enabling blocking comparisons. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #079 | P3 | task | Disposition retained worktrees in bounded cleanup batches | **Outcome:** the retained reconciliation tail is gradually classified without another disruptive all-worktree sweep. **Next:** process no more than ten worktrees per explicitly scheduled pass using current owner/process metadata, open-PR state, exact review-ledger coverage, ancestry, and cherry-pick-aware content proof. **Success:** remove only clean, inactive, bundled worktrees whose content is merged or explicitly rejected; record every disposition and retain recovery evidence. **Stop:** preserve dirty, active, secret-bearing, post-freeze, paused, or ambiguous work and never use reset, force deletion, broad clean, or process killing. | final reconciliation inventory retained 104 independent worktrees; session 2026-07-24 | 2026-07-24 | +| #085 | P3 | rec | Upload-limit client/server sync is unguarded | `NEXT_PUBLIC_MAX_UPLOAD_MB` (client, build-time inlined) and `MAX_UPLOAD_MB` (server, runtime) default 150/150 but nothing keeps them in sync — no zod link, gate, or test. Lower server-only → the 413-after-full-transfer UX FV-04 (#1064/#1069) was built to prevent; lower client-only → false pre-check rejection of files the server would accept (breaks #1064's client-is-a-strict-superset invariant). Both documented in `.env.example`/`docs/deployment-architecture.md` but unenforced; the client value is also frozen at `next build`, so changing the Railway service var without an image rebuild silently no-ops. Cheapest guard: a `check:*` script (or CI assertion) that fails when the two configured values disagree. | session 2026-07-28 (FV-04 adversarial workflow, PR #1069); ID #085 after #084 claimed on main | 2026-07-27 | +| #086 | P3 | task | Repository maturity backlog — remaining structural work | **Outcome:** the deferred repository-maturity backlog ships as verified draft PRs, one per structural change. **Canonical runbook:** [`docs/maturity-backlog-workorders.md`](maturity-backlog-workorders.md). **Remaining:** X3 `rag.ts` decomposition; X7 finish the `src/lib` domain reorg; X6 clinical/retrieval/answer coverage floors; X5 ACL-migration consolidation (provider-gated); L1 archive the retired `backfill:*` one-shots + the dead `ci-change-scope` token; L4 ledger rotation; M1 repo-host hardening (maintainer, audit §8). **Next:** X3 on user go-ahead. **Stop:** RAG/retrieval items need the flag + go-ahead; X5 is live-DB provider-gated. | `docs/maturity-backlog-workorders.md`; audit §8/§10; session 2026-07-28 | 2026-07-28 | +| #088 | P3 | task | Watch for union-driver duplication as open PRs merge the repaired ledger | **Outcome:** the 2026-07-28 ledger repair does not resurface as duplicated rows. **Next:** on the first few open PRs that merge `origin/main` after the repair, run `npm run check:branch-review-ledger` on the merged head. Ordinary 3-way merges take main's repaired lines cleanly; only a same-hunk conflict would let `merge=union` keep both the corrupted and repaired copies. **Success:** three consecutive post-repair merges pass the guard. **Stop:** if duplication appears, remove exact duplicates only (the ledger contract allows that) and never rewrite surrounding records. | branch-review-ledger hygiene pass rewrote 226 historical lines; session 2026-07-28 | 2026-07-28 | +| #090 | P2 | task | Upgrade the eslint ecosystem to clear remaining dev-scoped high advisories | **Outcome:** full `npm audit` reports zero high advisories from the eslint toolchain. **Next:** in a dedicated dependency pass, upgrade eslint and its plugin/config set together (npm offers `eslint@10.8.0`, `isSemVerMajor`); residual highs (`@eslint/config-array`, `@eslint/eslintrc`, `eslint`, `eslint-config-next`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, plus the advisory's numeric `<=5.0.7` hit on the unused `brace-expansion@1.1.16` / `2.1.2` maintenance lines that still ship an unpatched `main`) cascade from this toolchain. **Success:** `npm run lint` clean, `verify:cheap` green, full-audit highs cleared, no rule-config regressions. **Stop:** an eslint major previously broke `eslint-plugin-react` here — do not bundle into an unrelated PR, and do not force with `npm audit fix --force`. Production `npm audit --omit=dev` is already clean after the exceljs `archiver@8` / `unzipper@0.12.5` overrides on PR #1314; this item is eslint-dev cleanup only. | session 2026-07-28 brace-expansion triage (PR #1314) | 2026-07-28 | +| #091 | P2 | issue | Results band cannot express a partial-source failure | **Outcome:** a favourites/results count is never asserted while some of its sources failed to load. **Detail:** `SearchResultsBandStatus` is a single flat status, so a page fed by several registries has no way to say "3 loaded, 1 failed". `saved-registry-favourites-status.ts:29` works around this with `itemCount > 0 && folded !== "ready" ? "ready" : folded`, and `favourites-command-library-page.tsx` applies the same mask a second time — so a partial failure renders a confident count with no fault indication, which is the exact defect class PR #1316 removed everywhere else. Neither favourites page consumes the true `registryStatus` the fold already returns. **Next:** decide between (a) a `partial` arm on the status union carrying a "some sources unavailable" note beside an honest count, or (b) surfacing `registryStatus` as a separate inline notice and dropping the mask. **Stop:** do not widen the mask to more surfaces before the shape is decided. | PR #1316 review thread PRRT_kwDOSh5Fis6UUf-k; session 2026-07-28 | 2026-07-28 | +| #092 | P3 | task | Refetch pulse deferred on auth-backed registries (privacy invariant) | **Outcome:** a background refresh keeps the prior count visible instead of a skeleton, without weakening identity clearing. **Detail:** the `refetching` status is built in the band and adopted only on `formulation-home-page.tsx`, where the lag is `useDeferredValue` over static data. It is deliberately NOT adopted on `use-registry-records.ts:85`, `use-medication-catalog.ts:76` or `use-differential-catalog.ts:133`, which all clear data on entering loading. `use-differential-catalog.ts:122` states why: "Auth must clear prior identity's matches immediately", and `:164` that "a later retype of any prior query cannot resurrect authorized matches." **Next:** if adopted, guard preservation on identity AND query equality, and pin with a test that an identity change still clears immediately. **Stop:** never hold records across an auth transition. | PR #1316 plan phase 6; session 2026-07-28 | 2026-07-28 | +| #093 | P2 | issue | Next streaming `S:` clone causes Playwright strict-mode violations under CI load | **Outcome:** duplicate-element strict-mode failures stop appearing on loaded CI runs. **Detail:** under full-suite CI load Next.js leaves a hidden duplicate page root in the stream, so a `getByTestId` that is unique locally resolves to 2 elements in CI (seen as `differentials-search-results` on PR #1316, and previously noted on PR #1294 against main). It does not reproduce in isolation, on a single spec, or locally. The documented workaround is to scope the locator to the visible root. **Next:** confirm whether the clone is a Next 16.2.11 streaming artifact or an app-level double mount, then either scope the shared helpers once or fix the mount. **Stop:** do not paper over new occurrences with `.first()` before the duplicate itself is explained. | PR #1316 CI runs; PR #1294 note on main; session 2026-07-28 | 2026-07-28 | +| #094 | P2 | rec | Design-system gates assert structure, not rendered effect | **Outcome:** a style contract cannot pass while the style is inert. **Detail:** PR #1316's accent rail shipped inert because `.search-band` sat in `@layer components`, which loses to Tailwind's utilities layer regardless of specificity — and the test asserted `toHaveClass("search-band")`, i.e. class presence, not effect. Computed style showed `1px rgb(229,231,235)` where `2px rgb(11,111,134)` was intended. The same shape of gap let a rail-colour assertion compare a colour against a width and pass unconditionally. **Next:** for contracts where the visual IS the requirement (rails, forced-colors thickness, tap targets), assert `getComputedStyle` in a Playwright case rather than class names in a DOM test, and add the unlayered-component convention to the design-system contract check. **Stop:** do not convert existing passing DOM tests wholesale; add computed-style proof only where the effect carries the meaning. | PR #1316 Codex P2 finding; session 2026-07-28 | 2026-07-28 | +| #095 | P3 | issue | `PR required` reports failure for concurrency-cancelled jobs | **Outcome:** a red `PR required` means a real failure. **Detail:** the aggregate calls `require_success` on `coverage`/`production-ui`, so any push that supersedes an in-flight run reports `coverage result was cancelled` → exit 1, indistinguishable at a glance from a genuine failure. Eleven such reds were produced on PR #1316 in one session, and `Production UI` never once ran to completion. **Next:** in `.github/workflows/ci.yml`, either treat `cancelled` distinctly from `failure` in the aggregate, or reduce push frequency against long UI runs. **Stop:** do not relax `require_success` for genuine failures while doing so. | PR #1316 runs 30340972329 / 30341225585; session 2026-07-28 | 2026-07-28 | ## Resolved / archive From 9f055e428466707bbd89c100cc97c8b8353a292d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 08:33:33 +0000 Subject: [PATCH 20/30] fix(favourites): make Retry able to recover an account-load failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Codex P2s, both confirmed against the code before fixing. Retry on the favourites surfaces was a button that did nothing. When `/api/account/favourites` fails the provider clears every saved slug, so both registries end up disabled and `refetch()` invoked neither of them — the page stayed faulted no matter how many times it was pressed. That breaks the repo's own wiring rule: a control that advertises an action must perform one. `AccountDataContextValue` exposed no reload at all, so add one. It bumps an attempt counter and re-runs the existing load effect unchanged, which means an explicit Retry gets exactly the same clearing and abort semantics as an auth transition — the identity-clearing invariant is untouched. The favourites hook reissues the account request first, since when that is what failed the registries hold nothing to re-request. Second: the fault panel clipped its own recovery actions on a phone. Differentials passes Retry plus two "Browse …" links into a non-wrapping row inside an `overflow-hidden` root, so at 390px the trailing link was cut off rather than moving to a second line. Both tests were checked against the unfixed code rather than assumed: the account test reports 1 fetch instead of 2, and the wrap test finds all three actions sharing one line. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- src/components/account-data-provider.tsx | 13 ++- .../search-results-header-band.tsx | 6 +- .../use-saved-registry-favourites.ts | 14 ++- tests/favourites-account-retry.dom.test.tsx | 86 +++++++++++++++++++ tests/ui-accessibility.spec.ts | 37 ++++++++ 5 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 tests/favourites-account-retry.dom.test.tsx diff --git a/src/components/account-data-provider.tsx b/src/components/account-data-provider.tsx index d9db1ef440..07b870a422 100644 --- a/src/components/account-data-provider.tsx +++ b/src/components/account-data-provider.tsx @@ -38,6 +38,10 @@ type AccountDataContextValue = { error: string | null; isAuthenticated: boolean; isSaved: (contentType: FavouriteContentType, contentKey: string) => boolean; + /** Re-issue the account favourites request for the current identity. A failed + load clears every saved slug, so without this a Retry offered by a + favourites surface has nothing left to re-request and cannot recover. */ + reload: () => void; setFavourite: (contentType: FavouriteContentType, contentKey: string, saved: boolean) => Promise; clearFavourites: () => Promise; }; @@ -67,6 +71,10 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { const [favourites, setFavourites] = useState(emptyFavourites); const [ready, setReady] = useState(auth.status !== "authenticated"); const [error, setError] = useState(null); + // Bumping this re-runs the load effect unchanged, so an explicit Retry gets + // exactly the same clearing and abort semantics as an auth transition. + const [reloadAttempt, setReloadAttempt] = useState(0); + const reload = useCallback(() => setReloadAttempt((attempt) => attempt + 1), []); useEffect(() => { if (auth.status !== "authenticated") { @@ -110,7 +118,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { }); return () => controller.abort(); - }, [auth.authEpoch, auth.authorizationHeader, auth.status]); + }, [auth.authEpoch, auth.authorizationHeader, auth.status, reloadAttempt]); const setFavourite = useCallback( async (contentType: FavouriteContentType, contentKey: string, saved: boolean) => { @@ -186,8 +194,9 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { isSaved: (contentType, contentKey) => favourites[contentType].includes(contentKey), setFavourite, clearFavourites, + reload, }), - [auth.status, clearFavourites, error, favourites, ready, setFavourite], + [auth.status, clearFavourites, error, favourites, ready, reload, setFavourite], ); return {children}; diff --git a/src/components/clinical-dashboard/search-results-header-band.tsx b/src/components/clinical-dashboard/search-results-header-band.tsx index 02ea4ef8b6..fe79c0ee36 100644 --- a/src/components/clinical-dashboard/search-results-header-band.tsx +++ b/src/components/clinical-dashboard/search-results-header-band.tsx @@ -412,8 +412,12 @@ export function SearchResultsHeaderBand({ >

{resolvedFaultTitle}

{resolvedFaultBody}

+ {/* Wrapping is load-bearing: differentials passes Retry plus two + "Browse …" links, and the band root is `overflow-hidden`, so on a + narrow phone a non-wrapping row clips the trailing action away + rather than pushing it to a second line. */} {onRetry || faultAction ? ( -
+
{onRetry ? ( { + // The account request must be reissued first. When it is what failed it has + // already cleared every saved slug, so both registries are disabled and + // hold nothing to re-request — Retry would be a button that does nothing. + if (isAuthenticated) reloadAccount(); if (savedServices.length > 0) refetchServices(); if (savedForms.length > 0) refetchForms(); - }, [savedServices.length, savedForms.length, refetchServices, refetchForms]); + }, [isAuthenticated, reloadAccount, savedServices.length, savedForms.length, refetchServices, refetchForms]); return { items, status, registryStatus, refetch }; } diff --git a/tests/favourites-account-retry.dom.test.tsx b/tests/favourites-account-retry.dom.test.tsx new file mode 100644 index 0000000000..5c97d83374 --- /dev/null +++ b/tests/favourites-account-retry.dom.test.tsx @@ -0,0 +1,86 @@ +/** @vitest-environment jsdom */ + +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AccountDataProvider } from "@/components/account-data-provider"; +import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites"; + +const authSession = vi.hoisted(() => ({ + status: "authenticated" as string, + authEpoch: 1, + authorizationHeader: { Authorization: "Bearer test-token" } as Record, + session: { user: { email: "clinician@clinic.example" } }, + isConfigured: true, + error: null as string | null, + signInWithEmail: vi.fn(), + signOut: vi.fn(), +})); + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => authSession, +})); + +// The registries are not under test here: an account failure clears every saved +// slug, so both are disabled and the account request is the only recoverable +// part. Stubbing them keeps the assertion on the account path alone. +vi.mock("@/lib/use-registry-records", () => ({ + useRegistryRecords: () => ({ records: [], status: "ready", refetch: () => undefined }), +})); + +function Probe() { + const { items, status, refetch } = useSavedRegistryFavourites(); + return ( +
+ {status} + {items.length} + +
+ ); +} + +describe("favourites account retry", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("recovers after the account request fails once and succeeds on Retry", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: "Saved items could not be loaded." }), + }) + .mockResolvedValue({ + ok: true, + json: async () => ({ favourites: [{ contentType: "differential", contentKey: "delirium" }] }), + }); + vi.stubGlobal("fetch", fetchMock); + + render( + + + , + ); + + // The failed load faults the band and leaves nothing saved to re-request. + await waitFor(() => expect(screen.getByTestId("status")).toHaveTextContent("error")); + expect(screen.getByTestId("count")).toHaveTextContent("0"); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + + // Retry must reissue the account request itself — without that the button + // invokes two disabled registry refetches and the page stays faulted. + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.getByTestId("status")).toHaveTextContent("ready")); + expect(screen.getByTestId("count")).toHaveTextContent("1"); + }); +}); diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index 68b0e1605f..148d99b147 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -635,4 +635,41 @@ test.describe("Clinical KB accessibility coverage", () => { .toBe("3px"); await page.emulateMedia({ forcedColors: "none" }); }); + + test("fault recovery actions wrap instead of clipping on a phone", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + // Differentials is the widest fault: Retry plus two "Browse …" links. Their + // combined width exceeds a 390px panel, and the band root is + // `overflow-hidden`, so without wrapping the trailing link is cut off. + await page.route("**/api/differentials**", (route) => route.fulfill({ status: 500, json: { error: "down" } })); + await page.goto("/differentials?q=acute+confusion&run=1", { waitUntil: "domcontentloaded" }); + + const fault = page.locator('[data-testid="search-query-ribbon-fault"]:visible').first(); + await expect(fault).toBeVisible({ timeout: 20_000 }); + + const actions = fault.locator("a, button"); + await expect(actions).toHaveCount(3); + + const panelBox = await fault.boundingBox(); + expect(panelBox).not.toBeNull(); + const boxes = await actions.evaluateAll((nodes) => + nodes.map((node) => { + const rect = node.getBoundingClientRect(); + return { left: rect.left, right: rect.right, top: rect.top }; + }), + ); + + for (const box of boxes) { + expect(box.left, "a recovery action must not start left of the fault panel").toBeGreaterThanOrEqual( + panelBox!.x - 1, + ); + expect(box.right, "a recovery action must not be clipped by the overflow-hidden band").toBeLessThanOrEqual( + panelBox!.x + panelBox!.width + 1, + ); + } + + // Proof the row actually wrapped rather than merely fitting: three actions + // this wide cannot share one line at 390px. + expect(new Set(boxes.map((box) => Math.round(box.top))).size).toBeGreaterThan(1); + }); }); From 14e16a75c18fff90861a700e1391c8b6afe61d30 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 08:38:20 +0000 Subject: [PATCH 21/30] issues: record the local repro for #093 Both duplicate-root failures reproduce in a full local verify:ui against an isolated production build and pass in isolation, which makes the condition load/order-dependent rather than build-mode dependent. Also corrects an earlier note claiming CI runs next dev. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- docs/outstanding-issues.md | 78 +++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 5d1027328f..feed65ad19 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -90,45 +90,45 @@ removed after current-main verification; it is not missing recommended work. > > **Exact-head release confirmation (2026-07-28):** after final review narrowed expanded chunk selection to the measured clozapine blood-count action shape, `output/rag-retrieval-post-exact-head.json` repeated all 36 cases with document/content recall 1.0, MRR 0.8921, content MRR 0.9406, nDCG 0.9308, irrelevant-at-10 0.0917, zero failures and zero per-case document/content reciprocal-rank regressions versus `rag-retrieval-post-final.json`. Median latency rose 13,563 -> 19,729 ms while p90 improved 56,765 -> 55,660 ms; neither run had a latency-failed case, so no ranking or latency gate changed. Cache-bypassed exact-head answer probes for both admission/discharge cases and the clozapine threshold case were substantive, grounded, expected-source-backed and free of citation/numeric/route failures; all used zero provider requests and $0 generation cost. This was the protected behavior-change merge gate, not a rerun for #023. -| ID | Pri | Type | Summary | Detail / next action | Source | Added | -| ---- | --- | ----- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | -| #059 | P1 | task | Verify containment of every credential reported exposed in chat | **Outcome:** every reported exposed credential is rejected or retired. **Next:** in approved security windows, verify and revoke or rotate the GitHub token, OpenAI key, Supabase service-role JWT, database password, and E2E credential; create replacements only when required and update only intended secret stores. **Success:** provider evidence confirms the old credentials cannot authenticate, replacements are distinct and minimally scoped, presence/readiness checks pass, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never print or paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation; AI Agent Target Manifest | 2026-07-24 | -| #065 | P2 | task | Complete the paused compact document source-text accordion | **Outcome:** the document viewer uses compact nested disclosures while retaining complete text, citation/search navigation, print behavior, and composer clearance. **Next:** only when the user explicitly resumes, reconcile `codex/chat-document-text-accordion-7cb4` with current `main` and complete the focused 320/390/1280 px tests. **Success:** default disclosures are closed; deep links and search open only the active passage; printing expands/restores state; no overflow. **Verify:** focused document-viewer Playwright, `verify:cheap`, `verify:ui`, and static production-readiness. **Stop:** remain paused until explicit user return; no provider calls. | paused document-viewer task; `codex/chat-document-text-accordion-7cb4` | 2026-07-24 | -| #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 | -| #053 | P1 | task | Execute cross-border privacy/legal package | Execute OpenAI and Railway DPAs; decide ZDR and Australian data residency; obtain prompt-cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not represent the release as privacy-approved or alter final public privacy wording before sign-off. | `docs/openai-cross-border-basis.md`; `docs/privacy-impact-assessment.md` | 2026-07-24 | -| #055 | P2 | task | Run one exact-SHA full release and PR gate | Before the next full-confidence release/handoff, record the candidate/PR SHA and run the local/provider release gates, Firefox/WebKit, required hosted CI, and actionable GitHub review-thread closure once. Stop at the first actionable failure and rerun only the repaired smallest gate. | `docs/launch-operator-runbook.md`; `docs/codex-review-protocol.md` | 2026-07-24 | -| #056 | P2 | task | Reconcile the existing staging migration history | `Clinical KB Staging` already exists as a healthy, empty Supabase/Railway tier with distinct secrets and no production clinical data, but it is 23 repository migrations behind. In the next approved staging schema window, apply the exact missing migration chain, then re-run indexing, health, identity and data-boundary proof. Do not recreate the environment or copy production clinical documents. | current-main staging verification; `docs/staging-setup.md`; `docs/operator-backlog.md` | 2026-07-27 | -| #057 | P2 | task | Complete staging soak and rollback rehearsal | After #056, run the documented soak and rollback against an exact candidate; retain latency/error/rollback evidence. Stop on unsafe data, identity mismatch, or an unowned rollback decision. | `docs/launch-operator-runbook.md`; `docs/capacity-review.md` | 2026-07-24 | -| #005 | P3 | rec | `finalScore` saturates at clamp ceiling | Base + ~40 stacked boosts routinely exceed 1.0, so strong matches tie at 1.0 and order by an arbitrary `document_id` tiebreak. If ranking is ever revisited, break ties by the **pre-clamp** score rather than raising the `[0,1]` ceiling (downstream gates assume `[0,1]`). Ordering already sorts by the unbounded pre-clamp `rankScore` (`clinical-search.ts:1735,1927,1950-1955`), so the clamp confines only the reported confidence value, not result order. Not a defect on the current golden set; any change here is a protected RAG surface (canary required). | `docs/rag-hybrid-findings-and-todo.md` P1 item 4; `src/lib/clinical-search.ts:1735` | 2026-07-21 | -| #011 | P3 | task | Auth DB-connection allocation is operator-only | Supabase Auth (GoTrue) is capped at ~10 absolute DB connections (Supabase perf advisor). Switch to **percentage-based** allocation in the Supabase **dashboard** before the first compute scale-up — **not settable via SQL/MCP** (operator-owned). Verify via a staging soak + an approval-gated read-only advisor re-check. | `docs/auth-connection-cap-runbook.md`; `docs/process-hardening.md` (Known follow-up debts) | 2026-07-21 | -| #013 | P3 | rec | Route-chunk + mockup catalogue JSON weight | `build:analyze`: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), `/formulation` ships `formulation-content.json` (~52 KB, client-side local search — needs index/full split or a search endpoint, architectural). All route-scoped (not initial bundle). Also `*-mockups.tsx` (~100 KB across chunks) build though `/mockups` 404s in prod — exclude from the prod artifact. | session 2026-07-21 (build:analyze) | 2026-07-21 | -| #016 | P3 | rec | "Big but not easy" structural + motion perf | Deferred larger levers: (a) nonce-CSP forces every product route to `╞Æ Dynamic` (zero static generation) — evaluate Partial Prerendering / static shells for the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation); (b) sidebar expand/collapse animates `grid-template-columns` (biggest smoothness cost, motion-gated — needs a transform-overlay rethink); (c) Therapy Compass fetches 692 KB / 2.5 MB JSON client-side (defer until interaction + confirm brotli); (d) settings/setup/admin dialogs static-imported into the home chunk (`next/dynamic` them). | session 2026-07-21 (build route table + design audit) | 2026-07-21 | -| #017 | P3 | task | Field Web-Vitals baseline via live Lighthouse | In-sandbox runtime vitals were blocked (prod server hard-requires Supabase secrets; dev-mode CLS measured excellent at 0.00–0.04, content-first pages 0.000). Run Lighthouse against `psychiatry.tools` for real LCP/INP/CLS to prioritize #012–#016 by measured impact rather than reasoning. | session 2026-07-21 (measurement pass) | 2026-07-21 | -| #018 | P2 | task | Split the lithium, ADHD and metabolic residuals by mechanism | Current evidence keeps the mechanisms separate. **Lithium — closed within this item:** the row/atom-aware subject guard, foreign-parameter rejection and query-specific range promotion returned `0.5–1.0 mmol/L` with correct targeting/citation; the full retrieval canary remained 36/36 with recall 1.0 and zero per-case RR regressions, and the full answer canary passed every blocking gate. **ADHD — open corpus debt:** `CG.MHSP.ADHD.pdf` is absent from the hosted corpus and the retrieved chart exposes `accessible_table_count=0`; repair corpus/fixture or ingestion evidence rather than weakening extractive budgets. **Metabolic — open structured-evidence debt:** the standalone plural classifier worsened the live answer and was reverted; obtain auditable schedule text/table evidence before another candidate. | targeted live lithium/ADHD/metabolic evidence 2026-07-27; `docs/evidence/rag-reliability-evidence-2026-07-27.md`; refuted approaches | 2026-07-21 | -| #021 | P3 | rec | E-3d H2 residual: strong/comparison generation discards | approx. 6 generation attempts per full 44-case run still fail the final quality gate and fall to extractive on strong-route comparison/complex shapes (the designed-conservative outcome). PARKED: weakest cost/benefit on the queue — a wave (approx. $2-4 pair + reviewer cycle) to shave seconds off a few hard cases. Revisit only if latency/waste complaints or a cheaper lever appears. | E-3c design record; runs #59-#61 diagnostics | 2026-07-21 | -| #022 | P2 | task | Source-governance metadata refresh (operator) | The selected policy is now encoded locally as auditable `third_party_reference_attested` evidence with policy version, reviewer qualification, evidence references and append-only review history. It deliberately preserves `clinical_validation_status=unverified`; malformed, stale or non-BMJ evidence remains review debt. Migration `20260727010000_bmj_third_party_source_attestation.sql` is prepared but was **not applied**. The ten most visible local-document candidates are captured in `docs/evidence/rag-top-local-review-manifest-2026-07-26.json` with `attestation_applied=false`; qualified human review, deliberate hosted apply/attestation, and warning-rate remeasurement remain operator work. | governance worklist; local policy/migration tests; top-ten evidence manifest | 2026-07-21 | -| #023 | P2 | task | Complete scheduled browser and labeling disposition | The 2026-07-26 retrieval and answer artifacts are read and compared under resolved #051. Scheduled CI run `30216361999` failed its existing production dependency audit before Firefox/WebKit, while production Chromium passed. After that audit is green, capture one scheduled/manual browser-matrix datapoint; separately record the human decision for the stable irrelevant-at-10 set. #084 now makes each top-10 grade and matched signal reproducible, but it does not substitute for the human disposition. Do not rerun or spend on RAG for this item. | runs `30216191889`/`30216361999`; per-rank diagnostics #084; session 2026-07-27 | 2026-07-21 | -| #024 | P2 | issue | WebKit e2e `_rsc` prefetch access-control errors | PR #1205 narrowed catch-all interception and duplicate navigation, but Next 16.2.11 still raises `_rsc` access-control `pageerror`s after document-source fallbacks: `/documents/source?id=&page=2&chunk=safety%20plan` → `/documents/?page=2&chunk=safety+plan`; `/documents/source/evidence?id=not-a-uuid&page=2` → `/documents/search`. The invalid-id failure survived removing every Playwright route; Chromium passed both. **Next:** on a provider-free macOS host, run both URLs in stable Safari and Safari Technology Preview without interception, capture console text plus `_rsc` status/access-control headers, and compare Playwright WebKit with routing on/off. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without native evidence. | PRs #1179/#1205; current-main local WebKit evidence; session 2026-07-28 | 2026-07-28 | -| #025 | P2 | task | Activate the three webhooks (operator secrets) | Merged (#968/#1100) + deployed but inert — verified live: `POST /api/webhooks/railway` returns `503 webhook_not_configured`; the Supabase document-change trigger exists but lacks both activation inputs. To turn on: (1) Railway ΓåÆ set `RAILWAY_WEBHOOK_SECRET` + add the `?token=…` webhook URL; (2) set `SLACK_WEBHOOK_URL`/`DISCORD_WEBHOOK_URL` in BOTH the Railway **app/server env** and **GitHub repo secrets**; (3) set one matching document-change secret in the Railway app env as `SUPABASE_INGESTION_WEBHOOK_SECRET` and in Supabase Vault as `ingestion_webhook_secret`, then set the per-environment database GUC `app.ingestion_webhook_base_url` to the deployed app origin. Each path fails closed until fully configured, so this is pure ops. See `docs/webhooks.md` for verification and rotation. | sessions 2026-07-22/24; PRs #968/#1100; docs/webhooks.md | 2026-07-22 | -| #027 | P3 | rec | External uptime monitor independent of GitHub/Railway | `live-domain-monitor.yml` runs on GitHub's cron, so it won't run in exactly the outage it should catch (Actions or the deploy itself down). Add an off-platform synthetic monitor (UptimeRobot / Better Stack / Checkly) hitting `/api/health` with a webhook alert. Provider setup, not code. | session 2026-07-22 webhook review | 2026-07-22 | -| #028 | P3 | rec | Runtime error tracking (Sentry or similar) | No error tracking in the repo — production exceptions on `psychiatry.tools`, including how often `RAG_PROVIDER_MODE=auto` silently degrades to source-only, are invisible. Weigh adding `@sentry/nextjs` (dependency + DSN secret + instrumentation) vs cost; alert ΓåÆ chat/issue. Provider-backed; needs explicit sign-off before adding the dependency. | session 2026-07-22 webhook review | 2026-07-22 | -| #033 | P3 | rec | Source governance metadata absent from the LLM prompt | `buildRagSourceBlock` omits `document_status`, `clinical_validation_status`, and `extraction_quality`, so the model cannot self-caveat during generation and governance is enforced only post-hoc. Generation-surface change: needs `eval:rag` plus `eval:quality --rag-only` (grounded-supported must not drop, citation-failure 0) and explicit approval. Carries the same "unknown Γëá bad" hazard as #032 — on a partially-enriched corpus the model would likely over-caveat correct sources, so design the prompt wording before spending an eval. | `src/lib/rag/rag-source-block.ts:126-198`; PR #1051 audit item 8 | 2026-07-22 | -| #035 | P3 | rec | Threshold-conflict detection covers only 3 params | `detectThresholdDisagreements` checks only ANC, WBC, and platelets paired with withholding verbs, so cross-source conflicts on medication doses, lithium/thyroid levels, or vital signs go undetected. Deliberately narrow (see the comment at `:469-474`). Broadening changes when an answer is classified `conflicting` and adds warnings — real false-positive risk. Needs new fixtures plus a behaviour review before any change. | `src/lib/evidence.ts:469-574`; PR #1051 audit item 7 | 2026-07-22 | -| #036 | P3 | rec | No explicit `is_public` visibility flag on documents | Public-corpus visibility is implicit: `owner_id IS NULL` on an `indexed` document (`resolveSearchScope`). The `metadata.public_corpus` marker is written by the promotion migrations but never used as a retrieval filter. Promotion is unconditional on `clinical_validation_status`, so unverified documents are publicly searchable — compensated by keeping `unverified_source` in the frontend-visible warning set. A hard schema flag touches RLS and the clinical-risk-gated retrieval RPCs; weigh against the existing compensating control before acting. | `supabase/schema.sql:61-108`; `src/lib/search-scope.ts:181-236`; PR #1051 audit item 3 | 2026-07-22 | -| #037 | P3 | rec | D5 trust-cap-all-claims flag parked OFF | `NEXT_PUBLIC_RAG_TRUST_CAP_ALL_CLAIMS` extends authority gating from high-risk claims to **all** supported claims (`deriveTrust`). Ships OFF by design; flipping it caps trust to `medium` for routine claims across the board — a product/clinical-UX decision, not a defect. Both states are test-pinned. Next action: product decision, then flip and re-baseline the UI expectations. | `src/lib/answer-render-policy.ts:159-177`; PR #1051 audit item 11 | 2026-07-22 | -| #038 | P3 | rec | Consolidate shared comparison behavior | Several clinical modes expose comparison workflows with similar selection, empty-state and mobile-dock needs. Define one shared behavioral contract before another comparison surface is added; keep mode-specific clinical content separate. This is a design-system recommendation, not a current defect. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #039 | P3 | rec | Consolidate catalogue toolbar patterns | Catalogue/search pages have independently evolved filter, sort, result-count and mobile toolbar behavior. Inventory the existing implementations and converge only the repeated interaction contract; do not flatten mode-specific search semantics. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #040 | P3 | rec | Add targeted visual-regression baselines | Keep a small approved baseline set for high-value desktop/mobile surfaces and accessibility modes instead of screenshotting every route. Start with account/settings, document viewer, mode homes and bottom-composer interactions; define an intentional-update workflow before enabling blocking comparisons. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #079 | P3 | task | Disposition retained worktrees in bounded cleanup batches | **Outcome:** the retained reconciliation tail is gradually classified without another disruptive all-worktree sweep. **Next:** process no more than ten worktrees per explicitly scheduled pass using current owner/process metadata, open-PR state, exact review-ledger coverage, ancestry, and cherry-pick-aware content proof. **Success:** remove only clean, inactive, bundled worktrees whose content is merged or explicitly rejected; record every disposition and retain recovery evidence. **Stop:** preserve dirty, active, secret-bearing, post-freeze, paused, or ambiguous work and never use reset, force deletion, broad clean, or process killing. | final reconciliation inventory retained 104 independent worktrees; session 2026-07-24 | 2026-07-24 | -| #085 | P3 | rec | Upload-limit client/server sync is unguarded | `NEXT_PUBLIC_MAX_UPLOAD_MB` (client, build-time inlined) and `MAX_UPLOAD_MB` (server, runtime) default 150/150 but nothing keeps them in sync — no zod link, gate, or test. Lower server-only → the 413-after-full-transfer UX FV-04 (#1064/#1069) was built to prevent; lower client-only → false pre-check rejection of files the server would accept (breaks #1064's client-is-a-strict-superset invariant). Both documented in `.env.example`/`docs/deployment-architecture.md` but unenforced; the client value is also frozen at `next build`, so changing the Railway service var without an image rebuild silently no-ops. Cheapest guard: a `check:*` script (or CI assertion) that fails when the two configured values disagree. | session 2026-07-28 (FV-04 adversarial workflow, PR #1069); ID #085 after #084 claimed on main | 2026-07-27 | -| #086 | P3 | task | Repository maturity backlog — remaining structural work | **Outcome:** the deferred repository-maturity backlog ships as verified draft PRs, one per structural change. **Canonical runbook:** [`docs/maturity-backlog-workorders.md`](maturity-backlog-workorders.md). **Remaining:** X3 `rag.ts` decomposition; X7 finish the `src/lib` domain reorg; X6 clinical/retrieval/answer coverage floors; X5 ACL-migration consolidation (provider-gated); L1 archive the retired `backfill:*` one-shots + the dead `ci-change-scope` token; L4 ledger rotation; M1 repo-host hardening (maintainer, audit §8). **Next:** X3 on user go-ahead. **Stop:** RAG/retrieval items need the flag + go-ahead; X5 is live-DB provider-gated. | `docs/maturity-backlog-workorders.md`; audit §8/§10; session 2026-07-28 | 2026-07-28 | -| #088 | P3 | task | Watch for union-driver duplication as open PRs merge the repaired ledger | **Outcome:** the 2026-07-28 ledger repair does not resurface as duplicated rows. **Next:** on the first few open PRs that merge `origin/main` after the repair, run `npm run check:branch-review-ledger` on the merged head. Ordinary 3-way merges take main's repaired lines cleanly; only a same-hunk conflict would let `merge=union` keep both the corrupted and repaired copies. **Success:** three consecutive post-repair merges pass the guard. **Stop:** if duplication appears, remove exact duplicates only (the ledger contract allows that) and never rewrite surrounding records. | branch-review-ledger hygiene pass rewrote 226 historical lines; session 2026-07-28 | 2026-07-28 | -| #090 | P2 | task | Upgrade the eslint ecosystem to clear remaining dev-scoped high advisories | **Outcome:** full `npm audit` reports zero high advisories from the eslint toolchain. **Next:** in a dedicated dependency pass, upgrade eslint and its plugin/config set together (npm offers `eslint@10.8.0`, `isSemVerMajor`); residual highs (`@eslint/config-array`, `@eslint/eslintrc`, `eslint`, `eslint-config-next`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, plus the advisory's numeric `<=5.0.7` hit on the unused `brace-expansion@1.1.16` / `2.1.2` maintenance lines that still ship an unpatched `main`) cascade from this toolchain. **Success:** `npm run lint` clean, `verify:cheap` green, full-audit highs cleared, no rule-config regressions. **Stop:** an eslint major previously broke `eslint-plugin-react` here — do not bundle into an unrelated PR, and do not force with `npm audit fix --force`. Production `npm audit --omit=dev` is already clean after the exceljs `archiver@8` / `unzipper@0.12.5` overrides on PR #1314; this item is eslint-dev cleanup only. | session 2026-07-28 brace-expansion triage (PR #1314) | 2026-07-28 | -| #091 | P2 | issue | Results band cannot express a partial-source failure | **Outcome:** a favourites/results count is never asserted while some of its sources failed to load. **Detail:** `SearchResultsBandStatus` is a single flat status, so a page fed by several registries has no way to say "3 loaded, 1 failed". `saved-registry-favourites-status.ts:29` works around this with `itemCount > 0 && folded !== "ready" ? "ready" : folded`, and `favourites-command-library-page.tsx` applies the same mask a second time — so a partial failure renders a confident count with no fault indication, which is the exact defect class PR #1316 removed everywhere else. Neither favourites page consumes the true `registryStatus` the fold already returns. **Next:** decide between (a) a `partial` arm on the status union carrying a "some sources unavailable" note beside an honest count, or (b) surfacing `registryStatus` as a separate inline notice and dropping the mask. **Stop:** do not widen the mask to more surfaces before the shape is decided. | PR #1316 review thread PRRT_kwDOSh5Fis6UUf-k; session 2026-07-28 | 2026-07-28 | -| #092 | P3 | task | Refetch pulse deferred on auth-backed registries (privacy invariant) | **Outcome:** a background refresh keeps the prior count visible instead of a skeleton, without weakening identity clearing. **Detail:** the `refetching` status is built in the band and adopted only on `formulation-home-page.tsx`, where the lag is `useDeferredValue` over static data. It is deliberately NOT adopted on `use-registry-records.ts:85`, `use-medication-catalog.ts:76` or `use-differential-catalog.ts:133`, which all clear data on entering loading. `use-differential-catalog.ts:122` states why: "Auth must clear prior identity's matches immediately", and `:164` that "a later retype of any prior query cannot resurrect authorized matches." **Next:** if adopted, guard preservation on identity AND query equality, and pin with a test that an identity change still clears immediately. **Stop:** never hold records across an auth transition. | PR #1316 plan phase 6; session 2026-07-28 | 2026-07-28 | -| #093 | P2 | issue | Next streaming `S:` clone causes Playwright strict-mode violations under CI load | **Outcome:** duplicate-element strict-mode failures stop appearing on loaded CI runs. **Detail:** under full-suite CI load Next.js leaves a hidden duplicate page root in the stream, so a `getByTestId` that is unique locally resolves to 2 elements in CI (seen as `differentials-search-results` on PR #1316, and previously noted on PR #1294 against main). It does not reproduce in isolation, on a single spec, or locally. The documented workaround is to scope the locator to the visible root. **Next:** confirm whether the clone is a Next 16.2.11 streaming artifact or an app-level double mount, then either scope the shared helpers once or fix the mount. **Stop:** do not paper over new occurrences with `.first()` before the duplicate itself is explained. | PR #1316 CI runs; PR #1294 note on main; session 2026-07-28 | 2026-07-28 | -| #094 | P2 | rec | Design-system gates assert structure, not rendered effect | **Outcome:** a style contract cannot pass while the style is inert. **Detail:** PR #1316's accent rail shipped inert because `.search-band` sat in `@layer components`, which loses to Tailwind's utilities layer regardless of specificity — and the test asserted `toHaveClass("search-band")`, i.e. class presence, not effect. Computed style showed `1px rgb(229,231,235)` where `2px rgb(11,111,134)` was intended. The same shape of gap let a rail-colour assertion compare a colour against a width and pass unconditionally. **Next:** for contracts where the visual IS the requirement (rails, forced-colors thickness, tap targets), assert `getComputedStyle` in a Playwright case rather than class names in a DOM test, and add the unlayered-component convention to the design-system contract check. **Stop:** do not convert existing passing DOM tests wholesale; add computed-style proof only where the effect carries the meaning. | PR #1316 Codex P2 finding; session 2026-07-28 | 2026-07-28 | -| #095 | P3 | issue | `PR required` reports failure for concurrency-cancelled jobs | **Outcome:** a red `PR required` means a real failure. **Detail:** the aggregate calls `require_success` on `coverage`/`production-ui`, so any push that supersedes an in-flight run reports `coverage result was cancelled` → exit 1, indistinguishable at a glance from a genuine failure. Eleven such reds were produced on PR #1316 in one session, and `Production UI` never once ran to completion. **Next:** in `.github/workflows/ci.yml`, either treat `cancelled` distinctly from `failure` in the aggregate, or reduce push frequency against long UI runs. **Stop:** do not relax `require_success` for genuine failures while doing so. | PR #1316 runs 30340972329 / 30341225585; session 2026-07-28 | 2026-07-28 | +| ID | Pri | Type | Summary | Detail / next action | Source | Added | +| ---- | --- | ----- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | +| #059 | P1 | task | Verify containment of every credential reported exposed in chat | **Outcome:** every reported exposed credential is rejected or retired. **Next:** in approved security windows, verify and revoke or rotate the GitHub token, OpenAI key, Supabase service-role JWT, database password, and E2E credential; create replacements only when required and update only intended secret stores. **Success:** provider evidence confirms the old credentials cannot authenticate, replacements are distinct and minimally scoped, presence/readiness checks pass, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never print or paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation; AI Agent Target Manifest | 2026-07-24 | +| #065 | P2 | task | Complete the paused compact document source-text accordion | **Outcome:** the document viewer uses compact nested disclosures while retaining complete text, citation/search navigation, print behavior, and composer clearance. **Next:** only when the user explicitly resumes, reconcile `codex/chat-document-text-accordion-7cb4` with current `main` and complete the focused 320/390/1280 px tests. **Success:** default disclosures are closed; deep links and search open only the active passage; printing expands/restores state; no overflow. **Verify:** focused document-viewer Playwright, `verify:cheap`, `verify:ui`, and static production-readiness. **Stop:** remain paused until explicit user return; no provider calls. | paused document-viewer task; `codex/chat-document-text-accordion-7cb4` | 2026-07-24 | +| #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 | +| #053 | P1 | task | Execute cross-border privacy/legal package | Execute OpenAI and Railway DPAs; decide ZDR and Australian data residency; obtain prompt-cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not represent the release as privacy-approved or alter final public privacy wording before sign-off. | `docs/openai-cross-border-basis.md`; `docs/privacy-impact-assessment.md` | 2026-07-24 | +| #055 | P2 | task | Run one exact-SHA full release and PR gate | Before the next full-confidence release/handoff, record the candidate/PR SHA and run the local/provider release gates, Firefox/WebKit, required hosted CI, and actionable GitHub review-thread closure once. Stop at the first actionable failure and rerun only the repaired smallest gate. | `docs/launch-operator-runbook.md`; `docs/codex-review-protocol.md` | 2026-07-24 | +| #056 | P2 | task | Reconcile the existing staging migration history | `Clinical KB Staging` already exists as a healthy, empty Supabase/Railway tier with distinct secrets and no production clinical data, but it is 23 repository migrations behind. In the next approved staging schema window, apply the exact missing migration chain, then re-run indexing, health, identity and data-boundary proof. Do not recreate the environment or copy production clinical documents. | current-main staging verification; `docs/staging-setup.md`; `docs/operator-backlog.md` | 2026-07-27 | +| #057 | P2 | task | Complete staging soak and rollback rehearsal | After #056, run the documented soak and rollback against an exact candidate; retain latency/error/rollback evidence. Stop on unsafe data, identity mismatch, or an unowned rollback decision. | `docs/launch-operator-runbook.md`; `docs/capacity-review.md` | 2026-07-24 | +| #005 | P3 | rec | `finalScore` saturates at clamp ceiling | Base + ~40 stacked boosts routinely exceed 1.0, so strong matches tie at 1.0 and order by an arbitrary `document_id` tiebreak. If ranking is ever revisited, break ties by the **pre-clamp** score rather than raising the `[0,1]` ceiling (downstream gates assume `[0,1]`). Ordering already sorts by the unbounded pre-clamp `rankScore` (`clinical-search.ts:1735,1927,1950-1955`), so the clamp confines only the reported confidence value, not result order. Not a defect on the current golden set; any change here is a protected RAG surface (canary required). | `docs/rag-hybrid-findings-and-todo.md` P1 item 4; `src/lib/clinical-search.ts:1735` | 2026-07-21 | +| #011 | P3 | task | Auth DB-connection allocation is operator-only | Supabase Auth (GoTrue) is capped at ~10 absolute DB connections (Supabase perf advisor). Switch to **percentage-based** allocation in the Supabase **dashboard** before the first compute scale-up — **not settable via SQL/MCP** (operator-owned). Verify via a staging soak + an approval-gated read-only advisor re-check. | `docs/auth-connection-cap-runbook.md`; `docs/process-hardening.md` (Known follow-up debts) | 2026-07-21 | +| #013 | P3 | rec | Route-chunk + mockup catalogue JSON weight | `build:analyze`: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), `/formulation` ships `formulation-content.json` (~52 KB, client-side local search — needs index/full split or a search endpoint, architectural). All route-scoped (not initial bundle). Also `*-mockups.tsx` (~100 KB across chunks) build though `/mockups` 404s in prod — exclude from the prod artifact. | session 2026-07-21 (build:analyze) | 2026-07-21 | +| #016 | P3 | rec | "Big but not easy" structural + motion perf | Deferred larger levers: (a) nonce-CSP forces every product route to `╞Æ Dynamic` (zero static generation) — evaluate Partial Prerendering / static shells for the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation); (b) sidebar expand/collapse animates `grid-template-columns` (biggest smoothness cost, motion-gated — needs a transform-overlay rethink); (c) Therapy Compass fetches 692 KB / 2.5 MB JSON client-side (defer until interaction + confirm brotli); (d) settings/setup/admin dialogs static-imported into the home chunk (`next/dynamic` them). | session 2026-07-21 (build route table + design audit) | 2026-07-21 | +| #017 | P3 | task | Field Web-Vitals baseline via live Lighthouse | In-sandbox runtime vitals were blocked (prod server hard-requires Supabase secrets; dev-mode CLS measured excellent at 0.00–0.04, content-first pages 0.000). Run Lighthouse against `psychiatry.tools` for real LCP/INP/CLS to prioritize #012–#016 by measured impact rather than reasoning. | session 2026-07-21 (measurement pass) | 2026-07-21 | +| #018 | P2 | task | Split the lithium, ADHD and metabolic residuals by mechanism | Current evidence keeps the mechanisms separate. **Lithium — closed within this item:** the row/atom-aware subject guard, foreign-parameter rejection and query-specific range promotion returned `0.5–1.0 mmol/L` with correct targeting/citation; the full retrieval canary remained 36/36 with recall 1.0 and zero per-case RR regressions, and the full answer canary passed every blocking gate. **ADHD — open corpus debt:** `CG.MHSP.ADHD.pdf` is absent from the hosted corpus and the retrieved chart exposes `accessible_table_count=0`; repair corpus/fixture or ingestion evidence rather than weakening extractive budgets. **Metabolic — open structured-evidence debt:** the standalone plural classifier worsened the live answer and was reverted; obtain auditable schedule text/table evidence before another candidate. | targeted live lithium/ADHD/metabolic evidence 2026-07-27; `docs/evidence/rag-reliability-evidence-2026-07-27.md`; refuted approaches | 2026-07-21 | +| #021 | P3 | rec | E-3d H2 residual: strong/comparison generation discards | approx. 6 generation attempts per full 44-case run still fail the final quality gate and fall to extractive on strong-route comparison/complex shapes (the designed-conservative outcome). PARKED: weakest cost/benefit on the queue — a wave (approx. $2-4 pair + reviewer cycle) to shave seconds off a few hard cases. Revisit only if latency/waste complaints or a cheaper lever appears. | E-3c design record; runs #59-#61 diagnostics | 2026-07-21 | +| #022 | P2 | task | Source-governance metadata refresh (operator) | The selected policy is now encoded locally as auditable `third_party_reference_attested` evidence with policy version, reviewer qualification, evidence references and append-only review history. It deliberately preserves `clinical_validation_status=unverified`; malformed, stale or non-BMJ evidence remains review debt. Migration `20260727010000_bmj_third_party_source_attestation.sql` is prepared but was **not applied**. The ten most visible local-document candidates are captured in `docs/evidence/rag-top-local-review-manifest-2026-07-26.json` with `attestation_applied=false`; qualified human review, deliberate hosted apply/attestation, and warning-rate remeasurement remain operator work. | governance worklist; local policy/migration tests; top-ten evidence manifest | 2026-07-21 | +| #023 | P2 | task | Complete scheduled browser and labeling disposition | The 2026-07-26 retrieval and answer artifacts are read and compared under resolved #051. Scheduled CI run `30216361999` failed its existing production dependency audit before Firefox/WebKit, while production Chromium passed. After that audit is green, capture one scheduled/manual browser-matrix datapoint; separately record the human decision for the stable irrelevant-at-10 set. #084 now makes each top-10 grade and matched signal reproducible, but it does not substitute for the human disposition. Do not rerun or spend on RAG for this item. | runs `30216191889`/`30216361999`; per-rank diagnostics #084; session 2026-07-27 | 2026-07-21 | +| #024 | P2 | issue | WebKit e2e `_rsc` prefetch access-control errors | PR #1205 narrowed catch-all interception and duplicate navigation, but Next 16.2.11 still raises `_rsc` access-control `pageerror`s after document-source fallbacks: `/documents/source?id=&page=2&chunk=safety%20plan` → `/documents/?page=2&chunk=safety+plan`; `/documents/source/evidence?id=not-a-uuid&page=2` → `/documents/search`. The invalid-id failure survived removing every Playwright route; Chromium passed both. **Next:** on a provider-free macOS host, run both URLs in stable Safari and Safari Technology Preview without interception, capture console text plus `_rsc` status/access-control headers, and compare Playwright WebKit with routing on/off. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without native evidence. | PRs #1179/#1205; current-main local WebKit evidence; session 2026-07-28 | 2026-07-28 | +| #025 | P2 | task | Activate the three webhooks (operator secrets) | Merged (#968/#1100) + deployed but inert — verified live: `POST /api/webhooks/railway` returns `503 webhook_not_configured`; the Supabase document-change trigger exists but lacks both activation inputs. To turn on: (1) Railway ΓåÆ set `RAILWAY_WEBHOOK_SECRET` + add the `?token=…` webhook URL; (2) set `SLACK_WEBHOOK_URL`/`DISCORD_WEBHOOK_URL` in BOTH the Railway **app/server env** and **GitHub repo secrets**; (3) set one matching document-change secret in the Railway app env as `SUPABASE_INGESTION_WEBHOOK_SECRET` and in Supabase Vault as `ingestion_webhook_secret`, then set the per-environment database GUC `app.ingestion_webhook_base_url` to the deployed app origin. Each path fails closed until fully configured, so this is pure ops. See `docs/webhooks.md` for verification and rotation. | sessions 2026-07-22/24; PRs #968/#1100; docs/webhooks.md | 2026-07-22 | +| #027 | P3 | rec | External uptime monitor independent of GitHub/Railway | `live-domain-monitor.yml` runs on GitHub's cron, so it won't run in exactly the outage it should catch (Actions or the deploy itself down). Add an off-platform synthetic monitor (UptimeRobot / Better Stack / Checkly) hitting `/api/health` with a webhook alert. Provider setup, not code. | session 2026-07-22 webhook review | 2026-07-22 | +| #028 | P3 | rec | Runtime error tracking (Sentry or similar) | No error tracking in the repo — production exceptions on `psychiatry.tools`, including how often `RAG_PROVIDER_MODE=auto` silently degrades to source-only, are invisible. Weigh adding `@sentry/nextjs` (dependency + DSN secret + instrumentation) vs cost; alert ΓåÆ chat/issue. Provider-backed; needs explicit sign-off before adding the dependency. | session 2026-07-22 webhook review | 2026-07-22 | +| #033 | P3 | rec | Source governance metadata absent from the LLM prompt | `buildRagSourceBlock` omits `document_status`, `clinical_validation_status`, and `extraction_quality`, so the model cannot self-caveat during generation and governance is enforced only post-hoc. Generation-surface change: needs `eval:rag` plus `eval:quality --rag-only` (grounded-supported must not drop, citation-failure 0) and explicit approval. Carries the same "unknown Γëá bad" hazard as #032 — on a partially-enriched corpus the model would likely over-caveat correct sources, so design the prompt wording before spending an eval. | `src/lib/rag/rag-source-block.ts:126-198`; PR #1051 audit item 8 | 2026-07-22 | +| #035 | P3 | rec | Threshold-conflict detection covers only 3 params | `detectThresholdDisagreements` checks only ANC, WBC, and platelets paired with withholding verbs, so cross-source conflicts on medication doses, lithium/thyroid levels, or vital signs go undetected. Deliberately narrow (see the comment at `:469-474`). Broadening changes when an answer is classified `conflicting` and adds warnings — real false-positive risk. Needs new fixtures plus a behaviour review before any change. | `src/lib/evidence.ts:469-574`; PR #1051 audit item 7 | 2026-07-22 | +| #036 | P3 | rec | No explicit `is_public` visibility flag on documents | Public-corpus visibility is implicit: `owner_id IS NULL` on an `indexed` document (`resolveSearchScope`). The `metadata.public_corpus` marker is written by the promotion migrations but never used as a retrieval filter. Promotion is unconditional on `clinical_validation_status`, so unverified documents are publicly searchable — compensated by keeping `unverified_source` in the frontend-visible warning set. A hard schema flag touches RLS and the clinical-risk-gated retrieval RPCs; weigh against the existing compensating control before acting. | `supabase/schema.sql:61-108`; `src/lib/search-scope.ts:181-236`; PR #1051 audit item 3 | 2026-07-22 | +| #037 | P3 | rec | D5 trust-cap-all-claims flag parked OFF | `NEXT_PUBLIC_RAG_TRUST_CAP_ALL_CLAIMS` extends authority gating from high-risk claims to **all** supported claims (`deriveTrust`). Ships OFF by design; flipping it caps trust to `medium` for routine claims across the board — a product/clinical-UX decision, not a defect. Both states are test-pinned. Next action: product decision, then flip and re-baseline the UI expectations. | `src/lib/answer-render-policy.ts:159-177`; PR #1051 audit item 11 | 2026-07-22 | +| #038 | P3 | rec | Consolidate shared comparison behavior | Several clinical modes expose comparison workflows with similar selection, empty-state and mobile-dock needs. Define one shared behavioral contract before another comparison surface is added; keep mode-specific clinical content separate. This is a design-system recommendation, not a current defect. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #039 | P3 | rec | Consolidate catalogue toolbar patterns | Catalogue/search pages have independently evolved filter, sort, result-count and mobile toolbar behavior. Inventory the existing implementations and converge only the repeated interaction contract; do not flatten mode-specific search semantics. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #040 | P3 | rec | Add targeted visual-regression baselines | Keep a small approved baseline set for high-value desktop/mobile surfaces and accessibility modes instead of screenshotting every route. Start with account/settings, document viewer, mode homes and bottom-composer interactions; define an intentional-update workflow before enabling blocking comparisons. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #079 | P3 | task | Disposition retained worktrees in bounded cleanup batches | **Outcome:** the retained reconciliation tail is gradually classified without another disruptive all-worktree sweep. **Next:** process no more than ten worktrees per explicitly scheduled pass using current owner/process metadata, open-PR state, exact review-ledger coverage, ancestry, and cherry-pick-aware content proof. **Success:** remove only clean, inactive, bundled worktrees whose content is merged or explicitly rejected; record every disposition and retain recovery evidence. **Stop:** preserve dirty, active, secret-bearing, post-freeze, paused, or ambiguous work and never use reset, force deletion, broad clean, or process killing. | final reconciliation inventory retained 104 independent worktrees; session 2026-07-24 | 2026-07-24 | +| #085 | P3 | rec | Upload-limit client/server sync is unguarded | `NEXT_PUBLIC_MAX_UPLOAD_MB` (client, build-time inlined) and `MAX_UPLOAD_MB` (server, runtime) default 150/150 but nothing keeps them in sync — no zod link, gate, or test. Lower server-only → the 413-after-full-transfer UX FV-04 (#1064/#1069) was built to prevent; lower client-only → false pre-check rejection of files the server would accept (breaks #1064's client-is-a-strict-superset invariant). Both documented in `.env.example`/`docs/deployment-architecture.md` but unenforced; the client value is also frozen at `next build`, so changing the Railway service var without an image rebuild silently no-ops. Cheapest guard: a `check:*` script (or CI assertion) that fails when the two configured values disagree. | session 2026-07-28 (FV-04 adversarial workflow, PR #1069); ID #085 after #084 claimed on main | 2026-07-27 | +| #086 | P3 | task | Repository maturity backlog — remaining structural work | **Outcome:** the deferred repository-maturity backlog ships as verified draft PRs, one per structural change. **Canonical runbook:** [`docs/maturity-backlog-workorders.md`](maturity-backlog-workorders.md). **Remaining:** X3 `rag.ts` decomposition; X7 finish the `src/lib` domain reorg; X6 clinical/retrieval/answer coverage floors; X5 ACL-migration consolidation (provider-gated); L1 archive the retired `backfill:*` one-shots + the dead `ci-change-scope` token; L4 ledger rotation; M1 repo-host hardening (maintainer, audit §8). **Next:** X3 on user go-ahead. **Stop:** RAG/retrieval items need the flag + go-ahead; X5 is live-DB provider-gated. | `docs/maturity-backlog-workorders.md`; audit §8/§10; session 2026-07-28 | 2026-07-28 | +| #088 | P3 | task | Watch for union-driver duplication as open PRs merge the repaired ledger | **Outcome:** the 2026-07-28 ledger repair does not resurface as duplicated rows. **Next:** on the first few open PRs that merge `origin/main` after the repair, run `npm run check:branch-review-ledger` on the merged head. Ordinary 3-way merges take main's repaired lines cleanly; only a same-hunk conflict would let `merge=union` keep both the corrupted and repaired copies. **Success:** three consecutive post-repair merges pass the guard. **Stop:** if duplication appears, remove exact duplicates only (the ledger contract allows that) and never rewrite surrounding records. | branch-review-ledger hygiene pass rewrote 226 historical lines; session 2026-07-28 | 2026-07-28 | +| #090 | P2 | task | Upgrade the eslint ecosystem to clear remaining dev-scoped high advisories | **Outcome:** full `npm audit` reports zero high advisories from the eslint toolchain. **Next:** in a dedicated dependency pass, upgrade eslint and its plugin/config set together (npm offers `eslint@10.8.0`, `isSemVerMajor`); residual highs (`@eslint/config-array`, `@eslint/eslintrc`, `eslint`, `eslint-config-next`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, plus the advisory's numeric `<=5.0.7` hit on the unused `brace-expansion@1.1.16` / `2.1.2` maintenance lines that still ship an unpatched `main`) cascade from this toolchain. **Success:** `npm run lint` clean, `verify:cheap` green, full-audit highs cleared, no rule-config regressions. **Stop:** an eslint major previously broke `eslint-plugin-react` here — do not bundle into an unrelated PR, and do not force with `npm audit fix --force`. Production `npm audit --omit=dev` is already clean after the exceljs `archiver@8` / `unzipper@0.12.5` overrides on PR #1314; this item is eslint-dev cleanup only. | session 2026-07-28 brace-expansion triage (PR #1314) | 2026-07-28 | +| #091 | P2 | issue | Results band cannot express a partial-source failure | **Outcome:** a favourites/results count is never asserted while some of its sources failed to load. **Detail:** `SearchResultsBandStatus` is a single flat status, so a page fed by several registries has no way to say "3 loaded, 1 failed". `saved-registry-favourites-status.ts:29` works around this with `itemCount > 0 && folded !== "ready" ? "ready" : folded`, and `favourites-command-library-page.tsx` applies the same mask a second time — so a partial failure renders a confident count with no fault indication, which is the exact defect class PR #1316 removed everywhere else. Neither favourites page consumes the true `registryStatus` the fold already returns. **Next:** decide between (a) a `partial` arm on the status union carrying a "some sources unavailable" note beside an honest count, or (b) surfacing `registryStatus` as a separate inline notice and dropping the mask. **Stop:** do not widen the mask to more surfaces before the shape is decided. | PR #1316 review thread PRRT_kwDOSh5Fis6UUf-k; session 2026-07-28 | 2026-07-28 | +| #092 | P3 | task | Refetch pulse deferred on auth-backed registries (privacy invariant) | **Outcome:** a background refresh keeps the prior count visible instead of a skeleton, without weakening identity clearing. **Detail:** the `refetching` status is built in the band and adopted only on `formulation-home-page.tsx`, where the lag is `useDeferredValue` over static data. It is deliberately NOT adopted on `use-registry-records.ts:85`, `use-medication-catalog.ts:76` or `use-differential-catalog.ts:133`, which all clear data on entering loading. `use-differential-catalog.ts:122` states why: "Auth must clear prior identity's matches immediately", and `:164` that "a later retype of any prior query cannot resurrect authorized matches." **Next:** if adopted, guard preservation on identity AND query equality, and pin with a test that an identity change still clears immediately. **Stop:** never hold records across an auth transition. | PR #1316 plan phase 6; session 2026-07-28 | 2026-07-28 | +| #093 | P2 | issue | Next streaming `S:` clone causes Playwright strict-mode violations under CI load | **Outcome:** duplicate-element strict-mode failures stop appearing on loaded CI runs. **Detail:** under full-suite CI load Next.js leaves a hidden duplicate page root in the stream, so a `getByTestId` that is unique locally resolves to 2 elements in CI (seen as `differentials-search-results` on PR #1316, and previously noted on PR #1294 against main). It does not reproduce in isolation, on a single spec, or locally. The documented workaround is to scope the locator to the visible root. **Reproduced locally 2026-07-28** (isolated _production_ build via `run-playwright.mjs`, full `verify:ui`): `ui-tools.spec.ts:563` duplicated `forms-home` and `ui-smoke.spec.ts:3001` duplicated `favourite-row-lithium-monitoring-guideline`; in both, copy 1 is nested under `mobile-composer-reserve-pad`. Both pass when run alone, so it is load/order-dependent, not build-mode dependent — this also corrects an earlier note that CI uses `next dev`; it does not. **Next:** with a full-suite repro now available, bisect the preceding specs to find the state that triggers the second mount, then either scope the shared helpers to the visible root once or fix the mount. **Stop:** do not paper over new occurrences with `.first()` before the duplicate itself is explained. | PR #1316 CI runs; PR #1294 note on main; session 2026-07-28 | 2026-07-28 | +| #094 | P2 | rec | Design-system gates assert structure, not rendered effect | **Outcome:** a style contract cannot pass while the style is inert. **Detail:** PR #1316's accent rail shipped inert because `.search-band` sat in `@layer components`, which loses to Tailwind's utilities layer regardless of specificity — and the test asserted `toHaveClass("search-band")`, i.e. class presence, not effect. Computed style showed `1px rgb(229,231,235)` where `2px rgb(11,111,134)` was intended. The same shape of gap let a rail-colour assertion compare a colour against a width and pass unconditionally. **Next:** for contracts where the visual IS the requirement (rails, forced-colors thickness, tap targets), assert `getComputedStyle` in a Playwright case rather than class names in a DOM test, and add the unlayered-component convention to the design-system contract check. **Stop:** do not convert existing passing DOM tests wholesale; add computed-style proof only where the effect carries the meaning. | PR #1316 Codex P2 finding; session 2026-07-28 | 2026-07-28 | +| #095 | P3 | issue | `PR required` reports failure for concurrency-cancelled jobs | **Outcome:** a red `PR required` means a real failure. **Detail:** the aggregate calls `require_success` on `coverage`/`production-ui`, so any push that supersedes an in-flight run reports `coverage result was cancelled` → exit 1, indistinguishable at a glance from a genuine failure. Eleven such reds were produced on PR #1316 in one session, and `Production UI` never once ran to completion. **Next:** in `.github/workflows/ci.yml`, either treat `cancelled` distinctly from `failure` in the aggregate, or reduce push frequency against long UI runs. **Stop:** do not relax `require_success` for genuine failures while doing so. | PR #1316 runs 30340972329 / 30341225585; session 2026-07-28 | 2026-07-28 | ## Resolved / archive From 9bace1d1b359df5c9a87c40be1e374a89976fd2a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 09:03:06 +0000 Subject: [PATCH 22/30] fix(search-band): close open #1316 review threads Correct #024 ledger prose so it no longer claims independent resolution while the open-items row remains. Suppress favourites hub library counts until the registry is ready or concrete items exist. Make therapy data retry return a Promise that settles with the active reload so Retry stays busy, with focused DOM coverage for both surfaces. Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + docs/outstanding-issues.md | 2 +- .../clinical-dashboard/favourites-hub.tsx | 67 ++++++++++++++----- src/components/therapy-compass/bindings.tsx | 2 +- .../therapy-compass/data/use-therapy-data.ts | 55 +++++++++++---- ...ites-hub-unavailable-controls.dom.test.tsx | 61 ++++++++++++++--- ...therapy-compass-data-recovery.dom.test.tsx | 38 +++++++++++ 7 files changed, 185 insertions(+), 41 deletions(-) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 0820ef3408..775784fa6a 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1172,3 +1172,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-28 | PR #1337 / claude/circleci-config-decision-f1efc6 | e9bcebb9ef7baddb2eed961bf40bbd89a358b41b | CI babysit: CircleCI verify green | FIXED. Root cause of CircleCI red: cimg/node:24.9 fails engine-strict npm ci because @babel/parser requires Node >=24.11. Switched to cimg/node:24.18. Also addressed Codex P2s (ci-change-scope docs-only; PyMuPDF venv+PYTHON_BIN). Hosted ci/circleci: verify SUCCESS on tip; GH PR required SUCCESS. Codex threads previously resolved. | circleci config validate; contract Vitest 6/6; hosted CircleCI verify SUCCESS (job 225); hosted PR required/Static/Unit SUCCESS | | 2026-07-28 | cursor/mode-secondary-navigation-dc4e | ea9f74062269cc820af5fdc9d3d1ce09e5cf0a4a | pr-1336-babysit | fixed P1 Suspense+DocumentViewer double-nav; P2 documents/search gate; CI babysit | verify:cheap,vitest-70+4189,tsc,eslint,build | | 2026-07-28 | cursor/mode-secondary-navigation-dc4e | e33997dbdd4a62689ff17ff1f536d9e830ae22ad | pr-1336-babysit | fixed Codex P1 horizontal scroll + P2 service section ids; merged main | vitest-62,eslint,tsc | +| 2026-07-28 | claude/top-search-design-mockups-w53znc | 9731a9e35fdd036e039f88cbe1f8ccb0f99e9fdc | PR #1316 tip WIP: #024 status + favourites count suppress + therapy retry settle | no high-confidence P0/P1; residual #091 partial-count trust + search-band onRetry dead behind workspace error gate | vitest favourites-hub-unavailable-controls + therapy-compass-data-recovery (7 passed); static review of uncommitted diffs | diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index feed65ad19..806c85cf8c 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -82,7 +82,7 @@ removed after current-main verification; it is not missing recommended work. > **Merged-main canary update (2026-07-23, run `30018289898`):** the new structured report correctly recorded evaluated tree `c24f2e8f2d30d0c59fc1eba025d3dcd63478137e`, run/attempt identity and `cross-region-runner` latency context. Golden retrieval remained 36/36 with document/content recall 1.0 and no failed cases. The 44-case answer gate had grounded-supported and unsupported-correct rates of 1.0, but failed because `neuroleptic-side-effect-escalation` again returned one citation where two are required (citation-failure rate 0.0227). `admission-discharge-comparison` again omitted the specific AKG admission document after `comparison_source_extractive_fallback`; `admission-discharge-coverage-paraphrase` was advisory-only at 24,870 ms. Answer cost was reported as `$0.234736`. Do not retry immediately: retain this as the first structured datapoint, compare it with the scheduled 2026-07-26 report, and keep retrieval/ranking unchanged. > -> **Scheduled comparison outcome (2026-07-27, runs `30018289898` → `30216191889`):** the approved read-only artifact comparison covered the same 36 retrieval and 44 answer cases. Retrieval retained document/content recall 1.0, hit rate 1.0, MRR@10 0.8921 and irrelevant@10 0.0917, with no failed cases or per-case document-RR regressions; content MRR moved 0.9344 → 0.9333 only on `clozapine-anc-threshold`. End-to-end retrieval latency materially worsened (median 12,558 → 21,021 ms; p90 32,062 → 49,682 ms), so ranking remains unchanged. The subsequent #069 profile found acceptable hosted table-facts database plans, separating this broad tail from that narrow RPC defect. Answer quality kept grounded-supported and unsupported-correct rates at 1.0, cleared the prior citation failure, and improved p95 18,591 → 17,003 ms, but #019 repeated with the identical `19baf837c890b5d3` diagnostic signature and `comparison_source_extractive_fallback`. Expected-hit moved 0.6591 → 0.6364 and cost $0.234736 → $0.235936. The scheduled CI run failed its blocking dependency audit before the Firefox/WebKit matrix, so it supplied no second browser datapoint; #024 is independently resolved and release-matrix debt remains #055. No scheduled rerun was dispatched. +> **Scheduled comparison outcome (2026-07-27, runs `30018289898` → `30216191889`):** the approved read-only artifact comparison covered the same 36 retrieval and 44 answer cases. Retrieval retained document/content recall 1.0, hit rate 1.0, MRR@10 0.8921 and irrelevant@10 0.0917, with no failed cases or per-case document-RR regressions; content MRR moved 0.9344 → 0.9333 only on `clozapine-anc-threshold`. End-to-end retrieval latency materially worsened (median 12,558 → 21,021 ms; p90 32,062 → 49,682 ms), so ranking remains unchanged. The subsequent #069 profile found acceptable hosted table-facts database plans, separating this broad tail from that narrow RPC defect. Answer quality kept grounded-supported and unsupported-correct rates at 1.0, cleared the prior citation failure, and improved p95 18,591 → 17,003 ms, but #019 repeated with the identical `19baf837c890b5d3` diagnostic signature and `comparison_source_extractive_fallback`. Expected-hit moved 0.6591 → 0.6364 and cost $0.234736 → $0.235936. The scheduled CI run failed its blocking dependency audit before the Firefox/WebKit matrix, so it supplied no second browser datapoint. #024 remains open for native Safari/`_rsc` reproduction on a provider-free macOS host; release-matrix debt remains #055. No scheduled rerun was dispatched. > > **RAG reconciliation correction (2026-07-23, superseded for fallback status on 2026-07-27):** fresh current-main live evidence superseded the broad diagnosis in #018. The three named misses were not one composer defect. Lithium reproduced an unrelated-table retrieval fast-path defect; ADHD still retrieved a relevant chart-heavy CAMHS source but exhausted the extractive route budget; metabolic retrieved the correct AKG source but selected schedule-free prose; #019 was post-retrieval comparison source selection. The independent-fix and canary constraints remain authoritative, while #019 and #029 are now resolved by the final update below. > diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index 82af02c30b..59f1d1284e 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -105,6 +105,14 @@ export function FavouritesHub({ const showSets = selectedTab === "all" || selectedTab === "sets"; const showItems = selectedTab !== "sets"; const empty = (!showItems || visibleItems.length === 0) && (!showSets || visibleSets.length === 0); + // Counts are only trustworthy once the registry answers, or when the page already + // holds concrete items (demo/prototype rows). A pending or failed load with an + // empty list must not assert "0" — that reads as an empty library. + const libraryCountsTrusted = savedRegistryStatus === "ready" || allFavouriteItems.length > 0; + const libraryCountUnavailableLabel = + savedRegistryStatus === "loading" + ? "unavailable until favourites finish loading" + : "unavailable because favourites could not be loaded"; const selectedTabMeta = favouriteTabs.find((tab) => tab.id === selectedTab) ?? favouriteTabs[0]; const selectedTabLabel = selectedTabMeta.label; const selectedTabCount = getTypeCount(selectedTab); @@ -184,9 +192,19 @@ export function FavouritesHub({
{[ - { label: "Items", value: itemCount, icon: Heart }, - { label: "Sets", value: setCount, icon: Folder }, - { label: "Filters", value: activeFilterCount, icon: Filter }, + { + label: "Items", + value: libraryCountsTrusted ? String(itemCount) : "—", + icon: Heart, + countBearing: true, + }, + { + label: "Sets", + value: libraryCountsTrusted ? String(setCount) : "—", + icon: Folder, + countBearing: true, + }, + { label: "Filters", value: String(activeFilterCount), icon: Filter, countBearing: false }, ].map((stat) => { const Icon = stat.icon; return ( @@ -198,7 +216,14 @@ export function FavouritesHub({ {stat.label}
-

+

{stat.value}

@@ -229,7 +254,7 @@ export function FavouritesHub({ View - {selectedTabLabel} · {selectedTabCount} + {libraryCountsTrusted ? `${selectedTabLabel} · ${selectedTabCount}` : selectedTabLabel} {tab.label} - - {count} - + {libraryCountsTrusted ? ( + + {count} + + ) : ( + Count {libraryCountUnavailableLabel} + )} ); })} @@ -401,9 +430,11 @@ export function FavouritesHub({ {selectedTab === "sets" ? "Open a focused clinical set." : "Open, ask, copy, or organise saved items."}

- - {selectedTab === "sets" ? visibleSets.length : visibleItems.length} - + {libraryCountsTrusted ? ( + + {selectedTab === "sets" ? visibleSets.length : visibleItems.length} + + ) : null}
diff --git a/src/components/therapy-compass/bindings.tsx b/src/components/therapy-compass/bindings.tsx index f1263993e1..f1e8a8cde8 100644 --- a/src/components/therapy-compass/bindings.tsx +++ b/src/components/therapy-compass/bindings.tsx @@ -49,7 +49,7 @@ export type TcBindings = { // ---- data ----------------------------------------------------------- loading: boolean; error: string | null; - retryData: () => void; + retryData: () => void | Promise; therapies: Therapy[]; unreviewedTherapies: Therapy[]; reviewCount: number; diff --git a/src/components/therapy-compass/data/use-therapy-data.ts b/src/components/therapy-compass/data/use-therapy-data.ts index 48f6f58787..02ffef0e43 100644 --- a/src/components/therapy-compass/data/use-therapy-data.ts +++ b/src/components/therapy-compass/data/use-therapy-data.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Pathway, ReferenceData, Therapy, TherapyDataset } from "./types"; @@ -45,7 +45,8 @@ export type TherapyDataState = { data: TherapyDataset | null; loading: boolean; error: string | null; - retry: () => void; + /** Settles when the replacement request finishes so Retry can stay busy. */ + retry: () => Promise; }; export function useTherapyData(options: TherapyDataOptions = {}): TherapyDataState { @@ -64,12 +65,29 @@ export function useTherapyData(options: TherapyDataOptions = {}): TherapyDataSta } >({ requestKey: null, data: null, loading: true, error: null }); const [attempt, setAttempt] = useState(0); - const retry = useCallback(() => { + const retryWaitersRef = useRef void>>([]); + const inFlightRetryRef = useRef | null>(null); + + const settleRetryWaiters = useCallback(() => { + const waiters = retryWaitersRef.current; + retryWaitersRef.current = []; + inFlightRetryRef.current = null; + for (const settle of waiters) settle(); + }, []); + + const retry = useCallback((): Promise => { + // Coalesce repeated clicks onto the same in-flight reload. + if (inFlightRetryRef.current) return inFlightRetryRef.current; cache.delete(requestKey); // Keep the prior error message mounted so Retry focus is not lost while the // next attempt is in flight; success/failure handlers replace it below. setState((prev) => ({ ...prev, loading: true })); - setAttempt((value) => value + 1); + const promise = new Promise((resolve) => { + retryWaitersRef.current.push(resolve); + setAttempt((value) => value + 1); + }); + inFlightRetryRef.current = promise; + return promise; }, [requestKey]); useEffect(() => { @@ -78,24 +96,35 @@ export function useTherapyData(options: TherapyDataOptions = {}): TherapyDataSta const request = cache.get(requestKey)!; request .then((data) => { - if (active) setState({ requestKey, data, loading: false, error: null }); + if (!active) return; + setState({ requestKey, data, loading: false, error: null }); + // Only the active attempt may settle Retry; a superseded effect must not + // release waiters belonging to a newer reload. + settleRetryWaiters(); }) .catch((err: unknown) => { // Only clear the shared cache when this request is still the active one. // A newer retry may have already replaced `cache` with a fresh promise. if (cache.get(requestKey) === request) cache.delete(requestKey); - if (active) - setState({ - requestKey, - data: null, - loading: false, - error: err instanceof Error ? err.message : "Failed to load", - }); + if (!active) return; + setState({ + requestKey, + data: null, + loading: false, + error: err instanceof Error ? err.message : "Failed to load", + }); + settleRetryWaiters(); }); return () => { active = false; }; - }, [attempt, requestKey, resolved]); + }, [attempt, requestKey, resolved, settleRetryWaiters]); + + useEffect(() => { + return () => { + settleRetryWaiters(); + }; + }, [settleRetryWaiters]); if (state.requestKey !== requestKey) return { data: null, loading: true, error: null, retry }; return { data: state.data, loading: state.loading, error: state.error, retry }; diff --git a/tests/favourites-hub-unavailable-controls.dom.test.tsx b/tests/favourites-hub-unavailable-controls.dom.test.tsx index 97ca75fa3d..81608dfc39 100644 --- a/tests/favourites-hub-unavailable-controls.dom.test.tsx +++ b/tests/favourites-hub-unavailable-controls.dom.test.tsx @@ -1,18 +1,33 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { FavouritesHub } from "@/components/clinical-dashboard/favourites-hub"; +const favouritesHook = vi.hoisted(() => ({ + items: [] as Array<{ + id: string; + type: "note" | "source" | "set" | "service" | "form"; + title: string; + meta?: string; + set?: string; + keywords: string; + }>, + status: "ready" as "ready" | "loading" | "error" | "unauthorized", + registryStatus: "ready" as "ready" | "loading" | "error" | "unauthorized", + refetch: () => undefined, +})); + vi.mock("@/components/clinical-dashboard/use-saved-registry-favourites", () => ({ - useSavedRegistryFavourites: () => ({ - items: [], - status: "ready", - registryStatus: "ready", - refetch: () => undefined, - }), + useSavedRegistryFavourites: () => favouritesHook, })); describe("FavouritesHub unavailable controls", () => { + beforeEach(() => { + favouritesHook.items = []; + favouritesHook.status = "ready"; + favouritesHook.registryStatus = "ready"; + }); + it("keeps unavailable actions natively disabled and exposes their reasons", () => { render( undefined} demoMode={false} />); @@ -30,4 +45,34 @@ describe("FavouritesHub unavailable controls", () => { expect(newSet).not.toHaveAttribute("aria-disabled"); expect(newSet).toHaveAccessibleDescription("Creating favourite sets is coming soon."); }); + + it("does not assert library zeroes while the saved registry is still loading", () => { + favouritesHook.status = "loading"; + render( undefined} demoMode={false} />); + + const hub = screen.getByTestId("favourites-hub"); + expect(within(hub).getByText("Loading your favourites")).toBeInTheDocument(); + expect(within(hub).getByLabelText("Items unavailable until favourites finish loading")).toHaveTextContent("—"); + expect(within(hub).getByLabelText("Sets unavailable until favourites finish loading")).toHaveTextContent("—"); + // Filters is local UI state, not library inventory — a zero there is honest. + expect(within(hub).getByText("Filters").parentElement?.parentElement).toHaveTextContent("0"); + expect(within(hub).getByRole("button", { name: "Choose favourite type" })).toHaveTextContent("All"); + expect(within(hub).getByRole("button", { name: "Choose favourite type" })).not.toHaveTextContent("·"); + }); + + it("does not assert library zeroes after the saved registry fails", () => { + favouritesHook.status = "error"; + render( undefined} demoMode={false} />); + + const hub = screen.getByTestId("favourites-hub"); + expect(within(hub).getByText("Could not load your favourites")).toBeInTheDocument(); + expect( + within(hub).getByLabelText("Items unavailable because favourites could not be loaded"), + ).toHaveTextContent("—"); + expect( + within(hub).getByLabelText("Sets unavailable because favourites could not be loaded"), + ).toHaveTextContent("—"); + expect(within(hub).getByRole("button", { name: "Retry" })).toBeInTheDocument(); + expect(within(hub).queryByText("All · 0")).not.toBeInTheDocument(); + }); }); diff --git a/tests/therapy-compass-data-recovery.dom.test.tsx b/tests/therapy-compass-data-recovery.dom.test.tsx index e0cdcb6a4f..7944faa9e9 100644 --- a/tests/therapy-compass-data-recovery.dom.test.tsx +++ b/tests/therapy-compass-data-recovery.dom.test.tsx @@ -104,6 +104,44 @@ describe("Therapy Compass required data recovery", () => { await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); }); + it("keeps Retry busy until the replacement catalogue request settles", async () => { + let failTherapies = true; + let releaseRetry!: (value: unknown) => void; + const retryGate = new Promise((resolve) => { + releaseRetry = resolve; + }); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const path = String(input); + if (path.endsWith("/therapies-index.json")) { + if (failTherapies) return response(null, false, 503); + await retryGate; + return response([therapy]); + } + throw new Error(`Unexpected fetch: ${path}`); + }); + vi.stubGlobal("fetch", fetchMock); + + render( + + + , + ); + + expect(await screen.findByRole("alert")).toHaveTextContent("Therapy could not load"); + failTherapies = false; + const retry = screen.getByRole("button", { name: "Retry" }); + fireEvent.click(retry); + + await waitFor(() => expect(screen.getByRole("button", { name: "Retrying…" })).toBeDisabled()); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Therapy" })).not.toBeInTheDocument(); + + releaseRetry(undefined); + + expect(await screen.findByRole("heading", { name: "Therapy" })).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + it("loads full therapy records only on a record-rich route", async () => { navigation.pathname = "/therapy-compass/test-therapy"; const fetchMock = vi.fn(async (input: RequestInfo | URL) => { From 40278453ced485de71b97684d77c259d83d385b1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 09:03:23 +0000 Subject: [PATCH 23/30] docs: record PR #1316 review closeout for tip 9bace1d1 Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 775784fa6a..043b457c1c 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1173,3 +1173,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-28 | cursor/mode-secondary-navigation-dc4e | ea9f74062269cc820af5fdc9d3d1ce09e5cf0a4a | pr-1336-babysit | fixed P1 Suspense+DocumentViewer double-nav; P2 documents/search gate; CI babysit | verify:cheap,vitest-70+4189,tsc,eslint,build | | 2026-07-28 | cursor/mode-secondary-navigation-dc4e | e33997dbdd4a62689ff17ff1f536d9e830ae22ad | pr-1336-babysit | fixed Codex P1 horizontal scroll + P2 service section ids; merged main | vitest-62,eslint,tsc | | 2026-07-28 | claude/top-search-design-mockups-w53znc | 9731a9e35fdd036e039f88cbe1f8ccb0f99e9fdc | PR #1316 tip WIP: #024 status + favourites count suppress + therapy retry settle | no high-confidence P0/P1; residual #091 partial-count trust + search-band onRetry dead behind workspace error gate | vitest favourites-hub-unavailable-controls + therapy-compass-data-recovery (7 passed); static review of uncommitted diffs | +| 2026-07-28 | PR #1316 / `claude/top-search-design-mockups-w53znc` | `9bace1d1b359df5c9a87c40be1e374a89976fd2a` | CI/review closeout: #024 prose, favourites counts, therapy retry settle | FIXED open threads. CodeRabbit #024 contradiction corrected in prose (item stays open). Codex favourites counts suppressed until trusted. Codex therapy retry returns settling Promise + busy coverage. Merged latest main (clean). Bugbot: no P0/P1. Residual #091 partial-count trust; search-band onRetry still behind workspace error gate (workspace Retry uses loading). | full vitest 4229 passed / 4 skipped; typecheck; eslint touched; check:branch-review-ledger; Bugbot via pr-bugbot | From 77f6b0a99306662178b64c34ec78c0b6b303ced2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 09:06:33 +0000 Subject: [PATCH 24/30] style: format the favourites hub unavailable-controls test Prettier formatting only, no behaviour change. The unformatted file was failing both `Static PR checks` and the new `ci/circleci: verify` job, which each run `npm run format:check`; `prettier --check .` now reports all matched files clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- .../favourites-hub-unavailable-controls.dom.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/favourites-hub-unavailable-controls.dom.test.tsx b/tests/favourites-hub-unavailable-controls.dom.test.tsx index 81608dfc39..c80cab6e8d 100644 --- a/tests/favourites-hub-unavailable-controls.dom.test.tsx +++ b/tests/favourites-hub-unavailable-controls.dom.test.tsx @@ -66,12 +66,12 @@ describe("FavouritesHub unavailable controls", () => { const hub = screen.getByTestId("favourites-hub"); expect(within(hub).getByText("Could not load your favourites")).toBeInTheDocument(); - expect( - within(hub).getByLabelText("Items unavailable because favourites could not be loaded"), - ).toHaveTextContent("—"); - expect( - within(hub).getByLabelText("Sets unavailable because favourites could not be loaded"), - ).toHaveTextContent("—"); + expect(within(hub).getByLabelText("Items unavailable because favourites could not be loaded")).toHaveTextContent( + "—", + ); + expect(within(hub).getByLabelText("Sets unavailable because favourites could not be loaded")).toHaveTextContent( + "—", + ); expect(within(hub).getByRole("button", { name: "Retry" })).toBeInTheDocument(); expect(within(hub).queryByText("All · 0")).not.toBeInTheDocument(); }); From 05f07d601c67d25e0fd49d0e86f17885d55d7ecf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 09:07:21 +0000 Subject: [PATCH 25/30] fix(favourites,search): separate load errors; expand band adoption routes Track GET /api/account/favourites failures as loadError so a failed first save cannot mark an already-loaded empty library unavailable. Discover results-band mode homes in the adoption gate (not only /search/ paths), with two-hop import reachability and landing-only allowlist entries. Co-authored-by: BigSimmo --- src/components/account-data-provider.tsx | 14 ++- .../saved-registry-favourites-status.ts | 7 +- .../use-saved-registry-favourites.ts | 4 +- tests/favourites-account-retry.dom.test.tsx | 62 +++++++++++- .../saved-registry-favourites-status.test.ts | 24 ++++- tests/search-results-band-adoption.test.ts | 94 ++++++++++++++++--- 6 files changed, 178 insertions(+), 27 deletions(-) diff --git a/src/components/account-data-provider.tsx b/src/components/account-data-provider.tsx index 07b870a422..8e04a0e0de 100644 --- a/src/components/account-data-provider.tsx +++ b/src/components/account-data-provider.tsx @@ -35,6 +35,9 @@ function readDemoFavourites(): FavouritesByType { type AccountDataContextValue = { favourites: FavouritesByType; ready: boolean; + /** Failure of GET /api/account/favourites (initial load or reload). */ + loadError: string | null; + /** Failure of a save/clear mutation after the library was already loaded. */ error: string | null; isAuthenticated: boolean; isSaved: (contentType: FavouriteContentType, contentKey: string) => boolean; @@ -70,6 +73,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { const auth = useAuthSession(); const [favourites, setFavourites] = useState(emptyFavourites); const [ready, setReady] = useState(auth.status !== "authenticated"); + const [loadError, setLoadError] = useState(null); const [error, setError] = useState(null); // Bumping this re-runs the load effect unchanged, so an explicit Retry gets // exactly the same clearing and abort semantics as an auth transition. @@ -84,6 +88,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { if (cancelled) return; refreshDemoFavourites(); setReady(true); + setLoadError(null); setError(null); }); const unsubscribe = demoAccountData ? subscribeSavedRegistrySlugs(refreshDemoFavourites) : undefined; @@ -106,12 +111,14 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.message ?? payload.error ?? "Saved items could not be loaded."); setFavourites(normalizedFavourites(payload.favourites)); + setLoadError(null); setError(null); }) .catch((cause) => { if (cause instanceof DOMException && cause.name === "AbortError") return; setFavourites(emptyFavourites); - setError(cause instanceof Error ? cause.message : "Saved items could not be loaded."); + setLoadError(cause instanceof Error ? cause.message : "Saved items could not be loaded."); + setError(null); }) .finally(() => { if (!controller.signal.aborted) setReady(true); @@ -154,6 +161,8 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { if (!response?.ok) { setFavourites(previous); const payload = await response?.json().catch(() => ({})); + // Mutation failures must not poison loadError: the library already loaded, + // and Retry-on-GET would mis-describe a failed write as an unread library. setError(payload?.message ?? payload?.error ?? "Saved items could not be updated."); if (response?.status === 401) auth.markSessionExpired(); return false; @@ -189,6 +198,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { () => ({ favourites, ready, + loadError, error, isAuthenticated: auth.status === "authenticated", isSaved: (contentType, contentKey) => favourites[contentType].includes(contentKey), @@ -196,7 +206,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { clearFavourites, reload, }), - [auth.status, clearFavourites, error, favourites, ready, reload, setFavourite], + [auth.status, clearFavourites, error, favourites, loadError, ready, reload, setFavourite], ); return {children}; diff --git a/src/components/clinical-dashboard/saved-registry-favourites-status.ts b/src/components/clinical-dashboard/saved-registry-favourites-status.ts index 4c8d48295a..f41c009562 100644 --- a/src/components/clinical-dashboard/saved-registry-favourites-status.ts +++ b/src/components/clinical-dashboard/saved-registry-favourites-status.ts @@ -9,14 +9,17 @@ export type SavedFavouritesBandStatus = "ready" | "loading" | "unauthorized" | " export function foldSavedFavouritesStatus(input: { isAuthenticated: boolean; accountReady: boolean; - accountError: string | null; + /** Only GET /api/account/favourites failures. Mutation/save errors must not + pass through here — they would mark an already-loaded empty library as + unavailable and offer a GET Retry for a failed write. */ + accountLoadError: string | null; registryStatus: SavedFavouritesBandStatus; itemCount: number; }): { status: SavedFavouritesBandStatus; registryStatus: SavedFavouritesBandStatus } { const accountStatus: SavedFavouritesBandStatus = input.isAuthenticated && !input.accountReady ? "loading" - : input.isAuthenticated && input.accountError + : input.isAuthenticated && input.accountLoadError ? "error" : "ready"; diff --git a/src/components/clinical-dashboard/use-saved-registry-favourites.ts b/src/components/clinical-dashboard/use-saved-registry-favourites.ts index cec0950f2a..3f1f7e865d 100644 --- a/src/components/clinical-dashboard/use-saved-registry-favourites.ts +++ b/src/components/clinical-dashboard/use-saved-registry-favourites.ts @@ -44,7 +44,7 @@ export function useSavedRegistryFavourites(): SavedRegistryFavouritesResult { const { favourites, ready: accountReady, - error: accountError, + loadError: accountLoadError, isAuthenticated, reload: reloadAccount, } = useAccountData(); @@ -97,7 +97,7 @@ export function useSavedRegistryFavourites(): SavedRegistryFavouritesResult { const { status, registryStatus } = foldSavedFavouritesStatus({ isAuthenticated, accountReady, - accountError, + accountLoadError, registryStatus: rawRegistryStatus, itemCount: items.length, }); diff --git a/tests/favourites-account-retry.dom.test.tsx b/tests/favourites-account-retry.dom.test.tsx index 5c97d83374..eff7737eee 100644 --- a/tests/favourites-account-retry.dom.test.tsx +++ b/tests/favourites-account-retry.dom.test.tsx @@ -4,7 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AccountDataProvider } from "@/components/account-data-provider"; +import { AccountDataProvider, useAccountData } from "@/components/account-data-provider"; import { useSavedRegistryFavourites } from "@/components/clinical-dashboard/use-saved-registry-favourites"; const authSession = vi.hoisted(() => ({ @@ -16,6 +16,7 @@ const authSession = vi.hoisted(() => ({ error: null as string | null, signInWithEmail: vi.fn(), signOut: vi.fn(), + markSessionExpired: vi.fn(), })); vi.mock("@/lib/supabase/client", () => ({ @@ -42,9 +43,31 @@ function Probe() { ); } +function FirstSaveProbe() { + const { items, status } = useSavedRegistryFavourites(); + const { setFavourite, error, loadError } = useAccountData(); + return ( +
+ {status} + {items.length} + {loadError ?? ""} + {error ?? ""} + +
+ ); +} + describe("favourites account retry", () => { beforeEach(() => { vi.restoreAllMocks(); + authSession.status = "authenticated"; }); afterEach(() => { @@ -83,4 +106,41 @@ describe("favourites account retry", () => { await waitFor(() => expect(screen.getByTestId("status")).toHaveTextContent("ready")); expect(screen.getByTestId("count")).toHaveTextContent("1"); }); + + it("keeps a successfully loaded empty library ready after a failed first save", async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + if (method === "GET") { + return { + ok: true, + json: async () => ({ favourites: [] }), + }; + } + if (method === "PUT") { + return { + ok: false, + status: 503, + json: async () => ({ message: "Saved items could not be updated." }), + }; + } + throw new Error(`Unexpected fetch method: ${method}`); + }); + vi.stubGlobal("fetch", fetchMock); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByTestId("status")).toHaveTextContent("ready")); + expect(screen.getByTestId("count")).toHaveTextContent("0"); + + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(screen.getByTestId("action-error")).toHaveTextContent("could not be updated")); + expect(screen.getByTestId("load-error")).toHaveTextContent(""); + expect(screen.getByTestId("status")).toHaveTextContent("ready"); + expect(screen.getByTestId("count")).toHaveTextContent("0"); + }); }); diff --git a/tests/saved-registry-favourites-status.test.ts b/tests/saved-registry-favourites-status.test.ts index 7ad684f5ba..c26efe9124 100644 --- a/tests/saved-registry-favourites-status.test.ts +++ b/tests/saved-registry-favourites-status.test.ts @@ -8,7 +8,7 @@ describe("foldSavedFavouritesStatus", () => { foldSavedFavouritesStatus({ isAuthenticated: true, accountReady: false, - accountError: null, + accountLoadError: null, registryStatus: "ready", itemCount: 0, }).status, @@ -20,7 +20,7 @@ describe("foldSavedFavouritesStatus", () => { foldSavedFavouritesStatus({ isAuthenticated: true, accountReady: true, - accountError: "Saved items could not be loaded.", + accountLoadError: "Saved items could not be loaded.", registryStatus: "ready", itemCount: 0, }).status, @@ -31,7 +31,7 @@ describe("foldSavedFavouritesStatus", () => { const folded = foldSavedFavouritesStatus({ isAuthenticated: true, accountReady: false, - accountError: null, + accountLoadError: null, registryStatus: "ready", itemCount: 0, }); @@ -43,7 +43,7 @@ describe("foldSavedFavouritesStatus", () => { foldSavedFavouritesStatus({ isAuthenticated: true, accountReady: true, - accountError: null, + accountLoadError: null, registryStatus: "error", itemCount: 2, }).status, @@ -55,10 +55,24 @@ describe("foldSavedFavouritesStatus", () => { foldSavedFavouritesStatus({ isAuthenticated: true, accountReady: true, - accountError: null, + accountLoadError: null, registryStatus: "unauthorized", itemCount: 0, }).status, ).toBe("unauthorized"); }); + + it("stays ready for an empty library when only a mutation would have failed", () => { + // foldSavedFavouritesStatus only receives load errors. A failed first-save + // after a successful empty GET must leave the empty state truthful. + expect( + foldSavedFavouritesStatus({ + isAuthenticated: true, + accountReady: true, + accountLoadError: null, + registryStatus: "ready", + itemCount: 0, + }).status, + ).toBe("ready"); + }); }); diff --git a/tests/search-results-band-adoption.test.ts b/tests/search-results-band-adoption.test.ts index 4d63cba43e..b6e3492498 100644 --- a/tests/search-results-band-adoption.test.ts +++ b/tests/search-results-band-adoption.test.ts @@ -1,4 +1,4 @@ -import { readFileSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -81,8 +81,52 @@ const BAND_ROUTE_ALLOWLIST = new Map([ "src/app/(search-app)/documents/search/page.tsx", "Composer-driven landing stub with no result list of its own; the band is mounted by document-search-results.tsx inside the dashboard shell.", ], + [ + "src/app/(search-app)/dsm/page.tsx", + "DSM mode home is a catalogue landing; result lists (and the band) live on /dsm/search.", + ], + [ + "src/app/(search-app)/factsheets/page.tsx", + "Factsheets mode home is a catalogue landing; result lists (and the band) live on /factsheets/search.", + ], + [ + "src/app/(search-app)/therapy-compass/page.tsx", + "Therapy home is the library landing; the search results band lives on /therapy-compass/search.", + ], ]); +/** Resolve a mode href like `/services` to its App Router page file when present. */ +function modeHrefToPagePath(href: string): string | null { + const pathOnly = href.split("?")[0]?.trim() ?? ""; + if (!pathOnly.startsWith("/") || pathOnly === "/") return null; + const candidate = path.join(APP_DIR, "(search-app)", pathOnly.slice(1), "page.tsx"); + if (!existsSync(candidate)) return null; + return path.relative(REPO_ROOT, candidate).replaceAll(path.sep, "/"); +} + +/** One or two import hops from a route into `@/components/**` that mounts the band. */ +function routeReachesBand(routeSource: string, componentSources: Map) { + if (rendersBand(routeSource)) return true; + const imported = [...routeSource.matchAll(/from "@\/(components\/[^"]+)"/g)].map((match) => match[1]); + for (const specifier of imported) { + const componentPath = `src/${specifier}.tsx`; + const source = componentSources.get(componentPath); + if (!source) continue; + if (rendersBand(source)) return true; + // Page wrappers often re-export a child that mounts the band (e.g. differentials). + const nested = [...source.matchAll(/from "@\/(components\/[^"]+)"/g)].map((match) => match[1]); + if ( + nested.some((nestedSpec) => { + const nestedSource = componentSources.get(`src/${nestedSpec}.tsx`); + return nestedSource ? rendersBand(nestedSource) : false; + }) + ) { + return true; + } + } + return false; +} + describe("search results band adoption", () => { const productionComponents = walk(COMPONENTS_DIR) .map((abs) => ({ abs, rel: path.relative(REPO_ROOT, abs) })) @@ -110,14 +154,32 @@ describe("search results band adoption", () => { }); it("reaches the band from every production search route", () => { - const searchRoutes = walk(APP_DIR) - .map((abs) => ({ abs, rel: path.relative(REPO_ROOT, abs) })) + const nestedSearchRoutes = walk(APP_DIR) + .map((abs) => ({ abs, rel: path.relative(REPO_ROOT, abs).replaceAll(path.sep, "/") })) .filter(({ rel }) => !isMockupPath(rel)) - .filter(({ rel }) => rel.replaceAll(path.sep, "/").includes("/search/") && rel.endsWith("page.tsx")); + .filter(({ rel }) => rel.includes("/search/") && rel.endsWith("page.tsx")); + + // Top-level mode homes such as /services and /favourites also present result + // lists. Restricting the inventory to `/search/` left those pages unchecked. + const modeHomeRoutes = appModeDefinitions + .filter((mode) => mode.search.resultsSurface === "results-band") + .map((mode) => ("href" in mode && typeof mode.href === "string" ? modeHrefToPagePath(mode.href) : null)) + .filter((rel): rel is string => Boolean(rel)) + .map((rel) => ({ abs: path.join(REPO_ROOT, rel), rel })); + + const routeKeys = new Map(); + for (const route of [...nestedSearchRoutes, ...modeHomeRoutes]) { + routeKeys.set(route.rel, route); + } + const searchRoutes = [...routeKeys.values()]; // If this ever hits zero the assertion below passes vacuously, which would // make the gate silently useless. expect(searchRoutes.length).toBeGreaterThan(0); + expect( + searchRoutes.some((route) => route.rel === "src/app/(search-app)/services/page.tsx"), + "Mode-href discovery must include top-level results pages such as /services.", + ).toBe(true); const componentSources = new Map( productionComponents.map(({ abs, rel }) => [rel.replaceAll(path.sep, "/"), readFileSync(abs, "utf8")]), @@ -125,18 +187,9 @@ describe("search results band adoption", () => { const orphans: string[] = []; for (const { abs, rel } of searchRoutes) { - const key = rel.replaceAll(path.sep, "/"); - if (BAND_ROUTE_ALLOWLIST.has(key)) continue; + if (BAND_ROUTE_ALLOWLIST.has(rel)) continue; const routeSource = readFileSync(abs, "utf8"); - if (rendersBand(routeSource)) continue; - // One import hop: a route almost always delegates to a client component. - const imported = [...routeSource.matchAll(/from "@\/(components\/[^"]+)"/g)].map((match) => match[1]); - const reaches = imported.some((specifier) => - [...componentSources.entries()].some( - ([componentPath, source]) => componentPath === `src/${specifier}.tsx` && rendersBand(source), - ), - ); - if (!reaches) orphans.push(key); + if (!routeReachesBand(routeSource, componentSources)) orphans.push(rel); } expect( @@ -196,4 +249,15 @@ describe("band adoption detection", () => { } expect(mounted.includes(" { + const componentSources = new Map([ + [ + "src/components/orphan-results-page.tsx", + 'export function OrphanResultsPage() { return
; }', + ], + ]); + const routeSource = 'import { OrphanResultsPage } from "@/components/orphan-results-page";\nexport default OrphanResultsPage;'; + expect(routeReachesBand(routeSource, componentSources)).toBe(false); + }); }); From 1b7c0238d62dae35f6b0d55a2f92078f8e8fc0a1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 09:07:39 +0000 Subject: [PATCH 26/30] docs: record PR #1316 closeout for tip 7b968d69 Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 043b457c1c..5e2675dfe9 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1174,3 +1174,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-28 | cursor/mode-secondary-navigation-dc4e | e33997dbdd4a62689ff17ff1f536d9e830ae22ad | pr-1336-babysit | fixed Codex P1 horizontal scroll + P2 service section ids; merged main | vitest-62,eslint,tsc | | 2026-07-28 | claude/top-search-design-mockups-w53znc | 9731a9e35fdd036e039f88cbe1f8ccb0f99e9fdc | PR #1316 tip WIP: #024 status + favourites count suppress + therapy retry settle | no high-confidence P0/P1; residual #091 partial-count trust + search-band onRetry dead behind workspace error gate | vitest favourites-hub-unavailable-controls + therapy-compass-data-recovery (7 passed); static review of uncommitted diffs | | 2026-07-28 | PR #1316 / `claude/top-search-design-mockups-w53znc` | `9bace1d1b359df5c9a87c40be1e374a89976fd2a` | CI/review closeout: #024 prose, favourites counts, therapy retry settle | FIXED open threads. CodeRabbit #024 contradiction corrected in prose (item stays open). Codex favourites counts suppressed until trusted. Codex therapy retry returns settling Promise + busy coverage. Merged latest main (clean). Bugbot: no P0/P1. Residual #091 partial-count trust; search-band onRetry still behind workspace error gate (workspace Retry uses loading). | full vitest 4229 passed / 4 skipped; typecheck; eslint touched; check:branch-review-ledger; Bugbot via pr-bugbot | +| 2026-07-28 | PR #1316 / `claude/top-search-design-mockups-w53znc` | `7b968d695c4545e1677c2e7f136172ef686d0012` | CI/review closeout: loadError split + adoption mode homes | FIXED new Codex P2s. Separated account loadError from mutation error; expanded band adoption to mode href pages + 2-hop reach; prior #024/favourites-counts/therapy-retry threads already resolved. Merge-tree clean vs main (0 behind). Bugbot earlier pass had no P0/P1 on prior WIP. | vitest favourites-account-retry + adoption + hub (10); typecheck; full suite 4232/4 prior tip | From 9662d9b2cc38160c77386f5aa29e68f9bcb5ae68 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 09:10:38 +0000 Subject: [PATCH 27/30] style: format the search-results band adoption test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prettier formatting only, no behaviour change. Second recurrence of the same break: `format:check` is not part of `npm run test`, `typecheck`, or eslint, so a change can report a green suite and still fail both `Static PR checks` and `ci/circleci: verify`, which each run it. Verified on this tip: `prettier --check .` clean, and the full unit suite is 416 files / 4232 passed / 4 skipped — the post-merge run that had not yet been done. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- tests/search-results-band-adoption.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/search-results-band-adoption.test.ts b/tests/search-results-band-adoption.test.ts index b6e3492498..1359e08d03 100644 --- a/tests/search-results-band-adoption.test.ts +++ b/tests/search-results-band-adoption.test.ts @@ -257,7 +257,8 @@ describe("band adoption detection", () => { 'export function OrphanResultsPage() { return
; }', ], ]); - const routeSource = 'import { OrphanResultsPage } from "@/components/orphan-results-page";\nexport default OrphanResultsPage;'; + const routeSource = + 'import { OrphanResultsPage } from "@/components/orphan-results-page";\nexport default OrphanResultsPage;'; expect(routeReachesBand(routeSource, componentSources)).toBe(false); }); }); From 54b302f38d5df0a7171b1fc1167cc7eb66b09bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 09:29:21 +0000 Subject: [PATCH 28/30] issues: record the decisive #093 evidence from the first complete Production UI run ui-overlap.spec.ts:199 passed toHaveCount(1) and then resolved the same header#search locator to 2 elements one statement later, one hidden. A duplicate appearing after a passing count assertion is a stream or hydration artifact by construction, which rules out a static double mount and rules out this branch's CSS and component changes as a cause. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- docs/outstanding-issues.md | 78 +++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 806c85cf8c..6d5f54fbe1 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -90,45 +90,45 @@ removed after current-main verification; it is not missing recommended work. > > **Exact-head release confirmation (2026-07-28):** after final review narrowed expanded chunk selection to the measured clozapine blood-count action shape, `output/rag-retrieval-post-exact-head.json` repeated all 36 cases with document/content recall 1.0, MRR 0.8921, content MRR 0.9406, nDCG 0.9308, irrelevant-at-10 0.0917, zero failures and zero per-case document/content reciprocal-rank regressions versus `rag-retrieval-post-final.json`. Median latency rose 13,563 -> 19,729 ms while p90 improved 56,765 -> 55,660 ms; neither run had a latency-failed case, so no ranking or latency gate changed. Cache-bypassed exact-head answer probes for both admission/discharge cases and the clozapine threshold case were substantive, grounded, expected-source-backed and free of citation/numeric/route failures; all used zero provider requests and $0 generation cost. This was the protected behavior-change merge gate, not a rerun for #023. -| ID | Pri | Type | Summary | Detail / next action | Source | Added | -| ---- | --- | ----- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | -| #059 | P1 | task | Verify containment of every credential reported exposed in chat | **Outcome:** every reported exposed credential is rejected or retired. **Next:** in approved security windows, verify and revoke or rotate the GitHub token, OpenAI key, Supabase service-role JWT, database password, and E2E credential; create replacements only when required and update only intended secret stores. **Success:** provider evidence confirms the old credentials cannot authenticate, replacements are distinct and minimally scoped, presence/readiness checks pass, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never print or paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation; AI Agent Target Manifest | 2026-07-24 | -| #065 | P2 | task | Complete the paused compact document source-text accordion | **Outcome:** the document viewer uses compact nested disclosures while retaining complete text, citation/search navigation, print behavior, and composer clearance. **Next:** only when the user explicitly resumes, reconcile `codex/chat-document-text-accordion-7cb4` with current `main` and complete the focused 320/390/1280 px tests. **Success:** default disclosures are closed; deep links and search open only the active passage; printing expands/restores state; no overflow. **Verify:** focused document-viewer Playwright, `verify:cheap`, `verify:ui`, and static production-readiness. **Stop:** remain paused until explicit user return; no provider calls. | paused document-viewer task; `codex/chat-document-text-accordion-7cb4` | 2026-07-24 | -| #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 | -| #053 | P1 | task | Execute cross-border privacy/legal package | Execute OpenAI and Railway DPAs; decide ZDR and Australian data residency; obtain prompt-cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not represent the release as privacy-approved or alter final public privacy wording before sign-off. | `docs/openai-cross-border-basis.md`; `docs/privacy-impact-assessment.md` | 2026-07-24 | -| #055 | P2 | task | Run one exact-SHA full release and PR gate | Before the next full-confidence release/handoff, record the candidate/PR SHA and run the local/provider release gates, Firefox/WebKit, required hosted CI, and actionable GitHub review-thread closure once. Stop at the first actionable failure and rerun only the repaired smallest gate. | `docs/launch-operator-runbook.md`; `docs/codex-review-protocol.md` | 2026-07-24 | -| #056 | P2 | task | Reconcile the existing staging migration history | `Clinical KB Staging` already exists as a healthy, empty Supabase/Railway tier with distinct secrets and no production clinical data, but it is 23 repository migrations behind. In the next approved staging schema window, apply the exact missing migration chain, then re-run indexing, health, identity and data-boundary proof. Do not recreate the environment or copy production clinical documents. | current-main staging verification; `docs/staging-setup.md`; `docs/operator-backlog.md` | 2026-07-27 | -| #057 | P2 | task | Complete staging soak and rollback rehearsal | After #056, run the documented soak and rollback against an exact candidate; retain latency/error/rollback evidence. Stop on unsafe data, identity mismatch, or an unowned rollback decision. | `docs/launch-operator-runbook.md`; `docs/capacity-review.md` | 2026-07-24 | -| #005 | P3 | rec | `finalScore` saturates at clamp ceiling | Base + ~40 stacked boosts routinely exceed 1.0, so strong matches tie at 1.0 and order by an arbitrary `document_id` tiebreak. If ranking is ever revisited, break ties by the **pre-clamp** score rather than raising the `[0,1]` ceiling (downstream gates assume `[0,1]`). Ordering already sorts by the unbounded pre-clamp `rankScore` (`clinical-search.ts:1735,1927,1950-1955`), so the clamp confines only the reported confidence value, not result order. Not a defect on the current golden set; any change here is a protected RAG surface (canary required). | `docs/rag-hybrid-findings-and-todo.md` P1 item 4; `src/lib/clinical-search.ts:1735` | 2026-07-21 | -| #011 | P3 | task | Auth DB-connection allocation is operator-only | Supabase Auth (GoTrue) is capped at ~10 absolute DB connections (Supabase perf advisor). Switch to **percentage-based** allocation in the Supabase **dashboard** before the first compute scale-up — **not settable via SQL/MCP** (operator-owned). Verify via a staging soak + an approval-gated read-only advisor re-check. | `docs/auth-connection-cap-runbook.md`; `docs/process-hardening.md` (Known follow-up debts) | 2026-07-21 | -| #013 | P3 | rec | Route-chunk + mockup catalogue JSON weight | `build:analyze`: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), `/formulation` ships `formulation-content.json` (~52 KB, client-side local search — needs index/full split or a search endpoint, architectural). All route-scoped (not initial bundle). Also `*-mockups.tsx` (~100 KB across chunks) build though `/mockups` 404s in prod — exclude from the prod artifact. | session 2026-07-21 (build:analyze) | 2026-07-21 | -| #016 | P3 | rec | "Big but not easy" structural + motion perf | Deferred larger levers: (a) nonce-CSP forces every product route to `╞Æ Dynamic` (zero static generation) — evaluate Partial Prerendering / static shells for the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation); (b) sidebar expand/collapse animates `grid-template-columns` (biggest smoothness cost, motion-gated — needs a transform-overlay rethink); (c) Therapy Compass fetches 692 KB / 2.5 MB JSON client-side (defer until interaction + confirm brotli); (d) settings/setup/admin dialogs static-imported into the home chunk (`next/dynamic` them). | session 2026-07-21 (build route table + design audit) | 2026-07-21 | -| #017 | P3 | task | Field Web-Vitals baseline via live Lighthouse | In-sandbox runtime vitals were blocked (prod server hard-requires Supabase secrets; dev-mode CLS measured excellent at 0.00–0.04, content-first pages 0.000). Run Lighthouse against `psychiatry.tools` for real LCP/INP/CLS to prioritize #012–#016 by measured impact rather than reasoning. | session 2026-07-21 (measurement pass) | 2026-07-21 | -| #018 | P2 | task | Split the lithium, ADHD and metabolic residuals by mechanism | Current evidence keeps the mechanisms separate. **Lithium — closed within this item:** the row/atom-aware subject guard, foreign-parameter rejection and query-specific range promotion returned `0.5–1.0 mmol/L` with correct targeting/citation; the full retrieval canary remained 36/36 with recall 1.0 and zero per-case RR regressions, and the full answer canary passed every blocking gate. **ADHD — open corpus debt:** `CG.MHSP.ADHD.pdf` is absent from the hosted corpus and the retrieved chart exposes `accessible_table_count=0`; repair corpus/fixture or ingestion evidence rather than weakening extractive budgets. **Metabolic — open structured-evidence debt:** the standalone plural classifier worsened the live answer and was reverted; obtain auditable schedule text/table evidence before another candidate. | targeted live lithium/ADHD/metabolic evidence 2026-07-27; `docs/evidence/rag-reliability-evidence-2026-07-27.md`; refuted approaches | 2026-07-21 | -| #021 | P3 | rec | E-3d H2 residual: strong/comparison generation discards | approx. 6 generation attempts per full 44-case run still fail the final quality gate and fall to extractive on strong-route comparison/complex shapes (the designed-conservative outcome). PARKED: weakest cost/benefit on the queue — a wave (approx. $2-4 pair + reviewer cycle) to shave seconds off a few hard cases. Revisit only if latency/waste complaints or a cheaper lever appears. | E-3c design record; runs #59-#61 diagnostics | 2026-07-21 | -| #022 | P2 | task | Source-governance metadata refresh (operator) | The selected policy is now encoded locally as auditable `third_party_reference_attested` evidence with policy version, reviewer qualification, evidence references and append-only review history. It deliberately preserves `clinical_validation_status=unverified`; malformed, stale or non-BMJ evidence remains review debt. Migration `20260727010000_bmj_third_party_source_attestation.sql` is prepared but was **not applied**. The ten most visible local-document candidates are captured in `docs/evidence/rag-top-local-review-manifest-2026-07-26.json` with `attestation_applied=false`; qualified human review, deliberate hosted apply/attestation, and warning-rate remeasurement remain operator work. | governance worklist; local policy/migration tests; top-ten evidence manifest | 2026-07-21 | -| #023 | P2 | task | Complete scheduled browser and labeling disposition | The 2026-07-26 retrieval and answer artifacts are read and compared under resolved #051. Scheduled CI run `30216361999` failed its existing production dependency audit before Firefox/WebKit, while production Chromium passed. After that audit is green, capture one scheduled/manual browser-matrix datapoint; separately record the human decision for the stable irrelevant-at-10 set. #084 now makes each top-10 grade and matched signal reproducible, but it does not substitute for the human disposition. Do not rerun or spend on RAG for this item. | runs `30216191889`/`30216361999`; per-rank diagnostics #084; session 2026-07-27 | 2026-07-21 | -| #024 | P2 | issue | WebKit e2e `_rsc` prefetch access-control errors | PR #1205 narrowed catch-all interception and duplicate navigation, but Next 16.2.11 still raises `_rsc` access-control `pageerror`s after document-source fallbacks: `/documents/source?id=&page=2&chunk=safety%20plan` → `/documents/?page=2&chunk=safety+plan`; `/documents/source/evidence?id=not-a-uuid&page=2` → `/documents/search`. The invalid-id failure survived removing every Playwright route; Chromium passed both. **Next:** on a provider-free macOS host, run both URLs in stable Safari and Safari Technology Preview without interception, capture console text plus `_rsc` status/access-control headers, and compare Playwright WebKit with routing on/off. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without native evidence. | PRs #1179/#1205; current-main local WebKit evidence; session 2026-07-28 | 2026-07-28 | -| #025 | P2 | task | Activate the three webhooks (operator secrets) | Merged (#968/#1100) + deployed but inert — verified live: `POST /api/webhooks/railway` returns `503 webhook_not_configured`; the Supabase document-change trigger exists but lacks both activation inputs. To turn on: (1) Railway ΓåÆ set `RAILWAY_WEBHOOK_SECRET` + add the `?token=…` webhook URL; (2) set `SLACK_WEBHOOK_URL`/`DISCORD_WEBHOOK_URL` in BOTH the Railway **app/server env** and **GitHub repo secrets**; (3) set one matching document-change secret in the Railway app env as `SUPABASE_INGESTION_WEBHOOK_SECRET` and in Supabase Vault as `ingestion_webhook_secret`, then set the per-environment database GUC `app.ingestion_webhook_base_url` to the deployed app origin. Each path fails closed until fully configured, so this is pure ops. See `docs/webhooks.md` for verification and rotation. | sessions 2026-07-22/24; PRs #968/#1100; docs/webhooks.md | 2026-07-22 | -| #027 | P3 | rec | External uptime monitor independent of GitHub/Railway | `live-domain-monitor.yml` runs on GitHub's cron, so it won't run in exactly the outage it should catch (Actions or the deploy itself down). Add an off-platform synthetic monitor (UptimeRobot / Better Stack / Checkly) hitting `/api/health` with a webhook alert. Provider setup, not code. | session 2026-07-22 webhook review | 2026-07-22 | -| #028 | P3 | rec | Runtime error tracking (Sentry or similar) | No error tracking in the repo — production exceptions on `psychiatry.tools`, including how often `RAG_PROVIDER_MODE=auto` silently degrades to source-only, are invisible. Weigh adding `@sentry/nextjs` (dependency + DSN secret + instrumentation) vs cost; alert ΓåÆ chat/issue. Provider-backed; needs explicit sign-off before adding the dependency. | session 2026-07-22 webhook review | 2026-07-22 | -| #033 | P3 | rec | Source governance metadata absent from the LLM prompt | `buildRagSourceBlock` omits `document_status`, `clinical_validation_status`, and `extraction_quality`, so the model cannot self-caveat during generation and governance is enforced only post-hoc. Generation-surface change: needs `eval:rag` plus `eval:quality --rag-only` (grounded-supported must not drop, citation-failure 0) and explicit approval. Carries the same "unknown Γëá bad" hazard as #032 — on a partially-enriched corpus the model would likely over-caveat correct sources, so design the prompt wording before spending an eval. | `src/lib/rag/rag-source-block.ts:126-198`; PR #1051 audit item 8 | 2026-07-22 | -| #035 | P3 | rec | Threshold-conflict detection covers only 3 params | `detectThresholdDisagreements` checks only ANC, WBC, and platelets paired with withholding verbs, so cross-source conflicts on medication doses, lithium/thyroid levels, or vital signs go undetected. Deliberately narrow (see the comment at `:469-474`). Broadening changes when an answer is classified `conflicting` and adds warnings — real false-positive risk. Needs new fixtures plus a behaviour review before any change. | `src/lib/evidence.ts:469-574`; PR #1051 audit item 7 | 2026-07-22 | -| #036 | P3 | rec | No explicit `is_public` visibility flag on documents | Public-corpus visibility is implicit: `owner_id IS NULL` on an `indexed` document (`resolveSearchScope`). The `metadata.public_corpus` marker is written by the promotion migrations but never used as a retrieval filter. Promotion is unconditional on `clinical_validation_status`, so unverified documents are publicly searchable — compensated by keeping `unverified_source` in the frontend-visible warning set. A hard schema flag touches RLS and the clinical-risk-gated retrieval RPCs; weigh against the existing compensating control before acting. | `supabase/schema.sql:61-108`; `src/lib/search-scope.ts:181-236`; PR #1051 audit item 3 | 2026-07-22 | -| #037 | P3 | rec | D5 trust-cap-all-claims flag parked OFF | `NEXT_PUBLIC_RAG_TRUST_CAP_ALL_CLAIMS` extends authority gating from high-risk claims to **all** supported claims (`deriveTrust`). Ships OFF by design; flipping it caps trust to `medium` for routine claims across the board — a product/clinical-UX decision, not a defect. Both states are test-pinned. Next action: product decision, then flip and re-baseline the UI expectations. | `src/lib/answer-render-policy.ts:159-177`; PR #1051 audit item 11 | 2026-07-22 | -| #038 | P3 | rec | Consolidate shared comparison behavior | Several clinical modes expose comparison workflows with similar selection, empty-state and mobile-dock needs. Define one shared behavioral contract before another comparison surface is added; keep mode-specific clinical content separate. This is a design-system recommendation, not a current defect. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #039 | P3 | rec | Consolidate catalogue toolbar patterns | Catalogue/search pages have independently evolved filter, sort, result-count and mobile toolbar behavior. Inventory the existing implementations and converge only the repeated interaction contract; do not flatten mode-specific search semantics. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #040 | P3 | rec | Add targeted visual-regression baselines | Keep a small approved baseline set for high-value desktop/mobile surfaces and accessibility modes instead of screenshotting every route. Start with account/settings, document viewer, mode homes and bottom-composer interactions; define an intentional-update workflow before enabling blocking comparisons. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | -| #079 | P3 | task | Disposition retained worktrees in bounded cleanup batches | **Outcome:** the retained reconciliation tail is gradually classified without another disruptive all-worktree sweep. **Next:** process no more than ten worktrees per explicitly scheduled pass using current owner/process metadata, open-PR state, exact review-ledger coverage, ancestry, and cherry-pick-aware content proof. **Success:** remove only clean, inactive, bundled worktrees whose content is merged or explicitly rejected; record every disposition and retain recovery evidence. **Stop:** preserve dirty, active, secret-bearing, post-freeze, paused, or ambiguous work and never use reset, force deletion, broad clean, or process killing. | final reconciliation inventory retained 104 independent worktrees; session 2026-07-24 | 2026-07-24 | -| #085 | P3 | rec | Upload-limit client/server sync is unguarded | `NEXT_PUBLIC_MAX_UPLOAD_MB` (client, build-time inlined) and `MAX_UPLOAD_MB` (server, runtime) default 150/150 but nothing keeps them in sync — no zod link, gate, or test. Lower server-only → the 413-after-full-transfer UX FV-04 (#1064/#1069) was built to prevent; lower client-only → false pre-check rejection of files the server would accept (breaks #1064's client-is-a-strict-superset invariant). Both documented in `.env.example`/`docs/deployment-architecture.md` but unenforced; the client value is also frozen at `next build`, so changing the Railway service var without an image rebuild silently no-ops. Cheapest guard: a `check:*` script (or CI assertion) that fails when the two configured values disagree. | session 2026-07-28 (FV-04 adversarial workflow, PR #1069); ID #085 after #084 claimed on main | 2026-07-27 | -| #086 | P3 | task | Repository maturity backlog — remaining structural work | **Outcome:** the deferred repository-maturity backlog ships as verified draft PRs, one per structural change. **Canonical runbook:** [`docs/maturity-backlog-workorders.md`](maturity-backlog-workorders.md). **Remaining:** X3 `rag.ts` decomposition; X7 finish the `src/lib` domain reorg; X6 clinical/retrieval/answer coverage floors; X5 ACL-migration consolidation (provider-gated); L1 archive the retired `backfill:*` one-shots + the dead `ci-change-scope` token; L4 ledger rotation; M1 repo-host hardening (maintainer, audit §8). **Next:** X3 on user go-ahead. **Stop:** RAG/retrieval items need the flag + go-ahead; X5 is live-DB provider-gated. | `docs/maturity-backlog-workorders.md`; audit §8/§10; session 2026-07-28 | 2026-07-28 | -| #088 | P3 | task | Watch for union-driver duplication as open PRs merge the repaired ledger | **Outcome:** the 2026-07-28 ledger repair does not resurface as duplicated rows. **Next:** on the first few open PRs that merge `origin/main` after the repair, run `npm run check:branch-review-ledger` on the merged head. Ordinary 3-way merges take main's repaired lines cleanly; only a same-hunk conflict would let `merge=union` keep both the corrupted and repaired copies. **Success:** three consecutive post-repair merges pass the guard. **Stop:** if duplication appears, remove exact duplicates only (the ledger contract allows that) and never rewrite surrounding records. | branch-review-ledger hygiene pass rewrote 226 historical lines; session 2026-07-28 | 2026-07-28 | -| #090 | P2 | task | Upgrade the eslint ecosystem to clear remaining dev-scoped high advisories | **Outcome:** full `npm audit` reports zero high advisories from the eslint toolchain. **Next:** in a dedicated dependency pass, upgrade eslint and its plugin/config set together (npm offers `eslint@10.8.0`, `isSemVerMajor`); residual highs (`@eslint/config-array`, `@eslint/eslintrc`, `eslint`, `eslint-config-next`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, plus the advisory's numeric `<=5.0.7` hit on the unused `brace-expansion@1.1.16` / `2.1.2` maintenance lines that still ship an unpatched `main`) cascade from this toolchain. **Success:** `npm run lint` clean, `verify:cheap` green, full-audit highs cleared, no rule-config regressions. **Stop:** an eslint major previously broke `eslint-plugin-react` here — do not bundle into an unrelated PR, and do not force with `npm audit fix --force`. Production `npm audit --omit=dev` is already clean after the exceljs `archiver@8` / `unzipper@0.12.5` overrides on PR #1314; this item is eslint-dev cleanup only. | session 2026-07-28 brace-expansion triage (PR #1314) | 2026-07-28 | -| #091 | P2 | issue | Results band cannot express a partial-source failure | **Outcome:** a favourites/results count is never asserted while some of its sources failed to load. **Detail:** `SearchResultsBandStatus` is a single flat status, so a page fed by several registries has no way to say "3 loaded, 1 failed". `saved-registry-favourites-status.ts:29` works around this with `itemCount > 0 && folded !== "ready" ? "ready" : folded`, and `favourites-command-library-page.tsx` applies the same mask a second time — so a partial failure renders a confident count with no fault indication, which is the exact defect class PR #1316 removed everywhere else. Neither favourites page consumes the true `registryStatus` the fold already returns. **Next:** decide between (a) a `partial` arm on the status union carrying a "some sources unavailable" note beside an honest count, or (b) surfacing `registryStatus` as a separate inline notice and dropping the mask. **Stop:** do not widen the mask to more surfaces before the shape is decided. | PR #1316 review thread PRRT_kwDOSh5Fis6UUf-k; session 2026-07-28 | 2026-07-28 | -| #092 | P3 | task | Refetch pulse deferred on auth-backed registries (privacy invariant) | **Outcome:** a background refresh keeps the prior count visible instead of a skeleton, without weakening identity clearing. **Detail:** the `refetching` status is built in the band and adopted only on `formulation-home-page.tsx`, where the lag is `useDeferredValue` over static data. It is deliberately NOT adopted on `use-registry-records.ts:85`, `use-medication-catalog.ts:76` or `use-differential-catalog.ts:133`, which all clear data on entering loading. `use-differential-catalog.ts:122` states why: "Auth must clear prior identity's matches immediately", and `:164` that "a later retype of any prior query cannot resurrect authorized matches." **Next:** if adopted, guard preservation on identity AND query equality, and pin with a test that an identity change still clears immediately. **Stop:** never hold records across an auth transition. | PR #1316 plan phase 6; session 2026-07-28 | 2026-07-28 | -| #093 | P2 | issue | Next streaming `S:` clone causes Playwright strict-mode violations under CI load | **Outcome:** duplicate-element strict-mode failures stop appearing on loaded CI runs. **Detail:** under full-suite CI load Next.js leaves a hidden duplicate page root in the stream, so a `getByTestId` that is unique locally resolves to 2 elements in CI (seen as `differentials-search-results` on PR #1316, and previously noted on PR #1294 against main). It does not reproduce in isolation, on a single spec, or locally. The documented workaround is to scope the locator to the visible root. **Reproduced locally 2026-07-28** (isolated _production_ build via `run-playwright.mjs`, full `verify:ui`): `ui-tools.spec.ts:563` duplicated `forms-home` and `ui-smoke.spec.ts:3001` duplicated `favourite-row-lithium-monitoring-guideline`; in both, copy 1 is nested under `mobile-composer-reserve-pad`. Both pass when run alone, so it is load/order-dependent, not build-mode dependent — this also corrects an earlier note that CI uses `next dev`; it does not. **Next:** with a full-suite repro now available, bisect the preceding specs to find the state that triggers the second mount, then either scope the shared helpers to the visible root once or fix the mount. **Stop:** do not paper over new occurrences with `.first()` before the duplicate itself is explained. | PR #1316 CI runs; PR #1294 note on main; session 2026-07-28 | 2026-07-28 | -| #094 | P2 | rec | Design-system gates assert structure, not rendered effect | **Outcome:** a style contract cannot pass while the style is inert. **Detail:** PR #1316's accent rail shipped inert because `.search-band` sat in `@layer components`, which loses to Tailwind's utilities layer regardless of specificity — and the test asserted `toHaveClass("search-band")`, i.e. class presence, not effect. Computed style showed `1px rgb(229,231,235)` where `2px rgb(11,111,134)` was intended. The same shape of gap let a rail-colour assertion compare a colour against a width and pass unconditionally. **Next:** for contracts where the visual IS the requirement (rails, forced-colors thickness, tap targets), assert `getComputedStyle` in a Playwright case rather than class names in a DOM test, and add the unlayered-component convention to the design-system contract check. **Stop:** do not convert existing passing DOM tests wholesale; add computed-style proof only where the effect carries the meaning. | PR #1316 Codex P2 finding; session 2026-07-28 | 2026-07-28 | -| #095 | P3 | issue | `PR required` reports failure for concurrency-cancelled jobs | **Outcome:** a red `PR required` means a real failure. **Detail:** the aggregate calls `require_success` on `coverage`/`production-ui`, so any push that supersedes an in-flight run reports `coverage result was cancelled` → exit 1, indistinguishable at a glance from a genuine failure. Eleven such reds were produced on PR #1316 in one session, and `Production UI` never once ran to completion. **Next:** in `.github/workflows/ci.yml`, either treat `cancelled` distinctly from `failure` in the aggregate, or reduce push frequency against long UI runs. **Stop:** do not relax `require_success` for genuine failures while doing so. | PR #1316 runs 30340972329 / 30341225585; session 2026-07-28 | 2026-07-28 | +| ID | Pri | Type | Summary | Detail / next action | Source | Added | +| ---- | --- | ----- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | +| #059 | P1 | task | Verify containment of every credential reported exposed in chat | **Outcome:** every reported exposed credential is rejected or retired. **Next:** in approved security windows, verify and revoke or rotate the GitHub token, OpenAI key, Supabase service-role JWT, database password, and E2E credential; create replacements only when required and update only intended secret stores. **Success:** provider evidence confirms the old credentials cannot authenticate, replacements are distinct and minimally scoped, presence/readiness checks pass, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never print or paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation; AI Agent Target Manifest | 2026-07-24 | +| #065 | P2 | task | Complete the paused compact document source-text accordion | **Outcome:** the document viewer uses compact nested disclosures while retaining complete text, citation/search navigation, print behavior, and composer clearance. **Next:** only when the user explicitly resumes, reconcile `codex/chat-document-text-accordion-7cb4` with current `main` and complete the focused 320/390/1280 px tests. **Success:** default disclosures are closed; deep links and search open only the active passage; printing expands/restores state; no overflow. **Verify:** focused document-viewer Playwright, `verify:cheap`, `verify:ui`, and static production-readiness. **Stop:** remain paused until explicit user return; no provider calls. | paused document-viewer task; `codex/chat-document-text-accordion-7cb4` | 2026-07-24 | +| #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 | +| #053 | P1 | task | Execute cross-border privacy/legal package | Execute OpenAI and Railway DPAs; decide ZDR and Australian data residency; obtain prompt-cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not represent the release as privacy-approved or alter final public privacy wording before sign-off. | `docs/openai-cross-border-basis.md`; `docs/privacy-impact-assessment.md` | 2026-07-24 | +| #055 | P2 | task | Run one exact-SHA full release and PR gate | Before the next full-confidence release/handoff, record the candidate/PR SHA and run the local/provider release gates, Firefox/WebKit, required hosted CI, and actionable GitHub review-thread closure once. Stop at the first actionable failure and rerun only the repaired smallest gate. | `docs/launch-operator-runbook.md`; `docs/codex-review-protocol.md` | 2026-07-24 | +| #056 | P2 | task | Reconcile the existing staging migration history | `Clinical KB Staging` already exists as a healthy, empty Supabase/Railway tier with distinct secrets and no production clinical data, but it is 23 repository migrations behind. In the next approved staging schema window, apply the exact missing migration chain, then re-run indexing, health, identity and data-boundary proof. Do not recreate the environment or copy production clinical documents. | current-main staging verification; `docs/staging-setup.md`; `docs/operator-backlog.md` | 2026-07-27 | +| #057 | P2 | task | Complete staging soak and rollback rehearsal | After #056, run the documented soak and rollback against an exact candidate; retain latency/error/rollback evidence. Stop on unsafe data, identity mismatch, or an unowned rollback decision. | `docs/launch-operator-runbook.md`; `docs/capacity-review.md` | 2026-07-24 | +| #005 | P3 | rec | `finalScore` saturates at clamp ceiling | Base + ~40 stacked boosts routinely exceed 1.0, so strong matches tie at 1.0 and order by an arbitrary `document_id` tiebreak. If ranking is ever revisited, break ties by the **pre-clamp** score rather than raising the `[0,1]` ceiling (downstream gates assume `[0,1]`). Ordering already sorts by the unbounded pre-clamp `rankScore` (`clinical-search.ts:1735,1927,1950-1955`), so the clamp confines only the reported confidence value, not result order. Not a defect on the current golden set; any change here is a protected RAG surface (canary required). | `docs/rag-hybrid-findings-and-todo.md` P1 item 4; `src/lib/clinical-search.ts:1735` | 2026-07-21 | +| #011 | P3 | task | Auth DB-connection allocation is operator-only | Supabase Auth (GoTrue) is capped at ~10 absolute DB connections (Supabase perf advisor). Switch to **percentage-based** allocation in the Supabase **dashboard** before the first compute scale-up — **not settable via SQL/MCP** (operator-owned). Verify via a staging soak + an approval-gated read-only advisor re-check. | `docs/auth-connection-cap-runbook.md`; `docs/process-hardening.md` (Known follow-up debts) | 2026-07-21 | +| #013 | P3 | rec | Route-chunk + mockup catalogue JSON weight | `build:analyze`: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), `/formulation` ships `formulation-content.json` (~52 KB, client-side local search — needs index/full split or a search endpoint, architectural). All route-scoped (not initial bundle). Also `*-mockups.tsx` (~100 KB across chunks) build though `/mockups` 404s in prod — exclude from the prod artifact. | session 2026-07-21 (build:analyze) | 2026-07-21 | +| #016 | P3 | rec | "Big but not easy" structural + motion perf | Deferred larger levers: (a) nonce-CSP forces every product route to `╞Æ Dynamic` (zero static generation) — evaluate Partial Prerendering / static shells for the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation); (b) sidebar expand/collapse animates `grid-template-columns` (biggest smoothness cost, motion-gated — needs a transform-overlay rethink); (c) Therapy Compass fetches 692 KB / 2.5 MB JSON client-side (defer until interaction + confirm brotli); (d) settings/setup/admin dialogs static-imported into the home chunk (`next/dynamic` them). | session 2026-07-21 (build route table + design audit) | 2026-07-21 | +| #017 | P3 | task | Field Web-Vitals baseline via live Lighthouse | In-sandbox runtime vitals were blocked (prod server hard-requires Supabase secrets; dev-mode CLS measured excellent at 0.00–0.04, content-first pages 0.000). Run Lighthouse against `psychiatry.tools` for real LCP/INP/CLS to prioritize #012–#016 by measured impact rather than reasoning. | session 2026-07-21 (measurement pass) | 2026-07-21 | +| #018 | P2 | task | Split the lithium, ADHD and metabolic residuals by mechanism | Current evidence keeps the mechanisms separate. **Lithium — closed within this item:** the row/atom-aware subject guard, foreign-parameter rejection and query-specific range promotion returned `0.5–1.0 mmol/L` with correct targeting/citation; the full retrieval canary remained 36/36 with recall 1.0 and zero per-case RR regressions, and the full answer canary passed every blocking gate. **ADHD — open corpus debt:** `CG.MHSP.ADHD.pdf` is absent from the hosted corpus and the retrieved chart exposes `accessible_table_count=0`; repair corpus/fixture or ingestion evidence rather than weakening extractive budgets. **Metabolic — open structured-evidence debt:** the standalone plural classifier worsened the live answer and was reverted; obtain auditable schedule text/table evidence before another candidate. | targeted live lithium/ADHD/metabolic evidence 2026-07-27; `docs/evidence/rag-reliability-evidence-2026-07-27.md`; refuted approaches | 2026-07-21 | +| #021 | P3 | rec | E-3d H2 residual: strong/comparison generation discards | approx. 6 generation attempts per full 44-case run still fail the final quality gate and fall to extractive on strong-route comparison/complex shapes (the designed-conservative outcome). PARKED: weakest cost/benefit on the queue — a wave (approx. $2-4 pair + reviewer cycle) to shave seconds off a few hard cases. Revisit only if latency/waste complaints or a cheaper lever appears. | E-3c design record; runs #59-#61 diagnostics | 2026-07-21 | +| #022 | P2 | task | Source-governance metadata refresh (operator) | The selected policy is now encoded locally as auditable `third_party_reference_attested` evidence with policy version, reviewer qualification, evidence references and append-only review history. It deliberately preserves `clinical_validation_status=unverified`; malformed, stale or non-BMJ evidence remains review debt. Migration `20260727010000_bmj_third_party_source_attestation.sql` is prepared but was **not applied**. The ten most visible local-document candidates are captured in `docs/evidence/rag-top-local-review-manifest-2026-07-26.json` with `attestation_applied=false`; qualified human review, deliberate hosted apply/attestation, and warning-rate remeasurement remain operator work. | governance worklist; local policy/migration tests; top-ten evidence manifest | 2026-07-21 | +| #023 | P2 | task | Complete scheduled browser and labeling disposition | The 2026-07-26 retrieval and answer artifacts are read and compared under resolved #051. Scheduled CI run `30216361999` failed its existing production dependency audit before Firefox/WebKit, while production Chromium passed. After that audit is green, capture one scheduled/manual browser-matrix datapoint; separately record the human decision for the stable irrelevant-at-10 set. #084 now makes each top-10 grade and matched signal reproducible, but it does not substitute for the human disposition. Do not rerun or spend on RAG for this item. | runs `30216191889`/`30216361999`; per-rank diagnostics #084; session 2026-07-27 | 2026-07-21 | +| #024 | P2 | issue | WebKit e2e `_rsc` prefetch access-control errors | PR #1205 narrowed catch-all interception and duplicate navigation, but Next 16.2.11 still raises `_rsc` access-control `pageerror`s after document-source fallbacks: `/documents/source?id=&page=2&chunk=safety%20plan` → `/documents/?page=2&chunk=safety+plan`; `/documents/source/evidence?id=not-a-uuid&page=2` → `/documents/search`. The invalid-id failure survived removing every Playwright route; Chromium passed both. **Next:** on a provider-free macOS host, run both URLs in stable Safari and Safari Technology Preview without interception, capture console text plus `_rsc` status/access-control headers, and compare Playwright WebKit with routing on/off. Treat as an app defect only if native Safari reproduces; otherwise return to the harness. Never suppress `pageerror` or change CORS without native evidence. | PRs #1179/#1205; current-main local WebKit evidence; session 2026-07-28 | 2026-07-28 | +| #025 | P2 | task | Activate the three webhooks (operator secrets) | Merged (#968/#1100) + deployed but inert — verified live: `POST /api/webhooks/railway` returns `503 webhook_not_configured`; the Supabase document-change trigger exists but lacks both activation inputs. To turn on: (1) Railway ΓåÆ set `RAILWAY_WEBHOOK_SECRET` + add the `?token=…` webhook URL; (2) set `SLACK_WEBHOOK_URL`/`DISCORD_WEBHOOK_URL` in BOTH the Railway **app/server env** and **GitHub repo secrets**; (3) set one matching document-change secret in the Railway app env as `SUPABASE_INGESTION_WEBHOOK_SECRET` and in Supabase Vault as `ingestion_webhook_secret`, then set the per-environment database GUC `app.ingestion_webhook_base_url` to the deployed app origin. Each path fails closed until fully configured, so this is pure ops. See `docs/webhooks.md` for verification and rotation. | sessions 2026-07-22/24; PRs #968/#1100; docs/webhooks.md | 2026-07-22 | +| #027 | P3 | rec | External uptime monitor independent of GitHub/Railway | `live-domain-monitor.yml` runs on GitHub's cron, so it won't run in exactly the outage it should catch (Actions or the deploy itself down). Add an off-platform synthetic monitor (UptimeRobot / Better Stack / Checkly) hitting `/api/health` with a webhook alert. Provider setup, not code. | session 2026-07-22 webhook review | 2026-07-22 | +| #028 | P3 | rec | Runtime error tracking (Sentry or similar) | No error tracking in the repo — production exceptions on `psychiatry.tools`, including how often `RAG_PROVIDER_MODE=auto` silently degrades to source-only, are invisible. Weigh adding `@sentry/nextjs` (dependency + DSN secret + instrumentation) vs cost; alert ΓåÆ chat/issue. Provider-backed; needs explicit sign-off before adding the dependency. | session 2026-07-22 webhook review | 2026-07-22 | +| #033 | P3 | rec | Source governance metadata absent from the LLM prompt | `buildRagSourceBlock` omits `document_status`, `clinical_validation_status`, and `extraction_quality`, so the model cannot self-caveat during generation and governance is enforced only post-hoc. Generation-surface change: needs `eval:rag` plus `eval:quality --rag-only` (grounded-supported must not drop, citation-failure 0) and explicit approval. Carries the same "unknown Γëá bad" hazard as #032 — on a partially-enriched corpus the model would likely over-caveat correct sources, so design the prompt wording before spending an eval. | `src/lib/rag/rag-source-block.ts:126-198`; PR #1051 audit item 8 | 2026-07-22 | +| #035 | P3 | rec | Threshold-conflict detection covers only 3 params | `detectThresholdDisagreements` checks only ANC, WBC, and platelets paired with withholding verbs, so cross-source conflicts on medication doses, lithium/thyroid levels, or vital signs go undetected. Deliberately narrow (see the comment at `:469-474`). Broadening changes when an answer is classified `conflicting` and adds warnings — real false-positive risk. Needs new fixtures plus a behaviour review before any change. | `src/lib/evidence.ts:469-574`; PR #1051 audit item 7 | 2026-07-22 | +| #036 | P3 | rec | No explicit `is_public` visibility flag on documents | Public-corpus visibility is implicit: `owner_id IS NULL` on an `indexed` document (`resolveSearchScope`). The `metadata.public_corpus` marker is written by the promotion migrations but never used as a retrieval filter. Promotion is unconditional on `clinical_validation_status`, so unverified documents are publicly searchable — compensated by keeping `unverified_source` in the frontend-visible warning set. A hard schema flag touches RLS and the clinical-risk-gated retrieval RPCs; weigh against the existing compensating control before acting. | `supabase/schema.sql:61-108`; `src/lib/search-scope.ts:181-236`; PR #1051 audit item 3 | 2026-07-22 | +| #037 | P3 | rec | D5 trust-cap-all-claims flag parked OFF | `NEXT_PUBLIC_RAG_TRUST_CAP_ALL_CLAIMS` extends authority gating from high-risk claims to **all** supported claims (`deriveTrust`). Ships OFF by design; flipping it caps trust to `medium` for routine claims across the board — a product/clinical-UX decision, not a defect. Both states are test-pinned. Next action: product decision, then flip and re-baseline the UI expectations. | `src/lib/answer-render-policy.ts:159-177`; PR #1051 audit item 11 | 2026-07-22 | +| #038 | P3 | rec | Consolidate shared comparison behavior | Several clinical modes expose comparison workflows with similar selection, empty-state and mobile-dock needs. Define one shared behavioral contract before another comparison surface is added; keep mode-specific clinical content separate. This is a design-system recommendation, not a current defect. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #039 | P3 | rec | Consolidate catalogue toolbar patterns | Catalogue/search pages have independently evolved filter, sort, result-count and mobile toolbar behavior. Inventory the existing implementations and converge only the repeated interaction contract; do not flatten mode-specific search semantics. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #040 | P3 | rec | Add targeted visual-regression baselines | Keep a small approved baseline set for high-value desktop/mobile surfaces and accessibility modes instead of screenshotting every route. Start with account/settings, document viewer, mode homes and bottom-composer interactions; define an intentional-update workflow before enabling blocking comparisons. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #079 | P3 | task | Disposition retained worktrees in bounded cleanup batches | **Outcome:** the retained reconciliation tail is gradually classified without another disruptive all-worktree sweep. **Next:** process no more than ten worktrees per explicitly scheduled pass using current owner/process metadata, open-PR state, exact review-ledger coverage, ancestry, and cherry-pick-aware content proof. **Success:** remove only clean, inactive, bundled worktrees whose content is merged or explicitly rejected; record every disposition and retain recovery evidence. **Stop:** preserve dirty, active, secret-bearing, post-freeze, paused, or ambiguous work and never use reset, force deletion, broad clean, or process killing. | final reconciliation inventory retained 104 independent worktrees; session 2026-07-24 | 2026-07-24 | +| #085 | P3 | rec | Upload-limit client/server sync is unguarded | `NEXT_PUBLIC_MAX_UPLOAD_MB` (client, build-time inlined) and `MAX_UPLOAD_MB` (server, runtime) default 150/150 but nothing keeps them in sync — no zod link, gate, or test. Lower server-only → the 413-after-full-transfer UX FV-04 (#1064/#1069) was built to prevent; lower client-only → false pre-check rejection of files the server would accept (breaks #1064's client-is-a-strict-superset invariant). Both documented in `.env.example`/`docs/deployment-architecture.md` but unenforced; the client value is also frozen at `next build`, so changing the Railway service var without an image rebuild silently no-ops. Cheapest guard: a `check:*` script (or CI assertion) that fails when the two configured values disagree. | session 2026-07-28 (FV-04 adversarial workflow, PR #1069); ID #085 after #084 claimed on main | 2026-07-27 | +| #086 | P3 | task | Repository maturity backlog — remaining structural work | **Outcome:** the deferred repository-maturity backlog ships as verified draft PRs, one per structural change. **Canonical runbook:** [`docs/maturity-backlog-workorders.md`](maturity-backlog-workorders.md). **Remaining:** X3 `rag.ts` decomposition; X7 finish the `src/lib` domain reorg; X6 clinical/retrieval/answer coverage floors; X5 ACL-migration consolidation (provider-gated); L1 archive the retired `backfill:*` one-shots + the dead `ci-change-scope` token; L4 ledger rotation; M1 repo-host hardening (maintainer, audit §8). **Next:** X3 on user go-ahead. **Stop:** RAG/retrieval items need the flag + go-ahead; X5 is live-DB provider-gated. | `docs/maturity-backlog-workorders.md`; audit §8/§10; session 2026-07-28 | 2026-07-28 | +| #088 | P3 | task | Watch for union-driver duplication as open PRs merge the repaired ledger | **Outcome:** the 2026-07-28 ledger repair does not resurface as duplicated rows. **Next:** on the first few open PRs that merge `origin/main` after the repair, run `npm run check:branch-review-ledger` on the merged head. Ordinary 3-way merges take main's repaired lines cleanly; only a same-hunk conflict would let `merge=union` keep both the corrupted and repaired copies. **Success:** three consecutive post-repair merges pass the guard. **Stop:** if duplication appears, remove exact duplicates only (the ledger contract allows that) and never rewrite surrounding records. | branch-review-ledger hygiene pass rewrote 226 historical lines; session 2026-07-28 | 2026-07-28 | +| #090 | P2 | task | Upgrade the eslint ecosystem to clear remaining dev-scoped high advisories | **Outcome:** full `npm audit` reports zero high advisories from the eslint toolchain. **Next:** in a dedicated dependency pass, upgrade eslint and its plugin/config set together (npm offers `eslint@10.8.0`, `isSemVerMajor`); residual highs (`@eslint/config-array`, `@eslint/eslintrc`, `eslint`, `eslint-config-next`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, plus the advisory's numeric `<=5.0.7` hit on the unused `brace-expansion@1.1.16` / `2.1.2` maintenance lines that still ship an unpatched `main`) cascade from this toolchain. **Success:** `npm run lint` clean, `verify:cheap` green, full-audit highs cleared, no rule-config regressions. **Stop:** an eslint major previously broke `eslint-plugin-react` here — do not bundle into an unrelated PR, and do not force with `npm audit fix --force`. Production `npm audit --omit=dev` is already clean after the exceljs `archiver@8` / `unzipper@0.12.5` overrides on PR #1314; this item is eslint-dev cleanup only. | session 2026-07-28 brace-expansion triage (PR #1314) | 2026-07-28 | +| #091 | P2 | issue | Results band cannot express a partial-source failure | **Outcome:** a favourites/results count is never asserted while some of its sources failed to load. **Detail:** `SearchResultsBandStatus` is a single flat status, so a page fed by several registries has no way to say "3 loaded, 1 failed". `saved-registry-favourites-status.ts:29` works around this with `itemCount > 0 && folded !== "ready" ? "ready" : folded`, and `favourites-command-library-page.tsx` applies the same mask a second time — so a partial failure renders a confident count with no fault indication, which is the exact defect class PR #1316 removed everywhere else. Neither favourites page consumes the true `registryStatus` the fold already returns. **Next:** decide between (a) a `partial` arm on the status union carrying a "some sources unavailable" note beside an honest count, or (b) surfacing `registryStatus` as a separate inline notice and dropping the mask. **Stop:** do not widen the mask to more surfaces before the shape is decided. | PR #1316 review thread PRRT_kwDOSh5Fis6UUf-k; session 2026-07-28 | 2026-07-28 | +| #092 | P3 | task | Refetch pulse deferred on auth-backed registries (privacy invariant) | **Outcome:** a background refresh keeps the prior count visible instead of a skeleton, without weakening identity clearing. **Detail:** the `refetching` status is built in the band and adopted only on `formulation-home-page.tsx`, where the lag is `useDeferredValue` over static data. It is deliberately NOT adopted on `use-registry-records.ts:85`, `use-medication-catalog.ts:76` or `use-differential-catalog.ts:133`, which all clear data on entering loading. `use-differential-catalog.ts:122` states why: "Auth must clear prior identity's matches immediately", and `:164` that "a later retype of any prior query cannot resurrect authorized matches." **Next:** if adopted, guard preservation on identity AND query equality, and pin with a test that an identity change still clears immediately. **Stop:** never hold records across an auth transition. | PR #1316 plan phase 6; session 2026-07-28 | 2026-07-28 | +| #093 | P2 | issue | Next streaming `S:` clone causes Playwright strict-mode violations under CI load | **Outcome:** duplicate-element strict-mode failures stop appearing on loaded CI runs. **Detail:** under full-suite CI load Next.js leaves a hidden duplicate page root in the stream, so a `getByTestId` that is unique locally resolves to 2 elements in CI (seen as `differentials-search-results` on PR #1316, and previously noted on PR #1294 against main). It does not reproduce in isolation, on a single spec, or locally. The documented workaround is to scope the locator to the visible root. **Reproduced locally 2026-07-28** (isolated _production_ build via `run-playwright.mjs`, full `verify:ui`): `ui-tools.spec.ts:563` duplicated `forms-home` and `ui-smoke.spec.ts:3001` duplicated `favourite-row-lithium-monitoring-guideline`; in both, copy 1 is nested under `mobile-composer-reserve-pad`. Both pass when run alone, so it is load/order-dependent, not build-mode dependent — this also corrects an earlier note that CI uses `next dev`; it does not. **Strongest evidence (CI run `30345484316`, 2026-07-28): `ui-overlap.spec.ts:199` on `/` asserted `toHaveCount(1)` successfully and then the same `header#search` locator resolved to 2 a statement later, one of them hidden.** A duplicate that appears _after_ a passing count assertion is a stream/hydration artifact by construction, not a static double mount and not something a CSS or component change can cause. That makes four distinct testids across four specs with the identical shape. **Next:** with a full-suite repro now available, bisect the preceding specs to find the state that triggers the second mount, then either scope the shared helpers to the visible root once or fix the mount. **Stop:** do not paper over new occurrences with `.first()` before the duplicate itself is explained. | PR #1316 CI runs; PR #1294 note on main; session 2026-07-28 | 2026-07-28 | +| #094 | P2 | rec | Design-system gates assert structure, not rendered effect | **Outcome:** a style contract cannot pass while the style is inert. **Detail:** PR #1316's accent rail shipped inert because `.search-band` sat in `@layer components`, which loses to Tailwind's utilities layer regardless of specificity — and the test asserted `toHaveClass("search-band")`, i.e. class presence, not effect. Computed style showed `1px rgb(229,231,235)` where `2px rgb(11,111,134)` was intended. The same shape of gap let a rail-colour assertion compare a colour against a width and pass unconditionally. **Next:** for contracts where the visual IS the requirement (rails, forced-colors thickness, tap targets), assert `getComputedStyle` in a Playwright case rather than class names in a DOM test, and add the unlayered-component convention to the design-system contract check. **Stop:** do not convert existing passing DOM tests wholesale; add computed-style proof only where the effect carries the meaning. | PR #1316 Codex P2 finding; session 2026-07-28 | 2026-07-28 | +| #095 | P3 | issue | `PR required` reports failure for concurrency-cancelled jobs | **Outcome:** a red `PR required` means a real failure. **Detail:** the aggregate calls `require_success` on `coverage`/`production-ui`, so any push that supersedes an in-flight run reports `coverage result was cancelled` → exit 1, indistinguishable at a glance from a genuine failure. Eleven such reds were produced on PR #1316 in one session, and `Production UI` never once ran to completion. **Next:** in `.github/workflows/ci.yml`, either treat `cancelled` distinctly from `failure` in the aggregate, or reduce push frequency against long UI runs. **Stop:** do not relax `require_success` for genuine failures while doing so. | PR #1316 runs 30340972329 / 30341225585; session 2026-07-28 | 2026-07-28 | ## Resolved / archive From 7abc91f19d7f799e586f130be8769caa9ef0d0a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 09:43:19 +0000 Subject: [PATCH 29/30] fix(documents): report a failed registry once on the record path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The band's fault panel now owns error/unauthorized for the services and forms registry, but RecordRegistryNotice still rendered underneath it, so one failed request produced two adjacent panels saying much the same thing. This is the double-reporting already removed from the standalone services and forms pages, missed on the dashboard path. Loading is deliberately left to the notice: the band only says "Searching…" there and never names the registry, so suppressing it would lose information rather than duplicate it. Checked the test catches the defect rather than merely passing — with the guard removed both fault cases fail on the duplicate copy while the loading case still passes, confirming the suppression is not too broad. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- .../document-search-results.tsx | 8 +- .../document-search-record-fault.dom.test.tsx | 75 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/document-search-record-fault.dom.test.tsx diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index 4f01a5c601..c972fa95f8 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -870,6 +870,12 @@ function DocumentSearchResultsPanelImpl({ deployedClinicalKb: isDeployedClinicalKb(), }); const unavailableMessage = unavailable?.message ?? null; + // On the record path the band's fault panel now reports a failed registry, so + // RecordRegistryNotice would repeat that verbatim two lines below — the same + // double-reporting removed from the standalone services/forms pages. Loading + // is still the notice's to own: the band only says "Searching…" there. + const recordBandOwnsFault = + showRecordMatches && (recordStatus === "error" || recordStatus === "not_found" || recordStatus === "unauthorized"); const showResultsControls = matches.length > 0 && !loading; const showIdentityHeader = recordMatchCount > 0 || @@ -971,7 +977,7 @@ function DocumentSearchResultsPanelImpl({ {showRecordMatches ? ( <> - + {recordBandOwnsFault ? null : } ) : null} diff --git a/tests/document-search-record-fault.dom.test.tsx b/tests/document-search-record-fault.dom.test.tsx new file mode 100644 index 0000000000..49c437ea2b --- /dev/null +++ b/tests/document-search-record-fault.dom.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { DocumentSearchResultsPanel } from "@/components/clinical-dashboard/document-search-results"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + prefetch: vi.fn(), + }), + useSearchParams: () => new URLSearchParams(), + usePathname: () => "/services", +})); + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => ({ + status: "signed_out", + session: null, + isConfigured: true, + authorizationHeader: () => null, + registerAuthRequest: vi.fn(), + isAuthEpochCurrent: () => true, + markSessionExpired: vi.fn(), + }), +})); + +const baseProps = { + matches: [], + recordMatches: [], + recordMode: "services" as const, + showRecordMatches: true, + query: "crisis", + loading: false, + documentCount: 0, + realDataReady: true, + authUnavailable: false, + apiUnavailable: false, + setupWarning: null, + onScopeDocument: vi.fn(), + onAnswerFromDocument: vi.fn(), + onOpenRecentDocuments: vi.fn(), + onOpenLibrary: vi.fn(), + onOpenSourcePdf: vi.fn(), + onTagSearch: vi.fn(), +}; + +describe("document search record path fault reporting", () => { + it("reports a failed registry once, not twice", () => { + render(); + + // The band owns the failure. The legacy notice repeating "Couldn't load the + // services registry" underneath it is the double-report this guards. + expect(screen.getAllByRole("alert")).toHaveLength(1); + expect(screen.queryByText(/Couldn't load the services registry/i)).toBeNull(); + }); + + it("still reports an expired session once", () => { + render(); + + expect(screen.getAllByRole("alert")).toHaveLength(1); + expect(screen.queryByText(/search your private services registry/i)).toBeNull(); + }); + + it("keeps the loading notice, which the band does not duplicate", () => { + // The band only says "Searching…" while loading, so this notice is the only + // thing naming the registry — suppressing it here would lose information. + render(); + + expect(screen.getByText(/Loading your services registry/i)).toBeInTheDocument(); + }); +}); From 861a351805671809626f8c33ecec20ca77489c03 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 09:55:44 +0000 Subject: [PATCH 30/30] fix(account): treat a 401 favourites load as session expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The save and clear paths already call markSessionExpired() on a 401 (lines 177, 200); the load path only stored a generic message. That gap was invisible until this branch added reload and wired Retry to it — Retry re-sends the same rejected authorization header, so an expired token produced a button that could never recover. A control that advertises an action must perform one. markSessionExpired is depended on by identity rather than reached through auth, which would re-run the load effect on every auth-object render; it is a useCallback upstream, so it stays stable. Two tests, both checked against the unfixed code: the 401 case fails without the change, and a 503 case pins that a transient server fault still leaves a signed-in reader signed in rather than bouncing them to a sign-in screen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Akwz3Sdms8uJ5AkDt3CduY --- src/components/account-data-provider.tsx | 12 +++++- tests/favourites-account-retry.dom.test.tsx | 43 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/components/account-data-provider.tsx b/src/components/account-data-provider.tsx index 8e04a0e0de..df34725688 100644 --- a/src/components/account-data-provider.tsx +++ b/src/components/account-data-provider.tsx @@ -79,6 +79,10 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { // exactly the same clearing and abort semantics as an auth transition. const [reloadAttempt, setReloadAttempt] = useState(0); const reload = useCallback(() => setReloadAttempt((attempt) => attempt + 1), []); + // Depended on by identity rather than through `auth`, which would re-run the + // load effect on every auth-object render. It is a useCallback upstream, so + // this stays stable. + const markSessionExpired = auth.markSessionExpired; useEffect(() => { if (auth.status !== "authenticated") { @@ -109,6 +113,12 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { }) .then(async (response) => { const payload = await response.json().catch(() => ({})); + // A rejected token cannot be recovered by re-sending it, so an expired + // session has to change the auth state rather than only the error text. + // Retry now exists on this path; without this it would reissue the same + // 401 forever instead of routing the reader to sign in. The mutation + // paths below already do this. + if (response.status === 401) markSessionExpired(); if (!response.ok) throw new Error(payload.message ?? payload.error ?? "Saved items could not be loaded."); setFavourites(normalizedFavourites(payload.favourites)); setLoadError(null); @@ -125,7 +135,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { }); return () => controller.abort(); - }, [auth.authEpoch, auth.authorizationHeader, auth.status, reloadAttempt]); + }, [auth.authEpoch, auth.authorizationHeader, auth.status, markSessionExpired, reloadAttempt]); const setFavourite = useCallback( async (contentType: FavouriteContentType, contentKey: string, saved: boolean) => { diff --git a/tests/favourites-account-retry.dom.test.tsx b/tests/favourites-account-retry.dom.test.tsx index eff7737eee..245894a9b4 100644 --- a/tests/favourites-account-retry.dom.test.tsx +++ b/tests/favourites-account-retry.dom.test.tsx @@ -67,6 +67,9 @@ function FirstSaveProbe() { describe("favourites account retry", () => { beforeEach(() => { vi.restoreAllMocks(); + // Hoisted vi.fn()s keep their call history across restoreAllMocks, so the + // "not called" assertion below would inherit an earlier test's call. + authSession.markSessionExpired.mockClear(); authSession.status = "authenticated"; }); @@ -107,6 +110,46 @@ describe("favourites account retry", () => { expect(screen.getByTestId("count")).toHaveTextContent("1"); }); + it("marks the session expired when the account load is rejected as 401", async () => { + // Retry re-sends the same authorization header, so a rejected token can + // never be recovered by retrying. The load path has to change the auth + // state and route the reader to sign in, as the mutation paths already do. + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ message: "Session expired." }), + }); + vi.stubGlobal("fetch", fetchMock); + + render( + + + , + ); + + await waitFor(() => expect(authSession.markSessionExpired).toHaveBeenCalled()); + }); + + it("does not mark the session expired for a non-auth load failure", async () => { + // A 503 is exactly what Retry is for; flipping the session on it would send + // a signed-in reader to a sign-in screen over a transient server fault. + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({ message: "Saved items could not be loaded." }), + }); + vi.stubGlobal("fetch", fetchMock); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByTestId("status")).toHaveTextContent("error")); + expect(authSession.markSessionExpired).not.toHaveBeenCalled(); + }); + it("keeps a successfully loaded empty library ready after a failed first save", async () => { const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const method = (init?.method ?? "GET").toUpperCase();