From 0036ee46ba6b27d99ea1638d41473f32c855113b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:03:36 +0800 Subject: [PATCH 1/2] feat(ui): single compact site-wide privacy notice below the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The answer composer stacked two privacy elements (full notice above the pill plus a duplicate warning below it), and the answer home hero added a third "Privacy & data handling" link — on phones this burned three-plus lines of dock space. - Redesign PrivacyInputNotice as one quiet 11px line (text-2xs, smaller icon, muted link) and render it once below the pill for every search mode and composer placement; input aria-describedby now always points at it. z-[5] keeps it above the dock backdrop but under the command surface so the suggestions dropdown covers it. - Remove the duplicated hero-footer privacy link from the answer empty state and the orphaned composerWarning/composerLinkLabel copy. - Tests: assert exactly one /privacy link on the home page; repair the pre-existing #553 drift in ui-smoke (/privacy h1 is "Privacy & data handling", not "Privacy and data processing"). Co-Authored-By: Claude Fable 5 --- .../clinical-dashboard/answer-status.tsx | 17 +++----------- .../master-search-header.tsx | 23 ++++++++----------- src/components/privacy-input-notice.tsx | 15 ++++++++---- src/lib/ui-copy.ts | 13 ++++------- tests/ui-smoke.spec.ts | 4 ++-- tests/ui-tools.spec.ts | 9 ++++---- 6 files changed, 35 insertions(+), 46 deletions(-) diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index 34fd69b4c..c48802f20 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -1,12 +1,11 @@ "use client"; import { Clipboard, ClipboardCheck, History, MessageSquareText, Search, ShieldCheck, Upload } from "lucide-react"; -import Link from "next/link"; import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; import { cn, floatingControl, sourceCard } from "@/components/ui-primitives"; -import { answerEmptyState, answerLoading, copyButton, privacyCopy } from "@/lib/ui-copy"; +import { answerEmptyState, answerLoading, copyButton } from "@/lib/ui-copy"; export function CopyButton({ label, @@ -93,19 +92,9 @@ export function AnswerEmptyState({ {answerEmptyState.starters.uploadDocument.title} + {/* No privacy link here: the composer's PrivacyInputNotice is the + single site-wide notice, so the hero footer must not repeat it. */} - {/* Keep the footer compact; the full privacy detail lives on /privacy. */} -

