From b51922547b97a606ae025c731b5e9c009180f7dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 04:49:31 +0000 Subject: [PATCH] Answer page: symbol off, one number per claim, no safety rail, one panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything /mockups/answer-chat-perfected-v2 argues for, applied to the live answer surface after the clinical owner approved the look and chose "direction B" for the block beneath the answer (2026-08-26). Written up in docs/answer-page-redesign-handover.md §12.6–§12.8. The assistant shield tile and its grid column are gone from plain-answer-response, the matching ps-[var(--answer-message-gutter)] is gone from the bare AnswerCard header, and the token itself is deleted. The tile was decorative and its column cost about 2.75rem of every line of a clinical answer on a 390px phone. There are two speakers here, the person's turn is already a right-aligned bubble, and what identifies the assistant turn is the verification line above it. maxMarksPerCluster is 1. Checked before changing, not assumed: resolveClaimMarks already counts the remainder into overflow, +N renders it, every source it counts is on the rail below at full tap size, and that rail is where a Review due or Outdated badge lives — per the 2026-08-24 decision that staleness is carried by the row and the drawer and never by the mark. The test now derives its overflow expectation from the cap rather than hard-coding it. The safety card loses its warning-coloured top rule, which spanned a card that also holds Evidence gaps and Report a problem and so coloured two controls carrying no state. Colour moves into the icon tile plus a severity chip built from the finding's own label, now returned as severityLabel rather than run into the citation string. The row still renders whenever priority is set; it is the only route to the safety-findings sheet. Direction B under the answer: AnswerFollowUpSuggestions gains layout="rows" and renders on every width, CrossModeLinksSection gains variant="line", and questions sit above matches. Two second copies go with it — the composer dock no longer receives composerFollowUpSuggestions on the answer mode, and the dashboard no longer renders UniversalSearchAlsoMatches there. Both repeated content already on screen. Pinned so neither returns by accident. cleanCitationTitle now inserts the missing space in "Guideline(EMHS)". Every citation label passes through it, so the safety card, rail cards, drawer and print are fixed together. Display only. Verified: npm run test (871 files, 10502 passed, 1 skipped); npm run verify:ui 520 passed (18.1m) exit 0 — a first run found five failures, all this diff's, one a real aria-controls defect I introduced and four pinning the old layout, each diagnosed and fixed rather than re-run; eslint and tsc clean; check:design-system-contract passed with no ratchet moved (colour-only status indicators 0, status-coloured numerals 0); repo-wide prettier clean. check:production-readiness still fails on pre-existing release-mode privacy debt unrelated to this diff (#HVTYAT) — reported, not suppressed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AP3MXGx1bGeEBmFKYkeLA8 --- docs/answer-page-redesign-handover.md | 44 +++++++++ src/app/globals.css | 15 --- src/components/ClinicalDashboard.tsx | 20 +++- .../clinical-dashboard/answer-content.tsx | 20 ++-- .../answer-follow-up-suggestions.tsx | 66 ++++++++++--- .../answer-result-surface.tsx | 28 +++--- .../clinical-dashboard/cross-mode-links.tsx | 93 ++++++++++++++++++- .../clinical-dashboard/evidence-panels.tsx | 37 +++++--- src/components/ui/answer-card.tsx | 2 +- src/lib/answer-claim-marks.ts | 15 ++- src/lib/citations.ts | 15 ++- tests/answer-claim-marks.test.ts | 12 ++- tests/answer-follow-up-chips.dom.test.tsx | 16 +++- tests/citations.test.ts | 13 +++ tests/ui-smoke.spec.ts | 27 +++++- tests/ui-universal-search.spec.ts | 21 +++-- 16 files changed, 354 insertions(+), 90 deletions(-) diff --git a/docs/answer-page-redesign-handover.md b/docs/answer-page-redesign-handover.md index 19635c7a83..d20cc4423d 100644 --- a/docs/answer-page-redesign-handover.md +++ b/docs/answer-page-redesign-handover.md @@ -610,3 +610,47 @@ side by side with the answer on a large screen. Still state it in the PR body seeing the aside disappear deserves the sentence. --- + +### 12.8 The production change: edge to edge, one number, one panel (2026-08-26) + +Everything §12.6 and §12.7 drew in the mockup, applied to the live answer surface after the +owner approved the look and chose direction B. + +**Edge to edge.** The `answer-clinical-icon` tile and its grid column are gone from +`plain-answer-response` (`answer-content.tsx`), the matching `ps-[var(--answer-message-gutter)]` +is gone from the bare `AnswerCard` header, and `--answer-message-gutter` is deleted from +`globals.css`. The token existed only to keep those two non-nested places in one column; +with no column to clear, there is nothing left for it to hold. + +**One number per claim.** `maxMarksPerCluster` is `1`. Nothing is hidden: `resolveClaimMarks` +already counted the remainder into `overflow`, the `+N` renders it, and every source it counts +is on the rail immediately below at full tap size — which is also where a `Review due` or +`Outdated` badge lives, per the 2026-08-24 decision that staleness is carried by the row and +the drawer and never by the mark. The one signal that must not be lost was never on the mark. +`tests/answer-claim-marks.test.ts` now derives its overflow expectation from the cap rather +than hard-coding it, so the assertion keeps proving "every uncapped target is counted" if the +cap moves again. + +**The safety card has no rail.** Colour lives in the icon tile plus a severity chip built from +the finding's own label, which `answerSupportPriority` now returns as `severityLabel` instead +of running it into the citation string. Both priority rows lost `border-t-2`. + +**Direction B under the answer.** `AnswerFollowUpSuggestions` gained `layout="rows"` — one +question per full-width row — and the answer surface renders it above the library line on every +width. Three things had to move together for that to be an improvement rather than a third +panel: + +- The `hidden sm:block` wrapper is gone, so the questions exist on a phone at all. +- `CrossModeLinksSection` gained `variant="line"`: a single collapsed row carrying a preview of + the matches, opening to exactly the rail it always was. +- The composer dock no longer receives `composerFollowUpSuggestions` on the answer mode, and the + dashboard no longer renders `UniversalSearchAlsoMatches` there. Both were second copies — + the same three questions truncated onto one scrolling line, and a mode-level restatement of + the record-level matches directly above it. Pinned in + `tests/answer-follow-up-chips.dom.test.tsx` so neither returns by accident. + +**One citation fix travelling with it.** `cleanCitationTitle` now inserts the missing space in +`Guideline(EMHS)`. Every citation label in the product runs through that function, so the safety +card, the rail cards, the drawer and print are fixed together. Display only. + +--- diff --git a/src/app/globals.css b/src/app/globals.css index a6960ea81f..8c8415d7c4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1876,21 +1876,6 @@ 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/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index f853c23ac9..b6024f3bec 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3362,7 +3362,13 @@ function ClinicalDashboardContent({ void ask(); }} onCrossModeSearch={crossModeSearch} - composerFollowUpSuggestions={searchMode === "answer" ? answerFollowUpSuggestions : undefined} + /* The answer thread owns the follow-up questions now, as full-width + rows above its library line (owner decision, 2026-08-26, + "direction B"). The composer strip showed the same three questions + again, a few hundred pixels lower and truncated to whatever fitted + one scrolling line — which is the defect that argued for rows in + the first place. One place, readable, not two. */ + composerFollowUpSuggestions={undefined} onPickComposerFollowUpSuggestion={handlePickFollowUpSuggestion} composerFollowUpSuggestionsDisabled={loading} showPhoneSuggestionTickerOnHome={heroOwnsPhoneComposer} @@ -3807,9 +3813,15 @@ function ClinicalDashboardContent({ ) : null ) : null} - {showUniversalAlsoMatches && activeModeResultKind === "answer" ? ( - - ) : null} + {/* No mode-level "Also matches" under an answer. It sat directly + beneath the answer surface's own "Also in your library" and + asked the same question — where else does this appear — one + panel less specifically: this one names modes, that one names + the actual medication, factsheet or form inside them. Two + near-identical panels under one answer is what the owner + photographed on 2026-08-26. The mode-level view is still + reachable from mode navigation and still renders on the + tools, documents, services and forms result kinds above. */} {showSystemNotice && answer ? ( diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index c824fd7391..c560eed653 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -1,7 +1,7 @@ "use client"; import { Fragment, memo, useState } from "react"; -import { CircleAlert, ChevronDown, Copy, ShieldCheck } from "lucide-react"; +import { CircleAlert, ChevronDown, Copy } from "lucide-react"; import { SafeBoldText } from "@/components/SafeBoldText"; import { chatActionRow, chatAnswerText, chatMicroAction, cn, textMuted } from "@/components/ui-primitives"; @@ -461,15 +461,17 @@ export function NaturalLanguageAnswer({
-

{/* One span per sentence, joined by a single space, so the prose's diff --git a/src/components/clinical-dashboard/answer-follow-up-suggestions.tsx b/src/components/clinical-dashboard/answer-follow-up-suggestions.tsx index baff597f85..cb71c7ef67 100644 --- a/src/components/clinical-dashboard/answer-follow-up-suggestions.tsx +++ b/src/components/clinical-dashboard/answer-follow-up-suggestions.tsx @@ -1,6 +1,12 @@ "use client"; +import { ChevronRight } from "lucide-react"; + import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; +import { cn } from "@/components/ui-primitives"; + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; export function AnswerFollowUpSuggestions({ suggestions, @@ -15,18 +21,56 @@ export function AnswerFollowUpSuggestions({ disabled?: boolean; className?: string; testId?: string; - layout?: "wrap" | "scroll"; + /** + * `"rows"` is the answer thread's layout (owner decision, 2026-08-26): one + * question per full-width row rather than chips. + * + * Chips truncate. On a 390px phone the third suggestion sat off the right + * edge of a horizontally scrolling strip, under an answer that already has + * one — and a question a clinician cannot read is a question they will not + * ask. A row gives each one the full measure and stacks them vertically, with + * the page's own scroll. + */ + layout?: "wrap" | "scroll" | "rows"; }) { + if (layout !== "rows") { + return ( + + ); + } + if (!suggestions.length) return null; return ( - +

+

+ Ask next +

+
+ {suggestions.map((suggestion, index) => ( + + ))} +
+
); } diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index cf7b83c8c4..c12278c4c3 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -300,22 +300,24 @@ function StagedAnswerResultSurfaceImpl({ /> ) : null} - {crossModeQueries?.length && onCrossModeSearch ? ( - ) : null} - {followUpSuggestions?.length && onPickFollowUpSuggestion ? ( -
- -
+ {crossModeQueries?.length && onCrossModeSearch ? ( + ) : null}
diff --git a/src/components/clinical-dashboard/cross-mode-links.tsx b/src/components/clinical-dashboard/cross-mode-links.tsx index bc89704438..b92e2df0bb 100644 --- a/src/components/clinical-dashboard/cross-mode-links.tsx +++ b/src/components/clinical-dashboard/cross-mode-links.tsx @@ -2,8 +2,8 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; -import { Search, type LucideIcon } from "lucide-react"; +import { useEffect, useId, useMemo, useState } from "react"; +import { ChevronDown, Search, type LucideIcon } from "lucide-react"; import { cn, eyebrowText, semanticChipTone, sourceCard, type SemanticChipTone } from "@/components/ui-primitives"; import { logCrossModeLinkOpen } from "@/components/clinical-dashboard/source-actions"; @@ -24,7 +24,7 @@ function badgeChipTone(tone: CrossModeLinkBadge["tone"]): SemanticChipTone | nul return tone === "clinical" ? "info" : tone; } -type CrossModeLinksVariant = "card" | "compact" | "responsive-compact"; +type CrossModeLinksVariant = "card" | "compact" | "responsive-compact" | "line"; type CrossModeLinkCardProps = { link: CrossModeLink; @@ -183,6 +183,89 @@ export function CrossModeLinksSection({ return ; } +/** + * One line, opened on demand — the answer thread's variant (owner decision, + * 2026-08-26, "direction B"). + * + * Under an answer this block used to be a permanently expanded rail sitting + * directly above a second, near-identical panel of mode matches. Two panels + * asking the same question ("where else does this appear") read as one panel + * repeated, and neither is the clinician's next step — the follow-up questions + * above them are. So this collapses to a single row carrying a preview of what + * is inside, and opens to exactly the rail it always was. + * + * The preview names come from the resolved links, so the line can never + * advertise a match the expanded rail does not list. + */ +function CrossModeLinksLine({ + links, + onModeSearch, + query, +}: { + links: CrossModeLink[]; + onModeSearch: (mode: AppModeId, query: string) => void; + query: string; +}) { + const [open, setOpen] = useState(false); + // `useId`, not a constant: two of these can mount at once (the answer thread + // and a historical turn), and a duplicate id is a DOM-integrity failure the + // smoke audit fails on. + const panelId = useId(); + const preview = links.slice(0, 3).map((link) => link.title); + const rest = links.length - preview.length; + return ( +
+ + {/* Always mounted, hidden with `display: none` when closed. Rendering it + only while open left `aria-controls` pointing at nothing for the whole + time the line was collapsed — a broken ARIA reference, which is what + the smoke suite's DOM-integrity audit caught. */} +
+ {links.map((link) => ( + + ))} +
+
+ ); +} + export function CrossModeLinksStrip({ links, onModeSearch, @@ -197,6 +280,10 @@ export function CrossModeLinksStrip({ }) { if (links.length === 0) return null; + if (variant === "line") { + return ; + } + const compact = variant === "compact"; const responsiveCompact = variant === "responsive-compact"; const LinkItem = compact ? CrossModeLinkChip : CrossModeLinkCard; diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index cc7864de40..b49b99ea27 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -106,6 +106,12 @@ export { type AnswerSupportPriority = { title: string; detail: string; + /** + * The finding's own severity word ("Red flag", "Contraindication"), split out + * of `detail` so the row can set it as a chip instead of running it into the + * citation. Only the safety-findings priority has one. + */ + severityLabel?: string; sourceLabel?: string; tone: "priority" | "caution"; }; @@ -138,7 +144,8 @@ export function answerSupportPriority( if (firstSafetyFinding) { return { title: "Safety findings", - detail: formatSafetyFindingLabel(firstSafetyFinding), + severityLabel: firstSafetyFinding.label, + detail: formatCitationLabel(firstSafetyFinding.citation), tone: "caution", }; } @@ -252,18 +259,25 @@ export function AnswerSupportSummaryCard({ data-testid="answer-safety-findings-trigger" type="button" onClick={onOpenSafetyFindings} - className={cn(supportButtonClass, "w-full border-t-2 border-t-[color:var(--warning)]")} + className={cn(supportButtonClass, "w-full")} aria-label="Open safety-critical source findings" > {priority.title} - {priority.detail} + + {priority.severityLabel ? ( + + {priority.severityLabel} + + ) : null} + {priority.detail} + {safetyFindingsCount} @@ -271,18 +285,13 @@ export function AnswerSupportSummaryCard({ ) : ( -
+