diff --git a/docs/answer-page-redesign-handover.md b/docs/answer-page-redesign-handover.md index 5a6536c5b..19f4b7c62 100644 --- a/docs/answer-page-redesign-handover.md +++ b/docs/answer-page-redesign-handover.md @@ -6,6 +6,9 @@ `/mockups/answer-chat-redesign` (the three-way comparison it was chosen from). **Owner decision on record:** direction A — numbered marks in the prose, one source drawer that opens from the bottom. +**Second pass:** `/mockups/answer-chat-perfected-v2` — the answer states the first pass +did not draw, plus four corrections listed in §12. Both routes are live; v1 is the +record of what was approved, v2 is what to build from. **Read §12 before §4.** This document is for whoever builds it. It is written to be executable without the design conversation: what to build, in what order, against which data, and what will @@ -43,9 +46,18 @@ which 20/30 (v18) and 21/30 (v19) answers were `source_only`. question set, and ledger `#231` is the open row about that fallback behaviour. But it is enough to conclude the thing that matters here: -> Numbered marks are an enhancement that appears on model-synthesis answers. +> Numbered marks are an enhancement that appears wherever an answer carries sections. > The rail and the drawer must work on every answer, including source-only ones. +**Corrected 2026-08-24 — do not gate marks on the quality tier.** The six fallback sites +above do set `answerSections: []`, but they are not the only route to `source_only`. +`applyProviderLabels` (`src/lib/rag/rag-extractive-answer.ts`) tags **any** model-less +`routingMode: "extractive"` answer `source_only`, and `buildExtractiveAnswer` passes +`answerSections: naturalAnswer.answerSections ?? []` straight through — so a deterministic +extractive answer can be `source_only` **and** carry sections with support levels. Gate marks +on the sections and their `supportLevel`, never on `answerQualityTier`. One rule, no special +case, and it fails closed: no sections, no marks. + That is why the rail and drawer ship first, and the marks second. --- @@ -66,6 +78,46 @@ goes when they do. --- +## 2b. The answer states this has to cover + +`AnswerState` (`src/lib/answer-state-types.ts`) has five kinds. The v1 mockup draws one. +Every one of these reaches this surface, and two of them change what the surface must +contain rather than just what it says: + +| State | What changes | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ready` | The drawn case. Marks where sections earn them, rail, drawer. | +| `source_only` | Often no `answerSections`, so no marks and the rail is the only route to a source (20/30 in the read cited in §1) — but **not always**: the extractive route is tagged `source_only` and can carry sections. Gate on sections, not on the tier. | +| `stale_evidence` | Adds a banner. Overdue documents are named on the rail card and in the drawer, never in the mark (§4). | +| `partial_retrieval` | Adds a banner, and sections resting on unretrieved documents are absent — drop the section, never the citation. | +| `ungrounded` | Renders as `source_only` plus a stronger notice. | + +Two consequences the design has to answer, both drawn in v2 panel one: + +- **The rail can never be the control that gets shrunk when space is tight.** On a + `source_only` answer it carries the entire reference system on its own. +- **A degraded `AnswerCard` requires `onOpenSource`** (`answer-result-surface.tsx`) — a + caution is never raised with nowhere to go. That route is the drawer, so the banner's + action opens it. + +**Ordering: the evidence arrives before the prose, but arrives unnumbered.** A `VerifiedEvidencePreviewUnit` +(sequence 0) lands after retrieval, ranking, owner-scope and governance, carrying the +trimmed sources, and the client already consumes it (`search-utils.ts`, +`onEvidencePreview`). The rail can therefore be on screen before there is an answer at all. +Then whole verified sections land — `VerifiedAnswerSectionUnit`, each with its own +`citations` and `supportLevel`. See §12.3. + +**But do not number the preview.** `buildEvidencePreviewUnit` emits the top slice of the +retrieval results in retrieval order; `buildAnswerRenderModel` independently rebuilds +`primarySources` from citations, quote cards, section citation ids and core source links, +then dedupes and caps by trust. Different sets, different order — so a number assigned at +preview time can point at a different document once the answer lands, which is exactly the +attribution failure this design exists to prevent. Render preview cards unnumbered and assign +numbers when the answer arrives, or define and test an explicit identity/order reconciliation +contract first. Minimal regression case: preview `[A, B]`, final citations `[B]`. + +--- + ## 3. Build order — three pull requests Each is independently revertible and independently useful. Do not combine them: @@ -104,9 +156,11 @@ how to draw it but **what a "claim" is**, and there is one answer that is safe t - **Ship: a mark per rendered section only after direct support is established.** Sections carry exact chunk ids, but those ids record what the model associated with the section; they do not prove that - every routine claim is directly supported. Gate direct marks on `supportedClaims` or - `evidenceAssessments`; partial or unsupported sections must suppress the mark or be explicitly - qualified as related sources. + every routine claim is directly supported. Gate direct marks on **`AnswerSection.supportLevel`** + (`src/lib/types.ts` — `direct | partial | nearby | unsupported`, already resolved per section and + carried on the streamed `VerifiedAnswerSectionUnit`), falling back to `supportedClaims` / + `evidenceAssessments`. The four levels need **four treatments, not two** — see §12.2. Partial or + unsupported sections must suppress the plain mark, never silently print one. - **Do not ship: sentence-level marks derived by matching answer prose to retrieved chunks after the fact.** A mark that points at a page not actually supporting the claim is worse than no mark, and this failure mode is already an open ledger row — @@ -143,10 +197,34 @@ the earlier margin-plus-padding read as a word space. Hover and focus paint property of the document, and two hues inside running prose make the eye stop twice. Status lives on the rail card and in the drawer. _(This is the one design decision the owner may still reverse — see §10.)_ -- **Touch target.** An absolutely positioned transparent child at `inset -14px -6px`. - It must not change the line box. Production tap targets in this repo are `min-h-12` - (48px); do not "correct" that to `min-h-11` — see `AGENTS.md`, it reintroduces a +- **Touch target — the v1 figures are wrong; use these.** An absolutely positioned + transparent child that must not change the line box, at `inset -7px -10px -8px -10px` + and **split across a cluster** (outer edges reach 10px, interior edges 2px). + `inset -14px -6px`, as v1 has it, overlaps the marks on the line above and below — + prose at this size sets on a ~25px line and 14+14+~10 is 38 — and inside a cluster the + ±6px extensions overlap across the comma, so a tap between two numbers can open the + wrong source. The v1 caption claiming "an invisible 44-pixel target" overstates what + that inset produces. + **An inline mark cannot reach this repo's 48px production standard** (`min-h-tap`) + without stealing the line above it. That is a real limit, and it is the argument for + the rail: every source is reachable a second time from a card at full tap size, and on + a `source_only` answer the rail is the _only_ route. Size the **rail** at `min-h-12`, + not the `min-h-11` v1 carries — mockups are exempt from that gate and production is + not. Never "correct" production to `min-h-11`: see `AGENTS.md`, it reintroduces a known `ui-smoke` flake. +- **Forced colors.** The active ring must be an `outline`, not a `box-shadow` — box + shadows are not painted in forced-colors, so v1's ring vanishes and an open mark is + indistinguishable from a closed one. The claim wash is a `background`, which is + remapped, so pair it with a non-colour cue (a 2px left rule on the claim) or the + mechanism that holds the reader's place disappears exactly for the users who need it + most. Verified in Chromium `forcedColors: "active"` on `/mockups/answer-chat-perfected-v2`, + not assumed; `tests/ui-accessibility.spec.ts` already emulates the mode. +- **The prose splitter will not survive real answers.** v1's `Claim` binds the last word + to the cluster with `block.text.lastIndexOf(" ")`. Production prose runs through + `SafeBoldText` and carries `**bold**`, so the final word can sit inside a span a string + split cannot reach. Bind the cluster to a trailing anchor emitted by the renderer, and + cap the cluster (`1,2,+2`) so a claim on four documents cannot produce an unbreakable + run wider than a phone column. - **Never strand.** The final word of a claim and the whole mark cluster are wrapped in one `whitespace-nowrap` span, so a number cannot fall alone onto the next line. - **Clusters.** Multiple sources render `1,2` with a `--text-soft` comma at @@ -173,7 +251,28 @@ chip, and one primary action (`Open page 14`). Natural height, capped at 78%; on desktop a centred panel at `max-width: 560px` rather than a full-width sheet. - Secondary actions (copy passage, scope search to document, ask about passage) live - behind the overflow menu. + behind the overflow menu, **plus one more: "This page doesn't support the claim."** + `evidence-panels.tsx` already ships the taxonomy (`wrong_source`, `missing_source`, + `numeric_error`, …) and `RagAnswer` carries `interactionId` / `feedbackToken`. Once a + number points at a specific page, the moment a clinician opens it and finds it does not + say that is the highest-value moment in the product to catch a bad citation, and v1 has + no feedback control anywhere. +- **Support comes back, as one clause of words** — not the pill v1 struck as "never + actionable". If support decides whether a claim may carry a number (§3), it is the most + actionable field on the surface, and the reader is owed the reason at the moment they + open the page it points at. One line: "This page states the claim directly." / + "This page supports part of the claim…" / "Related — this page does not state the claim." +- **The pager does not scale past four.** `answer-render-policy.ts` caps primary sources + at 6 for high trust; six 36px buttons plus prev, next, overflow and close need ~396px + inside a ~362px phone drawer. Keep the numeric pager while `N <= 4` — random access by + number beats stepping — and above that show `‹ N of M ›`, with the rail behind still + giving random access. +- **Build it on `src/components/ui/sheet.tsx`, do not hand-roll it.** `Sheet` already + portals into `OverlayRoot`, traps focus, returns it with late resolution, handles Escape + and backdrop-pointer-down discipline, and is a bottom sheet on phone and a centred + dialog from `sm:` up — which is this drawer's spec exactly. v1's version sets + `aria-modal="true"` without inerting the background and scopes its Escape handler to + _focus inside the dialog_, so Escape does nothing if focus lands elsewhere. - Left/right arrows and Escape are wired. The pager is the same control by mouse. - **The claim that owns the open source is washed in `--clinical-accent-soft`** while the drawer is open. This is not decoration: the drawer covers the lower third of a @@ -216,8 +315,8 @@ ordinary Tailwind utilities and design tokens. Never hardcode hex - `tests/ui-stress.spec.ts`, `tests/ui-tools.spec.ts` — same test ids. - `tests/source-preview-popover.dom.test.tsx` — 16 cases pinning the popover's positioning, portalling and focus behaviour. If the drawer replaces the popover, - these become tests of the drawer; port them rather than dropping them, especially - the focus-move and viewport-pinning cases. + these become tests of the drawer; port them **onto `Sheet`** rather than dropping them, + especially the focus-move and viewport-pinning cases. - `tests/answer-preferences.dom.test.tsx` — pins the `compactCitations` preference behaviour on the capsule label, including that the missing-source warning is **never** hidden in compact mode. Decide explicitly what `compactCitations` means @@ -236,7 +335,27 @@ ordinary Tailwind utilities and design tokens. Never hardcode hex 6. The mark's accessible name is distinct from the pager's ("Source 2, …" vs "Show source 2, …") so the two controls do not announce identically. 7. A routine partial or unsupported section renders no direct claim mark; only - `supportedClaims` / `evidenceAssessments` may establish direct attribution. + `supportLevel` / `supportedClaims` / `evidenceAssessments` may establish direct + attribution. +8. Each of the four `supportLevel` values renders its own treatment: `direct` a plain + number, `partial` a marked number, `nearby` a worded control that opens the drawer, + `unsupported` worded and **not** a control. +9. Two marks in a cluster have non-overlapping hit rectangles, and a mark's rectangle does + not overlap the marks on the adjacent lines. +10. The pager renders as a counter above four sources and does not overflow a 390px + viewport at six. +11. Under `forcedColors: "active"` the open mark is still distinguishable from a closed + one, and the claim owning the open source is still identifiable. +12. `stale_evidence` and `partial_retrieval` render their banner and its `onOpenSource` + route, and that route opens the drawer. `source_only` renders the compact disclosure + instead, and does not restate the verification notice. +13. A `source_only` answer that **does** carry sections still renders its marks — the gate is + the sections, not `answerQualityTier`. +14. Opening the drawer from the rail or the pager shows no claim-support sentence; opening it + from a mark shows that mark's section support, including when one source is cited by two + sections at different support levels. +15. Preview-stage source cards render unnumbered, and numbers appear only once the answer + lands. --- @@ -298,12 +417,19 @@ requirement — it costs a branch in the render path that has to be removed late ## 10. Open decisions for the owner +_Recommendations for all three are drawn in `/mockups/answer-chat-perfected-v2`; decide +against the frames rather than against the prose._ + 1. **Status in the mark.** The design deliberately keeps every mark one colour and puts document staleness on the rail card and in the drawer. If a claim resting on an out-of-date document should say so at the point of the claim, that is a one-line change in the mark component — but decide before PR 2, not after. 2. **What happens to `compactCitations`.** The preference currently shrinks the sources capsule. The capsule is going. Either retarget it at the rail or retire it. + _Recommendation (drawn in v2): retarget._ Compact collapses the rail to one + `Sources · 3` chip that expands. The invariant `tests/answer-preferences.dom.test.tsx` + pins — the missing-source warning is never hidden in compact mode — then still means + something, because the verification notice and any worded mark sit outside the rail. 3. **Clinical notes.** The design folds the clinical-notes sheet away. Confirm nothing in it is relied on before it goes; it is the one removed surface with content not obviously duplicated elsewhere. @@ -316,3 +442,69 @@ requirement — it costs a branch in the render path that has to be removed late - Ledger `#VXB8XA` — the attribution-accuracy row that rules out post-hoc sentence matching. - Ledger `#231` — the `source_only` fallback rate that decides how often marks appear. + +--- + +## 12. Errata against the first pass + +Found by reading the code, after this document was written. Each is a place where +following §4 literally produces something that will be sent back at review, or that does +not work. All four are drawn corrected at `/mockups/answer-chat-perfected-v2`. + +### 12.1 The verification line is on the wrong side of the answer + +v1 places its own `VerifyLine` — "AI-generated · check each number against its page" — +_below_ the prose. `answer-result-surface.tsx` records the opposite decision with its +reasons and issue numbers (#207, #227, #228): system-owned verification wording sits +**above** the prose in document order, on screen and print alike, and its attribution is +read from `answerQualityTier` precisely so it can never announce "AI-generated" above a +notice saying no model wrote this answer. Either the line replaces the `AnswerCard` +notice — a design-system change with its own review — or it goes. Do not ship it as drawn. + +### 12.2 `supportLevel` needs four treatments, not two + +v1 has a number and a worded "no source", with nothing between, while its drawer strikes +support as "never actionable". §3 requires marks to be gated on support, which makes it +the field that decides whether a number appears at all. Drawn in v2: + +| Level | Treatment | +| ------------- | --------------------------------------------------------------------------------- | +| `direct` | Plain number. | +| `partial` | Number plus one trailing glyph; the drawer says which part in words. | +| `nearby` | Worded "related" — still a control, because the reader should see what it says. | +| `unsupported` | Worded "no source" — a statement, not a control: there is nowhere for it to lead. | + +A dotted underline and a 1px bottom border were both tried for `partial` and **neither is +drawn** under a `0.7em` glyph with `line-height: 0` — checked in the browser. Use a glyph. + +### 12.3 The streaming frame draws a state this product refuses to be in + +v1 panel five shows a typewriter caret mid-sentence. `src/lib/answer-stream-contract.ts` +excludes `token` and `revising` events by name — "accepting those events would re-expose +unvalidated clinical prose" — and `docs/verified-answer-incremental-delivery-design.md` +rejects raw model-token delivery, provisional prose and in-place revision outright. That +frame cannot legitimately occur, so do not build a caret for it. + +What does occur is better, and it is the sequence v2 panel three draws: evidence preview +lands and the rail paints, then verified sections land whole. It is also buildable now — +the preview half already ships. + +### 12.4 Two corrections found in review of this document (2026-08-24) + +Both were raised against PR #2356 and both were verified in the code before being accepted. + +- **`source_only` does not imply section-less.** See the corrected block in §1. The invariant + as first written would have suppressed marks on extractive answers carrying perfectly good + section-level citation ids. +- **The evidence preview must not be numbered.** See the ordering block in §2b. Numbering it + and promising the number is stable would reintroduce, in the streaming path, the exact + wrong-page attribution the design forbids everywhere else. + +### 12.5 The table aside is an unacknowledged removal + +`data-testid="table-specific-answer-layout"` gives tables their own column on `lg:` today +(`answer-result-surface.tsx`). Folding tables into a chip inside the drawer removes that +column. That is a real change to a shipped layout: state it in the PR body and decide it, +rather than discovering it at review. + +--- diff --git a/docs/branch-review-records/6db09ce93d6b8a6699b2577fa59a13c9a24cc65aaec4825715094972f3ef0941.record.md b/docs/branch-review-records/6db09ce93d6b8a6699b2577fa59a13c9a24cc65aaec4825715094972f3ef0941.record.md new file mode 100644 index 000000000..071411ebc --- /dev/null +++ b/docs/branch-review-records/6db09ce93d6b8a6699b2577fa59a13c9a24cc65aaec4825715094972f3ef0941.record.md @@ -0,0 +1 @@ +| 2026-08-24 | PR #2346 | 586ee8182c7bee69385f47dc215ee0ddcfd5d9da | answer page redesign handover | Reviewed the handover and the perfected mockup against the live answer surface. Two core decisions (one-colour mark, one source per drawer) confirmed sound and kept. Seven findings; four are defects: overlapping mark tap targets can open the wrong source, box-shadow ring plus background wash both drop in forced-colors, the streaming frame draws a shape the stream contract excludes by name, and the verification line contradicts the placement answer-result-surface records (#207/#227/#228). Three gaps: only 1 of 5 AnswerState kinds drawn (source_only was ~2/3 of the cited sample), supportLevel needs four treatments not two, no citation-feedback control. Corrections landed in handover section 12 plus new section 2b; corrected design built at /mockups/answer-chat-perfected-v2 in PR #2356. No production surface changed. | lint, typecheck, test (831 files / 10011 passed), verify:pr-local, build, check:bundle-budget (mockups 522.0 KiB vs 487.6 KiB baseline, within tolerance), Chromium browser check at 390px and 1440px, Chromium forcedColors active | diff --git a/docs/site-map.md b/docs/site-map.md index e06fa4ff9..6e6bd9f81 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1149,6 +1149,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/accessible-table-browser-fixture` - Route discovered from app directory Source: `src/app/mockups/accessible-table-browser-fixture/page.tsx`. - `/mockups/also-matches-accents` - Route discovered from app directory Source: `src/app/mockups/also-matches-accents/page.tsx`. - `/mockups/answer-chat-perfected` - Route discovered from app directory Source: `src/app/mockups/answer-chat-perfected/page.tsx`. +- `/mockups/answer-chat-perfected-v2` - Route discovered from app directory Source: `src/app/mockups/answer-chat-perfected-v2/page.tsx`. - `/mockups/answer-chat-redesign` - Route discovered from app directory Source: `src/app/mockups/answer-chat-redesign/page.tsx`. - `/mockups/answer-evidence-popups` - Route discovered from app directory Source: `src/app/mockups/answer-evidence-popups/page.tsx`. - `/mockups/answer-home-proposal` - Route discovered from app directory Source: `src/app/mockups/answer-home-proposal/page.tsx`. diff --git a/src/app/mockups/answer-chat-perfected-v2/page.tsx b/src/app/mockups/answer-chat-perfected-v2/page.tsx new file mode 100644 index 000000000..f153ce528 --- /dev/null +++ b/src/app/mockups/answer-chat-perfected-v2/page.tsx @@ -0,0 +1,5 @@ +import { AnswerChatPerfectedV2MockupsPage } from "@/components/answer-chat-perfected-v2-mockups"; + +export default function AnswerChatPerfectedV2MockupRoute() { + return ; +} diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 51fc9c8a1..077c1c683 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -64,7 +64,8 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { // sits directly between them, so shared chrome would read as a second real // header and a second real search bar over the study. const isAnswerChatRedesignMockup = pathname === "/mockups/answer-chat-redesign"; - const isAnswerChatPerfectedMockup = pathname === "/mockups/answer-chat-perfected"; + const isAnswerChatPerfectedMockup = + pathname === "/mockups/answer-chat-perfected" || pathname === "/mockups/answer-chat-perfected-v2"; // Draws its own sticky chrome + device frames for /privacy; shared shell would // read as a second real header over the study. const isPrivacyPageDirectionsMockup = pathname === "/mockups/privacy-page-directions"; diff --git a/src/components/answer-chat-perfected-mockups.tsx b/src/components/answer-chat-perfected-mockups.tsx index f01abf20c..4adeaac89 100644 --- a/src/components/answer-chat-perfected-mockups.tsx +++ b/src/components/answer-chat-perfected-mockups.tsx @@ -42,20 +42,20 @@ import { cn } from "@/components/ui-primitives"; * Nothing here is wired to real retrieval. All copy is synthetic. */ -const focusRing = +export const focusRing = "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; /** Frame and mark geometry lives in JS: this repo's mockup CSS pipeline only * re-emits utilities that already exist in non-mockup source, so a novel * arbitrary value written inside a mockup file never reaches the stylesheet. */ -const PHONE_WIDTH = 390; -const PHONE_HEIGHT = 844; -const DESKTOP_HEIGHT = 660; -const PROSE_MEASURE = { maxWidth: "68ch" } as const; +export const PHONE_WIDTH = 390; +export const PHONE_HEIGHT = 844; +export const DESKTOP_HEIGHT = 660; +export const PROSE_MEASURE = { maxWidth: "68ch" } as const; -type SourceStatus = "current" | "review-due"; +export type SourceStatus = "current" | "review-due"; -type MockSource = { +export type MockSource = { id: string; index: number; short: string; @@ -68,7 +68,7 @@ type MockSource = { attachment?: { kind: "table" | "figure"; label: string }; }; -const SOURCES: MockSource[] = [ +export const SOURCES: MockSource[] = [ { id: "s1", index: 1, @@ -108,18 +108,18 @@ const SOURCES: MockSource[] = [ }, ]; -const sourceById = (id: string) => SOURCES.find((source) => source.id === id) ?? SOURCES[0]; +export const sourceById = (id: string) => SOURCES.find((source) => source.id === id) ?? SOURCES[0]; -type AnswerBlock = { +export type AnswerBlock = { id: string; text: string; sourceIds: string[]; safety?: boolean; }; -const QUESTION = "What physical health monitoring does a patient on clozapine need in the first year?"; +export const QUESTION = "What physical health monitoring does a patient on clozapine need in the first year?"; -const ANSWER_BLOCKS: AnswerBlock[] = [ +export const ANSWER_BLOCKS: AnswerBlock[] = [ { id: "b1", text: "Haematological monitoring is the part that cannot slip: FBC and ANC at baseline, weekly for the first 18 weeks, fortnightly to week 52, then monthly while treatment continues.", @@ -189,7 +189,7 @@ export const MARK_VARIANTS: Array<{ * carry a real tap target without disturbing the line, and show which * source is currently open. */ -function RefMark({ +export function RefMark({ source, variant, active, @@ -305,7 +305,7 @@ function RefMark({ * cluster are bound into one non-breaking run so a mark can never be * stranded at the start of the next line. */ -function Claim({ +export function Claim({ block, variant, activeSourceId, @@ -358,7 +358,7 @@ function Claim({ /* ══════════════════════ the source rail ══════════════════════ */ -function statusLabel(status: SourceStatus) { +export function statusLabel(status: SourceStatus) { return status === "current" ? "Current" : "Review due"; } @@ -371,7 +371,7 @@ function statusLabel(status: SourceStatus) { * separate object beside the card rather than part of it, and the badge was * already carrying the same signal. */ -function SourceRail({ activeId, onOpen }: { activeId: string | null; onOpen: (id: string) => void }) { +export function SourceRail({ activeId, onOpen }: { activeId: string | null; onOpen: (id: string) => void }) { return ( + + ); +} + +/* ══════════════════════ answer-level chrome ══════════════════════ */ + +/** + * The verification notice sits ABOVE the prose. + * + * The first pass put a quiet line underneath it. `answer-result-surface.tsx` + * records the opposite, with reasons: system-owned verification wording is + * placed above the answer in document order, on screen and print alike, and its + * attribution is read from `answerQualityTier` so it can never announce + * "AI-generated" above a notice saying no model wrote this. Drawn below, this + * design would be sent back at review. + */ +function VerificationNotice({ kind, sourceCount }: { kind: AnswerStateKind; sourceCount: number }) { + const model = kind !== "source_only"; + return ( +

