From 185d96b4c686d3cb8f1c918e6da8214d54ab96ce Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:50:57 +0800 Subject: [PATCH 1/3] wip: tools-mockup + services-navigator design exploration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds the stray design exploration originally rescued in claude/mockup-wip-rescue, cleanly on top of current main instead of its old, regressed base. Adds three new mockup concepts (tools-action-workbench, tools-clinical-lanes, services-navigator-preview) plus rectangle-direction-mockups and the supporting edits to master-search-header/document-search-live-opener/ tools-page-mockup-page. Dropped from the original rescue (verified against current main): - ClinicalDashboard.tsx / dashboard-shell.tsx: the only real edits (two arbitrary text-size values replaced with type-scale tokens) are already on main independently; the rest of those files' rescue content was built on a since-regressed intermediate commit and would have deleted functionality main has since gained. - global-mockup-search-shell.tsx, globals.css: already identical to main. Unreviewed exploratory work — needs a decision on whether to fold into feature/tools-page-mockups (#225), develop further standalone, or drop. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 2 + .../mockups/tools-action-workbench/page.tsx | 12 + src/app/mockups/tools-clinical-lanes/page.tsx | 12 + src/app/services-navigator-preview/page.tsx | 5 + .../master-search-header.tsx | 231 +++++- .../document-search-live-opener.tsx | 537 ++++++++++-- .../services/services-navigator-preview.tsx | 771 ++++++++++++++++++ .../rectangle-direction-mockups.tsx | 527 ++++++++++++ .../tools-page-mockup-page.tsx | 314 +++++-- 9 files changed, 2252 insertions(+), 159 deletions(-) create mode 100644 src/app/mockups/tools-action-workbench/page.tsx create mode 100644 src/app/mockups/tools-clinical-lanes/page.tsx create mode 100644 src/app/services-navigator-preview/page.tsx create mode 100644 src/components/services/services-navigator-preview.tsx create mode 100644 src/components/tools-page-mockups/rectangle-direction-mockups.tsx diff --git a/.gitignore b/.gitignore index e01c0271a..99323698d 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,8 @@ next-env.d.ts # agent/QA artifacts .codex-screenshots/ +# generated design/QA review screenshots (e.g. artifacts/tools-page-review, favourites-review) +/artifacts/ # local hook tool cache — machine-local, never commit .impeccable/ # Local debugging scratch space — never commit (accidentally landed once via 'Save Codex local changes') diff --git a/src/app/mockups/tools-action-workbench/page.tsx b/src/app/mockups/tools-action-workbench/page.tsx new file mode 100644 index 000000000..1a9a4cc07 --- /dev/null +++ b/src/app/mockups/tools-action-workbench/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ToolsActionWorkbenchMockup } from "@/components/tools-page-mockups/rectangle-direction-mockups"; + +export const metadata: Metadata = { + title: "Tools Action Workbench Mockup - Clinical KB", + description: "Rectangle-first action workbench Tools page mockup for Clinical KB.", +}; + +export default function ToolsActionWorkbenchMockupRoute() { + return ; +} diff --git a/src/app/mockups/tools-clinical-lanes/page.tsx b/src/app/mockups/tools-clinical-lanes/page.tsx new file mode 100644 index 000000000..4c5615bc8 --- /dev/null +++ b/src/app/mockups/tools-clinical-lanes/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ToolsClinicalLanesMockup } from "@/components/tools-page-mockups/rectangle-direction-mockups"; + +export const metadata: Metadata = { + title: "Tools Clinical Lanes Mockup - Clinical KB", + description: "Rectangle-first clinical lane Tools page mockup for Clinical KB.", +}; + +export default function ToolsClinicalLanesMockupRoute() { + return ; +} diff --git a/src/app/services-navigator-preview/page.tsx b/src/app/services-navigator-preview/page.tsx new file mode 100644 index 000000000..9c80c1071 --- /dev/null +++ b/src/app/services-navigator-preview/page.tsx @@ -0,0 +1,5 @@ +import { ServicesNavigatorPreview } from "@/components/services/services-navigator-preview"; + +export default function ServicesNavigatorPreviewRoute() { + return ; +} diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 81704772c..cfe0091a8 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -14,15 +14,20 @@ import { createPortal } from "react-dom"; import { Activity, + BadgeCheck, BrainCircuit, CalendarDays, Check, CheckCircle2, ChevronDown, + FileSignature, FileText, Filter, + FolderOpen, + GitBranch, Globe2, Heart, + ListChecks, Loader2, Menu, MessageSquarePlus, @@ -81,7 +86,7 @@ const appModeIcons: Record = { answer: Sparkles, documents: FileText, services: ShieldCheck, - forms: FileText, + forms: FileSignature, favourites: Heart, differentials: BrainCircuit, prescribing: Pill, @@ -893,14 +898,166 @@ export function MasterSearchHeader({ ); } + // "open-evidence" is the one footer-chip action that isn't already a mode-action + // id — every other chip dispatches through the existing runModeAction handler + // (the same dispatcher the "+" action menu already uses for these ids). + type FooterChipActionId = ModeActionId | "open-evidence"; + + type FooterActionChip = { + icon: typeof Search; + shortLabel: string; + longLabel: string; + actionId: FooterChipActionId; + ariaLabel: string; + }; + + // The first ("trust") chip on the universal small-screen footer. Every mode gets + // one, mirroring Answer's "Evidence-based" chip in tone, each wired to a real + // action from that mode's own action menu rather than being decorative. + function footerTrustChipFor(mode: AppModeId): FooterActionChip | null { + switch (mode) { + case "answer": + return { + icon: ListChecks, + shortLabel: "Evidence", + longLabel: "Evidence-based", + actionId: "open-evidence", + ariaLabel: "Open evidence-backed answer sources", + }; + case "documents": + return { + icon: BadgeCheck, + shortLabel: "Indexed", + longLabel: "Fully indexed", + actionId: "documents-collections", + ariaLabel: "Open the indexed document library", + }; + case "forms": + return { + icon: BadgeCheck, + shortLabel: "Library", + longLabel: "Form library", + actionId: "documents-collections", + ariaLabel: "Open the form library", + }; + case "services": + return { + icon: BadgeCheck, + shortLabel: "Verified", + longLabel: "Verified directory", + actionId: "services-records", + ariaLabel: "Browse verified service records", + }; + case "favourites": + return { + icon: BadgeCheck, + shortLabel: "Trusted", + longLabel: "Trusted picks", + actionId: "favourites-browse", + ariaLabel: "Browse trusted favourites", + }; + case "differentials": + return { + icon: ListChecks, + shortLabel: "Evidence", + longLabel: "Evidence-linked", + actionId: "differentials-evidence", + ariaLabel: "Review cited differential evidence", + }; + case "prescribing": + return { + icon: ShieldCheck, + shortLabel: "Safety", + longLabel: "Safety-checked", + actionId: "medication-safety", + ariaLabel: "Review contraindications and cautions", + }; + case "tools": + return { + icon: BadgeCheck, + shortLabel: "Curated", + longLabel: "Curated registry", + actionId: "tools-browse", + ariaLabel: "Browse the curated tools registry", + }; + default: + return null; + } + } + + // The second footer chip. Answer/Documents/Forms use the shared document-scope + // trigger instead (see hasScopeFooterChip below) since scope is a real, existing + // concept for those three modes. Tools has no genuine second action yet, so it + // intentionally ships with a single chip rather than an invented one. + function footerSecondaryChipFor(mode: AppModeId): FooterActionChip | null { + switch (mode) { + case "services": + return { + icon: ListChecks, + shortLabel: "Pathways", + longLabel: "Pathways", + actionId: "services-pathways", + ariaLabel: "Browse referral pathways", + }; + case "favourites": + return { + icon: FolderOpen, + shortLabel: "Sets", + longLabel: "Sets", + actionId: "favourites-sets", + ariaLabel: "Open saved sets", + }; + case "differentials": + return { + icon: GitBranch, + shortLabel: "Criteria", + longLabel: "Criteria", + actionId: "differentials-criteria", + ariaLabel: "Compare distinguishing criteria", + }; + case "prescribing": + return { + icon: Activity, + shortLabel: "Monitor", + longLabel: "Monitoring", + actionId: "medication-monitoring", + ariaLabel: "Review the monitoring schedule", + }; + default: + return null; + } + } + + function runFooterChipAction(actionId: FooterChipActionId) { + if (actionId === "open-evidence") { + onOpenEvidence?.(); + return; + } + runModeAction(actionId); + } + function renderSearchComposer(placement: "default" | "desktop-home") { const isDesktopHomeComposer = placement === "desktop-home"; const usesAnswerFooterStyle = isAnswerFooterComposer && !isDesktopHomeComposer; const usesMobileBottomStyle = isMobileBottomComposer && !isDesktopHomeComposer; const usesUniversalFooterStyle = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); - const showFooterSearchChips = usesUniversalFooterStyle && searchMode === "answer"; - // Only the Answer chat composer uses the send affordance; every search-mode home uses the magnifier. + // Every mode shows the universal footer chip row on its small-screen composer now; + // larger screens (sticky-top / hero composers) are untouched for now. + const showFooterSearchChips = usesUniversalFooterStyle; + // Answer keeps the send affordance everywhere (it's the one conversational compose + // mode). Every other mode swaps the magnifier for its own mode-identity glyph, but + // only on the small-screen floating composer — larger screens keep the magnifier. const usesSendAffordance = usesAnswerFooterStyle; + const usesModeIdentityAffordance = usesUniversalFooterStyle && !usesSendAffordance; + const ModeIdentityIcon = appModeIcons[searchMode]; + const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents" || searchMode === "forms"; + const trustFooterChip = footerTrustChipFor(searchMode); + const secondaryFooterChip = footerSecondaryChipFor(searchMode); + // Fallback icons here are never rendered — both are only used inside a JSX guard + // on the corresponding chip being non-null — but keep the icon variables typed as + // components (not `| null`) so the JSX below type-checks without a cast. + const TrustFooterChipIcon = trustFooterChip?.icon ?? BadgeCheck; + const SecondaryFooterChipIcon = secondaryFooterChip?.icon ?? ListChecks; const composerPlaceholder = usesMobileBottomStyle && searchMode === "differentials" ? "Search a presentation" : queryPlaceholder; @@ -1004,38 +1161,56 @@ export function MasterSearchHeader({ ) : usesSendAffordance ? ( + ) : usesModeIdentityAffordance ? ( + ) : ( )} {submitLabel} - {showFooterSearchChips ? ( + {showFooterSearchChips && (trustFooterChip || hasScopeFooterChip || secondaryFooterChip) ? (
- - - {!usesScopeSheet && scopeOpen ? ( + {trustFooterChip ? ( + + ) : null} + {hasScopeFooterChip ? ( + + ) : null} + {!hasScopeFooterChip && secondaryFooterChip ? ( + + ) : null} + {hasScopeFooterChip && !usesScopeSheet && scopeOpen ? (
; + passage: string[]; + tableRows: Array<[string, string, string]>; +}; const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; const defaultQuery = "clozapine monitoring table"; +const mockSources: MockSourceDocument[] = [ + { + slug: "clozapine-monitoring", + title: "Clozapine physical health monitoring protocol", + fileName: "clozapine-physical-health-monitoring.pdf", + kind: "Protocol", + defaultPage: 12, + pageCount: 18, + status: "Current", + review: "Review 2026", + section: "Blood test monitoring table", + summary: + "Mock source preview for the command-centre handoff. It keeps the exact page, table evidence, and actions visible without requiring private document authentication.", + tags: ["Medication", "Monitoring", "Shared care"], + matchedTerms: ["clozapine", "monitoring", "table"], + evidence: [ + { label: "Table evidence", value: "8 rows", icon: Table2, tone: "success" }, + { label: "PDF page", value: "p.12", icon: FileText, tone: "info" }, + { label: "Review note", value: "2026", icon: AlertCircle, tone: "warning" }, + ], + passage: [ + "Monitoring requirements are grouped by treatment stage and missed-dose interval.", + "Restart and escalation decisions should be checked against the local protocol table.", + "Shared-care transfer requires the monitoring schedule and review responsibility to be visible.", + ], + tableRows: [ + ["Stable treatment", "Continue scheduled FBC/ANC checks", "Routine review"], + ["Missed dose 48-72h", "Restart pathway and monitoring check", "Prescriber review"], + ["Review due", "Confirm local protocol currency", "Document source status"], + ], + }, + { + slug: "acute-agitation-pathway", + title: "Acute agitation clinical pathway", + fileName: "acute-agitation-clinical-pathway.pdf", + kind: "Guideline", + defaultPage: 4, + pageCount: 9, + status: "Current", + review: "Local pathway", + section: "Flowchart and escalation pathway", + summary: + "Mock source preview showing how image and flowchart evidence can stay attached to the selected search result.", + tags: ["Risk", "Escalation", "ED"], + matchedTerms: ["agitation", "pathway", "flowchart"], + evidence: [ + { label: "Image evidence", value: "flowchart", icon: FileImage, tone: "info" }, + { label: "PDF page", value: "p.4", icon: FileText, tone: "success" }, + { label: "Risk pathway", value: "visible", icon: AlertCircle, tone: "warning" }, + ], + passage: [ + "The pathway separates immediate safety steps from medication and senior review prompts.", + "Flowchart evidence remains visible before opening the full source file.", + "Escalation points are grouped so the result can be scoped or used for a follow-up answer.", + ], + tableRows: [ + ["Immediate risk", "Use local safety pathway", "Escalate"], + ["De-escalation", "Document response and triggers", "Review"], + ["Senior input", "Confirm local governance", "Open source"], + ], + }, + { + slug: "mental-health-act-forms", + title: "Mental Health Act forms quick reference", + fileName: "mental-health-act-forms-reference.pdf", + kind: "Quick reference", + defaultPage: 2, + pageCount: 6, + status: "Indexed", + review: "Form checklist", + section: "Forms and documentation", + summary: + "Mock source preview for form-heavy results, keeping the document type and target page obvious from the handoff.", + tags: ["Forms", "Workflow", "Legal"], + matchedTerms: ["forms", "workflow", "reference"], + evidence: [ + { label: "Checklist", value: "forms", icon: BadgeCheck, tone: "success" }, + { label: "PDF page", value: "p.2", icon: FileText, tone: "info" }, + { label: "Workflow", value: "legal", icon: AlertCircle, tone: "warning" }, + ], + passage: [ + "The quick reference groups forms by use case and required documentation step.", + "The handoff preserves the target page so users can inspect the original source quickly.", + "Scope and answer actions remain available from the selected source preview.", + ], + tableRows: [ + ["Assessment", "Open form checklist", "Confirm status"], + ["Transfer", "Check required document", "Open source"], + ["Review", "Record local governance", "Scope"], + ], + }, +]; + async function fetchJson(url: string, signal: AbortSignal, authorizationHeader: Record): Promise { const response = await fetch(url, { cache: "no-store", @@ -74,12 +199,315 @@ function liveDocumentHref(documentId: string, result: ChunkSearchResult | undefi return `/documents/${documentId}?${params.toString()}`; } +function numberParam(value: string | null, fallback: number) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +} + +function mockSourceFor(documentHint: string, query: string) { + const normalized = `${documentHint} ${query}`.toLowerCase(); + return ( + mockSources.find((source) => normalized.includes(source.slug) || normalized.includes(source.title.toLowerCase())) ?? + (normalized.includes("agitation") + ? mockSources.find((source) => source.slug === "acute-agitation-pathway") + : null) ?? + (normalized.includes("mental health act") || normalized.includes("forms") + ? mockSources.find((source) => source.slug === "mental-health-act-forms") + : null) ?? + mockSources[0] + ); +} + +function TonePill({ + children, + tone = "neutral", +}: { + children: React.ReactNode; + tone?: "accent" | "info" | "success" | "warning" | "neutral"; +}) { + const toneClass = + tone === "accent" + ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" + : tone === "info" + ? "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]" + : tone === "success" + ? "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]" + : tone === "warning" + ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]" + : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]"; + return ( + + {children} + + ); +} + +function EvidenceCard({ label, value, icon: Icon, tone }: MockSourceDocument["evidence"][number]) { + const toneClass = + tone === "success" + ? "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]" + : tone === "warning" + ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]" + : "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]"; + return ( +
+ + +

{label}

+

{value}

+
+ ); +} + +function MockDocumentPagePreview({ source, page, chunk }: { source: MockSourceDocument; page: number; chunk: string }) { + return ( +
+
+
+

+ Mock source page +

+

{source.section}

+
+
+ p.{page} + {chunk.replaceAll("-", " ")} +
+
+
+
+
+ + + +
+
+

{source.passage[0]}

+
+
+ {source.passage.slice(1).map((line) => ( +

+ {line} +

+ ))} +
+
+ + + + + + + + + + {source.tableRows.map((row, index) => ( + + {row.map((cell) => ( + + ))} + + ))} + +
Source rowWhat to reviewAction
+ {cell} +
+
+
+ +
+
+ ); +} + +function MockSourceWorkbench({ + source, + page, + chunk, + query, + message, + liveHref, +}: { + source: MockSourceDocument; + page: number; + chunk: string; + query: string; + message: string; + liveHref?: string; +}) { + return ( +
+
+
+ + +
+

+ Mock source preview +

+

+ {source.title} +

+

{message}

+
+ {liveHref ? ( + +
+
+ +
+
+ +
+ {source.evidence.map((item) => ( + + ))} +
+
+ + +
+
+ ); +} + export function DocumentSearchLiveOpener() { const router = useRouter(); const searchParams = useSearchParams(); - const { authorizationHeader } = useAuthSession(); + const { authorizationHeader, status: authStatus } = useAuthSession(); const query = searchParams.get("q")?.trim() || defaultQuery; const documentHint = searchParams.get("document")?.trim() || "clozapine"; + const mockSource = useMemo(() => mockSourceFor(documentHint, query), [documentHint, query]); + const requestedPage = numberParam(searchParams.get("page"), mockSource.defaultPage); + const chunk = searchParams.get("chunk")?.trim() || "best-match"; const [state, setState] = useState({ status: "opening", message: "Finding an indexed document and matching source chunk.", @@ -91,6 +519,19 @@ export function DocumentSearchLiveOpener() { const controller = new AbortController(); async function openLiveDocument() { + if (authStatus === "loading") { + setState({ status: "opening", message: "Checking browser document access." }); + return; + } + if (!authorizationHeader.authorization) { + setState({ + status: "mock", + message: + "The live document viewer needs a signed-in private document session. This mock preview shows the same handoff behavior without requiring auth.", + }); + return; + } + try { setState({ status: "opening", message: "Finding a real indexed document." }); const documentParams = new URLSearchParams({ @@ -118,8 +559,9 @@ export function DocumentSearchLiveOpener() { if (documents.length === 0) { setState({ - status: "error", - message: "No indexed documents are available to open in the live viewer.", + status: "mock", + message: + "No indexed live document was available for this lookup. This mock preview shows the intended source handoff.", }); return; } @@ -161,19 +603,22 @@ export function DocumentSearchLiveOpener() { } catch (error) { if (controller.signal.aborted) return; setState({ - status: "error", - message: error instanceof Error ? error.message : "The live document could not be opened.", + status: "mock", + message: + error instanceof Error + ? `${error.message} Showing the mock source preview instead.` + : "The live document could not be opened. Showing the mock source preview instead.", }); } } void openLiveDocument(); return () => controller.abort(); - }, [authorizationHeader, lookupTerm, query, router]); + }, [authStatus, authorizationHeader, lookupTerm, query, router]); return (
-
+
-
-
- - {state.status === "opening" ? ( + {state.status === "opening" ? ( +
+
+ -
-

- Live document handoff -

-

- {state.status === "opening" ? "Opening the actual document" : "Could not open the actual document"} -

-

{state.message}

-
- - - - + +
+

+ Live document handoff +

+

+ Opening the actual document +

+

{state.message}

+
+ + + + +
-
- - {state.liveHref ? ( - -
+
+ ) : ( + + )}
); diff --git a/src/components/services/services-navigator-preview.tsx b/src/components/services/services-navigator-preview.tsx new file mode 100644 index 000000000..bb6219ab0 --- /dev/null +++ b/src/components/services/services-navigator-preview.tsx @@ -0,0 +1,771 @@ +"use client"; + +import Link from "next/link"; +import { + ArrowRight, + Bookmark, + Check, + ChevronDown, + CircleAlert, + CircleCheck, + CircleX, + DollarSign, + ExternalLink, + Menu, + Mic, + Phone, + Plus, + Search, + Send, + ShieldCheck, + SlidersHorizontal, + Users, + X, + type LucideIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { cn } from "@/components/ui-primitives"; +import { searchServiceRecords, serviceRecords, type ServiceRecord, type ServiceStatusChip } from "@/lib/services"; + +const defaultQuery = "13YARN crisis support aboriginal phone"; + +function visibleText(value: string | null | undefined, fallback = "Confirm locally") { + return value?.trim() ? value.trim() : fallback; +} + +function chipToneClass(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 criterionCounts(records: ServiceRecord[]) { + return records.reduce( + (totals, service) => { + for (const criterion of service.criteria ?? []) { + if (criterion.tone === "meet") totals.meets += 1; + if (criterion.tone === "caution") totals.cautions += 1; + if (criterion.tone === "reject") totals.rejects += 1; + } + return totals; + }, + { meets: 0, cautions: 0, rejects: 0 }, + ); +} + +function confidenceCounts(records: ServiceRecord[]) { + return records.reduce( + (totals, service) => { + const confidence = service.verification?.confidence ?? "Unknown"; + if (confidence === "High") totals.high += 1; + else if (confidence === "Medium") totals.medium += 1; + else if (confidence === "Low") totals.low += 1; + else totals.unknown += 1; + return totals; + }, + { high: 0, medium: 0, low: 0, unknown: 0 }, + ); +} + +function ServiceBadge({ chip }: { chip: ServiceStatusChip }) { + return ( + + + {visibleText(chip.label, "Status")} + + ); +} + +function Metric({ + icon: Icon, + label, + value, + detail, +}: { + icon: LucideIcon; + label: string; + value: string; + detail: string; +}) { + return ( +
+ + + + + {label} + {value} + {detail} + +
+ ); +} + +function ServiceCard({ + service, + index, + selected, + onToggleSelected, + compact = false, +}: { + service: ServiceRecord; + index: number; + selected: boolean; + onToggleSelected: (slug: string) => void; + compact?: boolean; +}) { + const rank = index + 1; + const highlighted = rank <= 2; + const contact = visibleText(service.primaryContact?.value); + const route = visibleText(service.primaryContact?.detail ?? service.route, "Referral route pending"); + const eligibility = visibleText(service.eligibility, "Eligibility pending"); + const cost = visibleText(service.cost, "Cost pending"); + const tags = [...(service.catchments ?? []), ...(service.tags ?? [])].slice(0, compact ? 3 : 5); + + return ( +
+
+ + {rank} + +
+
+

+ {service.title} +

+ {!compact && highlighted ? ( + Best fit + ) : null} +
+
+ {(service.statusChips ?? []).slice(0, compact ? 3 : 4).map((chip) => ( + + ))} +
+

+ {visibleText(service.subtitle ?? service.bestUse, "Open the record for referral details.")} +

+
+ +
+ +
+ + + + +
+ +
+
+ {tags.map((tag, tagIndex) => ( + 2 ? "max-sm:hidden" : "", + )} + > + {tag} + + ))} + {(service.tags?.length ?? 0) + (service.catchments?.length ?? 0) > tags.length ? ( + + +1 + + ) : null} +
+
+ + + Open + + +
+
+
+ ); +} + +function SearchBar({ + value, + onChange, + compact = false, + showSubmit = true, +}: { + value: string; + onChange: (next: string) => void; + compact?: boolean; + showSubmit?: boolean; +}) { + return ( +
event.preventDefault()} + > + + + onChange(event.target.value)} + placeholder="Search services..." + className="min-w-0 bg-transparent text-sm font-semibold text-[#061740] outline-none placeholder:text-slate-400" + /> + {value ? ( + + ) : null} + + {showSubmit ? ( + + ) : null} + + ); +} + +function Header() { + return ( +
+
+ + + +
+

Services Navigator

+

Psychiatry referral directory

+
+ + +
+
+ + + + AK + +
+
+ ); +} + +function Stepper() { + const steps = [ + ["1", "Search", "Find services"], + ["2", "Shortlist", "Pick best options"], + ["3", "Compare", "Review side by side"], + ["4", "Refer", "Send with confidence"], + ]; + return ( +
+ {steps.map(([number, title, body], index) => ( +
+ + {number} + + + + {title} + + {body} + +
+ ))} +
+ ); +} + +function DesktopRightRail({ + matches, + selected, + onToggleSelected, +}: { + matches: ServiceRecord[]; + selected: ServiceRecord[]; + onToggleSelected: (slug: string) => void; +}) { + const criteria = criterionCounts(matches); + const confidence = confidenceCounts(matches); + const localConfirmationCount = matches.filter((service) => + (service.source?.status ?? "").toLowerCase().includes("confirmation"), + ).length; + const verifiedCount = matches.filter( + (service) => + service.verification?.locallyVerified || (service.source?.status ?? "").toLowerCase().includes("source"), + ).length; + const checklistRows: Array<[string, number, LucideIcon, string]> = [ + ["Meets", criteria.meets, CircleCheck, "text-emerald-600"], + ["Caution", criteria.cautions, CircleAlert, "text-orange-500"], + ["Does not meet", criteria.rejects, CircleX, "text-red-600"], + ["Source verified", verifiedCount, CircleCheck, "text-emerald-600"], + ["Local confirmation", localConfirmationCount, CircleAlert, "text-orange-500"], + ]; + + return ( + + ); +} + +function PhonePreview({ + query, + onQueryChange, + matches, + selectedSlugs, + onToggleSelected, +}: { + query: string; + onQueryChange: (next: string) => void; + matches: ServiceRecord[]; + selectedSlugs: string[]; + onToggleSelected: (slug: string) => void; +}) { + return ( +
+
+
+
+ +
+ + + + Services Navigator +
+ +
+
+
+
+

{matches.length} referral matches

+

+ Best fit for crisis, ATSI-specific, phone referral. +

+
+ +
+
+ {["Best fit", "Crisis", "ATSI-specific", "+3"].map((chip, index) => ( + 2 ? "max-sm:hidden" : "", + index === 0 ? "border-[#007a78] bg-[#007a78] text-white" : "border-slate-200 bg-white text-slate-600", + )} + > + {chip} + + ))} +
+ +
+ {matches.slice(0, 3).map((service, index) => ( + + ))} +
+
+
+ {selectedSlugs.length} selected + Compare + +
+
+
+ + + +
+
+
+
+ ); +} + +export function ServicesNavigatorPreview() { + const [query, setQuery] = useState(defaultQuery); + 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 selectedServices = serviceRecords.filter((service) => selectedSlugs.includes(service.slug)); + + function toggleSelected(slug: string) { + setSelectedSlugs((current) => { + if (current.includes(slug)) return current.filter((item) => item !== slug); + return [slug, ...current].slice(0, 5); + }); + } + + return ( +
+
+
+
+
+
+ + +
+ +
+
+
+
+

+ {matches.length} referral matches +

+

+ Best fit for crisis, ATSI-specific, phone referral. +

+
+ +
+
+ {["Best fit", "Crisis", "ATSI-specific", "Phone referral", "Free", "WA"].map((chip, index) => ( + + ))} + +
+
+ {matches.map((service, index) => ( + + ))} +
+
+ +
+
+ +
+
+
+
+ +
event.preventDefault()} + > + + + setQuery(event.target.value)} + placeholder="Search services..." + className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-[#061740] outline-none placeholder:text-slate-400" + /> + {query ? ( + + ) : null} + + + +
+
+
+ ); +} diff --git a/src/components/tools-page-mockups/rectangle-direction-mockups.tsx b/src/components/tools-page-mockups/rectangle-direction-mockups.tsx new file mode 100644 index 000000000..6e5afbdea --- /dev/null +++ b/src/components/tools-page-mockups/rectangle-direction-mockups.tsx @@ -0,0 +1,527 @@ +"use client"; + +import Link from "next/link"; +import { + ArrowRight, + BookOpen, + CheckCircle2, + ClipboardList, + Clock3, + FileText, + HeartPulse, + Pill, + Pin, + Search, + ShieldCheck, + Sparkles, + Star, + Stethoscope, + type LucideIcon, +} from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/components/ui-primitives"; + +import { areaLabels, pinnedToolIds, toolById, tools, type ToolFixture } from "./tool-fixtures"; +import { useToolFilter, type ToolFilterId } from "./use-tool-filter"; + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +function IconTile({ icon: Icon, active = false }: { icon: LucideIcon; active?: boolean }) { + return ( + + + ); +} + +function SearchBar({ + value, + onChange, + placeholder = "Search tools by clinical job, source, or workflow", +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; +}) { + return ( +
event.preventDefault()} + className="grid min-h-[3.25rem] w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-full border border-[color:var(--border-strong)] bg-[color:var(--surface)] px-2 shadow-[var(--shadow-tight)]" + > +
-
-
- - {mode === "workflow" ? ( -
- {["Assess", "Reference", "Treat", "Coordinate"].map((label) => ( - - {label} - - ))} -
- ) : null} + {selectedTool && SelectedIcon ? ( +
+ -
- {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 ( - -
+ + ); + + return interactive ? ( + + ) : ( + + {rowContent} + + ); + })} +
+ +
+
+ + Recent work + + View +
+
+ {recentWork.slice(0, 2).map((item) => { + const Icon = item.icon; + return ( + +
+
+ + ) : null}
@@ -904,6 +1040,9 @@ 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; + const overviewToolIds = ["clinical-kb-search", suggestedId, "medication-prescribing", "favourites"]; return ( <> @@ -956,13 +1095,21 @@ function SplitPaneMockup() {

Launcher overview

- Filters sit beside the overview, while the full-width All tools view below carries the main browsing - weight. + Choose a tool to preview its purpose and recent context in the phone frame before opening it.

- {["clinical-kb-search", suggestedId, "medication-prescribing", "favourites"].map((id) => ( - - ))} + {overviewToolIds.map((id) => { + const tool = toolById(id); + return ( + setSelectedToolId(id)} + /> + ); + })}
@@ -971,6 +1118,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("")} /> From 4f5effb1d5aefc78dda1002987919e3345751b51 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:09:48 +0800 Subject: [PATCH 2/3] fix(ui): address PR 247 review feedback --- docs/site-map.md | 3 +++ .../master-search-header.tsx | 21 ++++++++++++------- .../document-search-live-opener.tsx | 8 ------- src/components/forms/form-detail-page.tsx | 8 +++---- tests/ui-tools.spec.ts | 7 +++++++ 5 files changed, 27 insertions(+), 20 deletions(-) diff --git a/docs/site-map.md b/docs/site-map.md index ad1cd4c3c..90f807532 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -13,6 +13,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/forms` - Forms home and search surface. Source: `src/app/forms/page.tsx`. - `/medications` - Medication index redirect. Source: `src/app/medications/page.tsx`. - `/services` - Services home and search surface. Source: `src/app/services/page.tsx`. +- `/services-navigator-preview` - Route discovered from app directory Source: `src/app/services-navigator-preview/page.tsx`. ## Mode/query routes @@ -92,6 +93,8 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/settings-search-clinical` - Route discovered from app directory Source: `src/app/mockups/settings-search-clinical/page.tsx`. - `/mockups/settings-search-general` - Route discovered from app directory Source: `src/app/mockups/settings-search-general/page.tsx`. - `/mockups/settings-search-privacy` - Route discovered from app directory Source: `src/app/mockups/settings-search-privacy/page.tsx`. +- `/mockups/tools-action-workbench` - Route discovered from app directory Source: `src/app/mockups/tools-action-workbench/page.tsx`. +- `/mockups/tools-clinical-lanes` - Route discovered from app directory Source: `src/app/mockups/tools-clinical-lanes/page.tsx`. - `/mockups/tools-command-center` - Route discovered from app directory Source: `src/app/mockups/tools-command-center/page.tsx`. - `/mockups/tools-split-pane` - Route discovered from app directory Source: `src/app/mockups/tools-split-pane/page.tsx`. - `/mockups/tools-task-directory` - Route discovered from app directory Source: `src/app/mockups/tools-task-directory/page.tsx`. diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index cfe0091a8..5966135d2 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -898,10 +898,10 @@ export function MasterSearchHeader({ ); } - // "open-evidence" is the one footer-chip action that isn't already a mode-action - // id — every other chip dispatches through the existing runModeAction handler - // (the same dispatcher the "+" action menu already uses for these ids). - type FooterChipActionId = ModeActionId | "open-evidence"; + // A couple of footer chips are mode-local shortcuts rather than shared + // ModeActionIds. Keep them explicit so they cannot fall through to document + // library behavior on non-document pages. + type FooterChipActionId = ModeActionId | "open-evidence" | "forms-library"; type FooterActionChip = { icon: typeof Search; @@ -937,7 +937,7 @@ export function MasterSearchHeader({ icon: BadgeCheck, shortLabel: "Library", longLabel: "Form library", - actionId: "documents-collections", + actionId: "forms-library", ariaLabel: "Open the form library", }; case "services": @@ -985,9 +985,9 @@ export function MasterSearchHeader({ } } - // The second footer chip. Answer/Documents/Forms use the shared document-scope + // The second footer chip. Answer/Documents use the shared document-scope // trigger instead (see hasScopeFooterChip below) since scope is a real, existing - // concept for those three modes. Tools has no genuine second action yet, so it + // concept for those two modes. Tools has no genuine second action yet, so it // intentionally ships with a single chip rather than an invented one. function footerSecondaryChipFor(mode: AppModeId): FooterActionChip | null { switch (mode) { @@ -1033,6 +1033,11 @@ export function MasterSearchHeader({ onOpenEvidence?.(); return; } + if (actionId === "forms-library") { + onSearchModeChange("forms"); + onQueryChange(""); + return; + } runModeAction(actionId); } @@ -1050,7 +1055,7 @@ export function MasterSearchHeader({ const usesSendAffordance = usesAnswerFooterStyle; const usesModeIdentityAffordance = usesUniversalFooterStyle && !usesSendAffordance; const ModeIdentityIcon = appModeIcons[searchMode]; - const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents" || searchMode === "forms"; + const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents"; const trustFooterChip = footerTrustChipFor(searchMode); const secondaryFooterChip = footerSecondaryChipFor(searchMode); // Fallback icons here are never rendered — both are only used inside a JSX guard diff --git a/src/components/document-search-live-opener.tsx b/src/components/document-search-live-opener.tsx index 06534dc27..b580fad73 100644 --- a/src/components/document-search-live-opener.tsx +++ b/src/components/document-search-live-opener.tsx @@ -523,14 +523,6 @@ export function DocumentSearchLiveOpener() { setState({ status: "opening", message: "Checking browser document access." }); return; } - if (!authorizationHeader.authorization) { - setState({ - status: "mock", - message: - "The live document viewer needs a signed-in private document session. This mock preview shows the same handoff behavior without requiring auth.", - }); - return; - } try { setState({ status: "opening", message: "Finding a real indexed document." }); diff --git a/src/components/forms/form-detail-page.tsx b/src/components/forms/form-detail-page.tsx index daad9f0fb..d12ee6db0 100644 --- a/src/components/forms/form-detail-page.tsx +++ b/src/components/forms/form-detail-page.tsx @@ -644,6 +644,10 @@ export function FormDetailPage({ form }: { form: FormRecord }) { )} +
+ +
+

Priority facts @@ -655,10 +659,6 @@ export function FormDetailPage({ form }: { form: FormRecord }) {

-
- -
-
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index eeee5c89f..c9630429d 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -236,6 +236,13 @@ test.describe("Clinical KB applications launcher", () => { 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); + if (home.path === "/forms") { + await expect(page.getByRole("button", { name: "Open source scope" })).toHaveCount(0); + await page.getByRole("button", { name: "Open the form library" }).click(); + await expect(page).toHaveURL(/\/forms$/); + await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible(); + await expect(page.getByTestId("form-search-results")).toHaveCount(0); + } await expectNoPageHorizontalOverflow(page); } }); From 7e716a02b064751ea41d7f01ae216c445d9e90e1 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:08:27 +0800 Subject: [PATCH 3/3] fix(ui): omit differentials footer trust chip pending a real evidence view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Evidence-linked" trust chip on the universal mobile footer wired to differentials-evidence, whose only handler (onOpenEvidence) opens the answer-mode evidence drawer — leaving differentials context instead of reviewing differential evidence. No differential-specific evidence view exists yet, so omit the chip rather than route somewhere misleading; differentials still gets a working footer action via the "Criteria" secondary chip. Scoped to the new footer only — mode-action-popup.tsx's pre-existing "+" menu entry for differentials-evidence is untouched. Co-Authored-By: Claude Sonnet 5 --- .../clinical-dashboard/master-search-header.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 5966135d2..5fdccee15 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -956,14 +956,11 @@ export function MasterSearchHeader({ actionId: "favourites-browse", ariaLabel: "Browse trusted favourites", }; - case "differentials": - return { - icon: ListChecks, - shortLabel: "Evidence", - longLabel: "Evidence-linked", - actionId: "differentials-evidence", - ariaLabel: "Review cited differential evidence", - }; + // Differentials has no differential-specific evidence view yet — the only + // handler for "differentials-evidence" is the answer-mode evidence drawer, + // which would take the user out of differentials context. Omit the trust + // chip here until a differential-specific action exists; "Criteria" (the + // secondary chip below) still gives differentials a footer action. case "prescribing": return { icon: ShieldCheck,