diff --git a/.env.example b/.env.example
index 77015ff07..1fe542fec 100644
--- a/.env.example
+++ b/.env.example
@@ -148,12 +148,14 @@ SENTRY_ENVIRONMENT=production
# Omit for current defaults. Example (enable diversity demotion + linear freshness):
# RAG_RANKING_CONFIG={"documentDiversityPenalty":0.03,"freshness":{"mode":"linear"}}
# #100 Phase 1: emit the governed retrieval-complete evidence preview as a verified unit
-# on the answer stream. Server emission flag; client rendering ships separately. Keep false
-# until the offline contract proof has landed and enablement is deliberately staged.
-RAG_INCREMENTAL_EVIDENCE_PREVIEW=false
-# #100 Phase 1 client rendering gate. This NEXT_PUBLIC value is inlined at build time;
-# keep it off until focused browser, accessibility, and clinical-governance proof is accepted.
-#NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER=false
+# on the answer stream, so the sources appear during the wait instead of only with the answer.
+# ON by default since 2026-08-27. Setting this to false is the FIRST rollback step: it stops
+# the server emitting the unit at all, so no client build can render one.
+RAG_INCREMENTAL_EVIDENCE_PREVIEW=true
+# #100 Phase 1 client rendering gate. ON by default; only the literal string "false" disables
+# it. This NEXT_PUBLIC value is inlined at build time, so changing it needs a rebuild, not a
+# restart. Disabling this is the SECOND rollback step, after server emission above.
+#NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER=true
# Ambiguity-only structured semantic reranking. Keep false until the retrieval canary is approved.
RAG_SEMANTIC_RERANK_ENABLED=false
# B1 extended answer telemetry (allow-listed numeric fields in rag_queries.metadata).
diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md
index faf2207a2..281c38792 100644
--- a/docs/search-chrome-behaviour.md
+++ b/docs/search-chrome-behaviour.md
@@ -839,7 +839,7 @@ The indicator under test changed when the answer wait was redrawn as a single qu
- 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 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 status line beside it still reads out what is happening, and the arriving source rail appears without layout jumps. Its cards are staggered by `.answer-sources-arriving .stagger-item` when motion is allowed; under Reduce Motion every card must be present and fully opaque immediately, never held invisible for the length of the cascade.
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/verified-answer-incremental-delivery-design.md b/docs/verified-answer-incremental-delivery-design.md
index 2250a1f3d..ce113877f 100644
--- a/docs/verified-answer-incremental-delivery-design.md
+++ b/docs/verified-answer-incremental-delivery-design.md
@@ -1,6 +1,12 @@
# Incremental delivery of verified answer content
-Status: **design accepted for staged implementation; no runtime behaviour changed**
+Status: **Phase 1 shipped and enabled by default (2026-08-27); Phase 2 unbuilt and provider-gated**
+Phase 1 rollout: both flags default ON — server emission `RAG_INCREMENTAL_EVIDENCE_PREVIEW`
+(`src/lib/env.ts`) and client rendering `NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER`
+(`src/lib/client-env.ts`, on unless the literal string `false`). Rollback order is unchanged:
+server emission first, then client rendering. The render path is exercised by
+`tests/answer-progress-ui-smoke.spec.ts` ("the sources arrive during the wait…" and "…paced
+apart…"), which closes the browser-proof gap `#100` recorded.
Tracks: [`#100`](outstanding-issues.md), [`#021`](outstanding-issues.md)
Origin: [`latency-audit-2026-07-28.md`](audit/latency-audit-2026-07-28.md#l0--structural-1)
@@ -114,8 +120,13 @@ This phase is provider-free and must land before either visible phase.
source-governance refusal used by the authoritative final response. Only when it permits disclosure,
build a preview through the existing client-source trimming policy and emit it as
`progress.verifiedUnit`.
-- Render it in a clearly labelled “Selected evidence — answer still being verified” region. Do not
- render it as answer prose or mark the answer complete.
+- Render it where the answer's own source rail will land, so arrival swaps content in place rather
+ than moving it. As shipped this is `AnswerEvidencePreview`, a rail of unnumbered cards under the
+ status line and prose placeholder — not the labelled “Selected evidence” panel this design first
+ described, which stacked a second loud block in the answer's position. Do not render it as answer
+ prose, do not mark the answer complete, and do not number the cards: the preview is retrieval
+ order while the final list is rebuilt from what the answer cites, so an early number can end up
+ pointing at a different document.
- Preserve the current final source list, source governance warnings, feedback token, telemetry, and
persistence behaviour.
diff --git a/src/app/globals.css b/src/app/globals.css
index 4053e8dd3..058ebfada 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -627,6 +627,14 @@
--duration-moderate: 200ms;
--duration-slow: 240ms;
--duration-deliberate: 300ms;
+ /* Stagger intervals — the gap between successive items entering a cascade. Deliberately
+ a separate scale from the duration ladder above and far below its shortest rung: the
+ ladder times an item's own entrance, while these time the space between entrances.
+ `--stagger-cascade` is the default for result grids and skeleton bars; the wider rung
+ is for short sequences where the default reads as a single movement rather than as
+ items arriving one at a time (the answer wait's six source cards). */
+ --stagger-cascade: 35ms;
+ --stagger-cascade-wide: 90ms;
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
--ease-emphasized: cubic-bezier(0.2, 0.8, 0.2, 1);
/* CSS keyword ease-out equivalent — do not conflate with chrome-reveal / soft.
@@ -4360,8 +4368,26 @@ html[data-motion="reduced"] .answer-progress-dot {
.stagger-item {
animation: cascade-fade-up var(--duration-moderate) var(--ease-out-soft) both;
- /* 35ms stagger interval has no duration rung. */
- animation-delay: calc(var(--stagger-index, 0) * 35ms);
+ animation-delay: calc(var(--stagger-index, 0) * var(--stagger-cascade));
+}
+
+/* The answer wait's source rail arrives one card at a time rather than as a single movement.
+ The default `--stagger-cascade` rung is right for a results grid the reader is about to
+ scan, but six cards in 175ms reads as one block appearing. On `--stagger-cascade-wide` the
+ last card lands around 450ms, so each is separately noticeable, and the whole rail is still
+ standing long before generation ends.
+
+ Scoped to this rail deliberately — the shared class also drives the prose skeleton bars
+ immediately above it and the search result grids, which keep the 35ms rung. Declared here,
+ unlayered and adjacent to the rule it overrides, because `.answer-sources-arriving` itself
+ lives in @layer components and a layered rule would lose to this unlayered one whatever its
+ specificity.
+
+ It cannot reintroduce motion when motion is suppressed: both reduced-motion paths below set
+ `animation: none !important` on `.stagger-item`, and that shorthand resets animation-delay
+ to 0s with it, so the cards stay immediately and fully visible. */
+.answer-sources-arriving .stagger-item {
+ animation-delay: calc(var(--stagger-index, 0) * var(--stagger-cascade-wide));
}
@media (prefers-reduced-motion: reduce) {
diff --git a/src/components/clinical-dashboard/answer-evidence-preview.tsx b/src/components/clinical-dashboard/answer-evidence-preview.tsx
index 49109cc15..7586498c2 100644
--- a/src/components/clinical-dashboard/answer-evidence-preview.tsx
+++ b/src/components/clinical-dashboard/answer-evidence-preview.tsx
@@ -10,8 +10,12 @@ import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract";
import { normalizeSourceMetadata } from "@/lib/source-metadata";
/** The render policy caps primary sources at six, so the rail is built for six
- * rather than for the three a specimen usually draws. */
-const visiblePreviewSourceLimit = 6;
+ * rather than for the three a specimen usually draws.
+ *
+ * Exported because the progress line prints this count as a number, and a unit may
+ * legitimately carry up to twelve sources. The line and the rail must read the same cap
+ * from one place or the line claims sources the reader cannot count. */
+export const visiblePreviewSourceLimit = 6;
/**
* The sources, arriving.
diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx
index d7bdfe77c..4ab7f3c2f 100644
--- a/src/components/clinical-dashboard/answer-status.tsx
+++ b/src/components/clinical-dashboard/answer-status.tsx
@@ -9,7 +9,10 @@ import {
answerProgressTookUnusualRoute,
type TimedAnswerProgressUpdate,
} from "@/components/clinical-dashboard/answer-progress";
-import { AnswerEvidencePreview } from "@/components/clinical-dashboard/answer-evidence-preview";
+import {
+ AnswerEvidencePreview,
+ visiblePreviewSourceLimit,
+} 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";
@@ -308,10 +311,12 @@ export function AnswerProgress({
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;
+ // The only number the wait prints, and it counts the cards directly below it — which is
+ // why it is the rail's visible cap, not the unit's length. A unit may carry up to twelve
+ // sources while the rail draws six, and a line reading "8 sources found" above six cards
+ // is a number the reader cannot reconcile with anything on screen.
+ const previewSourceCount = Math.min(evidencePreview?.sources.length ?? 0, visiblePreviewSourceLimit);
+ const previewMessage = latest ? answerProgressPreviewMessage(previewSourceCount, latest.stage) : null;
const currentMessage = previewMessage ?? (latest ? answerProgressDisplayMessage(latest) : "Reading your question…");
const details = events
.map((event) => ({ ...event, displayMessage: answerProgressDisplayMessage(event) }))
diff --git a/src/lib/client-env.ts b/src/lib/client-env.ts
index ebf979965..b60dc03ed 100644
--- a/src/lib/client-env.ts
+++ b/src/lib/client-env.ts
@@ -3,11 +3,22 @@ export function isLocalNoAuthMode() {
return process.env.NODE_ENV !== "production" && process.env.NEXT_PUBLIC_LOCAL_NO_AUTH === "true";
}
-/** Build-time client gate for #100 Phase 1 evidence-preview rendering. */
+/**
+ * Build-time client gate for #100 Phase 1 evidence-preview rendering.
+ *
+ * On unless explicitly disabled (2026-08-27 owner decision). The rail is the wait's most
+ * useful content and the unit reaching the browser has already passed the stream contract's
+ * structural validation, so an unset variable renders it rather than silently withholding it.
+ * `false` is the rollback, and it is the SECOND rollback step: disable server emission
+ * (RAG_INCREMENTAL_EVIDENCE_PREVIEW) first, per
+ * docs/verified-answer-incremental-delivery-design.md.
+ *
+ * This value is inlined at build time, so changing it requires a rebuild, not a restart.
+ */
export function incrementalEvidencePreviewRenderingEnabled(
value = process.env.NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER,
) {
- return value === "true";
+ return value !== "false";
}
export function resolveClientDemoMode({
diff --git a/src/lib/env.ts b/src/lib/env.ts
index 751f50cc5..a0bbc2d63 100644
--- a/src/lib/env.ts
+++ b/src/lib/env.ts
@@ -211,12 +211,20 @@ const envSchema = z.object({
.default("false")
.transform((value) => value === "true"),
// #100 Phase 1: emit a governed, client-trimmed evidence preview as a verified unit on
- // the answer stream once retrieval + ranking complete. Default OFF: server emission is
- // enabled deliberately after the offline contract proof; rendering is a separate client
- // flag per docs/verified-answer-incremental-delivery-design.md.
+ // the answer stream once retrieval + ranking complete. Default ON since 2026-08-27 by owner
+ // decision, after the offline contract proof and the browser journey in
+ // tests/answer-progress-ui-smoke.spec.ts proved the render path.
+ //
+ // The preview is built from the already-selected context, passes the same danger-level
+ // source-governance refusal as the final answer, and is trimmed by the same
+ // trimSourceForClient policy — retrieval, ranking, selection and the final payload are
+ // unchanged by it. Setting this to `false` is the FIRST rollback step; the client
+ // rendering gate (NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER) is the second, per
+ // docs/verified-answer-incremental-delivery-design.md. Phase 2 answer-section units remain
+ // unbuilt and provider-gated.
RAG_INCREMENTAL_EVIDENCE_PREVIEW: z
.enum(["true", "false"])
- .default("false")
+ .default("true")
.transform((value) => value === "true"),
RAG_REGISTRY_CORPUS_EMBEDDING: z
.enum(["true", "false"])
diff --git a/tests/answer-evidence-preview.dom.test.tsx b/tests/answer-evidence-preview.dom.test.tsx
index c7abfad50..ec9922096 100644
--- a/tests/answer-evidence-preview.dom.test.tsx
+++ b/tests/answer-evidence-preview.dom.test.tsx
@@ -1,7 +1,8 @@
import { render, screen, within } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { AnswerEvidencePreview } from "@/components/clinical-dashboard/answer-evidence-preview";
+import { AnswerProgress } from "@/components/clinical-dashboard/answer-status";
import { incrementalEvidencePreviewRenderingEnabled } from "@/lib/client-env";
import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract";
import { normalizeSourceMetadata } from "@/lib/source-metadata";
@@ -30,10 +31,25 @@ function evidencePreview(sourceCount = 4): VerifiedEvidencePreviewUnit {
}
describe("incremental answer evidence preview", () => {
- it("keeps its client render flag off unless explicitly enabled", () => {
- expect(incrementalEvidencePreviewRenderingEnabled(undefined)).toBe(false);
- expect(incrementalEvidencePreviewRenderingEnabled("false")).toBe(false);
+ // Inverted 2026-08-27 when Phase 1 was enabled by default. The rail is the wait's most
+ // useful content and every unit reaching the browser has already passed the stream
+ // contract's structural validation, so an unset variable renders it. Only the literal
+ // string "false" — the documented second rollback step — withholds it.
+ // The unset case reads the ambient variable through the default parameter, so it has to
+ // be stubbed away or the assertion only reports what the runner's environment happens to
+ // hold — it would pass for the wrong reason locally and fail on a shell that exports the
+ // rollback value.
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("renders unless the client gate is explicitly disabled", () => {
+ vi.stubEnv("NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER", undefined);
+ expect(process.env.NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER).toBeUndefined();
+ expect(incrementalEvidencePreviewRenderingEnabled()).toBe(true);
+ expect(incrementalEvidencePreviewRenderingEnabled(undefined)).toBe(true);
expect(incrementalEvidencePreviewRenderingEnabled("true")).toBe(true);
+ expect(incrementalEvidencePreviewRenderingEnabled("false")).toBe(false);
});
it("renders a bounded, non-live rail without presenting a completed answer", () => {
@@ -95,4 +111,24 @@ describe("incremental answer evidence preview", () => {
const { container } = render();
expect(container.firstChild).toBeNull();
});
+
+ // The wait prints exactly one number, and the contract for it is that the reader can
+ // count it on screen. A unit may legitimately carry up to twelve sources while the rail
+ // draws six, so the line has to read the rail's cap and not the unit's length — "8
+ // sources found" above six cards is the one thing this surface promises never to do.
+ it("counts only the sources the reader can see, not every source in the unit", () => {
+ render(
+ {}}
+ evidencePreview={evidencePreview(8)}
+ />,
+ );
+
+ const cards = screen.getAllByTestId("answer-evidence-preview-source");
+ expect(cards).toHaveLength(6);
+ expect(screen.getByTestId("answer-progress-line")).toHaveTextContent("6 sources found · writing the answer…");
+ });
});
diff --git a/tests/answer-progress-indicator-css.test.ts b/tests/answer-progress-indicator-css.test.ts
index fae549d00..4079bc000 100644
--- a/tests/answer-progress-indicator-css.test.ts
+++ b/tests/answer-progress-indicator-css.test.ts
@@ -44,6 +44,28 @@ function keyframes(name: string) {
throw new Error(`${name} keyframes are unterminated`);
}
+/** The character range of the `@layer components { … }` block, so a rule can be shown to sit
+ * outside it. Layered rules lose to unlayered ones in the cascade regardless of specificity,
+ * which is the whole reason the rail's override is declared where it is. */
+function componentsLayerRange() {
+ const start = globalsCss.indexOf("@layer components {");
+ expect(start, "@layer components is missing").toBeGreaterThanOrEqual(0);
+
+ let depth = 0;
+ for (let index = start; index < globalsCss.length; index += 1) {
+ if (globalsCss[index] === "{") depth += 1;
+ else if (globalsCss[index] === "}") {
+ depth -= 1;
+ if (depth === 0) {
+ const end = index;
+ return { contains: (position: number) => position > start && position < end };
+ }
+ }
+ }
+
+ throw new Error("@layer components is unterminated");
+}
+
function dotRuleBodies() {
return [...globalsCss.matchAll(/\.answer-progress-dot\s*{([^}]*)}/g)].map((match) => match[1] ?? "");
}
@@ -100,6 +122,37 @@ describe("answer progress indicator CSS", () => {
expect(answerStatusSource).toContain("answer-progress-dot grid");
});
+ it("paces the arriving source rail apart from the shared cascade rung", () => {
+ // The rail's cards must arrive one at a time, not as one block. `.stagger-item`
+ // ships 35ms, which is right for the prose skeleton bars directly above the rail
+ // and for search result grids; six cards at that interval is 175ms and reads as a
+ // single movement. The override is pinned here so a later edit to the shared rung
+ // cannot silently re-collapse the rail into one beat.
+ const railRule = globalsCss.match(/\.answer-sources-arriving \.stagger-item\s*{([^}]*)}/);
+ expect(railRule, "the rail's stagger override is missing").not.toBeNull();
+ expect(railRule?.[1]).toContain("var(--stagger-cascade-wide)");
+
+ // Both rungs are tokens, so the pacing is nameable and the design-system contract's
+ // hardcoded-duration ratchet stays satisfied. The wide rung must actually be wider —
+ // pointing it at the same value would leave the rule in place and the defect back.
+ const rung = (name: string) => Number(globalsCss.match(new RegExp(`--${name}:\\s*(\\d+)ms`))?.[1]);
+ expect(rung("stagger-cascade")).toBeGreaterThan(0);
+ expect(rung("stagger-cascade-wide")).toBeGreaterThan(rung("stagger-cascade"));
+
+ // Declared UNLAYERED. `.answer-sources-arriving` itself lives in @layer components,
+ // and a layered override loses to the unlayered `.stagger-item` rule whatever its
+ // specificity — the rail would silently keep the 35ms rung.
+ const railIndex = globalsCss.indexOf(".answer-sources-arriving .stagger-item");
+ expect(railIndex).toBeGreaterThan(0);
+ expect(componentsLayerRange().contains(railIndex), "the override must not sit in @layer components").toBe(false);
+
+ // And the reduced-motion resets must still come after it, so a suppressed rail
+ // shows every card at once rather than holding six invisible cards for 450ms.
+ expect(globalsCss.lastIndexOf('html[data-motion="reduced"] .stagger-item')).toBeGreaterThan(
+ globalsCss.indexOf(".answer-sources-arriving .stagger-item"),
+ );
+ });
+
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,
diff --git a/tests/answer-progress-ui-smoke.spec.ts b/tests/answer-progress-ui-smoke.spec.ts
index d85214e79..c4fa1e51b 100644
--- a/tests/answer-progress-ui-smoke.spec.ts
+++ b/tests/answer-progress-ui-smoke.spec.ts
@@ -222,6 +222,89 @@ async function installHoldingAnswerStream(page: Page) {
});
}
+/**
+ * The wait, with the evidence preview on it (#100 Phase 1).
+ *
+ * The preview rides the existing `progress` event as an optional `verifiedUnit`, and the
+ * client discards anything that fails `isDeliverableVerifiedUnit` — exact key allow-list,
+ * `images: []`, snippet under 900 characters — so these fixtures are built to that contract
+ * rather than to a convenient shape. Eight sources are sent to prove the rail's six-card cap
+ * and the line that counts it.
+ */
+function previewSource(index: number) {
+ return {
+ id: `chunk-${index + 1}`,
+ document_id: `doc-${index + 1}`,
+ title: `Synthetic monitoring guideline ${index + 1}`,
+ file_name: `guideline-${index + 1}.pdf`,
+ page_number: index + 2,
+ chunk_index: index,
+ section_heading: "Monitoring",
+ content: "Review the source passage and confirm the monitoring schedule before clinical use.",
+ image_ids: [],
+ similarity: 0.8 - index * 0.01,
+ images: [],
+ source_metadata: { document_status: "current", clinical_validation_status: "unverified" },
+ };
+}
+
+async function installEvidencePreviewAnswerStream(page: Page) {
+ const finalAnswer = { ...demoAnswer("Lithium dosing"), demoMode: true };
+ const sources = Array.from({ length: 8 }, (_, index) => previewSource(index));
+ await page.addInitScript(
+ ({ answer, previewSources }) => {
+ const originalFetch = window.fetch.bind(window);
+ window.fetch = async (input, init) => {
+ const rawUrl = typeof input === "string" ? input : input instanceof Request ? input.url : String(input);
+ const pathname = new URL(rawUrl, window.location.href).pathname;
+ if (pathname !== "/api/answer/stream") return originalFetch(input, init);
+
+ const encoder = new TextEncoder();
+ const events: Array<{ delay: number; event: string; data: unknown }> = [
+ { delay: 0, event: "progress", data: { stage: "scoping", message: "Preparing scope." } },
+ { delay: 150, event: "progress", data: { stage: "retrieving", message: "Searching documents." } },
+ {
+ delay: 400,
+ event: "progress",
+ data: {
+ stage: "ranking",
+ message: "Selecting evidence.",
+ selectedContextCount: 8,
+ verifiedUnit: {
+ schemaVersion: 1,
+ kind: "evidence_preview",
+ sequence: 0,
+ selectedContextCount: 8,
+ sources: previewSources,
+ },
+ },
+ },
+ { delay: 700, event: "progress", data: { stage: "generating", message: "Drafting answer." } },
+ // Held wide open: the whole point of the preview is that it is readable for the
+ // seconds generation takes, so the assertions below must run inside that window.
+ { delay: 4_000, event: "progress", data: { stage: "complete", message: "Ready.", elapsedMs: 4_000 } },
+ { delay: 4_100, event: "final", data: answer },
+ ];
+
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ for (const item of events) {
+ window.setTimeout(() => {
+ controller.enqueue(encoder.encode(`event: ${item.event}\ndata: ${JSON.stringify(item.data)}\n\n`));
+ if (item.event === "final") controller.close();
+ }, item.delay);
+ }
+ },
+ }),
+ { status: 200, headers: { "Content-Type": "text/event-stream; charset=utf-8" } },
+ );
+ };
+ },
+ { answer: finalAnswer, previewSources: sources },
+ );
+}
+
async function installSuccessfulThenInvalidAnswerStreams(page: Page) {
const firstAnswer = { ...demoAnswer("Lithium dosing"), demoMode: true };
await page.addInitScript(
@@ -537,6 +620,115 @@ test("the wait stands where the answer will, so arrival swaps content in place",
expect(await page.locator('[role="status"][aria-label="Loading answer"]').count()).toBe(0);
});
+test("the sources arrive during the wait and hand over to the answer's own rail", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await mockDashboardApis(page);
+ await installEvidencePreviewAnswerStream(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");
+ const rail = progress.getByTestId("answer-evidence-preview");
+ const cards = rail.getByTestId("answer-evidence-preview-source");
+
+ // The whole point: source-backed content is on screen while the answer is still
+ // being written, not only when it lands.
+ await expect(rail).toBeVisible({ timeout: 8_000 });
+ await expect(progress).toHaveAttribute("data-progress-state", "active");
+
+ // Six of the eight sent — the render policy's primary-source cap.
+ await expect(cards).toHaveCount(6);
+
+ // The one number the wait prints, and it counts exactly the cards below it.
+ await expect(progress.getByTestId("answer-progress-line")).toContainText("6 sources found");
+
+ // Numbering is what arrival buys. The preview is retrieval order; the final list is
+ // rebuilt from what the answer cites, so a number assigned now can point at a
+ // different document once the answer lands.
+ for (const card of await cards.all()) {
+ await expect(card.locator("[aria-hidden='true']").first()).toHaveText("\u2022");
+ }
+ await expect(rail).toHaveAttribute("aria-label", /not yet numbered/i);
+
+ // Every 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.
+ await expect(cards.first()).toHaveAttribute("href", "/documents/doc-1?page=2&chunk=chunk-1");
+
+ // Line, then the prose placeholder where the prose lands, then the sources where the
+ // answer's own rail lands. Every element already stands where its finished counterpart
+ // will, which is the entire "nothing jumps" claim.
+ 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"]'),
+ preview: top('[data-testid="answer-evidence-preview"]'),
+ };
+ });
+ expect(order.preview).not.toBeNull();
+ expect(order.skeleton ?? 0).toBeGreaterThan(order.line ?? 0);
+ expect(order.preview ?? 0).toBeGreaterThan(order.skeleton ?? 0);
+
+ // Arrival swaps content in place: the preview rail goes, the answer's own numbered
+ // rail stands in its position.
+ await expect(page.getByText(/In the synthetic lithium document/i)).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByTestId("answer-evidence-preview")).toHaveCount(0);
+ await expect(page.getByTestId("answer-source-rail")).toBeVisible();
+});
+
+test("the arriving sources are paced apart, and are simply present when motion is suppressed", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await mockDashboardApis(page);
+ await installEvidencePreviewAnswerStream(page);
+ await page.goto("/?mode=answer", { waitUntil: "domcontentloaded" });
+ await dismissBlockingPwaNotice(page);
+
+ const submit = await fillHydratedAnswerQuestion(page, "Lithium dosing");
+ await submit.click();
+
+ const cards = page.getByTestId("answer-evidence-preview").getByTestId("answer-evidence-preview-source");
+ await expect(cards.first()).toBeVisible({ timeout: 8_000 });
+
+ // Reduced motion first, because the suite runs that way by default (see the
+ // dual-mode note on contextOptions in playwright.config.ts). Suppressing motion must
+ // never withhold the content: the cards stop animating and are immediately, fully
+ // visible — not held invisible for the length of the cascade, which is exactly what a
+ // delay on a `both`-filled animation would do if the reduced-motion reset did not also
+ // zero the delay.
+ const suppressed = await cards.evaluateAll((nodes) =>
+ nodes.map((node) => ({
+ name: getComputedStyle(node).animationName,
+ delay: getComputedStyle(node).animationDelay,
+ opacity: getComputedStyle(node).opacity,
+ })),
+ );
+ expect(suppressed).toHaveLength(6);
+ for (const card of suppressed) {
+ expect(card.name).toBe("none");
+ expect(card.delay).toBe("0s");
+ expect(card.opacity).toBe("1");
+ }
+
+ // With motion allowed, cards arrive one at a time rather than as a single block. The
+ // shared `.stagger-item` rung is 35ms, which reads as one movement across six cards;
+ // this rail overrides it so each card is separately noticeable.
+ await page.emulateMedia({ reducedMotion: "no-preference" });
+ const delays = await cards.evaluateAll((nodes) =>
+ nodes.map((node) => Number.parseFloat(getComputedStyle(node).animationDelay)),
+ );
+ expect(delays[0]).toBe(0);
+ expect(delays[1] ?? 0).toBeGreaterThan(0.035);
+ expect(delays[5] ?? 0).toBeGreaterThan(delays[1] ?? 0);
+ // …and the whole rail is still standing well before a normal generation wait ends.
+ expect(delays[5] ?? 0).toBeLessThan(1);
+});
+
test("a completion frame cannot mark a previous answer complete when final is invalid", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockDashboardApis(page);
diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts
index 875682aa3..e45ae7b4c 100644
--- a/tests/ui-specifiers.spec.ts
+++ b/tests/ui-specifiers.spec.ts
@@ -1,5 +1,5 @@
import AxeBuilder from "@axe-core/playwright";
-import { expect, test, type Page, type TestInfo } from "playwright/test";
+import { expect, test, type Locator, type Page, type TestInfo } from "playwright/test";
const axeWcagTags = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"];
const axeBlockingImpacts = new Set(["critical", "serious"]);
@@ -29,6 +29,25 @@ async function gotoApp(page: Page, path: string) {
.catch(() => undefined);
}
+/** `gotoApp` waits for the server-rendered shell, not for React to attach its handlers.
+ * A click that lands in that window is silently dropped, and the assertion that follows
+ * fails as "element(s) not found" for a heading the step change would have rendered.
+ * Same probe as `tests/ui-smoke.spec.ts` and `tests/ui-stress.spec.ts`. */
+async function waitForReactEventHandler(locator: Locator, eventName: "onClick") {
+ await expect
+ .poll(
+ async () =>
+ locator.evaluate((element, reactEventName) => {
+ const propsKey = Object.keys(element).find((key) => key.startsWith("__reactProps$"));
+ if (!propsKey) return false;
+ const props = (element as unknown as Record>)[propsKey];
+ return typeof props?.[reactEventName] === "function";
+ }, eventName),
+ { timeout: 15_000 },
+ )
+ .toBe(true);
+}
+
async function expectNoHorizontalOverflow(page: Page) {
const overflow = await page.evaluate(
() => Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0) - window.innerWidth,
@@ -400,7 +419,9 @@ test("guides choices into a reviewable and copyable diagnosis", async ({ page },
const previous = page.getByRole("button", { name: "Previous", exact: true });
await expect(previous).toBeDisabled();
- await page.getByRole("button", { name: "Continue to features" }).click();
+ const continueToFeatures = page.getByRole("button", { name: "Continue to features" });
+ await waitForReactEventHandler(continueToFeatures, "onClick");
+ await continueToFeatures.click();
await expect(page.getByRole("heading", { name: "Add episode features" })).toBeFocused();
await page.getByText("Mixed features", { exact: true }).click();
await page.getByRole("button", { name: "Continue to course" }).click();
@@ -457,7 +478,9 @@ test("keeps the guide usable with reduced motion and forced colors", async ({ pa
.poll(() => page.evaluate(() => window.matchMedia("(prefers-reduced-motion: reduce)").matches))
.toBe(true);
await expect.poll(() => page.evaluate(() => window.matchMedia("(forced-colors: active)").matches)).toBe(true);
- await page.getByRole("button", { name: "Continue to features" }).click();
+ const continueToFeatures = page.getByRole("button", { name: "Continue to features" });
+ await waitForReactEventHandler(continueToFeatures, "onClick");
+ await continueToFeatures.click();
await expect(page.getByRole("heading", { name: "Add episode features" })).toBeFocused();
await expectNoHorizontalOverflow(page);
await expectNoBlockingAxeViolations(page, testInfo);