+ {model + ? `AI-generated from ${sourceCount} cited sources · check each number against its page` + : "Assembled from your documents · check each statement against its source"} +

+ ); +} + +/** + * `stale_evidence` and `partial_retrieval` carry the full banner: each says + * something the verification notice cannot, and each owes a route back to a + * cited page, because a degraded `AnswerCard` requires `onOpenSource` — a + * caution is never raised with nowhere to go. + * + * `source_only` does not get that treatment. Production carries it as a compact + * amber disclosure that expands (`source-only-disclosure` in + * `answer-content.tsx`), and the notice above already names the attribution, so + * a full banner here would print the same sentence twice (`#227`). Compact, and + * no route of its own: on this state the rail is the route. + */ +function SourceOnlyDisclosure() { + const [open, setOpen] = useState(false); + return ( +
+ + {open ? ( +

+ Assembled from your documents without the AI model, so it may be less complete. Verify dose, threshold, timing + and monitoring against the cited passages. +

+ ) : null} +
+ ); +} + +function StateBanner({ kind, onOpenSource }: { kind: AnswerStateKind; onOpenSource: () => void }) { + if (kind === "source_only") return ; + if (kind === "ready") return null; + const copy: Record< + Exclude, + { title: string; body: string; action: string } + > = { + stale_evidence: { + title: "1 of 3 sources is past its review date", + body: "Source 3 has not been reviewed since 2023. The metabolic claim rests on it and may not reflect current practice.", + action: "Open source 3", + }, + partial_retrieval: { + title: "Retrieved 2 of 4 selected sources", + body: "Two documents could not be read for this answer. What is below rests on the two that were, and the metabolic track is not covered.", + action: "Open source 1", + }, + }; + const { title, body, action } = copy[kind]; + return ( +
+

+

+

{body}

+ +
+ ); +} + +function ActionRow() { + return ( +
+ {[ + { key: "copy", label: "Copy", Icon: Copy }, + { key: "ask", label: "Follow up", Icon: CornerUpLeft }, + ].map(({ key, label, Icon }) => ( + + ))} +
+ ); +} + +/* ══════════════════════ the message ══════════════════════ */ + +/** Sections keep their headings. The payload delivers them and the first pass + * discarded them: `bottom_line` leads unheaded, the timing sections take a + * quiet run-in label so the prose still reads as prose rather than as a form, + * and `source_gap` is the natural home for the worded mark. */ +function SectionProse({ + sections, + pool, + activeId, + onOpen, +}: { + sections: V2Section[]; + pool: V2Source[]; + activeId: string | null; + onOpen: (id: string, sectionId?: string | null) => void; +}) { + return ( +
+ {sections.map((section) => { + const lit = activeId !== null && section.sourceIds.includes(activeId); + return ( +

+ {section.safety ? ( +

+ ); + })} +
+ ); +} + +/** + * Partial retrieval read two of four documents, so the metabolic track — the + * one resting on source 3 — is simply not in the answer. A mark pointing at a + * source the answer never retrieved is the failure this whole design exists to + * avoid, so the frame drops the section rather than the citation. + * + * `source_only` carries none here, but **do not read that as an invariant.** + * `applyProviderLabels` tags any model-less `routingMode: "extractive"` answer + * `source_only`, and `buildExtractiveAnswer` passes `answerSections` straight + * through — so a source-only answer can arrive WITH sections and their support + * levels. Marks are therefore gated on the sections themselves, never on the + * quality tier. One rule, no special case. + */ +const SECTIONS_BY_STATE: Record = { + ready: SECTIONS, + source_only: [], + stale_evidence: SECTIONS, + partial_retrieval: [SECTIONS[0], SECTIONS[1], SECTIONS[2], SECTIONS[4]], +}; + +function AnswerScreen({ + kind, + wide, + pool = THREE_SOURCES, + compactRail = false, + initialOpenId = null, + /** The claim whose mark opened the drawer. Null models a rail or pager open, + * which carries no claim — and the drawer then says so rather than guessing. */ + initialSectionId = null, +}: { + kind: AnswerStateKind; + wide: boolean; + pool?: V2Source[]; + compactRail?: boolean; + initialOpenId?: string | null; + initialSectionId?: string | null; +}) { + const [openId, setOpenId] = useState(initialOpenId); + const [openSectionId, setOpenSectionId] = useState(initialSectionId); + const sections = SECTIONS_BY_STATE[kind]; + // Gated on the sections, not on `kind`. See SECTIONS_BY_STATE. + const marks = sections.length > 0; + // Support belongs to a claim, not to a source: one document can carry a claim + // directly and another only partly. Resolve it from the section whose mark was + // pressed, and leave it null when the drawer was opened from the rail or the + // pager — those carry no claim, and inventing one is the exact failure this + // design exists to prevent. + const openSupport = + openSectionId === null ? null : (sections.find((section) => section.id === openSectionId)?.support ?? null); + const openSource = (id: string, sectionId?: string | null) => + setOpenId((current) => { + const next = current === id ? null : id; + setOpenSectionId(next === null ? null : (sectionId ?? null)); + return next; + }); + + return ( + <> + +
+
+ +
+ +
+ + openSource(pool[kind === "stale_evidence" ? 2 : 0].id, null)} + /> + {marks ? ( + + ) : ( +

+ {SOURCE_ONLY_PROSE} +

+ )} + openSource(id, null)} /> + +
+
+
+
+ + { + setOpenId(id); + setOpenSectionId(null); + }} + onClose={() => { + setOpenId(null); + setOpenSectionId(null); + }} + wide={wide} + /> + + ); +} + +/* ══════════════════════ evidence first ══════════════════════ */ + +/** + * What actually arrives before the prose. + * + * `answer-stream-contract.ts` excludes token events by name, and the + * incremental-delivery design rejects raw token delivery, provisional prose and + * in-place revision outright: any of them can put a dose on screen before the + * safety gates can take it off. So the typewriter caret the first pass drew is + * not a state this product can be in. + * + * The evidence preview is. It lands after retrieval, ranking, owner-scope and + * governance, it carries the trimmed sources, and the client already consumes + * it. The rail can be numbered and on screen before there is an answer to + * number — the fastest useful content this surface has, and nobody had drawn it. + */ +function PendingScreen({ stage }: { stage: "asked" | "evidence" | "answered" }) { + return ( + <> + +
+
+ +
+ +
+ {stage === "asked" ? ( +

+ Searching your documents… +

+ ) : ( + <> +

+ {stage === "evidence" ? "3 sources found · writing the answer…" : null} +

+ {stage === "answered" ? : null} + {stage === "answered" ? ( + undefined} + /> + ) : ( + + )} + undefined} + unnumbered={stage !== "answered"} + /> + + )} +
+
+
+
+ + + ); +} + +/* ══════════════════════ specimens ══════════════════════ */ + +const SUPPORT_ROWS: Array<{ level: SupportLevel; label: string; rule: string }> = [ + { level: "direct", label: "Direct", rule: "The section's pages state the claim. It earns a plain number." }, + { + level: "partial", + label: "Partial", + rule: "The pages carry part of it. The number takes an asterisk, and the drawer says which part in words.", + }, + { + level: "nearby", + label: "Nearby", + rule: "Retrieved, related, does not state the claim. No number — a number here is worse than none — but still a way in, because the reader should be able to see what it does say.", + }, + { + level: "unsupported", + label: "None", + rule: "Absent from the library. A statement, not a control: there is nowhere for it to lead.", + }, +]; + +/** + * The vertical constraint, at reading size. + * + * Two marks landing on consecutive lines is the case that decides how tall an + * inline target may be. The first pass reached 14px above and below a ~10px + * glyph set on a ~25px line, so these two boxes overlapped each other by a few + * pixels — and where they overlap, the tap goes to whichever paints last rather + * than to the one under the finger. + */ +function ConsecutiveLineSpecimen() { + return ( +
+

+ Marks on consecutive lines +

+

+ undefined} + />{" "} + undefined} + /> +

