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 ? ( +

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

+ {warning} +

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

- {warning} -

- ))} -
+
- ) : null} - {onSubmitFeedback ? ( -
- - {feedbackOpen ? ( -
- -
+ {onSubmitFeedback ? ( + ) : null}
- ) : null} -
+ {gapsDetail} + {feedbackDetail} +
+ ) : ( +
+ {warnings.length > 0 ? ( +
+ + {gapsDetail} +
+ ) : null} + {onSubmitFeedback ? ( +
+ + {feedbackDetail} +
+ ) : null} +
+ ) ) : null} ); diff --git a/src/components/ui/answer-card.tsx b/src/components/ui/answer-card.tsx index 60bc3dcc15..e45713530a 100644 --- a/src/components/ui/answer-card.tsx +++ b/src/components/ui/answer-card.tsx @@ -82,6 +82,17 @@ type AnswerCardBase = { /** Machine provenance, rendered through AnswerFooter. */ provenance?: AnswerFooterProps; actions?: AnswerCardAction[]; + /** + * `"raised"` is the bordered, shadowed panel this card has always drawn. + * + * `"bare"` removes the frame and the panel padding so the answer sits on the + * page, which is what the approved chat design draws: a question bubble, an + * assistant badge, and prose — no container. The card is still the component + * that owns the verification wording, the support word and the degraded + * banner, and it still refuses to render an answer without them; only the box + * around them goes. Adopted for the answer surface 2026-08-25. + */ + frame?: "raised" | "bare"; className?: string; }; @@ -105,8 +116,10 @@ export function AnswerCard({ provenance, actions, onOpenSource, + frame = "raised", className, }: AnswerCardProps) { + const bare = frame === "bare"; // Vertical density: lux horizontal `--pad-panel` stays, but stacked header+body // each carrying full panel padding added ~60px of phantom phone scroll against the // `#227` budget of 8 (short-answer smoke) and pushed the desktop table/prose delta @@ -119,12 +132,29 @@ export function AnswerCard({
-
+ {/* Bare: the notice and the support word share one line, because on a + source-only answer they were two stacked banners saying the same thing + above a four-line answer. The degraded banner still takes its own line + via `w-full` below. */} +
{query ? : null} {/* Above the prose and above the actions, in document order, on screen and on print alike. */} @@ -134,7 +164,10 @@ export function AnswerCard({

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

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