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 ? (
+ {/* 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}
+
+ setFeedbackOpen((current) => !current)}
+ className={cn(supportButtonClass, "w-full")}
+ aria-expanded={feedbackOpen}
+ aria-controls={feedbackOpen ? "answer-feedback-detail" : undefined}
+ >
+
+
+
+ Report a problem
+
+
+ Record whether the evidence supports this answer
+
+
+
+
+ {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}
- />
+
{compactWording}
@@ -287,7 +322,10 @@ export function VerificationNotice({
// it on screen already saying the same number — so it is the one line
// here that a phone can spend. Kept in the DOM and restored from `sm`
// and in print, where it is part of the audit artefact.
-
+
{sourceCount === 1 ? "Based on 1 cited source." : `Based on ${sourceCount} cited sources.`}
) : null}
diff --git a/tests/answer-source-rail.dom.test.tsx b/tests/answer-source-rail.dom.test.tsx
index ad8e8b639a..1cf8a5c416 100644
--- a/tests/answer-source-rail.dom.test.tsx
+++ b/tests/answer-source-rail.dom.test.tsx
@@ -2,10 +2,10 @@ import { useState } from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/components/clinical-dashboard/signed-image", () => ({
- SignedImage: ({ caption }: { caption?: string }) =>