From a594f0407c0998dc6e78f2bdff080848244ff4b5 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 25 Aug 2026 22:09:47 +0000
Subject: [PATCH 1/5] Answer page: take the box off the answer, and quieten the
warnings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The chat redesign landed its contents but not its frame. The numbered marks,
the source rail, the question bubble and the drawer all shipped, then rendered
inside the old raised AnswerCard with the full verification block above them —
new furniture in the old room. This is the frame.
- `AnswerCard` gains `frame="bare"`: no border, background, shadow or panel
padding, and no header divider. The card still owns the verification wording,
the support word and the degraded banner, and still refuses to render an
answer without them. Only the box goes.
- `VerificationNotice` gains `presentation="inline"`: the same approved compact
instruction held at every screen width as one quiet line, with the complete
governed wording still printed. On a source-only answer the full block, the
support eyebrow and the Source-only disclosure stated one caution three times
in eleven lines; three identical alarms teach a reader to skip all three.
Caution states keep the warning icon and colour — quieting a caution is not
the same as flattening it into a routine notice.
- The rail heading loses its top border and the disclosure above it loses its
-8px bottom margin. Together they drew a rule straight through the
Source-only pill, which is a full-width line crossing a `w-fit` chip.
- `AnswerSupportSummaryCard` gains `density="compact"`: safety keeps its full
row, while evidence gaps and report collapse from two stacked 56px two-line
rows into one line of buttons. Same ids, same routes, same wording.
- `--answer-message-gutter` keeps the notice line in the prose column. The
assistant badge is declared in a different component from the notice above it,
and without a shared value the governance line hangs off the left of the
answer it belongs to.
Verified at 390px against /mockups/answer-chat-perfected-v2: notice, support
word, prose and rail all start at x=57; the card reports border 0px and a
transparent background; the notice block is 32px where it was over 100.
Clinical note: the shrink is presentation only. No approved string was edited,
print still carries the complete wording, and the owner authorised the reduced
prominence on 2026-08-25.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01AP3MXGx1bGeEBmFKYkeLA8
---
.design-sync/config.json | 4 +-
src/app/globals.css | 15 ++
.../clinical-dashboard/answer-content.tsx | 5 +-
.../answer-result-surface.tsx | 11 +-
.../clinical-dashboard/answer-source-rail.tsx | 5 +-
.../clinical-dashboard/evidence-panels.tsx | 217 ++++++++++++------
src/components/ui/answer-card.tsx | 55 ++++-
src/components/ui/verification-notice.tsx | 48 +++-
tests/design-sync-visual-exports.test.ts | 5 +-
tests/ui-v2-answer-safety.dom.test.tsx | 29 +++
10 files changed, 301 insertions(+), 93 deletions(-)
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/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-rail.tsx b/src/components/clinical-dashboard/answer-source-rail.tsx
index 4542ad315a..08aa12711f 100644
--- a/src/components/clinical-dashboard/answer-source-rail.tsx
+++ b/src/components/clinical-dashboard/answer-source-rail.tsx
@@ -108,7 +108,10 @@ export function AnswerSourceRail({
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 ? (
+
+ 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/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/design-sync-visual-exports.test.ts b/tests/design-sync-visual-exports.test.ts
index 1261c757f6..84b3cf9c51 100644
--- a/tests/design-sync-visual-exports.test.ts
+++ b/tests/design-sync-visual-exports.test.ts
@@ -89,7 +89,10 @@ describe("design-sync visual exports", () => {
expect(config.dtsPropsFor.SegmentedControl).toContain("options: readonly SegmentedControlOption[]");
expect(config.dtsPropsFor.OverlayRoot).toBe("");
expect(config.dtsPropsFor.EmptyState).toContain('live?: "off" | "assertive" | "polite"');
- expect(config.dtsPropsFor.VerificationNotice).toContain('presentation?: "full" | "responsive-compact"');
+ // `inline` joined the union on 2026-08-25 for the chat-framed answer. The
+ // assertion still pins every member, so a variant cannot be added or dropped
+ // without this line moving with it.
+ expect(config.dtsPropsFor.VerificationNotice).toContain('presentation?: "inline" | "full" | "responsive-compact"');
expect(config.dtsPropsFor.AccessibleTable).toContain("caption: string");
expect(config.dtsPropsFor.AccessibleTable).not.toContain("caption?:");
expect(config.dtsPropsFor.ConfirmDialog).toContain("confirmLabel: string");
diff --git a/tests/ui-v2-answer-safety.dom.test.tsx b/tests/ui-v2-answer-safety.dom.test.tsx
index 5aefaab4bc..603ae77c65 100644
--- a/tests/ui-v2-answer-safety.dom.test.tsx
+++ b/tests/ui-v2-answer-safety.dom.test.tsx
@@ -153,6 +153,35 @@ describe("VerificationNotice", () => {
expect(full.className).toContain("print:block");
});
+ it("holds the compact instruction at every width in inline presentation, and still prints the full wording", () => {
+ render();
+
+ const compact = screen.getByTestId("verification-notice-compact");
+ const full = screen.getByTestId("verification-notice-full");
+ expect(compact).toHaveTextContent(
+ "Copied from cited sources without model synthesis. Verify against the cited sources before acting.",
+ );
+ // No `sm:hidden`: unlike responsive-compact, the quiet line is what a desktop
+ // reader gets too. Print is the one medium that still receives the full block.
+ expect(compact.className).not.toContain("sm:hidden");
+ expect(compact.className).toContain("print:hidden");
+ expect(full.className).toContain("hidden");
+ expect(full.className).toContain("print:block");
+ expect(full).toHaveTextContent(/without model synthesis/i);
+ });
+
+ it("keeps the warning mark on a caution state even when the notice is quietened", () => {
+ const { container, unmount } = render();
+ // A caution must stay distinguishable from a routine notice. Quieting the
+ // presentation may not flatten the two into the same grey line.
+ expect(container.querySelector("svg")).toBeInTheDocument();
+ expect(screen.getByTestId("verification-notice").className).toContain("--warning");
+ unmount();
+
+ render();
+ expect(screen.getByTestId("verification-notice").className).not.toContain("--warning");
+ });
+
it("preserves attribution and every state-specific instruction in responsive compact wording", () => {
const cases = [
["ready", "model", /AI-generated.*Verify every clinical claim against the cited sources before acting/i],
From 9ace6ef4b52ae96befffaf2d1abcaf1e0b5f0c10 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 25 Aug 2026 22:35:25 +0000
Subject: [PATCH 2/5] Answer page: show the cited document's front page in the
source drawer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A citation points 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 document search card already draws that thumbnail; the answer's
source drawer did not.
The cover id was the whole problem. It rides `RelatedDocument` on the search
payload and the answer surface never calls `/api/search`. 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. `/api/documents/[id]`
already carries it but returns pages, chunks and images alongside: a
kilobyte-scale payload to render one 80px picture. So `/api/documents/[id]/cover`
returns the id and nothing else, behind the same authorization the rest of the
document API uses — 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 is not itself a capability: `/api/images/[id]/signed-url`
re-checks ownership and committed generation before it signs anything.
The caption is the part that matters clinically. The index stores ONE first-page
thumbnail per document and no per-page renders, so a picture shown beside "p. 12"
with no caption reads as page 12 and quietly misrepresents the evidence. It
therefore always says "Front page" and, where known, where the passage actually
sits. Three DOM tests pin that, not the picture.
The lookup is cached per document including the misses — a text-only upload is
the common case and re-asking would spend a document-read rate-limit token to
learn the same null — and it fails to nothing: no cover, passage untouched.
Frame, surface and accent edge match `DocumentPagePreview` on the search card,
so one document looks like itself on both surfaces.
Verified at 390px: GET .../cover 200, GET /api/images/.../signed-url 200,
figure 80x143 with the page image decoded at 192x272.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01AP3MXGx1bGeEBmFKYkeLA8
---
docs/site-map.md | 1 +
src/app/api/documents/[id]/cover/route.ts | 70 +++++++++++++++
.../answer-source-drawer.tsx | 42 ++++++++-
.../clinical-dashboard/use-document-cover.ts | 88 +++++++++++++++++++
tests/answer-source-rail.dom.test.tsx | 72 ++++++++++++++-
5 files changed, 268 insertions(+), 5 deletions(-)
create mode 100644 src/app/api/documents/[id]/cover/route.ts
create mode 100644 src/components/clinical-dashboard/use-document-cover.ts
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..7d31099d51
--- /dev/null
+++ b/src/app/api/documents/[id]/cover/route.ts
@@ -0,0 +1,70 @@
+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 { 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() });
+
+/**
+ * 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").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" });
+
+ 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/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}
+
,
}));
import { AnswerSupportSummaryCard } from "@/components/clinical-dashboard/evidence-panels";
@@ -17,6 +17,7 @@ import {
sourceCapsuleDisplay,
sourceSupportSentence,
} from "@/components/clinical-dashboard/answer-source-rows";
+import { resetDocumentCoverCacheForTests } from "@/components/clinical-dashboard/use-document-cover";
import { normalizeSourceMetadata } from "@/lib/source-metadata";
import type { VisualEvidenceCard } from "@/lib/types";
@@ -506,3 +507,70 @@ describe("source drawer overflow menu", () => {
expect(report).toHaveTextContent("Confirm: report this page");
});
});
+
+describe("answer source drawer cover", () => {
+ /**
+ * The cover is the one thing on this surface that can misrepresent the
+ * evidence by being merely decorative. The index stores ONE first-page
+ * thumbnail per document and no per-page renders, so a picture shown beside
+ * "p. 12" with no caption reads as page 12. The caption is the guarantee, and
+ * that is what these tests pin — not the picture.
+ */
+ const fetchMock = vi.fn();
+
+ beforeEach(() => {
+ resetDocumentCoverCacheForTests();
+ fetchMock.mockReset();
+ vi.stubGlobal("fetch", fetchMock);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ async function openFirstSource(user: ReturnType) {
+ render();
+ await user.click(screen.getAllByTestId("answer-source-rail-row")[0]);
+ }
+
+ it("names the front page and where the passage actually sits", async () => {
+ fetchMock.mockResolvedValue({ ok: true, json: async () => ({ coverImageId: "cover-1" }) });
+ const user = userEvent.setup();
+ await openFirstSource(user);
+
+ const cover = await screen.findByTestId("answer-source-drawer-cover");
+ expect(cover).toHaveTextContent("Front page");
+ // The cited page, stated, so the thumbnail cannot be read as that page.
+ expect(cover).toHaveTextContent("passage on p. 4");
+ expect(within(cover).getByText(/Front page of/i)).toBeInTheDocument();
+ });
+
+ it("renders no cover when the document has none, and asks only once", async () => {
+ fetchMock.mockResolvedValue({ ok: true, json: async () => ({ coverImageId: null }) });
+ const user = userEvent.setup();
+ await openFirstSource(user);
+
+ await screen.findByTestId("answer-source-drawer-support");
+ expect(screen.queryByTestId("answer-source-drawer-cover")).not.toBeInTheDocument();
+
+ // A text-only upload is the common case; re-asking on every open would spend
+ // a document-read rate-limit token to learn the same null.
+ const calls = fetchMock.mock.calls.length;
+ await user.keyboard("{Escape}");
+ await user.click(screen.getAllByTestId("answer-source-rail-row")[0]);
+ await screen.findByTestId("answer-source-drawer-support");
+ expect(fetchMock.mock.calls.length).toBe(calls);
+ });
+
+ it("degrades to no cover when the lookup fails", async () => {
+ fetchMock.mockRejectedValue(new Error("offline"));
+ const user = userEvent.setup();
+ await openFirstSource(user);
+
+ await screen.findByTestId("answer-source-drawer-support");
+ expect(screen.queryByTestId("answer-source-drawer-cover")).not.toBeInTheDocument();
+ // The citation itself is untouched: a missing decoration must never take the
+ // passage down with it.
+ expect(screen.getByTestId("answer-source-drawer-passage")).toBeInTheDocument();
+ });
+});
From 1998451b11ef2071a6c3dd54ab82361c09e8c10b Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 26 Aug 2026 01:18:52 +0000
Subject: [PATCH 3/5] Regenerate the repo-awareness snapshot for the new cover
route
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`Static PR checks` went red on the exact failure the guard exists for:
[repo-awareness] The committed snapshot is behind the repository:
- routes differs from the repository
`src/app/api/documents/[id]/cover/route.ts` is a new route, and the
committed snapshot enumerates routes. The pre-commit hook synchronizes the
sitemap and the codebase index but not this snapshot, so adding a route
leaves it stale until CI says so.
Reproduced locally first, then regenerated with the repo's own tooling
(`npm run snapshot:repo-awareness`, never by hand). The diff is the route
entry, the api count 56 -> 57, and the head stamp — nothing else.
`check:repo-awareness-snapshot` now reports "in step with
data/repo-awareness-snapshot.json (192 pages, 436 documents, 2616 reviews)",
and the sibling checks in the same job (`format:changed`, `sitemap:check`)
pass locally.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01AP3MXGx1bGeEBmFKYkeLA8
---
data/repo-awareness-snapshot.json | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
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": {
From fd98774e4db2af483becd404a5f4ce5651fd6840 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 26 Aug 2026 01:31:18 +0000
Subject: [PATCH 4/5] Answer sources: make a failed cover lookup retryable, and
read the committed cover
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two review findings from the Codex connector, both verified against the code
before acting and both real.
**A transient failure was cached as an answer.** `loadCoverImageId` funnelled
every outcome through one `return null`: an authoritative "this document has no
cover" and a 429, a 5xx or an offline blip were stored identically. Because the
map then held the id, every later drawer open skipped the request, so the
thumbnail could not come back without a full page reload. The loader now
distinguishes them — `null` is an answer and caches, `undefined` is the server
declining for now and is never cached — and a 404 counts as an answer, because
"gone, or not ours to read" is a real result rather than a hiccup.
**The route took an arbitrary cover row.** `fetchDocumentCoverImageIds` scans
`document_images` for a `cover_page` row with no ordering and no generation
filter, so a document mid-reindex or mid-cover-repair can return 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. `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). Read that first.
The scan stays as a fallback rather than being deleted. Documents indexed before
the pointer existed carry no such key, and dropping straight to no-thumbnail
would regress them; the signed-url route re-checks ownership and committed
generation either way, so the fallback cannot hand out something the stricter
path would have refused.
Two new DOM tests pin the recovery Codex asked for: a lookup that rejects once
and then succeeds shows the cover on reopen, while a 404 caches and is not
re-asked. 30 passed in that file.
Verified: lint passed (5438 input files), typecheck passed, `npm run test` 870
test files passed, and the drawer still resolves live at 390px —
`GET .../cover` 200, `GET /api/images/.../signed-url` 200, caption
"Front page · passage on p. 1".
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01AP3MXGx1bGeEBmFKYkeLA8
---
src/app/api/documents/[id]/cover/route.ts | 28 ++++++++++++++-
.../clinical-dashboard/use-document-cover.ts | 36 ++++++++++++-------
tests/answer-source-rail.dom.test.tsx | 34 ++++++++++++++++++
3 files changed, 85 insertions(+), 13 deletions(-)
diff --git a/src/app/api/documents/[id]/cover/route.ts b/src/app/api/documents/[id]/cover/route.ts
index 7d31099d51..a7812cc3ae 100644
--- a/src/app/api/documents/[id]/cover/route.ts
+++ b/src/app/api/documents/[id]/cover/route.ts
@@ -52,7 +52,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
request.signal.throwIfAborted();
const { data: document, error: documentError } = await withOwnerReadScope(
- supabase.from("documents").select("id").eq("id", id),
+ supabase.from("documents").select("id,metadata").eq("id", id),
access.ownerId,
)
.abortSignal(request.signal)
@@ -60,6 +60,32 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
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;
+ const pointer =
+ metadata && typeof metadata === "object" && !Array.isArray(metadata)
+ ? (metadata as Record).cover_image_id
+ : null;
+ if (typeof pointer === "string" && pointer.length > 0) {
+ return NextResponse.json({ coverImageId: pointer });
+ }
+
+ // 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) {
diff --git a/src/components/clinical-dashboard/use-document-cover.ts b/src/components/clinical-dashboard/use-document-cover.ts
index bcc46f39a6..5c5ea30342 100644
--- a/src/components/clinical-dashboard/use-document-cover.ts
+++ b/src/components/clinical-dashboard/use-document-cover.ts
@@ -10,15 +10,23 @@ import { useEffect, useState } from "react";
* 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 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`.
+ * 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>();
+const inFlight = new Map>();
-async function loadCoverImageId(documentId: string): Promise {
+/** `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);
@@ -27,7 +35,10 @@ async function loadCoverImageId(documentId: string): Promise {
const request = (async () => {
try {
const response = await fetch(`/api/documents/${encodeURIComponent(documentId)}/cover`);
- if (!response.ok) return null;
+ // 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
@@ -35,16 +46,17 @@ async function loadCoverImageId(documentId: string): Promise {
: null;
return typeof value === "string" && value.length > 0 ? value : null;
} catch {
- // A cover is decoration for a citation, never the citation itself. A
- // failed lookup renders no thumbnail and changes nothing else on screen.
- return null;
+ // 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);
- coverImageIds.set(documentId, resolved);
+ if (resolved !== undefined) coverImageIds.set(documentId, resolved);
return resolved;
}
@@ -69,7 +81,7 @@ export function useDocumentCoverImageId(documentId: string | null | undefined):
if (!id || coverImageIds.get(id) !== undefined) return;
let active = true;
void loadCoverImageId(id).then((resolved) => {
- if (active) setFetched(resolved);
+ if (active) setFetched(resolved ?? null);
});
return () => {
active = false;
diff --git a/tests/answer-source-rail.dom.test.tsx b/tests/answer-source-rail.dom.test.tsx
index 8dfe5059e5..1cf8a5c416 100644
--- a/tests/answer-source-rail.dom.test.tsx
+++ b/tests/answer-source-rail.dom.test.tsx
@@ -562,6 +562,40 @@ describe("answer source drawer cover", () => {
expect(fetchMock.mock.calls.length).toBe(calls);
});
+ it("retries after a transient failure instead of caching it as a no-cover answer", async () => {
+ // The defect this pins: a 429/5xx/offline blip used to be cached exactly
+ // like an authoritative `null`, so every later open skipped the request and
+ // the thumbnail could not return without a full page reload.
+ fetchMock
+ .mockRejectedValueOnce(new Error("offline"))
+ .mockResolvedValue({ ok: true, json: async () => ({ coverImageId: "cover-1" }) });
+ const user = userEvent.setup();
+ await openFirstSource(user);
+
+ await screen.findByTestId("answer-source-drawer-support");
+ expect(screen.queryByTestId("answer-source-drawer-cover")).not.toBeInTheDocument();
+
+ await user.keyboard("{Escape}");
+ await user.click(screen.getAllByTestId("answer-source-rail-row")[0]);
+ expect(await screen.findByTestId("answer-source-drawer-cover")).toHaveTextContent("Front page");
+ });
+
+ it("treats a 404 as an answer and does not keep asking", async () => {
+ // 404 means the document is gone or not ours to read. That is a real answer,
+ // so it caches — unlike the transient failures above.
+ fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) });
+ const user = userEvent.setup();
+ await openFirstSource(user);
+
+ await screen.findByTestId("answer-source-drawer-support");
+ const calls = fetchMock.mock.calls.length;
+ await user.keyboard("{Escape}");
+ await user.click(screen.getAllByTestId("answer-source-rail-row")[0]);
+ await screen.findByTestId("answer-source-drawer-support");
+ expect(fetchMock.mock.calls.length).toBe(calls);
+ expect(screen.queryByTestId("answer-source-drawer-cover")).not.toBeInTheDocument();
+ });
+
it("degrades to no cover when the lookup fails", async () => {
fetchMock.mockRejectedValue(new Error("offline"));
const user = userEvent.setup();
From 81bed42d6c58da8660da11d6434af11e378486b4 Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Wed, 26 Aug 2026 09:44:59 +0800
Subject: [PATCH 5/5] fix: validate selected document cover
---
src/app/api/documents/[id]/cover/route.ts | 35 ++++-
tests/document-cover-route.test.ts | 159 ++++++++++++++++++++++
2 files changed, 188 insertions(+), 6 deletions(-)
create mode 100644 tests/document-cover-route.test.ts
diff --git a/src/app/api/documents/[id]/cover/route.ts b/src/app/api/documents/[id]/cover/route.ts
index a7812cc3ae..3d2f54c265 100644
--- a/src/app/api/documents/[id]/cover/route.ts
+++ b/src/app/api/documents/[id]/cover/route.ts
@@ -6,6 +6,7 @@ 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";
@@ -14,6 +15,7 @@ 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
@@ -73,13 +75,34 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
* uncommitted, silently losing the thumbnail — or an obsolete duplicate,
* which shows the wrong front page beside a citation.
*/
- const metadata = document.metadata;
- const pointer =
- metadata && typeof metadata === "object" && !Array.isArray(metadata)
- ? (metadata as Record).cover_image_id
+ const metadata =
+ document.metadata && typeof document.metadata === "object" && !Array.isArray(document.metadata)
+ ? (document.metadata as Record)
: null;
- if (typeof pointer === "string" && pointer.length > 0) {
- return NextResponse.json({ coverImageId: pointer });
+ 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
diff --git a/tests/document-cover-route.test.ts b/tests/document-cover-route.test.ts
new file mode 100644
index 0000000000..e82108edb2
--- /dev/null
+++ b/tests/document-cover-route.test.ts
@@ -0,0 +1,159 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const documentId = "11111111-1111-4111-8111-111111111111";
+const selectedCoverId = "22222222-2222-4222-8222-222222222222";
+const stagedCoverId = "33333333-3333-4333-8333-333333333333";
+const committedGeneration = "44444444-4444-4444-8444-444444444444";
+const stagedGeneration = "55555555-5555-4555-8555-555555555555";
+
+type QueryCall = {
+ table: string;
+ selected?: string;
+ filters: Array<{ column: string; value: unknown }>;
+};
+type QueryResult = { data: unknown; error: { message: string } | null };
+type QueryResolver = (call: QueryCall) => QueryResult;
+
+class QueryBuilder {
+ constructor(
+ private readonly call: QueryCall,
+ private readonly resolve: QueryResolver,
+ ) {}
+
+ select(selected: string) {
+ this.call.selected = selected;
+ return this;
+ }
+
+ eq(column: string, value: unknown) {
+ this.call.filters.push({ column, value });
+ return this;
+ }
+
+ abortSignal(signal: AbortSignal) {
+ void signal;
+ return this;
+ }
+
+ maybeSingle() {
+ return Promise.resolve(this.resolve(this.call));
+ }
+}
+
+const mocks = vi.hoisted(() => ({
+ createAdminClient: vi.fn(),
+ isDemoMode: vi.fn(),
+ rateLimit: vi.fn(),
+ withOwnerReadScope: vi.fn(),
+ fetchDocumentCoverImageIds: vi.fn(),
+}));
+
+vi.mock("@/lib/api-rate-limit", () => ({
+ rateLimitJsonResponse: () => Response.json({ error: "limited" }, { status: 429 }),
+}));
+vi.mock("@/lib/demo-data", () => ({ demoImages: [] }));
+vi.mock("@/lib/document-enrichment", () => ({ fetchDocumentCoverImageIds: mocks.fetchDocumentCoverImageIds }));
+vi.mock("@/lib/env", () => ({ isDemoMode: mocks.isDemoMode }));
+vi.mock("@/lib/http", () => ({
+ jsonError: () => Response.json({ error: "internal" }, { status: 500 }),
+ PublicApiError: class PublicApiError extends Error {},
+ publicErrorResponse: (error: string, status: number) => Response.json({ error }, { status }),
+}));
+vi.mock("@/lib/public-api-access", () => ({
+ enforceDocumentReadRateLimit: mocks.rateLimit,
+ withOwnerReadScope: mocks.withOwnerReadScope,
+}));
+vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: mocks.createAdminClient }));
+vi.mock("@/lib/supabase/auth", () => ({
+ AuthenticationError: class AuthenticationError extends Error {},
+ unauthorizedResponse: () => Response.json({ error: "unauthorized" }, { status: 401 }),
+}));
+
+import { GET } from "@/app/api/documents/[id]/cover/route";
+
+function request() {
+ return new Request(`http://localhost/api/documents/${documentId}/cover`);
+}
+
+function routeParams() {
+ return { params: Promise.resolve({ id: documentId }) };
+}
+
+function setRouteData(resolve: QueryResolver) {
+ const calls: QueryCall[] = [];
+ mocks.createAdminClient.mockReturnValue({
+ from: vi.fn((table: string) => {
+ const call: QueryCall = { table, filters: [] };
+ calls.push(call);
+ return new QueryBuilder(call, resolve);
+ }),
+ });
+ return calls;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.isDemoMode.mockReturnValue(false);
+ mocks.rateLimit.mockResolvedValue({
+ access: { ownerId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" },
+ rateLimit: { limited: false },
+ });
+ mocks.withOwnerReadScope.mockImplementation((query: unknown) => query);
+ mocks.fetchDocumentCoverImageIds.mockResolvedValue(new Map([[documentId, stagedCoverId]]));
+});
+
+describe("GET /api/documents/[id]/cover", () => {
+ it("returns only the selected cover after validating its document, kind, and committed generation", async () => {
+ const calls = setRouteData((call) => {
+ if (call.table === "documents") {
+ return {
+ data: {
+ id: documentId,
+ metadata: { index_generation_id: committedGeneration, cover_image_id: selectedCoverId },
+ },
+ error: null,
+ };
+ }
+ if (call.table === "document_images") {
+ return { data: { id: selectedCoverId, metadata: { index_generation_id: committedGeneration } }, error: null };
+ }
+ return { data: null, error: null };
+ });
+
+ const response = await GET(request(), routeParams());
+
+ await expect(response.json()).resolves.toEqual({ coverImageId: selectedCoverId });
+ expect(calls.find((call) => call.table === "document_images")).toMatchObject({
+ selected: "id,metadata",
+ filters: expect.arrayContaining([
+ { column: "id", value: selectedCoverId },
+ { column: "document_id", value: documentId },
+ { column: "source_kind", value: "cover_page" },
+ ]),
+ });
+ expect(mocks.fetchDocumentCoverImageIds).not.toHaveBeenCalled();
+ });
+
+ it("fails closed when the selected pointer is no longer committed", async () => {
+ setRouteData((call) => {
+ if (call.table === "documents") {
+ return {
+ data: {
+ id: documentId,
+ metadata: { index_generation_id: committedGeneration, cover_image_id: selectedCoverId },
+ },
+ error: null,
+ };
+ }
+ if (call.table === "document_images") {
+ return { data: { id: selectedCoverId, metadata: { index_generation_id: stagedGeneration } }, error: null };
+ }
+ return { data: null, error: null };
+ });
+
+ const response = await GET(request(), routeParams());
+
+ await expect(response.json()).resolves.toEqual({ coverImageId: null });
+ expect(mocks.fetchDocumentCoverImageIds).not.toHaveBeenCalled();
+ });
+});