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..a955bcc44 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -68,7 +68,10 @@ 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"; + +// 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)"; @@ -1131,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"; @@ -1191,9 +1201,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} -

+ {/* Single site-wide APP-5 privacy line: every composer variant (home + 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. */} 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..c82ee02fb 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. +// 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); } });