Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 3 additions & 14 deletions src/components/clinical-dashboard/answer-status.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -93,19 +92,9 @@ export function AnswerEmptyState({
{answerEmptyState.starters.uploadDocument.title}
</button>
</div>
{/* No privacy link here: the composer's PrivacyInputNotice is the
single site-wide notice, so the hero footer must not repeat it. */}
<ModeHomeVerificationFooter icon={ShieldCheck} label="Source backed" body="Clinical Guide library" />
{/* Keep the footer compact; the full privacy detail lives on /privacy. */}
<p
className="text-center text-2xs leading-4 text-[color:var(--text-muted)]"
aria-label={privacyCopy.noticeAriaLabel}
>
<Link
href="/privacy"
className="font-medium text-[color:var(--text-soft)] underline underline-offset-2 transition-colors hover:text-[color:var(--text-heading)]"
>
{privacyCopy.composerLinkLabel}
</Link>
</p>
</div>
}
/>
Expand Down
35 changes: 22 additions & 13 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)";
Expand Down Expand Up @@ -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 <main> 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";

Expand Down Expand Up @@ -1191,9 +1201,6 @@ export function MasterSearchHeader({
className="answer-suggestion-row-composer-followups relative z-10 w-full sm:hidden"
/>
) : null}
{searchMode === "answer" ? (
<PrivacyInputNotice className="px-2 text-left sm:justify-center sm:text-center" />
) : null}
<UniversalSearchCommandSurface
modeId={searchMode}
query={query}
Expand Down Expand Up @@ -1276,7 +1283,7 @@ export function MasterSearchHeader({
aria-expanded={commandDropdownOpen}
aria-controls={commandDropdownOpen ? commandListboxId : undefined}
aria-autocomplete="list"
aria-describedby={searchMode === "answer" ? "answer-composer-privacy-warning" : undefined}
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.
Expand Down Expand Up @@ -1326,14 +1333,16 @@ export function MasterSearchHeader({
</button>
</div>
</UniversalSearchCommandSurface>
{searchMode === "answer" ? (
<p
id="answer-composer-privacy-warning"
data-testid="answer-composer-privacy-warning"
className="relative z-10 mt-1.5 w-full px-3 text-center text-2xs leading-4 text-[color:var(--text-muted)]"
>
{privacyCopy.composerWarning}
</p>
{/* 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 ? (
<PrivacyInputNotice
id={composerPrivacyWarningId}
testId={composerPrivacyWarningId}
className="mt-1.5 justify-center px-3 text-center"
/>
) : 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. */}
Expand Down
15 changes: 11 additions & 4 deletions src/components/privacy-input-notice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<p
role="note"
id={id}
data-testid={testId}
className={cn(
"relative z-10 flex w-full flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs leading-5",
// z sits above the composer dock backdrop (z-0) but below the command
// surface (z-10) so the suggestions dropdown can cover the notice.
"relative z-[5] flex w-full min-w-0 flex-wrap items-center gap-x-1 gap-y-0 text-2xs leading-4",
textMuted,
className,
)}
>
<ShieldAlert className="h-3.5 w-3.5 shrink-0 text-[color:var(--warning)]" aria-hidden />
<ShieldAlert className="h-3 w-3 shrink-0 text-[color:var(--warning)]" aria-hidden />
<span>Do not enter patient-identifiable information.</span>
<Link
href="/privacy"
className="rounded-sm font-semibold text-[color:var(--clinical-accent)] underline decoration-[color:var(--clinical-accent)]/40 underline-offset-2 hover:decoration-current focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"
className="rounded-sm font-medium underline decoration-[color:var(--border-strong)] underline-offset-2 transition-colors hover:text-[color:var(--clinical-accent)] hover:decoration-current focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"
>
Privacy and data processing
</Link>
Expand Down
13 changes: 5 additions & 8 deletions src/lib/ui-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions tests/ui-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand Down
9 changes: 5 additions & 4 deletions tests/ui-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});

Expand Down
Loading