diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 28f6bfecc..01e24acd9 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -657,4 +657,6 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-05 | claude/privacy-notch-safe-area | a3967f8f0ee05b9a3ab922cd4a9feeebdf7efbbc | PR #1621 babysit standalone-shell review fixes | supersede: prior row HEAD ef92d628 was unresolvable; tip after main sync is this SHA; product fixes unchanged | ledger:append correction; merge-tree clean vs main | | 2026-08-05 | claude/privacy-notch-safe-area | fe352adb18d3260f0646f64ef992376e1449b0de | Run PR sweep | merged main (stale DIRTY); superseded unresolvable ledger HEAD per Devin; no product threads open | merge-tree clean | | 2026-08-05 | cursor/context7-refresh-22b5 | 9479401744191292002f689a07a2cb5308cedc97 | Run PR sweep | no unresolved threads; merged origin/main | merge-tree clean | +| 2026-08-05 | cursor/privacy-page-mockups-2ff6 | 7c82f92a447986178ba12d6f8b7a447bb63e91ef | Run PR sweep | resolved privacy/page conflict with #1621 standalone shell; fixed Devin double scroll-pad + scrollIntoView yank | merge resolved; prettier | | 2026-08-05 | cursor/ledger-fastest-wins-capture-1479 | b03b51d7b22671f2734115b5fef469bd2e932ae3 | prlanded PR #1624 | MERGED squash b03b51d7; 118/118 open↔queue, A1 #226 at order 5, #249–#251; orphaned #250 acuity clarify → fix-forward | check:outstanding-issues PASS on merge head; Bugbot clean after clarify | +| 2026-08-05 | cursor/privacy-page-mockups-2ff6 | 32c4406cb728176933b669779d4f16fd245534bb | Run PR sweep | supersede: desktop index sticky top tracks measured StickySignalChrome height; prior row checks lacked decisive prettier output | prettier --check mockup+ledger: All matched files use Prettier code style!; ResizeObserver sticky chrome height for desktop index | diff --git a/docs/site-map.md b/docs/site-map.md index 65089687a..6da476796 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1018,6 +1018,8 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/phone-inpage-navigation` - Route discovered from app directory Source: `src/app/mockups/phone-inpage-navigation/page.tsx`. - `/mockups/phone-mode-sheet-yes` - Route discovered from app directory Source: `src/app/mockups/phone-mode-sheet-yes/page.tsx`. - `/mockups/pinned-plus-menu` - Route discovered from app directory Source: `src/app/mockups/pinned-plus-menu/page.tsx`. +- `/mockups/privacy-live-signal-perfected` - Route discovered from app directory Source: `src/app/mockups/privacy-live-signal-perfected/page.tsx`. +- `/mockups/privacy-page-directions` - Route discovered from app directory Source: `src/app/mockups/privacy-page-directions/page.tsx`. - `/mockups/recent-searches-bottom` - Route discovered from app directory Source: `src/app/mockups/recent-searches-bottom/page.tsx`. - `/mockups/search-band-directions` - Route discovered from app directory Source: `src/app/mockups/search-band-directions/page.tsx`. - `/mockups/search-heading` - Route discovered from app directory Source: `src/app/mockups/search-heading/page.tsx`. diff --git a/mockups/README.md b/mockups/README.md index dddfc70f0..690fd4e85 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -37,6 +37,12 @@ The document-search mockups use generated non-patient bitmap assets in `public/m Some document-search mockups include live handoff routes (for example `document-search/source-overlays`) that resolve into the real document viewer with a selected page and chunk when indexed data is available locally. +## Privacy page redesign study (2026-08) + +- Selected perfected direction: [`/mockups/privacy-live-signal-perfected`](../src/app/mockups/privacy-live-signal-perfected/page.tsx) +- Full three-direction study: [`/mockups/privacy-page-directions`](../src/app/mockups/privacy-page-directions/page.tsx) +- Static comps: [`public/mockups/privacy-page-redesign-2026-08/`](../public/mockups/privacy-page-redesign-2026-08/README.md) + ## Phone Choose mode sheet YES comps Runnable study at [`/mockups/phone-mode-sheet-yes`](../src/app/mockups/phone-mode-sheet-yes/page.tsx): design review of the shipping phone mode sheet plus **YES 01 perfected** (sectioned clinical list — shipping recommendation) and YES 02 (icon deck alternate). Shared mockup chrome is suppressed so only the in-frame sheet is judged. diff --git a/public/mockups/privacy-page-redesign-2026-08/01-statement-theatre-desktop-phone.png b/public/mockups/privacy-page-redesign-2026-08/01-statement-theatre-desktop-phone.png new file mode 100644 index 000000000..fd6b61e64 Binary files /dev/null and b/public/mockups/privacy-page-redesign-2026-08/01-statement-theatre-desktop-phone.png differ diff --git a/public/mockups/privacy-page-redesign-2026-08/02-data-passport-desktop-phone.png b/public/mockups/privacy-page-redesign-2026-08/02-data-passport-desktop-phone.png new file mode 100644 index 000000000..c1150c57b Binary files /dev/null and b/public/mockups/privacy-page-redesign-2026-08/02-data-passport-desktop-phone.png differ diff --git a/public/mockups/privacy-page-redesign-2026-08/03-live-signal-desktop-phone.png b/public/mockups/privacy-page-redesign-2026-08/03-live-signal-desktop-phone.png new file mode 100644 index 000000000..1439e42e1 Binary files /dev/null and b/public/mockups/privacy-page-redesign-2026-08/03-live-signal-desktop-phone.png differ diff --git a/public/mockups/privacy-page-redesign-2026-08/03-live-signal-perfected-desktop-phone.png b/public/mockups/privacy-page-redesign-2026-08/03-live-signal-perfected-desktop-phone.png new file mode 100644 index 000000000..d2778dab3 Binary files /dev/null and b/public/mockups/privacy-page-redesign-2026-08/03-live-signal-perfected-desktop-phone.png differ diff --git a/public/mockups/privacy-page-redesign-2026-08/README.md b/public/mockups/privacy-page-redesign-2026-08/README.md new file mode 100644 index 000000000..1d2ec9de1 --- /dev/null +++ b/public/mockups/privacy-page-redesign-2026-08/README.md @@ -0,0 +1,27 @@ +# Privacy page redesign comps (2026-08) + +## Selected: Live Signal perfected + +| File | Notes | +| -------------------------------------------- | ------------------------------------------------------------------------- | +| `03-live-signal-perfected-desktop-phone.png` | **Selected** — sticky fused obligation, gists, region ticker, index/chips | + +Runnable: + +- Perfected focus: `/mockups/privacy-live-signal-perfected` +- Full study (01–03): `/mockups/privacy-page-directions` + +## Earlier directions (context) + +| File | Direction | +| ---------------------------------------- | --------------------------- | +| `01-statement-theatre-desktop-phone.png` | Statement theatre | +| `02-data-passport-desktop-phone.png` | Data passport | +| `03-live-signal-desktop-phone.png` | Live signal (pre-perfected) | + +## Contracts + +- Back control below `safe-area-inset-top`, ≥48×48 +- Amber = Important obligation only +- Clinical Sky for active index / section marks / jump chips +- Governance section headings + bodies match production diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 94df07b14..4a2c9d166 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -36,6 +36,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 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"; + const isPrivacyLiveSignalPerfectedMockup = pathname === "/mockups/privacy-live-signal-perfected"; // These studies draw complete app shells and their own search composers, so // shared chrome would make the interaction studies ambiguous. const isSearchLensMenuMockup = pathname === "/mockups/search-lens-menu"; @@ -84,6 +88,8 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isWarningConsolidationMockup && !isWarningLineMockup && !isAnswerHomeProposalMockup && + !isPrivacyPageDirectionsMockup && + !isPrivacyLiveSignalPerfectedMockup && !isSearchLensMenuMockup && !isPinnedPlusMenuMockup && !isPhoneModeSheetYesMockup @@ -98,6 +104,8 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isWarningConsolidationMockup && !isWarningLineMockup && !isAnswerHomeProposalMockup && + !isPrivacyPageDirectionsMockup && + !isPrivacyLiveSignalPerfectedMockup && !isSearchLensMenuMockup && !isPinnedPlusMenuMockup && !isPhoneModeSheetYesMockup diff --git a/src/app/mockups/privacy-live-signal-perfected/page.tsx b/src/app/mockups/privacy-live-signal-perfected/page.tsx new file mode 100644 index 000000000..ba762aa00 --- /dev/null +++ b/src/app/mockups/privacy-live-signal-perfected/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { PrivacyLiveSignalPerfectedMockups } from "@/components/privacy-live-signal-perfected-mockups"; + +export const metadata: Metadata = { + title: "Privacy Live Signal perfected - Clinical KB", + description: + "Perfected Live Signal direction for /privacy — sticky obligation chrome, gists, region ticker, desktop+phone.", +}; + +export default function PrivacyLiveSignalPerfectedMockupPage() { + return ; +} diff --git a/src/app/mockups/privacy-page-directions/page.tsx b/src/app/mockups/privacy-page-directions/page.tsx new file mode 100644 index 000000000..06a4c42cb --- /dev/null +++ b/src/app/mockups/privacy-page-directions/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { PrivacyPageDirectionsMockups } from "@/components/privacy-page-directions-mockups"; + +export const metadata: Metadata = { + title: "Privacy page directions - Clinical KB", + description: + "Three elevated redesign directions for /privacy with desktop and phone frames, grounded in Clinical White / Sky Graphite.", +}; + +export default function PrivacyPageDirectionsMockupPage() { + return ; +} diff --git a/src/components/privacy-live-signal-perfected-mockups.tsx b/src/components/privacy-live-signal-perfected-mockups.tsx new file mode 100644 index 000000000..425da5c64 --- /dev/null +++ b/src/components/privacy-live-signal-perfected-mockups.tsx @@ -0,0 +1,729 @@ +"use client"; + +import { ArrowLeft, ChevronDown, ShieldAlert } from "lucide-react"; +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; + +import { cn, eyebrowText } from "@/components/ui-primitives"; + +/** Nearest overflow-y scroller (the device frame), not the document. */ +function findScrollContainer(start: HTMLElement | null): HTMLElement | null { + let node: HTMLElement | null = start?.parentElement ?? null; + while (node && node !== document.documentElement) { + const { overflowY } = getComputedStyle(node); + if ( + (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") && + node.scrollHeight > node.clientHeight + 1 + ) { + return node; + } + node = node.parentElement; + } + return null; +} + +/** + * Scroll a target into a local container only — `scrollIntoView` would yank every + * scrollable ancestor, including the mockup review page (see phone-mode-sheet-yes). + * Prefer the live sticky chrome height (Important expanded/collapsed) over a fixed + * scroll-margin, falling back to the target's computed `scroll-margin-top`. + */ +function scrollContainerToTarget(container: HTMLElement, target: HTMLElement, stickyChrome: HTMLElement | null) { + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const chromeHeight = + stickyChrome?.getBoundingClientRect().height ?? (Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0); + container.scrollTop += targetRect.top - containerRect.top - chromeHeight; +} + +/** + * Live Signal — style polish pass (direction 03). + * + * Craft goals vs prior perfected frame: + * - Important is full-bleed under sticky chrome (true fused signal, not an inset card) + * - Phone jump chips live inside the sticky stack so navigation never scrolls away + * - Desktop defaults to one open section (signal, not a wall of text) + * - Collapsed rows keep scannable gists; expanded rows get a soft accent wash + spine + * - Region strip is a quiet instrument with a single eyebrow, not three loud chips + * + * Governance headings + bodies match production (`tests/privacy-ui.test.ts`). + */ + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +const DRAFT = + "This is draft product information based on the repository's configured behaviour. It is not legal advice, a final privacy policy, or an assertion of governance approval."; + +const IMPORTANT_SHORT = + "Do not enter identifiable patient details. Processing may include Singapore and the OpenAI API."; + +const IMPORTANT_FULL = + "Do not enter identifiable patient details such as names, dates of birth, or record numbers. Requests are processed by the application service in Singapore. With external provider mode configured, question text may be sent to the OpenAI API for retrieval embedding even when the final response is source-only; model-backed answer synthesis also sends the question and selected evidence."; + +type Section = { + heading: string; + short: string; + gist: string; + body: string; +}; + +const SECTIONS: Section[] = [ + { + heading: "What this tool is", + short: "Tool", + gist: "Clinical reference KB — not a patient-record system", + body: "Clinical KB is a knowledge base over clinical reference material. It is not a patient-record system and its provider-backed features do not ask for patient identifiers. The Safety Plan Generator accepts sensitive working content and support contacts but deliberately omits a patient-identifier field.", + }, + { + heading: "What is collected", + short: "Collected", + gist: "Questions, docs, telemetry — safety-plan work stays in-tab", + body: "Questions, generated answers, account identifiers, uploaded documents, retrieved excerpts, document metadata, and operational or retrieval telemetry may be processed. Free text and uploaded material can contain sensitive information if you enter it. Safety-plan working content is different: it remains in the current browser tab and is not sent to the application service or stored by Clinical KB.", + }, + { + heading: "How questions are handled", + short: "Questions", + gist: "Hashed query logs · answers omitted · short cache TTL", + body: "Raw question text is not written to query logs by default; logs use a keyed one-way hash. Generated answer text is also omitted from durable query logs by default. A short-lived response cache can contain the answer while its read TTL is valid.", + }, + { + heading: "Where data is stored and processed", + short: "Regions", + gist: "Sydney storage · Singapore app + worker", + body: "Documents, extracted evidence, metadata, account records, and owner-scoped operational records are stored in the configured Supabase project in Sydney. The production application and ingestion worker currently run on Railway in Singapore, so questions, retrieved evidence, answers, and ingestion material are processed in or transit through Singapore. File buckets are private and links are time-limited. The operator must verify deployed regions and contractual controls.", + }, + { + heading: "External provider processing", + short: "Providers", + gist: "OpenAI embedding / synthesis may leave Australia", + body: "When external provider mode is configured, question text may be sent to the OpenAI API to create a retrieval embedding, including when the final response is source-only. When model-backed answer synthesis is used, the question and selected source excerpts are also sent. This processing may occur outside Australia. The operator must verify provider regions, retention terms, contracts, and cross-border obligations.", + }, + { + heading: "Retention", + short: "Retention", + gist: "30-day queries · 90-day logs · hourly cache purge", + body: "Repository migrations configure 30-day retention for RAG query records, 90-day retention for retrieval logs and query-miss telemetry, and a bounded hourly purge of expired response-cache rows when the database scheduler is available. The operator must verify that those scheduled jobs are active. Uploaded documents remain until removed under the applicable process. Safety-plan working content has no Clinical KB retention: it is discarded when the component is cleared or the tab is closed. Clipboard, print, and PDF copies are outside the app and must follow the organisation's approved record-handling process.", + }, + { + heading: "Your responsibilities", + short: "You", + gist: "No identifiers · verify sources · report issues", + body: "Do not enter patient-identifiable information. In the Safety Plan Generator, add any patient identifier only after export through your organisation's approved clinical-record process. Upload only material you are authorised to use, keep access credentials private, review original linked sources before relying on clinical output, and report suspected privacy or access issues through your organisation's approved process.", + }, +]; + +const atmosphere = + "bg-[radial-gradient(ellipse_at_8%_-8%,color-mix(in_srgb,var(--warning-bg)_78%,transparent),transparent_34%),radial-gradient(ellipse_at_92%_4%,color-mix(in_srgb,var(--clinical-accent-soft)_48%,transparent),transparent_42%),linear-gradient(180deg,color-mix(in_srgb,var(--surface)_55%,transparent),transparent_18rem),var(--background)]"; + +function BrandMark({ size = "sm" }: { size?: "sm" | "md" }) { + return ( + + ); +} + +function BackControl() { + return ( + + ); +} + +function StatusBar() { + return ( + + ); +} + +function LiveDot() { + return ( + + + + + ); +} + +function RegionTicker({ compact = false }: { compact?: boolean }) { + const cells = [ + { place: "Sydney", role: "Supabase storage", tone: "accent" as const }, + { place: "Singapore", role: "App + worker", tone: "neutral" as const }, + { place: "External", role: "OpenAI API", tone: "warn" as const }, + ]; + + return ( +
+
+

