diff --git a/.design-sync/config.json b/.design-sync/config.json index a0bffcb5b5..1cf2964229 100644 --- a/.design-sync/config.json +++ b/.design-sync/config.json @@ -20,7 +20,7 @@ ], "dtsPropsFor": { "AccessibleTable": "actionsHeader?: string; caption: string; clinicalOnly?: boolean; columnAlign?: AccessibleTableColumnAlign[]; columns?: string[] | null; compact?: boolean; densePreview?: boolean; dialogTitle?: string | null; expandOnMobile?: boolean; hidePreviewCaption?: boolean; hidePreviewRowCount?: boolean; lowConfidenceFallback?: ReactNode; markdown?: string | null; normalizedTable?: NormalizedAccessibleTable | null; numericColumns?: number[]; previewRows?: number; rowActions?: ReactNode[]; rows?: string[][] | null;", - "AnswerCard": "actions?: AnswerCardAction[]; children: ReactNode; className?: string; onOpenSource?: ((sourceId: string, locator?: string) => void) | ((sourceId: string, locator?: string) => void); provenance?: AnswerFooterProps; query?: string; state: { kind: \"ready\"; sourceCount: number; } | DegradedAnswerState; support: AnswerSupportStrength; verification: VerificationNoticeProps;", + "AnswerCard": "actions?: AnswerCardAction[]; children: ReactNode; className?: string; frame?: \"raised\" | \"bare\"; onOpenSource?: ((sourceId: string, locator?: string) => void) | ((sourceId: string, locator?: string) => void); provenance?: AnswerFooterProps; query?: string; state: { kind: \"ready\"; sourceCount: number; } | DegradedAnswerState; support: AnswerSupportStrength; verification: VerificationNoticeProps;", "AnswerFooter": "className?: string; generatedAt?: string | null; metadata?: SourceMetadataInput; provenance?: string | SourceMetadataInput; publisher?: string | null; reviewDate?: string | null; version?: string | null;", "AsyncButton": "about?: string; accessKey?: string; \"aria-activedescendant\"?: string; \"aria-atomic\"?: (boolean | \"true\" | \"false\"); \"aria-autocomplete\"?: \"none\" | \"list\" | \"inline\" | \"both\"; \"aria-braillelabel\"?: string; \"aria-brailleroledescription\"?: string; \"aria-busy\"?: (boolean | \"true\" | \"false\"); \"aria-checked\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-colcount\"?: number; \"aria-colindex\"?: number; \"aria-colindextext\"?: string; \"aria-colspan\"?: number; \"aria-controls\"?: string; \"aria-current\"?: boolean | \"true\" | \"false\" | \"page\" | \"step\" | \"location\" | \"date\" | \"time\"; \"aria-describedby\"?: string; \"aria-description\"?: string; \"aria-details\"?: string; \"aria-disabled\"?: (boolean | \"true\" | \"false\"); \"aria-dropeffect\"?: \"none\" | \"link\" | \"copy\" | \"execute\" | \"move\" | \"popup\"; \"aria-errormessage\"?: string; \"aria-expanded\"?: (boolean | \"true\" | \"false\"); \"aria-flowto\"?: string; \"aria-grabbed\"?: (boolean | \"true\" | \"false\"); \"aria-haspopup\"?: boolean | \"true\" | \"false\" | \"dialog\" | \"grid\" | \"listbox\" | \"menu\" | \"tree\"; \"aria-hidden\"?: (boolean | \"true\" | \"false\"); \"aria-invalid\"?: boolean | \"true\" | \"false\" | \"grammar\" | \"spelling\"; \"aria-keyshortcuts\"?: string; \"aria-label\"?: string; \"aria-labelledby\"?: string; \"aria-level\"?: number; \"aria-live\"?: \"off\" | \"assertive\" | \"polite\"; \"aria-modal\"?: (boolean | \"true\" | \"false\"); \"aria-multiline\"?: (boolean | \"true\" | \"false\"); \"aria-multiselectable\"?: (boolean | \"true\" | \"false\"); \"aria-orientation\"?: \"horizontal\" | \"vertical\"; \"aria-owns\"?: string; \"aria-placeholder\"?: string; \"aria-posinset\"?: number; \"aria-pressed\"?: boolean | \"true\" | \"false\" | \"mixed\"; \"aria-readonly\"?: (boolean | \"true\" | \"false\"); \"aria-relevant\"?: \"text\" | \"additions\" | \"additions removals\" | \"additions text\" | \"all\" | \"removals\" | \"removals additions\" | \"removals text\" | \"text additions\" | \"text removals\"; \"aria-required\"?: (boolean | \"true\" | \"false\"); \"aria-roledescription\"?: string; \"aria-rowcount\"?: number; \"aria-rowindex\"?: number; \"aria-rowindextext\"?: string; \"aria-rowspan\"?: number; \"aria-selected\"?: (boolean | \"true\" | \"false\"); \"aria-setsize\"?: number; \"aria-sort\"?: \"none\" | \"ascending\" | \"descending\" | \"other\"; \"aria-valuemax\"?: number; \"aria-valuemin\"?: number; \"aria-valuenow\"?: number; \"aria-valuetext\"?: string; autoCapitalize?: \"off\" | \"none\" | \"on\" | \"sentences\" | \"words\" | \"characters\" | (string & {}); autoCorrect?: string; autoFocus?: boolean; autoSave?: string; busy: boolean; busyLabel: string; children: ReactNode; className?: string; color?: string; content?: string; contentEditable?: (boolean | \"true\" | \"false\") | \"inherit\" | \"plaintext-only\"; contextMenu?: string; dangerouslySetInnerHTML?: { __html: string | TrustedHTML; }; datatype?: string; defaultChecked?: boolean; defaultValue?: string | number | readonly string[]; dir?: string; disabled?: boolean; draggable?: (boolean | \"true\" | \"false\"); enterKeyHint?: \"enter\" | \"done\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\"; exportparts?: string; form?: string; formAction?: string | ((formData: FormData) => void | Promise); formEncType?: string; formMethod?: string; formNoValidate?: boolean; formTarget?: string; hidden?: boolean; id?: string; idleIcon?: ReactNode; inert?: boolean; inlist?: any; inputMode?: \"none\" | \"search\" | \"text\" | \"tel\" | \"url\" | \"email\" | \"numeric\" | \"decimal\"; is?: string; itemID?: string; itemProp?: string; itemRef?: string; itemScope?: boolean; itemType?: string; lang?: string; name?: string; nonce?: string; onAbort?: import(\"react\").ReactEventHandler; onAbortCapture?: import(\"react\").ReactEventHandler; onAnimationEnd?: import(\"react\").AnimationEventHandler; onAnimationEndCapture?: import(\"react\").AnimationEventHandler; onAnimationIteration?: import(\"react\").AnimationEventHandler; onAnimationIterationCapture?: import(\"react\").AnimationEventHandler; onAnimationStart?: import(\"react\").AnimationEventHandler; onAnimationStartCapture?: import(\"react\").AnimationEventHandler; onAuxClick?: import(\"react\").MouseEventHandler; onAuxClickCapture?: import(\"react\").MouseEventHandler; onBeforeInput?: import(\"react\").InputEventHandler; onBeforeInputCapture?: import(\"react\").InputEventHandler; onBeforeToggle?: import(\"react\").ToggleEventHandler; onBlur?: import(\"react\").FocusEventHandler; onBlurCapture?: import(\"react\").FocusEventHandler; onCanPlay?: import(\"react\").ReactEventHandler; onCanPlayCapture?: import(\"react\").ReactEventHandler; onCanPlayThrough?: import(\"react\").ReactEventHandler; onCanPlayThroughCapture?: import(\"react\").ReactEventHandler; onChange?: import(\"react\").ChangeEventHandler; onChangeCapture?: import(\"react\").ChangeEventHandler; onClick?: import(\"react\").MouseEventHandler; onClickCapture?: import(\"react\").MouseEventHandler; onCompositionEnd?: import(\"react\").CompositionEventHandler; onCompositionEndCapture?: import(\"react\").CompositionEventHandler; onCompositionStart?: import(\"react\").CompositionEventHandler; onCompositionStartCapture?: import(\"react\").CompositionEventHandler; onCompositionUpdate?: import(\"react\").CompositionEventHandler; onCompositionUpdateCapture?: import(\"react\").CompositionEventHandler; onContextMenu?: import(\"react\").MouseEventHandler; onContextMenuCapture?: import(\"react\").MouseEventHandler; onCopy?: import(\"react\").ClipboardEventHandler; onCopyCapture?: import(\"react\").ClipboardEventHandler; onCut?: import(\"react\").ClipboardEventHandler; onCutCapture?: import(\"react\").ClipboardEventHandler; onDoubleClick?: import(\"react\").MouseEventHandler; onDoubleClickCapture?: import(\"react\").MouseEventHandler; onDrag?: import(\"react\").DragEventHandler; onDragCapture?: import(\"react\").DragEventHandler; onDragEnd?: import(\"react\").DragEventHandler; onDragEndCapture?: import(\"react\").DragEventHandler; onDragEnter?: import(\"react\").DragEventHandler; onDragEnterCapture?: import(\"react\").DragEventHandler; onDragExit?: import(\"react\").DragEventHandler; onDragExitCapture?: import(\"react\").DragEventHandler; onDragLeave?: import(\"react\").DragEventHandler; onDragLeaveCapture?: import(\"react\").DragEventHandler; onDragOver?: import(\"react\").DragEventHandler; onDragOverCapture?: import(\"react\").DragEventHandler; onDragStart?: import(\"react\").DragEventHandler; onDragStartCapture?: import(\"react\").DragEventHandler; onDrop?: import(\"react\").DragEventHandler; onDropCapture?: import(\"react\").DragEventHandler; onDurationChange?: import(\"react\").ReactEventHandler; onDurationChangeCapture?: import(\"react\").ReactEventHandler; onEmptied?: import(\"react\").ReactEventHandler; onEmptiedCapture?: import(\"react\").ReactEventHandler; onEncrypted?: import(\"react\").ReactEventHandler; onEncryptedCapture?: import(\"react\").ReactEventHandler; onEnded?: import(\"react\").ReactEventHandler; onEndedCapture?: import(\"react\").ReactEventHandler; onError?: import(\"react\").ReactEventHandler; onErrorCapture?: import(\"react\").ReactEventHandler; onFocus?: import(\"react\").FocusEventHandler; onFocusCapture?: import(\"react\").FocusEventHandler; onGotPointerCapture?: import(\"react\").PointerEventHandler; onGotPointerCaptureCapture?: import(\"react\").PointerEventHandler; onInput?: import(\"react\").InputEventHandler; onInputCapture?: import(\"react\").InputEventHandler; onInvalid?: import(\"react\").ReactEventHandler; onInvalidCapture?: import(\"react\").ReactEventHandler; onKeyDown?: import(\"react\").KeyboardEventHandler; onKeyDownCapture?: import(\"react\").KeyboardEventHandler; onKeyPress?: import(\"react\").KeyboardEventHandler; onKeyPressCapture?: import(\"react\").KeyboardEventHandler; onKeyUp?: import(\"react\").KeyboardEventHandler; onKeyUpCapture?: import(\"react\").KeyboardEventHandler; onLoad?: import(\"react\").ReactEventHandler; onLoadCapture?: import(\"react\").ReactEventHandler; onLoadedData?: import(\"react\").ReactEventHandler; onLoadedDataCapture?: import(\"react\").ReactEventHandler; onLoadedMetadata?: import(\"react\").ReactEventHandler; onLoadedMetadataCapture?: import(\"react\").ReactEventHandler; onLoadStart?: import(\"react\").ReactEventHandler; onLoadStartCapture?: import(\"react\").ReactEventHandler; onLostPointerCapture?: import(\"react\").PointerEventHandler; onLostPointerCaptureCapture?: import(\"react\").PointerEventHandler; onMouseDown?: import(\"react\").MouseEventHandler; onMouseDownCapture?: import(\"react\").MouseEventHandler; onMouseEnter?: import(\"react\").MouseEventHandler; onMouseLeave?: import(\"react\").MouseEventHandler; onMouseMove?: import(\"react\").MouseEventHandler; onMouseMoveCapture?: import(\"react\").MouseEventHandler; onMouseOut?: import(\"react\").MouseEventHandler; onMouseOutCapture?: import(\"react\").MouseEventHandler; onMouseOver?: import(\"react\").MouseEventHandler; onMouseOverCapture?: import(\"react\").MouseEventHandler; onMouseUp?: import(\"react\").MouseEventHandler; onMouseUpCapture?: import(\"react\").MouseEventHandler; onPaste?: import(\"react\").ClipboardEventHandler; onPasteCapture?: import(\"react\").ClipboardEventHandler; onPause?: import(\"react\").ReactEventHandler; onPauseCapture?: import(\"react\").ReactEventHandler; onPlay?: import(\"react\").ReactEventHandler; onPlayCapture?: import(\"react\").ReactEventHandler; onPlaying?: import(\"react\").ReactEventHandler; onPlayingCapture?: import(\"react\").ReactEventHandler; onPointerCancel?: import(\"react\").PointerEventHandler; onPointerCancelCapture?: import(\"react\").PointerEventHandler; onPointerDown?: import(\"react\").PointerEventHandler; onPointerDownCapture?: import(\"react\").PointerEventHandler; onPointerEnter?: import(\"react\").PointerEventHandler; onPointerLeave?: import(\"react\").PointerEventHandler; onPointerMove?: import(\"react\").PointerEventHandler; onPointerMoveCapture?: import(\"react\").PointerEventHandler; onPointerOut?: import(\"react\").PointerEventHandler; onPointerOutCapture?: import(\"react\").PointerEventHandler; onPointerOver?: import(\"react\").PointerEventHandler; onPointerOverCapture?: import(\"react\").PointerEventHandler; onPointerUp?: import(\"react\").PointerEventHandler; onPointerUpCapture?: import(\"react\").PointerEventHandler; onProgress?: import(\"react\").ReactEventHandler; onProgressCapture?: import(\"react\").ReactEventHandler; onRateChange?: import(\"react\").ReactEventHandler; onRateChangeCapture?: import(\"react\").ReactEventHandler; onReset?: import(\"react\").ReactEventHandler; onResetCapture?: import(\"react\").ReactEventHandler; onScroll?: import(\"react\").UIEventHandler; onScrollCapture?: import(\"react\").UIEventHandler; onScrollEnd?: import(\"react\").UIEventHandler; onScrollEndCapture?: import(\"react\").UIEventHandler; onSeeked?: import(\"react\").ReactEventHandler; onSeekedCapture?: import(\"react\").ReactEventHandler; onSeeking?: import(\"react\").ReactEventHandler; onSeekingCapture?: import(\"react\").ReactEventHandler; onSelect?: import(\"react\").ReactEventHandler; onSelectCapture?: import(\"react\").ReactEventHandler; onStalled?: import(\"react\").ReactEventHandler; onStalledCapture?: import(\"react\").ReactEventHandler; onSubmit?: import(\"react\").SubmitEventHandler; onSubmitCapture?: import(\"react\").SubmitEventHandler; onSuspend?: import(\"react\").ReactEventHandler; onSuspendCapture?: import(\"react\").ReactEventHandler; onTimeUpdate?: import(\"react\").ReactEventHandler; onTimeUpdateCapture?: import(\"react\").ReactEventHandler; onToggle?: import(\"react\").ToggleEventHandler; onTouchCancel?: import(\"react\").TouchEventHandler; onTouchCancelCapture?: import(\"react\").TouchEventHandler; onTouchEnd?: import(\"react\").TouchEventHandler; onTouchEndCapture?: import(\"react\").TouchEventHandler; onTouchMove?: import(\"react\").TouchEventHandler; onTouchMoveCapture?: import(\"react\").TouchEventHandler; onTouchStart?: import(\"react\").TouchEventHandler; onTouchStartCapture?: import(\"react\").TouchEventHandler; onTransitionCancel?: import(\"react\").TransitionEventHandler; onTransitionCancelCapture?: import(\"react\").TransitionEventHandler; onTransitionEnd?: import(\"react\").TransitionEventHandler; onTransitionEndCapture?: import(\"react\").TransitionEventHandler; onTransitionRun?: import(\"react\").TransitionEventHandler; onTransitionRunCapture?: import(\"react\").TransitionEventHandler; onTransitionStart?: import(\"react\").TransitionEventHandler; onTransitionStartCapture?: import(\"react\").TransitionEventHandler; onVolumeChange?: import(\"react\").ReactEventHandler; onVolumeChangeCapture?: import(\"react\").ReactEventHandler; onWaiting?: import(\"react\").ReactEventHandler; onWaitingCapture?: import(\"react\").ReactEventHandler; onWheel?: import(\"react\").WheelEventHandler; onWheelCapture?: import(\"react\").WheelEventHandler; part?: string; popover?: \"\" | \"auto\" | \"manual\" | \"hint\"; popoverTarget?: string; popoverTargetAction?: \"toggle\" | \"show\" | \"hide\"; prefix?: string; property?: string; radioGroup?: string; rel?: string; resource?: string; results?: number; rev?: string; role?: import(\"react\").AriaRole; security?: string; slot?: string; spellCheck?: (boolean | \"true\" | \"false\"); style?: import(\"react\").CSSProperties; suppressContentEditableWarning?: boolean; suppressHydrationWarning?: boolean; tabIndex?: number; title?: string; translate?: \"yes\" | \"no\"; type?: \"submit\" | \"reset\" | \"button\"; typeof?: string; unselectable?: \"off\" | \"on\"; value?: string | number | readonly string[]; vocab?: string;", "Breadcrumb": "className?: string; items: Crumb[];", @@ -73,7 +73,7 @@ "ToastRegion": "", "ToggleSwitch": "\"aria-label\"?: string; className?: string; disabled?: boolean; enabled: boolean; onToggle?: (() => void);", "Tooltip": "children: ReactElement, string | import(\"react\").JSXElementConstructor>; className?: string; content: string; placement?: \"bottom\" | \"top\";", - "VerificationNotice": "attribution?: \"model\" | \"extractive\"; audience?: \"clinician\" | \"plain\"; className?: string; medium?: \"screen\" | \"print\"; presentation?: \"full\" | \"responsive-compact\"; printedAt?: string; printedBy?: string; sourceCount?: number; state: VerificationState;" + "VerificationNotice": "attribution?: \"model\" | \"extractive\"; audience?: \"clinician\" | \"plain\"; className?: string; medium?: \"screen\" | \"print\"; presentation?: \"inline\" | \"full\" | \"responsive-compact\"; printedAt?: string; printedBy?: string; sourceCount?: number; state: VerificationState;" }, "overrides": { "Sheet": { diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index e040b9c31e..9429e92191 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,8 +1,8 @@ { "version": "repo-awareness-snapshot-v1", "captured_revision": { - "sha": "dc68600786530b60c732aefe050a02e742eb2421", - "committed_at": "2026-08-26T05:55:49+08:00" + "sha": "c391de340e5946a35d5c7d9bf580ebe019e90c31", + "committed_at": "2026-08-26T00:26:15+00:00" }, "routes": { "modes": [ @@ -1239,6 +1239,10 @@ "path": "/api/documents/[id]", "file": "src/app/api/documents/[id]/route.ts" }, + { + "path": "/api/documents/[id]/cover", + "file": "src/app/api/documents/[id]/cover/route.ts" + }, { "path": "/api/documents/[id]/labels", "file": "src/app/api/documents/[id]/labels/route.ts" @@ -1378,7 +1382,7 @@ "product_pages": 55, "mockup_pages": 137, "redirects": 17, - "api": 56 + "api": 57 } }, "documentation": { diff --git a/docs/site-map.md b/docs/site-map.md index bb3153476a..fe7d50f788 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1303,6 +1303,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/api/differentials/presentations/[slug]` - Presentation workflow comparison data endpoint. Source: `src/app/api/differentials/presentations/[slug]/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]/cover` - Route discovered from app directory Source: `src/app/api/documents/[id]/cover/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]/reviews` - Document clinical review audit log. Source: `src/app/api/documents/[id]/reviews/route.ts`. diff --git a/src/app/api/documents/[id]/cover/route.ts b/src/app/api/documents/[id]/cover/route.ts new file mode 100644 index 0000000000..3d2f54c265 --- /dev/null +++ b/src/app/api/documents/[id]/cover/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { demoImages } from "@/lib/demo-data"; +import { isDemoMode } from "@/lib/env"; +import { jsonError, PublicApiError, publicErrorResponse } from "@/lib/http"; +import { fetchDocumentCoverImageIds } from "@/lib/document-enrichment"; +import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; +import { parseRouteParams } from "@/lib/validation/params"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; + +export const runtime = "nodejs"; + +const coverRouteParamsSchema = z.object({ id: z.string().uuid() }); +const coverImageIdSchema = z.string().uuid(); + +/** + * The document's first-page cover thumbnail id, for surfaces that show what a + * cited document looks like rather than what it says. + * + * It exists as its own route because the only alternatives were worse. The + * cover id rides `RelatedDocument` on the search payload, but the answer + * surface never calls `/api/search`; adding it to the answer's own source rows + * would mean editing retrieval hydration, which is a protected RAG surface and + * a far larger blast radius than a thumbnail earns. `/api/documents/[id]` + * already carries the id but returns pages, chunks and images with it — a + * kilobyte-scale payload to render one 90px picture. + * + * Authorization is the same shape the rest of the document API uses: the read + * rate limit first, then an owner-scoped existence check on `documents` BEFORE + * `document_images` is touched, so an unauthorized caller cannot learn whether + * a document id is real from the difference between two responses. The id it + * returns is not itself a capability — `/api/images/[id]/signed-url` re-checks + * ownership and committed-generation before it signs anything. + */ +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id: rawId } = await params; + + if (isDemoMode()) { + const cover = demoImages.find((image) => image.document_id === rawId && image.source_kind === "cover_page"); + return NextResponse.json({ coverImageId: cover?.id ?? null, demoMode: true }); + } + + const { id } = parseRouteParams({ id: rawId }, coverRouteParamsSchema, "Invalid document id."); + const supabase = createAdminClient(); + const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } + request.signal.throwIfAborted(); + + const { data: document, error: documentError } = await withOwnerReadScope( + supabase.from("documents").select("id,metadata").eq("id", id), + access.ownerId, + ) + .abortSignal(request.signal) + .maybeSingle(); + if (documentError) throw new Error(documentError.message); + if (!document) return publicErrorResponse("Document not found.", 404, { code: "document_not_found" }); + + /* + * `documents.metadata.cover_image_id` is the SELECTED cover: the worker + * writes it in the same committed-core metadata patch as + * `index_generation_id` (worker/main.ts), so it names the cover belonging to + * the generation the document currently serves. + * + * Prefer it over scanning `document_images` for a `cover_page` row. That + * scan takes whichever row comes back first, with no ordering and no + * generation filter, so a document mid-reindex or mid-cover-repair can hand + * back a staged row — which `/api/images/[id]/signed-url` then refuses as + * uncommitted, silently losing the thumbnail — or an obsolete duplicate, + * which shows the wrong front page beside a citation. + */ + const metadata = + document.metadata && typeof document.metadata === "object" && !Array.isArray(document.metadata) + ? (document.metadata as Record) + : null; + if (metadata && Object.hasOwn(metadata, "cover_image_id")) { + const parsedPointer = coverImageIdSchema.safeParse(metadata.cover_image_id); + if (!parsedPointer.success) return NextResponse.json({ coverImageId: null }); + + const { data: cover, error: coverError } = await supabase + .from("document_images") + .select("id,metadata") + .eq("id", parsedPointer.data) + .eq("document_id", id) + .eq("source_kind", "cover_page") + .abortSignal(request.signal) + .maybeSingle(); + if (coverError) throw new Error(coverError.message); + if ( + !cover || + !isCommittedGenerationMetadata({ + rowMetadata: cover.metadata, + committedGeneration: committedIndexGeneration(metadata), + }) + ) { + return NextResponse.json({ coverImageId: null }); + } + + return NextResponse.json({ coverImageId: cover.id }); + } + + // Documents indexed before the pointer existed carry no such key. Fall back + // to the scan rather than drop their thumbnail: it is the same resolution + // the document search cards already use, and the signed-url route still + // re-checks ownership and committed generation before it hands anything out. + const covers = await fetchDocumentCoverImageIds(supabase, [id], request.signal); + return NextResponse.json({ coverImageId: covers.get(id) ?? null }); + } catch (error) { + if (error instanceof AuthenticationError) return unauthorizedResponse(); + if (error instanceof PublicApiError) return jsonError(error); + return jsonError(error); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 8c8415d7c4..a6960ea81f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1876,6 +1876,21 @@ summary::-webkit-details-marker { --answer-mark-gap-star: 0.03em; } +/* The chat answer message's left gutter: the assistant badge column plus its + gap. Two non-nested places have to agree on it — the badge lives inside + `plain-answer-response` (answer-content.tsx) while the verification notice + and support word sit in the card header above it (answer-card.tsx, bare + frame) — and when they disagree the governance line hangs off the left of the + answer it belongs to. A shared value is what keeps them in one column. + px-1 (0.25rem) + badge h-8/w-8 (2rem) + gap-2 (0.5rem) = 2.75rem, plus the + 1px transparent border on that section. The border is invisible but it is + real geometry: without it in the sum the notice lands 1px left of the prose, + which on a crisp display is a visible ragged edge rather than a rounding + artefact. */ +:root { + --answer-message-gutter: calc(2.75rem + 1px); +} + .answer-source-mark { position: relative; top: -0.625em; diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 113ea224b4..c824fd7391 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -489,7 +489,10 @@ export function NaturalLanguageAnswer({ ))}

-
+ {/* No negative bottom margin. It pulled the rail up by 8px, and the rail + heading used to carry a top border — the two collided and drew a rule + straight through the Source-only pill. */} +
{sourceOnly ? (
{answerState.kind === "ready" ? ( - + {answerProse} ) : ( @@ -268,6 +271,7 @@ function StagedAnswerResultSurfaceImpl({ state={answerState} verification={answerVerification} support={answerSupport} + frame="bare" // Navigate to the cited page — do not reuse onScopeDocument. That // handler only replaces selectedDocumentIds and leaves the clinician // on the answer screen with a silent filter change while the button @@ -290,6 +294,9 @@ function StagedAnswerResultSurfaceImpl({ onOpenSafetyFindings={safetyFindings.length > 0 ? openSafetyFindings : undefined} pendingFeedback={pendingFeedback} onSubmitFeedback={onSubmitFeedback} + // Chat framing: safety keeps its row, the other two collapse to + // one line of buttons rather than two 56px stacked rows. + density="compact" /> ) : null} diff --git a/src/components/clinical-dashboard/answer-source-drawer.tsx b/src/components/clinical-dashboard/answer-source-drawer.tsx index eb3279df66..f0e25daa2e 100644 --- a/src/components/clinical-dashboard/answer-source-drawer.tsx +++ b/src/components/clinical-dashboard/answer-source-drawer.tsx @@ -18,6 +18,7 @@ import { cn, glassOverlaySurface, subtleStatusPill, textMuted } from "@/componen import { logSourceOpen } from "@/components/clinical-dashboard/source-actions"; import { cleanDisplayTitle, sourceQuoteDisplayText } from "@/components/clinical-dashboard/display-text"; import { SignedImage } from "@/components/clinical-dashboard/signed-image"; +import { useDocumentCoverImageId } from "@/components/clinical-dashboard/use-document-cover"; import { CanonicalAnswerTables } from "@/components/clinical-dashboard/visual-evidence"; import { answerSourceRailRowId, @@ -139,6 +140,9 @@ export function AnswerSourceDrawer({ const sourceTables = open ? tablesForSource(tables, sources, openIndex) : []; const sourceImages = open ? imagesForSource(visualEvidence, sources, openIndex) : []; const stale = source ? sourceRowIsStale(source) : false; + // Hooks cannot be conditional, so this asks for the open source's cover on + // every render and resolves to null while the drawer is closed. + const coverImageId = useDocumentCoverImageId(source?.documentId); const numbered = sources.length <= NUMBERED_PAGER_LIMIT; return ( @@ -252,9 +256,41 @@ export function AnswerSourceDrawer({ > {source ? (
-

- {sourceSupportSentence(source, activeSupportIndex, activeClaimSupport)} -

+ {/* What the document looks like, next to what it says. A citation is a + pointer into a physical-looking artefact, and a clinician who has + seen the front page of the protocol recognises it faster than they + read its title. + + The caption is not decoration. This is the FRONT page, never a + render of the cited page — the index stores one cover thumbnail per + document and no per-page renders — so an uncaptioned picture beside + "p. 12" would read as page 12 and quietly misrepresent the + evidence. Say which page it is, and say where the passage actually + sits. */} +
+ {coverImageId ? ( +
+ {/* Same 3:4 frame, surface and accent edge as `DocumentPagePreview` + on the document search card: one document, two surfaces, one + look. */} + +
+ Front page + {typeof source.pageNumber === "number" ? ` · passage on p. ${source.pageNumber}` : null} +
+
+ ) : null} +

+ {sourceSupportSentence(source, activeSupportIndex, activeClaimSupport)} +

+
{stale ? (

diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index bc9bf2b7b3..cc7864de40 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -188,6 +188,7 @@ export function AnswerSupportSummaryCard({ onOpenSafetyFindings, pendingFeedback = null, onSubmitFeedback, + density = "comfortable", }: { priority: AnswerSupportPriority | null; /** Answer-level evidence gaps (`renderModel.warnings`); they belong to no single source. */ @@ -197,13 +198,45 @@ export function AnswerSupportSummaryCard({ onOpenSafetyFindings?: () => void; pendingFeedback?: AnswerFeedbackType | null; onSubmitFeedback?: (feedbackType: AnswerFeedbackType) => void; + /** + * `"compact"` is the chat-framed answer's density. Safety keeps its full row — + * it is the one thing here that must not be tucked into a chip — while the + * evidence-gaps and report rows collapse from two 56px two-line rows into one + * line of small buttons. Same controls, same ids, same routes; roughly 90px of + * phone scroll returned under a four-line answer. + */ + density?: "comfortable" | "compact"; }) { const [gapsOpen, setGapsOpen] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false); + const compact = density === "compact"; const supportRowCount = Number(warnings.length > 0) + Number(Boolean(onSubmitFeedback)); const supportButtonClass = "grid min-h-[56px] grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2.5 px-3 py-2 text-left transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"; + // min-h-12 (48px), not min-h-11: 44px reintroduced a known sub-pixel rounding + // flake in `ui-smoke`. See AGENTS.md "External skill precedence". + const compactChipClass = + "inline-flex min-h-12 items-center gap-1.5 rounded-md px-2 text-xs font-semibold text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text-heading)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"; const safetyInteractive = Boolean(onOpenSafetyFindings && safetyFindingsCount > 0); + const gapsDetail = + warnings.length > 0 && gapsOpen ? ( +

+ {warnings.map((warning, index) => ( +

+ {warning} +

+ ))} +
+ ) : null; + const feedbackDetail = + onSubmitFeedback && feedbackOpen ? ( +
+ +
+ ) : null; return (
0 ? ( -
- {warnings.length > 0 ? ( -
- - {gapsOpen ? ( -
- {warnings.map((warning, index) => ( -

- {warning} -

- ))} -
+
- ) : null} - {onSubmitFeedback ? ( -
- - {feedbackOpen ? ( -
- -
+ {onSubmitFeedback ? ( + ) : null}
- ) : null} -
+ {gapsDetail} + {feedbackDetail} +
+ ) : ( +
+ {warnings.length > 0 ? ( +
+ + {gapsDetail} +
+ ) : null} + {onSubmitFeedback ? ( +
+ + {feedbackDetail} +
+ ) : null} +
+ ) ) : null}
); diff --git a/src/components/clinical-dashboard/use-document-cover.ts b/src/components/clinical-dashboard/use-document-cover.ts new file mode 100644 index 0000000000..5c5ea30342 --- /dev/null +++ b/src/components/clinical-dashboard/use-document-cover.ts @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * The first-page cover thumbnail id for a document, fetched on demand. + * + * The answer payload does not carry it: the cover rides `RelatedDocument` on + * the search payload, and putting it on the answer's own source rows would mean + * editing retrieval hydration — a protected RAG surface, and far more blast + * radius than a thumbnail earns. So the drawer asks for it when a source opens. + * + * Cached per document for the page's lifetime, including the authoritative + * misses. A document with no cover is the common case for a text-only upload, + * and re-asking on every drawer open would spend a document-read rate-limit + * token each time to learn the same `null`. + * + * An authoritative miss is not the same as a failed lookup, and the first cut + * of this cached both as `null`. A 429, a 5xx or an offline blip then pinned + * "no cover" for the rest of the page's life: every later open found the id in + * the map and skipped the request, so the thumbnail could not come back without + * a reload. `undefined` from the loader means "ask again next time" and is the + * one result that is never cached. + */ +const coverImageIds = new Map(); +const inFlight = new Map>(); + +/** `string`/`null` are answers and get cached; `undefined` is a transient failure. */ +async function loadCoverImageId(documentId: string): Promise { + const cached = coverImageIds.get(documentId); + if (cached !== undefined) return cached; + const pending = inFlight.get(documentId); + if (pending) return pending; + + const request = (async () => { + try { + const response = await fetch(`/api/documents/${encodeURIComponent(documentId)}/cover`); + // 404 is an answer: the document is gone or not ours to read. Anything + // else non-ok (429, 5xx) is the server declining for now, not saying no. + if (response.status === 404) return null; + if (!response.ok) return undefined; + const payload: unknown = await response.json(); + const value = + payload && typeof payload === "object" && "coverImageId" in payload + ? (payload as { coverImageId: unknown }).coverImageId + : null; + return typeof value === "string" && value.length > 0 ? value : null; + } catch { + // Offline, aborted, or unparseable. A cover is decoration for a citation, + // never the citation itself, so this renders no thumbnail and changes + // nothing else on screen — but it stays retryable. + return undefined; + } + })(); + + inFlight.set(documentId, request); + const resolved = await request; + inFlight.delete(documentId); + if (resolved !== undefined) coverImageIds.set(documentId, resolved); + return resolved; +} + +export function useDocumentCoverImageId(documentId: string | null | undefined): string | null { + const id = documentId ?? null; + /** + * Reset happens during render, not in an effect. Clearing the previous + * document's answer from inside an effect renders one frame with the wrong + * cover attached to the new source — a picture of the last document beside + * this document's passage — and `react-hooks/set-state-in-effect` rejects the + * synchronous set that would cause it. The effect below only ever sets state + * from the resolved promise. + */ + const [renderedId, setRenderedId] = useState(id); + const [fetched, setFetched] = useState(null); + if (renderedId !== id) { + setRenderedId(id); + setFetched(null); + } + + useEffect(() => { + if (!id || coverImageIds.get(id) !== undefined) return; + let active = true; + void loadCoverImageId(id).then((resolved) => { + if (active) setFetched(resolved ?? null); + }); + return () => { + active = false; + }; + }, [id]); + + if (!id) return null; + const cached = coverImageIds.get(id); + return cached !== undefined ? cached : fetched; +} + +/** Test-only reset for the process-local cover cache. */ +export function resetDocumentCoverCacheForTests() { + coverImageIds.clear(); + inFlight.clear(); +} diff --git a/src/components/ui/answer-card.tsx b/src/components/ui/answer-card.tsx index 60bc3dcc15..e45713530a 100644 --- a/src/components/ui/answer-card.tsx +++ b/src/components/ui/answer-card.tsx @@ -82,6 +82,17 @@ type AnswerCardBase = { /** Machine provenance, rendered through AnswerFooter. */ provenance?: AnswerFooterProps; actions?: AnswerCardAction[]; + /** + * `"raised"` is the bordered, shadowed panel this card has always drawn. + * + * `"bare"` removes the frame and the panel padding so the answer sits on the + * page, which is what the approved chat design draws: a question bubble, an + * assistant badge, and prose — no container. The card is still the component + * that owns the verification wording, the support word and the degraded + * banner, and it still refuses to render an answer without them; only the box + * around them goes. Adopted for the answer surface 2026-08-25. + */ + frame?: "raised" | "bare"; className?: string; }; @@ -105,8 +116,10 @@ export function AnswerCard({ provenance, actions, onOpenSource, + frame = "raised", className, }: AnswerCardProps) { + const bare = frame === "bare"; // Vertical density: lux horizontal `--pad-panel` stays, but stacked header+body // each carrying full panel padding added ~60px of phantom phone scroll against the // `#227` budget of 8 (short-answer smoke) and pushed the desktop table/prose delta @@ -119,12 +132,29 @@ export function AnswerCard({
-
+ {/* Bare: the notice and the support word share one line, because on a + source-only answer they were two stacked banners saying the same thing + above a four-line answer. The degraded banner still takes its own line + via `w-full` below. */} +
{query ? : null} {/* Above the prose and above the actions, in document order, on screen and on print alike. */} @@ -134,7 +164,10 @@ export function AnswerCard({

Evidence support: {ANSWER_SUPPORT_WORDING[support]} @@ -161,18 +194,20 @@ export function AnswerCard({ * degraded answer must remain re-verifiable whether or not a banner renders. */} {state.kind === "stale_evidence" || state.kind === "partial_retrieval" ? ( - void} - /> +

+ void} + /> +
) : null}
{actions?.length ? ( -
+
{actions.map((action) => (