- - {privacyCopy.composerLinkLabel} - -

} /> diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 29ff30d20..ef971fb95 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -68,7 +68,6 @@ import { appModeIcons } from "@/lib/app-mode-icons"; import type { ClinicalDocument, ClinicalQueryMode } from "@/lib/types"; import { type SearchScopeFilters } from "@/lib/search-scope"; import { tagSearchText } from "@/lib/document-tags"; -import { privacyCopy } from "@/lib/ui-copy"; const phoneSearchLayoutMediaQuery = "(max-width: 639px)"; const scopeSheetMediaQuery = "(max-width: 1023px)"; @@ -1191,9 +1190,6 @@ export function MasterSearchHeader({ className="answer-suggestion-row-composer-followups relative z-10 w-full sm:hidden" /> ) : null} - {searchMode === "answer" ? ( - - ) : null} - {searchMode === "answer" ? ( -

- {privacyCopy.composerWarning} -

- ) : null} + {/* Single site-wide APP-5 privacy line: every composer variant (home + hero, phone dock, answer thread, sticky search) renders exactly one + compact notice below the pill; no other surface may duplicate it. */} + {/* Scope popover is a form sibling so the "+" menu's "Set scope" action can open it even when the footer chip row is not shown. */} {hasScopeFooterChip && !usesScopeSheet && scopeOpen ? ( diff --git a/src/components/privacy-input-notice.tsx b/src/components/privacy-input-notice.tsx index 359472336..6c7f53ef4 100644 --- a/src/components/privacy-input-notice.tsx +++ b/src/components/privacy-input-notice.tsx @@ -3,21 +3,28 @@ import { ShieldAlert } from "lucide-react"; import { cn, textMuted } from "@/components/ui-primitives"; -export function PrivacyInputNotice({ className }: { className?: string }) { +// Compact APP-5 privacy notice shown beside clinical input controls (query +// composer, document upload). Deliberately one quiet 11px line — the wording +// and the /privacy link are governance copy (PIA-5) and must stay intact. +export function PrivacyInputNotice({ className, id, testId }: { className?: string; id?: string; testId?: string }) { return (

- + Do not enter patient-identifiable information. Privacy and data processing diff --git a/src/lib/ui-copy.ts b/src/lib/ui-copy.ts index 00b8ba612..1c04e6407 100644 --- a/src/lib/ui-copy.ts +++ b/src/lib/ui-copy.ts @@ -105,15 +105,12 @@ export const emptyStates = { }, } as const; -// Privacy / data-handling copy — the APP-5 warning shown at the query composer -// plus the labels for the /privacy transparency page. Wording is a -// plain-language engineering summary of docs/privacy-impact-assessment.md; it is -// not legal advice. See PIA-1 / PIA-5. The composer link is deliberately concise; -// the full detail lives on /privacy. +// Privacy / data-handling copy — labels for the /privacy transparency page and +// the answer empty-state link. Wording is a plain-language engineering summary +// of docs/privacy-impact-assessment.md; it is not legal advice. See PIA-1 / +// PIA-5. The APP-5 composer/upload warning itself lives in +// components/privacy-input-notice.tsx. export const privacyCopy = { - composerWarning: "Don't enter identifiable patient details.", - composerLinkLabel: "Privacy & data handling", - noticeAriaLabel: "Privacy and data-handling notice", pageEyebrow: "Privacy", pageTitle: "Privacy & data handling", } as const; diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 14b63fea9..b28eea651 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1563,8 +1563,8 @@ test.describe("Clinical KB UI smoke coverage", () => { await page.goto("/privacy", { waitUntil: "domcontentloaded" }); await expect(page.getByRole("main")).toBeVisible(); - await expect(page.getByRole("heading", { level: 1, name: "Privacy and data processing" })).toBeVisible(); - await expect(page.getByText("Draft for privacy and clinical-governance approval")).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: "Privacy & data handling" })).toBeVisible(); + await expect(page.getByText("This is draft product information", { exact: false })).toBeVisible(); await expectNoPageHorizontalOverflow(page); }); } diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 07f46b0d3..55801e9e3 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -549,14 +549,15 @@ test.describe("Clinical KB tools launcher", () => { const warning = page.getByTestId("answer-composer-privacy-warning"); await expect(warning).toBeVisible(); - await expect(warning).toHaveText("Don't enter identifiable patient details."); + await expect(warning).toContainText("Do not enter patient-identifiable information."); + await expect(warning.getByRole("link", { name: "Privacy and data processing" })).toBeVisible(); await expect(visibleGlobalSearchInput(page)).toHaveAttribute( "aria-describedby", "answer-composer-privacy-warning", ); - await expect( - page.getByTestId("answer-empty-state").getByRole("link", { name: "Privacy & data handling" }), - ).toBeVisible(); + // The composer notice is the single site-wide privacy element — no other + // /privacy link (e.g. the old hero-footer duplicate) may render with it. + await expect(page.locator('a[href="/privacy"]')).toHaveCount(1); } }); From ad5ef99f68dfd07d56a2ad9e2d0871b7deaf882c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:29:57 +0800 Subject: [PATCH 2/2] fix(ui): address PR #576 review findings - Skip the composer privacy notice on phone non-answer result docks: those paths reserve pill-only scroll clearance (5rem dashboard compact branch, 5.5rem standalone shell), so the extra line pushed the fixed dock over the last result. Entry composers for those flows still show the notice; answer docks keep it (reserves were sized for the old taller stack). aria-describedby now matches the render gate. - Extract the shared composer privacy-warning id into a constant so the aria wiring and notice id cannot drift. - Drop the stale "answer empty-state link" reference from the privacyCopy doc comment. Co-Authored-By: Claude Fable 5 --- .../master-search-header.tsx | 30 ++++++++++++++----- src/lib/ui-copy.ts | 6 ++-- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index ef971fb95..a955bcc44 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -69,6 +69,10 @@ import type { ClinicalDocument, ClinicalQueryMode } from "@/lib/types"; import { type SearchScopeFilters } from "@/lib/search-scope"; import { tagSearchText } from "@/lib/document-tags"; +// Shared between the composer input's aria-describedby and the rendered +// PrivacyInputNotice id/testId so the wiring cannot drift apart. +const composerPrivacyWarningId = "answer-composer-privacy-warning"; + const phoneSearchLayoutMediaQuery = "(max-width: 639px)"; const scopeSheetMediaQuery = "(max-width: 1023px)"; const desktopHomeComposerMediaQuery = "(min-width: 1024px)"; @@ -1130,6 +1134,13 @@ export function MasterSearchHeader({ const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents" || searchMode === "forms"; const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout; const shouldHideBottomOnScroll = Boolean(hideOnScroll && usesPhoneFooterDock); + // Phone submitted non-answer result docks reserve pill-only scroll + // clearance (ClinicalDashboard

margins / global-search-shell + // mobileComposerReserve), so an extra notice line would push the fixed + // dock over the last result. Those flows already showed the notice on + // their entry composer; answer docks keep it (their reserves were sized + // for the old taller notice-above-pill stack). + const showsComposerPrivacyNotice = searchMode === "answer" || !usesPhoneFooterDock; const commandSurfacePlacement = usesBottomComposerPlacement ? "bottom-dock" : "inline"; @@ -1272,7 +1283,7 @@ export function MasterSearchHeader({ aria-expanded={commandDropdownOpen} aria-controls={commandDropdownOpen ? commandListboxId : undefined} aria-autocomplete="list" - aria-describedby="answer-composer-privacy-warning" + aria-describedby={showsComposerPrivacyNotice ? composerPrivacyWarningId : undefined} // React's onChange already fires on every input event; a duplicate // onInput called onQueryChange twice per keystroke, doubling the // controlled-state work on a large parent tree. @@ -1323,13 +1334,16 @@ export function MasterSearchHeader({ {/* Single site-wide APP-5 privacy line: every composer variant (home - hero, phone dock, answer thread, sticky search) renders exactly one - compact notice below the pill; no other surface may duplicate it. */} - + hero, answer dock, sticky search) renders exactly one compact + notice below the pill; no other surface may duplicate it. Phone + non-answer result docks skip it — see showsComposerPrivacyNotice. */} + {showsComposerPrivacyNotice ? ( + + ) : null} {/* Scope popover is a form sibling so the "+" menu's "Set scope" action can open it even when the footer chip row is not shown. */} {hasScopeFooterChip && !usesScopeSheet && scopeOpen ? ( diff --git a/src/lib/ui-copy.ts b/src/lib/ui-copy.ts index 1c04e6407..c82ee02fb 100644 --- a/src/lib/ui-copy.ts +++ b/src/lib/ui-copy.ts @@ -105,9 +105,9 @@ export const emptyStates = { }, } as const; -// Privacy / data-handling copy — labels for the /privacy transparency page and -// the answer empty-state link. Wording is a plain-language engineering summary -// of docs/privacy-impact-assessment.md; it is not legal advice. See PIA-1 / +// Privacy / data-handling copy — labels for the /privacy transparency page. +// Wording is a plain-language engineering summary of +// docs/privacy-impact-assessment.md; it is not legal advice. See PIA-1 / // PIA-5. The APP-5 composer/upload warning itself lives in // components/privacy-input-notice.tsx. export const privacyCopy = {