+
+ canSwitchMode && setModeSelectorOpen((current) => !current)}
+ className="mode-action-mode-button"
+ >
+
+
+
+ {title}
+ {canSwitchMode ? (
+
+ ) : null}
+
+ {modeSelectorOpen && modeOptions?.length ? (
+
+ ) : null}
-
-
-
- {headerSubtitle}
-
+
+
+ {headerSubtitle}
@@ -367,7 +581,7 @@ export function ModeActionPopup({
data-testid="daily-actions-menu"
role="menu"
aria-label={title}
- className={cn("p-2.5", integrated && "p-3 sm:p-3.5")}
+ className={cn("mode-action-body polished-scroll p-2.5", integrated && "p-3 sm:p-3.5")}
>
{items.map((item, index) => {
@@ -400,14 +614,17 @@ export function ModeActionPopup({
{!integrated ? (
<>
-
-
+ {placement === "up" ? (
+
+ ) : (
+
+ )}
>
) : null}
@@ -429,7 +646,13 @@ export function ModeActionPopup({
title={buttonLabel}
onKeyDown={handleTriggerKeyDown}
onClick={() => {
- if (!open) onBeforeOpen?.();
+ if (!open) {
+ onBeforeOpen?.();
+ setModeSelectorOpen(false);
+ updatePlacement();
+ } else {
+ setModeSelectorOpen(false);
+ }
onOpenChange(!open);
}}
>
diff --git a/src/components/clinical-dashboard/use-sidebar-collapsed.ts b/src/components/clinical-dashboard/use-sidebar-collapsed.ts
index 86d3d3894..daf22d938 100644
--- a/src/components/clinical-dashboard/use-sidebar-collapsed.ts
+++ b/src/components/clinical-dashboard/use-sidebar-collapsed.ts
@@ -7,14 +7,15 @@ const changeEvent = "clinical-kb-sidebar-collapsed-change";
function getSnapshot() {
try {
- return window.localStorage.getItem(storageKey) === "1";
+ const storedValue = window.localStorage.getItem(storageKey);
+ return storedValue === null ? true : storedValue === "1";
} catch {
- return false;
+ return true;
}
}
function getServerSnapshot() {
- return false;
+ return true;
}
function subscribe(onChange: () => void) {
diff --git a/src/components/favourites-page-mockups/favourites-library-redesign-page.tsx b/src/components/favourites-page-mockups/favourites-library-redesign-page.tsx
new file mode 100644
index 000000000..ddde9e3ab
--- /dev/null
+++ b/src/components/favourites-page-mockups/favourites-library-redesign-page.tsx
@@ -0,0 +1,843 @@
+import Link from "next/link";
+import {
+ ArrowRight,
+ BookOpen,
+ CheckCircle2,
+ ChevronDown,
+ Clock3,
+ Copy,
+ ExternalLink,
+ FileText,
+ Filter,
+ Folder,
+ Heart,
+ History,
+ LayoutList,
+ Library,
+ MessageSquare,
+ MoreHorizontal,
+ Pill,
+ Pin,
+ Plus,
+ Quote,
+ Search,
+ ShieldCheck,
+ Sparkles,
+ Stethoscope,
+ type LucideIcon,
+} from "lucide-react";
+
+import { cn } from "@/components/ui-primitives";
+
+export type FavouritesLibraryRedesignVariant = "command-console" | "review-console" | "set-navigator";
+
+type FavouriteKind = "Medication" | "Document" | "Source" | "Saved search";
+type ReviewState = "current" | "review-due" | "recent";
+
+type FavouriteRecord = {
+ id: string;
+ title: string;
+ kind: FavouriteKind;
+ set: string;
+ summary: string;
+ provenance: string;
+ lastUsed: string;
+ action: string;
+ href: string;
+ reviewState: ReviewState;
+ reviewLabel: string;
+ icon: LucideIcon;
+};
+
+type FavouriteSet = {
+ title: string;
+ count: number;
+ summary: string;
+ lastUsed: string;
+ reviewDue: number;
+ icon: LucideIcon;
+};
+
+const focusRing =
+ "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0e7490]";
+
+const favouriteRecords: FavouriteRecord[] = [
+ {
+ id: "acamprosate-renal-screen",
+ title: "Acamprosate renal screen",
+ kind: "Medication",
+ set: "Ward round",
+ summary: "Medication page / renal cautions / dose notes",
+ provenance: "3 sources",
+ lastUsed: "Today 08:44",
+ action: "Open",
+ href: "/?mode=prescribing&q=acamprosate+renal+dose",
+ reviewState: "current",
+ reviewLabel: "Source-backed",
+ icon: Pill,
+ },
+ {
+ id: "lithium-monitoring-guideline",
+ title: "Lithium monitoring guideline",
+ kind: "Document",
+ set: "Prescribing safety",
+ summary: "PDF pages 4-9 / monitoring table",
+ provenance: "Verified PDF",
+ lastUsed: "Today 08:20",
+ action: "Ask",
+ href: "/?mode=documents&q=lithium+monitoring",
+ reviewState: "review-due",
+ reviewLabel: "Review due",
+ icon: FileText,
+ },
+ {
+ id: "clozapine-monitoring-table",
+ title: "Clozapine monitoring table",
+ kind: "Source",
+ set: "Clozapine clinic",
+ summary: "Saved table / ANC monitoring",
+ provenance: "Table source",
+ lastUsed: "Yesterday",
+ action: "Source",
+ href: "/?mode=documents&q=clozapine+monitoring+table",
+ reviewState: "current",
+ reviewLabel: "Source-backed",
+ icon: Quote,
+ },
+ {
+ id: "renal-dose-search",
+ title: "renal dose saved search",
+ kind: "Saved search",
+ set: "Ward round",
+ summary: "Medicines plus documents / eGFR cautions",
+ provenance: "Query",
+ lastUsed: "Today 07:55",
+ action: "Run",
+ href: "/?mode=answer&q=renal+dose&run=1",
+ reviewState: "recent",
+ reviewLabel: "Recently run",
+ icon: Search,
+ },
+ {
+ id: "qt-prolongation-quote",
+ title: "QT prolongation quote",
+ kind: "Source",
+ set: "Prescribing safety",
+ summary: "Source card / prescribing safety",
+ provenance: "Quote",
+ lastUsed: "Mon",
+ action: "Copy",
+ href: "/?mode=documents&q=QT+prolongation",
+ reviewState: "current",
+ reviewLabel: "Source-backed",
+ icon: Quote,
+ },
+];
+
+const favouriteSets: FavouriteSet[] = [
+ {
+ title: "Ward round",
+ count: 2,
+ summary: "Renal checks, recurring review prompts, and medication pages.",
+ lastUsed: "Today 08:44",
+ reviewDue: 0,
+ icon: Stethoscope,
+ },
+ {
+ title: "Prescribing safety",
+ count: 2,
+ summary: "Dose limits, QT risk, monitoring, and source quotes.",
+ lastUsed: "Today 08:20",
+ reviewDue: 1,
+ icon: ShieldCheck,
+ },
+ {
+ title: "Clozapine clinic",
+ count: 1,
+ summary: "ANC monitoring table, clinic source cards, counselling.",
+ lastUsed: "Yesterday",
+ reviewDue: 0,
+ icon: BookOpen,
+ },
+];
+
+const variants: Record<
+ FavouritesLibraryRedesignVariant,
+ {
+ label: string;
+ eyebrow: string;
+ title: string;
+ description: string;
+ selectedId: string;
+ accent: string;
+ pageClassName: string;
+ commandClassName: string;
+ resumeTitle: string;
+ resumeBody: string;
+ primaryFilter: string;
+ }
+> = {
+ "command-console": {
+ label: "Command console",
+ eyebrow: "Mockup 1",
+ title: "Favourites command library",
+ description: "A scalable library console with the action-first confidence of the command desk.",
+ selectedId: "acamprosate-renal-screen",
+ accent: "#007c89",
+ pageClassName: "bg-[#edf5f6] text-[#102033]",
+ commandClassName: "bg-[#007c89] text-white shadow-[0_14px_34px_rgba(0,124,137,0.24)] hover:bg-[#006c78]",
+ resumeTitle: "Resume Acamprosate renal screen",
+ resumeBody: "Open the ward-round renal caution page with source count, set context, and next action visible.",
+ primaryFilter: "Recent",
+ },
+ "review-console": {
+ label: "Review console",
+ eyebrow: "Mockup 2",
+ title: "Favourites review console",
+ description: "A governance-heavy version for source state, review due work, and selected-item inspection.",
+ selectedId: "lithium-monitoring-guideline",
+ accent: "#1d4ed8",
+ pageClassName: "bg-[#f3f6fb] text-[#0f172a]",
+ commandClassName: "bg-[#1d4ed8] text-white shadow-[0_14px_34px_rgba(29,78,216,0.22)] hover:bg-[#1e40af]",
+ resumeTitle: "Review lithium monitoring guideline",
+ resumeBody: "Check pages 4-9, mark the PDF as reviewed, then ask against the document if needed.",
+ primaryFilter: "Review due",
+ },
+ "set-navigator": {
+ label: "Set navigator",
+ eyebrow: "Mockup 3",
+ title: "Favourites set navigator",
+ description: "A library-first page that makes workflow sets the fastest way into saved material.",
+ selectedId: "renal-dose-search",
+ accent: "#047857",
+ pageClassName: "bg-[#eef6f1] text-[#10231d]",
+ commandClassName: "bg-[#047857] text-white shadow-[0_14px_34px_rgba(4,120,87,0.24)] hover:bg-[#03694c]",
+ resumeTitle: "Run renal dose saved search",
+ resumeBody: "Restart the ward-round saved query across medicines and documents before opening individual items.",
+ primaryFilter: "Ward round",
+ },
+};
+
+const variantRoutes: Array<{ id: FavouritesLibraryRedesignVariant; href: string }> = [
+ { id: "command-console", href: "/mockups/favourites-command-console" },
+ { id: "review-console", href: "/mockups/favourites-review-console" },
+ { id: "set-navigator", href: "/mockups/favourites-set-navigator" },
+];
+
+function statusClassName(state: ReviewState) {
+ if (state === "review-due") return "border-[#f5c56f] bg-[#fff7e8] text-[#9a5a00]";
+ if (state === "recent") return "border-[#b8c7ff] bg-[#eef3ff] text-[#244bc1]";
+ return "border-[#9fd8ce] bg-[#ecfbf7] text-[#006b57]";
+}
+
+function kindClassName(kind: FavouriteKind) {
+ if (kind === "Medication") return "border-[#9fd8ce] bg-[#ecfbf7] text-[#006b57]";
+ if (kind === "Document") return "border-[#b8c7ff] bg-[#eef3ff] text-[#244bc1]";
+ if (kind === "Source") return "border-[#dec2ff] bg-[#f6efff] text-[#6d28a8]";
+ return "border-[#c9d3df] bg-[#f6f8fb] text-[#46586d]";
+}
+
+function ShellPill({
+ children,
+ active = false,
+ icon: Icon,
+}: {
+ children: React.ReactNode;
+ active?: boolean;
+ icon?: LucideIcon;
+}) {
+ return (
+
+ {Icon ? : null}
+ {children}
+
+ );
+}
+
+function IconTile({ icon: Icon, active = false }: { icon: LucideIcon; active?: boolean }) {
+ return (
+
+
+
+ );
+}
+
+function CommandButton({ children, href, className }: { children: React.ReactNode; href: string; className: string }) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function VariantSwitch({ active }: { active: FavouritesLibraryRedesignVariant }) {
+ return (
+
+ {variantRoutes.map((route) => {
+ const config = variants[route.id];
+ const isActive = route.id === active;
+ return (
+
+ {config.eyebrow}: {config.label}
+
+ );
+ })}
+
+ );
+}
+
+function PageHeader({ variant, selected }: { variant: FavouritesLibraryRedesignVariant; selected: FavouriteRecord }) {
+ const config = variants[variant];
+ return (
+
+
+
+
+
+
+
+
+
{config.eyebrow}
+
+ {config.title}
+
+
{config.description}
+
+
+
+
+
+
+
+
+ {selected.action} selected
+
+
+
+ Add favourite
+
+
+
+
+ );
+}
+
+function ResumeBand({ variant, selected }: { variant: FavouritesLibraryRedesignVariant; selected: FavouriteRecord }) {
+ const config = variants[variant];
+ const stats = [
+ { label: "Source-backed", value: "5", icon: CheckCircle2 },
+ { label: "Review due", value: "1", icon: Clock3 },
+ { label: "Pinned sets", value: "3", icon: Pin },
+ { label: "Total items", value: "5", icon: Library },
+ ];
+
+ return (
+
+
+
+
+
+
+
+ Resume next
+
+
+ {selected.reviewLabel}
+
+
+
{config.resumeTitle}
+
{config.resumeBody}
+
+ {selected.set} / {selected.provenance} / {selected.lastUsed}
+
+
+
+ {selected.action}
+
+
+
+ {stats.map((stat) => {
+ const Icon = stat.icon;
+ return (
+
+
+
+
+
+ {stat.value}
+ {stat.label}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+function ReviewQueue({ variant }: { variant: FavouritesLibraryRedesignVariant }) {
+ const dueItem = favouriteRecords.find((item) => item.reviewState === "review-due") ?? favouriteRecords[1];
+ return (
+
+
+
+
Review queue
+
1 item needs confirmation
+
+
+
+
+
+
+
+
+
+
{dueItem.title}
+
+ {dueItem.set} / {dueItem.provenance}
+
+
Clinical review due in 7 days
+
+
+
+
+ Open review queue
+
+
+
+ );
+}
+
+function FilterRail({ variant }: { variant: FavouritesLibraryRedesignVariant }) {
+ const config = variants[variant];
+ const filters = [
+ { label: "All favourites", value: "5", icon: Heart },
+ { label: "Medications", value: "1", icon: Pill },
+ { label: "Documents", value: "1", icon: FileText },
+ { label: "Sources", value: "2", icon: Quote },
+ { label: "Saved searches", value: "1", icon: Search },
+ ];
+
+ return (
+
+
+
Filters
+
+ Reset
+
+
+
+ {filters.map((filter) => {
+ const Icon = filter.icon;
+ const active = filter.label === "All favourites" || filter.label === config.primaryFilter;
+ return (
+
+
+ {filter.label}
+ {filter.value}
+
+ );
+ })}
+
+
+
+
Saved sets
+
+ New set
+
+
+
+ {favouriteSets.map((set) => {
+ const Icon = set.icon;
+ const active = variant === "set-navigator" && set.title === "Ward round";
+ return (
+
+
+
+ {set.title}
+ {set.count} items
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+function SetNavigatorStrip({ variant }: { variant: FavouritesLibraryRedesignVariant }) {
+ if (variant !== "set-navigator") return null;
+
+ return (
+
+ {favouriteSets.map((set) => {
+ const Icon = set.icon;
+ const active = set.title === "Ward round";
+ return (
+
+
+
+
+
+
+ {set.count} items
+
+
+ {set.title}
+ {set.summary}
+
+ Last used {set.lastUsed}
+
+ {set.reviewDue ? `${set.reviewDue} review due` : "No review due"}
+
+
+
+ );
+ })}
+
+ );
+}
+
+function LibraryTable({ variant, selected }: { variant: FavouritesLibraryRedesignVariant; selected: FavouriteRecord }) {
+ const config = variants[variant];
+
+ return (
+
+
+
+
+
All favourites
+ 5 items
+
+
+ Primary action, source state, set context, and review state stay visible.
+
+
+
+
+ {config.primaryFilter}
+
+ Type
+ Table
+
+
+
+
+
+
+
+ Name
+ Type
+ Set
+ Source
+ Action
+
+ {favouriteRecords.map((record) => {
+ const isSelected = record.id === selected.id;
+ const Icon = record.icon;
+ return (
+
+
+
+ {isSelected ? : null}
+
+
+
+
+ {record.title}
+ {record.summary}
+
+
+ {record.reviewLabel}
+
+
+ {record.lastUsed}
+
+
+
+
+ {record.kind}
+
+
{record.set}
+
{record.provenance}
+
+
+ {record.action}
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+function Inspector({ selected, variant }: { selected: FavouriteRecord; variant: FavouritesLibraryRedesignVariant }) {
+ const config = variants[variant];
+ const related = favouriteRecords.filter((item) => item.id !== selected.id).slice(0, 3);
+
+ return (
+
+
+
Selected item
+
+
+
+
+
+
+
{selected.title}
+
+
+ {selected.kind}
+
+
+ {selected.reviewLabel}
+
+
+
+
+
+
+
Source details
+
+ {[
+ ["Set", selected.set],
+ ["Provenance", selected.provenance],
+ ["Last used", selected.lastUsed],
+ ["Next action", selected.action],
+ ].map(([label, value]) => (
+
+
{label}
+ {value}
+
+ ))}
+
+
+
+
+
+
+
+
+ {selected.reviewState === "review-due" ? "Clinical review due" : "Governance note"}
+
+
+ Keep source state visible before opening, asking, copying, or running saved material.
+
+
+
+
+
+
+
+ {selected.action} this item
+
+ {[
+ { label: "Ask a question", icon: MessageSquare },
+ { label: "Open source details", icon: ExternalLink },
+ { label: "Copy citation note", icon: Copy },
+ { label: "Move to set", icon: Folder },
+ ].map((action) => {
+ const Icon = action.icon;
+ return (
+
+
+ {action.label}
+
+ );
+ })}
+
+
+
+
Related items
+
+ {related.map((item) => (
+
+
+
+ {item.title}
+ {item.set}
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+export function FavouritesLibraryRedesignPage({ variant }: { variant: FavouritesLibraryRedesignVariant }) {
+ const config = variants[variant];
+ const selected = favouriteRecords.find((item) => item.id === config.selectedId) ?? favouriteRecords[0];
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/master-document-flow-mockups.tsx b/src/components/master-document-flow-mockups.tsx
new file mode 100644
index 000000000..c74df205c
--- /dev/null
+++ b/src/components/master-document-flow-mockups.tsx
@@ -0,0 +1,1983 @@
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import {
+ ArrowLeft,
+ BarChart3,
+ Bookmark,
+ BookOpen,
+ CheckCircle2,
+ ChevronDown,
+ ChevronRight,
+ Copy,
+ Download,
+ ExternalLink,
+ FileImage,
+ FileText,
+ Filter,
+ ImageIcon,
+ Layers3,
+ List,
+ MessageSquareText,
+ MoreVertical,
+ PanelRight,
+ Quote,
+ Search,
+ Send,
+ ShieldCheck,
+ Sparkles,
+ Table2,
+ type LucideIcon,
+} from "lucide-react";
+import { ReactNode, useMemo, useState } from "react";
+
+import { cn } from "@/components/ui-primitives";
+
+type EvidenceType = "table" | "quote" | "image" | "related";
+
+type EvidenceFixture = {
+ id: string;
+ type: EvidenceType;
+ label: string;
+ title: string;
+ body: string;
+ page: number;
+ section: string;
+ chunk: string;
+ terms: string[];
+ relevance: number;
+ accent: "teal" | "amber" | "blue" | "violet";
+};
+
+type DocumentFixture = {
+ slug: string;
+ title: string;
+ shortTitle: string;
+ source: string;
+ kind: string;
+ version: string;
+ status: "Current" | "Review due";
+ review: string;
+ updated: string;
+ relevance: number;
+ page: number;
+ chunk: string;
+ snippet: string;
+ terms: string[];
+ evidence: EvidenceFixture[];
+ pdfPath: string;
+ previewImagePath: string;
+};
+
+type EvidenceTab = "Table" | "Quote" | "Image" | "Source page" | "Context";
+
+const focusRing =
+ "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]";
+
+const documents: DocumentFixture[] = [
+ {
+ slug: "clozapine-monitoring",
+ title: "Clozapine prescribing and monitoring guidelines",
+ shortTitle: "Clozapine monitoring guidelines",
+ source: "Clinical Guidelines",
+ kind: "Therapeutic Guidelines",
+ version: "v3.2",
+ status: "Current",
+ review: "Review 2026",
+ updated: "24 May 2024",
+ relevance: 92,
+ page: 12,
+ chunk: "monitoring-table",
+ snippet: "Hematological monitoring schedule table for clozapine, including frequency and action thresholds.",
+ terms: ["clozapine", "monitoring", "table", "CRP", "routine monitoring", "frequency"],
+ pdfPath: "/demo-documents/synthetic-clozapine-monitoring-with-image.pdf",
+ previewImagePath: "/demo-documents/clozapine-table.png",
+ evidence: [
+ {
+ id: "monitoring-table",
+ type: "table",
+ label: "Table 3",
+ title: "Hematological monitoring schedule",
+ body: "Treatment duration, frequency, test, and action threshold for patients on clozapine.",
+ page: 12,
+ section: "4.1.2",
+ chunk: "chunk 18",
+ terms: ["routine monitoring", "frequency", "ANC"],
+ relevance: 92,
+ accent: "teal",
+ },
+ {
+ id: "infection-quote",
+ type: "quote",
+ label: "Quote",
+ title: "Patient advice for infection symptoms",
+ body: "Patients should be informed about the need for regular blood tests and advised to report symptoms of infection.",
+ page: 12,
+ section: "4.1.2",
+ chunk: "chunk 19",
+ terms: ["baseline", "weekly", "neutrophil count"],
+ relevance: 86,
+ accent: "amber",
+ },
+ {
+ id: "infection-image",
+ type: "image",
+ label: "Figure 2",
+ title: "Signs of infection",
+ body: "Visual warning signs used alongside monitoring requirements.",
+ page: 13,
+ section: "4.1.3",
+ chunk: "image 04",
+ terms: ["infection", "fever", "clinical warning"],
+ relevance: 78,
+ accent: "blue",
+ },
+ {
+ id: "related-thresholds",
+ type: "related",
+ label: "Table 1",
+ title: "Monitoring checklist and key thresholds",
+ body: "Related threshold checklist from the same guideline family.",
+ page: 4,
+ section: "4.1.1",
+ chunk: "chunk 06",
+ terms: ["thresholds", "checklist"],
+ relevance: 74,
+ accent: "violet",
+ },
+ ],
+ },
+ {
+ slug: "psychotropic-handbook",
+ title: "Psychotropic medications monitoring handbook",
+ shortTitle: "Psychotropic monitoring handbook",
+ source: "Reference",
+ kind: "Clinical KB Repository",
+ version: "v2.1",
+ status: "Current",
+ review: "Review 2026",
+ updated: "12 Apr 2024",
+ relevance: 84,
+ page: 18,
+ chunk: "psychotropic-monitoring",
+ snippet: "Monitoring requirements and frequency for antipsychotic medicines, including clozapine-specific checks.",
+ terms: ["monitoring", "frequency", "table"],
+ pdfPath: "/demo-documents/synthetic-lithium-monitoring.pdf",
+ previewImagePath: "/demo-documents/clozapine-table.png",
+ evidence: [],
+ },
+ {
+ slug: "quick-reference",
+ title: "Clozapine: Safe Prescribing Quick Reference",
+ shortTitle: "Safe prescribing quick reference",
+ source: "Guideline Summary",
+ kind: "Clinical KB Repository",
+ version: "v1.4",
+ status: "Current",
+ review: "Review 2026",
+ updated: "10 Mar 2024",
+ relevance: 78,
+ page: 4,
+ chunk: "monitoring-checklist",
+ snippet: "Monitoring checklist and key thresholds for safe prescribing workflows.",
+ terms: ["clozapine", "monitoring", "checklist"],
+ pdfPath: "/demo-documents/synthetic-clozapine-monitoring-with-image.pdf",
+ previewImagePath: "/demo-documents/clozapine-table.png",
+ evidence: [],
+ },
+ {
+ slug: "neutropenia-management",
+ title: "Neutropenia management in clozapine therapy",
+ shortTitle: "Neutropenia management",
+ source: "Clinical Review",
+ kind: "Clinical KB Repository",
+ version: "v1.0",
+ status: "Review due",
+ review: "Review 2025",
+ updated: "15 Feb 2024",
+ relevance: 72,
+ page: 9,
+ chunk: "anc-interventions",
+ snippet: "ANC monitoring and clinical intervention guidance for neutropenia risk.",
+ terms: ["ANC", "neutrophil count", "monitoring"],
+ pdfPath: "/demo-documents/synthetic-clozapine-monitoring-with-image.pdf",
+ previewImagePath: "/demo-documents/clozapine-table.png",
+ evidence: [],
+ },
+ {
+ slug: "community-protocols",
+ title: "Community mental health protocols",
+ shortTitle: "Community mental health protocols",
+ source: "Procedure",
+ kind: "Clinical KB Repository",
+ version: "v5.0",
+ status: "Current",
+ review: "Review 2026",
+ updated: "22 Jan 2024",
+ relevance: 65,
+ page: 16,
+ chunk: "community-monitoring",
+ snippet: "Medication monitoring protocols for shared care and community follow-up.",
+ terms: ["monitoring", "protocol"],
+ pdfPath: "/demo-documents/synthetic-risk-flow-with-image.pdf",
+ previewImagePath: "/demo-documents/risk-flow.png",
+ evidence: [],
+ },
+];
+
+const defaultDocument = documents[0];
+const defaultQuery = "clozapine monitoring table";
+const sourceCategoryCounts = [
+ ["All sources", "2,065"],
+ ["Guidelines", "842"],
+ ["Procedures", "468"],
+ ["Reference", "411"],
+ ["Education", "344"],
+ ["Policies", "-"],
+] as const;
+const libraryCategoryCounts = [
+ ["Favorites", "23"],
+ ["Recent", "12"],
+ ["My notes", "8"],
+] as const;
+const monitoringTableHeadings = ["Treatment duration", "Frequency", "Test", "Action threshold"] as const;
+const monitoringTableRows = [
+ ["0 - 18 weeks", "Weekly", "Full Blood Count (ANC)", "ANC < 1.5 x10^9/L"],
+ ["18 weeks - 1 year", "Fortnightly", "Full Blood Count (ANC)", "ANC < 1.5 x10^9/L"],
+ ["> 1 year", "4 weekly", "Full Blood Count (ANC)", "ANC < 1.0 x10^9/L"],
+] as const;
+
+function documentHref(document: DocumentFixture, query = defaultQuery) {
+ const params = new URLSearchParams({
+ mode: "documents",
+ document: document.slug,
+ q: query,
+ page: String(document.page),
+ chunk: document.chunk,
+ });
+ return `/mockups/document-search/source?${params.toString()}`;
+}
+
+function evidenceHref(document: DocumentFixture, evidence: EvidenceFixture, query = defaultQuery) {
+ const params = new URLSearchParams({
+ mode: "documents",
+ document: document.slug,
+ evidence: evidence.id,
+ q: query,
+ page: String(evidence.page),
+ chunk: evidence.id,
+ });
+ return `/mockups/document-search/source/evidence?${params.toString()}`;
+}
+
+function findDocument(slug: string | null) {
+ return documents.find((document) => document.slug === slug) ?? defaultDocument;
+}
+
+function findEvidence(document: DocumentFixture, id: string | null) {
+ return document.evidence.find((evidence) => evidence.id === id) ?? document.evidence[0];
+}
+
+function searchHref(query = defaultQuery) {
+ const params = new URLSearchParams({ mode: "documents" });
+ const normalizedQuery = query.trim();
+ if (normalizedQuery) params.set("q", normalizedQuery);
+ return `/mockups/document-search-command?${params.toString()}`;
+}
+
+function primaryEvidence(document: DocumentFixture) {
+ return document.evidence[0] ?? defaultDocument.evidence[0];
+}
+
+function primaryEvidenceLabel(document: DocumentFixture) {
+ const evidence = primaryEvidence(document);
+ if (evidence.type === "table") return evidence.label;
+ if (evidence.type === "quote") return "Quote";
+ if (evidence.type === "image") return evidence.label;
+ return "Related";
+}
+
+function evidenceTypeLabel(type: EvidenceType) {
+ if (type === "table") return "Table evidence";
+ if (type === "quote") return "Quote evidence";
+ if (type === "image") return "Image evidence";
+ return "Related evidence";
+}
+
+function Pill({
+ children,
+ active = false,
+ tone = "neutral",
+ icon: Icon,
+}: {
+ children: ReactNode;
+ active?: boolean;
+ tone?: "neutral" | "teal" | "green" | "amber" | "blue" | "violet";
+ icon?: LucideIcon;
+}) {
+ const toneClass =
+ active || tone === "teal"
+ ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
+ : tone === "green"
+ ? "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]"
+ : tone === "amber"
+ ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]"
+ : tone === "blue"
+ ? "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]"
+ : tone === "violet"
+ ? "border-violet-200 bg-violet-50 text-violet-700"
+ : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]";
+ return (
+
+ {Icon ? : null}
+ {children}
+
+ );
+}
+
+function IconButton({ label, icon: Icon, active = false }: { label: string; icon: LucideIcon; active?: boolean }) {
+ return (
+
+
+
+ );
+}
+
+function FileTile({ label = "PDF" }: { label?: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+function CategoryRailSection({
+ title,
+ items,
+ activeLabel,
+}: {
+ title: string;
+ items: readonly (readonly [string, string])[];
+ activeLabel?: string;
+}) {
+ return (
+
+ {title}
+ {items.map(([label, count]) => {
+ const active = label === activeLabel;
+ return (
+
+ {label}
+ {count}
+
+ );
+ })}
+
+ );
+}
+
+function DocumentSearchCategoryRail() {
+ return (
+
+
+
+
+
+
+ Tools
+
+ {[
+ ["Compare", BarChart3],
+ ["Collections", Layers3],
+ ["Uploads", Download],
+ ].map(([label, Icon]) =>
+ (() => {
+ const ToolIcon = Icon as LucideIcon;
+ return (
+
+
+ {label as string}
+
+ );
+ })(),
+ )}
+
+
+
+ );
+}
+
+function SearchResultMobileCard({
+ document,
+ query,
+ selected,
+}: {
+ document: DocumentFixture;
+ query: string;
+ selected: boolean;
+}) {
+ const evidence = primaryEvidence(document);
+ return (
+
+
+
+
+
+ {evidenceTypeLabel(evidence.type)}
+ ·
+ p.{evidence.page}
+ ·
+ {evidence.relevance}%
+
+
+ {selected ?
Best match :
}
+
+
+
+
+
+
+
+ {document.title}
+
+
+ {document.kind} · {document.version}
+
+
+ {evidence.title}
+
+
+
+ {evidence.body}
+
+
+ {document.status}
+
+
{primaryEvidenceLabel(document)}
+
+
+
+ Open document
+
+
+
+ Open evidence
+
+
+
+ );
+}
+
+function SelectedSourceTray({
+ selected,
+ selectedEvidence,
+ query,
+}: {
+ selected: DocumentFixture;
+ selectedEvidence: EvidenceFixture;
+ query: string;
+}) {
+ return (
+
+
+
+
+
+
+
+ Selected source
+
+
+ {selected.title}
+
+
+ {selected.kind} · {selected.version}
+
+
+
+
+
+
+
+
+
+
+ {["Evidence", "Why matched", "Actions"].map((item, index) => (
+
+ {item}
+
+ ))}
+
+
+
+
+
+
+ {evidenceTypeLabel(selectedEvidence.type)} · p.{selectedEvidence.page} · {selectedEvidence.relevance}%
+
+
+ {selectedEvidence.title}
+
+
+
+
+
+
+ Matched terms
+
+
+ {selectedEvidence.terms.slice(0, 3).map((term) => (
+
{term}
+ ))}
+
+
+
+
+ Exact table match with high term coverage in title and content.
+
+
+
+
+
+
+
+
+ Selected source
+
+
{selected.title}
+
+ {selected.kind} · {selected.version} · {selected.source}
+
+
+
+
+
+ Evidence on page
+
+
+
{selectedEvidence.label}
+
p.{selectedEvidence.page}
+
{selectedEvidence.chunk}
+
+
+
+
+ Matched terms
+
+
+ {selected.terms.slice(0, 4).map((term) => (
+
{term}
+ ))}
+
+
+
+
+ Why matched
+
+
+ Exact table match, high term coverage, current source.
+
+
+
+
+ Open document
+
+
+
+ Open evidence
+
+
+
+
+ );
+}
+
+function DocumentShell({ children, hideSidebar = false }: { children: ReactNode; hideSidebar?: boolean }) {
+ return (
+
+
+ {!hideSidebar ? (
+
+ ) : null}
+
{children}
+
+
+ );
+}
+
+function evidenceIcon(type: EvidenceType) {
+ if (type === "table") return Table2;
+ if (type === "quote") return Quote;
+ if (type === "image") return FileImage;
+ return Layers3;
+}
+
+function evidenceTone(type: EvidenceType): "teal" | "amber" | "blue" | "violet" {
+ if (type === "table") return "teal";
+ if (type === "quote") return "amber";
+ if (type === "image") return "blue";
+ return "violet";
+}
+
+function EvidenceTypeIcon({ type, className }: { type: EvidenceType; className?: string }) {
+ if (type === "table") return
;
+ if (type === "quote") return
;
+ if (type === "image") return
;
+ return
;
+}
+
+function MonitoringRowCards({ compact = false }: { compact?: boolean }) {
+ return (
+
+ {monitoringTableRows.map((row, rowIndex) => (
+
+
+
+
{row[0]}
+
+ {monitoringTableHeadings.slice(1).map((heading, index) => (
+
+
{heading}
+ {row[index + 1]}
+
+ ))}
+
+
+
+ ))}
+
+ );
+}
+
+export function MasterDocumentSearch() {
+ const searchParams = useSearchParams();
+ const query = searchParams.get("q")?.trim() || defaultQuery;
+ const [type, setType] = useState<"all" | EvidenceType>("all");
+
+ const filtered = useMemo(() => {
+ const lowered = query.toLowerCase();
+ return documents.filter((document) => {
+ const matchesQuery =
+ document.title.toLowerCase().includes(lowered) ||
+ document.snippet.toLowerCase().includes(lowered) ||
+ document.terms.some((term) => lowered.includes(term.toLowerCase()) || term.toLowerCase().includes(lowered));
+ const matchesType = type === "all" || document.evidence.some((item) => item.type === type);
+ return matchesQuery || matchesType || document.slug === defaultDocument.slug;
+ });
+ }, [query, type]);
+
+ const selected = filtered[0] ?? defaultDocument;
+ const selectedEvidence = selected.evidence[0] ?? defaultDocument.evidence[0];
+
+ return (
+
+
+
+
+
+
+
+
+ Documents
+
+
+ Find source evidence
+ Search command centre
+
+
+ Search documents, tables, quotes, and images
+
+
+
+
+ 2,065 indexed
+
+
+ Save search
+
+
+
+
+
+
+
+ {[
+ ["all", "Sources"],
+ ["table", "Tables"],
+ ["quote", "Quotes"],
+ ["image", "Images"],
+ ["related", "Related"],
+ ].map(([key, label]) => (
+
setType(key as "all" | EvidenceType)}
+ className={cn(
+ "inline-flex min-h-9 items-center gap-2 rounded-lg border px-3 text-xs font-bold shadow-[var(--shadow-inset)]",
+ focusRing,
+ type === key
+ ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]"
+ : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-heading)]",
+ )}
+ >
+ {label}
+
+
+ ))}
+
+
+ Current
+
+
+
+ More filters
+
+
+
+
+ Table
+
+
+
+ List
+
+
+
+
+
{filtered.length} results
+
+ Sort: Relevance
+
+
+
+
+
+
+
+ Document
+ Evidence
+ Status
+ Relevance
+ Actions
+
+
+ {filtered.map((document, index) => (
+
+
+
+
+
+ {index === 0 ?
Best match :
Relevant }
+
+
+ {document.title}
+
+
+ {document.kind} · {document.version} · {document.source}
+
+
+
+
+
+
{primaryEvidenceLabel(document)}
+
p.{document.page}
+
{document.chunk}
+
+
+ {document.snippet}
+
+
+
+
{document.status}
+
{document.review}
+
{document.updated}
+
+
+
+ {document.relevance}%
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ {filtered.map((document, index) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
+function DocumentPreview({ selectedEvidence }: { selectedEvidence: EvidenceFixture }) {
+ return (
+
+
+
+
+ 4.1.2 Routine monitoring schedule
+
+
+ Regular haematological monitoring is essential to reduce
+ risk and ensure treatment can continue safely.{" "}
+ Table 3 outlines the recommended monitoring
+ schedule.
+
+
+
+
+ Table 3. Haematological monitoring schedule for patients on clozapine.
+
+
+
+
+
+
+
+
+
+ {monitoringTableHeadings.map((heading) => (
+
+ {heading}
+
+ ))}
+
+
+
+ {monitoringTableRows.map((row, rowIndex) => (
+
+ {row.map((cell) => (
+
+ {selectedEvidence.type === "table" && rowIndex === 0 ? (
+ {cell}
+ ) : (
+ cell
+ )}
+
+ ))}
+
+ ))}
+
+
+
+
+ “Patients should be informed about the need for regular blood tests and advised to report symptoms of
+ infection immediately.”
+
+
+ {["Fever", "Tachycardia", "Shortness of breath", "Chest pain"].map((label) => (
+
+
+
+
+ {label}
+
+ ))}
+
+
+
+ );
+}
+
+function InspectorTabs({ active, onChange }: { active: string; onChange: (tab: string) => void }) {
+ return (
+
+ {["Evidence", "Summary", "Details", "Versions", "Notes"].map((tab) => (
+ onChange(tab)}
+ className={cn(
+ "min-h-12 border-b-2 px-2 text-sm font-bold transition",
+ focusRing,
+ active === tab
+ ? "border-[color:var(--clinical-accent)] text-[color:var(--clinical-accent)]"
+ : "border-transparent text-[color:var(--text-muted)] hover:text-[color:var(--text-heading)]",
+ )}
+ >
+ {tab}
+
+ ))}
+
+ );
+}
+
+function EvidenceCard({
+ document,
+ evidence,
+ query,
+ selected,
+}: {
+ document: DocumentFixture;
+ evidence: EvidenceFixture;
+ query: string;
+ selected: boolean;
+}) {
+ const Icon = evidenceIcon(evidence.type);
+ return (
+
+
+
+ {evidence.label}
+
+
+ {evidence.relevance}
+
+
+
{evidence.title}
+
{evidence.body}
+
+ Page {evidence.page} · Section {evidence.section}
+
+
+ );
+}
+
+export function MasterDocumentReader() {
+ const searchParams = useSearchParams();
+ const query = searchParams.get("q")?.trim() || defaultQuery;
+ const document = findDocument(searchParams.get("document"));
+ const selectedEvidence = findEvidence(document, searchParams.get("chunk"));
+ const [tab, setTab] = useState("Evidence");
+ const [filter, setFilter] = useState<"all" | EvidenceType>("all");
+ const [mobileEvidenceOpen, setMobileEvidenceOpen] = useState(false);
+ const evidenceItems = filter === "all" ? document.evidence : document.evidence.filter((item) => item.type === filter);
+
+ return (
+
+
+
+
+
+
+
+
+ Clozapine guidelines
+
+
+
+
+
+
{document.status}
+
{document.version}
+
p.{document.page} / 84
+
+
+ {[
+ ["Read", BookOpen],
+ ["Evidence", List],
+ ["Summary", FileText],
+ ].map(([label, Icon], index) => {
+ const TabIcon = Icon as LucideIcon;
+ return (
+ {
+ if (label === "Evidence") setMobileEvidenceOpen(true);
+ }}
+ className={cn(
+ "inline-flex min-h-10 items-center justify-center gap-2 rounded-md text-sm font-extrabold",
+ focusRing,
+ index === 0
+ ? "bg-[color:var(--surface)] text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)]"
+ : "text-[color:var(--text-heading)]",
+ )}
+ >
+
+ {label as string}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
Current version
+
+ {document.version} · Published 12 May 2024
+
+
+
Page {document.page} / 84
+
100%
+
+
+
+
+
+
+
+
+
Highlighted hit 1 of 3
+
+ {selectedEvidence.title}
+
+
+
+ Previous
+
+
+ Next
+
+
+ Clear highlights
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {tab === "Evidence" ? (
+ <>
+
+ {[
+ ["all", `All ${document.evidence.length}`],
+ ["table", "Tables 1"],
+ ["quote", "Quotes 1"],
+ ["image", "Images 1"],
+ ["related", "Related 1"],
+ ].map(([key, label]) => (
+ setFilter(key as "all" | EvidenceType)}
+ className={cn(
+ "inline-flex min-h-9 shrink-0 items-center rounded-lg border px-3 text-xs font-bold",
+ focusRing,
+ filter === key
+ ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
+ : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]",
+ )}
+ >
+ {label}
+
+ ))}
+
+
+
+ Matched terms
+
+
+ {document.terms.map((term) => (
+
{term}
+ ))}
+
+
+
+
+
+ Evidence in this document
+
+
{evidenceItems.length}
+
+ {evidenceItems.map((evidence) => (
+
+ ))}
+
+ >
+ ) : tab === "Summary" ? (
+
+
Document summary
+
+ This guideline contains monitoring schedules, patient advice, and threshold-based actions for
+ clozapine treatment. The strongest match is Table 3 on page 12.
+
+
+ Best evidence: Table 3
+
+
+ ) : (
+
+ {[
+ ["Source", document.source],
+ ["Version", document.version],
+ ["Review status", document.review],
+ ["Updated", document.updated],
+ ].map(([label, value]) => (
+
+
+ {label}
+
+
{value}
+
+ ))}
+
+ )}
+
+
+
+
+
+ {mobileEvidenceOpen ? (
+
+
+
+
+
Evidence on this page
+
Page {document.page} · Section 4.1.2
+
+
setMobileEvidenceOpen(false)}
+ className={cn("rounded-lg px-3 py-2 text-xs font-bold text-[color:var(--clinical-accent)]", focusRing)}
+ >
+ Collapse
+
+
+
+ {[
+ ["all", `All ${document.evidence.length}`],
+ ["table", "Tables 1"],
+ ["quote", "Quotes 1"],
+ ["image", "Images 1"],
+ ].map(([key, label]) => (
+ setFilter(key as "all" | EvidenceType)}
+ className={cn(
+ "inline-flex min-h-9 shrink-0 items-center rounded-lg border px-3 text-xs font-bold",
+ focusRing,
+ filter === key
+ ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
+ : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]",
+ )}
+ >
+ {label}
+
+ ))}
+
+
+ {evidenceItems.map((evidence) => (
+
+ ))}
+
+
+ ) : (
+
+ {[
+ ["Search", Search],
+ ["Evidence", List],
+ ["Compare", BarChart3],
+ ["Note", MessageSquareText],
+ ].map(([label, Icon]) => {
+ const ActionIcon = Icon as LucideIcon;
+ return (
+
{
+ if (label === "Evidence") setMobileEvidenceOpen(true);
+ }}
+ className={cn(
+ "inline-flex min-h-16 flex-col items-center justify-center gap-1 text-xs font-bold text-[color:var(--text-heading)]",
+ focusRing,
+ )}
+ >
+
+ {label as string}
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
+
+function ExtractedTable({ activeTab }: { activeTab: EvidenceTab }) {
+ if (activeTab === "Quote") {
+ return (
+
+ “Patients should be informed about the need for regular blood tests and advised to report symptoms of infection
+ immediately.”
+
+ );
+ }
+ if (activeTab === "Image") {
+ return (
+
+ {["Fever", "Temperature", "Skin signs", "Clinical review"].map((label) => (
+
+
+ {label}
+
+ ))}
+
+ );
+ }
+ return (
+ <>
+
+
+
+
+
+
+
+ {monitoringTableHeadings.map((heading) => (
+
+ {heading}
+
+ ))}
+
+
+
+ {monitoringTableRows.map((row, index) => (
+
+ {row.map((cell) => (
+
+ {cell}
+
+ ))}
+
+ ))}
+
+
+
+ >
+ );
+}
+
+export function MasterEvidenceDetail() {
+ const searchParams = useSearchParams();
+ const query = searchParams.get("q")?.trim() || defaultQuery;
+ const document = findDocument(searchParams.get("document"));
+ const evidence = findEvidence(document, searchParams.get("evidence") ?? searchParams.get("chunk"));
+ const [tab, setTab] = useState
(
+ evidence.type === "quote" ? "Quote" : evidence.type === "image" ? "Image" : "Table",
+ );
+
+ return (
+
+
+
+
+
+
+
+
Evidence
+
+ {evidence.label} · p.{evidence.page}
+
+
+
+
+
+
+
+
+
+
+
+ Documents
+
+ {document.shortTitle}
+
+ {evidence.label}
+
+
+
Evidence detail
+
Reusable source object
+
+
+
+
+ Back to document
+
+
+
+
+
+
+ {(["Table", "Quote", "Image", "Source page", "Context"] as EvidenceTab[]).map((item) => (
+
setTab(item)}
+ className={cn(
+ "inline-flex min-h-12 shrink-0 items-center gap-2 border-b-2 px-4 text-sm font-bold",
+ focusRing,
+ tab === item
+ ? "border-[color:var(--clinical-accent)] text-[color:var(--clinical-accent)]"
+ : "border-transparent text-[color:var(--text-muted)]",
+ )}
+ >
+ {item === "Table" ? (
+
+ ) : item === "Quote" ? (
+
+ ) : item === "Image" ? (
+
+ ) : (
+
+ )}
+ {item}
+
+ ))}
+
+
+
+
+
Reusable source object
+
+ {evidence.title}
+
+
+ {evidence.body}
+
+
+
{evidence.relevance}% relevance
+
High confidence
+
+
+
+
+
+ Download
+
+
+
+ Copy table
+
+
+
+
+
+
+
+ Source page
+
+
+ Open document
+
+
+
+
+ Page {evidence.page} in {document.title}
+
+
+ {[10, 11, 12, 13, 14].map((page) => (
+
+
+
+ {page === evidence.page ? (
+
+ ) : null}
+
+
{page}
+
+ ))}
+
+
+
+ This reusable source object preserves exact content, page context, and metadata for citation,
+ comparison, and answer reuse.
+
+
+
+
+
+ Source details
+
+
+
+
+ {[
+ ["Page", `Page ${evidence.page}`],
+ ["Section", `Section ${evidence.section}`],
+ ["Chunk ID", evidence.chunk],
+ ["Relevance", `${evidence.relevance}% relevance`],
+ ["Reliability", "High confidence"],
+ ].map(([label, value]) => (
+
+
{label}
+ {value}
+
+ ))}
+
+
+
+
+
+
+
+
+ Source document
+
+
+
+
+
+ {document.title}
+
+
+ {document.version} · Published 12 May 2024
+
+
+ Open full document
+
+
+
+
+
+
+ {[
+ ["Page", `Page ${evidence.page}`],
+ ["Section", `Section ${evidence.section}`],
+ ["Chunk ID", evidence.chunk],
+ ["Indexed", "24 May 2024 · 10:21 AEST"],
+ ["Source", "clinical-documents/clozapine-guidelines-v3.2.pdf"],
+ ].map(([label, value]) => (
+
+ {label}
+ {value}
+
+ ))}
+
+
+ Matched terms
+
+
+ {evidence.terms.map((term) => (
+
{term}
+ ))}
+
+
+
+
+ Relevance
+
+
+ {evidence.relevance}% relevance
+
+
+
+
Current
+
+
+ Open full document
+
+
+ {[
+ ["Cite", Quote],
+ ["Compare sources", BarChart3],
+ ["Use in answer", MessageSquareText],
+ ["Save evidence", Bookmark],
+ ["Copy evidence ID", Copy],
+ ].map(([label, Icon]) => (
+
+
+ {label as string}
+
+ ))}
+
+
+
+
+
+
+
+
+ Use in answer
+
+
+
+ Cite
+
+
+
+
+
+
+ );
+}
+
+export function MasterDocumentIndex() {
+ return (
+
+
+
+
+ {[
+ ["Search command centre", "Ranking and selection", searchHref(), Search],
+ ["Document reader", "Reading and context", documentHref(defaultDocument), BookOpen],
+ [
+ "Evidence detail",
+ "Exact reusable source object",
+ evidenceHref(defaultDocument, defaultDocument.evidence[0]),
+ Layers3,
+ ],
+ ].map(([title, body, href, Icon]) => (
+
+
+
+
+ {title as string}
+ {body as string}
+
+ Open
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/mode-home-template.tsx b/src/components/mode-home-template.tsx
index 52c4750c3..c316fb6e7 100644
--- a/src/components/mode-home-template.tsx
+++ b/src/components/mode-home-template.tsx
@@ -124,8 +124,8 @@ export function ModeHomeVerificationFooter({
icon: LucideIcon;
label: string;
body: string;
- verifiedCount: number;
- totalCount: number;
+ verifiedCount?: number;
+ totalCount?: number;
}) {
return (
@@ -135,9 +135,11 @@ export function ModeHomeVerificationFooter({
•
{body}
-
- {verifiedCount} of {totalCount} records are locally verified.
-
+ {typeof verifiedCount === "number" && typeof totalCount === "number" ? (
+
+ {verifiedCount} of {totalCount} records are locally verified.
+
+ ) : null}
);
}
@@ -202,26 +204,26 @@ export function ModeHomeTemplate({
{desktopComposerSlotId ? (
-
+
) : null}
{actions.length ? (
{actions.map((action, index) => {
const ActionIcon = action.icon;
const content = (
<>
-
-
+
+
-
+
{action.title}
-
+
{action.description}
@@ -232,8 +234,8 @@ export function ModeHomeTemplate({
>
);
const actionClassName = cn(
- "mode-home-action group grid min-h-[4.8rem] w-full grid-cols-[2.5rem_minmax(0,1fr)_1.25rem] items-center gap-3 bg-[color:var(--surface)] px-4 py-3 text-left transition hover:bg-[color:var(--surface-subtle)] focus-visible:relative focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] disabled:cursor-wait disabled:opacity-60 sm:min-h-[8rem] sm:grid-cols-[3.5rem_minmax(0,1fr)_1.5rem] sm:gap-4 sm:rounded-lg sm:border sm:border-[color:var(--border)] sm:px-5 sm:py-5 sm:shadow-[var(--shadow-card)] lg:min-h-[8.4rem] lg:px-6",
- index > 0 && "border-t border-[color:var(--border)] sm:border-t-[color:var(--border)]",
+ "mode-home-action group grid min-h-[4.8rem] w-full grid-cols-[2.5rem_minmax(0,1fr)_1.25rem] items-center gap-3 bg-[color:var(--surface)] px-4 py-3 text-left transition hover:bg-[color:var(--surface-subtle)] focus-visible:relative focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] disabled:cursor-wait disabled:opacity-60 lg:min-h-[8.4rem] lg:grid-cols-[3.5rem_minmax(0,1fr)_1.5rem] lg:gap-4 lg:rounded-lg lg:border lg:border-[color:var(--border)] lg:px-6 lg:py-5 lg:shadow-[var(--shadow-card)]",
+ index > 0 && "border-t border-[color:var(--border)] lg:border-t-[color:var(--border)]",
);
if (action.href) {
diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx
new file mode 100644
index 000000000..cd6854a1c
--- /dev/null
+++ b/src/components/services/services-navigator-page.tsx
@@ -0,0 +1,515 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import {
+ ArrowRight,
+ Bookmark,
+ Check,
+ ChevronDown,
+ CircleAlert,
+ CircleCheck,
+ CircleX,
+ DollarSign,
+ ExternalLink,
+ Phone,
+ ShieldCheck,
+ SlidersHorizontal,
+ Users,
+ X,
+ type LucideIcon,
+} from "lucide-react";
+import { useMemo, useState } from "react";
+
+import { cn } from "@/components/ui-primitives";
+import { appModeHomeHref } from "@/lib/app-modes";
+import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer";
+import { searchServiceRecords, serviceRecords, type ServiceRecord, type ServiceStatusChip } from "@/lib/services";
+
+const defaultQuery = "13YARN crisis support aboriginal phone";
+
+function text(value: string | null | undefined, fallback = "Confirm locally") {
+ return value?.trim() ? value.trim() : fallback;
+}
+
+function chipTone(tone: ServiceStatusChip["tone"] | undefined | null) {
+ if (tone === "danger") return "border-red-200 bg-red-50 text-red-700";
+ if (tone === "info") return "border-sky-200 bg-sky-50 text-sky-700";
+ if (tone === "warning") return "border-orange-200 bg-orange-50 text-orange-700";
+ if (tone === "success") return "border-emerald-200 bg-emerald-50 text-emerald-700";
+ return "border-slate-200 bg-slate-50 text-slate-600";
+}
+
+function serviceChipLabel(chip: ServiceStatusChip) {
+ const label = text(chip.label, "Status");
+ if (label.toLowerCase().includes("aboriginal and torres strait islander")) return "ATSI-specific";
+ return label;
+}
+
+function metricCounts(records: ServiceRecord[]) {
+ return records.reduce(
+ (total, service) => {
+ for (const criterion of service.criteria ?? []) {
+ if (criterion.tone === "meet") total.meets += 1;
+ if (criterion.tone === "caution") total.cautions += 1;
+ if (criterion.tone === "reject") total.rejects += 1;
+ }
+ const confidence = service.verification?.confidence ?? "Unknown";
+ if (confidence === "High") total.high += 1;
+ else if (confidence === "Medium") total.medium += 1;
+ else if (confidence === "Low") total.low += 1;
+ else total.unknown += 1;
+ if (service.verification?.locallyVerified || (service.source?.status ?? "").toLowerCase().includes("source")) {
+ total.verified += 1;
+ }
+ if ((service.source?.status ?? "").toLowerCase().includes("confirmation")) total.localConfirmation += 1;
+ return total;
+ },
+ { meets: 0, cautions: 0, rejects: 0, high: 0, medium: 0, low: 0, unknown: 0, verified: 0, localConfirmation: 0 },
+ );
+}
+
+function Stepper() {
+ return (
+
+ {[
+ ["1", "Search", "Find services"],
+ ["2", "Shortlist", "Pick best options"],
+ ["3", "Compare", "Review side by side"],
+ ["4", "Refer", "Send with confidence"],
+ ].map(([number, title, body], index) => (
+
+
+ {number}
+
+
+
+ {title}
+
+ {body}
+
+
+ ))}
+
+ );
+}
+
+function Chip({ chip }: { chip: ServiceStatusChip }) {
+ return (
+
+
+ {serviceChipLabel(chip)}
+
+ );
+}
+
+function Metric({
+ icon: Icon,
+ label,
+ value,
+ detail,
+ className,
+}: {
+ icon: LucideIcon;
+ label: string;
+ value: string;
+ detail: string;
+ className?: string;
+}) {
+ return (
+
+
+
+ {label}
+ {value}
+ {detail}
+
+
+ );
+}
+
+function ServiceCard({
+ service,
+ index,
+ selected,
+ onToggleSelected,
+}: {
+ service: ServiceRecord;
+ index: number;
+ selected: boolean;
+ onToggleSelected: (slug: string) => void;
+}) {
+ const rank = index + 1;
+ const tags = [...(service.catchments ?? []), ...(service.tags ?? [])].slice(0, 4);
+
+ return (
+
+
+
+ {rank}
+
+
+
+
{service.title}
+ {rank <= 2 ? (
+ Best fit
+ ) : null}
+
+
+ {(service.statusChips ?? []).slice(0, 3).map((chip) => (
+
+ ))}
+
+
+ {text(service.subtitle ?? service.bestUse, "Open the record for referral details.")}
+
+
+
onToggleSelected(service.slug)}
+ className="grid h-9 w-9 place-items-center rounded-lg text-[#061740] hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#007a78]"
+ aria-label={selected ? `Remove ${service.title} from selected services` : `Save ${service.title}`}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {tags.map((tag, tagIndex) => (
+ 2 ? "max-sm:hidden" : "",
+ )}
+ >
+ {tag}
+
+ ))}
+
+
+
+
+ Open
+
+ onToggleSelected(service.slug)}
+ className="inline-flex h-9 min-w-[94px] items-center justify-center gap-1.5 rounded-lg bg-[#007a78] px-3 text-xs font-bold text-white shadow-[0_8px_18px_rgba(0,122,120,0.18)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#007a78]"
+ >
+
+ {selected ? "Selected" : "Select"}
+
+
+
+
+ );
+}
+
+function RightRail({
+ matches,
+ selected,
+ onClearSelected,
+ onToggleSelected,
+}: {
+ matches: ServiceRecord[];
+ selected: ServiceRecord[];
+ onClearSelected: () => void;
+ onToggleSelected: (slug: string) => void;
+}) {
+ const counts = metricCounts(matches);
+ const rows: Array<[string, number, LucideIcon, string]> = [
+ ["Meets", counts.meets, CircleCheck, "text-emerald-600"],
+ ["Caution", counts.cautions, CircleAlert, "text-orange-500"],
+ ["Does not meet", counts.rejects, CircleX, "text-red-600"],
+ ["Source verified", counts.verified, CircleCheck, "text-emerald-600"],
+ ["Local confirmation", counts.localConfirmation, CircleAlert, "text-orange-500"],
+ ];
+
+ return (
+
+
+
+
Referral decision
+
+ Clear
+
+
+ Selected services ({selected.length})
+
+ {selected.map((service, index) => (
+ onToggleSelected(service.slug)}
+ className="grid min-h-16 grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-left"
+ >
+
+ {index + 1}
+
+
+ {service.title}
+
+ {text(service.cost, "Cost pending")} - {text(service.source?.status, "Source pending")}
+
+
+
+
+ ))}
+
+
+
+
+
Checklist
+
+ Edit
+
+
+
+ {rows.map(([label, count, Icon, color]) => (
+
+
+
+ {label}
+
+ {count}
+
+ ))}
+
+
+ Review details
+
+
+
+
+
Source confidence
+
+ View details
+
+
+
+
+
+
+
+
+
+
+ High
+
+ {counts.high}
+
+
+ Medium
+
+ {counts.medium}
+
+
+ Low
+
+ {counts.low}
+
+
+ Unknown
+
+ {counts.unknown}
+
+
+
+
+ Compare selected ({selected.length})
+
+
+ );
+}
+
+export function ServicesNavigatorPage() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const urlQuery = (searchParams.get("q") ?? searchParams.get("query") ?? "").trim();
+ const initialQuery = urlQuery || defaultQuery;
+ const [localQuery, setLocalQuery] = useState(() => ({ urlQuery, value: initialQuery }));
+ const query = localQuery.urlQuery === urlQuery ? localQuery.value : initialQuery;
+ const matches = useMemo(() => {
+ const ranked = searchServiceRecords(query);
+ return ranked.length ? ranked.map((match) => match.service) : serviceRecords;
+ }, [query]);
+ const [selectedSlugs, setSelectedSlugs] = useState(() => serviceRecords.slice(0, 2).map((service) => service.slug));
+ const selected = serviceRecords.filter((service) => selectedSlugs.includes(service.slug));
+
+ function toggleSelected(slug: string) {
+ setSelectedSlugs((current) =>
+ current.includes(slug) ? current.filter((item) => item !== slug) : [slug, ...current].slice(0, 5),
+ );
+ }
+
+ function applyServiceQuery(nextQuery: string) {
+ const trimmedQuery = nextQuery.trim();
+ setLocalQuery({ urlQuery, value: trimmedQuery });
+ if (trimmedQuery) {
+ router.push(appModeHomeHref("services", { query: trimmedQuery, focus: true, run: true }));
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {matches.length}
+
+
+
+ Referral matches
+
+
+ {matches.length} referral {matches.length === 1 ? "match" : "matches"}
+
+
+ Best fit for crisis, ATSI-specific phone referral.
+
+ Ranked for crisis support, ATSI-specific access, and phone referral.
+
+
+
+
+
+
+
+ Filters
+
+
+ Sort
+
+
+
+
+
+ {["Best fit", "Crisis", "ATSI-specific", "Phone referral", "Free", "WA"].map((chip, index) => (
+ applyServiceQuery(index === 0 ? defaultQuery : chip)}
+ className={cn(
+ "min-h-8 rounded-full border px-3 text-xs font-bold transition hover:-translate-y-px hover:shadow-sm",
+ index > 2 ? "max-sm:hidden" : "",
+ index === 0
+ ? "border-[#007a78] bg-[#007a78] text-white shadow-[0_5px_12px_rgba(0,122,120,0.16)]"
+ : "border-slate-200 bg-white text-slate-600 hover:bg-slate-50",
+ )}
+ >
+ {chip}
+
+ ))}
+
+
setLocalQuery({ urlQuery, value: "" })}
+ className="min-h-8 shrink-0 rounded-full px-2 text-xs font-bold text-blue-600 hover:bg-blue-50 hover:text-blue-700 max-sm:hidden"
+ >
+ Clear all
+
+
+
+
+ {matches.map((service, index) => (
+
+ ))}
+
+
+
setSelectedSlugs([])}
+ onToggleSelected={toggleSelected}
+ />
+
+
+
+
+ );
+}
diff --git a/src/components/source-overlay-redesign-mockups.tsx b/src/components/source-overlay-redesign-mockups.tsx
new file mode 100644
index 000000000..8a86af1ac
--- /dev/null
+++ b/src/components/source-overlay-redesign-mockups.tsx
@@ -0,0 +1,694 @@
+import type { ReactNode } from "react";
+import {
+ BookOpen,
+ CheckCircle2,
+ ChevronDown,
+ Clock3,
+ ExternalLink,
+ FileText,
+ Filter,
+ FolderOpen,
+ Layers3,
+ ListFilter,
+ PanelRight,
+ Search,
+ ShieldCheck,
+ SlidersHorizontal,
+ Table2,
+ UploadCloud,
+ X,
+ type LucideIcon,
+} from "lucide-react";
+
+import { cn } from "@/components/ui-primitives";
+
+type Device = "desktop" | "phone";
+type VariantTone = "scope" | "library" | "graphite";
+
+const focusRing =
+ "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]";
+
+const scopeFilters = [
+ "Medication",
+ "Topic",
+ "Site",
+ "Type",
+ "Service",
+ "Setting",
+ "Population",
+ "Risk",
+ "Workflow",
+ "Action",
+];
+
+const libraryFilters = ["Type", "Site", "Topic", "Population"];
+
+const compactFilterValues = [
+ "Lithium, clozapine",
+ "ECT, safety plan",
+ "FSH, RPBG",
+ "Guideline, policy",
+ "Mental health",
+ "Inpatient, ED",
+];
+
+const sourceRows = [
+ {
+ title: "Lithium monitoring protocol",
+ meta: "Therapeutic Guidelines - p.18 - table",
+ status: "Current",
+ tone: "success" as const,
+ icon: Table2,
+ },
+ {
+ title: "Clozapine prescribing guide",
+ meta: "Local policy set - p.12 - monitoring",
+ status: "Indexed",
+ tone: "accent" as const,
+ icon: FileText,
+ },
+ {
+ title: "Safety planning pathway",
+ meta: "Clinical repository - p.4 - checklist",
+ status: "Review",
+ tone: "warning" as const,
+ icon: ShieldCheck,
+ },
+];
+
+const designNotes = [
+ "Reduce phone height to content plus scroll, not a full blank sheet.",
+ "Move filters into compact groups so search stays visible.",
+ "Keep desktop balanced with equal columns and clear empty states.",
+];
+
+function toneClass(tone: VariantTone) {
+ if (tone === "library") {
+ return {
+ icon: "border-sky-200 bg-sky-50 text-sky-700",
+ soft: "bg-sky-50 text-sky-800 border-sky-200",
+ line: "border-sky-200",
+ };
+ }
+ if (tone === "graphite") {
+ return {
+ icon: "border-slate-300 bg-slate-100 text-slate-800",
+ soft: "bg-slate-100 text-slate-800 border-slate-300",
+ line: "border-slate-300",
+ };
+ }
+ return {
+ icon: "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]",
+ soft: "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]",
+ line: "border-[color:var(--clinical-accent-border)]",
+ };
+}
+
+function IconTile({ icon: Icon, tone = "scope", compact = false }: { icon: LucideIcon; tone?: VariantTone; compact?: boolean }) {
+ return (
+
+
+
+ );
+}
+
+function Pill({
+ children,
+ icon: Icon,
+ active = false,
+ tone = "scope",
+ small = false,
+}: {
+ children: ReactNode;
+ icon?: LucideIcon;
+ active?: boolean;
+ tone?: VariantTone;
+ small?: boolean;
+}) {
+ return (
+
+ {Icon ? : null}
+ {children}
+
+ );
+}
+
+function MockButton({
+ children,
+ icon: Icon,
+ primary = false,
+ tone = "scope",
+}: {
+ children: ReactNode;
+ icon?: LucideIcon;
+ primary?: boolean;
+ tone?: VariantTone;
+}) {
+ return (
+
+ {Icon ? : null}
+ {children}
+
+ );
+}
+
+function activeToneButton(tone: VariantTone) {
+ if (tone === "library") return "border-sky-200 bg-sky-50 text-sky-800";
+ if (tone === "graphite") return "border-slate-300 bg-slate-100 text-slate-800";
+ return "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]";
+}
+
+function SearchField({ placeholder, compact = false }: { placeholder: string; compact?: boolean }) {
+ return (
+
+
+
+ {placeholder}
+
+
+ );
+}
+
+function SelectField({ label, value, compact = false }: { label: string; value: string; compact?: boolean }) {
+ return (
+
+ {label}
+
+ {value}
+
+
+
+ );
+}
+
+function MiniFilterGrid({ device }: { device: Device }) {
+ return (
+
+ {compactFilterValues.slice(0, device === "desktop" ? 4 : 6).map((value, index) => (
+
+ ))}
+
+ );
+}
+
+function EmptyState({
+ title = "No indexed documents",
+ body = "Upload a guideline to start indexing.",
+ compact = false,
+}: {
+ title?: string;
+ body?: string;
+ compact?: boolean;
+}) {
+ return (
+
+
+
+
+
+ {title}
+ {body}
+
+
+ );
+}
+
+function SourceRow({ row, compact = false }: { row: (typeof sourceRows)[number]; compact?: boolean }) {
+ const Icon = row.icon;
+ return (
+
+
+
+
+
+
+ {row.title}
+
+ {row.meta}
+
+
+ {row.status}
+
+
+ );
+}
+
+function PanelHeader({
+ title,
+ description,
+ icon,
+ tone,
+ device,
+ count = "0 indexed",
+}: {
+ title: string;
+ description: string;
+ icon: LucideIcon;
+ tone: VariantTone;
+ device: Device;
+ count?: string;
+}) {
+ return (
+
+
+
+
+
+
+ {title}
+
+ {device === "desktop" ?
{count} : null}
+
+
+ {description}
+
+
+
+
+
+
+
+ );
+}
+
+function DesktopFrame({ children }: { children: ReactNode }) {
+ return (
+
+ );
+}
+
+function PhoneFrame({ children, shorter = true }: { children: ReactNode; shorter?: boolean }) {
+ return (
+
+ );
+}
+
+function MockupPair({
+ label,
+ intent,
+ children,
+}: {
+ label: string;
+ intent: string;
+ children: (device: Device) => ReactNode;
+}) {
+ return (
+
+
+
+
+ Desktop + phone
+
+
+
+
{children("desktop")}
+
{children("phone")}
+
+
+ );
+}
+
+function ScopeControlStrip({ device }: { device: Device }) {
+ return (
+
+
+
+
+
+
Scope
+
+ All documents
+
+
+
+
+
All documents
+
Recently updated
+
Pinned first
+
+
+
+
+
+ {device === "desktop" ? (
+
+ Clear refine filters
+
+ ) : null}
+
+
+
+
+ );
+}
+
+function ScopeSplitWorkbench({ device }: { device: Device }) {
+ const isDesktop = device === "desktop";
+ return (
+
+
+
+
+
+
+ {sourceRows.slice(0, isDesktop ? 3 : 2).map((row) => (
+
+ ))}
+
+
+
+
+
Filter set
+
+ Compact
+
+
+
+
+
+
+
+
+ {scopeFilters.slice(0, isDesktop ? 7 : 4).map((filter) => (
+
+ {filter}
+
+ ))}
+
+
+
+
+ );
+}
+
+function ScopeFacetRail({ device }: { device: Device }) {
+ const isDesktop = device === "desktop";
+ return (
+
+
+
+
+ {scopeFilters.slice(0, isDesktop ? 8 : 5).map((filter, index) => (
+
+
+ {filter}
+
+ ))}
+
+
+
+
+
+
+ {isDesktop ? : null}
+
+
+
+
+
+ );
+}
+
+function LibraryCompactSheet({ device }: { device: Device }) {
+ const isDesktop = device === "desktop";
+ return (
+
+
+
+
+
+
Source library
+
0 matching documents.
+
+
+ 0 shown
+
+
+
+
+ {libraryFilters.map((filter) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function LibraryStatusSplit({ device }: { device: Device }) {
+ const isDesktop = device === "desktop";
+ return (
+
+
+
+
+ Status
+
+ Indexed
+
+ Recent
+ Needs indexing
+ Review due
+
+
+
+
+ {sourceRows.slice(0, isDesktop ? 3 : 2).map((row) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function LibraryCommandPalette({ device }: { device: Device }) {
+ const isDesktop = device === "desktop";
+ return (
+
+
+
+
+
+
+
+ Open PDF
+
+
Tables
+
Current
+
+
+ {sourceRows.slice(0, isDesktop ? 3 : 2).map((row) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+export function SourceOverlayRedesignMockups() {
+ return (
+
+
+
+
+
+
+
+
Set 1
+
Document scope
+
+
+ 3 alternatives
+
+
+
+ {(device) => }
+
+
+ {(device) => }
+
+
+ {(device) => }
+
+
+
+
+
+
+
Set 2
+
Source library
+
+
+ 3 alternatives
+
+
+
+ {(device) => }
+
+
+ {(device) => }
+
+
+ {(device) => }
+
+
+
+
+ );
+}
diff --git a/src/components/tools-page-mockups/split-pane-refined-mockups.tsx b/src/components/tools-page-mockups/split-pane-refined-mockups.tsx
new file mode 100644
index 000000000..58f2a5d4a
--- /dev/null
+++ b/src/components/tools-page-mockups/split-pane-refined-mockups.tsx
@@ -0,0 +1,664 @@
+"use client";
+
+import Link from "next/link";
+import {
+ ArrowRight,
+ CheckCircle2,
+ ClipboardList,
+ Clock3,
+ Menu,
+ Search,
+ ShieldCheck,
+ Sparkles,
+ Stethoscope,
+ X,
+ type LucideIcon,
+} from "lucide-react";
+import { useMemo, useState } from "react";
+
+import { cn } from "@/components/ui-primitives";
+
+import { areaLabels, statusLabels, tools, type ToolFixture } from "./tool-fixtures";
+
+type RefinedVariant = "clinical-brief" | "safety-deck" | "compact-sheet";
+
+const focusRing =
+ "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]";
+
+const variantCopy: Record<
+ RefinedVariant,
+ {
+ title: string;
+ body: string;
+ defaultToolId: string;
+ accent: string;
+ detailTitle: string;
+ phoneTitle: string;
+ }
+> = {
+ "clinical-brief": {
+ title: "Tools clinical brief",
+ body: "Select a clinical tool, scan the important context, then open the workspace when it is the right fit.",
+ defaultToolId: "clinical-kb-search",
+ accent: "Current task",
+ detailTitle: "Clinical brief",
+ phoneTitle: "Mobile brief",
+ },
+ "safety-deck": {
+ title: "Tools safety deck",
+ body: "Put source confidence, review status, and clinical use cases directly beside the tool list.",
+ defaultToolId: "medication-prescribing",
+ accent: "Safety first",
+ detailTitle: "Use safely",
+ phoneTitle: "Mobile safety sheet",
+ },
+ "compact-sheet": {
+ title: "Tools compact sheet",
+ body: "A lean split-pane directory with the mobile popup as the main decision surface.",
+ defaultToolId: "services",
+ accent: "Fast path",
+ detailTitle: "Tool sheet",
+ phoneTitle: "Mobile action sheet",
+ },
+};
+
+const clinicalNotes: Record<
+ string,
+ {
+ when: string[];
+ checks: string[];
+ outputs: string[];
+ }
+> = {
+ "clinical-kb-search": {
+ when: ["Point-of-care clinical question", "Need cited source context", "Clarify guideline or policy wording"],
+ checks: ["Source-backed answer", "Citation trail", "Escalate if evidence is thin"],
+ outputs: ["Referenced answer", "Source snippets", "Follow-up search"],
+ },
+ documents: {
+ when: [
+ "Find a policy, table, image, or PDF page",
+ "Check local guideline wording",
+ "Open supporting source material",
+ ],
+ checks: ["Document provenance", "Page and chunk context", "Index health"],
+ outputs: ["Source document", "Relevant page", "Reusable source link"],
+ },
+ differentials: {
+ when: ["Compare diagnostic possibilities", "Structure uncertainty", "Review risk and presentation fit"],
+ checks: ["Red flags", "Supporting and opposing features", "Next-step questions"],
+ outputs: ["Differential set", "Comparison table", "Clinical prompts"],
+ },
+ "medication-prescribing": {
+ when: ["Review medication context", "Check monitoring or interactions", "Prepare prescribing support"],
+ checks: ["Contraindications", "Monitoring requirements", "Interaction cautions"],
+ outputs: ["Prescribing context", "Monitoring plan", "Caution list"],
+ },
+ services: {
+ when: ["Find referral route", "Check eligibility", "Confirm service contact or pathway"],
+ checks: ["Catchment", "Referral criteria", "Source-backed contact details"],
+ outputs: ["Referral pathway", "Eligibility notes", "Service record"],
+ },
+ forms: {
+ when: ["Find a clinical form", "Check readiness tasks", "Complete pathway paperwork"],
+ checks: ["Form currency", "Required fields", "Linked service pathway"],
+ outputs: ["Form link", "Completion checklist", "Readiness note"],
+ },
+ favourites: {
+ when: ["Resume saved clinical work", "Return to repeated workflows", "Open pinned sources"],
+ checks: ["Saved context", "Last-used status", "Review due markers"],
+ outputs: ["Saved item", "Pinned source", "Recent workflow"],
+ },
+};
+
+function IconBox({ icon: Icon, active = false }: { icon: LucideIcon; active?: boolean }) {
+ return (
+
+
+
+ );
+}
+
+function StatusBadge({ tool }: { tool: ToolFixture }) {
+ return (
+
+ {statusLabels[tool.status]}
+
+ );
+}
+
+function SourceBadge({ tool }: { tool: ToolFixture }) {
+ return (
+
+
+ {tool.sourceBacked ? "Source" : "Saved"}
+
+ );
+}
+
+function SearchBar({ value, onChange }: { value: string; onChange: (value: string) => void }) {
+ return (
+
+ );
+}
+
+function ToolList({
+ selectedId,
+ onSelect,
+ query,
+ onQueryChange,
+ variant,
+}: {
+ selectedId: string;
+ onSelect: (tool: ToolFixture) => void;
+ query: string;
+ onQueryChange: (value: string) => void;
+ variant: RefinedVariant;
+}) {
+ const visibleTools = useMemo(() => {
+ const normalized = query.trim().toLowerCase();
+ if (!normalized) return tools;
+ return tools.filter((tool) =>
+ [tool.title, tool.description, tool.secondary, areaLabels[tool.area]]
+ .join(" ")
+ .toLowerCase()
+ .includes(normalized),
+ );
+ }, [query]);
+
+ return (
+
+
+
+ {["All", "Assess", "Reference", "Treat", "Coordinate"].map((label) => (
+
+ {label}
+
+ ))}
+
+
+ {visibleTools.map((tool) => {
+ const Icon = tool.icon;
+ const active = selectedId === tool.id;
+ return (
+ onSelect(tool)}
+ className={cn(
+ "grid min-h-[5.25rem] grid-cols-[auto_minmax(0,1fr)] gap-3 rounded-md border p-3 text-left transition",
+ active
+ ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] shadow-[var(--shadow-tight)]"
+ : "border-[color:var(--border)] bg-[color:var(--surface)] hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--surface-raised)]",
+ variant === "compact-sheet" && "min-h-[4.5rem]",
+ focusRing,
+ )}
+ >
+
+
+
+ {tool.title}
+
+
+ {tool.description}
+
+
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+function ClinicalChecklist({ tool, variant }: { tool: ToolFixture; variant: RefinedVariant }) {
+ const notes = clinicalNotes[tool.id] ?? clinicalNotes["clinical-kb-search"];
+ const iconMap: Record = {
+ checks: ShieldCheck,
+ outputs: ClipboardList,
+ when: Stethoscope,
+ };
+ const labels: Record = {
+ checks: variant === "safety-deck" ? "Safety checks" : "Check before opening",
+ outputs: "What you get",
+ when: "Use when",
+ };
+
+ return (
+
+ {(["when", "checks", "outputs"] as const).map((key) => {
+ const Icon = iconMap[key];
+ return (
+
+
+
+
{labels[key]}
+
+
+ {notes[key].map((item) => (
+
+
+ {item}
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
+
+function DetailPanel({ tool, variant }: { tool: ToolFixture; variant: RefinedVariant }) {
+ const copy = variantCopy[variant];
+ const Icon = tool.icon;
+
+ return (
+
+
+
+
+
+
+
+ {copy.detailTitle}
+
+
+
+
+
+ {tool.title}
+
+
+ {tool.description}
+
+
+
+
+ {tool.primaryAction}
+
+
+
+
+
+
+
+
+
Clinical handling
+
+ Keep source context visible, confirm recency, and move into the tool only when the task matches the clinical
+ intent.
+
+
+
+ {["Source context visible", "Review status shown", "Mobile action mirrors desktop"].map((item) => (
+
+
+ {item}
+
+ ))}
+
+
+
+ );
+}
+
+function PhonePopup({
+ tool,
+ variant,
+ open,
+ onOpen,
+ onClose,
+ onSelectTool,
+}: {
+ tool: ToolFixture;
+ variant: RefinedVariant;
+ open: boolean;
+ onOpen: () => void;
+ onClose: () => void;
+ onSelectTool: (tool: ToolFixture) => void;
+}) {
+ const copy = variantCopy[variant];
+ const Icon = tool.icon;
+ const notes = clinicalNotes[tool.id] ?? clinicalNotes["clinical-kb-search"];
+
+ return (
+
+
+
+
Phone
+
{copy.phoneTitle}
+
+
+ 390px
+
+
+
+
+
+
+
+ Tools
+
+
+
+ {tools.slice(0, 5).map((item) => {
+ const ItemIcon = item.icon;
+ const active = item.id === tool.id;
+ return (
+
onSelectTool(item)}
+ className={cn(
+ "grid min-h-14 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-md border bg-[color:var(--surface)] px-2 text-left shadow-[var(--shadow-inset)]",
+ active ? "border-[color:var(--clinical-accent-border)]" : "border-[color:var(--border)]",
+ focusRing,
+ )}
+ >
+
+
+
+ {item.title}
+
+
+ {item.primaryAction}
+
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ {tool.title}
+
+
+ {copy.accent}
+
+
+
+
+ {open ? (
+
+ ) : (
+
+ )}
+
+
+
+ {open ? (
+
+
{tool.description}
+
+
+
+
+
+
+ Use when
+
+
{notes.when[0]}
+
+
+ {tool.primaryAction}
+
+
+
+ ) : null}
+
+
+
+
+ );
+}
+
+function MobileViewportSheet({
+ tool,
+ variant,
+ open,
+ onClose,
+}: {
+ tool: ToolFixture;
+ variant: RefinedVariant;
+ open: boolean;
+ onClose: () => void;
+}) {
+ const copy = variantCopy[variant];
+ const Icon = tool.icon;
+ const notes = clinicalNotes[tool.id] ?? clinicalNotes["clinical-kb-search"];
+
+ if (!open) return null;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {copy.phoneTitle}
+
+
+
+
+
+
+
+
+
{tool.description}
+
+
+
+
+
+
+
+
+
Use when
+
+
+ {notes.when.slice(0, 2).map((item) => (
+
+
+ {item}
+
+ ))}
+
+
+
+
+
+
+
Check first
+
+
{notes.checks.join(", ")}
+
+
+
+ {tool.primaryAction}
+
+
+
+
+
+ );
+}
+
+export function ToolsSplitPaneRefinedMockup({ variant }: { variant: RefinedVariant }) {
+ const copy = variantCopy[variant];
+ const [query, setQuery] = useState("");
+ const [selectedId, setSelectedId] = useState(copy.defaultToolId);
+ const [phoneOpen, setPhoneOpen] = useState(false);
+ const selectedTool = tools.find((tool) => tool.id === selectedId) ?? tools[0];
+
+ function selectTool(tool: ToolFixture) {
+ setSelectedId(tool.id);
+ setPhoneOpen(true);
+ }
+
+ return (
+
+
+
+
+
+
+
+ Concept 3
+
+
+
+ {copy.accent}
+
+
+
+ {copy.title}
+
+
+ {copy.body}
+
+
+
+
+
+ Continue medication review
+
+
+ Monitoring plan review · May 12, 2025
+
+
+
+
+
+
+
+
+
+ setPhoneOpen(true)}
+ onClose={() => setPhoneOpen(false)}
+ onSelectTool={selectTool}
+ />
+
+
setPhoneOpen(false)} />
+
+ );
+}
diff --git a/src/components/tools-page-mockups/tools-page-mockup-page.tsx b/src/components/tools-page-mockups/tools-page-mockup-page.tsx
index 92d1fd53d..69fa1c1c6 100644
--- a/src/components/tools-page-mockups/tools-page-mockup-page.tsx
+++ b/src/components/tools-page-mockups/tools-page-mockup-page.tsx
@@ -22,7 +22,7 @@ import {
X,
type LucideIcon,
} from "lucide-react";
-import type { ReactNode } from "react";
+import { useState, type ReactNode } from "react";
import { cn } from "@/components/ui-primitives";
@@ -416,26 +416,28 @@ function WideToolTile({
tool,
suggested = false,
compact = false,
+ selected = false,
+ onSelect,
}: {
tool: ToolFixture;
suggested?: boolean;
compact?: boolean;
+ selected?: boolean;
+ onSelect?: () => void;
}) {
- return (
-
+ const className = cn(
+ "group grid min-h-[9rem] min-w-0 max-w-full content-between rounded-md border bg-[color:var(--surface)] p-4 text-left shadow-[var(--shadow-inset)] transition hover:-translate-y-0.5 hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--surface-raised)] hover:shadow-[var(--shadow-soft)]",
+ selected || suggested
+ ? "border-[color:var(--clinical-accent-border)] ring-1 ring-[color:var(--clinical-accent)]/25"
+ : "border-[color:var(--border)]",
+ compact && "min-h-[7.75rem]",
+ focusRing,
+ );
+
+ const content = (
+ <>
-
+
@@ -458,8 +460,25 @@ function WideToolTile({
{tool.secondary}
-
+
+ >
+ );
+
+ if (onSelect) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
);
}
@@ -499,6 +518,8 @@ function WideAllToolsSection({
title = "All tools",
body = "Every tool opens directly. Pick by task, status, or last-used context.",
compact = false,
+ selectedToolId,
+ onSelectTool,
}: {
visibleTools: ToolFixture[];
totalCount: number;
@@ -508,6 +529,8 @@ function WideAllToolsSection({
title?: string;
body?: string;
compact?: boolean;
+ selectedToolId?: string;
+ onSelectTool?: (tool: ToolFixture) => void;
}) {
return (
@@ -530,7 +553,14 @@ function WideAllToolsSection({
{visibleTools.length > 0 ? (
{visibleTools.map((tool) => (
-
+ onSelectTool(tool) : undefined}
+ />
))}
) : (
@@ -544,12 +574,19 @@ function PhoneBrowserPreview({
title,
toolIds,
mode = "launch",
+ selectedTool,
+ onSelectTool,
+ onBackToDirectory,
}: {
title: string;
toolIds: string[];
mode?: "launch" | "workflow" | "directory";
+ selectedTool?: ToolFixture;
+ onSelectTool?: (tool: ToolFixture) => void;
+ onBackToDirectory?: () => void;
}) {
const featured = toolIds.map(toolById);
+ const SelectedIcon = selectedTool?.icon;
return (
@@ -575,93 +612,174 @@ function PhoneBrowserPreview({
-
-
- {mode === "workflow" ? (
-
- {["Assess", "Reference", "Treat", "Coordinate"].map((label) => (
-
- {label}
-
- ))}
-
- ) : null}
+ {selectedTool && SelectedIcon ? (
+
+
+
+ All tools
+
-
- {featured.map((tool, index) => {
- const Icon = tool.icon;
- return (
+
+
+
+ {selectedTool.title}
+
+
+ {selectedTool.description}
+
+
+
+ {selectedTool.sourceBacked ? : null}
+
+
+
+
+ Best for
+
+
+ {selectedTool.secondary}
+
+
+
+
+ Last used
+
+
+ {selectedTool.lastUsed}
+
+
+
-
-
-
- {tool.title}
-
-
- {areaLabels[tool.area]}
-
-
-
- {tool.sourceBacked ? (
-
- ) : null}
-
-
+ Open {selectedTool.primaryAction.toLowerCase()}
+
- );
- })}
-
+
-
-
-
- Recent work
-
- View
+
-
- {recentWork.slice(0, 2).map((item) => {
- const Icon = item.icon;
- return (
-
-
-
-
- {item.title}
+ ) : null}
+
+ {!selectedTool ? (
+ <>
+
+
+ {mode === "workflow" ? (
+
+ {["Assess", "Reference", "Treat", "Coordinate"].map((label) => (
+
+ {label}
+
+ ))}
+
+ ) : null}
+
+
+ {featured.map((tool, index) => {
+ const Icon = tool.icon;
+ const interactive = typeof onSelectTool === "function";
+ const rowClassName = cn(
+ "grid min-h-14 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-md bg-[color:var(--surface)] px-2 text-left shadow-[var(--shadow-inset)]",
+ mode === "directory" && "rounded-none border-t border-[color:var(--border)] first:border-t-0",
+ index === 0 && mode !== "directory" && "border border-[color:var(--clinical-accent-border)]",
+ focusRing,
+ );
+ const rowContent = (
+ <>
+
+
+
+ {tool.title}
+
+
+ {areaLabels[tool.area]}
+
-
- {item.area}
+
+ {tool.sourceBacked ? (
+
+ ) : null}
+
-
-
-
- );
- })}
-
-
+ >
+ );
+
+ if (interactive) {
+ return (
+
onSelectTool(tool)} className={rowClassName}>
+ {rowContent}
+
+ );
+ }
+
+ return (
+
+ {rowContent}
+
+ );
+ })}
+
+
+
+
+
+ Recent work
+
+ View
+
+
+ {recentWork.slice(0, 2).map((item) => {
+ const Icon = item.icon;
+ return (
+
+
+
+
+ {item.title}
+
+
+ {item.area}
+
+
+
+
+ );
+ })}
+
+
+ >
+ ) : null}
@@ -906,6 +1024,8 @@ const splitPaneFilters: { id: ToolFilterId; label: string; icon: LucideIcon }[]
function SplitPaneMockup() {
const filter = useToolFilter(tools);
const suggestedId = "services";
+ const [selectedToolId, setSelectedToolId] = useState(suggestedId);
+ const selectedTool = selectedToolId ? toolById(selectedToolId) : undefined;
return (
<>
@@ -973,6 +1093,9 @@ function SplitPaneMockup() {
title="Pocket directory"
toolIds={["clinical-kb-search", "documents", "differentials", suggestedId, "forms", "favourites"]}
mode="directory"
+ selectedTool={selectedTool}
+ onSelectTool={(tool) => setSelectedToolId(tool.id)}
+ onBackToDirectory={() => setSelectedToolId("")}
/>
@@ -983,8 +1106,10 @@ function SplitPaneMockup() {
onClearFilters={filter.reset}
suggestedId={suggestedId}
title="All tools"
- body="The directory is intentionally wide here: spacious cards, direct actions, and clear task grouping across the page."
+ body="Click any tool to preview it in the phone frame, then open the actual tool from the phone action."
compact
+ selectedToolId={selectedToolId}
+ onSelectTool={(tool) => setSelectedToolId(tool.id)}
/>
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
index 0d9b5c275..361ba5ad2 100644
--- a/src/components/ui/sheet.tsx
+++ b/src/components/ui/sheet.tsx
@@ -5,6 +5,8 @@ import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { cn, toolbarButton } from "@/components/ui-primitives";
+export type SheetMobileSize = "content" | "viewport";
+
/**
* Responsive overlay: a bottom sheet on mobile (rises from the bottom, safe-area
* aware, drag-grip) and a centred dialog from `sm:` up. CSS-only animation, no
@@ -35,6 +37,7 @@ export function Sheet({
bodyClassName,
placement = "default",
mobilePlacement = "bottom",
+ mobileSize = "content",
portal = false,
}: {
open: boolean;
@@ -59,6 +62,7 @@ export function Sheet({
bodyClassName?: string;
placement?: "default" | "left";
mobilePlacement?: "bottom" | "top" | "fullscreen";
+ mobileSize?: SheetMobileSize;
portal?: boolean;
}) {
const panelRef = useRef
(null);
@@ -73,7 +77,13 @@ export function Sheet({
const previousActiveElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
- const focusFrame = window.requestAnimationFrame(() => (initialFocusRef?.current ?? closeRef.current)?.focus());
+ const focusFrame = window.requestAnimationFrame(() => {
+ const focusTarget =
+ initialFocusRef?.current ??
+ panelRef.current?.querySelector('[data-sheet-autofocus="true"]') ??
+ closeRef.current;
+ focusTarget?.focus({ preventScroll: true });
+ });
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
@@ -131,6 +141,7 @@ export function Sheet({
const resolvedLabelledBy = labelledBy ?? (title ? titleId : undefined);
const defaultSheetIsFullscreen = placement !== "left" && mobilePlacement === "fullscreen";
const defaultSheetIsTopAligned = placement !== "left" && mobilePlacement === "top";
+ const defaultSheetUsesViewportSize = placement !== "left" && mobileSize === "viewport";
const sheet = (
{
const sidebar = page.locator("#clinical-tools-sidebar");
const modeButton = page.getByRole("button", { name: "Mode Tools" });
await expect(modeButton).toBeVisible();
+ const selectedToolSheet = page.getByRole("dialog", { name: "Risk & Safety" });
+ if (await isVisibleWithoutThrow(selectedToolSheet)) {
+ await selectedToolSheet.getByRole("button", { name: "Close Risk & Safety" }).click();
+ await expect(selectedToolSheet).toBeHidden();
+ }
+ const expandSidebar = page.getByRole("button", { name: "Expand sidebar" });
+ await expect(expandSidebar).toBeVisible();
+ await expectMinTouchTarget(expandSidebar);
+ await expect(page.getByTestId("collapsed-account-settings")).toHaveAccessibleName(
+ /G Guest Not signed in\. Set up workspace/,
+ );
+ await expect(sidebar).toHaveCount(0);
+ await expandSidebar.click();
+ await expect(sidebar).toBeVisible();
await expect(sidebar.getByRole("link", { name: "View tools" })).toHaveCount(0);
await expect(sidebar.getByRole("link", { name: "Tools", exact: true })).toHaveAttribute("href", "/?mode=tools");
await expect(sidebar.getByTestId("sidebar-account-settings")).toHaveAccessibleName(
- /G Guest Not signed in\. Open account profile/,
+ /G Guest Not signed in\. Set up workspace/,
);
const collapseSidebar = page.getByRole("button", { name: "Collapse sidebar" });
await expectMinTouchTarget(collapseSidebar);
await collapseSidebar.click();
await expect(page.getByTestId("collapsed-account-settings")).toHaveAccessibleName(
- /G Guest Not signed in\. Open account profile/,
+ /G Guest Not signed in\. Set up workspace/,
);
- await page.getByRole("button", { name: "Expand sidebar" }).click();
+ await expandSidebar.click();
await sidebar.getByRole("link", { name: "Answer", exact: true }).click();
await expect(page).toHaveURL(/\/\?mode=answer$/);
await expect(page.getByRole("button", { name: "Mode Answer" })).toBeVisible();
@@ -659,13 +692,18 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(page.getByRole("heading", { name: "Something went wrong" })).toHaveCount(0);
});
- test("account settings opens from desktop sidebar and collapsed avatar", async ({ page }) => {
+ test("account setup opens from desktop sidebar account affordances while settings stays separate", async ({
+ page,
+ }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockDemoApi(page);
await gotoApp(page, "/");
await waitForDemoDashboardReady(page);
const settings = accountSettingsDialog(page);
+ const setup = accountSetupDialog(page);
+ await page.getByRole("button", { name: "Expand sidebar" }).click();
+ await expect(page.locator("#clinical-tools-sidebar")).toBeVisible();
await page.locator("#clinical-tools-sidebar").getByRole("button", { name: "Settings", exact: true }).click();
await expect(settings).toBeVisible();
await expectAccountSettingsSurface(settings);
@@ -674,10 +712,17 @@ test.describe("Clinical KB UI smoke coverage", () => {
await settings.getByRole("button", { name: "Close settings" }).click();
await expect(settings).toBeHidden();
+ await page.locator("#clinical-tools-sidebar").getByTestId("sidebar-account-settings").click();
+ await expect(setup).toBeVisible();
+ await expectAccountSetupSurface(setup);
+ await expectNoPageHorizontalOverflow(page);
+ await setup.getByRole("button", { name: "Close account setup" }).click();
+ await expect(setup).toBeHidden();
+
await page.getByRole("button", { name: "Collapse sidebar" }).click();
await page.getByTestId("collapsed-account-settings").click();
- await expect(settings).toBeVisible();
- await expectAccountSettingsSurface(settings);
+ await expect(setup).toBeVisible();
+ await expectAccountSetupSurface(setup);
});
test("account settings uses a fullscreen settings page below desktop and closes from X and Escape", async ({
@@ -689,6 +734,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
await waitForDemoDashboardReady(page);
const settings = accountSettingsDialog(page);
+ const setup = accountSetupDialog(page);
const menu = await openMobileClinicalGuideMenu(page);
await menu.getByRole("button", { name: "Settings", exact: true }).click();
await expect(menu).toHaveCount(0);
@@ -715,6 +761,17 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(settings).toBeVisible();
await page.keyboard.press("Escape");
await expect(settings).toBeHidden();
+
+ const accountMenu = await openMobileClinicalGuideMenu(page);
+ await accountMenu.getByTestId("sidebar-account-settings").click();
+ await expect(accountMenu).toHaveCount(0);
+ await expect(setup).toBeVisible();
+ await expectAccountSetupSurface(setup);
+ const setupBox = await setup.boundingBox();
+ expect(setupBox).not.toBeNull();
+ expect(setupBox!.x).toBeGreaterThanOrEqual(-1);
+ expect(setupBox!.width + fullscreenTolerance).toBeLessThanOrEqual(viewport.width + fullscreenTolerance);
+ await expectNoPageHorizontalOverflow(page);
});
test("private mode unauthenticated dashboard gates real-mode search", async ({ page }) => {
@@ -1349,14 +1406,33 @@ test.describe("Clinical KB UI smoke coverage", () => {
expect(startHereBox).not.toBeNull();
expect((searchInputBox?.y ?? 0) + (searchInputBox?.height ?? 0) / 2).toBeGreaterThan(820 * 0.72);
expect((startHereBox?.y ?? 0) + (startHereBox?.height ?? 0)).toBeLessThan(searchInputBox?.y ?? 0);
- await expect(page.getByRole("button", { name: /Recent documents/i })).toBeVisible();
- await expect(page.getByRole("button", { name: /Browse library/i })).toBeVisible();
- await expect(page.getByRole("button", { name: /Open a source PDF/i })).toBeVisible();
- await page.getByRole("region", { name: "Smart facets" }).scrollIntoViewIfNeeded();
- await expect(page.getByRole("region", { name: "Smart facets" })).toBeVisible();
- await page.getByRole("region", { name: "Recent documents" }).scrollIntoViewIfNeeded();
- await expect(page.getByRole("region", { name: "Recent documents" })).toBeVisible();
- await expect(page.locator('a[href^="/documents/"]').first()).toBeVisible();
+ const recentDocumentsButton = page.getByRole("button", { name: /Recent documents/i }).first();
+ const browseLibraryButton = page.getByRole("button", { name: /Browse library/i }).first();
+ const sourcePdfButton = page.getByRole("button", { name: /Open a source PDF/i }).first();
+ await expect(recentDocumentsButton).toBeVisible();
+ await expect(browseLibraryButton).toBeVisible();
+ await expect(sourcePdfButton).toBeVisible();
+
+ await recentDocumentsButton.click();
+ const recentDocumentsDialog = page.getByRole("dialog", { name: "Recent documents" });
+ await expect(recentDocumentsDialog).toBeVisible();
+ await expect(recentDocumentsDialog.getByPlaceholder("Find a document")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(recentDocumentsDialog).toHaveCount(0);
+
+ await browseLibraryButton.click();
+ const sourceLibraryDialog = page.getByRole("dialog", { name: "Source library" });
+ await expect(sourceLibraryDialog).toBeVisible();
+ await expect(sourceLibraryDialog.getByPlaceholder("Find a document")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(sourceLibraryDialog).toHaveCount(0);
+
+ await sourcePdfButton.click();
+ const sourcePdfDialog = page.getByRole("dialog", { name: "Source PDFs" });
+ await expect(sourcePdfDialog).toBeVisible();
+ await expect(sourcePdfDialog.getByPlaceholder("Find a source PDF")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(sourcePdfDialog).toHaveCount(0);
await expect(page.getByText("Source library workspace")).toHaveCount(0);
await expect(page.getByText("Document display")).toHaveCount(0);
@@ -1364,23 +1440,18 @@ test.describe("Clinical KB UI smoke coverage", () => {
await questionInput.fill("lithium monitoring");
await page.getByRole("button", { name: "Find matching documents" }).click();
- await expect(page.getByText("Synthetic lithium monitoring protocol").first()).toBeVisible();
- await expect(page.getByRole("heading", { name: "1 document" })).toBeVisible();
- await expect(page.getByText("1 table").first()).toBeVisible();
- await expect(page.getByTestId("document-search-workspace")).toContainText("Best match");
- await expect(page.getByTestId("document-search-workspace")).toContainText("Relevant");
- await expect(page.getByRole("complementary", { name: "Selected document evidence" })).toBeVisible();
- await expect(page.getByRole("link", { name: /Open exact evidence/i })).toBeVisible();
- await expect(page.getByRole("button", { name: "Lithium", exact: true })).toBeVisible();
- await expect(page.getByText("Tag facets")).toHaveCount(0);
- await expectMinTouchTarget(page.getByRole("link", { name: /Open Synthetic lithium/i }).first());
- await expect(page.getByRole("button", { name: /Preview evidence for/i }).first()).toBeVisible();
- await expect(page.getByRole("button", { name: /Scope search to/i }).first()).toBeVisible();
- await page
- .getByRole("button", { name: /Answer from/i })
- .first()
- .click();
- await expect(page.getByRole("heading", { name: "Answer" })).toBeVisible();
+ await expect(page).toHaveURL(/\/mockups\/document-search-command\?.*q=lithium\+monitoring/);
+ await expect(page.getByRole("heading", { name: "Search command centre" })).toBeVisible();
+ const documentResults = page.getByRole("region", { name: "Document results" });
+ await expect(documentResults).toContainText("Clozapine prescribing and monitoring guidelines");
+ await expect(documentResults).toContainText("Best match");
+ await expect(documentResults).toContainText("Relevant");
+ await expect(documentResults.getByRole("link", { name: "Open document" }).first()).toBeVisible();
+ await expect(documentResults.getByRole("link", { name: "Evidence" }).first()).toBeVisible();
+ const selectedSource = page.getByRole("complementary").filter({ hasText: "Selected source" });
+ await expect(selectedSource).toBeVisible();
+ await expect(selectedSource).toContainText("Evidence on page");
+ await expect(selectedSource.getByRole("link", { name: "Open evidence" })).toBeVisible();
await expectNoPageHorizontalOverflow(page);
});
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts
index 60a153e8b..bc1a7dcf5 100644
--- a/tests/ui-tools.spec.ts
+++ b/tests/ui-tools.spec.ts
@@ -14,6 +14,42 @@ async function expectNoPageHorizontalOverflow(page: Page) {
expect(overflow).toBeLessThanOrEqual(2);
}
+function visibleGlobalSearchInput(page: Page) {
+ return page.locator('[data-testid="global-search-input"]:visible');
+}
+
+async function globalSearchComposerMetrics(page: Page, homeTestId?: string) {
+ return visibleGlobalSearchInput(page)
+ .first()
+ .evaluate((input, homeTestId) => {
+ const form = input.closest("form");
+ const pill = input.closest(".answer-footer-search-pill");
+ const home = homeTestId ? document.querySelector(`[data-testid="${homeTestId}"]`) : null;
+ if (!form) return null;
+
+ const formRect = form.getBoundingClientRect();
+ const homeRect = home?.getBoundingClientRect();
+ const style = window.getComputedStyle(form);
+
+ return {
+ formLeft: formRect.left,
+ formRight: formRect.right,
+ formTop: formRect.top,
+ formBottom: formRect.bottom,
+ formWidth: formRect.width,
+ formCenterX: formRect.left + formRect.width / 2,
+ formCenterY: formRect.top + formRect.height / 2,
+ homeLeft: homeRect?.left ?? null,
+ homeRight: homeRect?.right ?? null,
+ homeCenterX: homeRect ? homeRect.left + homeRect.width / 2 : null,
+ position: style.position,
+ viewportWidth: window.innerWidth,
+ viewportHeight: window.innerHeight,
+ pillClassName: pill?.className?.toString() ?? "",
+ };
+ }, homeTestId);
+}
+
async function expectVerticalSeparation(page: Page, upperSelector: string, lowerSelector: string, minimumGap = 8) {
const metrics = await page.evaluate(
({ upperSelector, lowerSelector }) => {
@@ -157,7 +193,9 @@ test.describe("Clinical KB applications launcher", () => {
await expect(page.getByRole("button", { name: "Mode Services" })).toBeVisible();
await expect(page.getByTestId("services-home")).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: "Find a service" })).toBeVisible();
- await expect(page.getByTestId("global-search-input")).toHaveCount(1);
+ await expect(page.getByRole("button", { name: "Expand sidebar" })).toBeVisible();
+ await expect(page.getByTestId("collapsed-account-settings")).toBeVisible();
+ await expect(visibleGlobalSearchInput(page)).toHaveCount(1);
const servicesHomeSearch = page.getByTestId("services-home").getByTestId("global-search-input");
await expect(servicesHomeSearch).toBeVisible();
const servicesSearchBox = await servicesHomeSearch.boundingBox();
@@ -166,7 +204,7 @@ test.describe("Clinical KB applications launcher", () => {
expect(servicesHeadingBox).not.toBeNull();
expect((servicesHeadingBox?.y ?? 0) + (servicesHeadingBox?.height ?? 0)).toBeLessThan(servicesSearchBox?.y ?? 0);
expect((servicesSearchBox?.y ?? 0) + (servicesSearchBox?.height ?? 0) / 2).toBeLessThan(900 * 0.62);
- await expect(page.getByTestId("global-search-input")).toHaveValue("");
+ await expect(visibleGlobalSearchInput(page)).toHaveValue("");
const servicesMenu = await openAppModeMenu(page, "Services");
await expect(servicesMenu.getByRole("menuitemradio", { name: /^Answer\b/ })).toBeVisible();
await expect(servicesMenu.getByRole("menuitemradio", { name: /^Documents\b/ })).toBeVisible();
@@ -175,12 +213,16 @@ test.describe("Clinical KB applications launcher", () => {
await expect(servicesMenu.getByRole("menuitemradio", { name: /^Differentials\b/ })).toBeVisible();
await expect(servicesMenu.getByRole("menuitemradio", { name: /^Medication\b/ })).toBeVisible();
await expect(servicesMenu.getByRole("menuitemradio", { name: /^Tools\b/ })).toBeVisible();
- await servicesMenu.getByRole("menuitemradio", { name: /^Forms\b/ }).click();
- await expect(page).toHaveURL(/\/forms$/);
+ await expect(async () => {
+ if (/\/forms$/.test(page.url())) return;
+ const menu = await openAppModeMenu(page, "Services");
+ await menu.getByRole("menuitemradio", { name: /^Forms\b/ }).click();
+ await expect(page).toHaveURL(/\/forms$/, { timeout: 2_000 });
+ }).toPass({ timeout: 20_000 });
await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible();
await expect(page.getByTestId("forms-home")).toBeVisible();
await expect(page.getByTestId("form-search-results")).toHaveCount(0);
- await expect(page.getByTestId("global-search-input")).toHaveValue("");
+ await expect(visibleGlobalSearchInput(page)).toHaveValue("");
await expectNoPageHorizontalOverflow(page);
});
@@ -199,8 +241,8 @@ test.describe("Clinical KB applications launcher", () => {
await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible();
await expect(page.getByTestId("forms-home")).toBeVisible();
await expect(page.getByTestId("form-search-results")).toHaveCount(0);
- await expect(page.getByTestId("global-search-input")).toHaveCount(1);
- await expect(page.getByTestId("global-search-input")).toHaveValue("");
+ await expect(visibleGlobalSearchInput(page)).toHaveCount(1);
+ await expect(visibleGlobalSearchInput(page)).toHaveValue("");
await gotoLauncher(page, "/forms");
await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible();
@@ -213,8 +255,8 @@ test.describe("Clinical KB applications launcher", () => {
await expect(page.getByRole("button", { name: "Mode Services" })).toBeVisible();
await expect(page.getByTestId("services-home")).toBeVisible();
await expect(page.getByTestId("service-search-results")).toHaveCount(0);
- await expect(page.getByTestId("global-search-input")).toHaveCount(1);
- await expect(page.getByTestId("global-search-input")).toHaveValue("");
+ await expect(visibleGlobalSearchInput(page)).toHaveCount(1);
+ await expect(visibleGlobalSearchInput(page)).toHaveValue("");
await expectNoPageHorizontalOverflow(page);
});
@@ -228,42 +270,101 @@ test.describe("Clinical KB applications launcher", () => {
] as const) {
await gotoLauncher(page, home.path);
await expect(page.getByTestId(home.testId)).toBeVisible();
- await expect(page.getByTestId("global-search-input")).toHaveCount(1);
+ await expect(visibleGlobalSearchInput(page)).toHaveCount(1);
- const searchBox = await page.getByTestId("global-search-input").boundingBox();
+ const searchBox = await visibleGlobalSearchInput(page).boundingBox();
const headingBox = await page.getByRole("heading", { level: 1, name: home.heading }).boundingBox();
expect(searchBox).not.toBeNull();
expect(headingBox).not.toBeNull();
expect((searchBox?.y ?? 0) + (searchBox?.height ?? 0) / 2).toBeGreaterThan(820 * 0.72);
expect((headingBox?.y ?? 0) + (headingBox?.height ?? 0)).toBeLessThan(searchBox?.y ?? 0);
+ const metrics = await globalSearchComposerMetrics(page);
+ expect(metrics).not.toBeNull();
+ expect(metrics?.position).toBe("fixed");
+ expect(metrics?.formWidth ?? 0).toBeLessThanOrEqual(390 - 8);
+ expect(metrics?.pillClassName).toContain("answer-footer-search-pill");
await expectNoPageHorizontalOverflow(page);
}
});
- test("mode home routes lift the shared search into the hero on tablet", async ({ page }) => {
- await page.setViewportSize({ width: 768, height: 1024 });
+ test("mode home routes center the shared search from tablet up", async ({ page }) => {
+ test.setTimeout(150_000);
- for (const home of [
- { path: "/services", testId: "services-home", heading: "Find a service" },
- { path: "/forms", testId: "forms-home", heading: "What do you need from forms?" },
- { path: "/differentials", testId: "differentials-home", heading: "Differentials" },
+ for (const viewport of [
+ { name: "tablet", width: 768, height: 1024 },
+ { name: "desktop", width: 1280, height: 900 },
] as const) {
- await gotoLauncher(page, home.path);
- await expect(page.getByTestId(home.testId)).toBeVisible();
- await expect(page.getByTestId("global-search-input")).toHaveCount(1);
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
- // From the tablet breakpoint up the composer is portaled into the hero
- // (inside the mode-home container) rather than floated over the heading.
- const heroSearch = page.getByTestId(home.testId).getByTestId("global-search-input");
- await expect(heroSearch).toBeVisible();
+ for (const home of [
+ { path: "/services", testId: "services-home", heading: "Find a service" },
+ { path: "/forms", testId: "forms-home", heading: "What do you need from forms?" },
+ { path: "/differentials", testId: "differentials-home", heading: "Differentials" },
+ ] as const) {
+ await gotoLauncher(page, home.path);
+ await expect(page.getByTestId(home.testId)).toBeVisible();
+ await expect(visibleGlobalSearchInput(page)).toHaveCount(1);
+
+ // From the tablet breakpoint up the composer is portaled into the hero
+ // (inside the mode-home container) rather than floated over the heading.
+ const heroSearch = page.getByTestId(home.testId).getByTestId("global-search-input");
+ await expect(heroSearch).toBeVisible();
+
+ const searchBox = await heroSearch.boundingBox();
+ const headingBox = await page.getByRole("heading", { level: 1, name: home.heading }).boundingBox();
+ expect(searchBox).not.toBeNull();
+ expect(headingBox).not.toBeNull();
+ // Search sits below the heading with no overlap.
+ expect((headingBox?.y ?? 0) + (headingBox?.height ?? 0)).toBeLessThanOrEqual(searchBox?.y ?? 0);
+
+ const metrics = await globalSearchComposerMetrics(page, home.testId);
+ expect(metrics, `${home.path} at ${viewport.name}`).not.toBeNull();
+ expect(metrics?.position).not.toBe("fixed");
+ expect(metrics?.pillClassName).toContain("answer-footer-search-pill");
+ expect(metrics?.formWidth ?? 0).toBeLessThanOrEqual(viewport.width - 16);
+ expect(metrics?.homeLeft).not.toBeNull();
+ expect(metrics?.homeRight).not.toBeNull();
+ expect(metrics?.homeCenterX).not.toBeNull();
+ expect(metrics?.formLeft ?? 0).toBeGreaterThanOrEqual((metrics?.homeLeft ?? 0) - 1);
+ expect(metrics?.formRight ?? 0).toBeLessThanOrEqual((metrics?.homeRight ?? viewport.width) + 1);
+ expect(Math.abs((metrics?.formCenterX ?? 0) - (metrics?.homeCenterX ?? 0))).toBeLessThanOrEqual(24);
+ await expectNoPageHorizontalOverflow(page);
+ }
+ }
+ });
- const searchBox = await heroSearch.boundingBox();
- const headingBox = await page.getByRole("heading", { level: 1, name: home.heading }).boundingBox();
- expect(searchBox).not.toBeNull();
- expect(headingBox).not.toBeNull();
- // Search sits below the heading with no overlap.
- expect((headingBox?.y ?? 0) + (headingBox?.height ?? 0)).toBeLessThanOrEqual(searchBox?.y ?? 0);
- await expectNoPageHorizontalOverflow(page);
+ test("search result and detail routes keep top search from tablet up", async ({ page }) => {
+ test.setTimeout(150_000);
+
+ for (const viewport of [
+ { name: "mobile", width: 390, height: 820 },
+ { name: "tablet", width: 768, height: 1024 },
+ { name: "desktop", width: 1280, height: 900 },
+ ] as const) {
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
+
+ for (const route of ["/services?q=13YARN&focus=1&run=1", "/services/13yarn"] as const) {
+ await gotoLauncher(page, route);
+ await expect(page.getByRole("button", { name: "Mode Services" })).toBeVisible({ timeout: 20_000 });
+ await expect(visibleGlobalSearchInput(page), `${route} at ${viewport.name}`).toHaveCount(1, {
+ timeout: 20_000,
+ });
+
+ const metrics = await globalSearchComposerMetrics(page);
+ expect(metrics, `${route} at ${viewport.name}`).not.toBeNull();
+ expect(metrics?.pillClassName).toContain("answer-footer-search-pill");
+ expect(metrics?.formWidth ?? 0).toBeLessThanOrEqual(viewport.width - 8);
+
+ if (viewport.width < 640) {
+ expect(metrics?.position).toBe("fixed");
+ expect(metrics?.formCenterY ?? 0).toBeGreaterThan(viewport.height * 0.72);
+ } else {
+ expect(metrics?.position).toBe("sticky");
+ expect(metrics?.formCenterY ?? viewport.height).toBeLessThan(viewport.height * 0.25);
+ }
+
+ await expectNoPageHorizontalOverflow(page);
+ }
}
});
@@ -555,10 +656,9 @@ test.describe("Clinical KB applications launcher", () => {
await expect(page.getByRole("heading", { name: "Safety snapshot" }).first()).toBeVisible();
await expect(page.getByText("Service details")).toHaveCount(0);
await expect(page.getByText("Transport order")).toHaveCount(0);
- await expect(page.getByText("Local only")).toBeVisible();
- await expect(page.getByText("Offline ready")).toBeVisible();
+ await expect(page.getByText("Local content only")).toBeVisible();
await expect(page.getByText("Source pending review").first()).toBeVisible();
- await expect(page.locator("header").getByRole("button", { name: "Copy after review" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Copy after review" })).toBeVisible();
await expect(page.getByTestId("global-search-input")).toHaveCount(0);
const tableScrolls = await page.getByTestId("differential-comparison-scroll").evaluate((element) => {
From 080243c6c80fa484f9d7fb2eb7a0d5e5a215bcf4 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 4 Jul 2026 12:56:59 +0800
Subject: [PATCH 07/16] Add public anonymous access implementation plan
---
...-04-public-anonymous-access-rate-limits.md | 1209 +++++++++++++++++
1 file changed, 1209 insertions(+)
create mode 100644 docs/superpowers/plans/2026-07-04-public-anonymous-access-rate-limits.md
diff --git a/docs/superpowers/plans/2026-07-04-public-anonymous-access-rate-limits.md b/docs/superpowers/plans/2026-07-04-public-anonymous-access-rate-limits.md
new file mode 100644
index 000000000..1fea72f2c
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-04-public-anonymous-access-rate-limits.md
@@ -0,0 +1,1209 @@
+# Public Anonymous Access and Rate Limiting Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Remove the forced sign-in/authorization barrier so anonymous users can run live searches, generate answers, browse/view source documents, and use the app without logging in, while protecting costly APIs with server-side rate limits.
+
+**Architecture:** Keep Supabase auth optional instead of required for public read/search/answer flows. Authenticated users keep owner-scoped behavior; anonymous users use a privacy-preserving anonymous caller key for rate limits and public/global search behavior. Uploads and owner-required mutations must either use a configured shared public workspace owner or stay disabled with setup copy that does not ask users to sign in.
+
+**Tech Stack:** Next.js App Router API routes, React 19 client UI, Supabase service-role server client, existing `consume_api_rate_limit` RPC, new anonymous rate-limit RPC/table, Vitest route tests, existing `npm run verify:cheap` gate.
+
+## Global Constraints
+
+- Do not require Supabase Auth for live `/api/search`, `/api/answer`, or `/api/answer/stream` requests.
+- Fix the visible UI error: “Search request was not authorized by the server.”
+- Remove the sign-in panel/gate from the main website experience.
+- Add rate-limit-only abuse protection, per user when authenticated and per anonymous caller when signed out.
+- Do not add captcha, Turnstile, reCAPTCHA, or new third-party dependencies.
+- Do not expose service-role keys or private env values to the browser.
+- Preserve existing authenticated behavior where a real Supabase session is present.
+- Keep destructive document management protected unless a shared public owner is explicitly configured.
+- Run `npm run verify:cheap` after implementation; for UI changes also run `npm run ensure` and `npm run verify:ui` if feasible.
+- Do not commit unless the user explicitly requests it.
+
+---
+
+## File Structure
+
+- Modify: `src/lib/env.ts`
+ - Add optional public workspace owner env support for anonymous upload/owner-required actions.
+- Modify: `src/lib/supabase/auth.ts`
+ - Add optional auth resolution instead of throwing for missing tokens.
+- Create: `src/lib/public-api-access.ts`
+ - Centralize anonymous caller key derivation, public access context, and public owner resolution.
+- Modify: `src/lib/api-rate-limit.ts`
+ - Add anonymous subject rate limiting while preserving existing authenticated owner rate limiting.
+- Create: `supabase/migrations/YYYYMMDDHHMMSS_public_api_rate_limit_subjects.sql`
+ - Add durable anonymous subject rate-limit storage and RPC.
+- Modify: `supabase/schema.sql`
+ - Mirror the new durable anonymous rate-limit table/function.
+- Modify: `src/app/api/search/route.ts`
+ - Stop requiring auth; apply public access context and anonymous rate limit.
+- Modify: `src/app/api/answer/route.ts`
+ - Stop requiring auth; apply public access context and anonymous rate limit.
+- Modify: `src/app/api/answer/stream/route.ts`
+ - Stop requiring auth; apply public access context and anonymous rate limit.
+- Modify: `src/app/api/documents/route.ts`
+ - Allow anonymous document listing for public/global search previews.
+- Modify likely source-preview routes after inspection during execution:
+ - `src/app/api/documents/[id]/route.ts`
+ - `src/app/api/documents/[id]/search/route.ts`
+ - `src/app/api/documents/[id]/signed-url/route.ts`
+ - `src/app/api/images/[id]/signed-url/route.ts`
+- Modify: `src/app/api/upload/route.ts`
+ - If public upload is desired, use shared public owner; otherwise return a non-auth setup message.
+- Modify: `src/components/ClinicalDashboard.tsx`
+ - Remove main-page auth gate and make `canRunSearch` true when setup is ready even without auth.
+- Modify: `src/components/clinical-dashboard/auth-panel.tsx`
+ - Stop rendering forced sign-in for the public flow, or leave as optional account panel only.
+- Modify: `src/components/clinical-dashboard/document-search-results.tsx`
+ - Replace “Sign in...” copy with setup/rate-limit copy.
+- Modify: `src/components/DocumentViewer.tsx`
+ - Let anonymous users open source previews without auth when the route supports it.
+- Modify: `tests/private-access-routes.test.ts`
+ - Update old 401 expectations for search/answer/doc preview to anonymous success/rate-limit behavior.
+- Add/modify focused tests as needed in `tests/api-validation-contract.test.ts` or a new `tests/public-access-rate-limit.test.ts` if the existing private access test becomes too large.
+
+---
+
+### Task 1: Add Public Access Context and Optional Auth
+
+**Files:**
+- Modify: `src/lib/supabase/auth.ts:32-83`
+- Create: `src/lib/public-api-access.ts`
+- Modify: `src/lib/env.ts:1-20`, `src/lib/env.ts:150-179`
+- Test: `tests/private-access-routes.test.ts`
+
+**Interfaces:**
+- Consumes: existing `requireAuthenticatedUser(request, supabase)`.
+- Produces:
+ - `getOptionalAuthenticatedUser(request: Request, supabase: AdminClient): Promise
`
+ - `anonymousApiSubjectKey(request: Request): string`
+ - `publicAccessContext(request: Request, supabase: AdminClient): Promise<{ ownerId?: string; authenticated: boolean; rateLimitSubject: { kind: "owner"; ownerId: string } | { kind: "anonymous"; subjectKey: string } }>`
+ - `publicWorkspaceOwnerId(): string | null`
+
+- [ ] **Step 1: Write failing tests for optional auth and anonymous key stability**
+
+Add tests near the auth/no-auth cases in `tests/private-access-routes.test.ts`:
+
+```ts
+it("derives a stable anonymous rate-limit subject without requiring Supabase auth", async () => {
+ const client = createSupabaseMock();
+ mockRuntime(client);
+ const { publicAccessContext } = await import("../src/lib/public-api-access");
+
+ const first = await publicAccessContext(
+ request("/api/search", {
+ headers: {
+ "x-forwarded-for": "203.0.113.44, 10.0.0.1",
+ "user-agent": "Vitest Browser",
+ },
+ }),
+ client as never,
+ );
+ const second = await publicAccessContext(
+ request("/api/search", {
+ headers: {
+ "x-forwarded-for": "203.0.113.44, 10.0.0.1",
+ "user-agent": "Vitest Browser",
+ },
+ }),
+ client as never,
+ );
+
+ expect(first.authenticated).toBe(false);
+ expect(first.ownerId).toBeUndefined();
+ expect(first.rateLimitSubject.kind).toBe("anonymous");
+ expect(first.rateLimitSubject).toEqual(second.rateLimitSubject);
+ expect(client.auth.getUser).not.toHaveBeenCalled();
+});
+
+it("uses authenticated owner rate limits when a valid bearer token is present", async () => {
+ const client = createSupabaseMock();
+ mockRuntime(client);
+ const { publicAccessContext } = await import("../src/lib/public-api-access");
+
+ const context = await publicAccessContext(authenticatedRequest("/api/search"), client as never);
+
+ expect(context).toMatchObject({
+ authenticated: true,
+ ownerId: userId,
+ rateLimitSubject: { kind: "owner", ownerId: userId },
+ });
+ expect(client.auth.getUser).toHaveBeenCalledTimes(1);
+});
+```
+
+- [ ] **Step 2: Run the focused tests and verify they fail**
+
+Run:
+
+```powershell
+npm run test -- tests/private-access-routes.test.ts -t "anonymous rate-limit subject|authenticated owner rate limits"
+```
+
+Expected: FAIL because `src/lib/public-api-access.ts` and optional auth helpers do not exist.
+
+- [ ] **Step 3: Implement optional auth helper**
+
+In `src/lib/supabase/auth.ts`, keep `requireAuthenticatedUser` unchanged and add:
+
+```ts
+export async function getOptionalAuthenticatedUser(
+ request: Request,
+ supabase: AdminClient,
+): Promise {
+ const token = extractSessionAccessToken(request);
+ if (!token) return null;
+
+ const { data, error } = await supabase.auth.getUser(token);
+ const userId = data.user?.id;
+ if (error || !userId) {
+ throw new AuthenticationError();
+ }
+
+ return { id: userId };
+}
+```
+
+- [ ] **Step 4: Add public env fields**
+
+In `src/lib/env.ts`, add optional keys to the env schema near the existing local no-auth owner fields:
+
+```ts
+PUBLIC_WORKSPACE_OWNER_ID: z.string().uuid().optional(),
+NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: z.enum(["true", "false"]).optional(),
+```
+
+Add helpers near `isLocalNoAuthMode()`:
+
+```ts
+export function publicWorkspaceOwnerId() {
+ return env.PUBLIC_WORKSPACE_OWNER_ID?.trim() || null;
+}
+
+export function publicUploadsEnabled() {
+ return process.env.NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED === "true";
+}
+```
+
+- [ ] **Step 5: Create public access helper**
+
+Create `src/lib/public-api-access.ts`:
+
+```ts
+import { createHash } from "node:crypto";
+import { publicWorkspaceOwnerId } from "@/lib/env";
+import type { createAdminClient } from "@/lib/supabase/admin";
+import { getOptionalAuthenticatedUser } from "@/lib/supabase/auth";
+
+type AdminClient = ReturnType;
+
+export type RateLimitSubject =
+ | { kind: "owner"; ownerId: string }
+ | { kind: "anonymous"; subjectKey: string };
+
+function firstForwardedIp(value: string | null) {
+ return value?.split(",")[0]?.trim() || "";
+}
+
+function requestIpSignal(request: Request) {
+ return (
+ firstForwardedIp(request.headers.get("cf-connecting-ip")) ||
+ firstForwardedIp(request.headers.get("x-forwarded-for")) ||
+ firstForwardedIp(request.headers.get("x-real-ip")) ||
+ "unknown-ip"
+ );
+}
+
+export function anonymousApiSubjectKey(request: Request) {
+ const userAgent = request.headers.get("user-agent")?.slice(0, 180) || "unknown-agent";
+ const acceptLanguage = request.headers.get("accept-language")?.slice(0, 80) || "unknown-language";
+ const raw = [requestIpSignal(request), userAgent, acceptLanguage].join("|");
+ return `anon:${createHash("sha256").update(raw).digest("hex")}`;
+}
+
+export async function publicAccessContext(request: Request, supabase: AdminClient) {
+ const user = await getOptionalAuthenticatedUser(request, supabase);
+ if (user) {
+ return {
+ ownerId: user.id,
+ authenticated: true,
+ rateLimitSubject: { kind: "owner", ownerId: user.id } satisfies RateLimitSubject,
+ };
+ }
+
+ return {
+ ownerId: undefined,
+ authenticated: false,
+ rateLimitSubject: { kind: "anonymous", subjectKey: anonymousApiSubjectKey(request) } satisfies RateLimitSubject,
+ };
+}
+
+export { publicWorkspaceOwnerId };
+```
+
+- [ ] **Step 6: Run the focused tests and verify they pass**
+
+Run:
+
+```powershell
+npm run test -- tests/private-access-routes.test.ts -t "anonymous rate-limit subject|authenticated owner rate limits"
+```
+
+Expected: PASS.
+
+---
+
+### Task 2: Add Durable Anonymous Rate Limiting
+
+**Files:**
+- Modify: `src/lib/api-rate-limit.ts:1-160`
+- Create: `supabase/migrations/YYYYMMDDHHMMSS_public_api_rate_limit_subjects.sql`
+- Modify: `supabase/schema.sql`
+- Test: `tests/private-access-routes.test.ts`
+
+**Interfaces:**
+- Consumes: `RateLimitSubject` from `src/lib/public-api-access.ts`.
+- Produces:
+ - `consumeSubjectApiRateLimit(args: { supabase: SupabaseAdmin; subject: RateLimitSubject; bucket: ApiRateLimitBucket; limit?: number; windowSeconds?: number; allowInMemoryFallbackOnUnavailable?: boolean }): Promise`
+
+- [ ] **Step 1: Write failing anonymous limiter test**
+
+Add near existing rate-limit tests in `tests/private-access-routes.test.ts`:
+
+```ts
+it("rate limits anonymous answer requests by anonymous subject key", async () => {
+ const client = createSupabaseMock();
+ client.rpc.mockImplementation(async (name: string, args?: Record) =>
+ name === "consume_api_subject_rate_limit" && args?.p_bucket === "answer"
+ ? { data: [rateLimitRow({ limited: true, limit_value: 10, remaining: 0 })], error: null }
+ : ok([]),
+ );
+ mockRuntime(client);
+ const { consumeSubjectApiRateLimit } = await import("../src/lib/api-rate-limit");
+
+ const result = await consumeSubjectApiRateLimit({
+ supabase: client as never,
+ subject: { kind: "anonymous", subjectKey: "anon:test-key" },
+ bucket: "answer",
+ });
+
+ expect(result.limited).toBe(true);
+ expect(client.rpc).toHaveBeenCalledWith(
+ "consume_api_subject_rate_limit",
+ expect.objectContaining({ p_subject_key: "anon:test-key", p_bucket: "answer" }),
+ );
+});
+```
+
+- [ ] **Step 2: Run the focused test and verify it fails**
+
+Run:
+
+```powershell
+npm run test -- tests/private-access-routes.test.ts -t "anonymous answer requests by anonymous subject key"
+```
+
+Expected: FAIL because `consumeSubjectApiRateLimit` does not exist.
+
+- [ ] **Step 3: Add SQL migration for anonymous subjects**
+
+Create `supabase/migrations/YYYYMMDDHHMMSS_public_api_rate_limit_subjects.sql` with a timestamp later than existing migrations:
+
+```sql
+set search_path = public, extensions, pg_temp;
+
+create table if not exists public.api_rate_limit_subjects (
+ subject_key text not null,
+ bucket text not null,
+ window_start timestamptz not null default now(),
+ request_count integer not null default 0 check (request_count >= 0),
+ updated_at timestamptz not null default now(),
+ primary key (subject_key, bucket),
+ constraint api_rate_limit_subjects_subject_key_nonempty check (btrim(subject_key) <> ''),
+ constraint api_rate_limit_subjects_bucket_nonempty check (btrim(bucket) <> '')
+);
+
+create index if not exists api_rate_limit_subjects_bucket_updated_idx
+ on public.api_rate_limit_subjects(bucket, updated_at desc);
+
+create or replace function public.consume_api_subject_rate_limit(
+ p_subject_key text,
+ p_bucket text,
+ p_limit integer,
+ p_window_seconds integer
+)
+returns table (
+ limited boolean,
+ limit_value integer,
+ remaining integer,
+ retry_after_seconds integer,
+ reset_at timestamptz
+)
+language plpgsql
+security definer
+set search_path = public, extensions, pg_temp
+as $$
+declare
+ v_now timestamptz := now();
+ v_window_start timestamptz := v_now;
+ v_count integer;
+ v_reset_at timestamptz;
+begin
+ if p_subject_key is null or btrim(p_subject_key) = '' then
+ raise exception 'subject_key is required';
+ end if;
+ if p_bucket is null or btrim(p_bucket) = '' then
+ raise exception 'bucket is required';
+ end if;
+ if p_limit < 1 then
+ raise exception 'limit must be positive';
+ end if;
+ if p_window_seconds < 1 then
+ raise exception 'window must be positive';
+ end if;
+
+ loop
+ update public.api_rate_limit_subjects
+ set
+ window_start = case
+ when window_start + make_interval(secs => p_window_seconds) <= v_now then v_window_start
+ else window_start
+ end,
+ request_count = case
+ when window_start + make_interval(secs => p_window_seconds) <= v_now then 1
+ else request_count + 1
+ end,
+ updated_at = v_now
+ where subject_key = p_subject_key
+ and bucket = p_bucket
+ returning request_count, window_start + make_interval(secs => p_window_seconds)
+ into v_count, v_reset_at;
+
+ exit when found;
+
+ begin
+ insert into public.api_rate_limit_subjects(subject_key, bucket, window_start, request_count, updated_at)
+ values (p_subject_key, p_bucket, v_window_start, 1, v_now)
+ returning request_count, window_start + make_interval(secs => p_window_seconds)
+ into v_count, v_reset_at;
+ exit;
+ exception when unique_violation then
+ end;
+ end loop;
+
+ return query
+ select
+ v_count > p_limit as limited,
+ p_limit as limit_value,
+ greatest(p_limit - v_count, 0) as remaining,
+ greatest(1, ceiling(extract(epoch from (v_reset_at - v_now)))::integer) as retry_after_seconds,
+ v_reset_at as reset_at;
+end;
+$$;
+
+revoke all privileges on table public.api_rate_limit_subjects from public, anon, authenticated;
+grant select, insert, update, delete on table public.api_rate_limit_subjects to service_role;
+
+revoke execute on function public.consume_api_subject_rate_limit(text, text, integer, integer) from public, anon, authenticated;
+grant execute on function public.consume_api_subject_rate_limit(text, text, integer, integer) to service_role;
+
+alter table public.api_rate_limit_subjects enable row level security;
+
+drop policy if exists "api rate limit subjects service role all" on public.api_rate_limit_subjects;
+create policy "api rate limit subjects service role all" on public.api_rate_limit_subjects
+ for all to service_role
+ using (true)
+ with check (true);
+```
+
+- [ ] **Step 4: Mirror SQL in `supabase/schema.sql`**
+
+Add the table, index, function, grants, RLS enable, and service-role policy to `supabase/schema.sql` near the existing `api_rate_limits` definitions.
+
+- [ ] **Step 5: Implement subject limiter**
+
+In `src/lib/api-rate-limit.ts`, import the type:
+
+```ts
+import type { RateLimitSubject } from "@/lib/public-api-access";
+```
+
+Add below `consumeApiRateLimit`:
+
+```ts
+export async function consumeSubjectApiRateLimit(args: {
+ supabase: SupabaseAdmin;
+ subject: RateLimitSubject;
+ bucket: ApiRateLimitBucket;
+ limit?: number;
+ windowSeconds?: number;
+ allowInMemoryFallbackOnUnavailable?: boolean;
+}): Promise {
+ if (args.subject.kind === "owner") {
+ return consumeApiRateLimit({
+ supabase: args.supabase,
+ ownerId: args.subject.ownerId,
+ bucket: args.bucket,
+ limit: args.limit,
+ windowSeconds: args.windowSeconds,
+ allowInMemoryFallbackOnUnavailable: args.allowInMemoryFallbackOnUnavailable,
+ });
+ }
+
+ const defaults = apiRateLimitDefaults[args.bucket];
+ const limit = args.limit ?? defaults.limit;
+ const windowSeconds = args.windowSeconds ?? defaults.windowSeconds;
+ const { data, error } = await args.supabase.rpc("consume_api_subject_rate_limit", {
+ p_subject_key: args.subject.subjectKey,
+ p_bucket: args.bucket,
+ p_limit: limit,
+ p_window_seconds: windowSeconds,
+ });
+
+ if (error) {
+ if (args.allowInMemoryFallbackOnUnavailable) {
+ console.warn("Durable anonymous API rate limit check unavailable; using local in-memory fallback.", {
+ bucket: args.bucket,
+ code: error.code,
+ message: error.message,
+ });
+ return consumeInMemoryApiRateLimit({ ownerId: args.subject.subjectKey, bucket: args.bucket, limit, windowSeconds });
+ }
+ throw new ApiRateLimitUnavailableError();
+ }
+
+ const row = parseRateLimitRow(data);
+ if (!row || typeof row.limited !== "boolean") {
+ if (args.allowInMemoryFallbackOnUnavailable) {
+ return consumeInMemoryApiRateLimit({ ownerId: args.subject.subjectKey, bucket: args.bucket, limit, windowSeconds });
+ }
+ throw new ApiRateLimitUnavailableError();
+ }
+
+ return {
+ limited: row.limited,
+ limit: Number(row.limit_value ?? limit),
+ remaining: Number(row.remaining ?? 0),
+ retryAfterSeconds: Math.max(1, Number(row.retry_after_seconds ?? windowSeconds)),
+ resetAt: String(row.reset_at ?? new Date(Date.now() + windowSeconds * 1000).toISOString()),
+ };
+}
+```
+
+- [ ] **Step 6: Run focused limiter test**
+
+Run:
+
+```powershell
+npm run test -- tests/private-access-routes.test.ts -t "anonymous answer requests by anonymous subject key"
+```
+
+Expected: PASS.
+
+---
+
+### Task 3: Make Search and Answer Public While Rate Limited
+
+**Files:**
+- Modify: `src/app/api/search/route.ts:882-909`
+- Modify: `src/app/api/answer/route.ts:64-108`
+- Modify: `src/app/api/answer/stream/route.ts:220-236`
+- Test: `tests/private-access-routes.test.ts:2688-2804`
+
+**Interfaces:**
+- Consumes: `publicAccessContext`, `consumeSubjectApiRateLimit`.
+- Produces: anonymous `/api/search`, `/api/answer`, and `/api/answer/stream` success instead of 401.
+
+- [ ] **Step 1: Replace the old failing 401 test with anonymous live-search expectations**
+
+Replace `it("rejects unauthenticated search and answer requests"...)` in `tests/private-access-routes.test.ts:2688` with:
+
+```ts
+it("allows anonymous search and answer requests with anonymous rate limits", async () => {
+ const searchChunksWithTelemetry = vi.fn(async () => ({
+ results: [],
+ telemetry: {
+ search_cache_hit: false,
+ text_fast_path_latency_ms: 0,
+ embedding_skipped: true,
+ embedding_latency_ms: 0,
+ embedding_cache_hit: false,
+ supabase_rpc_latency_ms: 0,
+ rerank_latency_ms: 0,
+ retrieval_strategy: "text_fast_path",
+ },
+ }));
+ const answerQuestionWithScope = vi.fn(async () => ({
+ answer: "No evidence found.",
+ grounded: false,
+ confidence: "unsupported",
+ citations: [],
+ sources: [],
+ }));
+ const client = createSupabaseMock();
+ client.rpc.mockImplementation(async (name: string) =>
+ name === "consume_api_subject_rate_limit" || name === "match_documents_hybrid"
+ ? { data: name === "consume_api_subject_rate_limit" ? [rateLimitRow()] : [], error: null }
+ : ok([]),
+ );
+ mockRuntime(client, { searchChunksWithTelemetry, answerQuestionWithScope });
+
+ const searchRoute = await import("../src/app/api/search/route");
+ const answerRoute = await import("../src/app/api/answer/route");
+
+ const anonymousHeaders = {
+ "x-forwarded-for": "203.0.113.20",
+ "user-agent": "Vitest Browser",
+ };
+ const searchResponse = await searchRoute.POST(
+ request("/api/search", {
+ method: "POST",
+ headers: anonymousHeaders,
+ body: JSON.stringify({ query: "monitoring", documentIds: [otherDocumentId] }),
+ }),
+ );
+ const answerResponse = await answerRoute.POST(
+ request("/api/answer", {
+ method: "POST",
+ headers: anonymousHeaders,
+ body: JSON.stringify({ query: "monitoring", documentId: otherDocumentId }),
+ }),
+ );
+
+ expect(searchResponse.status).toBe(200);
+ expect(answerResponse.status).toBe(200);
+ expect(searchChunksWithTelemetry).toHaveBeenCalledWith(expect.objectContaining({ ownerId: undefined }));
+ expect(answerQuestionWithScope).toHaveBeenCalledWith(expect.objectContaining({ ownerId: undefined, allowGlobalSearch: true }));
+ expect(client.auth.getUser).not.toHaveBeenCalled();
+ expect(client.rpc).toHaveBeenCalledWith(
+ "consume_api_subject_rate_limit",
+ expect.objectContaining({ p_subject_key: expect.stringMatching(/^anon:/), p_bucket: "search" }),
+ );
+ expect(client.rpc).toHaveBeenCalledWith(
+ "consume_api_subject_rate_limit",
+ expect.objectContaining({ p_subject_key: expect.stringMatching(/^anon:/), p_bucket: "answer" }),
+ );
+});
+```
+
+- [ ] **Step 2: Add a stream route anonymous test**
+
+Add below the anonymous search/answer test:
+
+```ts
+it("allows anonymous streamed answers with anonymous rate limits", async () => {
+ const answerQuestionWithScope = vi.fn(async () => ({
+ answer: "Streamed public answer.",
+ grounded: false,
+ confidence: "unsupported",
+ citations: [],
+ sources: [],
+ }));
+ const client = createSupabaseMock();
+ client.rpc.mockImplementation(async (name: string) =>
+ name === "consume_api_subject_rate_limit" ? { data: [rateLimitRow()], error: null } : ok([]),
+ );
+ mockRuntime(client, { answerQuestionWithScope });
+ const answerStreamRoute = await import("../src/app/api/answer/stream/route");
+
+ const response = await answerStreamRoute.POST(
+ request("/api/answer/stream", {
+ method: "POST",
+ headers: { "x-forwarded-for": "203.0.113.21", "user-agent": "Vitest Browser" },
+ body: JSON.stringify({ query: "monitoring" }),
+ }),
+ );
+ const body = await response.text();
+
+ expect(response.status).toBe(200);
+ expect(ssePayload(body, "final")).toMatchObject({ answer: "Streamed public answer." });
+ expect(answerQuestionWithScope).toHaveBeenCalledWith(expect.objectContaining({ ownerId: undefined, allowGlobalSearch: true }));
+ expect(client.rpc).toHaveBeenCalledWith(
+ "consume_api_subject_rate_limit",
+ expect.objectContaining({ p_subject_key: expect.stringMatching(/^anon:/), p_bucket: "answer" }),
+ );
+});
+```
+
+- [ ] **Step 3: Run the focused tests and verify they fail**
+
+Run:
+
+```powershell
+npm run test -- tests/private-access-routes.test.ts -t "anonymous search and answer|anonymous streamed answers"
+```
+
+Expected: FAIL with old 401 behavior.
+
+- [ ] **Step 4: Update `/api/search`**
+
+In `src/app/api/search/route.ts`, replace the authenticated-only block at `882-897` with:
+
+```ts
+supabase = createAdminClient();
+const access = await publicAccessContext(request, supabase);
+ownerId = access.ownerId;
+
+const rateLimit = await consumeSubjectApiRateLimit({
+ supabase,
+ subject: access.rateLimitSubject,
+ bucket: "search",
+ allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+});
+if (rateLimit.limited) {
+ return rateLimitJsonResponse(
+ "Search is temporarily rate limited because too many requests were received. Retry shortly.",
+ rateLimit,
+ );
+}
+```
+
+Add imports:
+
+```ts
+import { publicAccessContext } from "@/lib/public-api-access";
+import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
+```
+
+Remove `serverAuth.requireAuthenticatedUser` use from this route but keep `AuthenticationError` catch only if optional auth can throw for invalid tokens.
+
+- [ ] **Step 5: Update `/api/answer`**
+
+In `src/app/api/answer/route.ts`, replace `requireAuthenticatedUser` and owner-only rate limit with:
+
+```ts
+const supabase = createAdminClient();
+const access = await publicAccessContext(request, supabase);
+
+const rateLimit = await consumeSubjectApiRateLimit({
+ supabase,
+ subject: access.rateLimitSubject,
+ bucket: "answer",
+ allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(),
+});
+if (rateLimit.limited) {
+ return rateLimitJsonResponse("Too many answer requests. Retry shortly.", rateLimit);
+}
+
+const scope = await resolveSearchScope({
+ supabase,
+ ownerId: access.ownerId,
+ documentIds: body.documentIds ?? (body.documentId ? [body.documentId] : undefined),
+ filters: body.filters,
+});
+```
+
+Then call answer generation with:
+
+```ts
+const answer = await answerQuestionWithScope({
+ query: body.query,
+ documentId: singleDocumentScope ? body.documentId : undefined,
+ documentIds: singleDocumentScope
+ ? undefined
+ : (scope.documentIds ?? body.documentIds ?? (body.documentId ? [body.documentId] : undefined)),
+ ownerId: access.ownerId,
+ allowGlobalSearch: !access.ownerId,
+ queryMode: body.queryMode,
+ skipCache: body.skipCache,
+ signal: request.signal,
+});
+```
+
+- [ ] **Step 6: Update `/api/answer/stream`**
+
+In `src/app/api/answer/stream/route.ts`, replace `requireAuthenticatedUser` and owner-only rate limit with the same `publicAccessContext` and `consumeSubjectApiRateLimit` pattern. Return:
+
+```ts
+return streamAnswer(body, access.ownerId, request.signal);
+```
+
+Inside `streamAnswer`, keep the existing `allowGlobalSearch: !ownerId` behavior.
+
+- [ ] **Step 7: Run focused tests**
+
+Run:
+
+```powershell
+npm run test -- tests/private-access-routes.test.ts -t "anonymous search and answer|anonymous streamed answers|rate limits authenticated answer"
+```
+
+Expected: PASS.
+
+---
+
+### Task 4: Allow Anonymous Document Listing and Source Preview for Live Search Results
+
+**Files:**
+- Modify: `src/app/api/documents/route.ts:118-203`
+- Modify after inspection: `src/app/api/documents/[id]/route.ts`
+- Modify after inspection: `src/app/api/documents/[id]/search/route.ts`
+- Modify after inspection: `src/app/api/documents/[id]/signed-url/route.ts`
+- Modify after inspection: `src/app/api/images/[id]/signed-url/route.ts`
+- Test: `tests/private-access-routes.test.ts`
+
+**Interfaces:**
+- Consumes: `publicAccessContext`.
+- Produces: anonymous document browsing/source previews; authenticated users remain owner-scoped.
+
+- [ ] **Step 1: Write failing anonymous document list test**
+
+Add near document listing tests:
+
+```ts
+it("lists documents anonymously for public browsing without an auth session", async () => {
+ const documents = [{ id: documentId, owner_id: userId, title: "Public guideline", status: "indexed" }];
+ const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([])));
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/documents/route");
+
+ const response = await GET(request("/api/documents?includeMeta=false"));
+ const body = await payload(response);
+
+ expect(response.status).toBe(200);
+ expect(body.documents).toEqual(documents);
+ expect(client.auth.getUser).not.toHaveBeenCalled();
+ expect(client.calls[0].filters).not.toContainEqual({ column: "owner_id", value: userId });
+});
+```
+
+- [ ] **Step 2: Run the focused test and verify it fails**
+
+Run:
+
+```powershell
+npm run test -- tests/private-access-routes.test.ts -t "lists documents anonymously"
+```
+
+Expected: FAIL with 401.
+
+- [ ] **Step 3: Implement anonymous document list**
+
+In `src/app/api/documents/route.ts`, replace:
+
+```ts
+const user = await requireAuthenticatedUser(request, supabase);
+let query = supabase
+ .from("documents")
+ .select(DOCUMENT_LIST_COLUMNS, { count: "exact" })
+ .eq("owner_id", user.id)
+```
+
+with:
+
+```ts
+const access = await publicAccessContext(request, supabase);
+let query = supabase
+ .from("documents")
+ .select(DOCUMENT_LIST_COLUMNS, { count: "exact" });
+if (access.ownerId) query = query.eq("owner_id", access.ownerId);
+query = query
+```
+
+then keep the existing `.order(...).range(...)` chain after `query = query`.
+
+- [ ] **Step 4: Update source-preview routes using the same pattern**
+
+For each route that currently calls `requireAuthenticatedUser` and then filters by `owner_id`, change to:
+
+```ts
+const access = await publicAccessContext(request, supabase);
+let documentQuery = supabase.from("documents").select("...").eq("id", id);
+if (access.ownerId) documentQuery = documentQuery.eq("owner_id", access.ownerId);
+```
+
+Apply this to GET/search/signed-url preview routes only. Do not apply it to delete, label mutation, reindex, or bulk mutation routes in this task.
+
+- [ ] **Step 5: Add route-specific tests for one source preview and one signed URL**
+
+Add tests for anonymous success on `documents/[id]` and signed URL routes. Expected assertions:
+
+```ts
+expect(response.status).toBe(200);
+expect(client.auth.getUser).not.toHaveBeenCalled();
+expect(documentSelect?.filters).toContainEqual({ column: "id", value: documentId });
+expect(documentSelect?.filters).not.toContainEqual({ column: "owner_id", value: userId });
+```
+
+- [ ] **Step 6: Run focused document tests**
+
+Run:
+
+```powershell
+npm run test -- tests/private-access-routes.test.ts -t "documents anonymously|source preview|signed URL"
+```
+
+Expected: PASS.
+
+---
+
+### Task 5: Decide and Implement Anonymous Upload Behavior
+
+**Files:**
+- Modify: `src/app/api/upload/route.ts:24-238`
+- Modify: `src/components/clinical-dashboard/DocumentManagerPanel.tsx`
+- Test: `tests/private-access-routes.test.ts`, `tests/api-validation-contract.test.ts`
+
+**Interfaces:**
+- Consumes: `publicWorkspaceOwnerId()`, `publicUploadsEnabled()`, `publicAccessContext`.
+- Produces: either public upload to shared owner when configured, or no login prompt when not configured.
+
+- [ ] **Step 1: Write tests for the chosen upload contract**
+
+Use this contract: anonymous upload is allowed only when `NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED="true"` and `PUBLIC_WORKSPACE_OWNER_ID` is configured. Otherwise the app shows setup-required copy, not sign-in-required copy.
+
+Add tests:
+
+```ts
+it("rejects anonymous upload with setup guidance when public uploads are not configured", async () => {
+ const client = createSupabaseMock();
+ mockRuntime(client);
+ const { POST } = await import("../src/app/api/upload/route");
+ const formData = new FormData();
+ formData.set("file", new File(["hello"], "guideline.txt", { type: "text/plain" }));
+
+ const response = await POST(request("/api/upload", { method: "POST", body: formData }));
+
+ expect(response.status).toBe(503);
+ expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." });
+ expect(client.auth.getUser).not.toHaveBeenCalled();
+});
+
+it("uploads anonymous documents to the configured public workspace owner", async () => {
+ const publicOwnerId = "99999999-9999-4999-8999-999999999999";
+ const client = createSupabaseMock((call) => {
+ if (call.table === "documents" && call.operation === "select" && call.maybeSingle) return ok(null);
+ if (call.table === "documents" && call.operation === "insert") return ok({ id: documentId, owner_id: publicOwnerId });
+ if (call.table === "ingestion_jobs" && call.operation === "insert") return ok({ id: "job-1" });
+ if (call.table === "audit_log" && call.operation === "insert") return ok([]);
+ return ok([]);
+ });
+ mockRuntime(client, undefined, { publicUploadsEnabled: true, publicWorkspaceOwnerId: publicOwnerId });
+ const { POST } = await import("../src/app/api/upload/route");
+ const formData = new FormData();
+ formData.set("file", new File(["hello"], "guideline.txt", { type: "text/plain" }));
+
+ const response = await POST(request("/api/upload", { method: "POST", body: formData }));
+
+ expect(response.status).toBe(200);
+ expect(client.auth.getUser).not.toHaveBeenCalled();
+ expect(client.calls.find((call) => call.table === "documents" && call.operation === "insert")?.insertPayload).toMatchObject({
+ owner_id: publicOwnerId,
+ });
+});
+```
+
+- [ ] **Step 2: Run upload tests and verify they fail**
+
+Run:
+
+```powershell
+npm run test -- tests/private-access-routes.test.ts -t "anonymous upload|Public uploads"
+```
+
+Expected: FAIL with old auth requirement.
+
+- [ ] **Step 3: Implement owner resolution in upload route**
+
+In `src/app/api/upload/route.ts`, replace the required auth block with:
+
+```ts
+const access = await publicAccessContext(request, adminSupabase);
+const publicOwnerId = publicWorkspaceOwnerId();
+const uploadOwnerId = access.ownerId ?? (publicUploadsEnabled() ? publicOwnerId : null);
+if (!uploadOwnerId) {
+ return NextResponse.json({ error: "Public uploads are not configured for this workspace." }, { status: 503 });
+}
+```
+
+Then replace every `user.id` in upload ownership/storage/audit/naming code with `uploadOwnerId`.
+
+- [ ] **Step 4: Update upload UI copy**
+
+In `DocumentManagerPanel.tsx`, replace copy like:
+
+```ts
+"Sign in before uploading private guideline files."
+```
+
+with:
+
+```ts
+"Uploads are unavailable until this public workspace is configured."
+```
+
+- [ ] **Step 5: Run upload tests**
+
+Run:
+
+```powershell
+npm run test -- tests/private-access-routes.test.ts -t "anonymous upload|Public uploads"
+```
+
+Expected: PASS.
+
+---
+
+### Task 6: Remove Forced Login UI and Fix “Search request was not authorized” UX
+
+**Files:**
+- Modify: `src/components/ClinicalDashboard.tsx:3652-3664`, `src/components/ClinicalDashboard.tsx:5341-5893`
+- Modify: `src/components/clinical-dashboard/auth-panel.tsx`
+- Modify: `src/components/clinical-dashboard/document-search-results.tsx`
+- Modify: `src/components/DocumentViewer.tsx:1928-2256`
+- Test: `tests/ui-smoke.spec.ts` or focused component route tests if present
+
+**Interfaces:**
+- Consumes: public API behavior from Tasks 3-5.
+- Produces: no forced sign-in panel; search controls enabled when backend setup is ready; no 401 authorization banner for anonymous users.
+
+- [ ] **Step 1: Write or update a UI smoke test**
+
+Add/update Playwright assertion in `tests/ui-smoke.spec.ts`:
+
+```ts
+test("anonymous user can see enabled live search without sign-in gate", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByText("Sign in for private documents")).toHaveCount(0);
+ await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0);
+ await expect(page.getByTestId("global-search-input")).toBeEnabled();
+});
+```
+
+- [ ] **Step 2: Run the UI test and verify it fails or exposes current issue**
+
+Run:
+
+```powershell
+npm run ensure
+npm run test:e2e:chromium -- tests/ui-smoke.spec.ts -g "anonymous user can see enabled live search"
+```
+
+Expected: FAIL if the sign-in gate or disabled search is still present.
+
+- [ ] **Step 3: Update dashboard readiness logic**
+
+In `src/components/ClinicalDashboard.tsx`, change:
+
+```ts
+const canUsePrivateApis =
+ localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated");
+const canRunSearch = explicitDemoMode || (hasReadyPublicSearchSetup(setupChecks) && canUsePrivateApis);
+```
+
+to:
+
+```ts
+const publicApiReady = localProjectReady && hasReadyPublicSearchSetup(setupChecks);
+const canUsePrivateApis = publicApiReady;
+const canRunSearch = explicitDemoMode || publicApiReady;
+```
+
+Keep `authStatus === "authenticated"` only for optional account-specific UI, not for search availability.
+
+- [ ] **Step 4: Hide the forced auth panel**
+
+Change:
+
+```ts
+const showAuthPanel = !clientDemoMode && !canUsePrivateApis;
+```
+
+or its equivalent to:
+
+```ts
+const showAuthPanel = false;
+```
+
+If an optional account panel should remain in settings, render `AuthPanel` only inside settings/account surfaces, not in the main dashboard flow.
+
+- [ ] **Step 5: Replace user-facing authUnavailable copy**
+
+Search for strings:
+
+```text
+Sign in
+not authorized
+Authentication required
+Sign in or enable local no-auth mode
+```
+
+Replace public-flow copy with:
+
+```text
+The public workspace is not ready yet. Check setup status and try again.
+```
+
+For actual 429 errors, show:
+
+```text
+Too many requests. Please wait and retry shortly.
+```
+
+- [ ] **Step 6: Update DocumentViewer public readiness**
+
+In `src/components/DocumentViewer.tsx`, change public source preview readiness from auth-dependent to setup-dependent. Replace logic equivalent to:
+
+```ts
+const canUsePrivateApis = localProjectReady && (clientDemoMode || authStatus === "authenticated");
+```
+
+with:
+
+```ts
+const canUsePrivateApis = localProjectReady && (clientDemoMode || setupReadyForPublicApis);
+```
+
+Use the same existing setup signal used by the dashboard if available; if not available, derive it from successful setup-status/document fetch state in that component.
+
+- [ ] **Step 7: Run UI smoke test**
+
+Run:
+
+```powershell
+npm run ensure
+npm run test:e2e:chromium -- tests/ui-smoke.spec.ts -g "anonymous user can see enabled live search"
+```
+
+Expected: PASS.
+
+---
+
+### Task 7: Verify Authenticated Behavior Still Works and Destructive Routes Stay Protected
+
+**Files:**
+- Modify tests only unless failures reveal route regressions:
+ - `tests/private-access-routes.test.ts`
+ - `tests/api-validation-contract.test.ts`
+
+**Interfaces:**
+- Consumes: public access route changes.
+- Produces: confidence that anonymous access did not expose destructive actions.
+
+- [ ] **Step 1: Add protected mutation regression tests**
+
+Add tests proving anonymous delete/reindex/label mutation still fails:
+
+```ts
+it("keeps destructive document mutations protected from anonymous callers", async () => {
+ const client = createSupabaseMock();
+ mockRuntime(client);
+ const documentRoute = await import("../src/app/api/documents/[id]/route");
+ const reindexRoute = await import("../src/app/api/documents/[id]/reindex/route");
+
+ const deleteResponse = await documentRoute.DELETE(request(`/api/documents/${documentId}`, { method: "DELETE" }), {
+ params: Promise.resolve({ id: documentId }),
+ });
+ const reindexResponse = await reindexRoute.POST(request(`/api/documents/${documentId}/reindex`, { method: "POST" }), {
+ params: Promise.resolve({ id: documentId }),
+ });
+
+ expect(deleteResponse.status).toBe(401);
+ expect(reindexResponse.status).toBe(401);
+ expect(client.calls.some((call) => call.operation === "delete" || call.operation === "update")).toBe(false);
+});
+```
+
+- [ ] **Step 2: Run protected mutation tests**
+
+Run:
+
+```powershell
+npm run test -- tests/private-access-routes.test.ts -t "destructive document mutations"
+```
+
+Expected: PASS.
+
+- [ ] **Step 3: Run broader private/public route tests**
+
+Run:
+
+```powershell
+npm run test -- tests/private-access-routes.test.ts
+npm run test -- tests/api-validation-contract.test.ts
+```
+
+Expected: PASS.
+
+---
+
+### Task 8: Full Verification and Production Readiness Checks
+
+**Files:**
+- No source changes unless verification fails.
+
+**Interfaces:**
+- Consumes: all prior tasks.
+- Produces: verified anonymous live-search behavior.
+
+- [ ] **Step 1: Run cheap verification**
+
+Run:
+
+```powershell
+npm run verify:cheap
+```
+
+Expected: PASS.
+
+- [ ] **Step 2: Run UI verification because this changes frontend/auth UX**
+
+Run:
+
+```powershell
+npm run ensure
+npm run verify:ui
+```
+
+Expected: PASS. If browser setup is unavailable, record the exact failure and run the focused Playwright smoke test from Task 6 instead.
+
+- [ ] **Step 3: Run production readiness because this changes auth/public access**
+
+Run:
+
+```powershell
+npm run check:production-readiness
+```
+
+Expected: PASS or known environment-only warnings. Fix any new failures caused by public access env checks.
+
+- [ ] **Step 4: Manual smoke path**
+
+Use the URL printed by `npm run ensure` and verify:
+
+```text
+1. Open the app in a fresh/incognito browser.
+2. Confirm no "Sign in for private documents" panel appears.
+3. Type a clinical query into the composer.
+4. Submit the query.
+5. Confirm no "Search request was not authorized by the server" message appears.
+6. Confirm search/answer either returns live source-backed content or a non-auth setup/rate-limit message.
+7. Open a source preview from results.
+8. Confirm source preview does not ask for login.
+9. Repeat rapid submissions until a 429 appears.
+10. Confirm 429 copy tells the user to retry shortly, not sign in.
+```
+
+Expected: anonymous users can run live searches; excessive use is rate-limited.
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Forced login removal: Task 6.
+- “Search request was not authorized” fix: Tasks 3 and 6.
+- Live anonymous search: Task 3.
+- Source preview without login: Task 4.
+- Abuse control without captcha: Task 2 plus Task 3 route use.
+- Upload behavior without login: Task 5, with shared public owner guard.
+- Destructive action safety: Task 7.
+- Verification: Task 8.
+
+**Placeholder scan:** No TODO/TBD placeholders remain. The SQL migration filename needs a real timestamp at execution time because migration names must be unique in the executing branch.
+
+**Type consistency:** `RateLimitSubject`, `publicAccessContext`, and `consumeSubjectApiRateLimit` signatures are used consistently across tasks.
+
+**Non-commit note:** This plan intentionally excludes commit steps because the user has not requested a commit.
From e922119ae1fbf5d0700bd6d670883c999decb59b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 4 Jul 2026 05:12:25 +0000
Subject: [PATCH 08/16] chore: fix Prettier formatting in 4 files to pass CI
format:check
---
...-04-public-anonymous-access-rate-limits.md | 65 ++++++---
.../favourites-command-library-page.tsx | 31 +++--
.../master-document-flow-mockups.tsx | 43 ++++--
.../source-overlay-redesign-mockups.tsx | 130 ++++++++++++++----
4 files changed, 197 insertions(+), 72 deletions(-)
diff --git a/docs/superpowers/plans/2026-07-04-public-anonymous-access-rate-limits.md b/docs/superpowers/plans/2026-07-04-public-anonymous-access-rate-limits.md
index 1fea72f2c..9fbe85c15 100644
--- a/docs/superpowers/plans/2026-07-04-public-anonymous-access-rate-limits.md
+++ b/docs/superpowers/plans/2026-07-04-public-anonymous-access-rate-limits.md
@@ -69,12 +69,14 @@
### Task 1: Add Public Access Context and Optional Auth
**Files:**
+
- Modify: `src/lib/supabase/auth.ts:32-83`
- Create: `src/lib/public-api-access.ts`
- Modify: `src/lib/env.ts:1-20`, `src/lib/env.ts:150-179`
- Test: `tests/private-access-routes.test.ts`
**Interfaces:**
+
- Consumes: existing `requireAuthenticatedUser(request, supabase)`.
- Produces:
- `getOptionalAuthenticatedUser(request: Request, supabase: AdminClient): Promise`
@@ -199,9 +201,7 @@ import { getOptionalAuthenticatedUser } from "@/lib/supabase/auth";
type AdminClient = ReturnType;
-export type RateLimitSubject =
- | { kind: "owner"; ownerId: string }
- | { kind: "anonymous"; subjectKey: string };
+export type RateLimitSubject = { kind: "owner"; ownerId: string } | { kind: "anonymous"; subjectKey: string };
function firstForwardedIp(value: string | null) {
return value?.split(",")[0]?.trim() || "";
@@ -258,12 +258,14 @@ Expected: PASS.
### Task 2: Add Durable Anonymous Rate Limiting
**Files:**
+
- Modify: `src/lib/api-rate-limit.ts:1-160`
- Create: `supabase/migrations/YYYYMMDDHHMMSS_public_api_rate_limit_subjects.sql`
- Modify: `supabase/schema.sql`
- Test: `tests/private-access-routes.test.ts`
**Interfaces:**
+
- Consumes: `RateLimitSubject` from `src/lib/public-api-access.ts`.
- Produces:
- `consumeSubjectApiRateLimit(args: { supabase: SupabaseAdmin; subject: RateLimitSubject; bucket: ApiRateLimitBucket; limit?: number; windowSeconds?: number; allowInMemoryFallbackOnUnavailable?: boolean }): Promise`
@@ -469,7 +471,12 @@ export async function consumeSubjectApiRateLimit(args: {
code: error.code,
message: error.message,
});
- return consumeInMemoryApiRateLimit({ ownerId: args.subject.subjectKey, bucket: args.bucket, limit, windowSeconds });
+ return consumeInMemoryApiRateLimit({
+ ownerId: args.subject.subjectKey,
+ bucket: args.bucket,
+ limit,
+ windowSeconds,
+ });
}
throw new ApiRateLimitUnavailableError();
}
@@ -477,7 +484,12 @@ export async function consumeSubjectApiRateLimit(args: {
const row = parseRateLimitRow(data);
if (!row || typeof row.limited !== "boolean") {
if (args.allowInMemoryFallbackOnUnavailable) {
- return consumeInMemoryApiRateLimit({ ownerId: args.subject.subjectKey, bucket: args.bucket, limit, windowSeconds });
+ return consumeInMemoryApiRateLimit({
+ ownerId: args.subject.subjectKey,
+ bucket: args.bucket,
+ limit,
+ windowSeconds,
+ });
}
throw new ApiRateLimitUnavailableError();
}
@@ -507,12 +519,14 @@ Expected: PASS.
### Task 3: Make Search and Answer Public While Rate Limited
**Files:**
+
- Modify: `src/app/api/search/route.ts:882-909`
- Modify: `src/app/api/answer/route.ts:64-108`
- Modify: `src/app/api/answer/stream/route.ts:220-236`
- Test: `tests/private-access-routes.test.ts:2688-2804`
**Interfaces:**
+
- Consumes: `publicAccessContext`, `consumeSubjectApiRateLimit`.
- Produces: anonymous `/api/search`, `/api/answer`, and `/api/answer/stream` success instead of 401.
@@ -575,7 +589,9 @@ it("allows anonymous search and answer requests with anonymous rate limits", asy
expect(searchResponse.status).toBe(200);
expect(answerResponse.status).toBe(200);
expect(searchChunksWithTelemetry).toHaveBeenCalledWith(expect.objectContaining({ ownerId: undefined }));
- expect(answerQuestionWithScope).toHaveBeenCalledWith(expect.objectContaining({ ownerId: undefined, allowGlobalSearch: true }));
+ expect(answerQuestionWithScope).toHaveBeenCalledWith(
+ expect.objectContaining({ ownerId: undefined, allowGlobalSearch: true }),
+ );
expect(client.auth.getUser).not.toHaveBeenCalled();
expect(client.rpc).toHaveBeenCalledWith(
"consume_api_subject_rate_limit",
@@ -619,7 +635,9 @@ it("allows anonymous streamed answers with anonymous rate limits", async () => {
expect(response.status).toBe(200);
expect(ssePayload(body, "final")).toMatchObject({ answer: "Streamed public answer." });
- expect(answerQuestionWithScope).toHaveBeenCalledWith(expect.objectContaining({ ownerId: undefined, allowGlobalSearch: true }));
+ expect(answerQuestionWithScope).toHaveBeenCalledWith(
+ expect.objectContaining({ ownerId: undefined, allowGlobalSearch: true }),
+ );
expect(client.rpc).toHaveBeenCalledWith(
"consume_api_subject_rate_limit",
expect.objectContaining({ p_subject_key: expect.stringMatching(/^anon:/), p_bucket: "answer" }),
@@ -737,6 +755,7 @@ Expected: PASS.
### Task 4: Allow Anonymous Document Listing and Source Preview for Live Search Results
**Files:**
+
- Modify: `src/app/api/documents/route.ts:118-203`
- Modify after inspection: `src/app/api/documents/[id]/route.ts`
- Modify after inspection: `src/app/api/documents/[id]/search/route.ts`
@@ -745,6 +764,7 @@ Expected: PASS.
- Test: `tests/private-access-routes.test.ts`
**Interfaces:**
+
- Consumes: `publicAccessContext`.
- Produces: anonymous document browsing/source previews; authenticated users remain owner-scoped.
@@ -785,21 +805,16 @@ In `src/app/api/documents/route.ts`, replace:
```ts
const user = await requireAuthenticatedUser(request, supabase);
-let query = supabase
- .from("documents")
- .select(DOCUMENT_LIST_COLUMNS, { count: "exact" })
- .eq("owner_id", user.id)
+let query = supabase.from("documents").select(DOCUMENT_LIST_COLUMNS, { count: "exact" }).eq("owner_id", user.id);
```
with:
```ts
const access = await publicAccessContext(request, supabase);
-let query = supabase
- .from("documents")
- .select(DOCUMENT_LIST_COLUMNS, { count: "exact" });
+let query = supabase.from("documents").select(DOCUMENT_LIST_COLUMNS, { count: "exact" });
if (access.ownerId) query = query.eq("owner_id", access.ownerId);
-query = query
+query = query;
```
then keep the existing `.order(...).range(...)` chain after `query = query`.
@@ -842,11 +857,13 @@ Expected: PASS.
### Task 5: Decide and Implement Anonymous Upload Behavior
**Files:**
+
- Modify: `src/app/api/upload/route.ts:24-238`
- Modify: `src/components/clinical-dashboard/DocumentManagerPanel.tsx`
- Test: `tests/private-access-routes.test.ts`, `tests/api-validation-contract.test.ts`
**Interfaces:**
+
- Consumes: `publicWorkspaceOwnerId()`, `publicUploadsEnabled()`, `publicAccessContext`.
- Produces: either public upload to shared owner when configured, or no login prompt when not configured.
@@ -875,7 +892,8 @@ it("uploads anonymous documents to the configured public workspace owner", async
const publicOwnerId = "99999999-9999-4999-8999-999999999999";
const client = createSupabaseMock((call) => {
if (call.table === "documents" && call.operation === "select" && call.maybeSingle) return ok(null);
- if (call.table === "documents" && call.operation === "insert") return ok({ id: documentId, owner_id: publicOwnerId });
+ if (call.table === "documents" && call.operation === "insert")
+ return ok({ id: documentId, owner_id: publicOwnerId });
if (call.table === "ingestion_jobs" && call.operation === "insert") return ok({ id: "job-1" });
if (call.table === "audit_log" && call.operation === "insert") return ok([]);
return ok([]);
@@ -889,7 +907,9 @@ it("uploads anonymous documents to the configured public workspace owner", async
expect(response.status).toBe(200);
expect(client.auth.getUser).not.toHaveBeenCalled();
- expect(client.calls.find((call) => call.table === "documents" && call.operation === "insert")?.insertPayload).toMatchObject({
+ expect(
+ client.calls.find((call) => call.table === "documents" && call.operation === "insert")?.insertPayload,
+ ).toMatchObject({
owner_id: publicOwnerId,
});
});
@@ -925,13 +945,13 @@ Then replace every `user.id` in upload ownership/storage/audit/naming code with
In `DocumentManagerPanel.tsx`, replace copy like:
```ts
-"Sign in before uploading private guideline files."
+"Sign in before uploading private guideline files.";
```
with:
```ts
-"Uploads are unavailable until this public workspace is configured."
+"Uploads are unavailable until this public workspace is configured.";
```
- [ ] **Step 5: Run upload tests**
@@ -949,6 +969,7 @@ Expected: PASS.
### Task 6: Remove Forced Login UI and Fix “Search request was not authorized” UX
**Files:**
+
- Modify: `src/components/ClinicalDashboard.tsx:3652-3664`, `src/components/ClinicalDashboard.tsx:5341-5893`
- Modify: `src/components/clinical-dashboard/auth-panel.tsx`
- Modify: `src/components/clinical-dashboard/document-search-results.tsx`
@@ -956,6 +977,7 @@ Expected: PASS.
- Test: `tests/ui-smoke.spec.ts` or focused component route tests if present
**Interfaces:**
+
- Consumes: public API behavior from Tasks 3-5.
- Produces: no forced sign-in panel; search controls enabled when backend setup is ready; no 401 authorization banner for anonymous users.
@@ -1074,11 +1096,13 @@ Expected: PASS.
### Task 7: Verify Authenticated Behavior Still Works and Destructive Routes Stay Protected
**Files:**
+
- Modify tests only unless failures reveal route regressions:
- `tests/private-access-routes.test.ts`
- `tests/api-validation-contract.test.ts`
**Interfaces:**
+
- Consumes: public access route changes.
- Produces: confidence that anonymous access did not expose destructive actions.
@@ -1132,9 +1156,11 @@ Expected: PASS.
### Task 8: Full Verification and Production Readiness Checks
**Files:**
+
- No source changes unless verification fails.
**Interfaces:**
+
- Consumes: all prior tasks.
- Produces: verified anonymous live-search behavior.
@@ -1193,6 +1219,7 @@ Expected: anonymous users can run live searches; excessive use is rate-limited.
## Self-Review
**Spec coverage:**
+
- Forced login removal: Task 6.
- “Search request was not authorized” fix: Tasks 3 and 6.
- Live anonymous search: Task 3.
diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx
index a3202196a..8267b8da2 100644
--- a/src/components/clinical-dashboard/favourites-command-library-page.tsx
+++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx
@@ -139,7 +139,8 @@ const sourceRecords: SourceRecord[] = [
];
const typeStyles: Record = {
- Medication: "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]",
+ Medication:
+ "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]",
Document: "border-blue-200 bg-blue-50 text-blue-700",
Table: "border-emerald-200 bg-emerald-50 text-emerald-700",
"Saved search": "border-slate-200 bg-slate-50 text-slate-700",
@@ -161,13 +162,7 @@ function MiniIconTile({ icon: Icon, active = false }: { icon: LucideIcon; active
);
}
-function SmallChip({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
+function SmallChip({ children, className }: { children: React.ReactNode; className?: string }) {
return (
Continue
-
- Acamprosate renal screen
-
+ Acamprosate renal screen
Ward round · 3 sources · last opened Today 08:44
@@ -492,7 +485,8 @@ function FavouritesTable() {
key={item.id}
className={cn(
"relative h-[5.25rem] transition hover:bg-[color:var(--surface-subtle)]",
- item.selected && "bg-[color:var(--clinical-accent-soft)]/45 shadow-[inset_2px_0_0_var(--clinical-accent)]",
+ item.selected &&
+ "bg-[color:var(--clinical-accent-soft)]/45 shadow-[inset_2px_0_0_var(--clinical-accent)]",
)}
>
@@ -651,7 +645,8 @@ function ItemWorkspace() {
- Saved in Ward round
+ Saved in {" "}
+ Ward round
@@ -750,7 +745,10 @@ function ItemWorkspace() {
Edit
@@ -764,7 +762,10 @@ function ItemWorkspace() {
Updated 11 May 2024
Save note
diff --git a/src/components/master-document-flow-mockups.tsx b/src/components/master-document-flow-mockups.tsx
index c74df205c..01b435b7c 100644
--- a/src/components/master-document-flow-mockups.tsx
+++ b/src/components/master-document-flow-mockups.tsx
@@ -538,7 +538,10 @@ function SelectedSourceTray({
}) {
return (