From 11758c6214cbb0d12f513bf40aaa75f775b59caf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 16:48:26 +0000 Subject: [PATCH] Add three ChatGPT-style mockups for the Answer result. The live Answer page still reads as a verified document card. These design-scratch frames keep the same clozapine-monitoring question and sources, and try three citation treatments: inline pills, a claim-to-source rail, and inline source cards that never leave the thread. Co-authored-by: BigSimmo --- docs/site-map.md | 1 + src/app/mockups/answer-chat-redesign/page.tsx | 12 + src/app/mockups/mockups-layout-client.tsx | 6 + .../answer-chat-redesign-mockups.tsx | 854 ++++++++++++++++++ .../answer-chat-redesign-mockups.dom.test.tsx | 38 + tests/answer-chat-redesign-mockups.test.ts | 52 ++ 6 files changed, 963 insertions(+) create mode 100644 src/app/mockups/answer-chat-redesign/page.tsx create mode 100644 src/components/answer-chat-redesign-mockups.tsx create mode 100644 tests/answer-chat-redesign-mockups.dom.test.tsx create mode 100644 tests/answer-chat-redesign-mockups.test.ts diff --git a/docs/site-map.md b/docs/site-map.md index 33f300dc51..274b4b59ce 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1147,6 +1147,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir ## Mockup/prototype routes - `/mockups/accessible-table-browser-fixture` - Route discovered from app directory Source: `src/app/mockups/accessible-table-browser-fixture/page.tsx`. +- `/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/calculators-bedside-sheet` - Route discovered from app directory Source: `src/app/mockups/calculators-bedside-sheet/page.tsx`. diff --git a/src/app/mockups/answer-chat-redesign/page.tsx b/src/app/mockups/answer-chat-redesign/page.tsx new file mode 100644 index 0000000000..95539b94e1 --- /dev/null +++ b/src/app/mockups/answer-chat-redesign/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { AnswerChatRedesignMockups } from "@/components/answer-chat-redesign-mockups"; + +export const metadata: Metadata = { + title: "Answer chat redesign mockups - Clinical KB", + description: "Three ChatGPT-style directions for the Answer result, with distinct citation treatments.", +}; + +export default function AnswerChatRedesignMockupRoute() { + return ; +} diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index baeddb5b94..c3779779d0 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -56,6 +56,10 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { const isWarningConsolidationMockup = pathname === "/mockups/warning-consolidation"; const isWarningLineMockup = pathname === "/mockups/warning-line"; const isAnswerHomeProposalMockup = pathname === "/mockups/answer-home-proposal"; + // Draws its own Answer chrome, thread, citations and composer inside every + // device frame. Shared mockup chrome would read as a second, real search bar + // over a study whose subject is the chat itself. + const isAnswerChatRedesignMockup = pathname === "/mockups/answer-chat-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"; @@ -127,6 +131,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isWarningConsolidationMockup && !isWarningLineMockup && !isAnswerHomeProposalMockup && + !isAnswerChatRedesignMockup && !isPrivacyPageDirectionsMockup && !isPrivacyLiveSignalPerfectedMockup && !isSearchLensMenuMockup && @@ -152,6 +157,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isWarningConsolidationMockup && !isWarningLineMockup && !isAnswerHomeProposalMockup && + !isAnswerChatRedesignMockup && !isPrivacyPageDirectionsMockup && !isPrivacyLiveSignalPerfectedMockup && !isSearchLensMenuMockup && diff --git a/src/components/answer-chat-redesign-mockups.tsx b/src/components/answer-chat-redesign-mockups.tsx new file mode 100644 index 0000000000..3547368462 --- /dev/null +++ b/src/components/answer-chat-redesign-mockups.tsx @@ -0,0 +1,854 @@ +"use client"; + +// Design-scratch: the Answer RESULT after a question has returned — ChatGPT-style +// thread, three citation treatments. Does not change production retrieval, ranking, +// or the live answer card. Shared mockup chrome is suppressed for this route +// (`src/app/mockups/mockups-layout-client.tsx`) because every frame draws its own +// Answer top bar and docked composer. + +import Link from "next/link"; +import { useMemo, useState, type ReactNode } from "react"; +import { + Check, + ChevronDown, + Copy, + ExternalLink, + FileText, + Menu, + MessageSquareText, + Plus, + RefreshCw, + Send, + ShieldAlert, + X, +} from "lucide-react"; + +import { cn } from "@/components/ui-primitives"; + +const PRIVACY_LINE = "Do not enter patient-identifiable information."; +const PRIVACY_LINK = "Privacy and data processing"; + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +type Device = "phone" | "desktop"; +type DirectionId = "simple" | "sophisticated" | "perfected"; +type SourceStatus = "current" | "review-due"; + +type Source = { + id: number; + title: string; + locator: string; + status: SourceStatus; + quote: string; + reviewed: string; +}; + +const QUESTION = "When can weekly clozapine FBCs move to monthly, and what neutrophil threshold applies?"; + +const FOLLOW_UP = "What if the ANC is 1.4?"; + +const SOURCES: Source[] = [ + { + id: 1, + title: "WA Clozapine Protocol 2024", + locator: "p. 14 · Monitoring schedule", + status: "current", + quote: + "After 18 weeks of weekly full blood counts with ANC remaining at or above 1.5 × 10⁹/L, monitoring may move to monthly.", + reviewed: "Reviewed Mar 2026", + }, + { + id: 2, + title: "Haematology monitoring table", + locator: "Table 2 · Neutrophil thresholds", + status: "current", + quote: + "Green zone: ANC ≥ 1.5 × 10⁹/L — continue and, after 18 weeks, monthly FBC is permitted. Amber: 1.0–1.49 — increase frequency; do not extend the interval.", + reviewed: "Reviewed Jan 2026", + }, + { + id: 3, + title: "Clozapine shared-care checklist", + locator: "p. 3 · Dispensing gate", + status: "review-due", + quote: + "The GP or nominated prescriber must sight the latest valid blood result before authorising supply. Do not dispense on an overdue FBC.", + reviewed: "Review due", + }, +]; + +const currentDefects = [ + "The answer is a raised verified card (query echo, verification notice, support strength, footer) rather than an assistant message.", + "UserQuestionBubble exists, but the result path often uses AnswerCardQueryEcho — the page does not read as a conversation.", + "Citations are a count capsule plus sheets. Claims in the prose have no numbered marks, so a reader cannot jump from a sentence to its source.", + "Verification, support, and degraded banners sit above the prose and compete with it, instead of living on the claim they qualify.", + "Evidence opens in drawers, so checking a reference leaves the reading position.", + "Prior turns can persist, but the current result still wears card chrome — two models on one page.", + "The composer is the universal search pill, not a docked chat input, so the page still feels like search-with-a-result.", +]; + +const directions: Array<{ + id: DirectionId; + kicker: string; + title: string; + verdict: string; + description: string; + changes: string[]; +}> = [ + { + id: "simple", + kicker: "Simple · ChatGPT-faithful", + title: "A message, not a card", + verdict: "Smallest change that reads as chat", + description: + "Right-aligned question, left-aligned prose, numbered pills in the sentence, quiet source chips under the message. Trust is a whisper. No support card, no verification banner, no evidence drawer.", + changes: [ + "Drop AnswerCard chrome on the current turn — the assistant message is the surface.", + "Inline numbered pills are the only citation control; chips below repeat them for scanability.", + "A tap opens a quote preview in the thread, not a sheet over the page.", + "Composer docks as a chat pill. Privacy line stays verbatim under it.", + ], + }, + { + id: "sophisticated", + kicker: "Sophisticated · Claim ↔ source", + title: "The sentence lights the source", + verdict: "Best for verification while reading", + description: + "Superscript citations highlight the supporting sentence. Desktop keeps a slim sources rail that lights the match; phone uses a sheet inside the frame. Hover and tap work both ways: rail to claim, claim to rail.", + changes: [ + "Each cited sentence is a highlight target, not only the mark beside it.", + "Desktop rail shows currency (current / review due) beside the source — green and amber stay clinical, never decorative.", + "Phone sheet is framed, not a page-level modal, so the thread stays in view.", + "Follow-up chips sit above the composer, still inside the chat.", + ], + }, + { + id: "perfected", + kicker: "Perfected · Never leave the chat", + title: "The source comes to the paragraph", + verdict: "The product direction if one ships", + description: + "A follow-up turn is already on screen. Tapping a footnote expands an inline source card under that paragraph (desktop) or a compact sheet (phone). A Sources strip at the end of the message mirrors the marks. Copy and regenerate stay ghosted under the assistant turn.", + changes: [ + "No modal. The quote is an inline card in the reading flow, or a compact in-frame sheet on a phone.", + "End-of-message Sources strip expands the same records; hovering a mark also lights the strip.", + "A second turn shows the thread compounding, which today’s card replaces.", + "ChatGPT pill composer: plus, field, send. Privacy line unchanged.", + ], + }, +]; + +function sourceById(id: number): Source { + const found = SOURCES.find((source) => source.id === id); + if (!found) { + throw new Error(`Unknown mock source ${id}`); + } + return found; +} + +function StatusChip({ status }: { status: SourceStatus }) { + const current = status === "current"; + return ( + + {current ? "Current" : "Review due"} + + ); +} + +function CiteMark({ + n, + variant, + active, + onActivate, + onHover, +}: { + n: number; + variant: "pill" | "super" | "note"; + active: boolean; + onActivate: (id: number) => void; + onHover?: (id: number | null) => void; +}) { + const source = sourceById(n); + return ( + + ); +} + +function Claim({ active, children }: { active: boolean; children: ReactNode }) { + return ( + + {children} + + ); +} + +function FrameTopBar({ device }: { device: Device }) { + return ( +
+ + KB + + + + + Answer + + +
+ ); +} + +function FrameComposer({ device, placeholder }: { device: Device; placeholder: string }) { + return ( +
+
+ + + {placeholder} + + +
+

+

+
+ ); +} + +function UserBubble({ text, device }: { text: string; device: Device }) { + return ( +
+

+ {text} +

+
+ ); +} + +function QuoteCard({ source, onClose }: { source: Source; onClose?: () => void }) { + return ( + + ); +} + +function SourceChip({ + source, + active, + onActivate, + onHover, +}: { + source: Source; + active: boolean; + onActivate: (id: number) => void; + onHover?: (id: number | null) => void; +}) { + return ( + + ); +} + +function RailItem({ + source, + active, + onActivate, + onHover, +}: { + source: Source; + active: boolean; + onActivate: (id: number) => void; + onHover?: (id: number | null) => void; +}) { + return ( + + ); +} + +function InFrameSheet({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { + return ( +
+
+

{title}

+ +
+ {children} +
+ ); +} + +function SimpleAnswer({ activeId, onActivate }: { activeId: number | null; onActivate: (id: number | null) => void }) { + const toggle = (id: number) => onActivate(activeId === id ? null : id); + return ( +
+

+ After 18 weeks of weekly full blood counts, monitoring may move to monthly{" "} + if the absolute neutrophil count + has stayed at or above 1.5 × 10⁹/L + . An ANC of 1.0–1.49 stays in the + amber band — increase frequency; do not extend the interval{" "} + . Before supply, the shared-care + checklist still requires the latest valid result to be sighted{" "} + . +

+
+ {SOURCES.map((source) => ( + + ))} +
+ {activeId ? onActivate(null)} /> : null} +

+ 3 sources · 2 current · 1 review due. Verify doses and thresholds against the cited page. +

+
+ ); +} + +function SophisticatedAnswer({ + activeId, + hoverId, + onActivate, + onHover, +}: { + activeId: number | null; + hoverId: number | null; + onActivate: (id: number | null) => void; + onHover: (id: number | null) => void; +}) { + const lit = hoverId ?? activeId; + const toggle = (id: number) => onActivate(activeId === id ? null : id); + return ( +
+

+ + Weekly FBC continues for the first 18 weeks of treatment, after which monthly monitoring is permitted when + counts have been stable + + {" "} + + The neutrophil threshold for that extension is ANC ≥ 1.5 × 10⁹/L. Below that, in the 1.0–1.49 band, the + interval must not be lengthened + + {" "} + + Shared-care still gates dispensing on a sighted, in-date result — this checklist is marked review due + + +

+
+ ); +} + +function PerfectedAnswer({ + device, + activeId, + hoverId, + stripOpen, + onActivate, + onHover, + onToggleStrip, +}: { + device: Device; + activeId: number | null; + hoverId: number | null; + stripOpen: boolean; + onActivate: (id: number | null) => void; + onHover: (id: number | null) => void; + onToggleStrip: () => void; +}) { + const lit = hoverId ?? activeId; + const toggle = (id: number) => onActivate(activeId === id ? null : id); + const showInline = device === "desktop" && activeId !== null; + + return ( +
+

+ After 18 weeks of weekly FBCs, monthly monitoring is allowed when ANC has remained ≥ 1.5 × 10⁹/L + + . +

+ {showInline && (activeId === 1 || activeId === 2) ? ( + onActivate(null)} /> + ) : null} +

+ An ANC of 1.4 sits in the amber band: increase monitoring frequency and do not extend to monthly + . Supply still + requires a sighted in-date result + . +

+ {showInline && activeId === 3 ? onActivate(null)} /> : null} + +
+ + {stripOpen ? ( +
    + {SOURCES.map((source) => ( +
  • + +
  • + ))} +
+ ) : ( +
+ {SOURCES.map((source) => ( + + ))} +
+ )} +
+
+ ); +} + +function GhostActions() { + const [copied, setCopied] = useState(false); + return ( +
+ + +
+ ); +} + +function FollowUpChips() { + return ( +
+ {["ANC 1.4 next step", "What to tell the GP", "Missed FBC"].map((label) => ( + + {label} + + ))} +
+ ); +} + +function DirectionFrame({ direction, device }: { direction: DirectionId; device: Device }) { + const [activeId, setActiveId] = useState(null); + const [hoverId, setHoverId] = useState(null); + const [stripOpen, setStripOpen] = useState(direction === "perfected"); + const lit = hoverId ?? activeId; + const isPhone = device === "phone"; + const showSheet = isPhone && activeId !== null && (direction === "sophisticated" || direction === "perfected"); + const threadPad = device === "phone" ? "px-3 py-3" : "px-5 py-4"; + const column = direction === "sophisticated" && device === "desktop"; + const toggleActive = (id: number) => setActiveId((current) => (current === id ? null : id)); + + return ( +
+
+ +
+
+
+
+ + {direction === "simple" ? : null} + {direction === "sophisticated" ? ( + + ) : null} + {direction === "perfected" ? ( + <> + setStripOpen((open) => !open)} + /> + + +

+ 1.4 × 10⁹/L is amber, not green. Keep or increase FBC frequency; monthly is not permitted until + ANC is back at or above 1.5 + + . Do not authorise supply on an overdue result + + . +

+ + ) : null} +
+
+ {column ? ( + + ) : null} +
+ {direction === "sophisticated" ? ( +
+ +
+ ) : null} + + {showSheet && activeId ? ( + setActiveId(null)}> + + + ) : null} +
+
+
+ {device === "phone" ? "Phone 390" : "Desktop 1280"} + + {device === "phone" + ? "Same thread, phone composer, in-frame sheet when a source is open." + : "Same thread at reading width. Rail only on direction 02."} + +
+
+ ); +} + +function DirectionShowcase({ direction, number }: { direction: (typeof directions)[number]; number: number }) { + return ( +
+
+
+

+ Direction {String(number).padStart(2, "0")} · {direction.verdict} +

+

+ {direction.title} +

+

{direction.kicker}

+
+

+ {direction.description} +

+
+
    + {direction.changes.map((change) => ( +
  • + {change} +
  • + ))} +
+
+ + +
+
+ ); +} + +export function AnswerChatRedesignMockups() { + const composerCount = useMemo(() => directions.length * 2, []); + + return ( +
+
+
+

+ Answer · Result state +

+

+ Three chat directions for the Answer page +

+

+ Today’s Answer page is the centrepiece and still a verified document: AnswerResultSurface{" "} + stages an AnswerCard with query echo, verification notice, support strength, prose, a sources + capsule, and evidence sheets. Pieces of chat already exist (UserQuestionBubble, prior-turn + collapse) but the current result keeps card chrome, so the page does not read as a conversation. These three + frames keep the same clozapine-monitoring question and the same three sources. They change only how the + thread and the references are presented. Retrieval is untouched. {composerCount} composers, one per frame. +

+
+ +
+

+ What the live page is doing +

+
    + {currentDefects.map((defect, index) => ( +
  1. + {index + 1} + {defect} +
  2. + ))} +
+
+ +
+ {directions.map((direction, index) => ( + + ))} +
+
+
+ ); +} diff --git a/tests/answer-chat-redesign-mockups.dom.test.tsx b/tests/answer-chat-redesign-mockups.dom.test.tsx new file mode 100644 index 0000000000..55ba46398a --- /dev/null +++ b/tests/answer-chat-redesign-mockups.dom.test.tsx @@ -0,0 +1,38 @@ +import { render, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { AnswerChatRedesignMockups } from "@/components/answer-chat-redesign-mockups"; + +describe("answer chat redesign mockups — citation interaction", () => { + it("opens a quote preview from an inline citation in the simple phone frame", async () => { + const user = userEvent.setup(); + render(); + + const simplePhone = document.querySelector('[data-direction="simple"][data-device="phone"]'); + expect(simplePhone).toBeInstanceOf(HTMLElement); + + const frame = within(simplePhone as HTMLElement); + await user.click(frame.getAllByRole("button", { name: /Source 1,/ })[0]); + + expect(frame.getByRole("heading", { level: 3, name: "WA Clozapine Protocol 2024" })).toBeInTheDocument(); + expect(frame.getByText(/ANC remaining at or above 1\.5/)).toBeInTheDocument(); + }); + + it("lights a claim from the sophisticated desktop rail", async () => { + const user = userEvent.setup(); + render(); + + const sophisticatedDesktop = document.querySelector('[data-direction="sophisticated"][data-device="desktop"]'); + expect(sophisticatedDesktop).toBeInstanceOf(HTMLElement); + + const frame = within(sophisticatedDesktop as HTMLElement); + await user.click(frame.getByRole("button", { name: /Source 1 in rail, WA Clozapine Protocol 2024/ })); + + expect(frame.getByRole("button", { name: /Source 1 in rail, WA Clozapine Protocol 2024/ })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(frame.getByText(/After 18 weeks of weekly full blood counts with ANC remaining/)).toBeInTheDocument(); + }); +}); diff --git a/tests/answer-chat-redesign-mockups.test.ts b/tests/answer-chat-redesign-mockups.test.ts new file mode 100644 index 0000000000..1d1a9d481e --- /dev/null +++ b/tests/answer-chat-redesign-mockups.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { sourceSegment } from "./helpers/source-contract"; + +const source = readFileSync(new URL("../src/components/answer-chat-redesign-mockups.tsx", import.meta.url), "utf8"); +const pageSource = readFileSync(new URL("../src/app/mockups/answer-chat-redesign/page.tsx", import.meta.url), "utf8"); +const layoutSource = readFileSync(new URL("../src/app/mockups/mockups-layout-client.tsx", import.meta.url), "utf8"); + +describe("answer chat redesign mockups", () => { + it("renders frames at the widths named by their captions", () => { + expect(source).toContain('device === "phone" ? "w-[390px]" : "w-[1280px]"'); + expect(source).toContain('device === "phone" ? "Phone 390" : "Desktop 1280"'); + expect(source).not.toContain('device === "phone" ? "w-[340px]"'); + }); + + it("shows every direction at both widths", () => { + const showcase = sourceSegment(source, "function DirectionShowcase", "export function AnswerChatRedesignMockups", { + label: "direction showcase frames", + }); + expect(showcase).toContain(''); + expect(showcase).toContain(''); + }); + + it("keeps nested source titles at h3 under direction h2s", () => { + expect(source).toContain("

{ + expect(source).toContain('const PRIVACY_LINE = "Do not enter patient-identifiable information.";'); + expect(source).toContain('const PRIVACY_LINK = "Privacy and data processing";'); + expect(source).toContain('href="/privacy"'); + }); + + it("draws one composer per frame and suppresses shared mockup chrome", () => { + expect(source.match(/data-composer="1"/g)).toEqual(['data-composer="1"']); + expect(source).toContain("composerCount"); + expect(layoutSource).toContain('pathname === "/mockups/answer-chat-redesign"'); + expect(layoutSource).toContain("!isAnswerChatRedesignMockup"); + expect(pageSource).toContain("AnswerChatRedesignMockups"); + }); +});