+ Processing map +

+ {!compact ? ( +

Operator must verify regions

+ ) : null} +
+ {compact ? ( +
+ {cells.map((cell) => ( + + {cell.place} + · + {cell.role} + + ))} +
+ ) : ( +
+ {cells.map((cell, index) => ( +
0 && "border-l border-[color:var(--border)]")} + > +

+ {cell.place} +

+

+ {cell.role} +

+
+ ))} +
+ )} +
+ ); +} + +function StickySignalChrome({ + phone, + noticeOpen, + onToggleNotice, + noticePanelId, + openId, + onSelectSection, + chromeRef, +}: { + phone: boolean; + noticeOpen: boolean; + onToggleNotice: () => void; + noticePanelId: string; + openId: string; + onSelectSection: (heading: string) => void; + chromeRef: React.RefObject; +}) { + return ( +
+
+
+ + +
+
+

Privacy

+ + + Live signal + +
+

+ Privacy & data handling +

+
+ + Draft + +
+
+ + {/* Full-bleed fused obligation — no side inset, reads as chrome not a card */} +
+
+
+ {/* Keep mounted (hidden) so aria-controls IDREF stays valid when collapsed. */} + +
+ + {phone ? ( +
+
+ {SECTIONS.map((section, index) => { + const active = openId === section.heading; + return ( + + ); + })} +
+
+ ) : null} +
+ ); +} + +function SectionAccordion({ + phone, + openId, + setOpenId, + expandAll, + sectionRefs, + idPrefix, +}: { + phone: boolean; + openId: string; + setOpenId: (id: string) => void; + expandAll: boolean; + sectionRefs: React.RefObject>; + idPrefix: string; +}) { + return ( +
+ {SECTIONS.map((section, index) => { + const expanded = expandAll || openId === section.heading; + // Prefix with per-frame useId() so desktop + phone comps on one page never collide. + const panelId = `${idPrefix}-section-${index}`; + return ( +
{ + sectionRefs.current[section.heading] = node; + }} + className={cn( + // Clear sticky chrome (header + Important [+ phone chips]) when jump-scrolling. + phone ? "scroll-mt-[12.5rem]" : "scroll-mt-[10.5rem]", + index > 0 && "border-t border-[color:var(--border)]", + )} + > + + +
+ ); + })} +
+ ); +} + +export function LiveSignalPerfectedFrame({ phone = false }: { phone?: boolean }) { + const noticeId = useId(); + const sectionIdPrefix = useId(); + const sectionRefs = useRef>({}); + const stickyChromeRef = useRef(null); + const pendingScrollHeading = useRef(null); + const [noticeOpen, setNoticeOpen] = useState(false); + const [openId, setOpenId] = useState(SECTIONS[0]?.heading ?? ""); + const [expandAll, setExpandAll] = useState(false); + // Desktop index sticky offset must track live chrome height (Important open / text zoom). + const [stickyChromeHeightPx, setStickyChromeHeightPx] = useState(null); + + useEffect(() => { + const el = stickyChromeRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const update = () => { + setStickyChromeHeightPx(el.getBoundingClientRect().height); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, [noticeOpen, phone]); + + const scrollSectionIntoView = (heading: string) => { + const target = sectionRefs.current[heading]; + if (!target) return; + const container = findScrollContainer(target); + if (!container) return; + scrollContainerToTarget(container, target, stickyChromeRef.current); + }; + + const selectSection = (heading: string) => { + // Re-tapping the active row leaves openId/expandAll unchanged, so the layout + // effect would not run — scroll immediately in that no-op state path. + if (!expandAll && openId === heading) { + pendingScrollHeading.current = null; + scrollSectionIntoView(heading); + return; + } + pendingScrollHeading.current = heading; + setExpandAll(false); + setOpenId(heading); + }; + + // Scroll after React commits so collapsing the previous panel (or leaving expand-all) + // has already shifted layout; synchronous scrollIntoView measured the stale heights. + useLayoutEffect(() => { + const heading = pendingScrollHeading.current; + if (!heading || openId !== heading || expandAll) return; + pendingScrollHeading.current = null; + scrollSectionIntoView(heading); + }, [openId, expandAll]); + + return ( +
+ setNoticeOpen((value) => !value)} + noticePanelId={noticeId} + openId={openId} + onSelectSection={selectSection} + chromeRef={stickyChromeRef} + /> + +
+ {!phone ? ( + + ) : null} + +
+

