From 57ed7376356c94bd5c3be27f6685bb457207894f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:15:44 +0800 Subject: [PATCH 01/16] fix(ci): clear post-merge annotations --- .github/workflows/ci.yml | 16 +- .github/workflows/secret-scan.yml | 2 +- src/components/ClinicalDashboard.tsx | 244 +-------- .../clinical-dashboard/answer-content.tsx | 39 +- .../clinical-dashboard/evidence-panels.tsx | 515 +----------------- 5 files changed, 18 insertions(+), 798 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef0db729b..2a987d0ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,12 +30,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version-file: ".nvmrc" cache: npm @@ -93,12 +93,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version-file: ".nvmrc" cache: npm @@ -108,7 +108,7 @@ jobs: run: npm ci - name: Restore Chromium browser cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }} @@ -138,12 +138,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version-file: ".nvmrc" cache: npm @@ -156,7 +156,7 @@ jobs: run: npm run build - name: Restore browser cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index 8d608f2a1..8148f80f2 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -24,7 +24,7 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 persist-credentials: false diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 5850b63be..8643c16db 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -62,9 +62,7 @@ import { answerSurface, chatMicroAction, clinicalDivider, - clinicalNotesRow, cn, - evidenceRow, EmptyState, fieldControlPlain, fieldControlWithIcon, @@ -95,7 +93,7 @@ import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboar import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { StatusBadge, StrengthBadge } from "@/components/clinical-dashboard/badges"; +import { StatusBadge } from "@/components/clinical-dashboard/badges"; import { type SidebarIdentity, deriveSidebarIdentity, @@ -149,7 +147,6 @@ import { primaryVisualTable, QuoteCards, SafetyFindingsPanel, - VerificationWorkspace, } from "@/components/clinical-dashboard/evidence-panels"; import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; @@ -175,12 +172,8 @@ const ApplicationsLauncherWorkspace = dynamic( () => import("@/components/applications-launcher-page").then((m) => m.ApplicationsLauncherWorkspace), { ssr: false }, ); -import { - DocumentSearchResultsPanel, - MatchExplanationChips, - type SearchFacets, -} from "@/components/clinical-dashboard/document-search-results"; -import { isWeakRelevance, QueryCoverageChips, RelevanceBadge } from "@/components/clinical-dashboard/relevance"; +import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results"; +import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; import { answerPayloadIsUsable, isRetryableError, @@ -240,7 +233,6 @@ import type { QuoteCard, RagAnswer, AnswerSection, - ConflictOrGap, RelatedDocument, EvidenceSummary, SearchResult, @@ -252,14 +244,7 @@ import type { } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; -import { - type AnswerEvidenceMapRow, - type AnswerViewMode, - buildAnswerEvidenceMap, - buildClinicalOutputSections, - buildHighYieldClinicalOutputSections, - shouldPollForUpdates, -} from "@/lib/ward-output"; +import { type AnswerEvidenceMapRow, type AnswerViewMode, shouldPollForUpdates } from "@/lib/ward-output"; const navigationHashes = ["#search", "#quotes", "#images", "#sources"] as const; const mobileSectionFabMediaQuery = "(max-width: 768px), ((max-width: 1023px) and (hover: none) and (pointer: coarse))"; @@ -478,72 +463,6 @@ function normalizeNavigationHash(hash: string) { return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search"; } -function WhyThisMatchedPanel({ sources }: { sources: SearchResult[] }) { - const visibleSources = sources.slice(0, 3); - if (visibleSources.length === 0) return null; - - return ( -
- - - - - - - Why this matched - - Match signals, source strength, and term coverage for top passages - - - - - -
- {visibleSources.map((source) => ( -
-
-
-

- {cleanDisplayTitle(source.title)} -

-

- page {source.page_number ?? "n/a"} ·{" "} - chunk {source.chunk_index} -

-
-
- - - -
-
- - {source.index_unit ? ( -

- - {source.index_unit.unit_type.replaceAll("_", " ")}: - {" "} - {source.index_unit.title} -

- ) : null} -
- -
-
- ))} -
-
- ); -} - function compactClinicalTableCaption(item: VisualEvidenceCard) { const raw = item.tableTitle || item.tableLabel || item.caption || "Clinical table"; const cleaned = sourceTextForCompactDisplay(raw) @@ -886,7 +805,6 @@ function MobileEvidenceSheetContent({ answer, sources, renderModel, - query, visualEvidence, answerEvidenceMapRows, sourceGovernanceWarnings, @@ -902,7 +820,6 @@ function MobileEvidenceSheetContent({ answer: RagAnswer; sources: SearchResult[]; renderModel: AnswerRenderModel; - query: string; visualEvidence: VisualEvidenceCard[]; answerEvidenceMapRows: AnswerEvidenceMapRow[]; sourceGovernanceWarnings: SourceGovernanceWarning[]; @@ -995,7 +912,6 @@ function MobileEvidenceSheetContent({ ; } -function UnifiedEvidenceDrawerContent({ - answer, - renderModel, - query, - visualEvidence, - answerEvidenceMapRows, - pendingFeedback, - copiedQuotes, - onCopyQuotes, - onSubmitFeedback, - onFollowUpQuote, - onScopeDocument, -}: { - answer: RagAnswer; - renderModel: AnswerRenderModel; - query: string; - visualEvidence: VisualEvidenceCard[]; - answerEvidenceMapRows: AnswerEvidenceMapRow[]; - pendingFeedback: AnswerFeedbackType | null; - copiedQuotes: boolean; - onCopyQuotes: () => void; - onSubmitFeedback: (feedbackType: AnswerFeedbackType) => void; - onFollowUpQuote?: (quote: QuoteCard) => void; - onScopeDocument: (documentId: string) => void; -}) { - const order = evidenceTabOrder(answer, renderModel); - - return ( -
- - -
- {order.map((item) => ( - - {item} - - ))} -
- - {order.map((section) => { - if (section === "Claims") { - return ( -
-

Claims

- -
- ); - } - - if (section === "Tables") { - return ( -
-

Tables

- {visualEvidence.some((item) => item.accessibleTableMarkdown || item.tableRows?.length) ? ( -
- {visualEvidence - .filter((item) => item.accessibleTableMarkdown || item.tableRows?.length) - .slice(0, 3) - .map((item) => ( -
-
-

- {compactClinicalTableCaption(item)} -

- p.{item.page_number ?? "n/a"} -
-
- - Expand - - - Source - -
-
- ))} -
- ) : ( - - )} -
- ); - } - - if (section === "Images") { - return ( -
-

Images

- - - -
- ); - } - - if (section === "Quotes") { - return ( -
- -
- ); - } - - return ( -
-

Gaps

- -
- ); - })} -
- ); -} - function RelatedDocumentsPanel({ documents, onScopeDocument, @@ -1322,20 +1104,15 @@ function StagedAnswerResultSurface({ query, safeAnswerText, bestSource, - currentRelevance, - queryMode, sourceGovernanceWarnings, sourceSummary, renderModel, weakEvidence, - groupedGovernanceWarningCount, answerViewMode, answerEvidenceMapRows, onScopeDocument, answerGrounded, sources, - gaps, - searchScope, demoMode, safeAnswerSections, safetyFindings, @@ -1348,20 +1125,15 @@ function StagedAnswerResultSurface({ query: string; safeAnswerText: string; bestSource: BestSourceRecommendation | null; - currentRelevance: EvidenceRelevance | null | undefined; - queryMode: ClinicalQueryMode; sourceGovernanceWarnings: SourceGovernanceWarning[]; sourceSummary?: EvidenceSummary; renderModel: AnswerRenderModel; weakEvidence: boolean; - groupedGovernanceWarningCount: number; answerViewMode: AnswerViewMode; answerEvidenceMapRows: AnswerEvidenceMapRow[]; onScopeDocument: (documentId: string) => void; answerGrounded: boolean; sources: SearchResult[]; - gaps: ConflictOrGap[]; - searchScope: SearchScopeSummary | null; demoMode: boolean; safeAnswerSections: Array; safetyFindings: ReturnType; @@ -1568,7 +1340,6 @@ function StagedAnswerResultSurface({ answer={answer} sources={sources} renderModel={renderModel} - query={query} visualEvidence={renderModel.visualEvidence} answerEvidenceMapRows={answerEvidenceMapRows} sourceGovernanceWarnings={sourceGovernanceWarnings} @@ -1664,7 +1435,6 @@ function StagedAnswerResultSurface({ answer={answer} sources={sources} renderModel={renderModel} - query={query} visualEvidence={renderModel.visualEvidence} answerEvidenceMapRows={answerEvidenceMapRows} sourceGovernanceWarnings={sourceGovernanceWarnings} @@ -5161,7 +4931,6 @@ export function ClinicalDashboard({ const safetyFindings = useMemo(() => extractSafetyFindings(answer), [answer]); const bestSource = answerRenderModel?.bestSource ?? null; const sourceSummary = answer?.evidenceSummary ?? answer?.smartPanel?.evidenceSummary; - const gaps = answer?.conflictsOrGaps ?? answer?.smartPanel?.conflictsOrGaps ?? []; const answerGrounded = answer?.grounded === true && answer.confidence !== "unsupported" && @@ -5685,20 +5454,15 @@ export function ClinicalDashboard({ query={query} safeAnswerText={safeAnswerText} bestSource={bestSource} - currentRelevance={currentRelevance} - queryMode={queryMode} sourceGovernanceWarnings={sourceGovernanceWarnings} sourceSummary={sourceSummary} renderModel={answerRenderModel} weakEvidence={weakEvidence} - groupedGovernanceWarningCount={groupedGovernanceWarningCount} answerViewMode={answerViewMode} answerEvidenceMapRows={answerEvidenceMapRows} onScopeDocument={scopeOnlyDocument} answerGrounded={answerGrounded} sources={answerRenderModel.reviewSources} - gaps={gaps} - searchScope={searchScope} demoMode={demoMode} safeAnswerSections={safeAnswerSections} safetyFindings={safetyFindings} diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 9140030b2..1af3a5306 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -751,47 +751,12 @@ export function keyClinicalItemsFromTable(item: VisualEvidenceCard | null): KeyC .map((row): KeyClinicalItem | null => { const [domain, baseline] = row.map((cell) => cell.trim()).filter(Boolean); if (!domain || !baseline) return null; - const detail = baseline; return { - id: comparableAnswerText([domain, detail].join(" ")), + id: comparableAnswerText([domain, baseline].join(" ")), label: domain, - detail, + detail: baseline, }; }) .filter((value): value is KeyClinicalItem => value !== null) .slice(0, 5); } - -function KeyClinicalItems({ - sections, - table, -}: { - sections: Array; - table: VisualEvidenceCard | null; -}) { - const sectionItems = keyClinicalItemsFromSections(sections); - const tableItems = keyClinicalItemsFromTable(table); - const items = sectionItems.length >= 2 ? sectionItems : tableItems; - if (items.length < 2) return null; - - return ( -
-

Key monitoring items

-
    - {items.map((item) => ( -
  • - {item.label ? ( - <> - {item.label} - - - - ) : ( - - )} -
  • - ))} -
-
- ); -} diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index 3f82128ac..1a37db4d6 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -22,13 +22,12 @@ import { Search, ShieldAlert, ShieldCheck, - SlidersHorizontal, Table2, Target, } from "lucide-react"; import { AccessibleTable } from "@/components/AccessibleTable"; -import { clinicalQueryModeOptions, type AnswerFeedbackType } from "@/components/ClinicalDashboard"; +import { type AnswerFeedbackType } from "@/components/ClinicalDashboard"; import { ClinicalOutputPanel } from "@/components/clinical-dashboard/output-panel"; import { keyClinicalItemsFromSections, @@ -44,13 +43,7 @@ import { compactSourceSnippet, comparableAnswerText, } from "@/components/clinical-dashboard/display-text"; -import { - hasStrongRelevanceIcon, - QueryCoverageChips, - relevanceChipLabel, - RelevanceBadge, -} from "@/components/clinical-dashboard/relevance"; -import { SourceActionRow, sourceResultHref } from "@/components/clinical-dashboard/source-actions"; +import { SourceActionRow } from "@/components/clinical-dashboard/source-actions"; import { chatMicroAction, clinicalDivider, @@ -58,16 +51,12 @@ import { codeText, EmptyState, evidenceSurface, - floatingControl, iconTilePremium, metadataPill, panelSubtle, - primaryControl, proseMeasure, raisedCard, sourceCard, - SourceProvenance, - SourceStatusBadge, subtleStatusPill, tableMicroActionRow, textMuted, @@ -80,8 +69,7 @@ import { import { type AnswerRenderModel, type SourceLink } from "@/lib/answer-render-policy"; import { documentCitationHref, formatCitationLabel, formatCompactCitationLabel } from "@/lib/citations"; import { extractSafetyFindings, formatSafetyFindingLabel } from "@/lib/clinical-safety"; -import { frontendSourceGovernanceWarnings, type SourceGovernanceWarning } from "@/lib/source-governance"; -import { normalizeSourceMetadata, sourceStatusLabel, validationStatusLabel } from "@/lib/source-metadata"; +import { normalizeSourceMetadata, sourceStatusLabel } from "@/lib/source-metadata"; import { normalizeExtractedGlyphs, sourceTextForCompactDisplay, @@ -90,9 +78,6 @@ import { import type { AnswerSection, BestSourceRecommendation, - ClinicalQueryMode, - ConflictOrGap, - EvidenceRelevance, EvidenceSummary, QuoteCard, RagAnswer, @@ -575,28 +560,6 @@ function clinicalNoteHasDistinctDetail(row: ClinicalNotesRow) { return Boolean(detail) && detail !== title; } -function clinicalNoteDetailLabel(row: ClinicalNotesRow) { - const text = `${row.title} ${row.detail}`.toLowerCase(); - if (/\b(timing|level|schedule|dose change|stable|days?)\b/.test(text)) return "Timing"; - if (/\b(escalation|escalate|urgent|toxicity|trigger|vomiting|confusion|ataxia|tremor)\b/.test(text)) { - return "Escalate"; - } - if (/\b(baseline|confirm|record|document|check|review)\b/.test(text)) return "Action"; - return "Note"; -} - -function ClinicalNoteDetailCard({ row }: { row: ClinicalNotesRow }) { - const detail = sentenceCaseClinicalNoteDetail(row.detail); - return ( -
-

- {clinicalNoteDetailLabel(row)}: - {detail} -

-
- ); -} - function clinicalNotesTableEvidenceCount(answer: RagAnswer) { return (answer.visualEvidence ?? answer.smartPanel?.visualEvidence ?? []).filter( (item) => item.accessibleTableMarkdown || item.tableRows?.length, @@ -928,292 +891,6 @@ export function SafetyFindingsPanel({ findings }: { findings: ReturnType - } - /> -
-
-

What was found

-

{found}

-
-
-

- What was not found -

-

{missing}

-
-
-

Closest sources

- {closestSources.length ? ( -
- {closestSources.map((source) => ( - - - {cleanDisplayTitle(source.title)} - - ))} -
- ) : ( -

No nearby indexed sources were returned.

- )} -
-
-

- Suggested next search/upload -

-

- Try a narrower query using the missing terms, scope to a likely document, or upload/index the guideline that - directly covers "{query.trim()}". -

-
-
- - ); -} - -function EvidenceCounts({ - answer, - sourceSummary, - sourceCount, -}: { - answer: RagAnswer; - sourceSummary?: EvidenceSummary; - sourceCount: number; -}) { - const counts = [ - { - label: "Citations", - value: answer.citations.length, - }, - { - label: "Quotes", - value: answer.quoteCards?.length ?? sourceSummary?.quote_count ?? 0, - }, - { - label: "Images", - value: - answer.visualEvidence?.length ?? answer.smartPanel?.visualEvidence?.length ?? sourceSummary?.image_count ?? 0, - }, - { - label: "Passages", - value: sourceCount || sourceSummary?.total_sources || 0, - }, - ]; - - return ( -
- {counts.map((item) => ( -
-

{item.value}

-

{item.label}

-
- ))} -
- ); -} - -function AnswerSourceStatus({ - source, - weakEvidence, -}: { - source: BestSourceRecommendation | null | undefined; - weakEvidence: boolean; -}) { - const metadata = source?.source_metadata; - return ( -
-
-

Source status

- -
- - {weakEvidence ? ( -

- Evidence support is limited. Treat this as a source-finding result until the linked passage is verified. -

- ) : null} -
- ); -} - -function EvidenceSummaryCard({ - answer, - bestSource, - grounded, - relevance, - sourceSummary, - weakEvidence, - sources, - gaps, - onScopeDocument, - compact = false, - supporting = false, -}: { - answer: RagAnswer; - bestSource: BestSourceRecommendation | null; - grounded: boolean; - relevance?: EvidenceRelevance | null; - sourceSummary?: EvidenceSummary; - weakEvidence: boolean; - sources: SearchResult[]; - gaps: ConflictOrGap[]; - onScopeDocument: (documentId: string) => void; - compact?: boolean; - supporting?: boolean; -}) { - const sourceLabel = relevance && !relevance.isSourceBacked ? "Closest source" : "Top source"; - const supportLabel = relevanceChipLabel(relevance, grounded); - const sourceStrength = bestSource?.source_strength ?? sourceSummary?.source_strength ?? "none"; - const gapMessage = - gaps[0]?.message ?? - (!relevance?.isSourceBacked && relevance?.supportReason - ? relevance.supportReason - : (sourceSummary?.summary ?? null)); - - return ( - - ); -} - export function compactEvidenceSummary( answer: RagAnswer, sources: SearchResult[], @@ -1315,192 +992,6 @@ export function primaryVisualTable(answer: RagAnswer) { return answer.visualEvidence?.find((item) => item.accessibleTableMarkdown || item.tableRows?.length) ?? null; } -type RenderModelPdfSource = { - document_id: string; - title: string; - file_name: string; - page_number: number | null; - chunk_id: string | null; -}; - -function uniquePdfSourcesForRenderModel(renderModel: AnswerRenderModel): RenderModelPdfSource[] { - return renderModel.primarySources.map((source) => ({ - document_id: source.document_id, - title: source.title, - file_name: source.file_name, - page_number: source.page_number, - chunk_id: source.chunk_id, - })); -} - -function queryModeLabel(mode: ClinicalQueryMode) { - return clinicalQueryModeOptions.find((option) => option.value === mode)?.label ?? mode.replaceAll("_", " "); -} - -function AnswerInsightBar({ - answer, - bestSource, - relevance, - queryMode, - sourceGovernanceWarnings, -}: { - answer: RagAnswer; - bestSource: BestSourceRecommendation | null; - relevance?: EvidenceRelevance | null; - queryMode: ClinicalQueryMode; - sourceGovernanceWarnings: SourceGovernanceWarning[]; -}) { - const frontendGovernanceWarnings = frontendSourceGovernanceWarnings(sourceGovernanceWarnings); - const metadata = normalizeSourceMetadata( - bestSource?.source_metadata ?? answer.sources?.[0]?.source_metadata ?? answer.citations?.[0]?.source_metadata, - ); - const modeLabel = - answer.smartApiPlan?.displayMode?.replaceAll("_", " ") ?? - answer.responseMode?.replaceAll("_", " ") ?? - queryModeLabel(queryMode); - const sourceCount = answer.evidenceSummary?.total_sources ?? answer.sources?.length ?? answer.citations.length; - const support = relevanceChipLabel(relevance ?? answer.relevance, answer.grounded); - const sourceStatus = frontendGovernanceWarnings.length - ? `${frontendGovernanceWarnings.length} source status note${frontendGovernanceWarnings.length === 1 ? "" : "s"}` - : sourceStatusLabel(metadata); - const retrievalGate = answer.retrievalDiagnostics?.gateStatus; - const items = [ - { label: "Mode", value: modeLabel, icon: SlidersHorizontal }, - { - label: "Support", - value: support, - icon: hasStrongRelevanceIcon(relevance ?? answer.relevance, answer.grounded) ? CheckCircle2 : AlertCircle, - }, - { label: "Sources", value: String(sourceCount), icon: FileText }, - { label: "Confidence", value: answer.confidence, icon: Target }, - { - label: "Retrieval", - value: retrievalGate ? `${retrievalGate} gate` : "Not logged", - icon: retrievalGate === "blocked" ? ShieldAlert : CheckCircle2, - }, - { label: "Status", value: `${sourceStatus} / ${validationStatusLabel(metadata)}`, icon: BookOpen }, - ]; - - return ( -
- {items.map((item) => { - const Icon = item.icon; - return ( - - - - {item.label} - - {item.value} - - ); - })} -
- ); -} - -function EvidenceVerificationStrip({ - answer, - bestSource, - sourceSummary, - weakEvidence, - governanceWarningCount, -}: { - answer: RagAnswer; - bestSource: BestSourceRecommendation | null; - sourceSummary?: EvidenceSummary | null; - weakEvidence: boolean; - governanceWarningCount: number; -}) { - const metadata = normalizeSourceMetadata( - bestSource?.source_metadata ?? answer.sources?.[0]?.source_metadata ?? answer.citations?.[0]?.source_metadata, - ); - const sourceCount = sourceSummary?.total_sources ?? answer.sources?.length ?? answer.citations.length; - const citationCount = answer.citations.length; - const gapCount = answer.conflictsOrGaps?.length ?? answer.smartPanel?.conflictsOrGaps?.length ?? 0; - const retrievalGateBlocked = answer.retrievalDiagnostics?.gateStatus === "blocked"; - const checks = [ - { - label: "Citations", - value: citationCount ? `${citationCount} citation${citationCount === 1 ? "" : "s"}` : "None", - ready: citationCount > 0, - }, - { - label: "Sources", - value: `${sourceCount} source${sourceCount === 1 ? "" : "s"}`, - ready: sourceCount > 0, - }, - { - label: "Source status", - value: sourceStatusLabel(metadata), - ready: metadata.document_status === "current" && !governanceWarningCount, - }, - { - label: "Retrieval gate", - value: retrievalGateBlocked ? "Blocked for low signal" : answer.retrievalDiagnostics ? "Passed" : "Not available", - ready: !retrievalGateBlocked, - }, - { - label: "Gaps", - value: governanceWarningCount - ? `${governanceWarningCount} status note${governanceWarningCount === 1 ? "" : "s"}` - : gapCount - ? `${gapCount} gap${gapCount === 1 ? "" : "s"}` - : "None", - ready: !weakEvidence && !gapCount && !governanceWarningCount, - }, - ]; - - return ( -
-
- {checks.map((check) => ( -
-
- {check.ready ? ( - - ) : ( - - )} -

- {check.label} -

-
-

{check.value}

-
- ))} -
-
- - Pinned source - - {bestSource ? ( - <> - {bestSource.title} - - - ) : ( - No pinned source yet - )} -
-
- ); -} - const answerFeedbackOptions: Array<{ type: AnswerFeedbackType; label: string; From 2d5e029f3ef9949952976372d87de35e68b202f2 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:13:36 +0800 Subject: [PATCH 02/16] Add generated sitemap audit --- docs/process-hardening.md | 6 + docs/site-map.md | 163 +++++++++++ package.json | 4 +- scripts/generate-site-map.ts | 350 ++++++++++++++++++++++++ src/app/mockups/favourites-hub/page.tsx | 2 +- tests/site-map.test.ts | 96 +++++++ 6 files changed, 619 insertions(+), 2 deletions(-) create mode 100644 docs/site-map.md create mode 100644 scripts/generate-site-map.ts create mode 100644 tests/site-map.test.ts diff --git a/docs/process-hardening.md b/docs/process-hardening.md index adc7b8eeb..58fdb831b 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -73,6 +73,12 @@ For each: trace which module-scope helpers/icons/types it uses; move solely-cons - `npm run check:indexing` includes local OCR prerequisites (`fitz`/PyMuPDF, `pytesseract`, and the Tesseract binary). A failure at that prerequisite step is local machine setup debt, not evidence that indexed production data or search behavior regressed. - Supabase performance-advisor `unused_index` INFO items are monitored, not automatically fixed. Do not remove search/RAG support indexes until live query evidence, local explain/verification, and rollback planning show the index is safe to drop. +## Route sitemap guard (2026-07-03) + +- Route, navigation, redirect, app-mode, registry-slug, and mockup-route changes must run `npm run sitemap:update` and `npm run sitemap:check` so `docs/site-map.md` stays aligned with `src/app`, `src/lib/app-modes.ts`, Services/Forms registry fixtures, Differentials, and medication detail routes. +- `npm run verify:cheap` now includes `npm run sitemap:check`; a stale sitemap is treated as process drift, not a documentation nicety. +- Keep `docs/site-map.md` as the human-readable route map for now. If it becomes too large for review, split into a concise `docs/site-map.md` summary plus a generated `docs/site-map.generated.md` inventory, and update `scripts/generate-site-map.ts` / `tests/site-map.test.ts` in the same change. + ## Retrieval RPC drift & indexing hygiene (2026-07-01) - The four app-path hybrid retrieval RPCs (`match_document_chunks_hybrid`, `match_document_embedding_fields_hybrid`, `match_document_index_units_hybrid`, `match_document_memory_cards_hybrid` + its `_v2` core) had live-only performance fixes applied via raw SQL that were never captured in migrations, so a `supabase db reset` / branch DB reproduced the slow pre-fix shapes. Migration `20260701140631_codify_live_retrieval_rpcs` codifies the live definitions (validated byte-equivalent to live via whitespace-stripped `pg_get_functiondef` md5 before applying — a confirmed no-op on live), and `supabase/schema.sql` was reconciled to match. A clean replay now reproduces production retrieval. diff --git a/docs/site-map.md b/docs/site-map.md new file mode 100644 index 000000000..ad1cd4c3c --- /dev/null +++ b/docs/site-map.md @@ -0,0 +1,163 @@ +# Clinical KB Site Map + +This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` to verify it is current. + +## Main product pages + +- `/` - Main Clinical KB shell. Source: `src/app/page.tsx`. +- `/applications` - Application and tool launcher. Source: `src/app/applications/page.tsx`. +- `/differentials` - Differentials home and search surface. Source: `src/app/differentials/page.tsx`. +- `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/differentials/diagnoses/page.tsx`. +- `/differentials/presentations` - Presentation workflow stream. Source: `src/app/differentials/presentations/page.tsx`. +- `/favourites` - Saved clinical items and sets. Source: `src/app/favourites/page.tsx`. +- `/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`. + +## Mode/query routes + +- `/?mode=answer` - Answer mode. Search kind: `answer`. Query example: `/?mode=answer&q=example+question&focus=1&run=1`. +- `/?mode=documents` - Documents mode. Search kind: `documents`. Query example: `/?mode=documents&q=lithium+monitoring&focus=1&run=1`. +- `/services` - Services mode. Search kind: `services`. Query example: `/services?q=13YARN&focus=1&run=1`. +- `/forms` - Forms mode. Search kind: `documents`. Query example: `/forms?q=transport+forms&focus=1&run=1`. +- `/favourites` - Favourites mode. Search kind: `favourites`. Query example: `/favourites?q=clozapine+set&focus=1&run=1`. +- `/differentials` - Differentials mode. Search kind: `differentials`. Query example: `/differentials?q=acute+confusion&focus=1&run=1`. +- `/?mode=prescribing` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. +- `/?mode=tools` - Tools mode. Search kind: `tools`. Query example: `/?mode=tools&q=medications&focus=1&run=1`. + +## Registry-backed routes + +- `/services/[slug]` - Registry-backed service detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records. +- `/forms/[slug]` - Registry-backed form detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records. +- `/api/registry/records?kind=service` - Service registry collection endpoint. +- `/api/registry/records?kind=form` - Form registry collection endpoint. +- `/api/registry/records/[slug]?kind=service|form` - Registry detail endpoint. + +## Dynamic slug inventories + +### Seeded service slugs + +- `/services/[slug]` - Dynamic route family. +- `13yarn` +- `head-to-health` +- `mental-health-emergency-response-line` +- `rurallink` +- `wachs-aboriginal-mental-health` + +### Seeded form slugs + +- `/forms/[slug]` - Dynamic route family. +- `detention-examination-movement` +- `extension-transport-order` +- `transfer-order` +- `transport-crisis-form` + +### Differential diagnosis slugs + +- `/differentials/diagnoses/[slug]` - Dynamic route family. +- `delirium` +- `hepatic-encephalopathy` +- `meningitis-encephalitis` +- `pneumonia` +- `post-ictal-confusion` +- `substance-intoxication` +- `substance-withdrawal` +- `thyroid-disease` +- `wernicke-encephalopathy` + +### Medication slugs + +- `/medications/[slug]` - Dynamic route family. +- `acamprosate` + +## Document viewer route + +- `/documents/[id]` - Document viewer/detail page. Individual document IDs are intentionally not enumerated in this sitemap. + +## Mockup/prototype routes + +- `/mockups/answer-evidence-popups` - Route discovered from app directory Source: `src/app/mockups/answer-evidence-popups/page.tsx`. +- `/mockups/document-search` - Route discovered from app directory Source: `src/app/mockups/document-search/page.tsx`. +- `/mockups/document-search-command` - Route discovered from app directory Source: `src/app/mockups/document-search-command/page.tsx`. +- `/mockups/document-search-evidence-lens` - Route discovered from app directory Source: `src/app/mockups/document-search-evidence-lens/page.tsx`. +- `/mockups/document-search-triage-board` - Route discovered from app directory Source: `src/app/mockups/document-search-triage-board/page.tsx`. +- `/mockups/document-search/source` - Route discovered from app directory Source: `src/app/mockups/document-search/source/page.tsx`. +- `/mockups/favourites-command-desk` - Route discovered from app directory Source: `src/app/mockups/favourites-command-desk/page.tsx`. +- `/mockups/favourites-hub` - Route discovered from app directory Source: `src/app/mockups/favourites-hub/page.tsx`. +- `/mockups/favourites-library-view` - Route discovered from app directory Source: `src/app/mockups/favourites-library-view/page.tsx`. +- `/mockups/favourites-set-board` - Route discovered from app directory Source: `src/app/mockups/favourites-set-board/page.tsx`. +- `/mockups/medication-prescribing` - Route discovered from app directory Source: `src/app/mockups/medication-prescribing/page.tsx`. +- `/mockups/mode-dropdown` - Route discovered from app directory Source: `src/app/mockups/mode-dropdown/page.tsx`. +- `/mockups/recent-searches-bottom` - Route discovered from app directory Source: `src/app/mockups/recent-searches-bottom/page.tsx`. +- `/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-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`. +- `/mockups/tools-workflow-board` - Route discovered from app directory Source: `src/app/mockups/tools-workflow-board/page.tsx`. + +### Non-routed mockup artifacts + +- `mockups/answer-evidence-popups/page.tsx` - Root-level mockup artifact outside `src/app`; not a Next route. +- `mockups/medication-prescribing/page.tsx` - Root-level mockup artifact outside `src/app`; not a Next route. + +## API routes + +- `/api/answer` - Generate answer response. Source: `src/app/api/answer/route.ts`. +- `/api/answer/stream` - Streaming answer response. Source: `src/app/api/answer/stream/route.ts`. +- `/api/documents` - Document collection operations. Source: `src/app/api/documents/route.ts`. +- `/api/documents/[id]` - Document detail operations. Source: `src/app/api/documents/[id]/route.ts`. +- `/api/documents/[id]/labels` - Document label operations. Source: `src/app/api/documents/[id]/labels/route.ts`. +- `/api/documents/[id]/reindex` - Single-document reindex operation. Source: `src/app/api/documents/[id]/reindex/route.ts`. +- `/api/documents/[id]/search` - Search within one document. Source: `src/app/api/documents/[id]/search/route.ts`. +- `/api/documents/[id]/signed-url` - Private document signed URL. Source: `src/app/api/documents/[id]/signed-url/route.ts`. +- `/api/documents/[id]/summarize` - Document summary operation. Source: `src/app/api/documents/[id]/summarize/route.ts`. +- `/api/documents/[id]/table-facts` - Document table facts. Source: `src/app/api/documents/[id]/table-facts/route.ts`. +- `/api/documents/bulk` - Bulk document operations. Source: `src/app/api/documents/bulk/route.ts`. +- `/api/documents/bulk/reindex` - Bulk reindex operation. Source: `src/app/api/documents/bulk/reindex/route.ts`. +- `/api/eval-cases` - Evaluation case data. Source: `src/app/api/eval-cases/route.ts`. +- `/api/health` - Health check. Source: `src/app/api/health/route.ts`. +- `/api/images/[id]/signed-url` - Private image signed URL. Source: `src/app/api/images/[id]/signed-url/route.ts`. +- `/api/ingestion/batches` - Ingestion batch state. Source: `src/app/api/ingestion/batches/route.ts`. +- `/api/ingestion/jobs` - Ingestion job collection. Source: `src/app/api/ingestion/jobs/route.ts`. +- `/api/ingestion/jobs/[id]/retry` - Retry ingestion job. Source: `src/app/api/ingestion/jobs/[id]/retry/route.ts`. +- `/api/ingestion/quality` - Ingestion quality reporting. Source: `src/app/api/ingestion/quality/route.ts`. +- `/api/jobs` - Job state. Source: `src/app/api/jobs/route.ts`. +- `/api/local-project-id` - Local project identity guard. Source: `src/app/api/local-project-id/route.ts`. +- `/api/registry/records` - Registry record collection. Source: `src/app/api/registry/records/route.ts`. +- `/api/registry/records/[slug]` - Registry record detail. Source: `src/app/api/registry/records/[slug]/route.ts`. +- `/api/search` - Search endpoint. Source: `src/app/api/search/route.ts`. +- `/api/search/interaction` - Search interaction telemetry. Source: `src/app/api/search/interaction/route.ts`. +- `/api/setup-status` - Setup status. Source: `src/app/api/setup-status/route.ts`. +- `/api/upload` - Upload endpoint. Source: `src/app/api/upload/route.ts`. + +## Redirects + +- `/medications` - Redirects to `/?mode=prescribing`. Source: `src/app/medications/page.tsx`. +- `/mockups/favourites-hub` - Redirects to `/favourites`. Source: `src/app/mockups/favourites-hub/page.tsx`. +- `/mockups/medication-prescribing` - Redirects to `/medications/acamprosate`. Source: `src/app/mockups/medication-prescribing/page.tsx`. + +## Known caveats and stale-path flags + +- No active stale internal route targets are expected in the current generated sitemap. +- `/mockups/favourites-hub` is a legacy compatibility route and should redirect to `/favourites`. +- Registry-backed service and form pages may show sign-in, load-error, or in-app not-found states for missing per-user records. +- Live user registries may contain additional service or form slugs beyond the seeded/demo slugs listed here. +- `/documents/[id]` is intentionally summarized as a route family; individual document IDs are private runtime data. +- Several differential records are placeholder scaffolds pending source-backed local clinical content. + +## Route ownership/source map + +| Area | Source | +| ------------------------------ | --------------------------------------------------------------------------------------------- | +| Root dashboard and query modes | `src/app/page.tsx, src/lib/app-modes.ts` | +| Global shell layouts | `src/app/*/layout.tsx, src/components/clinical-dashboard/global-search-shell.tsx` | +| Services | `src/app/services, src/lib/services.ts, src/app/api/registry/records` | +| Forms | `src/app/forms, src/lib/forms.ts, src/app/api/registry/records` | +| Favourites | `src/app/favourites, src/components/clinical-dashboard/favourites-home-page.tsx` | +| Differentials | `src/app/differentials, src/lib/differentials.ts` | +| Medications | `src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx` | +| Documents | `src/app/documents/[id], src/app/api/documents` | +| Applications and tools | `src/app/applications, src/components/applications-launcher-page.tsx` | +| Mockups | `src/app/mockups, mockups/` | diff --git a/package.json b/package.json index cbfb2f301..c3573704d 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,12 @@ "test:e2e:accessibility": "node scripts/run-playwright.mjs tests/ui-accessibility.spec.ts --project=chromium", "test:e2e:chromium": "node scripts/run-playwright.mjs --project=chromium", "test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts", - "verify:cheap": "npm run check:runtime && npm run lint && npm run typecheck && npm run test", + "verify:cheap": "npm run check:runtime && npm run sitemap:check && npm run lint && npm run typecheck && npm run test", "verify:ui": "npm run check:runtime && npm run test:e2e:chromium", "verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:production-readiness && npm run governance:release && npm run eval:quality:release", "ci:env-check": "node scripts/check-ci-env.mjs", + "sitemap:update": "tsx scripts/generate-site-map.ts", + "sitemap:check": "tsx scripts/generate-site-map.ts --check", "check:runtime": "tsx scripts/check-runtime.ts", "check:deployment-readiness": "node scripts/deployment-boot-smoke.mjs", "check:edge:functions": "node scripts/check-edge-functions.mjs", diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts new file mode 100644 index 000000000..6ffa12b24 --- /dev/null +++ b/scripts/generate-site-map.ts @@ -0,0 +1,350 @@ +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { format } from "prettier"; + +import { appModeDefinitions, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; +import { differentialRecords } from "@/lib/differentials"; +import { formRecords } from "@/lib/forms"; +import { serviceRecords } from "@/lib/services"; + +const appDir = path.join(process.cwd(), "src", "app"); +const siteMapPath = path.join(process.cwd(), "docs", "site-map.md"); +const medicationSlugs = ["acamprosate"] as const; + +type RouteKind = "page" | "api"; + +type DiscoveredRoute = { + route: string; + file: string; +}; + +type RedirectRoute = { + route: string; + file: string; + target: string; +}; + +type SiteMapData = { + pageRoutes: DiscoveredRoute[]; + apiRoutes: DiscoveredRoute[]; + redirects: RedirectRoute[]; + nonRoutedMockupArtifacts: string[]; +}; + +const routeDescriptions: Record = { + "/": "Main Clinical KB shell.", + "/applications": "Application and tool launcher.", + "/differentials": "Differentials home and search surface.", + "/differentials/diagnoses": "Diagnosis stream.", + "/differentials/diagnoses/[slug]": "Differential diagnosis detail.", + "/differentials/presentations": "Presentation workflow stream.", + "/documents/[id]": "Document viewer/detail page.", + "/favourites": "Saved clinical items and sets.", + "/forms": "Forms home and search surface.", + "/forms/[slug]": "Registry-backed form detail.", + "/medications": "Medication index redirect.", + "/medications/[slug]": "Medication detail.", + "/services": "Services home and search surface.", + "/services/[slug]": "Registry-backed service detail.", +}; + +const apiDescriptions: Record = { + "/api/answer": "Generate answer response.", + "/api/answer/stream": "Streaming answer response.", + "/api/documents": "Document collection operations.", + "/api/documents/[id]": "Document detail operations.", + "/api/documents/[id]/labels": "Document label operations.", + "/api/documents/[id]/reindex": "Single-document reindex operation.", + "/api/documents/[id]/search": "Search within one document.", + "/api/documents/[id]/signed-url": "Private document signed URL.", + "/api/documents/[id]/summarize": "Document summary operation.", + "/api/documents/[id]/table-facts": "Document table facts.", + "/api/documents/bulk": "Bulk document operations.", + "/api/documents/bulk/reindex": "Bulk reindex operation.", + "/api/eval-cases": "Evaluation case data.", + "/api/health": "Health check.", + "/api/images/[id]/signed-url": "Private image signed URL.", + "/api/ingestion/batches": "Ingestion batch state.", + "/api/ingestion/jobs": "Ingestion job collection.", + "/api/ingestion/jobs/[id]/retry": "Retry ingestion job.", + "/api/ingestion/quality": "Ingestion quality reporting.", + "/api/jobs": "Job state.", + "/api/local-project-id": "Local project identity guard.", + "/api/registry/records": "Registry record collection.", + "/api/registry/records/[slug]": "Registry record detail.", + "/api/search": "Search endpoint.", + "/api/search/interaction": "Search interaction telemetry.", + "/api/setup-status": "Setup status.", + "/api/upload": "Upload endpoint.", +}; + +const routeOwnershipRows = [ + ["Root dashboard and query modes", "src/app/page.tsx, src/lib/app-modes.ts"], + ["Global shell layouts", "src/app/*/layout.tsx, src/components/clinical-dashboard/global-search-shell.tsx"], + ["Services", "src/app/services, src/lib/services.ts, src/app/api/registry/records"], + ["Forms", "src/app/forms, src/lib/forms.ts, src/app/api/registry/records"], + ["Favourites", "src/app/favourites, src/components/clinical-dashboard/favourites-home-page.tsx"], + ["Differentials", "src/app/differentials, src/lib/differentials.ts"], + ["Medications", "src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx"], + ["Documents", "src/app/documents/[id], src/app/api/documents"], + ["Applications and tools", "src/app/applications, src/components/applications-launcher-page.tsx"], + ["Mockups", "src/app/mockups, mockups/"], +] as const; + +function toPosixPath(value: string) { + return value.split(path.sep).join("/"); +} + +function routeSegment(segment: string) { + if (segment.startsWith("(") && segment.endsWith(")")) return null; + if (segment.startsWith("@")) return null; + return segment; +} + +function fileToRoute(filePath: string, kind: RouteKind) { + const suffix = kind === "page" ? "page.tsx" : "route.ts"; + const relative = toPosixPath(path.relative(appDir, filePath)); + const withoutFile = relative.slice(0, -suffix.length).replace(/\/$/, ""); + const segments = withoutFile.split("/").filter(Boolean).map(routeSegment).filter(Boolean); + return segments.length ? `/${segments.join("/")}` : "/"; +} + +function collectFiles(root: string, targetFileName: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const fullPath = path.join(root, entry.name); + if (entry.isDirectory()) { + files.push(...collectFiles(fullPath, targetFileName)); + continue; + } + if (entry.isFile() && entry.name === targetFileName) files.push(fullPath); + } + return files; +} + +function discoverRoutes(kind: RouteKind): DiscoveredRoute[] { + const targetFile = kind === "page" ? "page.tsx" : "route.ts"; + return collectFiles(appDir, targetFile) + .map((file) => ({ + route: fileToRoute(file, kind), + file: toPosixPath(path.relative(process.cwd(), file)), + })) + .sort((left, right) => left.route.localeCompare(right.route) || left.file.localeCompare(right.file)); +} + +function discoverRedirects(pageRoutes: DiscoveredRoute[]): RedirectRoute[] { + return pageRoutes + .map((page) => { + const source = readFileSync(path.join(process.cwd(), page.file), "utf8"); + const target = source.match(/\bredirect\(\s*["']([^"']+)["']\s*\)/)?.[1]; + return target ? { ...page, target } : null; + }) + .filter((value): value is RedirectRoute => Boolean(value)) + .sort((left, right) => left.route.localeCompare(right.route)); +} + +function discoverNonRoutedMockupArtifacts() { + const mockupsDir = path.join(process.cwd(), "mockups"); + if (!existsSync(mockupsDir)) return []; + return collectFiles(mockupsDir, "page.tsx") + .map((file) => toPosixPath(path.relative(process.cwd(), file))) + .sort((left, right) => left.localeCompare(right)); +} + +export function collectSiteMapData(): SiteMapData { + const pageRoutes = discoverRoutes("page"); + return { + pageRoutes, + apiRoutes: discoverRoutes("api"), + redirects: discoverRedirects(pageRoutes), + nonRoutedMockupArtifacts: discoverNonRoutedMockupArtifacts(), + }; +} + +function bullet(route: string, description?: string) { + return `- \`${route}\`${description ? ` - ${description}` : ""}`; +} + +function routeLine(route: DiscoveredRoute, descriptionMap: Record) { + return bullet( + route.route, + `${descriptionMap[route.route] ?? "Route discovered from app directory"} Source: \`${route.file}\`.`, + ); +} + +function sortedSlugs(slugs: readonly string[]) { + return [...slugs].sort((left, right) => left.localeCompare(right)); +} + +function renderSlugInventory(title: string, routePattern: string, slugs: readonly string[]) { + return [ + `### ${title}`, + "", + bullet(routePattern, "Dynamic route family."), + ...sortedSlugs(slugs).map((slug) => `- \`${slug}\``), + ]; +} + +function renderModeRoutes() { + const examples: Record = { + answer: appModeHomeHref("answer", { query: "example question", focus: true, run: true }), + documents: appModeHomeHref("documents", { query: "lithium monitoring", focus: true, run: true }), + services: appModeHomeHref("services", { query: "13YARN", focus: true, run: true }), + forms: appModeHomeHref("forms", { query: "transport forms", focus: true, run: true }), + favourites: appModeHomeHref("favourites", { query: "clozapine set", focus: true, run: true }), + differentials: appModeHomeHref("differentials", { query: "acute confusion", focus: true, run: true }), + prescribing: appModeHomeHref("prescribing", { query: "acamprosate renal dose", focus: true, run: true }), + tools: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), + }; + + return appModeDefinitions.map((mode) => + bullet( + ("href" in mode ? mode.href : undefined) ?? appModeHomeHref(mode.id), + `${mode.label} mode. Search kind: \`${mode.search.kind}\`. Query example: \`${examples[mode.id]}\`.`, + ), + ); +} + +function section(title: string, lines: string[]) { + return [`## ${title}`, "", ...lines, ""]; +} + +function renderSiteMapRaw(data = collectSiteMapData()) { + const productRoutes = data.pageRoutes.filter( + (route) => + !route.route.startsWith("/api") && + !route.route.startsWith("/mockups") && + ![ + "/documents/[id]", + "/services/[slug]", + "/forms/[slug]", + "/differentials/diagnoses/[slug]", + "/medications/[slug]", + ].includes(route.route), + ); + const mockupRoutes = data.pageRoutes.filter((route) => route.route.startsWith("/mockups")); + + const lines = [ + "# Clinical KB Site Map", + "", + "This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` to verify it is current.", + "", + ...section( + "Main product pages", + productRoutes.map((route) => routeLine(route, routeDescriptions)), + ), + ...section("Mode/query routes", renderModeRoutes()), + ...section("Registry-backed routes", [ + bullet( + "/services/[slug]", + "Registry-backed service detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records.", + ), + bullet( + "/forms/[slug]", + "Registry-backed form detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records.", + ), + bullet("/api/registry/records?kind=service", "Service registry collection endpoint."), + bullet("/api/registry/records?kind=form", "Form registry collection endpoint."), + bullet("/api/registry/records/[slug]?kind=service|form", "Registry detail endpoint."), + ]), + ...section("Dynamic slug inventories", [ + ...renderSlugInventory( + "Seeded service slugs", + "/services/[slug]", + serviceRecords.map((record) => record.slug), + ), + "", + ...renderSlugInventory( + "Seeded form slugs", + "/forms/[slug]", + formRecords.map((record) => record.slug), + ), + "", + ...renderSlugInventory( + "Differential diagnosis slugs", + "/differentials/diagnoses/[slug]", + differentialRecords.map((record) => record.slug), + ), + "", + ...renderSlugInventory("Medication slugs", "/medications/[slug]", medicationSlugs), + ]), + ...section("Document viewer route", [ + bullet( + "/documents/[id]", + "Document viewer/detail page. Individual document IDs are intentionally not enumerated in this sitemap.", + ), + ]), + ...section("Mockup/prototype routes", [ + ...mockupRoutes.map((route) => routeLine(route, routeDescriptions)), + ...(data.nonRoutedMockupArtifacts.length + ? [ + "", + "### Non-routed mockup artifacts", + "", + ...data.nonRoutedMockupArtifacts.map((file) => + bullet(file, "Root-level mockup artifact outside `src/app`; not a Next route."), + ), + ] + : []), + ]), + ...section( + "API routes", + data.apiRoutes.map((route) => routeLine(route, apiDescriptions)), + ), + ...section( + "Redirects", + data.redirects.length + ? data.redirects.map((redirect) => + bullet(redirect.route, `Redirects to \`${redirect.target}\`. Source: \`${redirect.file}\`.`), + ) + : ["- No page-level redirects discovered."], + ), + ...section("Known caveats and stale-path flags", [ + "- No active stale internal route targets are expected in the current generated sitemap.", + "- `/mockups/favourites-hub` is a legacy compatibility route and should redirect to `/favourites`.", + "- Registry-backed service and form pages may show sign-in, load-error, or in-app not-found states for missing per-user records.", + "- Live user registries may contain additional service or form slugs beyond the seeded/demo slugs listed here.", + "- `/documents/[id]` is intentionally summarized as a route family; individual document IDs are private runtime data.", + "- Several differential records are placeholder scaffolds pending source-backed local clinical content.", + ]), + ...section("Route ownership/source map", [ + "| Area | Source |", + "| --- | --- |", + ...routeOwnershipRows.map(([area, source]) => `| ${area} | \`${source}\` |`), + ]), + ]; + + return `${lines + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim()}\n`; +} + +export async function renderSiteMap(data = collectSiteMapData()) { + return format(renderSiteMapRaw(data), { parser: "markdown", printWidth: 120 }); +} + +async function main() { + const expected = await renderSiteMap(); + const check = process.argv.includes("--check"); + + if (check) { + const current = existsSync(siteMapPath) ? readFileSync(siteMapPath, "utf8") : ""; + if (current !== expected) { + console.error("docs/site-map.md is stale. Run `npm run sitemap:update` and commit the result."); + process.exitCode = 1; + } + return; + } + + writeFileSync(siteMapPath, expected, "utf8"); + console.log(`Updated ${toPosixPath(path.relative(process.cwd(), siteMapPath))}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/src/app/mockups/favourites-hub/page.tsx b/src/app/mockups/favourites-hub/page.tsx index c115eb7d0..438f9733b 100644 --- a/src/app/mockups/favourites-hub/page.tsx +++ b/src/app/mockups/favourites-hub/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function FavouritesHubMockupRedirect() { - redirect("/?mode=documents"); + redirect("/favourites"); } diff --git a/tests/site-map.test.ts b/tests/site-map.test.ts new file mode 100644 index 000000000..5fafba6de --- /dev/null +++ b/tests/site-map.test.ts @@ -0,0 +1,96 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { appModeDefinitions, appModeHomeHref } from "@/lib/app-modes"; +import { tools } from "@/components/tools-page-mockups/tool-fixtures"; +import { differentialRecords } from "@/lib/differentials"; +import { formRecords } from "@/lib/forms"; +import { serviceRecords } from "@/lib/services"; +import { collectSiteMapData, renderSiteMap } from "../scripts/generate-site-map"; + +const siteMapPath = path.join(process.cwd(), "docs", "site-map.md"); +const siteMap = readFileSync(siteMapPath, "utf8"); + +const acceptedDynamicPatterns = [ + /^\/documents\/[^/?#]+(?:[?#].*)?$/, + /^\/services\/[^/?#]+(?:[?#].*)?$/, + /^\/forms\/[^/?#]+(?:[?#].*)?$/, + /^\/differentials\/diagnoses\/[^/?#]+(?:[?#].*)?$/, + /^\/medications\/[^/?#]+(?:[?#].*)?$/, +]; + +function pathOnly(href: string) { + return href.split(/[?#]/)[0] || "/"; +} + +function routePatternForHref(href: string) { + const pathname = pathOnly(href); + if (acceptedDynamicPatterns.some((pattern) => pattern.test(href))) { + if (pathname.startsWith("/documents/")) return "/documents/[id]"; + if (pathname.startsWith("/services/")) return "/services/[slug]"; + if (pathname.startsWith("/forms/")) return "/forms/[slug]"; + if (pathname.startsWith("/differentials/diagnoses/")) return "/differentials/diagnoses/[slug]"; + if (pathname.startsWith("/medications/")) return "/medications/[slug]"; + } + return pathname; +} + +function expectDocumentedRoute(route: string) { + expect(siteMap, `Expected ${route} to be documented in docs/site-map.md`).toContain(`\`${route}\``); +} + +function expectDocumentedHref(href: string) { + expectDocumentedRoute(routePatternForHref(href)); +} + +describe("tracked sitemap", () => { + it("matches the generated sitemap output", async () => { + expect(siteMap).toBe(await renderSiteMap()); + }); + + it("documents every app page route and API route", () => { + const data = collectSiteMapData(); + + for (const pageRoute of data.pageRoutes) expectDocumentedRoute(pageRoute.route); + for (const apiRoute of data.apiRoutes) expectDocumentedRoute(apiRoute.route); + }); + + it("documents seeded dynamic slugs", () => { + for (const service of serviceRecords) expectDocumentedRoute(service.slug); + for (const form of formRecords) expectDocumentedRoute(form.slug); + for (const record of differentialRecords) expectDocumentedRoute(record.slug); + expectDocumentedRoute("acamprosate"); + }); + + it("documents core navigation href targets", () => { + for (const mode of appModeDefinitions) { + expectDocumentedHref(("href" in mode ? mode.href : undefined) ?? appModeHomeHref(mode.id)); + } + + for (const tool of tools) expectDocumentedHref(tool.href); + + for (const href of [ + "/?mode=answer", + "/?mode=documents", + "/?mode=prescribing", + "/?mode=tools", + "/services", + "/forms", + "/favourites", + "/differentials", + "/medications/acamprosate", + "/differentials/diagnoses/delirium", + ]) { + expectDocumentedHref(href); + } + }); + + it("documents known intentional caveats and compatibility routes", () => { + expect(siteMap).toContain("No active stale internal route targets"); + expect(siteMap).toContain("legacy compatibility route"); + expect(siteMap).toContain("Root-level mockup artifact outside `src/app`"); + expect(siteMap).toContain("Live user registries may contain additional service or form slugs"); + expect(siteMap).toContain("individual document IDs are private runtime data"); + }); +}); From 2d16f83fd18da24db63ce640f2f0a0722757b805 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:36:23 +0800 Subject: [PATCH 03/16] feat(search): universal footer chips + mode-identity icons on mobile Every mode's small-screen floating search composer now shares Answer's chip-row/icon pattern instead of only Documents/Services/Favourites/etc getting a bare magnifier with no chips. Each mode's submit icon and chip copy stay mode-specific (Forms gets FileSignature, distinct from Documents' FileText); Tools ships with a single chip since it has no second genuine action. Larger screens are untouched for now. Co-Authored-By: Claude Sonnet 5 --- .../global-mockup-search-shell.tsx | 5 - .../master-search-header.tsx | 260 ++++++++++++++---- 2 files changed, 203 insertions(+), 62 deletions(-) diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index 30ce1c45c..31cc310b2 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -321,11 +321,6 @@ function GlobalMockupSearchShellClient({ (desktopSearchPlacement === "hero" || isFormsOnlyShell) && isStandaloneModeHome ? "hero" : "default" } searchComposerVisible={shouldShowSearchComposer} - workflowCopyText={ - isDifferentialPresentationWorkflow - ? "Acute confusion / encephalopathy differential comparison. Stabilise ABCs, check BGL, sats, attention test, collateral, and review medications/substances before handoff." - : undefined - } desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} heroComposerFromTablet={isStandaloneModeHome} /> diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 5d9077871..cfe0091a8 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -14,17 +14,20 @@ import { createPortal } from "react-dom"; import { Activity, + BadgeCheck, BrainCircuit, CalendarDays, Check, CheckCircle2, ChevronDown, - Cloud, - Copy, + FileSignature, FileText, Filter, + FolderOpen, + GitBranch, Globe2, Heart, + ListChecks, Loader2, Menu, MessageSquarePlus, @@ -34,7 +37,6 @@ import { Send, ShieldCheck, Sparkles, - AlertCircle, ArrowLeft, X, Lock, @@ -84,7 +86,7 @@ const appModeIcons: Record = { answer: Sparkles, documents: FileText, services: ShieldCheck, - forms: FileText, + forms: FileSignature, favourites: Heart, differentials: BrainCircuit, prescribing: Pill, @@ -199,7 +201,6 @@ export function MasterSearchHeader({ mobileSearchPlacement = "default", desktopSearchPlacement = "default", searchComposerVisible = true, - workflowCopyText, desktopHomeComposerSlotId, heroComposerFromTablet = false, mobileLeadingAction = "menu", @@ -237,7 +238,6 @@ export function MasterSearchHeader({ mobileSearchPlacement?: "default" | "bottom"; desktopSearchPlacement?: "default" | "hero"; searchComposerVisible?: boolean; - workflowCopyText?: string; desktopHomeComposerSlotId?: string; /** Portal the composer into the hero slot from the tablet breakpoint (sm) up, * rather than the default desktop (lg) breakpoint. */ @@ -898,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; @@ -1009,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 ? (
{isWorkflowHeader ? ( <> -
- - - Local only - - - - Offline ready - - - - Source pending review - -
- ) : null} {!isWorkflowHeader ? ( From 18f9ba6cea97d9c3acca74848b9156337241713d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:02:32 +0800 Subject: [PATCH 04/16] fix(search): scope popover reachable via + menu on desktop widths The document-scope popover was nested inside the footer chip row, which only renders on the small-screen floating composer. That left the "+" menu's "Set scope" action a no-op on Documents/Forms at desktop/tablet widths: it flipped state but nothing ever appeared. Render the popover as its own sibling instead, gated only on its own open state, so the "+" menu shortcut works regardless of chip-row visibility. Co-Authored-By: Claude Sonnet 5 --- .../master-search-header.tsx | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index cfe0091a8..cceef86cf 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1210,22 +1210,26 @@ export function MasterSearchHeader({ {secondaryFooterChip.longLabel} ) : null} - {hasScopeFooterChip && !usesScopeSheet && scopeOpen ? ( -
-
- Document scope - {scopeSummary} -
- {scopePreview ? ( -

{scopePreview}

- ) : null} - {renderScopeRows()} -
+
+ ) : null} + {/* Rendered as a sibling of the chip row (not nested inside it) so the "+" + menu's "Set scope" action still opens this popover on screens where the + chip row itself is hidden (documents/forms desktop widths) — the popover + still anchors correctly since the form stays position:fixed/sticky there. */} + {hasScopeFooterChip && !usesScopeSheet && scopeOpen ? ( +
+
+ Document scope + {scopeSummary} +
+ {scopePreview ? ( +

{scopePreview}

) : null} + {renderScopeRows()}
) : null} Date: Fri, 3 Jul 2026 19:42:29 +0800 Subject: [PATCH 05/16] fix(ui): prevent mode-home search composer overlap flash on services/forms at tablet+ The hero-placement composer briefly rendered as an absolute float over the hero heading before the portal lifted it into the hero slot. Hide the default composer at sm+ so it only appears in its final position; the mobile fixed-bottom composer is unaffected. Co-Authored-By: Claude Opus 4.8 --- src/components/clinical-dashboard/master-search-header.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index cceef86cf..ef0025b51 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1072,8 +1072,13 @@ export function MasterSearchHeader({ : usesMobileBottomStyle ? cn( "document-mobile-search-edge fixed z-40 mx-auto max-w-3xl sm:z-20 sm:w-full sm:px-4 sm:py-3 lg:max-w-4xl", + // Hero-placement mode-homes (services/forms) portal the composer into + // the hero from sm up. Hide the default (non-portaled) composer at sm+ + // so it never briefly flashes as an overlapping float over the hero + // before the portal activates; the mobile fixed-bottom slot still shows + // below sm. Other homes keep a sticky bar until the portal lifts it. isHeroDesktopComposer - ? "forms-hero-search-edge sm:absolute" + ? "sm:hidden" : "sm:sticky sm:top-[calc(4.75rem+env(safe-area-inset-top))]", ) : "sticky top-[calc(4.75rem+env(safe-area-inset-top))] z-20 mx-auto w-full max-w-3xl px-3 py-3 sm:px-4 lg:max-w-4xl", From 2630d5f8465e32c44ca6b9ad34a77a81592d5909 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:34:21 +0800 Subject: [PATCH 06/16] Refine database search and answer rendering flows --- docs/site-map.md | 22 + scripts/generate-site-map.ts | 69 + src/app/favourites/legacy/page.tsx | 18 + src/app/favourites/page.tsx | 19 +- src/app/globals.css | 422 +++- .../mockups/document-search-command/page.tsx | 4 +- src/app/mockups/document-search/page.tsx | 152 +- .../document-search/source-overlays/page.tsx | 12 + .../document-search/source/evidence/page.tsx | 17 + .../mockups/document-search/source/page.tsx | 8 +- .../favourites-command-console/page.tsx | 12 + .../favourites-review-console/page.tsx | 12 + .../mockups/favourites-set-navigator/page.tsx | 12 + src/app/mockups/mockups-layout-client.tsx | 19 +- .../tools-split-clinical-brief/page.tsx | 12 + .../tools-split-compact-sheet/page.tsx | 12 + .../mockups/tools-split-safety-deck/page.tsx | 12 + src/app/services/layout.tsx | 2 +- src/app/services/page.tsx | 29 +- src/components/ClinicalDashboard.tsx | 401 +++- ...age.backup-20260704-tools-redesign.tsx.bak | 1113 +++++++++ src/components/applications-launcher-page.tsx | 1605 +++++++------ .../clinical-dashboard/ClinicalSidebar.tsx | 17 +- .../account-setup-dialog.tsx | 338 +++ .../clinical-dashboard/answer-status.tsx | 5 +- .../clinical-dashboard/auth-panel.tsx | 195 +- .../clinical-dashboard/dashboard-shell.tsx | 24 +- .../clinical-dashboard/differentials-home.tsx | 9 +- .../document-search-results.tsx | 312 +-- .../favourites-command-library-page.tsx | 851 +++++++ .../clinical-dashboard/favourites-hub.tsx | 19 +- .../global-mockup-search-shell.tsx | 181 +- .../master-search-header.tsx | 539 +++-- .../medication-prescribing-workspace.tsx | 17 +- .../clinical-dashboard/mode-action-popup.tsx | 287 ++- .../use-sidebar-collapsed.ts | 7 +- .../favourites-library-redesign-page.tsx | 843 +++++++ .../master-document-flow-mockups.tsx | 1983 +++++++++++++++++ src/components/mode-home-template.tsx | 28 +- .../services/services-navigator-page.tsx | 515 +++++ .../source-overlay-redesign-mockups.tsx | 694 ++++++ .../split-pane-refined-mockups.tsx | 664 ++++++ .../tools-page-mockup-page.tsx | 313 ++- src/components/ui/sheet.tsx | 25 +- tests/ui-smoke.spec.ts | 133 +- tests/ui-tools.spec.ts | 168 +- 46 files changed, 10188 insertions(+), 1963 deletions(-) create mode 100644 src/app/favourites/legacy/page.tsx create mode 100644 src/app/mockups/document-search/source-overlays/page.tsx create mode 100644 src/app/mockups/document-search/source/evidence/page.tsx create mode 100644 src/app/mockups/favourites-command-console/page.tsx create mode 100644 src/app/mockups/favourites-review-console/page.tsx create mode 100644 src/app/mockups/favourites-set-navigator/page.tsx create mode 100644 src/app/mockups/tools-split-clinical-brief/page.tsx create mode 100644 src/app/mockups/tools-split-compact-sheet/page.tsx create mode 100644 src/app/mockups/tools-split-safety-deck/page.tsx create mode 100644 src/components/applications-launcher-page.backup-20260704-tools-redesign.tsx.bak create mode 100644 src/components/clinical-dashboard/account-setup-dialog.tsx create mode 100644 src/components/clinical-dashboard/favourites-command-library-page.tsx create mode 100644 src/components/favourites-page-mockups/favourites-library-redesign-page.tsx create mode 100644 src/components/master-document-flow-mockups.tsx create mode 100644 src/components/services/services-navigator-page.tsx create mode 100644 src/components/source-overlay-redesign-mockups.tsx create mode 100644 src/components/tools-page-mockups/split-pane-refined-mockups.tsx diff --git a/docs/site-map.md b/docs/site-map.md index ad1cd4c3c..4697d38a3 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -10,6 +10,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/differentials/diagnoses/page.tsx`. - `/differentials/presentations` - Presentation workflow stream. Source: `src/app/differentials/presentations/page.tsx`. - `/favourites` - Saved clinical items and sets. Source: `src/app/favourites/page.tsx`. +- `/favourites/legacy` - Route discovered from app directory Source: `src/app/favourites/legacy/page.tsx`. - `/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`. @@ -25,6 +26,19 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/?mode=prescribing` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. - `/?mode=tools` - Tools mode. Search kind: `tools`. Query example: `/?mode=tools&q=medications&focus=1&run=1`. +## Mode page index + +| Mode | Home page | Search/results page | Information/detail pages | +| ------------- | -------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Answer | `/?mode=answer` | `/?mode=answer&q=example+question&focus=1&run=1` | Answer, citations, evidence, and source panels render inside the root dashboard shell. | +| Documents | `/?mode=documents` | `/?mode=documents&q=lithium+monitoring&focus=1&run=1` | `/documents/[id]` document viewer and in-document search. | +| Services | `/services` | `/services?q=13YARN&focus=1&run=1` | `/services/[slug]` service record pages. | +| Forms | `/forms` | `/forms?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | +| Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | +| Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`. | +| Medication | `/?mode=prescribing` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; `/medications` redirects to medication mode. | +| Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | `/applications` launcher and tool detail panels inside tools mode. | + ## Registry-backed routes - `/services/[slug]` - Registry-backed service detail. Content depends on auth, demo mode, local no-auth mode, and per-user registry records. @@ -82,10 +96,15 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/document-search-evidence-lens` - Route discovered from app directory Source: `src/app/mockups/document-search-evidence-lens/page.tsx`. - `/mockups/document-search-triage-board` - Route discovered from app directory Source: `src/app/mockups/document-search-triage-board/page.tsx`. - `/mockups/document-search/source` - Route discovered from app directory Source: `src/app/mockups/document-search/source/page.tsx`. +- `/mockups/document-search/source-overlays` - Route discovered from app directory Source: `src/app/mockups/document-search/source-overlays/page.tsx`. +- `/mockups/document-search/source/evidence` - Route discovered from app directory Source: `src/app/mockups/document-search/source/evidence/page.tsx`. +- `/mockups/favourites-command-console` - Route discovered from app directory Source: `src/app/mockups/favourites-command-console/page.tsx`. - `/mockups/favourites-command-desk` - Route discovered from app directory Source: `src/app/mockups/favourites-command-desk/page.tsx`. - `/mockups/favourites-hub` - Route discovered from app directory Source: `src/app/mockups/favourites-hub/page.tsx`. - `/mockups/favourites-library-view` - Route discovered from app directory Source: `src/app/mockups/favourites-library-view/page.tsx`. +- `/mockups/favourites-review-console` - Route discovered from app directory Source: `src/app/mockups/favourites-review-console/page.tsx`. - `/mockups/favourites-set-board` - Route discovered from app directory Source: `src/app/mockups/favourites-set-board/page.tsx`. +- `/mockups/favourites-set-navigator` - Route discovered from app directory Source: `src/app/mockups/favourites-set-navigator/page.tsx`. - `/mockups/medication-prescribing` - Route discovered from app directory Source: `src/app/mockups/medication-prescribing/page.tsx`. - `/mockups/mode-dropdown` - Route discovered from app directory Source: `src/app/mockups/mode-dropdown/page.tsx`. - `/mockups/recent-searches-bottom` - Route discovered from app directory Source: `src/app/mockups/recent-searches-bottom/page.tsx`. @@ -93,7 +112,10 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/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-command-center` - Route discovered from app directory Source: `src/app/mockups/tools-command-center/page.tsx`. +- `/mockups/tools-split-clinical-brief` - Route discovered from app directory Source: `src/app/mockups/tools-split-clinical-brief/page.tsx`. +- `/mockups/tools-split-compact-sheet` - Route discovered from app directory Source: `src/app/mockups/tools-split-compact-sheet/page.tsx`. - `/mockups/tools-split-pane` - Route discovered from app directory Source: `src/app/mockups/tools-split-pane/page.tsx`. +- `/mockups/tools-split-safety-deck` - Route discovered from app directory Source: `src/app/mockups/tools-split-safety-deck/page.tsx`. - `/mockups/tools-task-directory` - Route discovered from app directory Source: `src/app/mockups/tools-task-directory/page.tsx`. - `/mockups/tools-workflow-board` - Route discovered from app directory Source: `src/app/mockups/tools-workflow-board/page.tsx`. diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 6ffa12b24..2c5afe126 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -206,6 +206,74 @@ function renderModeRoutes() { ); } +type ModePageIndexRow = { + mode: string; + home: string; + search: string; + detail: string; +}; + +function renderRouteTable(rows: ModePageIndexRow[]) { + return [ + "| Mode | Home page | Search/results page | Information/detail pages |", + "| --- | --- | --- | --- |", + ...rows.map((row) => `| ${row.mode} | \`${row.home}\` | \`${row.search}\` | ${row.detail} |`), + ]; +} + +function renderModePageIndex() { + return renderRouteTable([ + { + mode: "Answer", + home: appModeHomeHref("answer"), + search: appModeHomeHref("answer", { query: "example question", focus: true, run: true }), + detail: "Answer, citations, evidence, and source panels render inside the root dashboard shell.", + }, + { + mode: "Documents", + home: appModeHomeHref("documents"), + search: appModeHomeHref("documents", { query: "lithium monitoring", focus: true, run: true }), + detail: "`/documents/[id]` document viewer and in-document search.", + }, + { + mode: "Services", + home: appModeHomeHref("services"), + search: appModeHomeHref("services", { query: "13YARN", focus: true, run: true }), + detail: "`/services/[slug]` service record pages.", + }, + { + mode: "Forms", + home: appModeHomeHref("forms"), + search: appModeHomeHref("forms", { query: "transport forms", focus: true, run: true }), + detail: "`/forms/[slug]` form record pages.", + }, + { + mode: "Favourites", + home: appModeHomeHref("favourites"), + search: appModeHomeHref("favourites", { query: "clozapine set", focus: true, run: true }), + detail: "Saved set and saved item detail render inside the favourites page surface.", + }, + { + mode: "Differentials", + home: appModeHomeHref("differentials"), + search: appModeHomeHref("differentials", { query: "acute confusion", focus: true, run: true }), + detail: "`/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`.", + }, + { + mode: "Medication", + home: appModeHomeHref("prescribing"), + search: appModeHomeHref("prescribing", { query: "acamprosate renal dose", focus: true, run: true }), + detail: "`/medications/[slug]`; `/medications` redirects to medication mode.", + }, + { + mode: "Tools", + home: appModeHomeHref("tools"), + search: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), + detail: "`/applications` launcher and tool detail panels inside tools mode.", + }, + ]); +} + function section(title: string, lines: string[]) { return [`## ${title}`, "", ...lines, ""]; } @@ -235,6 +303,7 @@ function renderSiteMapRaw(data = collectSiteMapData()) { productRoutes.map((route) => routeLine(route, routeDescriptions)), ), ...section("Mode/query routes", renderModeRoutes()), + ...section("Mode page index", renderModePageIndex()), ...section("Registry-backed routes", [ bullet( "/services/[slug]", diff --git a/src/app/favourites/legacy/page.tsx b/src/app/favourites/legacy/page.tsx new file mode 100644 index 000000000..e0095f8b6 --- /dev/null +++ b/src/app/favourites/legacy/page.tsx @@ -0,0 +1,18 @@ +import { FavouritesHomePage } from "@/components/clinical-dashboard/favourites-home-page"; + +type LegacyFavouritesPageProps = { + searchParams?: Promise<{ + q?: string | string[]; + }>; +}; + +function firstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function LegacyFavouritesPage({ searchParams }: LegacyFavouritesPageProps) { + const params = searchParams ? await searchParams : {}; + const query = firstSearchParam(params.q)?.trim() ?? ""; + + return ; +} diff --git a/src/app/favourites/page.tsx b/src/app/favourites/page.tsx index 8dd8c0675..d8317ff84 100644 --- a/src/app/favourites/page.tsx +++ b/src/app/favourites/page.tsx @@ -1,18 +1,5 @@ -import { FavouritesHomePage } from "@/components/clinical-dashboard/favourites-home-page"; +import { FavouritesCommandLibraryPage } from "@/components/clinical-dashboard/favourites-command-library-page"; -type FavouritesPageProps = { - searchParams?: Promise<{ - q?: string | string[]; - }>; -}; - -function firstSearchParam(value: string | string[] | undefined) { - return Array.isArray(value) ? value[0] : value; -} - -export default async function FavouritesPage({ searchParams }: FavouritesPageProps) { - const params = searchParams ? await searchParams : {}; - const query = firstSearchParam(params.q)?.trim() ?? ""; - - return ; +export default function FavouritesPage() { + return ; } diff --git a/src/app/globals.css b/src/app/globals.css index 7fc0f9190..4e5cb2726 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -431,7 +431,7 @@ summary::-webkit-details-marker { * here so the whole class stays in one layer. * * The COMPOSER chrome further below (answer-footer-search-*, - * desktop-home-search-*, document-mobile-search-pill) stays INTENTIONALLY + * document-mobile-search-pill) stays INTENTIONALLY * UNLAYERED: its call sites stack utilities from shared ui-primitives * constants that rely on the class winning, and PR #171's frosted rework * left ~40 value conflicts there. Reconciling those is tracked in @@ -504,6 +504,21 @@ summary::-webkit-details-marker { } } +.mode-home-composer-slot { + width: min(100%, clamp(19rem, 90vw, 52rem)); + max-width: calc(100vw - 1rem - var(--safe-area-left) - var(--safe-area-right)); + margin-inline: auto; +} + +.universal-home-search-edge { + width: 100%; +} + +.universal-top-search-edge { + width: min(100%, clamp(19rem, 88vw, 52rem)); + max-width: calc(100vw - 1rem - var(--safe-area-left) - var(--safe-area-right)); +} + .floating-composer-edge { left: max(0.75rem, var(--safe-area-left)); right: max(0.75rem, var(--safe-area-right)); @@ -511,7 +526,7 @@ summary::-webkit-details-marker { } .answer-footer-search-edge { - bottom: max(1.45rem, calc(var(--safe-area-bottom) + 1rem)); + bottom: max(0.45rem, calc(var(--safe-area-bottom) + 0.35rem)); } .answer-footer-search-pill { @@ -532,6 +547,26 @@ summary::-webkit-details-marker { transform 180ms ease; } +.answer-footer-search-backdrop { + pointer-events: none; + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 0; + height: max(6.5rem, calc(var(--safe-area-bottom) + 5.25rem)); + background: linear-gradient( + 180deg, + transparent 0%, + color-mix(in srgb, var(--background) 10%, transparent) 26%, + color-mix(in srgb, var(--background) 32%, transparent) 62%, + color-mix(in srgb, var(--background) 46%, transparent) 100% + ); + backdrop-filter: blur(14px) saturate(130%); + -webkit-backdrop-filter: blur(14px) saturate(130%); + mask-image: linear-gradient(180deg, transparent 0%, black 32%, black 100%); + -webkit-mask-image: linear-gradient(180deg, transparent 0%, black 32%, black 100%); +} + .answer-footer-search-pill:hover { border-color: var(--border-strong); box-shadow: @@ -548,6 +583,297 @@ summary::-webkit-details-marker { 0 20px 48px rgb(16 24 40 / 12%); } +.answer-footer-search-pill-open { + border-color: color-mix(in srgb, var(--clinical-accent) 46%, var(--border-strong)); + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--clinical-accent) 10%, transparent), + 0 8px 20px rgb(16 24 40 / 8%), + 0 22px 52px rgb(16 24 40 / 14%); +} + +.answer-footer-search-pill-open[data-menu-placement="up"] { + border-top-left-radius: 1rem; + border-top-right-radius: 1rem; +} + +.answer-footer-search-pill-open[data-menu-placement="down"] { + border-bottom-left-radius: 1rem; + border-bottom-right-radius: 1rem; +} + +.mode-action-surface { + --mode-action-max-height: min(72dvh, 34rem); + --mode-action-body-max-height: min(54dvh, 24rem); + transform-origin: center bottom; +} + +.mode-action-surface[data-placement="down"] { + transform-origin: center top; +} + +.mode-action-panel { + max-height: var(--mode-action-max-height); + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); +} + +.mode-action-surface[data-placement="up"] .mode-action-panel { + border-bottom-left-radius: 1rem; + border-bottom-right-radius: 1rem; +} + +.mode-action-surface[data-placement="down"] .mode-action-panel { + border-top-left-radius: 1rem; + border-top-right-radius: 1rem; +} + +.mode-action-header { + position: relative; + z-index: 1; + display: grid; + min-height: 4.9rem; + grid-template-areas: "selector summary close"; + grid-template-columns: minmax(13.5rem, 0.42fr) minmax(0, 1fr) 3.25rem; + align-items: center; + gap: 0.75rem; + padding: 0.72rem 0.78rem; + color: #fff; + background: + radial-gradient(circle at 18% 0%, rgb(255 255 255 / 18%), transparent 34%), + linear-gradient( + 135deg, + color-mix(in srgb, var(--clinical-accent) 88%, #073d4a 12%) 0%, + color-mix(in srgb, var(--primary-700) 86%, #ffffff 14%) 100% + ); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 18%), + inset 0 -1px 0 rgb(255 255 255 / 10%); +} + +.mode-action-selector-shell { + position: relative; + grid-area: selector; + min-width: 0; +} + +.mode-action-mode-button { + display: grid; + min-height: 3.15rem; + width: 100%; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + border: 1px solid rgb(255 255 255 / 30%); + border-radius: 999px; + background: rgb(255 255 255 / 10%); + padding: 0.42rem 0.72rem; + color: #fff; + font-size: 1rem; + font-weight: 850; + line-height: 1; + text-align: left; + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 18%), + 0 8px 22px rgb(0 35 44 / 14%); + transition: + background-color 160ms ease, + border-color 160ms ease; +} + +.mode-action-mode-button:hover { + border-color: rgb(255 255 255 / 42%); + background: rgb(255 255 255 / 14%); +} + +.mode-action-mode-button:disabled { + cursor: default; +} + +.mode-action-mode-button:focus-visible, +.mode-action-close:focus-visible, +.mode-action-mode-option:focus-visible { + outline: 2px solid color-mix(in srgb, #fff 70%, var(--clinical-accent)); + outline-offset: 2px; +} + +.mode-action-mode-icon { + display: grid; + height: 2.1rem; + width: 2.1rem; + flex: 0 0 auto; + place-items: center; + border: 1px solid rgb(255 255 255 / 32%); + border-radius: 0.5rem; + background: rgb(255 255 255 / 12%); + color: #fff; +} + +.mode-action-header-summary { + grid-area: summary; + display: flex; + min-width: 0; + align-items: center; + gap: 1rem; + color: rgb(255 255 255 / 92%); + font-size: 0.98rem; + font-weight: 720; + line-height: 1.2; +} + +.mode-action-header-divider { + display: block; + height: 2.15rem; + width: 1px; + flex: 0 0 auto; + background: rgb(255 255 255 / 28%); +} + +.mode-action-close { + grid-area: close; + display: grid; + height: 2.7rem; + width: 2.7rem; + place-items: center; + justify-self: center; + border: 1px solid rgb(255 255 255 / 36%); + border-radius: 999px; + background: rgb(255 255 255 / 10%); + color: #fff; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 16%); + transition: + background-color 160ms ease, + border-color 160ms ease; +} + +.mode-action-close:hover { + border-color: rgb(255 255 255 / 48%); + background: rgb(255 255 255 / 16%); +} + +.mode-action-body { + max-height: var(--mode-action-body-max-height); + overflow-y: auto; + overscroll-behavior: contain; +} + +.mode-action-mode-menu { + position: absolute; + top: calc(100% + 0.45rem); + left: 0; + z-index: 20; + width: min(22rem, calc(100vw - 2rem)); + max-height: min(19rem, var(--mode-action-body-max-height)); + overflow-y: auto; + border: 1px solid var(--border-lux); + border-radius: 0.75rem; + background: color-mix(in srgb, var(--surface-lux) 96%, transparent); + padding: 0.4rem; + color: var(--text); + box-shadow: 0 18px 48px rgb(15 37 48 / 18%); + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); +} + +.mode-action-mode-option { + display: grid; + min-height: 3.2rem; + width: 100%; + grid-template-columns: 2.1rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.65rem; + border: 1px solid transparent; + border-radius: 0.5rem; + padding: 0.45rem 0.55rem; + text-align: left; + color: var(--text-muted); + transition: + background-color 150ms ease, + border-color 150ms ease, + color 150ms ease; +} + +.mode-action-mode-option:hover { + border-color: var(--border); + background: var(--surface-subtle); + color: var(--text); +} + +.mode-action-mode-option-active { + border-color: var(--clinical-accent-border); + background: var(--clinical-accent-soft); + color: var(--text-heading); +} + +.mode-action-mode-option-icon { + display: grid; + height: 2rem; + width: 2rem; + place-items: center; + border: 1px solid var(--clinical-accent-border); + border-radius: 0.5rem; + background: color-mix(in srgb, var(--clinical-accent-soft) 72%, var(--surface-lux)); + color: var(--clinical-accent); +} + +@media (max-width: 430px) { + .mode-action-header { + min-height: 5.7rem; + grid-template-areas: + "selector close" + "summary summary"; + grid-template-columns: minmax(0, 1fr) 2.85rem; + gap: 0.5rem 0.65rem; + padding: 0.62rem; + } + + .mode-action-mode-button { + min-height: 2.85rem; + gap: 0.55rem; + padding-inline: 0.58rem; + font-size: 0.95rem; + } + + .mode-action-mode-icon { + height: 1.95rem; + width: 1.95rem; + } + + .mode-action-header-summary { + gap: 0; + padding-inline: 0.18rem; + font-size: 0.83rem; + white-space: normal; + } + + .mode-action-header-summary .truncate { + white-space: normal; + } + + .mode-action-header-divider { + display: none; + } + + .mode-action-close { + height: 2.55rem; + width: 2.55rem; + } + + .mode-action-mode-menu { + width: min(20rem, calc(100vw - 1.5rem)); + } +} + +@media (prefers-reduced-motion: no-preference) { + .mode-action-surface[data-placement="up"] { + animation: mode-action-fold-up 170ms cubic-bezier(0.22, 1, 0.36, 1) both; + } + + .mode-action-surface[data-placement="down"] { + animation: mode-action-fold-down 170ms cubic-bezier(0.22, 1, 0.36, 1) both; + } +} + .answer-footer-search-action { height: 2.75rem; width: 2.75rem; @@ -647,7 +973,7 @@ summary::-webkit-details-marker { .dashboard-composer-edge.answer-footer-search-edge { left: 50%; right: auto; - width: min(calc(100vw - 16px - var(--safe-area-left) - var(--safe-area-right)), 400px); + width: min(calc(100vw - 8px - var(--safe-area-left) - var(--safe-area-right)), 400px); transform: translateX(-50%); } @@ -661,51 +987,23 @@ summary::-webkit-details-marker { bottom: max(1.45rem, calc(var(--safe-area-bottom) + 1rem)); } -.desktop-home-search-pill { - min-height: 3.45rem; - gap: 0.375rem; - border-color: color-mix(in srgb, var(--border-strong) 80%, transparent); - background: color-mix(in srgb, var(--surface) 96%, transparent); - padding-inline: 0.5rem; - box-shadow: - 0 1px 2px rgb(16 24 40 / 4%), - 0 6px 16px rgb(16 24 40 / 6%), - 0 18px 40px rgb(16 24 40 / 9%); - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); - transition: - border-color 180ms ease, - box-shadow 180ms ease; -} - -.desktop-home-search-pill:hover { - border-color: var(--border-strong); - box-shadow: - 0 1px 2px rgb(16 24 40 / 5%), - 0 8px 20px rgb(16 24 40 / 8%), - 0 22px 48px rgb(16 24 40 / 11%); -} - -.desktop-home-search-pill:focus-within { - border-color: var(--clinical-accent); - box-shadow: - 0 0 0 3px color-mix(in srgb, var(--clinical-accent) 16%, transparent), - 0 8px 20px rgb(16 24 40 / 8%), - 0 18px 40px rgb(16 24 40 / 9%); -} +@media (min-width: 640px) { + .mode-home-composer-slot { + width: min(100%, clamp(28rem, 74vw, 54rem)); + max-width: calc(100vw - 3rem - var(--safe-area-left) - var(--safe-area-right)); + } -.desktop-home-search-input { - font-size: 1rem; - font-weight: 560; -} + .universal-top-search-edge { + width: min(100%, clamp(28rem, 74vw, 54rem)); + max-width: calc(100vw - 3rem - var(--safe-area-left) - var(--safe-area-right)); + } -@media (min-width: 640px) { .floating-composer-edge { bottom: max(1rem, var(--safe-area-bottom)); } .answer-footer-search-edge { - bottom: max(1.25rem, calc(var(--safe-area-bottom) + 0.75rem)); + bottom: max(0.75rem, calc(var(--safe-area-bottom) + 0.5rem)); } .dashboard-composer-edge.answer-footer-search-edge { @@ -754,6 +1052,10 @@ summary::-webkit-details-marker { } @media (max-width: 639px) { + .dashboard-composer-edge.answer-footer-search-edge { + width: min(calc(100vw - 8px - var(--safe-area-left) - var(--safe-area-right)), 400px); + } + .document-mobile-search-edge { left: max(0.375rem, var(--safe-area-left)); right: max(0.375rem, var(--safe-area-right)); @@ -763,8 +1065,8 @@ summary::-webkit-details-marker { .document-mobile-search-edge.answer-footer-search-edge { left: 50%; right: auto; - bottom: max(0.5rem, calc(var(--safe-area-bottom) + 0.375rem)); - width: min(calc(100vw - 16px - var(--safe-area-left) - var(--safe-area-right)), 400px); + bottom: max(0.45rem, calc(var(--safe-area-bottom) + 0.35rem)); + width: min(calc(100vw - 8px - var(--safe-area-left) - var(--safe-area-right)), 400px); transform: translateX(-50%); } @@ -780,6 +1082,14 @@ summary::-webkit-details-marker { } @media (min-width: 1024px) { + .mode-home-composer-slot { + width: min(100%, clamp(36rem, 56vw, 56rem)); + } + + .universal-top-search-edge { + width: min(100%, clamp(36rem, 56vw, 56rem)); + } + .dashboard-composer-edge { left: calc(var(--clinical-sidebar-width, 20rem) + 2rem); right: max(2rem, var(--safe-area-right)); @@ -871,6 +1181,28 @@ summary::-webkit-details-marker { } } +@keyframes mode-action-fold-up { + from { + opacity: 0; + transform: translateY(8px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes mode-action-fold-down { + from { + opacity: 0; + transform: translateY(-8px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + @keyframes shimmer { from { background-position: -150% 0; @@ -884,8 +1216,8 @@ summary::-webkit-details-marker { * Helper classes with no same-property utility conflicts at any call site * live in @layer components so Tailwind utilities can override them. * Audited 2026-07-02: the chrome classes above (edge-glass-header, - * universal-header-*, answer-footer-search-*, *-composer-edge, - * desktop-home-search-*) DO conflict with call-site utilities and stay + * universal-header-*, answer-footer-search-*, *-composer-edge) DO + * conflict with call-site utilities and stay * unlayered deliberately — layering them changes rendered pixels. When * adding a utility to an element carrying one of those classes, check the * class body first; the class wins. diff --git a/src/app/mockups/document-search-command/page.tsx b/src/app/mockups/document-search-command/page.tsx index 1d6af0207..46503a4e8 100644 --- a/src/app/mockups/document-search-command/page.tsx +++ b/src/app/mockups/document-search-command/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; -import { DocumentSearchMockupPage } from "@/components/document-search-mockups"; +import { MasterDocumentSearch } from "@/components/master-document-flow-mockups"; export const metadata: Metadata = { title: "Document Search Command Mockup - Clinical KB", @@ -8,5 +8,5 @@ export const metadata: Metadata = { }; export default function DocumentSearchCommandMockupRoute() { - return ; + return ; } diff --git a/src/app/mockups/document-search/page.tsx b/src/app/mockups/document-search/page.tsx index 24e9f9d2b..ccc03a193 100644 --- a/src/app/mockups/document-search/page.tsx +++ b/src/app/mockups/document-search/page.tsx @@ -1,157 +1,17 @@ -import Image from "next/image"; -import Link from "next/link"; import type { Metadata } from "next"; -import { ArrowRight, FileText, Search, ShieldCheck, Sparkles } from "lucide-react"; +import { Suspense } from "react"; -import { cn } from "@/components/ui-primitives"; +import { MasterDocumentIndex } from "@/components/master-document-flow-mockups"; export const metadata: Metadata = { title: "Document Search Mockups - Clinical KB", - description: "Three runnable document-search UX concepts for Clinical KB document mode.", + description: "Master runnable document-search UX flow for Clinical KB document mode.", }; -const concepts = [ - { - href: "/mockups/document-search-command?mode=documents", - eyebrow: "Production candidate", - title: "Command center", - body: "Compact search, sort, result rows, and an active source preview for fast document lookup.", - image: "/mockups/document-search/source-stack.png", - alt: "Synthetic layered document stack with highlighted abstract source regions.", - icon: Search, - priorities: ["Fast scan", "Sort clarity", "Pinned preview"], - }, - { - href: "/mockups/document-search-evidence-lens?mode=documents", - eyebrow: "Evidence lens", - title: "Source proof in view", - body: "A split workbench that keeps the selected page, table, image, and ranking explanation together.", - image: "/mockups/document-search/evidence-preview.png", - alt: "Synthetic source page connected to abstract table, image, and warning evidence panels.", - icon: ShieldCheck, - priorities: ["Preview first", "Why this result", "Exact evidence"], - }, - { - href: "/mockups/document-search-triage-board?mode=documents", - eyebrow: "Discovery board", - title: "Library triage", - body: "A document-mode home for recent sources, source health, smart facets, and status lanes.", - image: "/mockups/document-search/triage-map.png", - alt: "Synthetic document triage board with abstract grouped source cards and status lanes.", - icon: Sparkles, - priorities: ["Recent work", "Source health", "Facet discovery"], - }, -] as const; - -const focusRing = - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; - -function Pill({ children, active = false }: { children: string; active?: boolean }) { - return ( - - {children} - - ); -} - export default function DocumentSearchMockupsIndexRoute() { return ( -
-
-
-
-
-
- - -

- Document mode UX -

-
-

- Three runnable document search directions -

-

- This page is the review board. Each direction below opens as its own full runnable mockup inside the - shared Clinical KB header and document-mode bottom composer. -

-
-
-

- Open a direction -

-
- Command - Evidence lens - Triage board -
-
-
-
- -
- {concepts.map((concept, index) => { - const Icon = concept.icon; - return ( - -
- {concept.alt} -
-
-
-
- - -

- {concept.eyebrow} -

-
-

- {concept.title} -

-

{concept.body}

-
- {concept.priorities.map((priority, priorityIndex) => ( - - {priority} - - ))} -
-
- - Open full mockup - - -
- - ); - })} -
-
-
+ + + ); } diff --git a/src/app/mockups/document-search/source-overlays/page.tsx b/src/app/mockups/document-search/source-overlays/page.tsx new file mode 100644 index 000000000..c414d5f96 --- /dev/null +++ b/src/app/mockups/document-search/source-overlays/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { SourceOverlayRedesignMockups } from "@/components/source-overlay-redesign-mockups"; + +export const metadata: Metadata = { + title: "Source Overlay Redesign Mockups - Clinical KB", + description: "Document scope and source library overlay redesign mockups for desktop and phone.", +}; + +export default function SourceOverlayRedesignMockupsRoute() { + return ; +} diff --git a/src/app/mockups/document-search/source/evidence/page.tsx b/src/app/mockups/document-search/source/evidence/page.tsx new file mode 100644 index 000000000..6d291518d --- /dev/null +++ b/src/app/mockups/document-search/source/evidence/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { MasterEvidenceDetail } from "@/components/master-document-flow-mockups"; + +export const metadata: Metadata = { + title: "Evidence Detail Mockup - Clinical KB", + description: "Functional evidence object mockup for tables, quotes, images, and source page context.", +}; + +export default function DocumentSearchEvidenceDetailRoute() { + return ( + + + + ); +} diff --git a/src/app/mockups/document-search/source/page.tsx b/src/app/mockups/document-search/source/page.tsx index 3bfaaa123..5143ab6b1 100644 --- a/src/app/mockups/document-search/source/page.tsx +++ b/src/app/mockups/document-search/source/page.tsx @@ -1,17 +1,17 @@ import type { Metadata } from "next"; import { Suspense } from "react"; -import { DocumentSearchLiveOpener } from "@/components/document-search-live-opener"; +import { MasterDocumentReader } from "@/components/master-document-flow-mockups"; export const metadata: Metadata = { - title: "Open Highlighted Document - Clinical KB", - description: "Resolves a document-search mockup result to the live document viewer with a selected source chunk.", + title: "Document Reader Mockup - Clinical KB", + description: "Functional document reader mockup with bundled PDF content, highlights, and evidence inspector.", }; export default function HighlightedDocumentSearchSourceRoute() { return ( - + ); } diff --git a/src/app/mockups/favourites-command-console/page.tsx b/src/app/mockups/favourites-command-console/page.tsx new file mode 100644 index 000000000..bd324500d --- /dev/null +++ b/src/app/mockups/favourites-command-console/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { FavouritesLibraryRedesignPage } from "@/components/favourites-page-mockups/favourites-library-redesign-page"; + +export const metadata: Metadata = { + title: "Favourites Command Console Mockup - Clinical KB", + description: "Library-first favourites mockup with resume-next command workflow.", +}; + +export default function FavouritesCommandConsoleMockupRoute() { + return ; +} diff --git a/src/app/mockups/favourites-review-console/page.tsx b/src/app/mockups/favourites-review-console/page.tsx new file mode 100644 index 000000000..a0aa0b166 --- /dev/null +++ b/src/app/mockups/favourites-review-console/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { FavouritesLibraryRedesignPage } from "@/components/favourites-page-mockups/favourites-library-redesign-page"; + +export const metadata: Metadata = { + title: "Favourites Review Console Mockup - Clinical KB", + description: "Library-first favourites mockup with stronger review and provenance workflow.", +}; + +export default function FavouritesReviewConsoleMockupRoute() { + return ; +} diff --git a/src/app/mockups/favourites-set-navigator/page.tsx b/src/app/mockups/favourites-set-navigator/page.tsx new file mode 100644 index 000000000..b6da91806 --- /dev/null +++ b/src/app/mockups/favourites-set-navigator/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { FavouritesLibraryRedesignPage } from "@/components/favourites-page-mockups/favourites-library-redesign-page"; + +export const metadata: Metadata = { + title: "Favourites Set Navigator Mockup - Clinical KB", + description: "Library-first favourites mockup with workflow-set navigation.", +}; + +export default function FavouritesSetNavigatorMockupRoute() { + return ; +} diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 8a155b33b..2ebc5637c 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -9,11 +9,26 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { const pathname = usePathname(); const isToolsPageMockup = pathname.startsWith("/mockups/tools-"); const isFavouritesPageMockup = pathname.startsWith("/mockups/favourites-"); + const isDocumentSearchMockup = pathname.startsWith("/mockups/document-search"); + const isSourceOverlayRedesignMockup = pathname === "/mockups/document-search/source-overlays"; + const isStandaloneDocumentFlow = + pathname === "/mockups/document-search" || pathname.startsWith("/mockups/document-search/source"); + const documentFlowOwnsMobileChrome = pathname.startsWith("/mockups/document-search/source"); return ( {children} diff --git a/src/app/mockups/tools-split-clinical-brief/page.tsx b/src/app/mockups/tools-split-clinical-brief/page.tsx new file mode 100644 index 000000000..ea580c5ae --- /dev/null +++ b/src/app/mockups/tools-split-clinical-brief/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ToolsSplitPaneRefinedMockup } from "@/components/tools-page-mockups/split-pane-refined-mockups"; + +export const metadata: Metadata = { + title: "Tools Split Clinical Brief Mockup - Clinical KB", + description: "Refined split-pane Tools mockup with clinical brief and mobile popup.", +}; + +export default function ToolsSplitClinicalBriefMockupRoute() { + return ; +} diff --git a/src/app/mockups/tools-split-compact-sheet/page.tsx b/src/app/mockups/tools-split-compact-sheet/page.tsx new file mode 100644 index 000000000..013a78603 --- /dev/null +++ b/src/app/mockups/tools-split-compact-sheet/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ToolsSplitPaneRefinedMockup } from "@/components/tools-page-mockups/split-pane-refined-mockups"; + +export const metadata: Metadata = { + title: "Tools Split Compact Sheet Mockup - Clinical KB", + description: "Refined split-pane Tools mockup with compact mobile action sheet.", +}; + +export default function ToolsSplitCompactSheetMockupRoute() { + return ; +} diff --git a/src/app/mockups/tools-split-safety-deck/page.tsx b/src/app/mockups/tools-split-safety-deck/page.tsx new file mode 100644 index 000000000..6bb3c3846 --- /dev/null +++ b/src/app/mockups/tools-split-safety-deck/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { ToolsSplitPaneRefinedMockup } from "@/components/tools-page-mockups/split-pane-refined-mockups"; + +export const metadata: Metadata = { + title: "Tools Split Safety Deck Mockup - Clinical KB", + description: "Refined split-pane Tools mockup with safety-focused clinical organisation.", +}; + +export default function ToolsSplitSafetyDeckMockupRoute() { + return ; +} diff --git a/src/app/services/layout.tsx b/src/app/services/layout.tsx index 0f0fa36cb..462b9148f 100644 --- a/src/app/services/layout.tsx +++ b/src/app/services/layout.tsx @@ -4,7 +4,7 @@ import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search export default function ServicesLayout({ children }: { children: ReactNode }) { return ( - + {children} ); diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 8e2915e08..bc4fbff80 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -1,5 +1,30 @@ +import { Suspense } from "react"; + import { ServicesHomePage } from "@/components/services/services-home-page"; +import { ServicesNavigatorPage } from "@/components/services/services-navigator-page"; + +type ServicesSearchParams = Promise<{ [key: string]: string | string[] | undefined }>; + +function readFirstSearchParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function ServicesIndexRoute({ searchParams }: { searchParams: ServicesSearchParams }) { + const resolvedSearchParams = await searchParams; + const query = ( + readFirstSearchParam(resolvedSearchParams.q) ?? + readFirstSearchParam(resolvedSearchParams.query) ?? + "" + ).trim(); + const hasSubmittedSearch = resolvedSearchParams.run === "1" && query.length > 0; + + if (!hasSubmittedSearch) { + return ; + } -export default function ServicesIndexRoute() { - return ; + return ( + }> + + + ); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 8643c16db..fa98f53c3 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -9,12 +9,15 @@ import { BookOpen, CheckCircle2, ChevronDown, + ChevronRight, CircleUserRound, + Clock3, ClipboardCheck, Copy, ExternalLink, FileImage, FileText, + FolderOpen, Globe2, HelpCircle, Heart, @@ -23,6 +26,7 @@ import { ListChecks, Loader2, LogOut, + Mail, LockKeyhole, Palette, PanelTop, @@ -43,7 +47,16 @@ import { Wrench, X, } from "lucide-react"; -import { type CSSProperties, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type CSSProperties, + type FormEvent, + type RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { AccessibleTable } from "@/components/AccessibleTable"; import { DocumentOrganizationBadges, @@ -89,6 +102,7 @@ import { import { useAuthSession } from "@/lib/supabase/client"; import { SafeBoldText } from "@/components/SafeBoldText"; import { Sheet } from "@/components/ui/sheet"; +import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; @@ -2021,23 +2035,15 @@ function DocumentDrawer({ }, [documents]); const isAdminMode = mode === "admin" && canManageDocuments; - const modeLabel = - mode === "recent" - ? "Recent documents" - : mode === "source" - ? "Source PDFs" - : mode === "admin" - ? statusFilterLabel(statusFilter) - : "Source library"; - const modeSummary = - mode === "recent" - ? "Recently updated indexed sources." - : mode === "source" - ? "PDF source documents ready to open." - : mode === "admin" - ? "Document maintenance and indexing tools." - : "Search and open indexed clinical sources."; const filterValue = filter.toLowerCase(); + const sourcePdfCount = useMemo( + () => + documents.filter((document) => { + const typeText = `${document.file_type} ${document.file_name}`.toLowerCase(); + return documentStatusMatchesFilter(document, statusFilter) && typeText.includes("pdf"); + }).length, + [documents, statusFilter], + ); const filtered = documents .filter((document) => { @@ -2101,16 +2107,46 @@ function DocumentDrawer({ if (mode !== "recent") return 0; return new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime(); }); + const availableDocumentCount = mode === "source" ? sourcePdfCount : (pagination?.total ?? documents.length); + const statusTitle = + mode === "recent" + ? `${availableDocumentCount.toLocaleString()} recent source${availableDocumentCount === 1 ? "" : "s"}` + : mode === "source" + ? `${availableDocumentCount.toLocaleString()} source PDF${availableDocumentCount === 1 ? "" : "s"}` + : isAdminMode + ? `${statusFilterLabel(statusFilter)}: ${filtered.length.toLocaleString()} shown` + : `${availableDocumentCount.toLocaleString()} indexed source${availableDocumentCount === 1 ? "" : "s"}`; + const statusHelper = + availableDocumentCount === 0 + ? mode === "recent" + ? "Recent source rows will appear here after indexing." + : mode === "source" + ? "Indexed PDF source rows will appear below." + : "Indexed source rows will appear below." + : mode === "recent" + ? "Continue reading from the most recently updated sources." + : mode === "source" + ? "Open original PDF source documents." + : "Search and filter to open indexed clinical sources."; return (
-
-
-

{modeLabel}

-

- {modeSummary} {filtered.length} matching document{filtered.length === 1 ? "" : "s"}. -

+
+ + +
+

{statusTitle}

+

{statusHelper}

+ + {filtered.length.toLocaleString()} shown +
{/* Dynamic Browse Library Filters */} -
+
setSelectedSite(e.target.value)} - className="w-full mt-1 px-2.5 py-1.5 text-xs rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text)] focus:border-[color:var(--primary)] focus:outline-none" + className={cn(fieldControlPlain, "mt-1 h-10 text-xs font-semibold shadow-none sm:h-9")} aria-label="Filter by site" > @@ -2161,7 +2198,7 @@ function DocumentDrawer({ setSelectedPopulation(e.target.value)} - className="w-full mt-1 px-2.5 py-1.5 text-xs rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text)] focus:border-[color:var(--primary)] focus:outline-none" + className={cn(fieldControlPlain, "mt-1 h-10 text-xs font-semibold shadow-none sm:h-9")} aria-label="Filter by population" > @@ -2506,7 +2543,41 @@ export function SettingsDialog({ onOpenGuide: () => void; }) { const closeButtonRef = useRef(null); + const settingsEmailInputRef = useRef(null); const currentThemeLabel = theme === "dark" ? "Dark" : "Light"; + const auth = useAuthSession(); + const [settingsEmail, setSettingsEmail] = useState(""); + const [emailEntryOpen, setEmailEntryOpen] = useState(false); + const [settingsEmailAttempted, setSettingsEmailAttempted] = useState(false); + const [accountNotice, setAccountNotice] = useState(null); + const settingsAuthBusy = auth.status === "loading"; + const signedOutAccount = !identity.signedIn; + + async function submitSettingsEmail(event: FormEvent) { + event.preventDefault(); + if (!settingsEmail.trim()) return; + setAccountNotice(null); + setSettingsEmailAttempted(true); + await auth.signInWithEmail(settingsEmail.trim()); + } + + function openSettingsEmailEntry() { + setEmailEntryOpen(true); + setAccountNotice(null); + } + + function chooseSettingsProvider(provider: string) { + setAccountNotice(`${provider} sign-in is a placeholder for now. Continue with email to use this workspace.`); + } + + useEffect(() => { + if (!emailEntryOpen) return; + const focusFrame = window.requestAnimationFrame(() => { + settingsEmailInputRef.current?.focus({ preventScroll: true }); + }); + return () => window.cancelAnimationFrame(focusFrame); + }, [emailEntryOpen]); + const settingSections = [ { title: "Account", @@ -2606,7 +2677,7 @@ export function SettingsDialog({ -
+

-
+
+

+ Clinical Guide account +

- - {identity.initials} + + {signedOutAccount ? : identity.initials} {identity.signedIn ? ( ) : null}
-

- Clinical context -

-

+

{identity.displayName}

-

- Consultant psychiatrist, Western Australia +

+ {signedOutAccount ? "Sign in or create an account" : "Consultant psychiatrist, Western Australia"}

-
- - -
+ {signedOutAccount ? ( +
+ + +
+ ) : ( +
+ + +
+ )}
- + + {signedOutAccount ? ( +
+
+ + +
+ + {emailEntryOpen ? ( +
+ + +
+ ) : null} + +
+ + or continue with + +
+ +
+ chooseSettingsProvider("Apple")} /> + chooseSettingsProvider("Google")} /> + chooseSettingsProvider("Microsoft")} /> + +
+ +

+ + Accounts save preferences and search history. Do not enter PHI. +

+ + {(accountNotice || !auth.isConfigured || (settingsEmailAttempted && auth.error)) && ( +

+ {accountNotice ?? + (settingsEmailAttempted ? auth.error : null) ?? + "Supabase browser authentication is not configured for account sign-in."} +

+ )} +
+ ) : ( + + )}
-
+
@@ -2702,6 +2882,62 @@ function SettingsChip({ label }: { label: string }) { ); } +function SettingsProviderRow({ + provider, + onClick, +}: { + provider: "Apple" | "Google" | "Microsoft" | "email"; + onClick: () => void; +}) { + const label = provider === "email" ? "Use email instead" : provider; + + return ( + + ); +} + +function SettingsProviderMark({ provider }: { provider: "Apple" | "Google" | "Microsoft" }) { + if (provider === "Microsoft") { + return ( +
); } -function PinnedSection({ - pinnedApps, - selectedId, - onSelect, - onTogglePin, - copy, +function FilterTabs({ + activeFilter, + onFilterChange, }: { - pinnedApps: LauncherApp[]; - selectedId: string; - onSelect: (id: string) => void; - onTogglePin: (id: string) => void; - copy: LauncherCopy; + activeFilter: LauncherFilter; + onFilterChange: (filter: LauncherFilter) => void; }) { return ( -
-
-
- - Pinned -
- {pinnedApps.length} pinned + <> +
+ {desktopFilters.map((filter) => { + const active = filter.id === activeFilter || (filter.id === "all" && activeFilter === "more"); + return ( + + ); + })}
-
- {pinnedApps.map((app) => { - const selected = selectedId === app.id; +
+ {mobileFilters.map((filter) => { + const active = filter.id === activeFilter || (filter.id === "all" && activeFilter === "saved"); return ( -
onFilterChange(filter.id)} className={cn( - "grid w-full grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center gap-2 px-3 py-3 text-left transition hover:bg-[color:var(--surface-subtle)] sm:gap-3", - selected && "bg-[color:var(--clinical-accent-soft)]/55", + "inline-flex min-h-7 shrink-0 items-center justify-center gap-0.5 rounded-lg border px-2 text-[9px] font-bold transition", + active + ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)]" + : "border-[color:var(--border)] bg-[color:var(--surface-lux)] text-[color:var(--text-muted)]", + focusRing, )} > - - - -
+ {filter.label} + {filter.hasMenu ? : null} + ); })}
-
+ ); } -function ApplicationRow({ +function ToolCard({ app, selected, - pinned, onSelect, - onTogglePin, }: { app: LauncherApp; selected: boolean; - pinned: boolean; onSelect: (id: string) => void; - onTogglePin: (id: string) => void; }) { + const externalProps = app.external ? { target: "_blank", rel: "noopener noreferrer" } : {}; return ( -
{ + event.preventDefault(); + onSelect(app.id); + }} className={cn( - "grid min-h-[72px] grid-cols-[auto_minmax(0,1fr)_7rem_6rem_5.5rem_auto] items-center gap-3 border-t border-[color:var(--border)] px-3 py-3 transition first:border-t-0", - selected && - "rounded-lg border border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)]/55 shadow-[var(--glow-soft)]", - !selected && "hover:bg-[color:var(--surface-subtle)]", + "group grid min-h-[9.25rem] grid-cols-[auto_minmax(0,1fr)_auto] gap-4 rounded-lg border bg-[color:var(--surface-lux)] p-4 text-left shadow-[var(--shadow-card)] transition hover:-translate-y-0.5 hover:border-[color:var(--clinical-accent-border)] hover:shadow-[var(--shadow-soft)] motion-reduce:hover:translate-y-0", + selected + ? app.id === "risk-safety" + ? "border-red-200 bg-red-50/45" + : "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)]/50" + : "border-[color:var(--border)]", + focusRing, )} - data-testid={`application-row-${app.id}`} - {...{ href: app.href }} + {...externalProps} > - - - -
+ + Best for: {app.bestFor} + + + + + + + {app.actionLabel} + + + ); } -function MobileApplicationRow({ +function MobileToolRow({ app, selected, onSelect, @@ -572,220 +667,217 @@ function MobileApplicationRow({ onSelect: (id: string) => void; }) { return ( - + ); } -function DetailPanel({ - app, - pinned, - onTogglePin, - onClose, - headingId, - copy, - testId = "selected-application-panel", - variant = "inline", +function DetailSection({ + icon: Icon, + title, + children, + compact, }: { - app: LauncherApp; - pinned: boolean; - onTogglePin: (id: string) => void; - onClose?: () => void; - headingId?: string; - copy: LauncherCopy; - testId?: string; - variant?: "inline" | "sheet"; + icon: LucideIcon; + title: string; + children: React.ReactNode; + compact?: boolean; }) { - const related = app.relatedIds.map(appById); - return ( - +
); } @@ -803,305 +895,158 @@ export function ApplicationsLauncherWorkspace({ query: controlledQuery, onQueryChange, desktopComposerSlotId, - showDetailPanel = true, className, }: ApplicationsLauncherWorkspaceProps) { const [uncontrolledQuery, setUncontrolledQuery] = useState(""); - const [activeFilter, setActiveFilter] = useState<(typeof filterOptions)[number]["id"]>("all"); - const [selectedId, setSelectedId] = useState("clinical-kb-search"); - const [pinnedIds, setPinnedIds] = useState(seedPinnedIds); - const [mobileDetailOpen, setMobileDetailOpen] = useState(false); - const [desktopViewport, setDesktopViewport] = useState(false); + const [activeFilter, setActiveFilter] = useState("all"); + const [selectedId, setSelectedId] = useState(() => initialToolId(controlledQuery)); + const [detailOpen, setDetailOpen] = useState(variant === "dashboard-tools"); const isDashboardTools = variant === "dashboard-tools"; const copy = isDashboardTools ? dashboardToolsLauncherCopy : standaloneLauncherCopy; const query = controlledQuery ?? uncontrolledQuery; - const normalizedQuery = query.trim().toLowerCase(); - const pinnedApps = pinnedIds.map(appById); - const selectedApp = appById(selectedId); const filteredApps = useMemo(() => { return launcherApps.filter((app) => { const matchesFilter = activeFilter === "all" || - (activeFilter === "pinned" - ? pinnedIds.includes(app.id) - : activeFilter === "review_due" - ? app.status === "review_due" - : activeFilter === "source_backed" - ? app.sourceBacked - : true); + activeFilter === "more" || + (activeFilter === "saved" ? app.area === "saved" : app.area === activeFilter); const matchesQuery = !normalizedQuery || - [app.title, app.description, app.workflow, app.detail, areaLabels[app.area]].some((value) => - value.toLowerCase().includes(normalizedQuery), - ); + [app.title, app.mobileTitle, app.description, app.bestFor, app.detail, areaLabels[app.area], ...app.keywords] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(normalizedQuery); return matchesFilter && matchesQuery; }); - }, [activeFilter, normalizedQuery, pinnedIds]); + }, [activeFilter, normalizedQuery]); - useEffect(() => { - if (!isDashboardTools) return undefined; - const media = window.matchMedia("(min-width: 1024px)"); - const update = () => setDesktopViewport(media.matches); - update(); - media.addEventListener("change", update); - return () => media.removeEventListener("change", update); - }, [isDashboardTools]); - - function togglePin(id: string) { - setPinnedIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : [id, ...current])); - } - - function selectApplication(id: string) { - setSelectedId(id); - if (typeof window !== "undefined" && window.matchMedia("(max-width: 1023px)").matches) { - setMobileDetailOpen(true); - } - } + const effectiveSelectedId = filteredApps.some((app) => app.id === selectedId) + ? selectedId + : (filteredApps[0]?.id ?? selectedId); + const selectedApp = appById(effectiveSelectedId); function updateQuery(nextQuery: string) { - if (controlledQuery === undefined) { - setUncontrolledQuery(nextQuery); - } + if (controlledQuery === undefined) setUncontrolledQuery(nextQuery); onQueryChange?.(nextQuery); } - function submitFooterSearch(event: FormEvent) { - event.preventDefault(); - const firstMatch = filteredApps[0]; - if (firstMatch) selectApplication(firstMatch.id); + function openTool(id: string) { + setSelectedId(id); + setDetailOpen(true); } - const workspace = ( - <> - {isDashboardTools ? ( -
-
- - {desktopComposerSlotId ? ( -
- ) : null} -
- - + function submitSearch() { + if (filteredApps[0]) openTool(filteredApps[0].id); + } -
- - {normalizedQuery ? ( - - ) : null} -
-
- ) : ( -
- - - -

+ return ( +
+
+ + + +
+

{copy.heading}

-

+

{copy.description}

-
- - -
-
- - - -
-
- )} +

-
-
- +
+ + + ) : ( + + )} -
-
- -

{copy.allSectionLabel}

-
- -
- {copy.allColumnLabel} - Last used - Status - Action - -
- - {filteredApps.length === 0 ? ( -
-

{copy.emptyTitle}

-

{copy.emptyBody}

-
- ) : ( - <> -
- {filteredApps.map((app) => ( - - ))} -
- -
- {filteredApps.map((app) => ( - - ))} -
- - )} - -

- Showing {filteredApps.length > 0 ? "1" : "0"} to {filteredApps.length} of {launcherApps.length}{" "} - {copy.countNoun} -

-
+
+
+ +
+
+ +
+
+ -
-
-

Recent activity

- View all -
-
- {recentActivity.slice(0, 3).map((item) => { - const Icon = item.icon; - return ( - - ); - })} +
+
+
+

{copy.allSectionLabel}

+
+
+ +
+ Sort by + A to Z +
-
+
- {(!isDashboardTools || (showDetailPanel && desktopViewport)) && ( -
- + {filteredApps.length === 0 ? ( +
+

{copy.emptyTitle}

+

{copy.emptyBody}

+ ) : ( + <> +
+ {filteredApps.map((app) => ( + + ))} +
+
+ {filteredApps.map((app) => ( + + ))} +
+ )} -
- setMobileDetailOpen(false)} - labelledBy="selected-application-sheet-heading" - closeLabel={copy.closeSelectedLabel} - contentClassName="lg:hidden rounded-t-[1.75rem] bg-[color:var(--surface-lux)]" - bodyClassName="px-5 pb-6 pt-4 sm:px-5" - portal - > - setMobileDetailOpen(false)} - headingId="selected-application-sheet-heading" - copy={copy} - testId="selected-application-sheet-panel" - variant="sheet" - /> - - - ); +

+ Showing {filteredApps.length > 0 ? "1" : "0"} to {filteredApps.length} of {launcherApps.length}{" "} + {copy.countNoun} +

+ - if (isDashboardTools) { - return ( -
- {workspace} -
- ); - } + {isDashboardTools ? ( + + ) : null} - return
{workspace}
; + setDetailOpen(false)} /> + + ); } export function ApplicationsLauncherPage() { diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 2e759b8f2..e8d5b8b76 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -47,7 +47,8 @@ export function deriveSidebarIdentity(email: string | null | undefined): Sidebar } function accountProfileLabel(identity: SidebarIdentity) { - return `${identity.initials} ${identity.displayName} ${identity.detail}. Open account profile`; + const action = identity.signedIn ? "Open account profile" : "Set up workspace"; + return `${identity.initials} ${identity.displayName} ${identity.detail}. ${action}`; } const sidebarToolItems = [ @@ -74,6 +75,7 @@ export function ClinicalSidebarContent({ onPickRecent, onOpenGuide, onOpenSettings, + onOpenAccount, theme, onToggleTheme, onPrefetchApplications, @@ -88,6 +90,7 @@ export function ClinicalSidebarContent({ onPickRecent: (query: string) => void; onOpenGuide: () => void; onOpenSettings: () => void; + onOpenAccount: () => void; theme: ResolvedTheme; onToggleTheme: () => void; onPrefetchApplications?: () => void; @@ -264,7 +267,7 @@ export function ClinicalSidebarContent({ type="button" onClick={() => { onNavigate?.(); - window.requestAnimationFrame(onOpenSettings); + window.requestAnimationFrame(onOpenAccount); }} data-testid="sidebar-account-settings" className="mt-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-left shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--clinical-accent-soft)]/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" @@ -299,6 +302,7 @@ export function ClinicalDesktopSidebar({ onPickRecent, onOpenGuide, onOpenSettings, + onOpenAccount, theme, onToggleTheme, onPrefetchApplications, @@ -313,6 +317,7 @@ export function ClinicalDesktopSidebar({ onPickRecent: (query: string) => void; onOpenGuide: () => void; onOpenSettings: () => void; + onOpenAccount: () => void; theme: ResolvedTheme; onToggleTheme: () => void; onPrefetchApplications: () => void; @@ -449,10 +454,10 @@ export function ClinicalDesktopSidebar({
+ +
+
+
+ +

+ Set up your workspace +

+

+ Sync source preferences, search history, and clinical defaults across devices. +

+
+ + + + + +
+
+ + or continue with + +
+ +
+ {(["Apple", "Google", "Microsoft"] as const).map((provider) => ( + chooseProvider(provider)} /> + ))} +
+
+ +
+
+
+
+

+ Source preferences +

+ +
+

+ Choose the sources you rely on most. +

+
+ + + Saved + +
+ +
+ {sourcePreferences.map((source) => { + const Icon = source.icon; + const selected = selectedSources.has(source.id); + return ( + + ); + })} +
+
+ +
+
+
+ +

+ Security summary +

+
+ + + Verified + +
+ +
+ {securitySummary.map((item) => { + const Icon = item.icon; + return ( +
+ + + + {item.label} + + + {item.detail} + + + +
+ ); + })} +
+
+ + {statusMessage ? ( +

+ {statusMessage} +

+ ) : null} + +

+ Already have an account?{" "} + +

+
+
+ + + ); +} + +function ProviderButton({ provider, onClick }: { provider: Provider; onClick: () => void }) { + return ( + + ); +} + +function ProviderMark({ provider }: { provider: Provider }) { + if (provider === "Microsoft") { + return ( +