Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/answer-page-redesign-handover.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
15 changes: 0 additions & 15 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1981,21 +1981,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;
Expand Down
20 changes: 16 additions & 4 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -3807,9 +3813,15 @@ function ClinicalDashboardContent({
) : null
) : null}

{showUniversalAlsoMatches && activeModeResultKind === "answer" ? (
<UniversalSearchAlsoMatches modeId={searchMode} query={universalAlsoMatchesQuery} />
) : 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. */}
</section>

{showSystemNotice && answer ? (
Expand Down
20 changes: 11 additions & 9 deletions src/components/clinical-dashboard/answer-content.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -461,15 +461,17 @@ export function NaturalLanguageAnswer({
<section
data-testid="plain-answer-response"
aria-label="Primary natural-language answer"
className="relative grid grid-cols-[auto_minmax(0,1fr)] gap-2 rounded-lg border border-transparent bg-transparent px-1 py-0.5 text-[color:var(--text-heading)]"
/* No assistant badge, and therefore no column reserved for one. The tile
was decorative (`aria-hidden`) and its column cost ~2.75rem of every
line of a clinical answer on a 390px phone. There are two speakers on
this surface, the person's turn is already a right-aligned bubble, and
the answer is the one element here that wants the full measure. What
identifies the turn instead is the verification line above it, which is
information rather than decoration. Approved by the owner 2026-08-26
against /mockups/answer-chat-perfected-v2; see §12.6 of
docs/answer-page-redesign-handover.md. */
className="relative rounded-lg bg-transparent py-0.5 text-[color:var(--text-heading)]"
>
<span
data-testid="answer-clinical-icon"
className="mt-1 grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-[color:var(--clinical-accent)]/25 bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)]"
aria-hidden="true"
>
<ShieldCheck aria-hidden="true" className="size-icon-lg" />
</span>
<div className="min-w-0 space-y-1">
<p className={chatAnswerText}>
{/* One span per sentence, joined by a single space, so the prose's
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
<AnswerSuggestionChips
suggestions={suggestions}
onPick={onPick}
disabled={disabled}
label="Try next"
labelPlacement={layout === "wrap" ? "above" : "inline"}
testId={testId}
layout={layout}
className={className}
/>
);
}
if (!suggestions.length) return null;
return (
<AnswerSuggestionChips
suggestions={suggestions}
onPick={onPick}
disabled={disabled}
label="Try next"
labelPlacement={layout === "wrap" ? "above" : "inline"}
testId={testId}
layout={layout}
className={className}
/>
<section data-testid={testId} aria-label="Follow-up questions" className={cn("min-w-0", className)}>
<p className="mb-1.5 text-3xs font-semibold uppercase tracking-eyebrow text-[color:var(--text-muted)]">
Ask next
</p>
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-raised)]">
{suggestions.map((suggestion, index) => (
<button
key={suggestion}
type="button"
onClick={() => onPick(suggestion)}
disabled={disabled}
className={cn(
"flex min-h-12 w-full items-center gap-2 px-3 text-left text-xs font-medium leading-5 text-[color:var(--text-heading)] transition hover:bg-[color:var(--surface-subtle)] disabled:opacity-60",
index > 0 && "border-t border-[color:var(--border)]",
focusRing,
)}
>
<span className="min-w-0 flex-1">{suggestion}</span>
<ChevronRight aria-hidden="true" className="size-icon-xs shrink-0 text-[color:var(--text-muted)]" />
</button>
))}
</div>
</section>
);
}
28 changes: 15 additions & 13 deletions src/components/clinical-dashboard/answer-result-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,22 +300,24 @@ function StagedAnswerResultSurfaceImpl({
/>
) : null}

{crossModeQueries?.length && onCrossModeSearch ? (
<CrossModeLinksSection
queries={crossModeQueries}
onModeSearch={onCrossModeSearch}
variant="responsive-compact"
{/* Follow-ups first, and on every width. They were wrapped in
`hidden sm:block`, so the most likely next tap on this surface
was desktop-only — the phone got the two "where else does this
appear" panels and none of the questions. Asking the next
question is the clinical step; browsing the library is not, so
the questions lead and the matches collapse to one line beneath
them (owner decision, 2026-08-26, "direction B"). */}
{followUpSuggestions?.length && onPickFollowUpSuggestion ? (
<AnswerFollowUpSuggestions
suggestions={followUpSuggestions}
onPick={onPickFollowUpSuggestion}
disabled={followUpSuggestionsDisabled}
layout="rows"
/>
) : null}

{followUpSuggestions?.length && onPickFollowUpSuggestion ? (
<div className="hidden sm:block">
<AnswerFollowUpSuggestions
suggestions={followUpSuggestions}
onPick={onPickFollowUpSuggestion}
disabled={followUpSuggestionsDisabled}
/>
</div>
{crossModeQueries?.length && onCrossModeSearch ? (
<CrossModeLinksSection queries={crossModeQueries} onModeSearch={onCrossModeSearch} variant="line" />
) : null}
</div>
</div>
Expand Down
93 changes: 90 additions & 3 deletions src/components/clinical-dashboard/cross-mode-links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -183,6 +183,89 @@ export function CrossModeLinksSection({
return <CrossModeLinksStrip links={links} onModeSearch={handleModeSearch} query={telemetryQuery} variant={variant} />;
}

/**
* 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 (
<section aria-label="Related pages in other modes" data-testid="cross-mode-links" className="min-w-0">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={panelId}
data-testid="cross-mode-links-line-trigger"
className={cn(
"flex min-h-12 w-full items-center gap-2 rounded-xl border border-[color:var(--border)] px-3 text-left transition hover:bg-[color:var(--surface-subtle)]",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
)}
>
<span className={cn(eyebrowText, "shrink-0")}>Also in your library</span>
<span className="min-w-0 flex-1 truncate text-2xs text-[color:var(--text-muted)]">
{preview.join(" · ")}
{rest > 0 ? ` · +${rest}` : null}
</span>
<ChevronDown
aria-hidden="true"
className={cn(
"size-icon-xs shrink-0 text-[color:var(--text-muted)] transition-transform",
open && "rotate-180",
)}
/>
</button>
{/* 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. */}
<div
id={panelId}
role="list"
className={cn(
"cross-mode-links-rail mt-1.5 grid min-w-0 gap-1.5 md:flex md:max-w-full md:flex-wrap md:gap-2",
!open && "hidden",
)}
data-testid="cross-mode-links-rail"
>
{links.map((link) => (
<CrossModeLinkCard
key={`${link.modeId}:${link.slug}`}
link={link}
Icon={appModeIcons[link.modeId]}
query={query}
onModeSearch={onModeSearch}
/>
))}
</div>
</section>
);
}

export function CrossModeLinksStrip({
links,
onModeSearch,
Expand All @@ -197,6 +280,10 @@ export function CrossModeLinksStrip({
}) {
if (links.length === 0) return null;

if (variant === "line") {
return <CrossModeLinksLine links={links} onModeSearch={onModeSearch} query={query} />;
}

const compact = variant === "compact";
const responsiveCompact = variant === "responsive-compact";
const LinkItem = compact ? CrossModeLinkChip : CrossModeLinkCard;
Expand Down
Loading
Loading