{DRAFT}

+ + { + setExpandAll(false); + setOpenId(id); + }} + expandAll={!phone && expandAll} + sectionRefs={sectionRefs} + /> +
+
+
+ ); +} + +function DeviceChrome({ + label, + widthLabel, + phone = false, + children, +}: { + label: string; + widthLabel: string; + phone?: boolean; + children: React.ReactNode; +}) { + return ( +
+
+ + {label} + + {widthLabel} +
+
+ {phone ? : null} +
+ {/* + Section jump clearance lives only on each section's scroll-mt-*. + Do not also set scroll-pt here — scrollIntoView / margin-aware + container scroll would compound the two and land mid-frame. + */} + {children} +
+
+
+ ); +} + +export function PrivacyLiveSignalPerfectedMockups() { + return ( +
+
+
+

+ Privacy · Live Signal · style polish +

+

+ Live Signal — perfected +

+

+ Full-bleed sticky Important, phone jump chips locked into the sticky stack, one-open desktop default with + index gists, and quieter processing map. Same governance wording. Amber only for the obligation. +

+
    + {[ + "Full-bleed sticky signal", + "Chips never scroll away", + "Index shows gists", + "One section open by default", + "Safe-area back control", + ].map((item) => ( +
  • + {item} +
  • + ))} +
+
+
+ +
+ + + + + + +
+
+ ); +} diff --git a/src/components/privacy-page-directions-mockups.tsx b/src/components/privacy-page-directions-mockups.tsx new file mode 100644 index 000000000..c8eb9bf22 --- /dev/null +++ b/src/components/privacy-page-directions-mockups.tsx @@ -0,0 +1,615 @@ +"use client"; + +import { ArrowLeft, ShieldAlert } from "lucide-react"; + +import { LiveSignalPerfectedFrame } from "@/components/privacy-live-signal-perfected-mockups"; +import { cn, eyebrowText } from "@/components/ui-primitives"; + +/** + * Privacy page redesign study — second pass. + * + * First pass (ledger / trust-map / indexed) read as a generic SaaS policy card + * stack. This pass aims for a clinical instrument: brand-led first viewport, + * atmospheric wash (not flat white), amber reserved for the obligation, and + * three layouts that are structurally different rather than three skins on + * the same card list. + * + * Governance headings + body wording match production + * (`tests/privacy-ui.test.ts`). Layout and chrome only. + */ + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +const DRAFT = + "Draft product information from configured repository behaviour — not legal advice, not a final policy, not governance approval."; + +const IMPORTANT = + "Do not enter identifiable patient details such as names, dates of birth, or record numbers. Requests are processed by the application service in Singapore. With external provider mode configured, question text may be sent to the OpenAI API for retrieval embedding even when the final response is source-only; model-backed answer synthesis also sends the question and selected evidence."; + +type Section = { heading: string; body: string; short: string }; + +const SECTIONS: Section[] = [ + { + heading: "What this tool is", + short: "Tool", + body: "Clinical KB is a knowledge base over clinical reference material. It is not a patient-record system and its provider-backed features do not ask for patient identifiers. The Safety Plan Generator accepts sensitive working content and support contacts but deliberately omits a patient-identifier field.", + }, + { + heading: "What is collected", + short: "Collected", + body: "Questions, generated answers, account identifiers, uploaded documents, retrieved excerpts, document metadata, and operational or retrieval telemetry may be processed. Free text and uploaded material can contain sensitive information if you enter it. Safety-plan working content is different: it remains in the current browser tab and is not sent to the application service or stored by Clinical KB.", + }, + { + heading: "How questions are handled", + short: "Questions", + body: "Raw question text is not written to query logs by default; logs use a keyed one-way hash. Generated answer text is also omitted from durable query logs by default. A short-lived response cache can contain the answer while its read TTL is valid.", + }, + { + heading: "Where data is stored and processed", + short: "Regions", + body: "Documents, extracted evidence, metadata, account records, and owner-scoped operational records are stored in the configured Supabase project in Sydney. The production application and ingestion worker currently run on Railway in Singapore, so questions, retrieved evidence, answers, and ingestion material are processed in or transit through Singapore. File buckets are private and links are time-limited. The operator must verify deployed regions and contractual controls.", + }, + { + heading: "External provider processing", + short: "Providers", + body: "When external provider mode is configured, question text may be sent to the OpenAI API to create a retrieval embedding, including when the final response is source-only. When model-backed answer synthesis is used, the question and selected source excerpts are also sent. This processing may occur outside Australia. The operator must verify provider regions, retention terms, contracts, and cross-border obligations.", + }, + { + heading: "Retention", + short: "Retention", + body: "Repository migrations configure 30-day retention for RAG query records, 90-day retention for retrieval logs and query-miss telemetry, and a bounded hourly purge of expired response-cache rows when the database scheduler is available. The operator must verify that those scheduled jobs are active. Uploaded documents remain until removed under the applicable process. Safety-plan working content has no Clinical KB retention: it is discarded when the component is cleared or the tab is closed. Clipboard, print, and PDF copies are outside the app and must follow the organisation's approved record-handling process.", + }, + { + heading: "Your responsibilities", + short: "You", + body: "Do not enter patient-identifiable information. In the Safety Plan Generator, add any patient identifier only after export through your organisation's approved clinical-record process. Upload only material you are authorised to use, keep access credentials private, review original linked sources before relying on clinical output, and report suspected privacy or access issues through your organisation's approved process.", + }, +]; + +type DirectionId = "theatre" | "passport" | "signal"; + +const directions: Array<{ + id: DirectionId; + number: string; + name: string; + verdict: string; + summary: string; + strengths: string[]; + cost: string; + recommended?: boolean; +}> = [ + { + id: "theatre", + number: "01", + name: "Statement theatre", + verdict: "Brand-led first viewport", + summary: + "First screen is one composition: Clinical KB mark, display title, and a full-bleed amber obligation band — then a calm 68ch reading column. Desktop pins a graphite command rail; phone keeps sticky glass chrome below the notch.", + strengths: ["Hero reads as brand, not a form", "Obligation is the loudest plane", "Almost no card chrome"], + cost: "Long scroll on phone with no section jump.", + }, + { + id: "passport", + number: "02", + name: "Data passport", + verdict: "Sealed instrument brief", + summary: + "A graphite command masthead seals the page. Region facts become a precision instrument strip (Sydney · Singapore · OpenAI). Giant watermark section numbers do the hierarchy work — one white brief panel, not a farm of cards.", + strengths: ["Feels issued, not stacked", "Regions above the fold", "Typographic weight"], + cost: "Command masthead is heavier chrome than 01.", + }, + { + id: "signal", + number: "03", + name: "Live signal", + verdict: "Perfected · selected", + summary: + "Sticky fused obligation chrome with Full/Less disclosure, one-line gists on every collapsed row, region ticker as instrument strip, living signal index (desktop) and jump chips (phone). Obligation never scrolls away.", + strengths: ["Always-on Important", "Scan without opening", "Phone density solved"], + cost: "Full bodies still sit one tap behind — first section opens by default.", + recommended: true, + }, +]; + +const atmosphere = + "bg-[radial-gradient(ellipse_at_20%_0%,color-mix(in_srgb,var(--clinical-accent-soft)_55%,transparent),transparent_42%),radial-gradient(ellipse_at_90%_10%,color-mix(in_srgb,var(--surface-inset)_80%,transparent),transparent_38%),var(--background)]"; + +function BrandMark({ size = "md" }: { size?: "sm" | "md" | "lg" }) { + return ( + + ); +} + +function BackControl({ tone = "light" }: { tone?: "light" | "dark" }) { + return ( + + ); +} + +function StatusBar({ invert = false }: { invert?: boolean }) { + return ( + + ); +} + +function DeviceChrome({ + label, + widthLabel, + phone = false, + children, +}: { + label: string; + widthLabel: string; + phone?: boolean; + children: React.ReactNode; +}) { + return ( +
+
+ + {label} + + {widthLabel} +
+
+ {phone ? : null} +
+ {/* + Jump clearance is section scroll-mt only (see Live Signal perfected). + Pairing scroll-pt here compounds and lands mid-frame. + */} + {children} +
+
+
+ ); +} + +function RegionStrip({ compact = false }: { compact?: boolean }) { + const cells = [ + { place: "Sydney", role: "Supabase storage", tone: "accent" as const }, + { place: "Singapore", role: "App + worker", tone: "neutral" as const }, + { place: "External", role: "OpenAI API", tone: "warn" as const }, + ]; + return ( +
+ {cells.map((cell) => ( +
+

+ {cell.place} +

+

{cell.role}

+
+ ))} +
+ ); +} + +function ObligationBand({ density = "full" }: { density?: "full" | "compact" | "bar" }) { + if (density === "bar") { + return ( +
+
+ ); + } + + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* 01 — Statement theatre */ +/* ------------------------------------------------------------------ */ + +function TheatreFrame({ phone = false }: { phone?: boolean }) { + if (phone) { + return ( +
+
+
+ + +
+

Clinical KB

+

Privacy

+
+
+
+ +
+

+ Privacy +

+

+ Privacy & data handling +

+

{DRAFT}

+
+ + + +
+ {SECTIONS.map((section, index) => ( +
+

+ {String(index + 1).padStart(2, "0")} +

+

+ {section.heading} +

+

{section.body}

+
+ ))} +
+
+ ); + } + + return ( +
+ + +
+
+ +
+ {SECTIONS.map((section, index) => ( +
+ +
+

+ {section.heading} +

+

+ {section.body} +

+
+
+ ))} +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* 02 — Data passport */ +/* ------------------------------------------------------------------ */ + +function PassportFrame({ phone = false }: { phone?: boolean }) { + return ( +
+
+
+ + +
+

+ Clinical KB · sealed brief +

+

+ Privacy & data handling +

+
+ + Draft + +
+ {!phone ?

{DRAFT}

: null} +
+ +
+ {phone ?

{DRAFT}

: null} + + + +
+ +
+ {SECTIONS.map((section, index) => ( +
+

+ {String(index + 1).padStart(2, "0")} +

+
+

+ {section.heading} +

+

{section.body}

+
+
+ ))} +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* 03 — Live signal (perfected frame) */ +/* ------------------------------------------------------------------ */ + +function SignalFrame({ phone = false }: { phone?: boolean }) { + return ; +} + +function DirectionPreview({ id }: { id: DirectionId }) { + const Frame = id === "theatre" ? TheatreFrame : id === "passport" ? PassportFrame : SignalFrame; + return ( +
+ + + + + + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ + +export function PrivacyPageDirectionsMockups() { + return ( +
+
+
+

+ Privacy page — redesign study · pass 2 +

+

+ Dramatically elevated directions +

+

+ Pass 1 was three skins on a card stack. Pass 2 changes structure: brand-led theatre, sealed passport, and a + live obligation signal. Still Clinical White / Sky Graphite. Still the same governance wording. Amber only + for Important. Back control always below the notch. +

+
+
+ +
+
+

+ Why pass 1 was rejected +

+
    +
  • + Generic SaaS cards. Seven white + panels equalised the Important obligation with Retention. +
  • +
  • + No brand presence. The first + viewport could belong to any product after removing the nav. +
  • +
  • + Same structure thrice. Ledger / + map / TOC were skins, not distinct compositions. +
  • +
+
+ + {directions.map((direction) => ( +
+
+
+ + {direction.number} + +
+
+

+ {direction.name} +

+ + {direction.verdict} + + {direction.recommended ? ( + + Recommended + + ) : null} +
+

+ {direction.summary} +

+

+ Trade-off — {direction.cost} +

+
+
+
+ {direction.strengths.map((strength) => ( + + {strength} + + ))} +
+
+ +
+ ))} + +
+

+ Recommendation +

+

+ Ship 03 Live Signal (perfected). The + obligation stays on screen, collapsed rows carry gists so clinicians can scan without opening every section, + and phone jump chips solve long-doc navigation. Dedicated review route:{" "} + /mockups/privacy-live-signal-perfected. +

+
+
+
+ ); +}