+

+ Both numbers are live controls and their targets meet without overlapping. Under the first pass’s geometry + the upper target reached about 3px past the baseline of the line below it. +

+
+ ); +} + +/** Both paints together, because forced-colors is only judged against what it + * replaces. The right-hand column resolves `Canvas` and `CanvasText`, which is + * what the OS substitutes when a clinician turns high contrast on. */ +function ForcedColorsSpecimen() { + return ( +
+ {( + [ + ["As designed", false], + ["As high contrast paints it", true], + ] as const + ).map(([caption, forced]) => ( +
+

+ {caption} +

+

+ Troponin and CRP at baseline and weekly for the first four weeks + + 2 + + . +

+

+ {forced + ? "The outline and the left rule survive. A box-shadow ring and a background wash — what the first pass used — would both be dropped here, leaving open and closed marks identical." + : "Open mark and its claim. The ring is an outline and the wash is paired with a left rule, so neither signal depends on paint that forced-colors removes."} +

+
+ ))} +
+ ); +} + +const RECONCILE: Array<[string, string, string]> = [ + [ + "Verification notice", + "First pass put a quiet line under the answer.", + "Moved above the prose. `answer-result-surface.tsx` records that placement and reads its attribution from `answerQualityTier`, so it cannot announce AI authorship above a source-only notice.", + ], + [ + "Table aside", + "`table-specific-answer-layout` gives tables their own column on wide screens today.", + "The design folds tables into a chip in the drawer, which removes that column. A real removal — decide it, do not let it happen quietly.", + ], + [ + "compactCitations", + "The preference shrinks a capsule this design deletes.", + "Retargeted at the rail: collapse to one chip that expands. The pinned invariant — the missing-source warning is never hidden — still holds, because the notice and any worded mark sit outside the rail.", + ], + [ + "Clinical notes sheet", + "Folded away with nothing obviously carrying its content.", + "Its three tabs are Essentials, Actions and Safety. Actions and Safety already have homes; confirm Essentials is duplicated before it goes.", + ], + [ + "Answer feedback", + "No way to report a citation that does not support its claim.", + "One item in the drawer menu, mapping to the `wrong_source` type the feedback taxonomy already ships.", + ], +]; + +/* ══════════════════════ page ══════════════════════ */ + +export function AnswerChatPerfectedV2MockupsPage() { + return ( +
+
+
+
+ +

+ Clinical KB · answer page · direction A, second pass +

+
+

+ The states, not the specimen +

+

+ The first pass is kept, including both decisions it argues for: one colour for the mark, one source at a + time in the drawer. What it drew was a single answer — model-written, confident, every claim numbered. The + payload produces at least five, and on the only measurement in the handover the one it never drew was the + most common. This page draws the rest of them, and corrects four things that were checked against the code. +

+
+ + +
+ {( + [ + ["Model synthesis · numbered", "ready"], + ["Source-only · this one has no sections", "source_only"], + ["Stale evidence · banner + route", "stale_evidence"], + ["Partial retrieval · one track missing", "partial_retrieval"], + ] as const + ).map(([caption, kind]) => ( + + + + ))} +
+

+ Note what the second frame proves. With no numbers in the prose, every route to a source runs through the + rail — so the rail cannot be treated as a summary of the marks, and it cannot be the control that gets + shrunk when space is tight. +

+

+ One correction to the handover, found while building this:{" "} + + source-only does not mean section-less. + {" "} + The deterministic extractive route passes answerSections straight through + and is then tagged source-only for having no model, so that answer can arrive with sections and support + levels intact. Marks are gated on the sections, never on the quality tier — one rule rather than a special + case, and it means a source-only answer sometimes does carry numbers. +

+
+ + +
+ +
+ {SUPPORT_ROWS.map(({ level, label, rule }) => ( +
+

+ {label} +

+

+ undefined} + /> +

+

{rule}

+
+ ))} +
+
+ +
+

+ Cardiac surveillance runs alongside the bloods: troponin and CRP at baseline and weekly for the first + four weeks + undefined} + /> +

