From db98bf0698537e261dd031db47d70b08e895e244 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 22:13:20 +0000 Subject: [PATCH 1/6] mockup(answer): redraw the wait to match the answer it precedes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The answer surface was quietened in #2386 and #2388 — the assistant tile off the turn, one number per claim, the safety rail's colour moved into its own icon, two panels made one. The loading state was not in that pass, so AnswerProgressStepper still owns the four to twelve seconds before every answer with a filled accent panel, a 36px icon tile, a five-circle stepper, four connecting rails, a scrolling ECG trace, a per-second elapsed counter and a Processing details disclosure. It is now the loudest element on the surface, and it is the first thing a reader sees. New study at /mockups/answer-loading-redesign, in four panels. Panel One draws the real AnswerProgressStepper — imported from answer-status.tsx rather than redrawn, so the comparison is against what ships and not against an unflattering approximation. Both densities, counter live. Six observations travel with it, of which the substantive one is the last: the evidence preview already crosses the stream boundary before the prose, trimmed and owner-scoped and consumed by the client today, and the surface currently spends that arrival on a progress bar about the fact that it arrived. Panel Two runs three directions off one shared eleven-beat clock, because a loading state cannot be judged from a still. A scrubs to any beat and pauses. Direction A is what answer-chat-perfected-v2 already drew, held for the whole wait. Direction B adds the sources arriving — each lands in the rail as it is found, carrying a dot rather than a number, because the evidence preview is retrieval-ordered while the final list is rebuilt from what the answer cites, so a number assigned during the wait can end up pointing at a different document. Direction C names each document as it is opened and is drawn to be rejected: retrieval opens far more documents than the answer cites, so a reader who watches six titles go past takes all six to be behind the answer. B is recommended. All three end on the same arrived answer and the same numbered rail, so only the wait is being compared. Panel Three is B at the 68ch desktop measure, with the copy for each beat mapped to the stage that already emits it, and a list of what is deliberately dropped. Panel Four draws the five states the current stepper renders identically — slow, nothing found, assembled without the model, stopped, reduced motion. Design scratch only. No production surface changes: answer-status.tsx still ships the stepper drawn in Panel One, and removing it is the follow-up once a direction is chosen. Verified: tsc --noEmit clean; eslint clean on all three changed files; prettier clean; check:design-system-contract passed (55 components, 76 roots, no ratchet moved); route-reachability, site-map, bundle-budget and calculator-mockup-boundary suites 62 passed (4 files); page rendered at 1440px in Chromium with zero console errors, scrubbed and screenshotted at the searching and arrived beats. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012kHT2YKCNnUrVckTaJW6ga --- docs/site-map.md | 1 + .../mockups/answer-loading-redesign/page.tsx | 5 + src/app/mockups/mockups-layout-client.tsx | 7 + .../answer-loading-redesign-mockups.tsx | 1037 +++++++++++++++++ 4 files changed, 1050 insertions(+) create mode 100644 src/app/mockups/answer-loading-redesign/page.tsx create mode 100644 src/components/answer-loading-redesign-mockups.tsx diff --git a/docs/site-map.md b/docs/site-map.md index 687e0c6c5f..a737bb4847 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1139,6 +1139,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/answer-chat-redesign` - Route discovered from app directory Source: `src/app/mockups/answer-chat-redesign/page.tsx`. - `/mockups/answer-evidence-popups` - Route discovered from app directory Source: `src/app/mockups/answer-evidence-popups/page.tsx`. - `/mockups/answer-home-proposal` - Route discovered from app directory Source: `src/app/mockups/answer-home-proposal/page.tsx`. +- `/mockups/answer-loading-redesign` - Route discovered from app directory Source: `src/app/mockups/answer-loading-redesign/page.tsx`. - `/mockups/calculators-bedside-sheet` - Route discovered from app directory Source: `src/app/mockups/calculators-bedside-sheet/page.tsx`. - `/mockups/calculators-clinical-console` - Route discovered from app directory Source: `src/app/mockups/calculators-clinical-console/page.tsx`. - `/mockups/calculators-directory-grid` - Route discovered from app directory Source: `src/app/mockups/calculators-directory-grid/page.tsx`. diff --git a/src/app/mockups/answer-loading-redesign/page.tsx b/src/app/mockups/answer-loading-redesign/page.tsx new file mode 100644 index 0000000000..918b1c9297 --- /dev/null +++ b/src/app/mockups/answer-loading-redesign/page.tsx @@ -0,0 +1,5 @@ +import { AnswerLoadingRedesignMockupsPage } from "@/components/answer-loading-redesign-mockups"; + +export default function AnswerLoadingRedesignMockupRoute() { + return ; +} diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 6149ceb96e..42bf0b2e09 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -66,6 +66,11 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { const isAnswerChatRedesignMockup = pathname === "/mockups/answer-chat-redesign"; const isAnswerChatPerfectedMockup = pathname === "/mockups/answer-chat-perfected" || pathname === "/mockups/answer-chat-perfected-v2"; + // The loading study draws its own top bar, transcript and composer inside every + // phone and desktop frame, and its whole subject is what occupies the answer + // column before the answer. Shared chrome above those frames would read as a + // second real header and a second real search bar over the study. + const isAnswerLoadingRedesignMockup = pathname === "/mockups/answer-loading-redesign"; // Draws its own sticky chrome + device frames for /privacy; shared shell would // read as a second real header over the study. const isPrivacyPageDirectionsMockup = pathname === "/mockups/privacy-page-directions"; @@ -177,6 +182,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isAnswerHomeProposalMockup && !isAnswerChatRedesignMockup && !isAnswerChatPerfectedMockup && + !isAnswerLoadingRedesignMockup && !isPrivacyPageDirectionsMockup && !isPrivacyLiveSignalPerfectedMockup && !isSearchLensMenuMockup && @@ -208,6 +214,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isAnswerHomeProposalMockup && !isAnswerChatRedesignMockup && !isAnswerChatPerfectedMockup && + !isAnswerLoadingRedesignMockup && !isPrivacyPageDirectionsMockup && !isPrivacyLiveSignalPerfectedMockup && !isSearchLensMenuMockup && diff --git a/src/components/answer-loading-redesign-mockups.tsx b/src/components/answer-loading-redesign-mockups.tsx new file mode 100644 index 0000000000..ce9ddb5b2e --- /dev/null +++ b/src/components/answer-loading-redesign-mockups.tsx @@ -0,0 +1,1037 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react"; +import { FileText, Pause, Play, RotateCcw, Search, Square } from "lucide-react"; + +import type { TimedAnswerProgressUpdate } from "@/components/clinical-dashboard/answer-progress"; +import { AnswerProgressStepper } from "@/components/clinical-dashboard/answer-status"; +import { + Composer, + DesktopFrame, + DetailCard, + Panel, + PhoneFrame, + PROSE_MEASURE, + TopBar, + UserTurn, + focusRing, + type SourceStatus, +} from "@/components/answer-chat-perfected-mockups"; +import { cn } from "@/components/ui-primitives"; + +/** + * The wait, redrawn to match the answer it precedes. + * + * `/mockups/answer-chat-perfected-v2` and the two PRs that landed from it + * (#2386, #2388) spent their whole argument on subtraction: the assistant + * avatar came off the turn because a ~2.75rem column cost every line of a + * clinical answer on a 390px phone; the safety card lost a warning-coloured + * rule that spanned two controls carrying no state; the second copies of the + * follow-up chips and the also-matches panel were deleted. What is left is + * quiet — muted 2xs status text, one accent, hairlines, and the sources. + * + * The loading state was not part of that pass, so `AnswerProgressStepper` still + * ships as written: a filled accent panel carrying an icon tile, a five-circle + * stepper with connecting rails, a scrolling ECG trace, a per-second elapsed + * counter and a Processing details disclosure. It is the loudest element on the + * answer surface, and it occupies that surface for the four to twelve seconds + * before the answer — so it is the first thing a reader sees and the thing that + * sets their expectation of the answer's register. + * + * Panel One draws the real component (imported, not redrawn) so the comparison + * is against what ships. Panels Two and Three propose three replacements and + * recommend one. Panel Four draws the states that are not the happy path, + * because the wait is where most of them are decided. + * + * Nothing here is wired to retrieval. All copy and all counts are synthetic. + */ + +/* ══════════════════════ data ══════════════════════ */ + +type LoadingSource = { + id: string; + index: number; + short: string; + origin: string; + page: number; + status: SourceStatus; +}; + +/** Six, because `dedupeSourceLinks` caps primary sources at six and the rail + * has to survive its own worst case rather than the three a specimen draws. */ +const POOL: LoadingSource[] = [ + { + id: "s1", + index: 1, + short: "Physical health protocol", + origin: "Statewide mental health · 2025", + page: 12, + status: "current", + }, + { + id: "s2", + index: 2, + short: "Myocarditis surveillance", + origin: "Local formulary · 2025", + page: 14, + status: "current", + }, + { + id: "s3", + index: 3, + short: "Metabolic monitoring", + origin: "RANZCP guidance · 2024", + page: 31, + status: "current", + }, + { + id: "s4", + index: 4, + short: "Clozapine titration", + origin: "Hospital protocol · 2023", + page: 4, + status: "review-due", + }, + { + id: "s5", + index: 5, + short: "Neutropenia thresholds", + origin: "Haematology pathway · 2025", + page: 8, + status: "current", + }, + { + id: "s6", + index: 6, + short: "Bowel care in clozapine", + origin: "Statewide mental health · 2025", + page: 22, + status: "current", + }, +]; + +const ANSWER_LINES: Array<{ id: string; text: string; refs: number[] }> = [ + { + id: "a1", + text: "Full blood count and absolute neutrophil count at baseline, weekly for the first 18 weeks, fortnightly to week 52, then monthly while treatment continues.", + refs: [1], + }, + { + id: "a2", + text: "Troponin and CRP at baseline and weekly for the first four weeks, with urgent cardiology review where troponin exceeds twice the upper limit of normal.", + refs: [2], + }, + { + id: "a3", + text: "Weight, waist circumference, lipids and HbA1c at baseline, at three months, then annually.", + refs: [3, 5], + }, +]; + +/* ══════════════════════ the replay clock ══════════════════════ */ + +/** + * A loading state cannot be judged from a still. Every direction below is + * driven from one shared tick so the three phones move together and can be + * compared at the same instant, and so a reviewer can stop the clock on the + * frame they want to argue about. + * + * Autoplay is off when the reviewer's OS asks for reduced motion — this page + * is about restraint, and a page about restraint that ignores the setting + * would be arguing against itself. + */ +const BEAT_COUNT = 11; + +type Phase = "asked" | "searching" | "writing" | "answered"; + +type Beat = { + tick: number; + phase: Phase; + found: number; + scanned: number; + seconds: number; +}; + +const BEAT_SECONDS = [0, 0.6, 1.1, 1.5, 1.9, 2.3, 2.7, 3.4, 4.6, 6.1, 7.2]; + +function beatFor(tick: number): Beat { + const seconds = BEAT_SECONDS[tick] ?? 0; + if (tick === 0) return { tick, phase: "asked", found: 0, scanned: 0, seconds }; + if (tick <= 6) { + return { tick, phase: "searching", found: tick, scanned: Math.round((2_140 * tick) / 6), seconds }; + } + if (tick <= 9) return { tick, phase: "writing", found: 6, scanned: 2_140, seconds }; + return { tick, phase: "answered", found: 6, scanned: 2_140, seconds }; +} + +const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + +/** Subscribed rather than sampled into state, so the preference is read during + * render and the server snapshot is a stable `false`. Sampling it in an effect + * would mean a first paint that autoplays and then stops. */ +function usePrefersReducedMotion() { + return useSyncExternalStore( + (onChange) => { + const query = window.matchMedia(REDUCED_MOTION_QUERY); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }, + () => window.matchMedia(REDUCED_MOTION_QUERY).matches, + () => false, + ); +} + +function useReplayClock(intervalMs = 640) { + const [tick, setTick] = useState(0); + // `null` means "follow the OS". Pressing Play or Pause is a deliberate + // override and is honoured either way — a reviewer who asked for reduced + // motion may still want to watch this page move once. + const [override, setOverride] = useState(null); + const reducedMotion = usePrefersReducedMotion(); + const playing = override ?? !reducedMotion; + + useEffect(() => { + if (!playing) return undefined; + const id = window.setInterval(() => setTick((current) => (current + 1) % BEAT_COUNT), intervalMs); + return () => window.clearInterval(id); + }, [playing, intervalMs]); + + return { + beat: beatFor(tick), + playing, + toggle: () => setOverride(!playing), + scrub: (next: number) => { + setOverride(false); + setTick(next); + }, + reset: () => setTick(0), + }; +} + +function ReplayControls({ + playing, + tick, + onToggle, + onScrub, + onReset, +}: { + playing: boolean; + tick: number; + onToggle: () => void; + onScrub: (next: number) => void; + onReset: () => void; +}) { + return ( +
+ + +
+ {Array.from({ length: BEAT_COUNT }, (_, index) => { + const label = `${BEAT_SECONDS[index]?.toFixed(1)} seconds in`; + return ( + + ); + })} +
+

t + {BEAT_SECONDS[tick]?.toFixed(1)}s

+
+ ); +} + +/* ══════════════════════ shared parts of the redesign ══════════════════════ */ + +/** + * The whole animation, in one element. + * + * A 5px dot at the start of the status line, breathing on a 2.4s cycle. It is + * the only moving thing in directions A and B, it costs one composited + * property, and at rest — reduced motion, forced colors, a screenshot — it is + * still a correct, legible bullet rather than a blank space where a spinner + * used to be. That last property is what disqualifies a spinner here: a + * stopped `Loader2` is a fragment of a circle. + */ +function BreathDot({ tone = "accent" }: { tone?: "accent" | "muted" | "success" }) { + return ( +