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);
}
});