From db98bf0698537e261dd031db47d70b08e895e244 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 26 Aug 2026 22:13:20 +0000
Subject: [PATCH 1/6] mockup(answer): redraw the wait to match the answer it
precedes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The answer surface was quietened in #2386 and #2388 — the assistant tile off
the turn, one number per claim, the safety rail's colour moved into its own
icon, two panels made one. The loading state was not in that pass, so
AnswerProgressStepper still owns the four to twelve seconds before every answer
with a filled accent panel, a 36px icon tile, a five-circle stepper, four
connecting rails, a scrolling ECG trace, a per-second elapsed counter and a
Processing details disclosure. It is now the loudest element on the surface,
and it is the first thing a reader sees.
New study at /mockups/answer-loading-redesign, in four panels.
Panel One draws the real AnswerProgressStepper — imported from answer-status.tsx
rather than redrawn, so the comparison is against what ships and not against an
unflattering approximation. Both densities, counter live. Six observations
travel with it, of which the substantive one is the last: the evidence preview
already crosses the stream boundary before the prose, trimmed and owner-scoped
and consumed by the client today, and the surface currently spends that arrival
on a progress bar about the fact that it arrived.
Panel Two runs three directions off one shared eleven-beat clock, because a
loading state cannot be judged from a still. A scrubs to any beat and pauses.
Direction A is what answer-chat-perfected-v2 already drew, held for the whole
wait. Direction B adds the sources arriving — each lands in the rail as it is
found, carrying a dot rather than a number, because the evidence preview is
retrieval-ordered while the final list is rebuilt from what the answer cites,
so a number assigned during the wait can end up pointing at a different
document. Direction C names each document as it is opened and is drawn to be
rejected: retrieval opens far more documents than the answer cites, so a reader
who watches six titles go past takes all six to be behind the answer.
B is recommended. All three end on the same arrived answer and the same
numbered rail, so only the wait is being compared.
Panel Three is B at the 68ch desktop measure, with the copy for each beat mapped
to the stage that already emits it, and a list of what is deliberately dropped.
Panel Four draws the five states the current stepper renders identically —
slow, nothing found, assembled without the model, stopped, reduced motion.
Design scratch only. No production surface changes: answer-status.tsx still
ships the stepper drawn in Panel One, and removing it is the follow-up once a
direction is chosen.
Verified: tsc --noEmit clean; eslint clean on all three changed files; prettier
clean; check:design-system-contract passed (55 components, 76 roots, no ratchet
moved); route-reachability, site-map, bundle-budget and calculator-mockup-boundary
suites 62 passed (4 files); page rendered at 1440px in Chromium with zero console
errors, scrubbed and screenshotted at the searching and arrived beats.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_012kHT2YKCNnUrVckTaJW6ga
---
docs/site-map.md | 1 +
.../mockups/answer-loading-redesign/page.tsx | 5 +
src/app/mockups/mockups-layout-client.tsx | 7 +
.../answer-loading-redesign-mockups.tsx | 1037 +++++++++++++++++
4 files changed, 1050 insertions(+)
create mode 100644 src/app/mockups/answer-loading-redesign/page.tsx
create mode 100644 src/components/answer-loading-redesign-mockups.tsx
diff --git a/docs/site-map.md b/docs/site-map.md
index 687e0c6c5f..a737bb4847 100644
--- a/docs/site-map.md
+++ b/docs/site-map.md
@@ -1139,6 +1139,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir
- `/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`.
+- `/mockups/answer-loading-redesign` - Route discovered from app directory Source: `src/app/mockups/answer-loading-redesign/page.tsx`.
- `/mockups/calculators-bedside-sheet` - Route discovered from app directory Source: `src/app/mockups/calculators-bedside-sheet/page.tsx`.
- `/mockups/calculators-clinical-console` - Route discovered from app directory Source: `src/app/mockups/calculators-clinical-console/page.tsx`.
- `/mockups/calculators-directory-grid` - Route discovered from app directory Source: `src/app/mockups/calculators-directory-grid/page.tsx`.
diff --git a/src/app/mockups/answer-loading-redesign/page.tsx b/src/app/mockups/answer-loading-redesign/page.tsx
new file mode 100644
index 0000000000..918b1c9297
--- /dev/null
+++ b/src/app/mockups/answer-loading-redesign/page.tsx
@@ -0,0 +1,5 @@
+import { AnswerLoadingRedesignMockupsPage } from "@/components/answer-loading-redesign-mockups";
+
+export default function AnswerLoadingRedesignMockupRoute() {
+ return ;
+}
diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx
index 6149ceb96e..42bf0b2e09 100644
--- a/src/app/mockups/mockups-layout-client.tsx
+++ b/src/app/mockups/mockups-layout-client.tsx
@@ -66,6 +66,11 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) {
const isAnswerChatRedesignMockup = pathname === "/mockups/answer-chat-redesign";
const isAnswerChatPerfectedMockup =
pathname === "/mockups/answer-chat-perfected" || pathname === "/mockups/answer-chat-perfected-v2";
+ // The loading study draws its own top bar, transcript and composer inside every
+ // phone and desktop frame, and its whole subject is what occupies the answer
+ // column before the answer. Shared chrome above those frames would read as a
+ // second real header and a second real search bar over the study.
+ const isAnswerLoadingRedesignMockup = pathname === "/mockups/answer-loading-redesign";
// 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";
@@ -177,6 +182,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) {
!isAnswerHomeProposalMockup &&
!isAnswerChatRedesignMockup &&
!isAnswerChatPerfectedMockup &&
+ !isAnswerLoadingRedesignMockup &&
!isPrivacyPageDirectionsMockup &&
!isPrivacyLiveSignalPerfectedMockup &&
!isSearchLensMenuMockup &&
@@ -208,6 +214,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) {
!isAnswerHomeProposalMockup &&
!isAnswerChatRedesignMockup &&
!isAnswerChatPerfectedMockup &&
+ !isAnswerLoadingRedesignMockup &&
!isPrivacyPageDirectionsMockup &&
!isPrivacyLiveSignalPerfectedMockup &&
!isSearchLensMenuMockup &&
diff --git a/src/components/answer-loading-redesign-mockups.tsx b/src/components/answer-loading-redesign-mockups.tsx
new file mode 100644
index 0000000000..ce9ddb5b2e
--- /dev/null
+++ b/src/components/answer-loading-redesign-mockups.tsx
@@ -0,0 +1,1037 @@
+"use client";
+
+import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react";
+import { FileText, Pause, Play, RotateCcw, Search, Square } from "lucide-react";
+
+import type { TimedAnswerProgressUpdate } from "@/components/clinical-dashboard/answer-progress";
+import { AnswerProgressStepper } from "@/components/clinical-dashboard/answer-status";
+import {
+ Composer,
+ DesktopFrame,
+ DetailCard,
+ Panel,
+ PhoneFrame,
+ PROSE_MEASURE,
+ TopBar,
+ UserTurn,
+ focusRing,
+ type SourceStatus,
+} from "@/components/answer-chat-perfected-mockups";
+import { cn } from "@/components/ui-primitives";
+
+/**
+ * The wait, redrawn to match the answer it precedes.
+ *
+ * `/mockups/answer-chat-perfected-v2` and the two PRs that landed from it
+ * (#2386, #2388) spent their whole argument on subtraction: the assistant
+ * avatar came off the turn because a ~2.75rem column cost every line of a
+ * clinical answer on a 390px phone; the safety card lost a warning-coloured
+ * rule that spanned two controls carrying no state; the second copies of the
+ * follow-up chips and the also-matches panel were deleted. What is left is
+ * quiet — muted 2xs status text, one accent, hairlines, and the sources.
+ *
+ * The loading state was not part of that pass, so `AnswerProgressStepper` still
+ * ships as written: a filled accent panel carrying an icon tile, a five-circle
+ * stepper with connecting rails, a scrolling ECG trace, a per-second elapsed
+ * counter and a Processing details disclosure. It is the loudest element on the
+ * answer surface, and it occupies that surface for the four to twelve seconds
+ * before the answer — so it is the first thing a reader sees and the thing that
+ * sets their expectation of the answer's register.
+ *
+ * Panel One draws the real component (imported, not redrawn) so the comparison
+ * is against what ships. Panels Two and Three propose three replacements and
+ * recommend one. Panel Four draws the states that are not the happy path,
+ * because the wait is where most of them are decided.
+ *
+ * Nothing here is wired to retrieval. All copy and all counts are synthetic.
+ */
+
+/* ══════════════════════ data ══════════════════════ */
+
+type LoadingSource = {
+ id: string;
+ index: number;
+ short: string;
+ origin: string;
+ page: number;
+ status: SourceStatus;
+};
+
+/** Six, because `dedupeSourceLinks` caps primary sources at six and the rail
+ * has to survive its own worst case rather than the three a specimen draws. */
+const POOL: LoadingSource[] = [
+ {
+ id: "s1",
+ index: 1,
+ short: "Physical health protocol",
+ origin: "Statewide mental health · 2025",
+ page: 12,
+ status: "current",
+ },
+ {
+ id: "s2",
+ index: 2,
+ short: "Myocarditis surveillance",
+ origin: "Local formulary · 2025",
+ page: 14,
+ status: "current",
+ },
+ {
+ id: "s3",
+ index: 3,
+ short: "Metabolic monitoring",
+ origin: "RANZCP guidance · 2024",
+ page: 31,
+ status: "current",
+ },
+ {
+ id: "s4",
+ index: 4,
+ short: "Clozapine titration",
+ origin: "Hospital protocol · 2023",
+ page: 4,
+ status: "review-due",
+ },
+ {
+ id: "s5",
+ index: 5,
+ short: "Neutropenia thresholds",
+ origin: "Haematology pathway · 2025",
+ page: 8,
+ status: "current",
+ },
+ {
+ id: "s6",
+ index: 6,
+ short: "Bowel care in clozapine",
+ origin: "Statewide mental health · 2025",
+ page: 22,
+ status: "current",
+ },
+];
+
+const ANSWER_LINES: Array<{ id: string; text: string; refs: number[] }> = [
+ {
+ id: "a1",
+ text: "Full blood count and absolute neutrophil count at baseline, weekly for the first 18 weeks, fortnightly to week 52, then monthly while treatment continues.",
+ refs: [1],
+ },
+ {
+ id: "a2",
+ text: "Troponin and CRP at baseline and weekly for the first four weeks, with urgent cardiology review where troponin exceeds twice the upper limit of normal.",
+ refs: [2],
+ },
+ {
+ id: "a3",
+ text: "Weight, waist circumference, lipids and HbA1c at baseline, at three months, then annually.",
+ refs: [3, 5],
+ },
+];
+
+/* ══════════════════════ the replay clock ══════════════════════ */
+
+/**
+ * A loading state cannot be judged from a still. Every direction below is
+ * driven from one shared tick so the three phones move together and can be
+ * compared at the same instant, and so a reviewer can stop the clock on the
+ * frame they want to argue about.
+ *
+ * Autoplay is off when the reviewer's OS asks for reduced motion — this page
+ * is about restraint, and a page about restraint that ignores the setting
+ * would be arguing against itself.
+ */
+const BEAT_COUNT = 11;
+
+type Phase = "asked" | "searching" | "writing" | "answered";
+
+type Beat = {
+ tick: number;
+ phase: Phase;
+ found: number;
+ scanned: number;
+ seconds: number;
+};
+
+const BEAT_SECONDS = [0, 0.6, 1.1, 1.5, 1.9, 2.3, 2.7, 3.4, 4.6, 6.1, 7.2];
+
+function beatFor(tick: number): Beat {
+ const seconds = BEAT_SECONDS[tick] ?? 0;
+ if (tick === 0) return { tick, phase: "asked", found: 0, scanned: 0, seconds };
+ if (tick <= 6) {
+ return { tick, phase: "searching", found: tick, scanned: Math.round((2_140 * tick) / 6), seconds };
+ }
+ if (tick <= 9) return { tick, phase: "writing", found: 6, scanned: 2_140, seconds };
+ return { tick, phase: "answered", found: 6, scanned: 2_140, seconds };
+}
+
+const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
+
+/** Subscribed rather than sampled into state, so the preference is read during
+ * render and the server snapshot is a stable `false`. Sampling it in an effect
+ * would mean a first paint that autoplays and then stops. */
+function usePrefersReducedMotion() {
+ return useSyncExternalStore(
+ (onChange) => {
+ const query = window.matchMedia(REDUCED_MOTION_QUERY);
+ query.addEventListener("change", onChange);
+ return () => query.removeEventListener("change", onChange);
+ },
+ () => window.matchMedia(REDUCED_MOTION_QUERY).matches,
+ () => false,
+ );
+}
+
+function useReplayClock(intervalMs = 640) {
+ const [tick, setTick] = useState(0);
+ // `null` means "follow the OS". Pressing Play or Pause is a deliberate
+ // override and is honoured either way — a reviewer who asked for reduced
+ // motion may still want to watch this page move once.
+ const [override, setOverride] = useState(null);
+ const reducedMotion = usePrefersReducedMotion();
+ const playing = override ?? !reducedMotion;
+
+ useEffect(() => {
+ if (!playing) return undefined;
+ const id = window.setInterval(() => setTick((current) => (current + 1) % BEAT_COUNT), intervalMs);
+ return () => window.clearInterval(id);
+ }, [playing, intervalMs]);
+
+ return {
+ beat: beatFor(tick),
+ playing,
+ toggle: () => setOverride(!playing),
+ scrub: (next: number) => {
+ setOverride(false);
+ setTick(next);
+ },
+ reset: () => setTick(0),
+ };
+}
+
+function ReplayControls({
+ playing,
+ tick,
+ onToggle,
+ onScrub,
+ onReset,
+}: {
+ playing: boolean;
+ tick: number;
+ onToggle: () => void;
+ onScrub: (next: number) => void;
+ onReset: () => void;
+}) {
+ return (
+
+ );
+}
+
+/* ══════════════════════ shared parts of the redesign ══════════════════════ */
+
+/**
+ * The whole animation, in one element.
+ *
+ * A 5px dot at the start of the status line, breathing on a 2.4s cycle. It is
+ * the only moving thing in directions A and B, it costs one composited
+ * property, and at rest — reduced motion, forced colors, a screenshot — it is
+ * still a correct, legible bullet rather than a blank space where a spinner
+ * used to be. That last property is what disqualifies a spinner here: a
+ * stopped `Loader2` is a fragment of a circle.
+ */
+function BreathDot({ tone = "accent" }: { tone?: "accent" | "muted" | "success" }) {
+ return (
+
+ );
+}
+
+/**
+ * One line, at the size the v2 answer surface already uses for provenance.
+ *
+ * `aria-live="polite"` and not `role="status"` on a wrapper: the text is
+ * replaced in place, so the live region has to be the element that persists.
+ */
+function StatusLine({
+ children,
+ onStop,
+ stopLabel = "Stop",
+}: {
+ children: ReactNode;
+ onStop?: () => void;
+ stopLabel?: string;
+}) {
+ return (
+
+ );
+}
+
+const RAIL_SCROLL_FADE = {
+ scrollbarWidth: "none",
+ maskImage: "linear-gradient(90deg, black calc(100% - 1.75rem), transparent)",
+ WebkitMaskImage: "linear-gradient(90deg, black calc(100% - 1.75rem), transparent)",
+} as const;
+
+/**
+ * The rail, arriving.
+ *
+ * Kept from `/mockups/answer-chat-perfected-v2` unchanged, including the rule
+ * that decides this whole design: a card that arrives before the answer carries
+ * a dot, not a number. The evidence preview is the top slice of retrieval in
+ * retrieval order; the final list is rebuilt from what the answer actually
+ * cites and re-capped by trust. Different sets, different order — so a number
+ * assigned during the wait can end up pointing at a different document once the
+ * answer lands. Numbering is what arrival buys.
+ *
+ * Cards are keyed by source id and appended, so React re-runs the entry
+ * animation only on the card that is genuinely new. The ones already on screen
+ * do not re-animate, and none of them move.
+ */
+function ArrivingRail({ found, numbered }: { found: number; numbered: boolean }) {
+ const shown = POOL.slice(0, found);
+ if (shown.length === 0) return null;
+ return (
+
+ {shown.map((source) => (
+
+ ))}
+
+ );
+}
+
+function Mark({ n }: { n: number }) {
+ return (
+
+ );
+}
+
+/** The provenance line from the shipped answer surface, so the frames end where
+ * the real page ends rather than in a skeleton. */
+function ArrivedAnswer() {
+ return (
+ <>
+
+ Written from 6 of your documents.
+
+ Check each claim against the source before acting on it.
+
+ {/* The rail belongs to the arrived answer in all three directions — they
+ differ in what the wait shows, never in what the answer is. Drawing it
+ only under the recommended one would flatter it with somebody else's
+ work. */}
+
+ >
+ );
+}
+
+/* ══════════════════════ the three directions ══════════════════════ */
+
+/**
+ * Direction A — one line.
+ *
+ * Exactly what the v2 pending screen drew, held for the whole wait. It is the
+ * right register and it is honest. Its weakness is only visible with a clock
+ * running: from about t+0.6s to t+3.4s the screen says the same six words and
+ * nothing accrues, so a reader with a slow query has no way to tell a working
+ * search from a stuck one except by counting seconds themselves.
+ */
+function DirectionA({ beat }: { beat: Beat }) {
+ if (beat.phase === "answered") return ;
+ return (
+ <>
+ undefined}>
+
+ Searching your documents…
+
+
+ >
+ );
+}
+
+/**
+ * Direction B — the sources arrive. Recommended.
+ *
+ * The same line, plus the one thing that is genuinely happening: documents are
+ * being found. Each lands in the rail as a dotted card, the count in the line
+ * moves with it, and when the answer arrives the cards take their numbers and
+ * the prose writes above them. The rail does not move, is not rebuilt and is
+ * not replaced — the reader's eye has already settled on the row it will still
+ * be reading in ten seconds.
+ *
+ * This also fixes the layout jump the stepper causes. Today a tall accent panel
+ * occupies the answer's position and then vanishes, so the answer lands
+ * somewhere the eye was not. Here the wait is drawn in the answer's own column,
+ * at the answer's own size, and only the middle of it changes.
+ */
+function DirectionB({ beat }: { beat: Beat }) {
+ const answered = beat.phase === "answered";
+ return (
+ <>
+ {answered ? (
+
+ ) : (
+ <>
+ undefined}>
+
+ {beat.phase === "asked" ? (
+ "Reading your question…"
+ ) : beat.phase === "searching" ? (
+ <>
+ Searching your documents · {beat.found} found
+ >
+ ) : (
+ <>
+ 6 sources · writing the answer…
+ >
+ )}
+
+ {beat.phase === "writing" ? : null}
+
+ >
+ )}
+ >
+ );
+}
+
+/**
+ * Direction C — the reading list.
+ *
+ * Names each document as it is opened, with a hairline that fills while it is
+ * being read, then collapses into the rail. It is the most informative of the
+ * three and the one people ask for.
+ *
+ * It is not recommended, for a reason that is specific to this product rather
+ * than to taste. Retrieval opens far more documents than the answer cites, and
+ * ranking then discards most of them. A reader who has watched six titles
+ * scroll past has read six documents into the answer, four of which never
+ * support a claim in it — and on this surface a source the reader believes is
+ * behind the answer, but is not, is the exact failure the citation design
+ * exists to prevent. It is drawn here so the trade is visible, not hidden in a
+ * paragraph.
+ */
+function DirectionC({ beat }: { beat: Beat }) {
+ if (beat.phase === "answered") return ;
+ const scanning = POOL.slice(0, Math.max(1, beat.found));
+ return (
+ <>
+ undefined}>
+
+ Reading {beat.scanned.toLocaleString("en-AU")} indexed documents
+
+
+
+ >
+ );
+}
+
+/** The recommended direction at the desktop reading column, where the answer is
+ * a 68ch measure and the wait has to hold that column without filling it. */
+function DesktopScreen({ beat }: { beat: Beat }) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+/** A transcript fragment, unframed, for the states that only need their own
+ * three lines to be judged. */
+function Fragment({ title, note, children }: { title: string; note: string; children: ReactNode }) {
+ return (
+
+
{title}
+
{note}
+
+ {children}
+
+
+ );
+}
+
+/* ══════════════════════ panel one: what ships ══════════════════════ */
+
+function TodayStepper({ density }: { density: "expanded" | "compact" }) {
+ // Fixed at first render so the counter climbs from a plausible seven seconds
+ // instead of starting at zero every time this page re-renders. The events are
+ // shaped exactly as `normalizeAnswerProgressEvent` produces them.
+ const [startedAt] = useState(() => Date.now() - 7_000);
+ const events: TimedAnswerProgressUpdate[] = [
+ { stage: "scoping", message: "scoping", receivedAt: startedAt },
+ { stage: "retrieving", message: "retrieving", receivedAt: startedAt + 900 },
+ { stage: "retrieved", message: "retrieved", resultCount: 24, receivedAt: startedAt + 2_600 },
+ {
+ stage: "ranking",
+ message: "ranking",
+ australianSourceCount: 4,
+ waSourceCount: 2,
+ receivedAt: startedAt + 4_200,
+ },
+ { stage: "generating", message: "generating", receivedAt: startedAt + 5_600 },
+ ];
+ return (
+ undefined} density={density} />
+ );
+}
+
+const OBSERVATIONS: Array<{ id: string; heading: string; body: string }> = [
+ {
+ id: "o1",
+ heading: "It is the loudest thing on a surface that just finished getting quiet",
+ body: "A filled accent panel, a 36px icon tile, five circles, four connecting rails, a scrolling waveform and a live counter — against an answer page whose own provenance line is 2xs muted text. The two were designed in different years and it shows in the first four seconds of every question.",
+ },
+ {
+ id: "o2",
+ heading: "It reports the pipeline, not the search",
+ body: "Prepare scope · Search sources · Select evidence · Draft answer · Check answer are the stages of the RAG orchestrator. They are accurate and they are internal. What a clinician waiting on this screen wants to know is which of their documents are being read and how many came back.",
+ },
+ {
+ id: "o3",
+ heading: "The waveform is a clinical signal with nothing behind it",
+ body: "A scrolling ECG trace on a psychiatry reference tool reads as a physiological readout. It is a decoration on a fixed path — the same 320-unit trace whatever the query does — and it is the one element here that could be mistaken for data.",
+ },
+ {
+ id: "o4",
+ heading: "The counter makes the wait the subject",
+ body: "'7s elapsed' re-renders every second, in the one position the eye is already resting on. Nothing can be done with the number while the search is healthy, and re-drawing it once a second is what turns a four-second wait into a watched four-second wait.",
+ },
+ {
+ id: "o5",
+ heading: "The answer lands somewhere the eye is not",
+ body: "The expanded stepper is roughly 210px tall and it is removed, not transformed, when the answer arrives. Everything below it jumps up by that distance at the exact moment the reader is given something to read.",
+ },
+ {
+ id: "o6",
+ heading: "It never shows a single source",
+ body: "This is the substantive one. The evidence preview already crosses the stream boundary before the prose — trimmed, owner-scoped, governed, and consumed by the client today. The most useful content this surface has arrives early and is currently spent on a five-circle progress bar about the fact that it arrived.",
+ },
+];
+
+/* ══════════════════════ page ══════════════════════ */
+
+export function AnswerLoadingRedesignMockupsPage() {
+ const phones = useReplayClock();
+ const desktop = useReplayClock(720);
+
+ return (
+
+
+
+
+
+
+
+
+ Clinical KB · answer page · the wait
+
+
+
+ Show the sources arriving, not the pipeline running
+
+
+ The answer surface was quietened in PRs #2386 and #2388 — the assistant tile off the turn, one number per
+ claim, the safety rail’s colour moved into its own icon, two panels made one. The loading state was
+ not part of that pass, so a filled accent panel with a five-circle stepper, a scrolling ECG trace and a
+ per-second counter still owns the four to twelve seconds before every answer. Three replacements below,
+ drawn moving, with one recommended.
+
+ Right register, honest, and the cheapest thing to build. Its weakness only appears with the clock
+ running: between t+0.6s and t+3.4s the screen says the same six words and nothing accrues, so a slow
+ query and a stuck one look identical.
+
+
+
+
+ Each card lands as it is found and carries a dot, not a number. Nothing on screen moves position, so the
+ rail the reader settles on during the wait is the rail they are still reading afterwards. The answer
+ writes in above it and the dots become numbers.{" "}
+ Recommended.
+
+
+
+
+ The most informative, and the one people ask for. It is also the only one that can mislead: retrieval
+ opens many more documents than the answer cites, so a reader watches six titles go past and takes all
+ six to be behind the answer. On this surface that is the precise error the citation design exists to
+ prevent.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[
+ ["scoping", "Reading your question…", "stage only"],
+ ["retrieving", "Searching your documents · N found", "evidence preview, as each unit arrives"],
+ ["retrieved / ranking", "N sources · writing the answer…", "resultCount, then the trimmed preview"],
+ ["generating / verifying", "unchanged — the rail is already right", "no new event"],
+ ["complete", "Written from N of your documents.", "the final source list"],
+ ].map(([stage, copy, source]) => (
+
+
{stage}
+
{copy}
+
{source}
+
+ ))}
+
+
+
+
+ {[
+ ["The five-step stepper", "It narrates the orchestrator. The reader is not operating it."],
+ ["The ECG waveform", "A clinical signal drawn from a fixed path. It could be read as data."],
+ [
+ "The elapsed counter",
+ "Only useful when something is wrong — so it appears only then (Panel Four).",
+ ],
+ ["The spinner", "It has no correct resting frame. A dot does."],
+ ["Processing details", "A disclosure nobody opens, holding the same five stages again."],
+ ["The accent panel", "Fill is how this surface marks a hazard. A wait is not one."],
+ ].map(([item, why]) => (
+
+
+
+ {item}
+
+ {why}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ undefined}>
+
+ Searching your documents · 4 found · still going at{" "}
+ 10s
+
+
+
+
+
+
+
+ No documents in your library matched this question.
+
+
+ Nothing was written, because there was nothing to write from.
+
+
+ {["Search the whole library", "Rephrase the question"].map((label) => (
+
+ ))}
+
+
+
+
+
+
+ 6 sources · assembling from the sources directly…
+
+
+
+ Same line, one word different. It sets the expectation before the answer has to defend it.
+
+
+
+
+
+
+ Stopped. 4 sources were found — open them, or ask again.
+
+
+
+
+
+
+
+
+ Searching your documents · 3 found
+
+
+
+
+
+
+
+
+ Written from 6 of your documents.
+
+
+
+
+
+
+ One thing worth saying plainly about the whole set. The current design spends its animation budget on
+ movement that carries no information — a waveform on a fixed path, a spinner, a counter — and spends none of
+ it on the one event the reader actually cares about, which is a document being found. The proposal is not
+ “less animation”. It is the same budget, moved onto the only thing on this screen that is really
+ happening.
+
+
+
+
+
+
+ );
+}
From 9ceb8744ac60efd16da8e0d406a373d8ebd68f39 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 26 Aug 2026 22:20:16 +0000
Subject: [PATCH 2/6] mockup(answer): the fourth panel draws six cards, not
five
The intro counted the states that need their own words and then included the
arrived state, which is there for comparison rather than as a sixth state.
Names it as such instead of miscounting the row.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_012kHT2YKCNnUrVckTaJW6ga
---
src/components/answer-loading-redesign-mockups.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/answer-loading-redesign-mockups.tsx b/src/components/answer-loading-redesign-mockups.tsx
index ce9ddb5b2e..cefd8944de 100644
--- a/src/components/answer-loading-redesign-mockups.tsx
+++ b/src/components/answer-loading-redesign-mockups.tsx
@@ -916,7 +916,7 @@ export function AnswerLoadingRedesignMockupsPage() {
Date: Thu, 27 Aug 2026 10:07:56 +0000
Subject: [PATCH 3/6] Answer wait: one quiet line and the sources arriving, not
a five-step panel
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Direction B from /mockups/answer-loading-redesign, applied to the live surface
after the clinical owner chose it (2026-08-27).
`AnswerProgressStepper` is gone. It was a filled accent panel carrying a 36px
icon tile, a five-circle stepper with connecting rails, a scrolling ECG trace, a
per-second elapsed counter and a Processing details disclosure — roughly 210px
of the loudest chrome in the product, occupying the answer's own position for
the four to twelve seconds before every answer, on a surface that PRs #2386 and
#2388 had just spent their whole argument quietening. It narrated the
orchestrator's five stages, which the reader is not operating, and it never
showed a single source.
`AnswerProgress` replaces it with one status line plus the arriving source rail.
The rail is the substantive half. The evidence preview already crossed the
stream boundary before the prose — trimmed, owner-scoped, governed, consumed by
the client — and was being rendered as a SECOND full panel below the stepper,
with its own icon tile, heading and three-column card grid. Two loud blocks in
the answer's position, both removed when the answer arrived. It is now a
horizontal rail of small cards drawn to look like the source rail the arrived
answer renders, mounted inside AnswerProgress rather than beside it, so nothing
is removed and nothing jumps when the answer lands.
Cards carry a dot, not a number. The preview is the top slice of retrieval in
retrieval order; the final list is rebuilt from what the answer cites and
re-capped by trust. A number assigned during the wait can point at a different
document once the answer lands, which is the precise failure the citation design
exists to prevent. Pinned by a DOM test, and stated in the rail's accessible
name for a reader who never sees the dot.
Checked while building, not assumed: `isDeliverableVerifiedUnit` pins
evidence_preview to sequence 0 and rejects a repeat, so the preview crosses once,
complete — it cannot accrue card by card. And it is gated behind
NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER (#100 Phase 1), off by
default, so today the line carries the accrual alone via resultCount and the rail
is simply absent rather than a placeholder.
Improvements beyond the mockup:
- Retrieval counts passages and selection counts sources, never one noun for
both. Collapsing them is how a reader believes two dozen documents are behind
an answer that cites three. Pinned in tests/answer-progress.test.ts.
- `fallback` says "Assembling the answer from the sources directly" while it is
happening. Twenty of thirty answers in the 2026-08-18 blinded read were
source_only; the wait is the honest place to set that expectation.
- Processing details became "How this answer was built", shown only after a
retrying/fallback/cached run. The old disclosure held the same five stages for
every question, which is why nobody opened it.
- The elapsed counter is gone. In its place one threshold at 10s appends
"taking longer than usual" and does not tick. Nothing can be done with the
number while the search is healthy; re-drawing it every second in the position
the eye rests on is what made the wait the subject.
- SearchProgressBanner (library/document modes) loses its filled accent band and
spinning Loader2 for the same line. Fill is how this app marks a hazard.
- AnswerSkeleton drops its own status line. Found in the browser, not in a test:
it renders in the answer's body slot directly under AnswerProgress, so the
screen showed "Writing the answer…" above "Reading your question…". There is
now exactly one place that says what is happening.
- The completion dot is not green. A status hue that nothing else on the element
repeats is a colour-only signal, and it was redundant beside a line already
reading "Answer ready in 3s". Caught by check:design-system-contract.
Motion. The ECG trace, its two animation tokens, its keyframes and its
compositor rules are deleted; the indicator is a 5px dot breathing on opacity.
That choice is what holds the contract ui-phone-motion.spec.ts exists for, after
Reduce Motion set the trace to opacity 0 and left a dead panel on a physical
iPhone: a stopped dot is a complete, correct bullet, where a stopped spinner is a
fragment of a circle. The animation stays in globals.css rather than a
motion-safe: utility because html[data-motion="full"] must be able to opt back in
over the OS, which a Tailwind media variant cannot express.
tests/answer-activity-trace-css.test.ts pinned the deleted component, so it is
replaced by tests/answer-progress-indicator-css.test.ts carrying the same
regression forward against the dot — including an assertion that the trace stays
deleted, since a partial revival is how the original defect shipped.
docs/search-chrome-behaviour.md's physical-iPhone rubric named the ECG strip by
class and would have sent a tester hunting for markup that no longer exists;
docs/design-system/COMPONENTS.md named the component in its live-region adoption
note. Both updated — the second found by check:dead-code-candidate, which is the
gate doing its job.
Verified: verify:cheap exit 0 — 37 gates enforced, design-system contract passed
(1021 production files, no ratchet moved), 886 test files / 10733 tests passed;
Playwright chromium answer-progress-ui-smoke + ui-phone-motion 9 passed,
ui-universal-search 20 passed; driven in a real browser at 390px through
scoping → retrieving → retrieved(24) → ranking(4 AU, 2 WA) → generating and past
the 10s threshold, and again with the preview flag on to confirm six real cards
render, link to /documents/?page=&chunk=, and carry no numbers.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_012kHT2YKCNnUrVckTaJW6ga
---
data/repo-awareness-snapshot.json | 13 +-
docs/design-system/COMPONENTS.md | 7 +-
docs/search-chrome-behaviour.md | 10 +-
src/app/globals.css | 78 +--
src/components/ClinicalDashboard.tsx | 16 +-
src/components/DocumentViewer.tsx | 4 +-
.../answer-loading-redesign-mockups.tsx | 206 ++++++--
.../answer-evidence-preview.tsx | 119 +++--
.../clinical-dashboard/answer-progress.ts | 67 ++-
.../clinical-dashboard/answer-status.tsx | 483 +++++++-----------
src/lib/tailwind-merge.ts | 3 +-
tests/answer-activity-trace-css.test.ts | 91 ----
tests/answer-evidence-preview.dom.test.tsx | 45 +-
tests/answer-progress-indicator-css.test.ts | 118 +++++
tests/answer-progress-ui-smoke.spec.ts | 241 ++++-----
tests/answer-progress.test.ts | 52 +-
...aywright-motion-emulation-contract.test.ts | 14 +-
tests/ui-phone-motion.spec.ts | 150 +++---
tests/ui-smoke.spec.ts | 2 +-
tests/ui-universal-search.spec.ts | 4 +-
20 files changed, 940 insertions(+), 783 deletions(-)
delete mode 100644 tests/answer-activity-trace-css.test.ts
create mode 100644 tests/answer-progress-indicator-css.test.ts
diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json
index fb0c0caaba..a076f171df 100644
--- a/data/repo-awareness-snapshot.json
+++ b/data/repo-awareness-snapshot.json
@@ -1,8 +1,8 @@
{
"version": "repo-awareness-snapshot-v1",
"captured_revision": {
- "sha": "c19d2cb01243ef93dbd8464d756fe0b6c21c3b5c",
- "committed_at": "2026-08-26T22:58:40+08:00"
+ "sha": "db98bf0698537e261dd031db47d70b08e895e244",
+ "committed_at": "2026-08-26T22:13:20+00:00"
},
"routes": {
"modes": [
@@ -313,6 +313,11 @@
"file": "src/app/mockups/answer-home-proposal/page.tsx",
"area": "mockup"
},
+ {
+ "path": "/mockups/answer-loading-redesign",
+ "file": "src/app/mockups/answer-loading-redesign/page.tsx",
+ "area": "mockup"
+ },
{
"path": "/mockups/calculators-bedside-sheet",
"file": "src/app/mockups/calculators-bedside-sheet/page.tsx",
@@ -1383,9 +1388,9 @@
],
"counts": {
"modes": 15,
- "pages": 193,
+ "pages": 194,
"product_pages": 55,
- "mockup_pages": 138,
+ "mockup_pages": 139,
"redirects": 17,
"api": 57
}
diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md
index e1a2b7cb4e..08454387ff 100644
--- a/docs/design-system/COMPONENTS.md
+++ b/docs/design-system/COMPONENTS.md
@@ -478,9 +478,14 @@ into the previous sentence; `RouteAnnouncer` skips the first render (arrival is
navigation), moves focus to the new `
` unless focus sits inside a dialog or a
`data-preserve-focus` workflow, and announces the page title once. Retiring the visible
`aria-live` nodes that remain in production — `document-search-results.tsx`, `StageList`,
-`AnswerProgressStepper`, `EmptyState`'s default — is adoption work in PR 13, because each
+`AnswerProgress`, `EmptyState`'s default — is adoption work in PR 13, because each
one needs its own surface diff. Until then two announcement mechanisms coexist.
+`AnswerProgress` is the successor to `AnswerProgressStepper`, which was retired when the
+answer wait was redrawn as a single quiet status line plus the arriving source rail. Its
+live region moved with it and is now the status line itself (`answer-progress-line`) rather
+than a wrapper, because the line is the element that persists while its text is replaced.
+
---
## 6 · `DocumentFrame`
diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md
index ab0f5b3cef..4640da135a 100644
--- a/docs/search-chrome-behaviour.md
+++ b/docs/search-chrome-behaviour.md
@@ -817,13 +817,15 @@ The application supports explicit user motion preference overrides in addition t
### Physical iPhone acceptance rubric
-To prevent regressions of the phone/PWA answer-progress animation defect (where OS Reduce Motion froze animations and rendered the ECG trace invisible at `opacity: 0`), verify the following rubric on a physical iPhone in both Mobile Safari and the installed standalone PWA:
+To prevent regressions of the phone/PWA answer-progress animation defect (where OS Reduce Motion froze animations and rendered the then-current ECG trace invisible at `opacity: 0`), verify the following rubric on a physical iPhone in both Mobile Safari and the installed standalone PWA.
+
+The indicator under test changed when the answer wait was redrawn as a single quiet status line: the scrolling ECG strip and the five-circle stepper are gone, and what remains is one breathing dot (`.answer-progress-dot`, `data-slot="answer-progress-dot"`) at the head of the line. The rubric is otherwise unchanged, and the dot was chosen partly because it makes step 2 trivial to satisfy — its resting frame is a complete, correct bullet, where a stopped spinner is a fragment of a circle.
1. **Motion=Full (`data-motion="full"`):**
- - In physical Safari and installed standalone PWA, when the in-app Motion setting is set to **Full**, the ECG strip (`.answer-activity-trace__sweep`) visibly travels continuously and the current-step spinner rotates, even if iOS system **Reduce Motion** is enabled in Accessibility settings.
+ - In physical Safari and installed standalone PWA, when the in-app Motion setting is set to **Full**, the dot visibly breathes (a continuous opacity cycle, 2.4s) even if iOS system **Reduce Motion** is enabled in Accessibility settings.
2. **Motion=System / Motion=Reduced:**
- - When iOS system **Reduce Motion** is enabled (or in-app Motion is set to **Reduced**), the ECG trace remains static and clearly visible at `opacity: 0.55` (`translateX(0)` aligned), rather than disappearing or rendering a blank box (`opacity: 0`).
- - The current step spinner stops rotating and displays as a static marker without layout jumps.
+ - When iOS system **Reduce Motion** is enabled (or in-app Motion is set to **Reduced**), the dot stops breathing and remains clearly visible at full opacity, rather than disappearing or rendering a blank box (`opacity: 0`).
+ - The status line beside it still reads out what is happening, and the arriving source rail (when `NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER` is enabled) appears without layout jumps.
The motion preference contract in `src/components/clinical-dashboard/answer-status.tsx` and the corresponding stylesheet rules in `src/app/globals.css` must remain strictly intact across all breakpoints.
diff --git a/src/app/globals.css b/src/app/globals.css
index 86cc194718..208558c898 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -305,8 +305,7 @@
--animate-dialog-rise: dialog-rise 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
--animate-action-tray-in: action-tray-in var(--swipe-duration, 160ms) cubic-bezier(0.22, 1, 0.36, 1) both;
--animate-shimmer: shimmer 1.4s linear infinite;
- --animate-answer-ecg: answer-ecg-scroll 3.2s linear infinite;
- --animate-answer-ecg-compact: answer-ecg-scroll 2.6s linear infinite;
+ --animate-answer-progress-breath: answer-progress-breath 2.4s ease-in-out infinite;
}
/* Theme tokens */
@@ -3907,24 +3906,26 @@ td,
}
}
-/* Scroll the bright ECG strip like a cardiac monitor.
+/* The answer/search progress indicator, breathing.
*
- * The strip is two identical copies of the trace side by side inside a 200%-wide
- * HTML span, so translating it by exactly -50% lands copy 2 where copy 1 started
- * and the loop has no seam (the path starts at `M0 24` and ends at the same y).
+ * Opacity only, on a 5px dot. The ECG strip this replaces animated `transform`
+ * because mobile WebKit can report SVG path animations as running without
+ * repainting them; a plain opacity fade on a plain HTML span has never had that
+ * problem, and there is no strip to keep seamless.
*
- * `transform` is the only animated property here on purpose. Mobile WebKit can
- * report animations on SVG path properties as running without repainting them,
- * especially in an installed PWA, and an earlier whole-line opacity pulse was
- * technically running but too subtle to read as motion on a phone hairline.
- * Translating a plain HTML layer is the same compositor-thread recipe that
- * `.animate-skeleton-shimmer::after` already uses reliably in this app. */
-@keyframes answer-ecg-scroll {
- from {
- transform: translate3d(0, 0, 0);
+ * It never reaches 0. The indicator must remain visible at every frame,
+ * including the resting frame when motion is suppressed — that is the contract
+ * `ui-phone-motion.spec.ts` exists to hold, after Reduce Motion set the old
+ * trace to `opacity: 0` and left a dead panel on a physical iPhone while an
+ * answer was generating. */
+@keyframes answer-progress-breath {
+ 0%,
+ 100% {
+ opacity: 1;
}
- to {
- transform: translate3d(-50%, 0, 0);
+
+ 50% {
+ opacity: 0.35;
}
}
@@ -3941,23 +3942,22 @@ td,
* those unlayered classes, check the class body first; the class wins.
*/
@layer components {
- /* `isolation` + `translateZ(0)` mirror .animate-skeleton-shimmer: they give the
- scrolling strip its own compositor layer so WebKit repaints it. The mask is
- static (never animated) and only softens the two clipped edges. */
- .answer-activity-trace {
- isolation: isolate;
- transform: translateZ(0);
- -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 10%, #000 90%, transparent 100%);
- mask-image: linear-gradient(90deg, transparent 0%, #000 10%, #000 90%, transparent 100%);
+ .answer-progress-dot {
+ animation: var(--animate-answer-progress-breath);
+ will-change: opacity;
}
- .answer-activity-trace__sweep {
- animation: var(--animate-answer-ecg);
- will-change: transform;
+ /* The arriving source rail fades at its right edge instead of clipping a card
+ mid-word, the same mechanism .answer-suggestion-chips-scroll already ships.
+ The mask is static and never animated. */
+ .answer-sources-arriving {
+ scrollbar-width: none;
+ -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 1.75rem), transparent);
+ mask-image: linear-gradient(90deg, #000 calc(100% - 1.75rem), transparent);
}
- .answer-activity-trace[data-density="compact"] .answer-activity-trace__sweep {
- animation: var(--animate-answer-ecg-compact);
+ .answer-sources-arriving::-webkit-scrollbar {
+ display: none;
}
.app-edge-backdrop {
@@ -4290,20 +4290,22 @@ td,
}
/* Suppressing motion must not delete the status indicator. `opacity: 0` here used
- to hide the bright trace outright, so anyone with Reduce Motion on saw a dead
- panel while an answer was generating. At translateX(0) the first copy of the
- strip fills the box exactly, so simply stopping the animation leaves a correct,
- clearly visible static ECG. */
+ to hide the old ECG trace outright, so anyone with Reduce Motion on saw a dead
+ panel while an answer was generating. Stopping the dot's breath leaves it at
+ full opacity — a correct, clearly visible bullet — which is the whole reason
+ the indicator is a dot rather than a spinner.
+ `html:not([data-motion="full"])` is what lets the in-app Motion preference opt
+ back IN over the OS request; a Tailwind `motion-safe:` variant could not. */
@media (prefers-reduced-motion: reduce) {
- html:not([data-motion="full"]) .answer-activity-trace__sweep {
+ html:not([data-motion="full"]) .answer-progress-dot {
animation: none;
- opacity: 0.55;
+ opacity: 1;
}
}
-html[data-motion="reduced"] .answer-activity-trace__sweep {
+html[data-motion="reduced"] .answer-progress-dot {
animation: none;
- opacity: 0.55;
+ opacity: 1;
}
/* IMP-04: Compositor-thread sweep used by .animate-skeleton-shimmer::after. */
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index b6024f3bec..156e8689f3 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -84,7 +84,7 @@ import { resolveModeHomeCanvasClass } from "@/components/clinical-dashboard/mode
import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/clinical-dashboard/display-text";
import { isPreformattedGroundedAnswer } from "@/components/clinical-dashboard/answer-content";
import {
- AnswerProgressStepper,
+ AnswerProgress,
AnswerSkeleton,
SearchProgressBanner,
SharedHomeEmptyState,
@@ -93,7 +93,6 @@ import {
type AnswerProgressUpdate,
type TimedAnswerProgressUpdate,
} from "@/components/clinical-dashboard/answer-progress";
-import { AnswerEvidencePreview } from "@/components/clinical-dashboard/answer-evidence-preview";
import { requestAnswerStream } from "@/components/clinical-dashboard/answer-request";
import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header";
import { PhoneFooterLayerFrame } from "@/components/clinical-dashboard/phone-footer-layer-portal";
@@ -3628,22 +3627,23 @@ function ClinicalDashboardContent({
{searchMode !== "prescribing" &&
(activeModeResultKind === "answer" ? (
showAnswerProgress ? (
-
) : null
) : loading && answerProgress ? (
) : null)}
- {activeModeResultKind === "answer" && loading && answerEvidencePreview ? (
-
- ) : null}
-
{showUniversalAlsoMatches &&
(activeModeResultKind === "tools" ||
activeModeResultKind === "documents" ||
diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx
index 9e4a7edf9f..3b56537d63 100644
--- a/src/components/DocumentViewer.tsx
+++ b/src/components/DocumentViewer.tsx
@@ -21,7 +21,7 @@ import { PhoneFooterLayerPortal } from "@/components/clinical-dashboard/phone-fo
import { useActiveScrollOwner } from "@/components/clinical-dashboard/use-active-scroll-owner";
import { PhoneHeaderCollapsePortal } from "@/components/clinical-dashboard/phone-header-collapse-portal";
import { useDocumentViewerChromeScroll } from "@/components/clinical-dashboard/use-document-viewer-chrome-scroll";
-import { AnswerProgressStepper } from "@/components/clinical-dashboard/answer-status";
+import { AnswerProgress } from "@/components/clinical-dashboard/answer-status";
import {
appBackdrop,
cn,
@@ -1416,7 +1416,7 @@ export function DocumentViewer({
{(loadingSummary || summary || summaryError) && (
Show the sources arriving, not the pipeline running
@@ -736,15 +873,16 @@ export function AnswerLoadingRedesignMockupsPage() {
The answer surface was quietened in PRs #2386 and #2388 — the assistant tile off the turn, one number per
claim, the safety rail’s colour moved into its own icon, two panels made one. The loading state was
not part of that pass, so a filled accent panel with a five-circle stepper, a scrolling ECG trace and a
- per-second counter still owns the four to twelve seconds before every answer. Three replacements below,
- drawn moving, with one recommended.
+ per-second counter owned the four to twelve seconds before every answer. Three replacements are drawn below,
+ moving, with one recommended — and direction B is the one that was chosen and applied to the live surface.
+ This page is kept as the record of that argument.
-
-
-
-
-
-
-
- Selected evidence — answer still being verified
-
-
- {preview.selectedContextCount} source passage{preview.selectedContextCount === 1 ? "" : "s"} selected. The
- final answer and source list remain authoritative.
-
);
}
diff --git a/src/components/clinical-dashboard/answer-progress.ts b/src/components/clinical-dashboard/answer-progress.ts
index 2bfd194dbd..4ab0cd2056 100644
--- a/src/components/clinical-dashboard/answer-progress.ts
+++ b/src/components/clinical-dashboard/answer-progress.ts
@@ -4,14 +4,6 @@ import { isDeliverableVerifiedUnit } from "@/lib/answer-stream-contract";
export type AnswerProgressUpdate = PublicAnswerProgressEvent;
export type TimedAnswerProgressUpdate = AnswerProgressUpdate & { receivedAt: number };
-export const answerProgressSteps = [
- { label: "Prepare scope", description: "Interpreting your question", stage: "scoping" },
- { label: "Search sources", description: "Scanning indexed clinical documents", stage: "retrieving" },
- { label: "Select evidence", description: "Prioritising relevant passages", stage: "ranking" },
- { label: "Draft answer", description: "Synthesising the response and citations", stage: "generating" },
- { label: "Check answer", description: "Checking citations and clinical details", stage: "verifying" },
-] as const;
-
const answerProgressStages = new Set([
"scoping",
"retrieving",
@@ -83,30 +75,53 @@ export function answerProgressStepIndex(stage: PublicAnswerProgressStage) {
return 4;
}
-/** UI copy is derived from the public stage/counts and never from an incoming message. */
+/** UI copy is derived from the public stage/counts and never from an incoming message.
+ *
+ * Written for a single quiet line rather than a stepper panel, so each string is
+ * a clause the reader can take in at a glance while waiting. Two rules hold it
+ * together:
+ *
+ * - **One noun per stage.** Retrieval counts *passages* (`resultCount`, every
+ * candidate chunk) and selection counts *sources* (the trimmed documents the
+ * rail actually shows). Those are different numbers, and using one word for
+ * both is how a reader ends up believing 24 documents are behind an answer
+ * that cites three.
+ * - **The unusual route says so while it is happening.** `fallback` means the
+ * answer is being assembled without the model, which on the only measurement
+ * in the handover was the majority case. The wait is the honest place to set
+ * that expectation — not the answer, which would then have to defend it.
+ */
export function answerProgressDisplayMessage(progress: AnswerProgressUpdate) {
- if (progress.stage === "scoping") return "Preparing the clinical search scope.";
- if (progress.stage === "retrieved" && progress.resultCount !== undefined) {
- return `Found ${progress.resultCount} candidate source passage${progress.resultCount === 1 ? "" : "s"}.`;
- }
+ if (progress.stage === "scoping") return "Reading your question\u2026";
if (progress.stage === "retrieving" || progress.stage === "retrieved") {
- return "Searching indexed clinical documents.";
+ return progress.resultCount === undefined
+ ? "Searching your documents\u2026"
+ : `Searching your documents \u00b7 ${progress.resultCount} passage${progress.resultCount === 1 ? "" : "s"} found`;
}
if (progress.stage === "ranking") {
if (progress.australianSourceCount) {
- const waDetail = progress.waSourceCount ? `, including ${progress.waSourceCount} WA` : "";
- return `Prioritising ${progress.australianSourceCount} Australian source passage${progress.australianSourceCount === 1 ? "" : "s"}${waDetail}.`;
+ const waDetail = progress.waSourceCount ? `, ${progress.waSourceCount} from WA` : "";
+ return `Prioritising ${progress.australianSourceCount} Australian source${progress.australianSourceCount === 1 ? "" : "s"}${waDetail}`;
}
- return "Selecting the most relevant source passages.";
- }
- if (progress.stage === "retrying") {
- return "The draft needs another pass; revising it against the evidence.";
- }
- if (progress.stage === "fallback") {
- return "Building a source-backed answer from the selected passages.";
+ return "Selecting the most relevant passages\u2026";
}
- if (progress.stage === "generating") return "Drafting a cited answer from the selected passages.";
- if (progress.stage === "verifying") return "Checking citations, clinical numbers, and source metadata.";
- if (progress.stage === "cached") return "Loading a recent cited answer.";
+ if (progress.stage === "retrying") return "Revising the draft against the evidence\u2026";
+ if (progress.stage === "fallback") return "Assembling the answer from the sources directly\u2026";
+ if (progress.stage === "generating") return "Writing the answer\u2026";
+ if (progress.stage === "verifying") return "Checking citations and clinical numbers\u2026";
+ if (progress.stage === "cached") return "Loading a recent cited answer\u2026";
return "Answer ready.";
}
+
+/** The stages worth disclosing after the fact.
+ *
+ * A routine answer has nothing to explain — scope, search, select, write, check,
+ * in that order, every time — which is why the old Processing details disclosure
+ * held the same five lines for every question and nobody opened it. These three
+ * stages mean the answer did NOT take the ordinary route, and that is worth a
+ * reader being able to read back. */
+const disclosableStages = new Set(["retrying", "fallback", "cached"]);
+
+export function answerProgressTookUnusualRoute(events: readonly AnswerProgressUpdate[]) {
+ return events.some((event) => disclosableStages.has(event.stage));
+}
diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx
index 24d0d2733b..4e126151b3 100644
--- a/src/components/clinical-dashboard/answer-status.tsx
+++ b/src/components/clinical-dashboard/answer-status.tsx
@@ -1,20 +1,21 @@
"use client";
-import type { CSSProperties } from "react";
-import { Activity, Check, Clipboard, ClipboardCheck, History, Loader2, Square } from "lucide-react";
+import { useEffect, useState, type CSSProperties } from "react";
+import { Clipboard, ClipboardCheck, History, Square } from "lucide-react";
import {
answerProgressDisplayMessage,
- answerProgressStepIndex,
- answerProgressSteps,
+ answerProgressTookUnusualRoute,
type TimedAnswerProgressUpdate,
} from "@/components/clinical-dashboard/answer-progress";
+import { AnswerEvidencePreview } from "@/components/clinical-dashboard/answer-evidence-preview";
+import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract";
import { useClientTime } from "@/lib/use-client-time";
import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips";
import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences";
import { ModeHomeTemplate } from "@/components/mode-home-template";
import { ShowAllChip } from "@/components/show-all-chip";
-import { cn, floatingControl, sourceCard } from "@/components/ui-primitives";
+import { cn, floatingControl } from "@/components/ui-primitives";
import { appModeIcons } from "@/lib/app-mode-icons";
import type { AppModeId } from "@/lib/app-modes";
import { consolidatedModeSearchPath } from "@/lib/consolidated-mode-home-redirect";
@@ -128,32 +129,33 @@ function skeletonBar(className: string, staggerIndex: number) {
);
}
+/**
+ * The window between submit and the first progress event, and the lazy-load
+ * fallback for the dashboard chunk.
+ *
+ * It used to draw a bordered card, a source card with a tap-sized block, two
+ * pill placeholders and a two-column grid — a wireframe of an answer that has
+ * not been retrieved yet, promising a shape the payload may not produce (twenty
+ * of thirty answers in the 2026-08-18 blinded read carried no sections at all).
+ * Three prose bars make no promise beyond "text is coming", which is the only
+ * thing that is actually known at this point.
+ *
+ * It deliberately carries no status text. This renders in the answer's body
+ * slot while AnswerProgress renders the status line directly above it, and two
+ * indicators disagreeing on the same screen — "Writing the answer…" over
+ * "Reading your question…" — is worse than one. There is exactly one place that
+ * says what is happening.
+ *
+ * role=status so the window is still announced; without it a screen reader stays
+ * silent until AnswerProgress mounts with its own live region.
+ */
export function AnswerSkeleton() {
- // role=status (matching LoadingPanel) so the initial answer-pending window —
- // after submit but before the first progress event — is announced. Without it
- // the aria-label sits on a plain div and screen readers stay silent until the
- // progress stepper (its own role=status) mounts.
return (
-
@@ -166,134 +168,162 @@ function elapsedLabel(elapsedMs: number) {
}
/**
- * Single-line progress banner for the non-answer (library/document) search modes,
- * the flat sibling of AnswerProgressStepper.
+ * The whole animation, in one element.
*
- * The Stop control is the sourceCapsuleHit/sourceCapsule pattern from
- * ui-primitives: the button is an invisible 48px tap target and the inner span is
- * the compact visible pill. The banner carries no vertical padding, so a bare
- * `min-h-tap` button filled its whole content box and sat 1px off the banner
- * border; splitting the face out keeps 8px of clearance without shrinking the tap
- * target, and keeps the focus ring inside the banner.
+ * A 5px dot at the head of the status line, breathing on a 2.4s cycle. It
+ * replaces a `Loader2` spinner in the search banner and a scrolling ECG trace in
+ * the answer progress panel, and it is the only moving thing either surface now
+ * has.
+ *
+ * The reason it is a dot and not a spinner is the state it has to survive. The
+ * indicator must stay correct and clearly visible when motion is suppressed —
+ * that is a contract this repo learned the hard way, after Reduce Motion set the
+ * ECG trace to `opacity: 0` and left a dead panel on a physical iPhone while an
+ * answer was generating. A stopped dot is a bullet. A stopped spinner is a
+ * fragment of a circle.
+ *
+ * The animation itself lives in globals.css as `.answer-progress-dot`, not as a
+ * `motion-safe:` utility, because the in-app Motion preference has to be able to
+ * opt back IN over the OS request and a Tailwind media variant cannot be
+ * overridden by `html[data-motion="full"]`.
*/
-export function SearchProgressBanner({ message, onStop }: { message: string; onStop: () => void }) {
+function ProgressDot() {
+ // One colour, running or complete. A green dot on completion was a status hue
+ // carrying meaning that nothing else on the element repeated — and it was
+ // redundant besides, because the line beside it already changes to "Answer
+ // ready in 3s". Dropping it removes a colour-only signal and one more thing to
+ // look at.
+ //
+ // The 20px box is the line-height of the text it marks, so the dot sits on the
+ // optical centre of the first line without a nudge margin, and stays on the
+ // first line when the text wraps.
return (
-
-
- {message}
-
-
+
+
);
}
-type AnswerProgressDensity = "expanded" | "compact";
+/**
+ * The Stop control, as a quiet text control rather than a raised pill.
+ *
+ * Kept at a 48px tap target with an 8px-tall visible face, the same
+ * hit-area-larger-than-face pattern the raised pill used, so nothing about
+ * reachability changes — only the weight.
+ */
+function StopControl({ onStop }: { onStop: () => void }) {
+ return (
+
+ );
+}
-const answerActivityPath =
- "M0 24 H46 L52 23 L57 7 L64 37 L72 24 H122 L128 23 L133 4 L141 40 L149 24 H198 L204 23 L209 9 L216 35 L224 24 H272 L278 23 L283 10 L290 34 L298 24 H320";
+/** After this long the wait is worth naming as abnormal. Deliberately a single
+ * threshold rather than a running counter: the old panel re-rendered "Ns
+ * elapsed" every second in the one position the eye already rests on, which
+ * makes the wait the subject. Nothing can be done with the number while the
+ * search is healthy; "taking longer than usual" is the part that is actionable,
+ * and it is announced once. */
+const slowAnswerNoticeMs = 10_000;
-function AnswerActivityTrace({ density }: { density: AnswerProgressDensity }) {
- const compact = density === "compact";
+function useSlowNotice(active: boolean, startedAt: number | null) {
+ // The timer records WHICH run went slow rather than a bare boolean, so a new
+ // question clears the notice by identity instead of by a reset written into an
+ // effect body. Nothing is set synchronously during the effect.
+ const [slowRun, setSlowRun] = useState(null);
+ useEffect(() => {
+ if (!active || startedAt === null) return undefined;
+ const timer = window.setTimeout(() => setSlowRun(startedAt), slowAnswerNoticeMs);
+ return () => window.clearTimeout(timer);
+ }, [active, startedAt]);
+ return active && startedAt !== null && slowRun === startedAt;
+}
+/**
+ * Single-line progress for the non-answer (library/document) search modes, the
+ * flat sibling of AnswerProgress.
+ *
+ * It was a filled accent band with a spinning `Loader2`. Fill is how this app
+ * marks a hazard, and a search in flight is not one, so it is now the same quiet
+ * line the answer surface uses.
+ */
+export function SearchProgressBanner({ message, onStop }: { message: string; onStop: () => void }) {
return (
-
-
- {/* Two identical copies inside a 200%-wide strip. Each copy is `w-1/2` of the
- strip, i.e. exactly one container width, so the trace is not horizontally
- compressed. Translating the strip by -50% puts copy 2 where copy 1 was, so
- the loop is seamless and the resting (reduced-motion) frame at 0% is a
- correctly aligned full-width ECG rather than a blank box. */}
-
- {[0, 1].map((copy) => (
-
- ))}
-
-
+
+ {message}
+
+
);
}
-export function AnswerProgressStepper({
+/**
+ * The wait on the answer surface.
+ *
+ * Replaces `AnswerProgressStepper`: a filled accent panel carrying a 36px icon
+ * tile, a five-circle stepper with connecting rails, a scrolling ECG trace, a
+ * per-second elapsed counter and a Processing details disclosure. Six things
+ * were wrong with it, and the two that mattered are these — it narrated the
+ * orchestrator's five stages, which the reader is not operating, and it never
+ * showed a single source, even though the evidence preview crosses the stream
+ * boundary before the prose and is the most useful content this surface has.
+ *
+ * What is here instead is one status line and the sources arriving beneath it,
+ * drawn in the answer's own column at the answer's own size. Two consequences
+ * are the point of the design rather than side effects:
+ *
+ * - **Nothing jumps.** The old panel was ~210px tall and was removed, not
+ * transformed, when the answer arrived, so everything below it moved up by
+ * that distance at the exact moment the reader was given something to read.
+ * Here only the line changes; the rail stays where the eye settled.
+ * - **The rail degrades to nothing, not to a placeholder.** The preview unit is
+ * gated behind `NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER` (#100
+ * Phase 1) and is off by default, so today the line carries the accrual on
+ * its own via `resultCount` and the rail is simply absent. Nothing here
+ * fabricates a source to fill the space.
+ */
+export function AnswerProgress({
events,
startedAt,
active,
onStop,
- density = "expanded",
+ evidencePreview = null,
}: {
events: TimedAnswerProgressUpdate[];
startedAt: number | null;
active: boolean;
onStop: () => void;
- density?: AnswerProgressDensity;
+ evidencePreview?: VerifiedEvidencePreviewUnit | null;
}) {
const latest = events.at(-1) ?? null;
const finished = latest?.stage === "complete";
- const now = useClientTime({
- fallback: startedAt ?? 0,
- updateInterval: active && !finished && startedAt ? 1_000 : undefined,
- });
- const currentStep = latest ? answerProgressStepIndex(latest.stage) : 0;
- const clientElapsedMs = startedAt ? Math.max(0, (finished ? (latest?.receivedAt ?? now) : now) - startedAt) : 0;
- const elapsedMs = finished && latest?.elapsedMs !== undefined ? latest.elapsedMs : clientElapsedMs;
- const currentMessage = latest ? answerProgressDisplayMessage(latest) : "Preparing the clinical search scope.";
- const compact = density === "compact" && !finished;
- const stageProgress = currentStep / Math.max(1, answerProgressSteps.length - 1);
+ const running = active && !finished;
+ const slow = useSlowNotice(running, startedAt);
+ // Only read on completion, so the clock is sampled once rather than subscribed
+ // to at 1Hz for the whole wait.
+ const now = useClientTime({ fallback: startedAt ?? 0 });
+ const clientElapsedMs = startedAt ? Math.max(0, (latest?.receivedAt ?? now) - startedAt) : 0;
+ const elapsedMs = latest?.elapsedMs !== undefined ? latest.elapsedMs : clientElapsedMs;
+ const currentMessage = latest ? answerProgressDisplayMessage(latest) : "Reading your question…";
+ const unusualRoute = answerProgressTookUnusualRoute(events);
const details = events
.map((event) => ({ ...event, displayMessage: answerProgressDisplayMessage(event) }))
.filter((event, index, all) => index === 0 || event.displayMessage !== all[index - 1]?.displayMessage)
@@ -301,176 +331,43 @@ export function AnswerProgressStepper({
return (
-
- {finished
- ? "Answer generation complete."
- : `Answer generation moved to step ${currentStep + 1} of ${answerProgressSteps.length}: ${answerProgressSteps[currentStep]?.label ?? "Prepare scope"}.`}
-
-
- {compact ? : null}
-
-
- ) : null}
+ {evidencePreview ? : null}
- {finished || !compact ? (
-
-
- Processing details
+ {/* A routine answer has nothing to disclose — the old panel offered the same
+ five stages every time. These three stages mean the answer did not take
+ the ordinary route, which is the case a reader may actually want to read
+ back. */}
+ {finished && unusualRoute ? (
+
+
+ How this answer was built
{details.map((event, index) => (
diff --git a/src/lib/tailwind-merge.ts b/src/lib/tailwind-merge.ts
index 6d3bfb06e7..3ba16d36a4 100644
--- a/src/lib/tailwind-merge.ts
+++ b/src/lib/tailwind-merge.ts
@@ -125,8 +125,7 @@ export const CLINICAL_TWMERGE_THEME = {
"dialog-rise",
"action-tray-in",
"shimmer",
- "answer-ecg",
- "answer-ecg-compact",
+ "answer-progress-breath",
],
} as const;
diff --git a/tests/answer-activity-trace-css.test.ts b/tests/answer-activity-trace-css.test.ts
deleted file mode 100644
index 0e15afeffe..0000000000
--- a/tests/answer-activity-trace-css.test.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { readFileSync } from "node:fs";
-
-import { describe, expect, it } from "vitest";
-
-const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
-const answerStatusSource = readFileSync(
- new URL("../src/components/clinical-dashboard/answer-status.tsx", import.meta.url),
- "utf8",
-);
-
-function keyframes(name: string) {
- const start = globalsCss.indexOf(`@keyframes ${name}`);
- expect(start, `${name} keyframes are missing`).toBeGreaterThanOrEqual(0);
-
- let depth = 0;
- let opened = false;
- for (let index = start; index < globalsCss.length; index += 1) {
- if (globalsCss[index] === "{") {
- depth += 1;
- opened = true;
- } else if (globalsCss[index] === "}") {
- depth -= 1;
- if (opened && depth === 0) return globalsCss.slice(start, index + 1);
- }
- }
-
- throw new Error(`${name} keyframes are unterminated`);
-}
-
-describe("answer activity trace CSS", () => {
- it("does not paint-contain the animated SVG on WebKit", () => {
- expect(globalsCss).not.toMatch(/\.answer-activity-trace\s*{[^}]*contain:\s*paint;/s);
- });
-
- it("travels with transform instead of WebKit-unreliable SVG dash offsets", () => {
- const scroll = keyframes("answer-ecg-scroll");
-
- // translate3d only. A whole-line opacity pulse (the previous shape) technically
- // ran on WebKit but was too subtle on a phone hairline to read as motion, and
- // SVG dash offsets before that could animate without repainting at all.
- expect(scroll).toMatch(/transform:\s*translate3d\(0,\s*0,\s*0\);/);
- expect(scroll).toMatch(/transform:\s*translate3d\(-50%,\s*0,\s*0\);/);
- expect(scroll).not.toMatch(/stroke-dashoffset/);
- expect(scroll).not.toMatch(/opacity/);
- });
-
- it("hosts the animation on a regular HTML compositor layer instead of an SVG path", () => {
- expect(answerStatusSource).toMatch(/]*data-slot="answer-activity-trace-sweep"/s);
- expect(answerStatusSource).not.toMatch(/]*data-slot="answer-activity-trace-sweep"/s);
- expect(globalsCss).toMatch(/\.answer-activity-trace__sweep\s*{[^}]*will-change:\s*transform;/s);
- // The strip is 200% wide and holds two copies, so -50% is exactly one copy and
- // the loop has no seam. Without both halves the animation jumps every cycle.
- expect(answerStatusSource).toMatch(/answer-activity-trace__sweep[^"]*w-\[200%\]/);
- expect(answerStatusSource).toMatch(/\[0,\s*1\]\.map/);
- });
-
- it("gets its own compositor layer so WebKit repaints the moving strip", () => {
- expect(globalsCss).toMatch(/\.answer-activity-trace\s*{[^}]*isolation:\s*isolate;/s);
- expect(globalsCss).toMatch(/\.answer-activity-trace\s*{[^}]*transform:\s*translateZ\(0\);/s);
- });
-
- it("keeps the trace visible when motion is suppressed", () => {
- // Regression guard for the defect this file's earlier revisions missed entirely:
- // `opacity: 0` under reduced motion deleted the only progress indicator on the
- // panel, so every user with OS Reduce Motion on watched a frozen, blank box.
- // Suppressing motion must leave a static trace, never remove it.
- const suppressions = [...globalsCss.matchAll(/\.answer-activity-trace__sweep\s*{([^}]*)}/g)].map(
- (match) => match[1] ?? "",
- );
- const stopped = suppressions.filter((body) => /animation:\s*none/.test(body));
-
- expect(stopped.length, "expected reduced-motion and data-motion=reduced rules").toBeGreaterThanOrEqual(2);
- for (const body of stopped) {
- expect(body).not.toMatch(/opacity:\s*0\s*;/);
- expect(body).toMatch(/opacity:\s*0\.\d+;/);
- }
- });
-
- it("lets the in-app motion preference opt back in over the OS setting", () => {
- // iOS Reduce Motion is commonly on for reasons unrelated to vestibular
- // sensitivity. Without this override there is no in-app way to get the
- // answer-progress feedback back on a physical iPhone.
- expect(globalsCss).toMatch(/@custom-variant\s+motion-reduce\s*{/);
- expect(globalsCss).toMatch(/@custom-variant\s+motion-safe\s*{/);
- expect(globalsCss).toMatch(
- /@media \(prefers-reduced-motion: reduce\) {\s*html:not\(\[data-motion="full"\]\) \.answer-activity-trace__sweep/s,
- );
- // The universal suppression is what froze the step spinner; it must be gated too.
- expect(globalsCss).toMatch(/html:not\(\[data-motion="full"\]\) \*,/);
- });
-});
diff --git a/tests/answer-evidence-preview.dom.test.tsx b/tests/answer-evidence-preview.dom.test.tsx
index 197eee929a..5f402a9ac6 100644
--- a/tests/answer-evidence-preview.dom.test.tsx
+++ b/tests/answer-evidence-preview.dom.test.tsx
@@ -34,16 +34,47 @@ describe("incremental answer evidence preview", () => {
expect(incrementalEvidencePreviewRenderingEnabled("true")).toBe(true);
});
- it("renders a bounded, non-live evidence region without presenting a completed answer", () => {
- render();
+ it("renders a bounded, non-live rail without presenting a completed answer", () => {
+ render();
const region = screen.getByTestId("answer-evidence-preview");
- expect(
- within(region).getByRole("heading", { name: "Selected evidence — answer still being verified" }),
- ).toBeTruthy();
- expect(within(region).getByText(/4 source passages selected/i)).toBeTruthy();
- expect(within(region).getAllByRole("link")).toHaveLength(3);
+ // Six, matching the render policy's primary-source cap, not the nine offered.
+ expect(within(region).getAllByRole("link")).toHaveLength(6);
expect(region).not.toHaveAttribute("aria-live");
expect(within(region).queryByText(/answer ready/i)).toBeNull();
+ // The old panel announced itself with a heading and a sentence of
+ // explanation above the progress panel it duplicated. The rail is content,
+ // not a second region to read past.
+ expect(within(region).queryByRole("heading")).toBeNull();
+ });
+
+ // The single most important invariant on this surface. The preview is the top
+ // slice of retrieval in retrieval order; the final list is rebuilt from what
+ // the answer actually cites and re-capped by trust. A number assigned here can
+ // therefore point at a different document once the answer lands, which is the
+ // precise failure the citation design exists to prevent.
+ it("never numbers a source before the answer has decided the list", () => {
+ render();
+
+ const region = screen.getByTestId("answer-evidence-preview");
+ for (const card of within(region).getAllByTestId("answer-evidence-preview-source")) {
+ expect(card.textContent ?? "").not.toMatch(/(?:^|\s)[1-9]\s*[.:)]?\s*Clinical guideline/);
+ expect(card.querySelector("[aria-hidden='true']")?.textContent?.trim()).toBe("\u2022");
+ }
+ // The accessible name says so too, for a reader who never sees the dot.
+ expect(region.getAttribute("aria-label")).toMatch(/not yet numbered/i);
+ });
+
+ it("links every card to the exact page the passage came from", () => {
+ render();
+
+ const links = within(screen.getByTestId("answer-evidence-preview")).getAllByRole("link");
+ expect(links[0]?.getAttribute("href")).toBe("/documents/doc-1?page=2&chunk=chunk-1");
+ expect(links[1]?.getAttribute("href")).toBe("/documents/doc-2?page=3&chunk=chunk-2");
+ });
+
+ it("renders nothing rather than an empty frame when the preview carries no sources", () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
});
});
diff --git a/tests/answer-progress-indicator-css.test.ts b/tests/answer-progress-indicator-css.test.ts
new file mode 100644
index 0000000000..fae549d00e
--- /dev/null
+++ b/tests/answer-progress-indicator-css.test.ts
@@ -0,0 +1,118 @@
+import { readFileSync } from "node:fs";
+
+import { describe, expect, it } from "vitest";
+
+const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
+const answerStatusSource = readFileSync(
+ new URL("../src/components/clinical-dashboard/answer-status.tsx", import.meta.url),
+ "utf8",
+);
+
+/**
+ * The successor to `answer-activity-trace-css.test.ts`, which pinned the
+ * scrolling ECG strip the answer-progress panel used to draw. That component was
+ * deleted when the wait was redrawn as a single quiet line, so its CSS contract
+ * went with it — but the *reason* that file existed did not, and this file
+ * carries it forward.
+ *
+ * The defect it guards is real and was reported from a physical iPhone: with OS
+ * Reduce Motion on, the app's own reduced-motion CSS stopped every animation and
+ * additionally set the trace to `opacity: 0`, so the only progress indicator on
+ * the surface disappeared and users watched a dead panel while an answer
+ * generated. Suppressing motion must never remove the indicator.
+ *
+ * The new indicator is a 5px dot whose animation is an opacity breath. That
+ * choice is what makes the guarantee cheap to hold: a stopped dot is a correct,
+ * fully visible bullet, whereas a stopped spinner is a fragment of a circle.
+ */
+function keyframes(name: string) {
+ const start = globalsCss.indexOf(`@keyframes ${name}`);
+ expect(start, `${name} keyframes are missing`).toBeGreaterThanOrEqual(0);
+
+ let depth = 0;
+ let opened = false;
+ for (let index = start; index < globalsCss.length; index += 1) {
+ if (globalsCss[index] === "{") {
+ depth += 1;
+ opened = true;
+ } else if (globalsCss[index] === "}") {
+ depth -= 1;
+ if (opened && depth === 0) return globalsCss.slice(start, index + 1);
+ }
+ }
+
+ throw new Error(`${name} keyframes are unterminated`);
+}
+
+function dotRuleBodies() {
+ return [...globalsCss.matchAll(/\.answer-progress-dot\s*{([^}]*)}/g)].map((match) => match[1] ?? "");
+}
+
+describe("answer progress indicator CSS", () => {
+ it("never animates the indicator to invisible", () => {
+ const breath = keyframes("answer-progress-breath");
+ const opacities = [...breath.matchAll(/opacity:\s*([\d.]+)\s*;/g)].map((match) => Number(match[1]));
+
+ expect(opacities.length, "the breath is an opacity animation").toBeGreaterThanOrEqual(2);
+ for (const opacity of opacities) {
+ expect(opacity).toBeGreaterThan(0.2);
+ }
+ });
+
+ it("leaves the indicator fully visible when motion is suppressed", () => {
+ // The regression this whole file exists for. A stopped animation must leave a
+ // painted dot, and the resting frame must not be a faded one either — there
+ // is no strip to soften here, so the dot simply sits at full opacity.
+ const stopped = dotRuleBodies().filter((body) => /animation:\s*none/.test(body));
+
+ expect(stopped.length, "expected reduced-motion and data-motion=reduced rules").toBeGreaterThanOrEqual(2);
+ for (const body of stopped) {
+ expect(body).not.toMatch(/opacity:\s*0\s*;/);
+ expect(body).toMatch(/opacity:\s*1\s*;/);
+ expect(body).not.toMatch(/display:\s*none/);
+ expect(body).not.toMatch(/visibility:\s*hidden/);
+ }
+ });
+
+ it("lets the in-app motion preference opt back in over the OS setting", () => {
+ // iOS Reduce Motion is commonly on for reasons unrelated to vestibular
+ // sensitivity. Without this override there is no in-app way to get the
+ // answer-progress feedback back on a physical iPhone. A Tailwind
+ // `motion-safe:` variant cannot express this, which is why the animation
+ // lives in globals.css rather than on the element.
+ expect(globalsCss).toMatch(/@custom-variant\s+motion-reduce\s*{/);
+ expect(globalsCss).toMatch(/@custom-variant\s+motion-safe\s*{/);
+ expect(globalsCss).toMatch(
+ /@media \(prefers-reduced-motion: reduce\) {\s*html:not\(\[data-motion="full"\]\) \.answer-progress-dot/s,
+ );
+ // The universal suppression is what froze the old step spinner; it must be gated too.
+ expect(globalsCss).toMatch(/html:not\(\[data-motion="full"\]\) \*,/);
+ });
+
+ it("hosts the indicator on the element the component actually renders", () => {
+ // A CSS contract that no markup opts into is a comment. Both the progress
+ // line and the pre-first-event skeleton carry the class.
+ // Exactly one. The skeleton that renders in the answer's body slot during the
+ // same window carries no indicator and no status text of its own, because two
+ // indicators disagreeing on one screen is worse than one.
+ expect([...answerStatusSource.matchAll(/data-slot="answer-progress-dot"/g)]).toHaveLength(1);
+ // …and it opts into the CSS contract by class, not only by data-slot.
+ expect(answerStatusSource).toContain("answer-progress-dot grid");
+ });
+
+ it("keeps the retired ECG trace and its animation deleted", () => {
+ // The component, its CSS, its two animation tokens and its keyframes were
+ // removed together. A partial revival — markup without the compositor rules,
+ // or rules without the reduced-motion guard above — is how the original
+ // defect got shipped.
+ for (const retired of [
+ "answer-activity-trace",
+ "answer-ecg-scroll",
+ "--animate-answer-ecg",
+ "--animate-answer-ecg-compact",
+ ]) {
+ expect(globalsCss).not.toContain(retired);
+ }
+ expect(answerStatusSource).not.toContain("answer-activity-trace");
+ });
+});
diff --git a/tests/answer-progress-ui-smoke.spec.ts b/tests/answer-progress-ui-smoke.spec.ts
index 0f95649633..9a1d6580b9 100644
--- a/tests/answer-progress-ui-smoke.spec.ts
+++ b/tests/answer-progress-ui-smoke.spec.ts
@@ -351,7 +351,7 @@ async function installSuccessfulThenHoldingAnswerStreams(page: Page) {
);
}
-test("answer progress remains user-safe through fallback and keeps a compact completed state", async ({ page }) => {
+test("answer progress remains user-safe through fallback and discloses the unusual route", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 820 });
await mockDashboardApis(page);
await installTimedAnswerStream(page);
@@ -360,65 +360,43 @@ test("answer progress remains user-safe through fallback and keeps a compact com
const submit = await fillHydratedAnswerQuestion(page, "Lithium dosing");
await submit.click();
- const progress = page.getByTestId("answer-progress-stepper");
+ const progress = page.getByTestId("answer-progress");
+ const line = progress.getByTestId("answer-progress-line");
await expect(progress).toBeVisible();
await expect(progress).toHaveAttribute("aria-busy", "true");
- await expect(progress).toHaveAttribute("data-density", "expanded");
- const activityTrace = progress.getByTestId("answer-activity-trace");
- await expect(activityTrace).toHaveAttribute("data-density", "expanded");
- await expect(activityTrace.locator('[data-slot="answer-activity-trace-sweep"]')).toHaveCount(1);
- await expect(progress.getByText("Creating your cited answer", { exact: true })).toBeVisible();
- for (const label of ["Prepare scope", "Search sources", "Select evidence", "Draft answer", "Check answer"]) {
- await expect(progress.getByText(label, { exact: true })).toBeVisible();
- }
- for (const description of [
- "Interpreting your question",
- "Scanning indexed clinical documents",
- "Prioritising relevant passages",
- "Synthesising the response and citations",
- "Checking citations and clinical details",
+ // The line is the live region, because it is the element that persists while
+ // its text is replaced.
+ await expect(line).toHaveAttribute("aria-live", "polite");
+ await expect(progress.locator('[data-slot="answer-progress-dot"]')).toBeVisible();
+
+ // The retired panel narrated five orchestrator stages the reader is not
+ // operating. None of that vocabulary may come back.
+ for (const retired of [
+ "Prepare scope",
+ "Search sources",
+ "Select evidence",
+ "Draft answer",
+ "Check answer",
+ "Processing details",
]) {
- await expect(progress.getByText(description, { exact: true })).toBeVisible();
+ await expect(progress.getByText(retired, { exact: true })).toHaveCount(0);
}
+ await expect(progress.getByLabel("Answer generation stages")).toHaveCount(0);
+
const stop = progress.getByRole("button", { name: "Stop generating answer" });
await expect(stop).toBeVisible();
expect((await stop.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(48);
- await expect(progress).toContainText("Prioritising 4 Australian source passages, including 4 WA", {
- timeout: 3_000,
- });
- await expect(progress).toContainText("Drafting a cited answer from the selected passages", { timeout: 4_000 });
- const stageRail = progress.getByLabel("Answer generation stages");
- const currentStage = stageRail.locator('li[data-state="current"]');
- await expect(currentStage).toContainText("Draft answer");
- const compactStageGeometry = await stageRail.evaluate((rail) => {
- const railRect = rail.getBoundingClientRect();
- const stageRects = [...rail.querySelectorAll("li")].map((stage) => stage.getBoundingClientRect());
- return {
- clientWidth: rail.clientWidth,
- scrollWidth: rail.scrollWidth,
- railLeft: railRect.left,
- railRight: railRect.right,
- stageLefts: stageRects.map((stage) => stage.left),
- stageRights: stageRects.map((stage) => stage.right),
- };
- });
- expect(compactStageGeometry.scrollWidth).toBeLessThanOrEqual(compactStageGeometry.clientWidth + 1);
- expect(Math.min(...compactStageGeometry.stageLefts)).toBeGreaterThanOrEqual(compactStageGeometry.railLeft - 1);
- expect(Math.max(...compactStageGeometry.stageRights)).toBeLessThanOrEqual(compactStageGeometry.railRight + 1);
-
- await page.setViewportSize({ width: 1440, height: 1000 });
- const wideStageGeometry = await stageRail.evaluate((rail) => {
- const stageRects = [...rail.querySelectorAll("li")].map((stage) => stage.getBoundingClientRect());
- return {
- clientWidth: rail.clientWidth,
- scrollWidth: rail.scrollWidth,
- stageTops: stageRects.map((stage) => stage.top),
- };
- });
- expect(wideStageGeometry.scrollWidth).toBeLessThanOrEqual(wideStageGeometry.clientWidth + 1);
- expect(Math.max(...wideStageGeometry.stageTops) - Math.min(...wideStageGeometry.stageTops)).toBeLessThanOrEqual(1);
- await expect(progress).toContainText("Building a source-backed answer", { timeout: 5_000 });
+ // Retrieval counts passages; selection counts sources. The two nouns must not
+ // be interchangeable — see tests/answer-progress.test.ts for the rule.
+ await expect(line).toContainText("12 passages found", { timeout: 3_000 });
+ await expect(line).toContainText("Prioritising 4 Australian sources, 4 from WA", { timeout: 3_000 });
+ await expect(line).toContainText("Writing the answer", { timeout: 4_000 });
+
+ // The wait is where the reader learns the model was not used, rather than
+ // meeting a source-only answer that then has to defend itself.
+ await expect(line).toContainText("Assembling the answer from the sources directly", { timeout: 5_000 });
+
// Rolling deployments may still route a new client to an older server that
// emits provisional token/revising frames. The client must ignore both so
// unvalidated clinical prose never reaches the page before the final event.
@@ -427,18 +405,23 @@ test("answer progress remains user-safe through fallback and keeps a compact com
await expect(page.getByText("Provisional lithium draft")).toHaveCount(0);
await expect(progress).toHaveAttribute("data-progress-state", "complete", { timeout: 6_000 });
- await expect(progress).toHaveAttribute("data-density", "complete");
- await expect(activityTrace).toHaveCount(0);
- await expect(progress).toContainText("Answer ready in 3s");
- await expect(progress.getByText("Processing details", { exact: true })).toBeVisible();
+ await expect(line).toContainText("Answer ready in 3s");
await expect(page.getByTestId("stop-answer")).toHaveCount(0);
+
+ // This run went through `fallback`, so the build disclosure is offered. On an
+ // ordinary run it is not — pinned in the follow-up test below.
+ const disclosure = progress.getByText("How this answer was built", { exact: true });
+ await expect(disclosure).toBeVisible();
+ await disclosure.click();
+ await expect(progress).toContainText("Assembling the answer from the sources directly");
+
await expect(page.getByText(/In the synthetic lithium document/i)).toBeVisible({ timeout: 8_000 });
await expect(page.locator("body")).not.toContainText(
/private-(?:model|route|provider-reason|fallback|draft|check|ready)-marker/i,
);
});
-test("follow-up answer generation stays compact above the previous answer", async ({ page }) => {
+test("follow-up answer generation stays one line above the previous answer", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 820 });
await page.emulateMedia({ reducedMotion: "no-preference" });
await mockDashboardApis(page);
@@ -452,60 +435,62 @@ test("follow-up answer generation stays compact above the previous answer", asyn
const previousAnswer = page.getByText(/In the synthetic lithium document/i);
await expect(previousAnswer).toBeVisible({ timeout: 8_000 });
+ const progress = page.getByTestId("answer-progress");
+ // The first answer took the ordinary route, so nothing is disclosed about it.
+ await expect(progress.getByText("How this answer was built", { exact: true })).toHaveCount(0);
+
const followUpSubmit = await fillHydratedAnswerQuestion(page, "What monitoring is needed?");
await followUpSubmit.click();
- const progress = page.getByTestId("answer-progress-stepper");
- await expect(progress).toHaveAttribute("data-density", "compact");
- await expect(progress.getByText("Creating cited answer", { exact: true })).toBeVisible();
- await expect(progress).toContainText("Step 4 of 5 · Draft answer");
+ await expect(progress.getByTestId("answer-progress-line")).toContainText("Writing the answer");
await expect(previousAnswer).toBeVisible();
await expect(progress.getByLabel("Answer generation stages")).toHaveCount(0);
- await expect(progress.getByText("Processing details", { exact: true })).toHaveCount(0);
- const activityTrace = progress.getByTestId("answer-activity-trace");
- await expect(activityTrace).toHaveAttribute("data-density", "compact");
- const compactSweep = activityTrace.locator('[data-slot="answer-activity-trace-sweep"]');
- await expect(compactSweep).toHaveCount(1);
+ const dot = progress.locator('[data-slot="answer-progress-dot"]');
expect(
- await compactSweep.evaluate((trace) => {
- const style = getComputedStyle(trace);
+ await dot.evaluate((node) => {
+ const style = getComputedStyle(node);
return {
+ name: style.animationName,
duration: style.animationDuration,
iterationCount: style.animationIterationCount,
- timingFunction: style.animationTimingFunction,
};
}),
- ).toEqual({ duration: "2.6s", iterationCount: "infinite", timingFunction: "linear" });
+ ).toEqual({ name: "answer-progress-breath", duration: "2.4s", iterationCount: "infinite" });
+
const stop = progress.getByRole("button", { name: "Stop generating answer" });
expect((await stop.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(48);
+ // The wait must not introduce a horizontal scrollbar at any supported width,
+ // and it must stay inside its own column. The retired panel was ~210px tall;
+ // the line is a fraction of that, which is the point — assert it stays small
+ // so a future addition cannot quietly grow a panel back.
for (const width of [320, 390, 639, 768, 1440, 1920]) {
await page.setViewportSize({ width, height: width < 768 ? 844 : 1000 });
const geometry = await progress.evaluate((section) => {
- const trace = section.querySelector('[data-testid="answer-activity-trace"]');
const sectionRect = section.getBoundingClientRect();
- const traceRect = trace?.getBoundingClientRect();
+ const lineRect = section
+ .querySelector('[data-testid="answer-progress-line"]')
+ ?.getBoundingClientRect();
return {
bodyClientWidth: document.body.clientWidth,
bodyScrollWidth: document.body.scrollWidth,
sectionLeft: sectionRect.left,
sectionRight: sectionRect.right,
- traceLeft: traceRect?.left ?? 0,
- traceRight: traceRect?.right ?? 0,
- traceHeight: traceRect?.height ?? 0,
+ sectionHeight: sectionRect.height,
+ lineLeft: lineRect?.left ?? 0,
+ lineRight: lineRect?.right ?? 0,
};
});
expect(geometry.bodyScrollWidth).toBeLessThanOrEqual(geometry.bodyClientWidth + 1);
- expect(geometry.traceLeft).toBeGreaterThanOrEqual(geometry.sectionLeft - 1);
- expect(geometry.traceRight).toBeLessThanOrEqual(geometry.sectionRight + 1);
- expect(geometry.traceHeight).toBeLessThanOrEqual(21);
+ expect(geometry.lineLeft).toBeGreaterThanOrEqual(geometry.sectionLeft - 1);
+ expect(geometry.lineRight).toBeLessThanOrEqual(geometry.sectionRight + 1);
+ expect(geometry.sectionHeight).toBeLessThanOrEqual(96);
}
await stop.press("Enter");
await expect(page.getByTestId("answer-cancelled")).toBeVisible();
await expect(previousAnswer).toBeVisible();
- await expect(activityTrace).toHaveCount(0);
});
test("a completion frame cannot mark a previous answer complete when final is invalid", async ({ page }) => {
@@ -518,7 +503,7 @@ test("a completion frame cannot mark a previous answer complete when final is in
await submit.click();
await expect(page.getByText(/In the synthetic lithium document/i)).toBeVisible({ timeout: 8_000 });
- await expect(page.getByTestId("answer-progress-stepper")).toHaveAttribute("data-progress-state", "complete");
+ await expect(page.getByTestId("answer-progress")).toHaveAttribute("data-progress-state", "complete");
const followUpSubmit = await fillHydratedAnswerQuestion(page, "What about monitoring?");
await followUpSubmit.click();
@@ -526,7 +511,6 @@ test("a completion frame cannot mark a previous answer complete when final is in
await expect(page.getByTestId("answer-error")).toContainText("Answer stream returned an invalid final payload", {
timeout: 10_000,
});
- await expect(page.getByTestId("answer-activity-trace")).toHaveCount(0);
await expect(page.locator('[data-progress-state="complete"]')).toHaveCount(0);
await expect(page.getByText(/Answer ready in/)).toHaveCount(0);
await expect(page.getByText(/In the synthetic lithium document/i)).toBeVisible();
@@ -543,77 +527,70 @@ test("answer progress keeps focus, reduced-motion, and forced-colour behavior in
const submit = await fillHydratedAnswerQuestion(page, "Lithium dosing");
await submit.click();
- const progress = page.getByTestId("answer-progress-stepper");
- const currentStage = progress.getByLabel("Answer generation stages").locator('li[data-state="current"]');
- await expect(currentStage).toContainText("Draft answer");
-
- const activeSpinner = currentStage.locator("svg");
- const activityTraceSweep = progress.locator('[data-slot="answer-activity-trace-sweep"]');
- const activityTraceBase = progress.locator('[data-slot="answer-activity-trace-base"]');
- await expect(activeSpinner).toBeVisible();
- await expect(activityTraceSweep).toBeVisible();
- await expect(activityTraceBase).toBeVisible();
- expect(await activeSpinner.evaluate((spinner) => getComputedStyle(spinner).animationName)).toBe("none");
- expect(await activityTraceSweep.evaluate((trace) => getComputedStyle(trace).animationName)).toBe("none");
- // Suppressing motion must not delete the indicator. This previously resolved to
- // "0", which left everyone with OS Reduce Motion staring at a blank, frozen panel.
- expect(await activityTraceSweep.evaluate((trace) => getComputedStyle(trace).opacity)).toBe("0.55");
-
+ const progress = page.getByTestId("answer-progress");
+ const line = progress.getByTestId("answer-progress-line");
+ const dot = progress.locator('[data-slot="answer-progress-dot"]');
+ await expect(line).toContainText("Writing the answer");
+ await expect(dot).toBeVisible();
+
+ // Suppressing motion must not delete the indicator. The retired ECG sweep
+ // resolved to opacity 0 here, which left everyone with OS Reduce Motion
+ // staring at a blank, frozen panel on a physical iPhone. A dot has a correct
+ // resting frame, so the guarantee is simply full opacity.
+ expect(await dot.evaluate((node) => getComputedStyle(node).animationName)).toBe("none");
+ expect(await dot.evaluate((node) => getComputedStyle(node).opacity)).toBe("1");
+ const restingBox = await dot.boundingBox();
+ expect(restingBox?.width ?? 0).toBeGreaterThan(0);
+ expect(restingBox?.height ?? 0).toBeGreaterThan(0);
+
+ // Stop is the only control in the running state, and it is reachable and
+ // operable from the keyboard.
const stop = progress.getByRole("button", { name: "Stop generating answer" });
- const details = progress.getByText("Processing details", { exact: true });
await stop.focus();
- await page.keyboard.press("Tab");
- await expect(details).toBeFocused();
- await page.keyboard.press("Shift+Tab");
await expect(stop).toBeFocused();
await page.emulateMedia({ reducedMotion: "no-preference" });
- expect(await activeSpinner.evaluate((spinner) => getComputedStyle(spinner).animationName)).not.toBe("none");
expect(
- await activityTraceSweep.evaluate((trace) => {
- const style = getComputedStyle(trace);
+ await dot.evaluate((node) => {
+ const style = getComputedStyle(node);
return {
name: style.animationName,
duration: style.animationDuration,
iterationCount: style.animationIterationCount,
- timingFunction: style.animationTimingFunction,
};
}),
- ).toEqual({
- name: "answer-ecg-scroll",
- duration: "3.2s",
- iterationCount: "infinite",
- timingFunction: "linear",
- });
- const restingTransform = await activityTraceSweep.evaluate(async (trace) => {
- const animation = trace.getAnimations()[0];
+ ).toEqual({ name: "answer-progress-breath", duration: "2.4s", iterationCount: "infinite" });
+
+ const restingOpacity = await dot.evaluate(async (node) => {
+ const animation = node.getAnimations()[0];
animation.pause();
animation.currentTime = 0;
await new Promise(requestAnimationFrame);
- return getComputedStyle(trace).transform;
+ return getComputedStyle(node).opacity;
});
- const restingPixels = await activityTraceSweep.screenshot();
- const midTransform = await activityTraceSweep.evaluate(async (trace) => {
- const animation = trace.getAnimations()[0];
- animation.currentTime = 1_600;
+ const restingPixels = await dot.screenshot();
+ const midOpacity = await dot.evaluate(async (node) => {
+ const animation = node.getAnimations()[0];
+ animation.currentTime = 1_200;
await new Promise(requestAnimationFrame);
- return getComputedStyle(trace).transform;
+ return getComputedStyle(node).opacity;
});
- const midPixels = await activityTraceSweep.screenshot();
- // The strip actually moves: a matrix translate, not the identity, and a raster
- // that genuinely differs. Computed style alone was never enough — the previous
- // animation satisfied every computed-style assertion while reading as static.
- expect(restingTransform).toBe("matrix(1, 0, 0, 1, 0, 0)");
- expect(midTransform).not.toBe(restingTransform);
- expect(midTransform).toMatch(/^matrix\(1, 0, 0, 1, -\d/);
- expect(restingPixels.equals(midPixels), "the WebKit raster must visibly change as the strip travels").toBe(false);
+ const midPixels = await dot.screenshot();
+ // The breath actually breathes: a different computed opacity AND a raster that
+ // genuinely differs. Computed style alone was never enough — the animation this
+ // replaced satisfied every computed-style assertion while reading as static.
+ expect(restingOpacity).toBe("1");
+ expect(midOpacity).not.toBe(restingOpacity);
+ expect(Number.parseFloat(midOpacity)).toBeGreaterThan(0.2);
+ expect(restingPixels.equals(midPixels), "the WebKit raster must visibly change as the dot breathes").toBe(false);
await page.emulateMedia({ reducedMotion: "reduce", forcedColors: "active" });
- await expect(currentStage.locator('[data-slot="answer-progress-stage-marker"]')).toBeVisible();
- await expect(currentStage.getByText("Draft answer", { exact: true })).toBeVisible();
- await expect(progress.getByTestId("answer-activity-trace")).toBeVisible();
- expect(await activeSpinner.evaluate((spinner) => getComputedStyle(spinner).animationName)).toBe("none");
- expect(await activityTraceSweep.evaluate((trace) => getComputedStyle(trace).animationName)).toBe("none");
+ // Forced colours paint neither the token background nor the animation, so the
+ // dot declares a system colour of its own. Without it the only indicator on the
+ // surface disappears for high-contrast users.
+ await expect(dot).toBeVisible();
+ await expect(line).toContainText("Writing the answer");
+ expect(await dot.evaluate((node) => getComputedStyle(node).animationName)).toBe("none");
await stop.press("Enter");
await expect(page.getByTestId("answer-cancelled")).toBeVisible();
diff --git a/tests/answer-progress.test.ts b/tests/answer-progress.test.ts
index bec5014a8c..a4646f91c8 100644
--- a/tests/answer-progress.test.ts
+++ b/tests/answer-progress.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
answerProgressDisplayMessage,
answerProgressStepIndex,
+ answerProgressTookUnusualRoute,
normalizeAnswerProgressEvent,
} from "../src/components/clinical-dashboard/answer-progress";
import { toPublicAnswerProgressEvent } from "../src/lib/answer-progress-public";
@@ -103,7 +104,7 @@ describe("answer progress events", () => {
const progress = normalizeAnswerProgressEvent({ message: "Selected fast route using private-model-marker." });
expect(progress).toMatchObject({ stage: "ranking" });
- expect(answerProgressDisplayMessage(progress!)).toBe("Selecting the most relevant source passages.");
+ expect(answerProgressDisplayMessage(progress!)).toBe("Selecting the most relevant passages\u2026");
expect(answerProgressDisplayMessage(progress!)).not.toMatch(/fast|private|model|route/i);
});
@@ -116,9 +117,54 @@ describe("answer progress events", () => {
waSourceCount: 4,
});
- expect(answerProgressDisplayMessage(progress!)).toBe("Prioritising 4 Australian source passages, including 4 WA.");
+ expect(answerProgressDisplayMessage(progress!)).toBe("Prioritising 4 Australian sources, 4 from WA");
expect(answerProgressStepIndex("fallback")).toBe(3);
- expect(answerProgressDisplayMessage({ stage: "fallback", message: "private" })).toContain("source-backed answer");
+ expect(answerProgressDisplayMessage({ stage: "fallback", message: "private" })).toBe(
+ "Assembling the answer from the sources directly\u2026",
+ );
+ });
+
+ // The status line counts two different things and must never call them the same
+ // thing: `resultCount` is every candidate chunk retrieval touched, while the
+ // arriving rail shows the trimmed sources. Collapsing both into one noun is how
+ // a reader ends up believing two dozen documents are behind an answer that
+ // cites three.
+ it("counts passages during retrieval and never calls them sources", () => {
+ const line = (stage: "retrieving" | "retrieved", resultCount?: number) =>
+ answerProgressDisplayMessage({
+ stage,
+ message: "private",
+ ...(resultCount === undefined ? {} : { resultCount }),
+ });
+
+ expect(line("retrieving")).toBe("Searching your documents\u2026");
+ expect(line("retrieved")).toBe("Searching your documents\u2026");
+ expect(line("retrieved", 1)).toBe("Searching your documents \u00b7 1 passage found");
+ expect(line("retrieved", 24)).toBe("Searching your documents \u00b7 24 passages found");
+ expect(line("retrieved", 24)).not.toMatch(/source/i);
+ });
+
+ // The wait is where a reader should learn the answer is being assembled without
+ // the model, rather than meeting a source-only answer that then has to defend
+ // itself.
+ it("names the unusual route while it is happening", () => {
+ expect(answerProgressDisplayMessage({ stage: "fallback", message: "private" })).toMatch(/sources directly/);
+ expect(answerProgressDisplayMessage({ stage: "retrying", message: "private" })).toMatch(/Revising the draft/);
+ });
+
+ // A routine answer has nothing to disclose, which is why the retired Processing
+ // details panel held the same five stages for every question. Only a
+ // non-ordinary route earns the disclosure.
+ it("offers the build disclosure only when the answer left the ordinary route", () => {
+ const ordinary = (["scoping", "retrieving", "ranking", "generating", "verifying", "complete"] as const).map(
+ (stage) => ({ stage, message: "private" }),
+ );
+
+ expect(answerProgressTookUnusualRoute(ordinary)).toBe(false);
+ expect(answerProgressTookUnusualRoute([...ordinary, { stage: "fallback", message: "private" }])).toBe(true);
+ expect(answerProgressTookUnusualRoute([...ordinary, { stage: "retrying", message: "private" }])).toBe(true);
+ expect(answerProgressTookUnusualRoute([...ordinary, { stage: "cached", message: "private" }])).toBe(true);
+ expect(answerProgressTookUnusualRoute([])).toBe(false);
});
it("rejects invalid progress objects and clamps safe counts", () => {
diff --git a/tests/playwright-motion-emulation-contract.test.ts b/tests/playwright-motion-emulation-contract.test.ts
index 1aa2caec23..0b85c411ee 100644
--- a/tests/playwright-motion-emulation-contract.test.ts
+++ b/tests/playwright-motion-emulation-contract.test.ts
@@ -52,15 +52,17 @@ describe("playwright motion emulation contract (#75JA0P)", () => {
expect(globalsCss).toContain('html:not([data-motion="full"])');
expect(globalsCss).toContain('html[data-motion="reduced"]');
- // ECG trace sweep suppression must not delete the ink
- const sweepRules = [...globalsCss.matchAll(/\.answer-activity-trace__sweep\s*\{([^}]*)\}/g)].map(
- (match) => match[1] ?? "",
- );
- const stoppedRules = sweepRules.filter((body) => /animation:\s*none/.test(body));
+ // Answer-progress indicator suppression must not delete the ink. The
+ // indicator used to be a scrolling ECG strip that reduced motion faded to
+ // 0.55; it is now a breathing dot that simply stops at full opacity. Either
+ // way the rule this asserts is the same one: stopping the animation must
+ // leave something painted.
+ const dotRules = [...globalsCss.matchAll(/\.answer-progress-dot\s*\{([^}]*)\}/g)].map((match) => match[1] ?? "");
+ const stoppedRules = dotRules.filter((body) => /animation:\s*none/.test(body));
expect(stoppedRules.length).toBeGreaterThanOrEqual(2);
for (const rule of stoppedRules) {
expect(rule).not.toMatch(/opacity:\s*0\s*;/);
- expect(rule).toMatch(/opacity:\s*0\.\d+;/);
+ expect(rule).toMatch(/opacity:\s*(?:1|0\.\d+)\s*;/);
}
});
});
diff --git a/tests/ui-phone-motion.spec.ts b/tests/ui-phone-motion.spec.ts
index 1cfd535173..d96111e52a 100644
--- a/tests/ui-phone-motion.spec.ts
+++ b/tests/ui-phone-motion.spec.ts
@@ -7,12 +7,16 @@ import { devices, expect, test, type Page } from "playwright/test";
* The answer-progress panel was reported dead on a physical iPhone in both Safari
* and the installed PWA. The cause was not a WebKit repaint bug — it was this
* app's own reduced-motion CSS, which stopped every animation and additionally set
- * the ECG trace to `opacity: 0`. Every existing gate missed it because
+ * the then-current ECG trace to `opacity: 0`. Every existing gate missed it because
* playwright.config.ts applies `reducedMotion: "reduce"` suite-wide and the one
* spec asserting the animation opts out to "no-preference" first, so the default
* user configuration was never exercised.
*
- * Two contracts are pinned here:
+ * Two contracts are pinned here, and they outlived the component that prompted
+ * them: the ECG trace has since been replaced by a breathing dot on the quiet
+ * progress line, so these tests now target `.answer-progress-dot`. The rules are
+ * unchanged, and the dot was chosen partly because it makes the first one easy to
+ * hold — a stopped dot is a bullet, where a stopped spinner is a broken circle.
* 1. Reduce Motion suppresses motion but must never remove the indicator.
* 2. The in-app Motion preference ("full") can opt back in over the OS request.
*
@@ -111,7 +115,7 @@ async function startAnswer(page: Page) {
await expect(submit).toBeEnabled();
}).toPass({ timeout: 30_000 });
await submit.click();
- return page.getByTestId("answer-progress-stepper");
+ return page.getByTestId("answer-progress");
}
// The device fields are picked explicitly rather than spread: the descriptor's
@@ -130,6 +134,33 @@ test.use({
});
test.describe("phone motion behaviour with OS Reduce Motion on", () => {
+ const dotOf = (progress: ReturnType) => progress.locator('[data-slot="answer-progress-dot"]');
+
+ /** Reads the computed state that decides whether an indicator is still there. */
+ const indicatorState = (dot: ReturnType) =>
+ dot.evaluate((node) => {
+ const style = getComputedStyle(node);
+ return {
+ animationName: style.animationName,
+ opacity: Number.parseFloat(style.opacity),
+ display: style.display,
+ visibility: style.visibility,
+ };
+ });
+
+ /** Proves the breath actually moves, rather than merely being declared. */
+ const breathTravel = (dot: ReturnType) =>
+ dot.evaluate(async (node) => {
+ const animation = node.getAnimations()[0];
+ animation.pause();
+ animation.currentTime = 0;
+ await new Promise(requestAnimationFrame);
+ const resting = getComputedStyle(node).opacity;
+ animation.currentTime = 1_200;
+ await new Promise(requestAnimationFrame);
+ return { resting, mid: getComputedStyle(node).opacity };
+ });
+
test("suppresses motion without hiding the progress indicator", async ({ page }) => {
await stubAnswerStream(page);
await seedMotionPreference(page, "system");
@@ -137,23 +168,23 @@ test.describe("phone motion behaviour with OS Reduce Motion on", () => {
const progress = await startAnswer(page);
await expect(progress).toBeVisible();
- const sweep = progress.locator('[data-slot="answer-activity-trace-sweep"]');
- const base = progress.locator('[data-slot="answer-activity-trace-base"]');
- await expect(sweep).toBeVisible();
- await expect(base).toBeVisible();
+ const dot = dotOf(progress);
+ await expect(dot).toBeVisible();
- const state = await sweep.evaluate((node) => {
- const style = getComputedStyle(node);
- return { animationName: style.animationName, opacity: Number.parseFloat(style.opacity) };
- });
+ const state = await indicatorState(dot);
expect(state.animationName).toBe("none");
- // The exact resting value is a design choice; that it is legible is the contract.
- expect(state.opacity).toBeGreaterThan(0.3);
+ // The whole point of a dot: its resting frame is the complete, correct mark.
+ expect(state.opacity).toBe(1);
+ expect(state.display).not.toBe("none");
+ expect(state.visibility).not.toBe("hidden");
- // A static trace still has to paint real ink, not an empty box.
- const box = await sweep.boundingBox();
+ // A stopped indicator still has to paint real ink, not an empty box.
+ const box = await dot.boundingBox();
expect(box?.width ?? 0).toBeGreaterThan(0);
expect(box?.height ?? 0).toBeGreaterThan(0);
+
+ // And the line it marks must still say what is happening.
+ await expect(progress.getByTestId("answer-progress-line")).toContainText(/\w/);
});
test("motion:full opts back in over the OS setting", async ({ page }) => {
@@ -163,27 +194,12 @@ test.describe("phone motion behaviour with OS Reduce Motion on", () => {
const progress = await startAnswer(page);
await expect(page.locator("html")).toHaveAttribute("data-motion", "full");
- const sweep = progress.locator('[data-slot="answer-activity-trace-sweep"]');
- await expect(sweep).toBeVisible();
- expect(await sweep.evaluate((node) => getComputedStyle(node).animationName)).toBe("answer-ecg-scroll");
-
- // The step spinner is the second half of the report: the universal reduced-motion
- // rule froze it too, which is how the whole panel read as broken.
- const spinner = progress.getByLabel("Answer generation stages").locator('li[data-state="current"] svg');
- expect(await spinner.evaluate((node) => getComputedStyle(node).animationName)).not.toBe("none");
+ const dot = dotOf(progress);
+ await expect(dot).toBeVisible();
+ expect(await dot.evaluate((node) => getComputedStyle(node).animationName)).toBe("answer-progress-breath");
- // Prove travel, not just a declared animation.
- const transforms = await sweep.evaluate(async (node) => {
- const animation = node.getAnimations()[0];
- animation.pause();
- animation.currentTime = 0;
- await new Promise(requestAnimationFrame);
- const resting = getComputedStyle(node).transform;
- animation.currentTime = 1_600;
- await new Promise(requestAnimationFrame);
- return { resting, mid: getComputedStyle(node).transform };
- });
- expect(transforms.mid).not.toBe(transforms.resting);
+ const travel = await breathTravel(dot);
+ expect(travel.mid).not.toBe(travel.resting);
});
test("motion:reduced still wins when the OS has no preference", async ({ page }) => {
@@ -194,9 +210,11 @@ test.describe("phone motion behaviour with OS Reduce Motion on", () => {
const progress = await startAnswer(page);
await expect(page.locator("html")).toHaveAttribute("data-motion", "reduced");
- const sweep = progress.locator('[data-slot="answer-activity-trace-sweep"]');
- await expect(sweep).toBeVisible();
- expect(await sweep.evaluate((node) => getComputedStyle(node).animationName)).toBe("none");
+ const dot = dotOf(progress);
+ await expect(dot).toBeVisible();
+ const state = await indicatorState(dot);
+ expect(state.animationName).toBe("none");
+ expect(state.opacity).toBe(1);
});
test("runs active animations under OS default no-preference motion", async ({ page }) => {
@@ -207,24 +225,12 @@ test.describe("phone motion behaviour with OS Reduce Motion on", () => {
const progress = await startAnswer(page);
await expect(progress).toBeVisible();
- const sweep = progress.locator('[data-slot="answer-activity-trace-sweep"]');
- await expect(sweep).toBeVisible();
- expect(await sweep.evaluate((node) => getComputedStyle(node).animationName)).toBe("answer-ecg-scroll");
+ const dot = dotOf(progress);
+ await expect(dot).toBeVisible();
+ expect(await dot.evaluate((node) => getComputedStyle(node).animationName)).toBe("answer-progress-breath");
- const spinner = progress.getByLabel("Answer generation stages").locator('li[data-state="current"] svg');
- expect(await spinner.evaluate((node) => getComputedStyle(node).animationName)).not.toBe("none");
-
- const transforms = await sweep.evaluate(async (node) => {
- const animation = node.getAnimations()[0];
- animation.pause();
- animation.currentTime = 0;
- await new Promise(requestAnimationFrame);
- const resting = getComputedStyle(node).transform;
- animation.currentTime = 1_600;
- await new Promise(requestAnimationFrame);
- return { resting, mid: getComputedStyle(node).transform };
- });
- expect(transforms.mid).not.toBe(transforms.resting);
+ const travel = await breathTravel(dot);
+ expect(travel.mid).not.toBe(travel.resting);
});
test("contract: motion-sensitive components explicitly declare both reduce and no-preference behaviors", async ({
@@ -232,44 +238,30 @@ test.describe("phone motion behaviour with OS Reduce Motion on", () => {
}) => {
await stubAnswerStream(page);
- // 1. Reduced motion: animations suppressed, indicators remain visible and legible
+ // 1. Reduced motion: animation suppressed, indicator remains visible and legible
await page.emulateMedia({ reducedMotion: "reduce" });
await seedMotionPreference(page, "system");
let progress = await startAnswer(page);
await expect(progress).toBeVisible();
- let sweep = progress.locator('[data-slot="answer-activity-trace-sweep"]');
- await expect(sweep).toBeVisible();
- const stateReduce = await sweep.evaluate((node) => {
- const style = getComputedStyle(node);
- return {
- animationName: style.animationName,
- opacity: Number.parseFloat(style.opacity),
- display: style.display,
- visibility: style.visibility,
- };
- });
+ let dot = dotOf(progress);
+ await expect(dot).toBeVisible();
+ const stateReduce = await indicatorState(dot);
expect(stateReduce.animationName).toBe("none");
- expect(stateReduce.opacity).toBeGreaterThan(0.3);
+ expect(stateReduce.opacity).toBe(1);
expect(stateReduce.display).not.toBe("none");
expect(stateReduce.visibility).not.toBe("hidden");
- // 2. Full motion: animations active and traveling
+ // 2. Full motion: animation active and actually changing
await page.emulateMedia({ reducedMotion: "no-preference" });
await seedMotionPreference(page, "system");
await page.goto("/?mode=answer", { waitUntil: "domcontentloaded" });
await dismissBlockingPwaNotice(page);
progress = await startAnswer(page);
await expect(progress).toBeVisible();
- sweep = progress.locator('[data-slot="answer-activity-trace-sweep"]');
- await expect(sweep).toBeVisible();
- const stateFull = await sweep.evaluate((node) => {
- const style = getComputedStyle(node);
- return {
- animationName: style.animationName,
- opacity: Number.parseFloat(style.opacity),
- };
- });
- expect(stateFull.animationName).toBe("answer-ecg-scroll");
+ dot = dotOf(progress);
+ await expect(dot).toBeVisible();
+ const stateFull = await indicatorState(dot);
+ expect(stateFull.animationName).toBe("answer-progress-breath");
expect(stateFull.opacity).toBeGreaterThan(0);
});
});
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index 3bd5d864e9..23108b5f09 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -5407,7 +5407,7 @@ test.describe("Clinical KB UI smoke coverage", () => {
const generatedSummary = page.getByTestId("generated-clinical-summary");
await expect(generatedSummary).toBeVisible();
- await expect(page.getByTestId("answer-progress-stepper")).toHaveAttribute("data-progress-state", "complete");
+ await expect(page.getByTestId("answer-progress")).toHaveAttribute("data-progress-state", "complete");
await expect(page.getByText(/Answer ready in 1s/)).toBeVisible();
await expect(generatedSummary).toContainText("clozapine monitoring requires regular FBC/ANC checks");
await expect(generatedSummary).not.toContainText("Key practical points:");
diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts
index a8078830df..53d13d02c7 100644
--- a/tests/ui-universal-search.spec.ts
+++ b/tests/ui-universal-search.spec.ts
@@ -552,9 +552,9 @@ test.describe("universal search smart affordances", () => {
await input.fill("acamprosat");
await page.getByRole("button", { name: "Generate source-backed answer" }).click();
- const progress = page.getByTestId("answer-progress-stepper");
+ const progress = page.getByTestId("answer-progress");
await expect(progress).toBeVisible();
- await expect(progress).toContainText("Drafting a cited answer from the selected passages.");
+ await expect(progress).toContainText("Writing the answer");
// The contract under test is unchanged — no cross-mode matches while the
// answer is still drafting, and matches once it is final. On answer mode the
// element carrying it is now the surface's own library line; the mode-level
From fa2d8e4a7e3391e7758d8693d264985b3380619c Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 27 Aug 2026 14:39:19 +0000
Subject: [PATCH 4/6] Answer wait: stand where the answer will, and count only
what is on screen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Takes the pending screens from /mockups/answer-chat-perfected-v2 ("Evidence
arrives first") and applies what they get right to the live wait. Three changes,
each a defect in what shipped in the previous commit.
**Order.** The mockup draws status line → prose placeholder → sources, because
that is where each one's finished counterpart lands. The first cut put the rail
directly under the line and left AnswerSkeleton to render below it as a sibling,
so the rail sat ABOVE the prose during the wait and BELOW it after — travelling
the height of the answer at the exact moment the reader was given something to
read. That is the whole "nothing jumps" claim, and it was wrong. AnswerProgress
now owns the wait end to end, ClinicalDashboard stops rendering AnswerSkeleton
beside it, and a Playwright test pins the geometry rather than the markup.
**Counts.** The mockup prints one number and it is the number of cards visible
beneath it. Nothing else. Measured against that rule, both counts I added fail:
`resultCount` is candidate chunks — commonly 24 where the answer cites three —
so a reader who takes "24" away has been told the wrong thing about how much
evidence is behind their answer, whatever noun sat beside it; and
`australianSourceCount` is a ratio (4 of 6) nothing on screen can confirm. The
first is gone. The second survives as the fact without the figure —
"Prioritising Australian sources" — because a Perth reader does care that local
guidance is being favoured, and the per-source origin stays where it can be
checked. `answerProgressPreviewMessage` is now the only place the wait prints a
number, and it counts the rail.
Accrual does not depend on numbers: a healthy wait still moves through four
distinct clauses in about seven seconds, which is what separates working from
stuck.
**Completion.** The mockup's third frame hands the completed state to the
answer's own provenance line and prints nothing else. Production already renders
that line ("AI-generated from N cited sources", clinical owner approved
2026-08-25), so "Answer ready in 3s" underneath it was a competing completion
statement and the last of the elapsed counter. The completed wait now renders no
visible chrome — only the screen-reader announcement, plus the "How this answer
was built" disclosure when the answer left the ordinary route.
Also from the mockup: the rail cards carry review status rather than a section
heading ("p.12 · Current"), read through `sourceStatusShortLabel` so the wait and
the arrived answer can never disagree about a document. Freshness is the fact
that decides whether a source should be trusted at all; a section heading is
orientation the reader gets anyway on opening it.
One consequence worth naming: `compactSourceSnippet` now has no call site,
because the evidence preview was the only surface printing a source snippet and
the rail prints none. The formatter is kept — it is the contract any
reintroduced snippet must go through — and its guard in
rendered-text-formatting.test.ts becomes conditional on `source.content` being
rendered at all, so it fires the moment one of these surfaces touches it again.
The unconditional raw-render half is unchanged and now also covers
`{source.content}`.
Verified: tsc clean; eslint clean; prettier clean; design-system contract passed
(1028 production files, no ratchet moved); full offline suite 895 files / 10841
tests passed; Playwright chromium answer-progress-ui-smoke 5 passed (including
the new geometry test), ui-phone-motion 5 passed, ui-universal-search 20 passed;
driven in a real browser at 390px, where the rendered tops are line 88 → prose
128 with the rail structurally next, and the line reads "Searching your
documents…" then "Writing the answer…" with no digit in either.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_012kHT2YKCNnUrVckTaJW6ga
---
src/components/ClinicalDashboard.tsx | 8 +-
.../answer-evidence-preview.tsx | 13 +-
.../clinical-dashboard/answer-progress.ts | 83 +++++----
.../clinical-dashboard/answer-status.tsx | 161 ++++++++++--------
tests/answer-evidence-preview.dom.test.tsx | 18 ++
tests/answer-progress-ui-smoke.spec.ts | 54 +++++-
tests/answer-progress.test.ts | 53 ++++--
tests/rendered-text-formatting.test.ts | 14 +-
tests/ui-smoke.spec.ts | 6 +-
9 files changed, 285 insertions(+), 125 deletions(-)
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index d79364b050..05b77146c9 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -3778,7 +3778,13 @@ function ClinicalDashboardContent({
>
)
) : showAnswerPending ? (
-
+ // Only until the first progress event. From there AnswerProgress owns
+ // the whole wait — line, prose placeholder, sources, in the order the
+ // arrived answer uses — and rendering the skeleton here as well would
+ // put a second prose placeholder below its sources.
+ showAnswerProgress ? null : (
+
+ )
) : answer && answerRenderModel ? (
stagedDashboardExtraction.answerSurface ? (
<>
diff --git a/src/components/clinical-dashboard/answer-evidence-preview.tsx b/src/components/clinical-dashboard/answer-evidence-preview.tsx
index 3c82634e80..49109cc150 100644
--- a/src/components/clinical-dashboard/answer-evidence-preview.tsx
+++ b/src/components/clinical-dashboard/answer-evidence-preview.tsx
@@ -3,9 +3,11 @@
import Link from "next/link";
import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text";
+import { sourceStatusShortLabel } from "@/components/clinical-dashboard/answer-source-rows";
import { sourceResultHref } from "@/components/clinical-dashboard/source-actions";
import { cn } from "@/components/ui-primitives";
import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract";
+import { normalizeSourceMetadata } from "@/lib/source-metadata";
/** The render policy caps primary sources at six, so the rail is built for six
* rather than for the three a specimen usually draws. */
@@ -49,12 +51,19 @@ export function AnswerEvidencePreview({ preview }: { preview: VerifiedEvidencePr
>
{visibleSources.map((source, index) => {
const title = cleanDisplayTitle(source.title);
+ // Freshness, not the section heading. A section heading is orientation a
+ // reader gets anyway once the card is opened; whether the document is
+ // still current is the one thing that decides whether it should be
+ // trusted at all, and this is a clinical reference tool. Read through the
+ // same helper the arrived answer's rail uses, so the wait and the answer
+ // never disagree about a document's status.
+ const status = sourceStatusShortLabel(normalizeSourceMetadata(source.source_metadata));
return (
p.{source.page_number ?? "n/a"}
- {source.section_heading ? ` · ${cleanDisplayTitle(source.section_heading)}` : ""}
+ {` · ${status}`}
diff --git a/src/components/clinical-dashboard/answer-progress.ts b/src/components/clinical-dashboard/answer-progress.ts
index 4ab0cd2056..d6019ef497 100644
--- a/src/components/clinical-dashboard/answer-progress.ts
+++ b/src/components/clinical-dashboard/answer-progress.ts
@@ -75,44 +75,69 @@ export function answerProgressStepIndex(stage: PublicAnswerProgressStage) {
return 4;
}
-/** UI copy is derived from the public stage/counts and never from an incoming message.
+/** UI copy is derived from the public stage and never from an incoming message.
*
- * Written for a single quiet line rather than a stepper panel, so each string is
- * a clause the reader can take in at a glance while waiting. Two rules hold it
- * together:
+ * One rule holds this whole set together, taken from the pending screens in
+ * `/mockups/answer-chat-perfected-v2`:
*
- * - **One noun per stage.** Retrieval counts *passages* (`resultCount`, every
- * candidate chunk) and selection counts *sources* (the trimmed documents the
- * rail actually shows). Those are different numbers, and using one word for
- * both is how a reader ends up believing 24 documents are behind an answer
- * that cites three.
- * - **The unusual route says so while it is happening.** `fallback` means the
- * answer is being assembled without the model, which on the only measurement
- * in the handover was the majority case. The wait is the honest place to set
- * that expectation — not the answer, which would then have to defend it.
+ * **The wait shows no number the reader cannot reconcile with something on
+ * screen.**
+ *
+ * That rules out every raw count the stream offers. `resultCount` is candidate
+ * chunks — commonly 24 where the answer will cite three — and a reader who takes
+ * "24" away from this screen has been told the wrong thing about how much
+ * evidence is behind their answer, whether or not the word beside it was
+ * "passages". `australianSourceCount` fails the same test: it is real and it is
+ * useful, but 4 of 6 is a ratio nothing on screen can confirm, so the fact
+ * survives here as a fact ("Prioritising Australian sources") and the per-source
+ * origin stays where it can be checked — on the sources themselves.
+ *
+ * The one count that IS shown lives in `answerProgressPreviewMessage` below,
+ * because it counts exactly the cards visible beneath the line.
+ *
+ * Accrual does not depend on numbers. A healthy wait moves through four
+ * distinct clauses in roughly seven seconds, which is what tells a reader the
+ * search is working rather than stuck.
*/
export function answerProgressDisplayMessage(progress: AnswerProgressUpdate) {
- if (progress.stage === "scoping") return "Reading your question\u2026";
- if (progress.stage === "retrieving" || progress.stage === "retrieved") {
- return progress.resultCount === undefined
- ? "Searching your documents\u2026"
- : `Searching your documents \u00b7 ${progress.resultCount} passage${progress.resultCount === 1 ? "" : "s"} found`;
- }
+ if (progress.stage === "scoping") return "Reading your question…";
+ if (progress.stage === "retrieving" || progress.stage === "retrieved") return "Searching your documents…";
if (progress.stage === "ranking") {
- if (progress.australianSourceCount) {
- const waDetail = progress.waSourceCount ? `, ${progress.waSourceCount} from WA` : "";
- return `Prioritising ${progress.australianSourceCount} Australian source${progress.australianSourceCount === 1 ? "" : "s"}${waDetail}`;
- }
- return "Selecting the most relevant passages\u2026";
+ // The fact, not the ratio. A Perth reader cares that local guidance is being
+ // favoured; "4 of 6" is the part nothing on screen can confirm.
+ return progress.australianSourceCount
+ ? "Prioritising Australian sources…"
+ : "Selecting the most relevant passages…";
}
- if (progress.stage === "retrying") return "Revising the draft against the evidence\u2026";
- if (progress.stage === "fallback") return "Assembling the answer from the sources directly\u2026";
- if (progress.stage === "generating") return "Writing the answer\u2026";
- if (progress.stage === "verifying") return "Checking citations and clinical numbers\u2026";
- if (progress.stage === "cached") return "Loading a recent cited answer\u2026";
+ if (progress.stage === "retrying") return "Revising the draft against the evidence…";
+ if (progress.stage === "fallback") return "Assembling the answer from the sources directly…";
+ if (progress.stage === "generating") return "Writing the answer…";
+ if (progress.stage === "verifying") return "Checking citations and clinical numbers…";
+ if (progress.stage === "cached") return "Loading a recent cited answer…";
return "Answer ready.";
}
+/**
+ * The line once the evidence preview is on screen.
+ *
+ * This is the only place the wait prints a number, and it prints the number of
+ * cards the reader can count directly beneath it. The mockup's wording
+ * ("3 sources found · writing the answer…") is kept because it names both halves
+ * of what is true at that moment: retrieval finished, generation has not.
+ *
+ * Returns null before generation starts, so the caller falls back to the stage
+ * clause rather than claiming the answer is being written while ranking is still
+ * running.
+ */
+export function answerProgressPreviewMessage(sourceCount: number, stage: PublicAnswerProgressStage) {
+ if (sourceCount <= 0) return null;
+ const sources = `${sourceCount} source${sourceCount === 1 ? "" : "s"} found`;
+ if (stage === "generating" || stage === "retrying") return `${sources} · writing the answer…`;
+ if (stage === "fallback") return `${sources} · assembling the answer from them…`;
+ if (stage === "verifying") return `${sources} · checking the citations…`;
+ return sources;
+}
+
/** The stages worth disclosing after the fact.
*
* A routine answer has nothing to explain — scope, search, select, write, check,
diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx
index 4e126151b3..09d39d0f3a 100644
--- a/src/components/clinical-dashboard/answer-status.tsx
+++ b/src/components/clinical-dashboard/answer-status.tsx
@@ -5,12 +5,12 @@ import { Clipboard, ClipboardCheck, History, Square } from "lucide-react";
import {
answerProgressDisplayMessage,
+ answerProgressPreviewMessage,
answerProgressTookUnusualRoute,
type TimedAnswerProgressUpdate,
} from "@/components/clinical-dashboard/answer-progress";
import { AnswerEvidencePreview } from "@/components/clinical-dashboard/answer-evidence-preview";
import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract";
-import { useClientTime } from "@/lib/use-client-time";
import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips";
import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences";
import { ModeHomeTemplate } from "@/components/mode-home-template";
@@ -130,21 +130,35 @@ function skeletonBar(className: string, staggerIndex: number) {
}
/**
- * The window between submit and the first progress event, and the lazy-load
- * fallback for the dashboard chunk.
+ * Three prose bars, and deliberately nothing else.
*
- * It used to draw a bordered card, a source card with a tap-sized block, two
- * pill placeholders and a two-column grid — a wireframe of an answer that has
- * not been retrieved yet, promising a shape the payload may not produce (twenty
- * of thirty answers in the 2026-08-18 blinded read carried no sections at all).
- * Three prose bars make no promise beyond "text is coming", which is the only
- * thing that is actually known at this point.
+ * The retired skeleton drew a bordered card, a source card with a tap-sized
+ * block, two pill placeholders and a two-column grid — a wireframe of an answer
+ * that has not been retrieved yet, promising a shape the payload may not
+ * produce. Twenty of thirty answers in the 2026-08-18 blinded read carried no
+ * sections at all. Three bars promise only "text is coming", which is the one
+ * thing actually known at this point.
+ */
+function AnswerProseSkeleton() {
+ return (
+
+ );
+}
+
+/**
+ * The window before the first progress event, and the lazy-load fallback for the
+ * dashboard chunk.
*
- * It deliberately carries no status text. This renders in the answer's body
- * slot while AnswerProgress renders the status line directly above it, and two
- * indicators disagreeing on the same screen — "Writing the answer…" over
- * "Reading your question…" — is worse than one. There is exactly one place that
- * says what is happening.
+ * It carries no status text of its own. Once progress events start arriving,
+ * `AnswerProgress` owns the whole wait — line, prose placeholder and sources, in
+ * that order — and this component is not rendered beside it. Two indicators
+ * disagreeing on one screen ("Writing the answer…" above "Reading your
+ * question…") is worse than one, and that is exactly what shipped before this
+ * was split.
*
* role=status so the window is still announced; without it a screen reader stays
* silent until AnswerProgress mounts with its own live region.
@@ -152,21 +166,12 @@ function skeletonBar(className: string, staggerIndex: number) {
export function AnswerSkeleton() {
return (
);
}
-function elapsedLabel(elapsedMs: number) {
- const seconds = Math.max(0, Math.floor(elapsedMs / 1_000));
- return seconds < 1 ? "<1s" : `${seconds}s`;
-}
-
/**
* The whole animation, in one element.
*
@@ -280,25 +285,40 @@ export function SearchProgressBanner({ message, onStop }: { message: string; onS
*
* Replaces `AnswerProgressStepper`: a filled accent panel carrying a 36px icon
* tile, a five-circle stepper with connecting rails, a scrolling ECG trace, a
- * per-second elapsed counter and a Processing details disclosure. Six things
- * were wrong with it, and the two that mattered are these — it narrated the
- * orchestrator's five stages, which the reader is not operating, and it never
- * showed a single source, even though the evidence preview crosses the stream
- * boundary before the prose and is the most useful content this surface has.
+ * per-second elapsed counter and a Processing details disclosure. It narrated
+ * the orchestrator's five stages, which the reader is not operating, and it
+ * never showed a single source — even though the evidence preview crosses the
+ * stream boundary before the prose and is the most useful content this surface
+ * has.
+ *
+ * What is here instead is the pending screen from
+ * `/mockups/answer-chat-perfected-v2`, in the order that mockup draws it and for
+ * the reason it draws it that way:
*
- * What is here instead is one status line and the sources arriving beneath it,
- * drawn in the answer's own column at the answer's own size. Two consequences
- * are the point of the design rather than side effects:
+ * status line
+ * prose placeholder ← where the answer's prose will be
+ * sources ← where the answer's source rail will be
*
- * - **Nothing jumps.** The old panel was ~210px tall and was removed, not
- * transformed, when the answer arrived, so everything below it moved up by
- * that distance at the exact moment the reader was given something to read.
- * Here only the line changes; the rail stays where the eye settled.
- * - **The rail degrades to nothing, not to a placeholder.** The preview unit is
- * gated behind `NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER` (#100
- * Phase 1) and is off by default, so today the line carries the accrual on
- * its own via `resultCount` and the rail is simply absent. Nothing here
- * fabricates a source to fill the space.
+ * That order is the entire "nothing jumps" claim. The first cut of this
+ * component put the rail directly under the line and left the prose placeholder
+ * to render below it as a sibling, which meant the rail travelled the height of
+ * the answer at the exact moment the reader was given something to read. Here
+ * every element is already standing where its finished counterpart lands, so the
+ * arrival swaps content in place: the placeholder becomes prose, and the dotted
+ * preview cards are replaced by the answer's own numbered rail in the same spot.
+ *
+ * On completion this renders no visible chrome at all. The answer surface
+ * already prints its own governed provenance line above the prose
+ * ("AI-generated from N cited sources", clinical owner approved 2026-08-25), so
+ * a second "Answer ready in 3s" underneath it was a competing completion
+ * statement and a last vestige of the elapsed counter. What survives is the
+ * screen-reader announcement and, when the answer left the ordinary route, the
+ * disclosure that explains it.
+ *
+ * The rail degrades to nothing rather than to a placeholder: the preview unit is
+ * gated behind `NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER` (#100 Phase
+ * 1) and is off by default, so today the line and the prose placeholder are the
+ * whole wait. Nothing here invents a source to fill the space.
*/
export function AnswerProgress({
events,
@@ -317,13 +337,12 @@ export function AnswerProgress({
const finished = latest?.stage === "complete";
const running = active && !finished;
const slow = useSlowNotice(running, startedAt);
- // Only read on completion, so the clock is sampled once rather than subscribed
- // to at 1Hz for the whole wait.
- const now = useClientTime({ fallback: startedAt ?? 0 });
- const clientElapsedMs = startedAt ? Math.max(0, (latest?.receivedAt ?? now) - startedAt) : 0;
- const elapsedMs = latest?.elapsedMs !== undefined ? latest.elapsedMs : clientElapsedMs;
- const currentMessage = latest ? answerProgressDisplayMessage(latest) : "Reading your question…";
const unusualRoute = answerProgressTookUnusualRoute(events);
+ // The only number the wait prints, and it counts the cards directly below it.
+ const previewMessage = latest
+ ? answerProgressPreviewMessage(evidencePreview?.sources.length ?? 0, latest.stage)
+ : null;
+ const currentMessage = previewMessage ?? (latest ? answerProgressDisplayMessage(latest) : "Reading your question…");
const details = events
.map((event) => ({ ...event, displayMessage: answerProgressDisplayMessage(event) }))
.filter((event, index, all) => index === 0 || event.displayMessage !== all[index - 1]?.displayMessage)
@@ -337,33 +356,35 @@ export function AnswerProgress({
aria-busy={running}
className="grid gap-2"
>
-
- {evidencePreview ? : null}
+
+
+ {evidencePreview ? : null}
+ >
+ )}
- {/* A routine answer has nothing to disclose — the old panel offered the same
- five stages every time. These three stages mean the answer did not take
- the ordinary route, which is the case a reader may actually want to read
- back. */}
+ {/* A routine answer has nothing to disclose — the retired panel offered the
+ same five stages every time. These three stages mean the answer did not
+ take the ordinary route, which is the case a reader may actually want to
+ read back. */}
{finished && unusualRoute ? (
diff --git a/tests/answer-evidence-preview.dom.test.tsx b/tests/answer-evidence-preview.dom.test.tsx
index 5f402a9ac6..c7abfad506 100644
--- a/tests/answer-evidence-preview.dom.test.tsx
+++ b/tests/answer-evidence-preview.dom.test.tsx
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import { AnswerEvidencePreview } from "@/components/clinical-dashboard/answer-evidence-preview";
import { incrementalEvidencePreviewRenderingEnabled } from "@/lib/client-env";
import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract";
+import { normalizeSourceMetadata } from "@/lib/source-metadata";
function evidencePreview(sourceCount = 4): VerifiedEvidencePreviewUnit {
return {
@@ -23,6 +24,7 @@ function evidencePreview(sourceCount = 4): VerifiedEvidencePreviewUnit {
image_ids: [],
similarity: 0.8,
images: [],
+ source_metadata: normalizeSourceMetadata({ document_status: index === 0 ? "review_due" : "current" }),
})),
};
}
@@ -65,6 +67,22 @@ describe("incremental answer evidence preview", () => {
expect(region.getAttribute("aria-label")).toMatch(/not yet numbered/i);
});
+ // Freshness is the one fact that decides whether a source should be trusted at
+ // all, and it is read through the same helper the arrived answer's rail uses so
+ // the wait and the answer can never disagree about a document's status.
+ it("shows each source's review status, not its section heading", () => {
+ render();
+
+ const cards = within(screen.getByTestId("answer-evidence-preview")).getAllByTestId(
+ "answer-evidence-preview-source",
+ );
+ expect(cards[0]?.textContent).toContain("Review due");
+ expect(cards[1]?.textContent).toContain("Current");
+ expect(cards[0]?.textContent).not.toContain("Monitoring");
+ // And a reader who never sees the card still gets it.
+ expect(cards[0]?.getAttribute("aria-label")).toContain("Review due");
+ });
+
it("links every card to the exact page the passage came from", () => {
render();
diff --git a/tests/answer-progress-ui-smoke.spec.ts b/tests/answer-progress-ui-smoke.spec.ts
index 9a1d6580b9..d85214e79e 100644
--- a/tests/answer-progress-ui-smoke.spec.ts
+++ b/tests/answer-progress-ui-smoke.spec.ts
@@ -387,10 +387,11 @@ test("answer progress remains user-safe through fallback and discloses the unusu
await expect(stop).toBeVisible();
expect((await stop.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(48);
- // Retrieval counts passages; selection counts sources. The two nouns must not
- // be interchangeable — see tests/answer-progress.test.ts for the rule.
- await expect(line).toContainText("12 passages found", { timeout: 3_000 });
- await expect(line).toContainText("Prioritising 4 Australian sources, 4 from WA", { timeout: 3_000 });
+ // No number the reader cannot reconcile with the screen. The stream offers
+ // resultCount 12 and australianSourceCount 4 at these stages; neither reaches
+ // the line. See tests/answer-progress.test.ts for the rule.
+ await expect(line).toContainText("Prioritising Australian sources", { timeout: 3_000 });
+ await expect(line).not.toContainText(/\d/);
await expect(line).toContainText("Writing the answer", { timeout: 4_000 });
// The wait is where the reader learns the model was not used, rather than
@@ -405,8 +406,13 @@ test("answer progress remains user-safe through fallback and discloses the unusu
await expect(page.getByText("Provisional lithium draft")).toHaveCount(0);
await expect(progress).toHaveAttribute("data-progress-state", "complete", { timeout: 6_000 });
- await expect(line).toContainText("Answer ready in 3s");
await expect(page.getByTestId("stop-answer")).toHaveCount(0);
+ // No visible completion chrome. The answer surface prints its own governed
+ // provenance line above the prose, so a second "Answer ready in 3s" underneath
+ // it was a competing completion statement and the last of the elapsed counter.
+ await expect(line).toHaveCount(0);
+ await expect(page.getByText(/Answer ready in/)).toHaveCount(0);
+ await expect(progress.getByRole("status")).toContainText("Answer ready.");
// This run went through `fallback`, so the build disclosure is offered. On an
// ordinary run it is not — pinned in the follow-up test below.
@@ -493,6 +499,44 @@ test("follow-up answer generation stays one line above the previous answer", asy
await expect(previousAnswer).toBeVisible();
});
+test("the wait stands where the answer will, so arrival swaps content in place", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await mockDashboardApis(page);
+ await installHoldingAnswerStream(page);
+ await page.goto("/?mode=answer", { waitUntil: "domcontentloaded" });
+ await dismissBlockingPwaNotice(page);
+
+ const submit = await fillHydratedAnswerQuestion(page, "Lithium dosing");
+ await submit.click();
+
+ const progress = page.getByTestId("answer-progress");
+ await expect(progress.getByTestId("answer-progress-line")).toBeVisible();
+
+ // Status line, then the prose placeholder where the prose lands, then the
+ // sources where the answer's own rail lands. The first cut of this component
+ // put the rail directly under the line and left the placeholder to render
+ // below it, which meant the rail travelled the height of the answer at the
+ // exact moment the reader was given something to read.
+ const order = await progress.evaluate((section) => {
+ const top = (selector: string) => {
+ const node = section.querySelector(selector);
+ return node ? node.getBoundingClientRect().top : null;
+ };
+ return {
+ line: top('[data-testid="answer-progress-line"]'),
+ skeleton: top('[data-slot="answer-prose-skeleton"]'),
+ sectionChildren: [...section.children].length,
+ };
+ });
+ expect(order.line).not.toBeNull();
+ expect(order.skeleton).not.toBeNull();
+ expect(order.skeleton ?? 0).toBeGreaterThan(order.line ?? 0);
+
+ // And exactly one prose placeholder on the page — the dashboard must not also
+ // render AnswerSkeleton beside this one.
+ expect(await page.locator('[role="status"][aria-label="Loading answer"]').count()).toBe(0);
+});
+
test("a completion frame cannot mark a previous answer complete when final is invalid", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockDashboardApis(page);
diff --git a/tests/answer-progress.test.ts b/tests/answer-progress.test.ts
index a4646f91c8..8311619c50 100644
--- a/tests/answer-progress.test.ts
+++ b/tests/answer-progress.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
answerProgressDisplayMessage,
+ answerProgressPreviewMessage,
answerProgressStepIndex,
answerProgressTookUnusualRoute,
normalizeAnswerProgressEvent,
@@ -117,31 +118,51 @@ describe("answer progress events", () => {
waSourceCount: 4,
});
- expect(answerProgressDisplayMessage(progress!)).toBe("Prioritising 4 Australian sources, 4 from WA");
+ expect(answerProgressDisplayMessage(progress!)).toBe("Prioritising Australian sources\u2026");
expect(answerProgressStepIndex("fallback")).toBe(3);
expect(answerProgressDisplayMessage({ stage: "fallback", message: "private" })).toBe(
"Assembling the answer from the sources directly\u2026",
);
});
- // The status line counts two different things and must never call them the same
- // thing: `resultCount` is every candidate chunk retrieval touched, while the
- // arriving rail shows the trimmed sources. Collapsing both into one noun is how
- // a reader ends up believing two dozen documents are behind an answer that
- // cites three.
- it("counts passages during retrieval and never calls them sources", () => {
- const line = (stage: "retrieving" | "retrieved", resultCount?: number) =>
- answerProgressDisplayMessage({
- stage,
- message: "private",
- ...(resultCount === undefined ? {} : { resultCount }),
- });
+ // The rule the whole wait is built on: no number the reader cannot reconcile
+ // with something on screen. `resultCount` is candidate chunks — commonly 24
+ // where the answer cites three — so a reader who takes "24" away has been told
+ // the wrong thing about how much evidence is behind their answer, whatever
+ // noun sat beside it. `australianSourceCount` fails the same test as a ratio,
+ // so the fact survives without the figure.
+ it("prints no count the reader cannot reconcile with the screen", () => {
+ const line = (stage: "retrieving" | "retrieved" | "ranking", extra: Record = {}) =>
+ answerProgressDisplayMessage({ stage, message: "private", ...extra });
expect(line("retrieving")).toBe("Searching your documents\u2026");
expect(line("retrieved")).toBe("Searching your documents\u2026");
- expect(line("retrieved", 1)).toBe("Searching your documents \u00b7 1 passage found");
- expect(line("retrieved", 24)).toBe("Searching your documents \u00b7 24 passages found");
- expect(line("retrieved", 24)).not.toMatch(/source/i);
+ expect(line("retrieved", { resultCount: 24 })).toBe("Searching your documents\u2026");
+ expect(line("ranking", { australianSourceCount: 4, waSourceCount: 2 })).toBe(
+ "Prioritising Australian sources\u2026",
+ );
+
+ for (const stage of ["scoping", "retrieving", "retrieved", "ranking", "generating", "verifying"] as const) {
+ expect(
+ answerProgressDisplayMessage({ stage, message: "private", resultCount: 24, australianSourceCount: 4 }),
+ ).not.toMatch(/\d/);
+ }
+ });
+
+ // The single exception, and it counts exactly the cards rendered beneath the
+ // line — so a reader can check it by looking down.
+ it("prints one count, and only for the sources actually on screen", () => {
+ expect(answerProgressPreviewMessage(0, "generating")).toBeNull();
+ expect(answerProgressPreviewMessage(1, "generating")).toBe("1 source found \u00b7 writing the answer\u2026");
+ expect(answerProgressPreviewMessage(3, "generating")).toBe("3 sources found \u00b7 writing the answer\u2026");
+ expect(answerProgressPreviewMessage(6, "fallback")).toBe(
+ "6 sources found \u00b7 assembling the answer from them\u2026",
+ );
+ expect(answerProgressPreviewMessage(6, "verifying")).toBe("6 sources found \u00b7 checking the citations\u2026");
+ // Before generation starts it states the count only — claiming the answer is
+ // being written while ranking is still running would be a lie the reader
+ // cannot see through.
+ expect(answerProgressPreviewMessage(3, "ranking")).toBe("3 sources found");
});
// The wait is where a reader should learn the answer is being assembled without
diff --git a/tests/rendered-text-formatting.test.ts b/tests/rendered-text-formatting.test.ts
index 1b2bdb0048..e977d0b706 100644
--- a/tests/rendered-text-formatting.test.ts
+++ b/tests/rendered-text-formatting.test.ts
@@ -62,8 +62,20 @@ describe("document-derived text must route through a formatter", () => {
});
it("renders source-card snippets through compactSourceSnippet with the card title deduped", () => {
+ // The raw-render half is unconditional and is the actual regression guard.
expect(dashboardSurfaces).not.toMatch(/(? {
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index 000e35ee42..4466d32183 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -5579,7 +5579,11 @@ test.describe("Clinical KB UI smoke coverage", () => {
const generatedSummary = page.getByTestId("generated-clinical-summary");
await expect(generatedSummary).toBeVisible();
await expect(page.getByTestId("answer-progress")).toHaveAttribute("data-progress-state", "complete");
- await expect(page.getByText(/Answer ready in 1s/)).toBeVisible();
+ // The completed wait prints no visible chrome: the summary card arriving is
+ // the completion signal, and an elapsed time is a timing boast rather than
+ // anything a reader acts on. The announcement survives for screen readers.
+ await expect(page.getByText(/Answer ready in/)).toHaveCount(0);
+ await expect(page.getByTestId("answer-progress").getByRole("status")).toContainText("Answer ready.");
await expect(generatedSummary).toContainText("clozapine monitoring requires regular FBC/ANC checks");
await expect(generatedSummary).not.toContainText("Key practical points:");
await expect(generatedSummary).not.toContainText("**");
From 5d01c6761f58660911b7017aa0e3506ff0d8d366 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 27 Aug 2026 16:40:21 +0000
Subject: [PATCH 5/6] test(ui-smoke): assert the wait on AnswerProgress, not
the retired label
AnswerSkeleton no longer renders its own "Loading answer" status line beside
AnswerProgress, so the in-flight assertion in "answer search URL opens chat
without the answer home copy" was still hunting for an element this branch
deliberately removed. Retarget it at the progress element's active state.
The same branch already retargeted the completed-state assertion further down
this file; these two were missed.
---
tests/ui-smoke.spec.ts | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index 4466d32183..9d9d32e02d 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -2963,8 +2963,11 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(page.getByTestId("shared-home-empty-state")).toHaveCount(0);
await expect(page.getByText("What can I help with?", { exact: true })).toHaveCount(0);
// Prefer :visible — a useSearchParams() Suspense ancestor can leave a persistent
- // hidden S: clone (search-chrome invariant 17), which makes getByLabel strict-mode fail.
- await expect(page.locator('[aria-label="Loading answer"]:visible')).toBeVisible();
+ // hidden S: clone (search-chrome invariant 17), which makes getByTestId strict-mode fail.
+ // AnswerProgress owns the in-flight state; AnswerSkeleton no longer renders its own
+ // "Loading answer" status line beside it, so the wait is asserted on the progress
+ // element's active state rather than the retired label.
+ await expect(page.locator('[data-testid="answer-progress"][data-progress-state="active"]:visible')).toBeVisible();
await expect.poll(() => answerRequests[0]).toBe(question);
const questionEcho = page.getByTestId("user-question-bubble");
From 9855e706c972e6164cf4ccd61588fb4a9c8ce37f Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 27 Aug 2026 17:08:50 +0000
Subject: [PATCH 6/6] test(ui-smoke): measure the edge dock from its revealed
state
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The short-runway geometry assertions read the dock right after the related-pages
disclosure collapses. Expanding that disclosure scrolls this short page to its
end, which is a real downward gesture, so the dock can legitimately be
scroll-hidden when the panel collapses again — hide-on-scroll behaving as
designed, and one upward drag brings it back (verified in Chromium: a 40px
wheel-up restores transform to none).
Whether that hide engages depends on the exact answer height, so the assertion
was pinning content height rather than the edge-to-edge geometry it names. This
branch removes the completed wait's visible chrome, which shortens the answer by
~66px and tips the same sequence over that line.
Scroll back to the top before measuring. Every geometry assertion is unchanged —
flush bottom, zero left/right, full-bleed width — and now runs against a
deterministic starting position.
---
tests/ui-smoke.spec.ts | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index 9d9d32e02d..b9318bc5af 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -2741,6 +2741,19 @@ test.describe("Clinical KB UI smoke coverage", () => {
const header = page.locator("header.universal-header");
const dock = page.locator("form.answer-footer-search-dock");
await expect(dock).toBeVisible();
+ // Measure the edge-to-edge contract from the revealed state. Expanding the
+ // disclosure above scrolls this short page to its end, which is a genuine
+ // downward gesture, so the dock may legitimately be scroll-hidden by the
+ // time the panel collapses again — hide-on-scroll working, not a defect
+ // (one upward drag brings it straight back). Whether that hide engages
+ // depends on the exact answer height, so asserting the resting transform
+ // here would pin content height rather than the geometry this test names.
+ // Return to the top first: the assertions below are about where a *visible*
+ // dock sits (flush bottom, full-bleed), and that is what must hold.
+ await scrollPrimarySurface(page, 0);
+ await expect
+ .poll(async () => await dock.evaluate((node) => window.getComputedStyle(node).transform))
+ .toMatch(/^(none|matrix\(1, 0, 0, 1, 0, 0\))$/);
const edgeGeometry = await dock.evaluate((node) => {
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);