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);