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
13 changes: 9 additions & 4 deletions data/repo-awareness-snapshot.json
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
{
"version": "repo-awareness-snapshot-v1",
"captured_revision": {
"sha": "2717fc58379236dbc41f6755cf77d55c9f6d4b12",
"committed_at": "2026-08-27T22:51:37+08:00"
"sha": "15dcd00c16cfa8328a0a578f392e221459034aed",
"committed_at": "2026-08-27T16:04:44+00:00"
},
"routes": {
"modes": [
Expand DownExpand Up@@ -313,6 +313,11 @@
"file": "src/app/mockups/answer-home-proposal/page.tsx",
"area": "mockup"
},
{
"path": "/mockups/answer-loading-redesign",
"file": "src/app/mockups/answer-loading-redesign/page.tsx",
"area": "mockup"
},
{
"path": "/mockups/calculators-bedside-sheet",
"file": "src/app/mockups/calculators-bedside-sheet/page.tsx",
Expand DownExpand Up@@ -1367,9 +1372,9 @@
],
"counts": {
"modes": 15,
"pages": 189,
"pages": 190,
"product_pages": 55,
"mockup_pages": 134,
"mockup_pages": 135,
"redirects": 17,
"api": 58
}
Expand Down
7 changes: 6 additions & 1 deletion docs/design-system/COMPONENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -480,9 +480,14 @@ into the previous sentence; `RouteAnnouncer` skips the first render (arrival is
navigation), moves focus to the new `<h1>` 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`
Expand Down
10 changes: 6 additions & 4 deletions docs/search-chrome-behaviour.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/site-map.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`.
Expand Down
78 changes: 40 additions & 38 deletions src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 */
Expand DownExpand Up@@ -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;
}
}

Expand All@@ -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 {
Expand DownExpand Up@@ -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. */
Expand Down
5 changes: 5 additions & 0 deletions src/app/mockups/answer-loading-redesign/page.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
import { AnswerLoadingRedesignMockupsPage } from "@/components/answer-loading-redesign-mockups";

export default function AnswerLoadingRedesignMockupRoute() {
return <AnswerLoadingRedesignMockupsPage />;
}
7 changes: 7 additions & 0 deletions src/app/mockups/mockups-layout-client.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -184,6 +189,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) {
!isAnswerHomeProposalMockup &&
!isAnswerChatRedesignMockup &&
!isAnswerChatPerfectedMockup &&
!isAnswerLoadingRedesignMockup &&
!isPrivacyPageDirectionsMockup &&
!isPrivacyLiveSignalPerfectedMockup &&
!isSearchLensMenuMockup &&
Expand DownExpand Up@@ -216,6 +222,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) {
!isAnswerHomeProposalMockup &&
!isAnswerChatRedesignMockup &&
!isAnswerChatPerfectedMockup &&
!isAnswerLoadingRedesignMockup &&
!isPrivacyPageDirectionsMockup &&
!isPrivacyLiveSignalPerfectedMockup &&
!isSearchLensMenuMockup &&
Expand Down
24 changes: 15 additions & 9 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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";
Expand DownExpand Up@@ -3650,22 +3649,23 @@ function ClinicalDashboardContent({
{searchMode !== "prescribing" &&
(activeModeResultKind === "answer" ? (
showAnswerProgress ? (
<AnswerProgressStepper
// The evidence preview is rendered by AnswerProgress rather than
// as a sibling panel below it. Two separate blocks in the answer's
// own position — a progress panel and an evidence panel — both
// vanished when the answer arrived; as one unit the rail simply
// stays and takes its numbers.
<AnswerProgress
events={answerProgressEvents}
startedAt={answerProgressStartedAt}
active={loading}
onStop={stopSearch}
density={loading && Boolean(answer) ? "compact" : "expanded"}
evidencePreview={loading ? answerEvidencePreview : null}
/>
) : null
) : loading && answerProgress ? (
<SearchProgressBanner message={answerProgress} onStop={stopSearch} />
) : null)}

{activeModeResultKind === "answer" && loading && answerEvidencePreview ? (
<AnswerEvidencePreview preview={answerEvidencePreview} />
) : null}

{showUniversalAlsoMatches &&
(activeModeResultKind === "tools" ||
activeModeResultKind === "documents" ||
Expand DownExpand Up@@ -3784,7 +3784,13 @@ function ClinicalDashboardContent({
</>
)
) : showAnswerPending ? (
<AnswerSkeleton />
// 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 : (
<AnswerSkeleton />
)
) : answer && answerRenderModel ? (
stagedDashboardExtraction.answerSurface ? (
<>
Expand Down
4 changes: 2 additions & 2 deletions src/components/DocumentViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -1416,7 +1416,7 @@ export function DocumentViewer({
{(loadingSummary || summary || summaryError) && (
<div className="min-w-0 space-y-3 lg:col-span-2">
{summaryProgressStartedAt && summaryProgressEvents.length > 0 ? (
<AnswerProgressStepper
<AnswerProgress
events={summaryProgressEvents}
startedAt={summaryProgressStartedAt}
active={loadingSummary}
Expand Down
Loading
Loading