diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 0b9bd1dd4..2f3073164 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; /** * Last-resort boundary for the App Router. Unlike `app/error.tsx`, this replaces @@ -11,8 +11,10 @@ import { useEffect } from "react"; * still renders correctly when the styling/theming system is exactly what failed. */ export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + const headingRef = useRef(null); useEffect(() => { console.error("Fatal error captured by global-error boundary:", error); + headingRef.current?.focus(); }, [error]); return ( @@ -25,8 +27,8 @@ export default function GlobalError({ error, reset }: { error: Error & { digest? alignItems: "center", justifyContent: "center", padding: "1rem", - backgroundColor: "#f4f5f7", - color: "#1a1c1e", + backgroundColor: "Canvas", + color: "CanvasText", fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"', }} @@ -37,15 +39,27 @@ export default function GlobalError({ error, reset }: { error: Error & { digest? maxWidth: "28rem", boxSizing: "border-box", borderRadius: "1rem", - border: "1px solid #e2e4e8", - backgroundColor: "#ffffff", + border: "1px solid CanvasText", + backgroundColor: "Canvas", padding: "1.5rem", textAlign: "center", - boxShadow: "0 10px 30px rgba(15, 23, 42, 0.12)", + boxShadow: "0 10px 30px color-mix(in srgb, CanvasText 12%, transparent)", }} > -

Something went wrong

-

+

+ Something went wrong +

+

The application failed to load. Please try again, or reload the page if the problem persists.

{error.digest && ( @@ -53,11 +67,11 @@ export default function GlobalError({ error, reset }: { error: Error & { digest? style={{ margin: "0 0 1.25rem", borderRadius: "0.5rem", - backgroundColor: "#f4f5f7", + backgroundColor: "Canvas", padding: "0.5rem", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", fontSize: "0.75rem", - color: "#5b6069", + color: "CanvasText", wordBreak: "break-all", }} > @@ -72,8 +86,8 @@ export default function GlobalError({ error, reset }: { error: Error & { digest? cursor: "pointer", borderRadius: "0.5rem", border: "none", - backgroundColor: "#1a1c1e", - color: "#ffffff", + backgroundColor: "ButtonFace", + color: "ButtonText", padding: "0.625rem 1rem", fontSize: "0.875rem", fontWeight: 600, @@ -87,9 +101,9 @@ export default function GlobalError({ error, reset }: { error: Error & { digest? style={{ cursor: "pointer", borderRadius: "0.5rem", - border: "1px solid #e2e4e8", - backgroundColor: "#ffffff", - color: "#1a1c1e", + border: "1px solid ButtonText", + backgroundColor: "ButtonFace", + color: "ButtonText", padding: "0.625rem 1rem", fontSize: "0.875rem", fontWeight: 600, diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 138dee3ff..2e5b7b7b5 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -33,11 +33,11 @@ import { appBackdrop, answerSurface, cn, + EmptyState, floatingControl, InlineNotice, primaryControl, textMuted, - toneInfo, } from "@/components/ui-primitives"; import { useAuthSession } from "@/lib/supabase/client"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; @@ -3598,100 +3598,90 @@ export function ClinicalDashboard({ {activeModeSearch.resultHeading} {answerLifecycle.status === "cancelled" && activeModeResultKind === "answer" ? ( -
-
-

Generation stopped

-

No partial clinical answer was kept.

-
- -
- ) : error && errorKind === "no-results" && activeModeResultKind === "answer" ? ( -
-
-
-
+ focusComposerInput()} className={cn(primaryControl, "text-xs")} + onClick={() => void ask(answerLifecycle.query ?? query)} > - {answerRecovery.rephrase} +
-
- ) : error ? ( -
-
-
- {activeModeResultKind === "answer" && lastFailedQuery && ( -
+ } + /> + ) : error && errorKind === "no-results" && activeModeResultKind === "answer" ? ( + -
- )} -
+ + } + /> + ) : error ? ( + + + + + ) : undefined + } + /> ) : null} {searchMode !== "prescribing" && diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 38fe86010..05f21b300 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; import { CircleAlert, @@ -64,7 +65,6 @@ import { } from "@/components/ui-primitives"; import { BadgeCluster } from "@/components/clinical-dashboard/clinical-badge"; import { SignedImage } from "@/components/clinical-dashboard/signed-image"; -import { NativePdfEmbed, PdfCanvasViewer } from "@/components/document-viewer/pdf-canvas-viewer"; import { NonPdfSourcePreview } from "@/components/document-viewer/non-pdf-source-preview"; import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; @@ -105,6 +105,34 @@ import { buildDocumentSummaryBadges } from "@/lib/document-summary-badges"; import { documentSummaryQuestion } from "@/lib/answer-contract"; import type { DocumentDetailPayload } from "@/lib/document-detail-contract"; +// pdf-canvas-viewer is only needed after a source document has loaded and the +// user is viewing a PDF. Keeping it out of the document route's initial client +// chunk avoids parsing its reader controls for image, text, and download-only +// documents. pdf.js itself remains loaded on demand by that component. +const PdfCanvasViewer = dynamic( + () => import("@/components/document-viewer/pdf-canvas-viewer").then((module) => module.PdfCanvasViewer), + { + ssr: false, + loading: () => , + }, +); +const NativePdfEmbed = dynamic( + () => import("@/components/document-viewer/pdf-canvas-viewer").then((module) => module.NativePdfEmbed), + { ssr: false, loading: () => }, +); + +function PdfPreviewLoading() { + return ( +
+ Loading PDF reader… +
+ ); +} + type PageRow = { id: string; page_number: number; diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index f47cc51ba..25756904f 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -13,6 +13,7 @@ import { Heart, MoreVertical, Pill, + Pin, Quote, Search, ShieldCheck, @@ -24,6 +25,7 @@ import { useMemo, useRef, useState } from "react"; import { FavouritesMobileBrowseRail, + FavouritesMobileQuickViews, FavouritesSidebar, useFavouritesNavCollapsed, type FavouritesViewMode, @@ -237,7 +239,15 @@ function filterAndSortItems( }); } -function MiniIconTile({ icon: Icon, active = false }: { icon: LucideIcon; active?: boolean }) { +function MiniIconTile({ + icon: Icon, + active = false, + className, +}: { + icon: LucideIcon; + active?: boolean; + className?: string; +}) { return ( @@ -457,7 +468,7 @@ function FavouriteMobileCard({
onSelect(item.id)} aria-pressed={selected} - aria-label={`Select ${item.title}`} - className={cn("absolute inset-0 cursor-pointer rounded-lg", focusRing)} + aria-label={item.pinned ? `Select ${item.title}, pinned` : `Select ${item.title}`} + className={cn("absolute inset-0 cursor-pointer rounded-xl", focusRing)} />
-

- {item.title} -

-

- {item.description} -

-
+
+ +
+
+ {item.pinned ? ( + + ) : null} +

+ {item.title} +

+
+

+ {item.description} +

+
+
+ +
{item.type} {isSourceBacked(item) ? ( + Source-backed ) : null}
-
-
-
-
- - Set -
-
{item.set}
-
-
-
Last used
-
{item.lastUsed}
+
+ + + {item.set} + + + · + + {item.lastUsed}
-
+
+ Open @@ -553,11 +577,21 @@ function FavouritesTable({ return rows.filter((item) => favouriteMatchesCommandScopes(item, commandScopes)); }, [commandScopes, items, searchTerm, selectedSet, selectedTypeId, viewMode, sortMode]); + // With the item workspace open (only at 2xl), the middle column narrows sharply. + // Drop the leading icon and the secondary Evidence column there so titles keep + // room instead of collapsing to a couple of characters. + const compact = Boolean(selectedItemId); + const rowIconClass = compact ? "hidden" : "hidden 2xl:grid"; + const evidenceHeadClass = cn("hidden px-3", compact ? "" : "w-[7rem] 2xl:table-cell"); + const evidenceCellClass = cn("hidden px-3 align-middle", compact ? "" : "2xl:table-cell"); + return ( -
-
-

- {tableRows.length} {tableRows.length === 1 ? "item" : "items"} +

+
+

+ + {tableRows.length} + {tableRows.length === 1 ? "item" : "items"} {tableRows.length !== items.length ? ` of ${items.length}` : ""}

@@ -583,25 +617,25 @@ function FavouritesTable({
- +
- - + - - - - - @@ -620,18 +654,32 @@ function FavouritesTable({ "bg-[color:var(--clinical-accent-soft)]/45 shadow-[inset_3px_0_0_var(--clinical-accent)]", )} > - @@ -644,14 +692,22 @@ function FavouritesTable({ {item.set} - - {/* The Evidence column is hidden below lg, so span 5 there and 6 at lg+. */} + {/* The Evidence column is hidden below 2xl, so span 5 there and 6 at 2xl+. */} {[ - { colSpan: 5, className: "px-4 py-10 text-center lg:hidden" }, - { colSpan: 6, className: "hidden px-4 py-10 text-center lg:table-cell" }, + { colSpan: 5, className: "px-4 py-10 text-center 2xl:hidden" }, + { colSpan: 6, className: "hidden px-4 py-10 text-center 2xl:table-cell" }, ].map(({ colSpan, className }) => (
+
Item + Type + Set + Evidence + Last used + Action
+ - - - {item.evidence} - + + {isSourceBacked(item) ? ( + + + {item.evidence} + + ) : ( + + {item.evidence} + + )} - {item.lastUsed} + + {item.lastUsed} + event.stopPropagation()}>
@@ -672,10 +728,10 @@ function FavouritesTable({ })} {tableRows.length === 0 ? (
@@ -980,7 +1036,7 @@ export function FavouritesCommandLibraryPage({ query = "" }: { query?: string }) onSelectViewMode={setViewMode} />
-
+
@@ -1007,6 +1063,24 @@ export function FavouritesCommandLibraryPage({ query = "" }: { query?: string })
+ + + + )} - -
{selectedItem ? setSelectedItemId(null)} /> : null} diff --git a/src/components/clinical-dashboard/favourites-library-nav.tsx b/src/components/clinical-dashboard/favourites-library-nav.tsx index 64a97e28c..2cd6da4a1 100644 --- a/src/components/clinical-dashboard/favourites-library-nav.tsx +++ b/src/components/clinical-dashboard/favourites-library-nav.tsx @@ -377,6 +377,123 @@ function SetBrowseCard({ ); } +/** + * Surfaces the sidebar's primary quick views (All / Source-backed / Pinned / + * Recently used) as a horizontally scrollable chip rail below `lg`, where the + * full sidebar is hidden. Keeps small-screen navigation at parity with desktop. + */ +export function FavouritesMobileQuickViews({ + items, + selectedSetId, + selectedTypeId, + viewMode, + onSelectSet, + onSelectType, + onSelectViewMode, +}: Pick< + FavouritesNavProps, + "items" | "selectedSetId" | "selectedTypeId" | "viewMode" | "onSelectSet" | "onSelectType" | "onSelectViewMode" +>) { + const sourceBackedCount = items.filter(isSourceBacked).length; + const pinnedCount = items.filter((item) => item.pinned).length; + const allActive = !selectedSetId && viewMode === "all" && selectedTypeId === "all"; + + const chips: Array<{ + id: string; + icon: LucideIcon; + label: string; + count: number; + active: boolean; + onClick: () => void; + }> = [ + { + id: "all", + icon: LayoutGrid, + label: "All", + count: items.length, + active: allActive, + onClick: () => { + onSelectViewMode("all"); + onSelectSet(null); + onSelectType("all"); + }, + }, + { + id: "source-backed", + icon: ShieldCheck, + label: "Source-backed", + count: sourceBackedCount, + active: viewMode === "source-backed", + onClick: () => { + onSelectSet(null); + onSelectViewMode(viewMode === "source-backed" ? "all" : "source-backed"); + }, + }, + { + id: "pinned", + icon: Pin, + label: "Pinned", + count: pinnedCount, + active: viewMode === "pinned", + onClick: () => { + onSelectSet(null); + onSelectViewMode(viewMode === "pinned" ? "all" : "pinned"); + }, + }, + { + id: "recent", + icon: Search, + label: "Recently used", + count: items.length, + active: viewMode === "recent", + onClick: () => { + onSelectSet(null); + onSelectViewMode(viewMode === "recent" ? "all" : "recent"); + }, + }, + ]; + + return ( +
+
+
+ {chips.map((chip) => { + const Icon = chip.icon; + return ( + + ); + })} +
+
+
+ ); +} + export function FavouritesMobileBrowseRail({ sets, selectedSetId, diff --git a/src/components/route-error-boundary.tsx b/src/components/route-error-boundary.tsx index ae9957733..6d31de18e 100644 --- a/src/components/route-error-boundary.tsx +++ b/src/components/route-error-boundary.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { TriangleAlert, RefreshCw } from "lucide-react"; import { cn, primaryControl } from "@/components/ui-primitives"; @@ -38,8 +38,10 @@ export function RouteErrorBoundary({ showReload = false, minHeightClass = "min-h-[50vh]", }: RouteErrorBoundaryProps) { + const headingRef = useRef(null); useEffect(() => { console.error(logLabel, error); + headingRef.current?.focus(); }, [error, logLabel]); return ( @@ -54,7 +56,13 @@ export function RouteErrorBoundary({
-

{title}

+

+ {title} +

{description}

diff --git a/src/components/therapy-compass/bindings.tsx b/src/components/therapy-compass/bindings.tsx index 7cf2d999e..3d4e59e65 100644 --- a/src/components/therapy-compass/bindings.tsx +++ b/src/components/therapy-compass/bindings.tsx @@ -24,6 +24,7 @@ export type TcBindings = { // ---- data ----------------------------------------------------------- loading: boolean; error: string | null; + retryData: () => void; therapies: Therapy[]; unreviewedTherapies: Therapy[]; reviewCount: number; @@ -207,7 +208,7 @@ export function TcProvider({ initialQuery?: string; autoRunSearch?: boolean; }) { - const { data, loading, error } = useTherapyData(); + const { data, loading, error, retry } = useTherapyData(); const therapies = useMemo(() => data?.therapies ?? [], [data]); const pathways = useMemo(() => data?.pathways ?? [], [data]); @@ -271,6 +272,7 @@ export function TcProvider({ return { loading, error, + retryData: retry, therapies, unreviewedTherapies, reviewCount: unreviewedTherapies.length, @@ -429,6 +431,7 @@ export function TcProvider({ }, [ loading, error, + retry, data, therapies, unreviewedTherapies, diff --git a/src/components/therapy-compass/data/use-therapy-data.ts b/src/components/therapy-compass/data/use-therapy-data.ts index cecce45ad..f04d4a702 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 { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { Pathway, ReferenceData, Therapy, TherapyDataset } from "./types"; @@ -32,28 +32,39 @@ export type TherapyDataState = { data: TherapyDataset | null; loading: boolean; error: string | null; + retry: () => void; }; export function useTherapyData(): TherapyDataState { - const [state, setState] = useState({ data: null, loading: true, error: null }); + const [state, setState] = useState>({ data: null, loading: true, error: null }); + const [attempt, setAttempt] = useState(0); + const retry = useCallback(() => { + cache = null; + // 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); + }, []); useEffect(() => { let active = true; cache ??= loadDataset(); - cache + const request = cache; + request .then((data) => { if (active) setState({ data, loading: false, error: null }); }) .catch((err: unknown) => { - // Allow a retry on the next mount if the fetch failed. - cache = null; + // 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 === request) cache = null; if (active) setState({ data: null, loading: false, error: err instanceof Error ? err.message : "Failed to load" }); }); return () => { active = false; }; - }, []); + }, [attempt]); - return state; + return { ...state, retry }; } diff --git a/src/components/therapy-compass/therapy-card.tsx b/src/components/therapy-compass/therapy-card.tsx index e48cb47c1..102d78c73 100644 --- a/src/components/therapy-compass/therapy-card.tsx +++ b/src/components/therapy-compass/therapy-card.tsx @@ -86,9 +86,11 @@ export function ResultCard({ therapy }: { therapy: Therapy }) { + + ) : ( + <> + {b.isHome && } + {b.isSearch && } + {b.isDetail && } + {b.isCompare && } + {b.isRecommend && } + {b.isPathways && } + {b.isBrief && } + {b.isSheets && } + {b.isOther && } + + )} diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx index 7c736d6bb..79effd296 100644 --- a/src/components/ui-primitives.tsx +++ b/src/components/ui-primitives.tsx @@ -426,18 +426,51 @@ export function LoadingPanel({ ); } -export function EmptyState({ icon: Icon, title, body }: { icon?: IconComponent; title: string; body: string }) { +export function EmptyState({ + icon: Icon, + title, + body, + actions, + live, + tone = "neutral", + testId, +}: { + icon?: IconComponent; + title: string; + body: string; + /** Optional controls stay within the shared state surface rather than becoming a second panel. */ + actions?: ReactNode; + /** Announce a state transition only when the state is introduced dynamically. */ + live?: "polite" | "assertive"; + tone?: "neutral" | "info" | "danger"; + testId?: string; +}) { return ( -
+
{Icon && ( - + )}

{title}

{body}

+ {actions ?
{actions}
: null}
diff --git a/tests/document-viewer-pdf-reader-lazy.test.ts b/tests/document-viewer-pdf-reader-lazy.test.ts new file mode 100644 index 000000000..cca001374 --- /dev/null +++ b/tests/document-viewer-pdf-reader-lazy.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const viewerSource = readFileSync( + fileURLToPath(new URL("../src/components/DocumentViewer.tsx", import.meta.url)), + "utf8", +); + +describe("DocumentViewer PDF reader loading", () => { + it("keeps both PDF reader exports out of the document route's initial client chunk", () => { + expect(viewerSource).not.toMatch( + /import\s*\{[^}]*\b(?:NativePdfEmbed|PdfCanvasViewer)\b[^}]*\}\s*from\s*["']@\/components\/document-viewer\/pdf-canvas-viewer["']/, + ); + + expect(viewerSource).toContain("const PdfCanvasViewer = dynamic("); + expect(viewerSource).toContain( + '() => import("@/components/document-viewer/pdf-canvas-viewer").then((module) => module.PdfCanvasViewer)', + ); + expect(viewerSource).toContain("const NativePdfEmbed = dynamic("); + expect(viewerSource).toContain( + '() => import("@/components/document-viewer/pdf-canvas-viewer").then((module) => module.NativePdfEmbed)', + ); + + const canvasBlock = viewerSource.slice( + viewerSource.indexOf("const PdfCanvasViewer = dynamic("), + viewerSource.indexOf("const NativePdfEmbed = dynamic("), + ); + const nativeBlock = viewerSource.slice( + viewerSource.indexOf("const NativePdfEmbed = dynamic("), + viewerSource.indexOf("function PdfPreviewLoading"), + ); + expect(canvasBlock).toContain("ssr: false"); + expect(nativeBlock).toContain("ssr: false"); + expect(canvasBlock).toContain("loading: () => "); + expect(nativeBlock).toContain("loading: () => "); + }); +}); diff --git a/tests/ui-primitives.dom.test.tsx b/tests/ui-primitives.dom.test.tsx new file mode 100644 index 000000000..4a380b2f8 --- /dev/null +++ b/tests/ui-primitives.dom.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { Search } from "lucide-react"; +import { describe, expect, it } from "vitest"; + +import { EmptyState } from "@/components/ui-primitives"; + +describe("EmptyState", () => { + it("keeps recovery actions inside an announced state surface", () => { + render( + Rephrase question} + />, + ); + + const state = screen.getByTestId("recovery-state"); + expect(state).toHaveAttribute("role", "status"); + expect(screen.getByRole("button", { name: "Rephrase question" })).toBeVisible(); + }); + + it("uses an assertive announcement for a dynamic failure", () => { + render(); + + expect(screen.getByRole("alert")).toHaveTextContent("Answer unavailable"); + }); +});