diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 4dae4b2c88..860e53ed04 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -930,14 +930,14 @@ tests to run. Registered public components: 53 Components with a valid design-sync preview: 53 -Components with product imports: 27 +Components with product imports: 29 This generated snapshot is a local source-derived inventory. It does not assert remote design-project publication. | Component | Family | Built | Locally registered | Observed v2 mount | Proof declared | Baseline committed | Product imports | | ------------------------ | -------- | ----- | ------------------ | --------------------- | -------------- | ------------------ | --------------: | | `AccessibleTable` | source | yes | yes | inherited-global-root | yes | no | 4 | -| `AnswerCard` | answer | yes | yes | no | yes | no | 0 | +| `AnswerCard` | answer | yes | yes | inherited-global-root | yes | no | 1 | | `AnswerFooter` | answer | yes | yes | no | yes | no | 0 | | `AsyncButton` | controls | yes | yes | inherited-global-root | yes | no | 4 | | `Breadcrumb` | layout | yes | yes | inherited-global-root | yes | no | 1 | @@ -947,7 +947,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `Citation` | source | yes | yes | no | yes | no | 0 | | `CitationList` | source | yes | yes | no | yes | no | 0 | | `ConfirmDialog` | layout | yes | yes | no | yes | no | 0 | -| `DateDisplay` | source | yes | yes | inherited-global-root | yes | no | 2 | +| `DateDisplay` | source | yes | yes | inherited-global-root | yes | no | 3 | | `Disclosure` | layout | yes | yes | no | yes | no | 0 | | `DisclosureGroup` | layout | yes | yes | no | yes | no | 0 | | `DoseLine` | answer | yes | yes | no | yes | no | 0 | @@ -962,13 +962,13 @@ This generated snapshot is a local source-derived inventory. It does not assert | `InlineNotice` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `LinkAction` | controls | yes | yes | no | yes | no | 0 | | `LoadingPanel` | feedback | yes | yes | inherited-global-root | yes | no | 7 | -| `MissingValue` | feedback | yes | yes | inherited-global-root | yes | no | 2 | +| `MissingValue` | feedback | yes | yes | inherited-global-root | yes | no | 3 | | `OverlayRoot` | layout | yes | yes | inherited-global-root | yes | no | 1 | | `PageHeader` | layout | yes | yes | inherited-global-root | yes | no | 2 | | `Pagination` | controls | yes | yes | no | yes | no | 0 | | `PanelHeading` | layout | yes | yes | inherited-global-root | yes | no | 2 | | `Progress` | feedback | yes | yes | no | yes | no | 0 | -| `Quantity` | answer | yes | yes | no | yes | no | 0 | +| `Quantity` | answer | yes | yes | inherited-global-root | yes | no | 1 | | `RadioGroup` | controls | yes | yes | no | yes | no | 0 | | `RetrievalStateBanner` | answer | yes | yes | inherited-global-root | yes | no | 1 | | `SafeBoldText` | layout | yes | yes | inherited-global-root | yes | no | 8 | @@ -981,7 +981,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | | `SourceStatusBadge` | source | yes | yes | inherited-global-root | yes | no | 1 | | `StageList` | feedback | yes | yes | no | yes | no | 0 | -| `StatusMark` | source | yes | yes | inherited-global-root | yes | no | 1 | +| `StatusMark` | source | yes | yes | inherited-global-root | yes | no | 2 | | `Tabs` | controls | yes | yes | no | yes | no | 0 | | `TextField` | controls | yes | yes | inherited-global-root | yes | no | 3 | | `TextLink` | controls | yes | yes | no | yes | no | 0 | diff --git a/docs/design-system/adoption-contract.json b/docs/design-system/adoption-contract.json index 013f8767dd..fc9e4cd762 100644 --- a/docs/design-system/adoption-contract.json +++ b/docs/design-system/adoption-contract.json @@ -592,7 +592,7 @@ "status": "not-committed", "files": [] }, - "sanctionedSpecialPatterns": ["AnswerResultSurface", "VerificationNotice", "RetrievalStateBanner"], + "sanctionedSpecialPatterns": ["AnswerResultSurface", "RetrievalStateBanner"], "documentedDisposition": "Answers are rendered inside the shared ClinicalDashboard route rather than a standalone page route." }, { diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 8c2f0257ff..908dd988ce 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -131,8 +131,8 @@ "source": "src/components/ui/answer-card.tsx", "sourceExported": true, "entryExported": true, - "directImportFiles": [], - "productImportFiles": [], + "directImportFiles": ["src/components/clinical-dashboard/answer-result-surface.tsx"], + "productImportFiles": ["src/components/clinical-dashboard/answer-result-surface.tsx"], "designSync": { "listedInSourceMap": true, "listedInDtsProps": true, @@ -145,6 +145,7 @@ "tests/design-sync-contract.test.ts", "tests/design-sync-visual-exports.test.ts", "tests/design-system-adoption.test.ts", + "tests/ui-smoke.spec.ts", "tests/ui-v2-answer-safety.dom.test.tsx" ], "baseline": { @@ -155,8 +156,8 @@ }, "built": true, "locallyRegistered": true, - "v2ShellMounted": false, - "v2MountMode": "none", + "v2ShellMounted": true, + "v2MountMode": "inherited-global-root", "proofDeclared": true, "baselineCommitted": false }, @@ -471,6 +472,7 @@ "src/components/ui/verification-notice.tsx" ], "productImportFiles": [ + "src/components/ui/answer-card.tsx", "src/components/ui/retrieval-state-banner.tsx", "src/components/ui/verification-notice.tsx" ], @@ -978,7 +980,11 @@ "src/components/ui/answer-card.tsx", "src/components/ui/date-display.tsx" ], - "productImportFiles": ["src/components/AccessibleTable.tsx", "src/components/ui/date-display.tsx"], + "productImportFiles": [ + "src/components/AccessibleTable.tsx", + "src/components/ui/answer-card.tsx", + "src/components/ui/date-display.tsx" + ], "designSync": { "listedInSourceMap": true, "listedInDtsProps": true, @@ -1155,7 +1161,7 @@ "sourceExported": true, "entryExported": true, "directImportFiles": ["src/components/ui/answer-card.tsx"], - "productImportFiles": [], + "productImportFiles": ["src/components/ui/answer-card.tsx"], "designSync": { "listedInSourceMap": true, "listedInDtsProps": true, @@ -1175,8 +1181,8 @@ }, "built": true, "locallyRegistered": true, - "v2ShellMounted": false, - "v2MountMode": "none", + "v2ShellMounted": true, + "v2MountMode": "inherited-global-root", "proofDeclared": true, "baselineCommitted": false }, @@ -1214,11 +1220,8 @@ "source": "src/components/ui/retrieval-state-banner.tsx", "sourceExported": true, "entryExported": true, - "directImportFiles": [ - "src/components/clinical-dashboard/answer-result-surface.tsx", - "src/components/ui/answer-card.tsx" - ], - "productImportFiles": ["src/components/clinical-dashboard/answer-result-surface.tsx"], + "directImportFiles": ["src/components/ui/answer-card.tsx"], + "productImportFiles": ["src/components/ui/answer-card.tsx"], "designSync": { "listedInSourceMap": true, "listedInDtsProps": true, @@ -1640,7 +1643,7 @@ "src/components/ui/citation.tsx", "src/components/ui/retrieval-state-banner.tsx" ], - "productImportFiles": ["src/components/ui/retrieval-state-banner.tsx"], + "productImportFiles": ["src/components/ui/answer-card.tsx", "src/components/ui/retrieval-state-banner.tsx"], "designSync": { "listedInSourceMap": true, "listedInDtsProps": true, @@ -1853,11 +1856,8 @@ "source": "src/components/ui/verification-notice.tsx", "sourceExported": true, "entryExported": true, - "directImportFiles": [ - "src/components/clinical-dashboard/answer-result-surface.tsx", - "src/components/ui/answer-card.tsx" - ], - "productImportFiles": ["src/components/clinical-dashboard/answer-result-surface.tsx"], + "directImportFiles": ["src/components/ui/answer-card.tsx"], + "productImportFiles": ["src/components/ui/answer-card.tsx"], "designSync": { "listedInSourceMap": true, "listedInDtsProps": true, @@ -3046,17 +3046,17 @@ "files": [] }, "permittedComponentFamilies": ["answer", "controls", "feedback", "layout", "source"], - "sanctionedSpecialPatterns": ["AnswerResultSurface", "RetrievalStateBanner", "VerificationNotice"], + "sanctionedSpecialPatterns": ["AnswerResultSurface", "RetrievalStateBanner"], "roots": [ { "file": "src/components/clinical-dashboard/answer-result-surface.tsx", "exists": true, - "imports": ["RetrievalStateBanner", "Sheet", "VerificationNotice"], + "imports": ["AnswerCard", "Sheet"], "importedFamilies": ["answer", "layout"], "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": ["AnswerResultSurface", "VerificationNotice", "RetrievalStateBanner"] + "sanctionedPatternsPresent": ["AnswerResultSurface", "RetrievalStateBanner"] }, { "file": "src/components/clinical-dashboard/answer-thread-turn.tsx", @@ -3239,7 +3239,7 @@ "summary": { "registeredComponentCount": 53, "previewCount": 53, - "productImportedComponentCount": 27, + "productImportedComponentCount": 29, "rootCount": 55, "productionRouteCount": 47, "nextUiEntryCount": 99 diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index f4db5c6c42..e7650ab2a2 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -8,11 +8,7 @@ import { ClipboardCheck, ExternalLink, Layers, ShieldAlert } from "lucide-react" import { type AnswerFeedbackType } from "@/lib/answer-feedback"; import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { CrossModeLinksSection } from "@/components/clinical-dashboard/cross-mode-links"; -import { - isPreformattedGroundedAnswer, - NaturalLanguageAnswer, - UserQuestionBubble, -} from "@/components/clinical-dashboard/answer-content"; +import { isPreformattedGroundedAnswer, NaturalLanguageAnswer } from "@/components/clinical-dashboard/answer-content"; import { answerStateForAnswer } from "@/components/clinical-dashboard/answer-copy-payload"; import { AnswerSupportSummaryCard, @@ -28,9 +24,8 @@ import { } from "@/components/clinical-dashboard/evidence-panels"; import { citedDocumentHref } from "@/components/clinical-dashboard/source-actions"; import { CanonicalAnswerTables, MobileEvidenceSheetContent } from "@/components/clinical-dashboard/visual-evidence"; -import { RetrievalStateBanner } from "@/components/ui/retrieval-state-banner"; +import { AnswerCard, AnswerCardQueryEcho } from "@/components/ui/answer-card"; import { Sheet } from "@/components/ui/sheet"; -import { VerificationNotice } from "@/components/ui/verification-notice"; import { answerSurface, cn, iconTilePremium, subtleStatusPill } from "@/components/ui-primitives"; import { type AnswerRenderModel } from "@/lib/answer-render-policy"; import { type AppModeId } from "@/lib/app-modes"; @@ -204,6 +199,34 @@ function StagedAnswerResultSurfaceImpl({ weakEvidence, answerState, }); + // Built once so both arms of the `ready` / degraded split below stay identical. + // The split exists only because `AnswerCardProps` discriminates on `state` to make + // `onOpenSource` required for a degraded card (DECISIONS §Q1), and a union-typed + // `state` cannot narrow that at the call site. + const answerVerification = { + state: answerState.kind, + presentation: "responsive-compact" as const, + // From the quality tier, never from the state kind: #207 precedence lets + // stale/partial/ungrounded outrank source_only, so keying on the kind announced + // "AI-generated" directly above the Source-only disclosure saying no model wrote + // it (#228). + attribution: (answer.answerQualityTier === "source_only" ? "extractive" : "model") as "extractive" | "model", + sourceCount: "sourceCount" in answerState ? answerState.sourceCount : sourceCount, + }; + const answerProse = ( + + ); const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); @@ -211,9 +234,16 @@ function StagedAnswerResultSurfaceImpl({ return (
-
- - + {/* No outer p-2.5: AnswerCard is the raised surface (#216). Nesting panel + padding here stacked on the card's own pad and blew the phone short-answer + scroll budget (#227) by ~60px. */} +
+ {/* When a table aside is present, keep the query echo above the grid — the + same placement UserQuestionBubble had — so desktop tableTop aligns with + the card chrome rather than sitting ~40px above prose buried under the + in-card query+notice stack (ui-smoke clinical-table delta). Phone-only + answers without a table keep the echo inside AnswerCard. */} + {showLayoutAside ? : null}
- {/* Only where the banner says something the notice cannot. For - `stale_evidence` it names which sources are overdue and for - `partial_retrieval` how much was missed; for `ungrounded` and - `source_only` it restates the notice almost word for word, and - the live "Review source match" card below the answer states it a - third time. Measured on a one-sentence answer: three renderings - of one warning, eleven lines of caution around one line of - answer, 147px of scroll where the phone budget is 8 - (tests/ui-smoke.spec.ts:2056, ledger #227). Three identical - alarms teach a reader to skip all three, so the duplicate is the - dangerous one, not the missing one. */} - {answerState.kind === "stale_evidence" || answerState.kind === "partial_retrieval" ? ( - + {answerProse} + + ) : ( + , p. N". + verification={answerVerification} + query={showLayoutAside ? undefined : query} + // 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 + // is labelled "Open , p. N". onOpenSource={(sourceId, locator) => { const href = citedDocumentHref(sourceId, locator, [...sources, ...(answer.sources ?? [])]); if (href) router.push(href); }} - /> - ) : null} - + > + {answerProse} + + )} {showInlineSupportCard ? ( void; }; +/** + * Question echo owned by the answer shell. Live product may render this above a + * table-aside grid (so desktop tableTop stays aligned with the card, matching the + * old UserQuestionBubble placement) while still using the AnswerCard test id. + */ +export function AnswerCardQueryEcho({ query, className }: { query: string; className?: string }) { + const cleaned = query.trim(); + if (!cleaned) return null; + return ( +

+ Question: + {cleaned} +

+ ); +} + type AnswerCardBase = { /** System-owned verification wording. Required: a generated answer cannot render without it. */ verification: VerificationNoticeProps; @@ -68,6 +84,14 @@ export function AnswerCard({ onOpenSource, className, }: AnswerCardProps) { + // 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 + // past 180px once the query echo moved into this header. Keep the raised card chrome; + // reclaim the double vertical pad at the header/body seam and the loose gap-stack. + const panelX = "px-[var(--pad-panel,1.5rem)]"; + const panelY = "py-3"; + return (
-
- {query ? ( -

- Question: - {query} -

- ) : null} +
+ {query ? : null} {/* Above the prose and above the actions, in document order, on screen and on print alike. */} - {state.kind !== "ready" ? ( + {/* + * Ledger `#227` over `#207`, decided 3 Aug 2026. `#207` required a banner on + * every degraded state, on the reasoning that an adoption failure here is + * silent — "the card renders, the prose is fine, and the caution the product + * shows today is simply gone". That reasoning holds only where the banner is + * the sole carrier of the caution, and it is not: `VerificationNotice` above + * states `ungrounded` and `source_only` in words, and for those two kinds the + * banner restates it almost verbatim. `#227` measured the cost of the + * duplicate on a one-sentence answer — three renderings of one warning, + * eleven lines of caution around one line of answer, 147px of scroll against + * a phone budget of 8. Three identical alarms teach a reader to skip all + * three, so the duplicate is the dangerous outcome, not the omission. + * + * The banner survives for exactly the two kinds where it says something the + * notice cannot: `stale_evidence` names WHICH sources are overdue, + * `partial_retrieval` names HOW MUCH was missed. The caution itself is never + * lost for any kind — it is carried by the notice and by `data-state`. + * + * `onOpenSource` stays required for every degraded state (DECISIONS §Q1): a + * degraded answer must remain re-verifiable whether or not a banner renders. + */} + {state.kind === "stale_evidence" || state.kind === "partial_retrieval" ? ( void} @@ -96,7 +136,10 @@ export function AnswerCard({
{actions?.length ? ( -
+
{actions.map((action) => (