+

+ Vertical reach is now the line box and no more — about 25px, not the 48px this repo requires of + production controls. An inline mark cannot have 48px without stealing the line above it, and that is + not a limitation to design around; it is the reason every source is reachable a second time from a + rail card at full tap size. Cluster targets split rather than overlap, and a claim on more than two + documents shows +1 instead of growing an unbreakable run. +

+ +
+
+
+
+ +
+
+ + +
+ {( + [ + ["1 · asked", "asked"], + ["2 · evidence preview lands", "evidence"], + ["3 · verified answer lands", "answered"], + ] as const + ).map(([caption, stage]) => ( + + + + ))} +
+

+ The sources are on screen before the answer is, and deliberately not numbered yet. The preview is + the top slice of the retrieval results in retrieval order; the final list is rebuilt from what the answer + actually cites and capped by how far the answer is trusted. Those are different sets in a different order, + so a number assigned here could point at a different document once the answer lands. Numbering is what + arrival buys. Sections then land whole and already verified — never a claim that renumbers as more text + arrives, which is the failure the first pass was trying to draw with a caret. +

+
+ + +
+
+ + + +
+
+ + + +
+

What came back, and why

+
+ {[ + [ + "Support, as one clause of words", + "Not a pill. If support decides whether the claim gets a number, the reader is owed the reason at the moment they open the page it points at. It is scoped to the claim whose mark was pressed, so one document cited by two claims can be direct for one and partial for the other — and a drawer opened from the rail says plainly that it carries no claim at all rather than inventing one.", + ], + [ + "A way to say the page doesn't support the claim", + "The feedback taxonomy already ships wrong_source. This is the moment a bad citation is catchable, and there was no control for it anywhere.", + ], + [ + "A counter above four sources", + "The numeric pager is kept where it fits, because random access by number is better than stepping. It just cannot fit six.", + ], + ].map(([title, body]) => ( +
+
{title}
+
{body}
+
+ ))} +
+
+
+
+
+ + +
+ + undefined} /> + + +
+ undefined} compact /> +

Compact — tap to expand.

+ undefined} /> +
+
+
+
+ + +
+ + + + {["Surface", "Today", "This design"].map((heading) => ( + + ))} + + + + {RECONCILE.map(([surface, today, now]) => ( + + + + + + ))} + +
+ {heading} +
+ {surface} + {today}{now}
+
+

+ The build order in the handover still holds: the rail and the drawer first, on every answer, then marks + where sections earn them. This page changes what “every answer” has to mean. +

+
+
+
+ ); +}