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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion docs/search-chrome-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 14 additions & 3 deletions docs/verified-answer-incremental-delivery-design.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Incremental delivery of verified answer content

Status: **design accepted for staged implementation; no runtime behaviour changed**<br>
Status: **Phase 1 shipped and enabled by default (2026-08-27); Phase 2 unbuilt and provider-gated**<br>
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.<br>
Tracks: [`#100`](outstanding-issues.md), [`#021`](outstanding-issues.md)<br>
Origin: [`latency-audit-2026-07-28.md`](audit/latency-audit-2026-07-28.md#l0--structural-1)

Expand Down Expand Up @@ -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.

Expand Down
30 changes: 28 additions & 2 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 10 additions & 5 deletions src/components/clinical-dashboard/answer-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) }))
Expand Down
15 changes: 13 additions & 2 deletions src/lib/client-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
16 changes: 12 additions & 4 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
44 changes: 40 additions & 4 deletions tests/answer-evidence-preview.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Comment thread
BigSimmo marked this conversation as resolved.
});

it("renders a bounded, non-live rail without presenting a completed answer", () => {
Expand Down Expand Up @@ -95,4 +111,24 @@ describe("incremental answer evidence preview", () => {
const { container } = render(<AnswerEvidencePreview preview={evidencePreview(0)} />);
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(
<AnswerProgress
events={[{ stage: "generating", message: "Drafting.", receivedAt: 0 }]}
startedAt={0}
active
onStop={() => {}}
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…");
});
});
53 changes: 53 additions & 0 deletions tests/answer-progress-indicator-css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] ?? "");
}
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading