` 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 057795e747..faf2207a29 100644
--- a/docs/search-chrome-behaviour.md
+++ b/docs/search-chrome-behaviour.md
@@ -831,13 +831,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/docs/site-map.md b/docs/site-map.md
index 4c76e8273a..4b7604a4bc 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/globals.css b/src/app/globals.css
index 57af6f986c..4053e8dd30 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -308,8 +308,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 */
@@ -3934,24 +3933,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;
}
}
@@ -3968,23 +3969,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 {
@@ -4318,20 +4318,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/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 5d31598284..9d6f5b10bd 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";
@@ -184,6 +189,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) {
!isAnswerHomeProposalMockup &&
!isAnswerChatRedesignMockup &&
!isAnswerChatPerfectedMockup &&
+ !isAnswerLoadingRedesignMockup &&
!isPrivacyPageDirectionsMockup &&
!isPrivacyLiveSignalPerfectedMockup &&
!isSearchLensMenuMockup &&
@@ -216,6 +222,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) {
!isAnswerHomeProposalMockup &&
!isAnswerChatRedesignMockup &&
!isAnswerChatPerfectedMockup &&
+ !isAnswerLoadingRedesignMockup &&
!isPrivacyPageDirectionsMockup &&
!isPrivacyLiveSignalPerfectedMockup &&
!isSearchLensMenuMockup &&
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index ad144da0d9..61c6a20d2e 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -84,7 +84,7 @@ import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/cli
import { AnswerCancelledNotice } from "@/components/clinical-dashboard/answer-cancelled-notice";
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";
@@ -3650,22 +3649,23 @@ function ClinicalDashboardContent({
{searchMode !== "prescribing" &&
(activeModeResultKind === "answer" ? (
showAnswerProgress ? (
-
) : null
) : loading && answerProgress ? (
) : null)}
- {activeModeResultKind === "answer" && loading && answerEvidencePreview ? (
-
- ) : null}
-
{showUniversalAlsoMatches &&
(activeModeResultKind === "tools" ||
activeModeResultKind === "documents" ||
@@ -3784,7 +3784,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/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) && (
{summaryProgressStartedAt && summaryProgressEvents.length > 0 ? (
- = [
+ {
+ 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 ══════════════════════ */
+
+/**
+ * The stepper as it shipped, redrawn.
+ *
+ * Panel One originally imported the real `AnswerProgressStepper` so the
+ * comparison could not be accused of flattering the proposal. Direction B has
+ * since been applied to the live surface and that component no longer exists,
+ * so what stands here is a faithful redraw kept for the record: same accent
+ * panel, same 36px icon tile, same five circles and connecting rail, same
+ * counter, same disclosure. The ECG trace is drawn as a static path — its
+ * scrolling strip and the CSS that moved it were deleted with the component.
+ */
+const RETIRED_STEPS = [
+ ["Prepare scope", "Interpreting your question"],
+ ["Search sources", "Scanning indexed clinical documents"],
+ ["Select evidence", "Prioritising relevant passages"],
+ ["Draft answer", "Synthesising the response and citations"],
+ ["Check answer", "Checking citations and clinical details"],
+] as const;
+
+const RETIRED_ECG_PATH =
+ "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";
+
+function RetiredEcg({ compact }: { compact: boolean }) {
+ return (
+
+ >
+ )}
+
+ );
+}
+
+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
+
+
+ Direction B shipped
+
+
+
+ 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 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.
+
+ 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.
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/clinical-dashboard/answer-evidence-preview.tsx b/src/components/clinical-dashboard/answer-evidence-preview.tsx
index a576db6794..49109cc150 100644
--- a/src/components/clinical-dashboard/answer-evidence-preview.tsx
+++ b/src/components/clinical-dashboard/answer-evidence-preview.tsx
@@ -1,67 +1,95 @@
"use client";
import Link from "next/link";
-import { FileText, ShieldCheck } from "lucide-react";
-import { cleanDisplayTitle, compactSourceSnippet } from "@/components/clinical-dashboard/display-text";
+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, panelSubtle, textMuted } from "@/components/ui-primitives";
+import { cn } from "@/components/ui-primitives";
import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract";
+import { normalizeSourceMetadata } from "@/lib/source-metadata";
-const visiblePreviewSourceLimit = 3;
+/** The render policy caps primary sources at six, so the rail is built for six
+ * rather than for the three a specimen usually draws. */
+const visiblePreviewSourceLimit = 6;
+/**
+ * The sources, arriving.
+ *
+ * This used to be a full panel — an icon tile, a heading reading "Selected
+ * evidence — answer still being verified", a sentence of explanation, and a
+ * three-column grid of cards with snippets — rendered *underneath* a filled
+ * accent progress stepper. Two loud blocks stacked in the answer's own position,
+ * both of which then disappeared when the answer arrived.
+ *
+ * It is now one horizontal rail of small cards that sits directly under the
+ * progress line, and it is deliberately built to look like the source rail the
+ * arrived answer renders, because that is what it becomes. Nothing is removed
+ * when the answer lands; the cards stay where the reader's eye already settled.
+ *
+ * **The cards carry a dot, not a number.** The preview is the top slice of
+ * retrieval in retrieval order, while the final list is rebuilt from what the
+ * answer actually cites and re-capped by trust (`collectSourceCandidates` /
+ * `dedupeSourceLinks`). Different sets in a different order, so a number
+ * assigned here can end up pointing at a different document once the answer
+ * lands — the precise failure the citation design exists to prevent. Numbering
+ * is what arrival buys.
+ *
+ * Each card is a real link to the real page, so a reader who recognises a
+ * document can open it without waiting for the answer at all.
+ */
export function AnswerEvidencePreview({ preview }: { preview: VerifiedEvidencePreviewUnit }) {
const visibleSources = preview.sources.slice(0, visiblePreviewSourceLimit);
+ if (visibleSources.length === 0) return null;
return (
-
-
-
-
-
-
-
-
- Selected evidence — answer still being verified
-
-
- {preview.selectedContextCount} source passage{preview.selectedContextCount === 1 ? "" : "s"} selected. The
- final answer and source list remain authoritative.
-
-
-
-
-
-
- {visibleSources.map((source, index) => {
- const title = cleanDisplayTitle(source.title);
- const snippet = compactSourceSnippet(source.content, { dropTitle: title });
- return (
- {
+ 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 (
+
+
-
);
}
diff --git a/src/components/clinical-dashboard/answer-progress.ts b/src/components/clinical-dashboard/answer-progress.ts
index 2bfd194dbd..d6019ef497 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,78 @@ 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.
+ *
+ * One rule holds this whole set together, taken from the pending screens in
+ * `/mockups/answer-chat-perfected-v2`:
+ *
+ * **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 "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 === "retrieving" || progress.stage === "retrieved") {
- return "Searching indexed clinical documents.";
- }
+ 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 ? `, including ${progress.waSourceCount} WA` : "";
- return `Prioritising ${progress.australianSourceCount} Australian source passage${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.";
+ // 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 === "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…";
+ 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,
+ * 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 29647a8e86..d7bdfe77c4 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, History, Loader2, Square } from "lucide-react";
+import { useEffect, useState, type CSSProperties } from "react";
+import { History, Square } from "lucide-react";
import {
answerProgressDisplayMessage,
- answerProgressStepIndex,
- answerProgressSteps,
+ answerProgressPreviewMessage,
+ answerProgressTookUnusualRoute,
type TimedAnswerProgressUpdate,
} from "@/components/clinical-dashboard/answer-progress";
-import { useClientTime } from "@/lib/use-client-time";
+import { AnswerEvidencePreview } from "@/components/clinical-dashboard/answer-evidence-preview";
+import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract";
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, sourceCard } from "@/components/ui-primitives";
+import { cn } 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";
@@ -98,172 +99,220 @@ function skeletonBar(className: string, staggerIndex: number) {
);
}
+/**
+ * Three prose bars, and deliberately nothing else.
+ *
+ * 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 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.
+ */
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 (
-
);
}
-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.
+ *
+ * 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"]`.
+ */
+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 (
+
+
+
+ );
}
/**
- * Single-line progress banner for the non-answer (library/document) search modes,
- * the flat sibling of AnswerProgressStepper.
+ * The Stop control, as a quiet text control rather than a raised pill.
*
- * 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.
+ * 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.
*/
-export function SearchProgressBanner({ message, onStop }: { message: string; onStop: () => void }) {
+function StopControl({ onStop }: { onStop: () => void }) {
return (
-
-
- {message}
-
-
+
+
+ Stop
+
+
);
}
-type AnswerProgressDensity = "expanded" | "compact";
-
-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. 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:
+ *
+ * status line
+ * prose placeholder ← where the answer's prose will be
+ * sources ← where the answer's source rail will be
+ *
+ * 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,
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);
+ 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)
@@ -271,176 +320,45 @@ 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 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 ? (
+
+
+ How this answer was built
{details.map((event, index) => (
diff --git a/src/lib/tailwind-merge.ts b/src/lib/tailwind-merge.ts
index 1d7418c99a..b302f0b488 100644
--- a/src/lib/tailwind-merge.ts
+++ b/src/lib/tailwind-merge.ts
@@ -126,8 +126,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..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" }),
})),
};
}
@@ -34,16 +36,63 @@ 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);
+ });
+
+ // 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();
+
+ 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..d85214e79e 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,44 @@ 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);
+ // 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
+ // 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 });
- 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 });
// 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 +406,28 @@ 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(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.
+ 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 +441,100 @@ 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("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 }) => {
@@ -518,7 +547,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 +555,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 +571,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..8311619c50 100644
--- a/tests/answer-progress.test.ts
+++ b/tests/answer-progress.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
answerProgressDisplayMessage,
+ answerProgressPreviewMessage,
answerProgressStepIndex,
+ answerProgressTookUnusualRoute,
normalizeAnswerProgressEvent,
} from "../src/components/clinical-dashboard/answer-progress";
import { toPublicAnswerProgressEvent } from "../src/lib/answer-progress-public";
@@ -103,7 +105,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 +118,74 @@ describe("answer progress events", () => {
waSourceCount: 4,
});
- expect(answerProgressDisplayMessage(progress!)).toBe("Prioritising 4 Australian source passages, including 4 WA.");
+ expect(answerProgressDisplayMessage(progress!)).toBe("Prioritising Australian sources\u2026");
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 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", { 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
+ // 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/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-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 577e940d7b..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);
@@ -2963,8 +2976,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");
@@ -5578,8 +5594,12 @@ 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.getByText(/Answer ready in 1s/)).toBeVisible();
+ await expect(page.getByTestId("answer-progress")).toHaveAttribute("data-progress-state", "complete");
+ // 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("**");
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