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
27 changes: 17 additions & 10 deletions src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { Suspense, type ReactNode } from "react";

import { ClinicalBadge } from "@/components/clinical-dashboard/clinical-badge";
import { NavigationBackButton } from "@/components/navigation-back-button";
import { PrivacyPageBackButton } from "@/components/privacy-page-back-button";
import {
cn,
eyebrowText,
Expand Down Expand Up @@ -75,15 +77,20 @@ export default function PrivacyPage() {
<main className={cn(searchPageCanvas)}>
<div className={cn(searchPageShell)}>
<div className={cn(searchPageContainer, "space-y-6")}>
<header className="space-y-2">
<p className={eyebrowText}>{privacyCopy.pageEyebrow}</p>
<h1 className="text-2xl font-semibold tracking-tight text-[color:var(--text-heading)] sm:text-3xl">
{privacyCopy.pageTitle}
</h1>
<p className="max-w-[68ch] text-sm leading-6 text-[color:var(--text-muted)]">
This is draft product information based on the repository&apos;s configured behaviour. It is not legal
advice, a final privacy policy, or an assertion of governance approval.
</p>
<header className="space-y-3">
<Suspense fallback={<NavigationBackButton fallbackHref="/" />}>
<PrivacyPageBackButton />
</Suspense>
<div className="space-y-2">
<p className={eyebrowText}>{privacyCopy.pageEyebrow}</p>
<h1 className="text-2xl font-semibold tracking-tight text-[color:var(--text-heading)] sm:text-3xl">
{privacyCopy.pageTitle}
</h1>
<p className="max-w-[68ch] text-sm leading-6 text-[color:var(--text-muted)]">
This is draft product information based on the repository&apos;s configured behaviour. It is not legal
advice, a final privacy policy, or an assertion of governance approval.
</p>
</div>
</header>

<section className={cn(raisedCard, "p-4 sm:p-5")}>
Expand Down
29 changes: 17 additions & 12 deletions src/app/reference/colour-coding/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Metadata } from "next";

import { ClinicalBadge } from "@/components/clinical-dashboard/clinical-badge";
import { NavigationBackButton } from "@/components/navigation-back-button";
import { appModeHomeHref } from "@/lib/app-modes";
import {
cn,
eyebrowText,
Expand Down Expand Up @@ -41,18 +43,21 @@ export default function ColourCodingReferencePage() {
>
<div className={cn(searchPageShell)}>
<div className={cn(searchPageContainer, "space-y-6")}>
<header className="space-y-2">
<p className={eyebrowText}>Reference</p>
<h1 className="text-2xl font-semibold tracking-tight text-[color:var(--text-heading)] sm:text-3xl">
Colour coding reference
</h1>
<p className="max-w-[68ch] text-sm leading-6 text-[color:var(--text-muted)]">
Badges flag important content so clinical screens are faster to scan. The system uses six tones only —
meaning drives the colour, never the other way round. Danger and warning also carry an icon so they stay
distinguishable without colour. Governance lives in{" "}
<span className="font-mono text-xs">docs/clinical-badge-system-guide.md</span>; this page is generated
from <span className="font-mono text-xs">src/lib/semantic-flags.ts</span>.
</p>
<header className="space-y-3">
<NavigationBackButton fallbackHref={appModeHomeHref("tools")} />
<div className="space-y-2">
<p className={eyebrowText}>Reference</p>
<h1 className="text-2xl font-semibold tracking-tight text-[color:var(--text-heading)] sm:text-3xl">
Colour coding reference
</h1>
<p className="max-w-[68ch] text-sm leading-6 text-[color:var(--text-muted)]">
Badges flag important content so clinical screens are faster to scan. The system uses six tones only —
meaning drives the colour, never the other way round. Danger and warning also carry an icon so they stay
distinguishable without colour. Governance lives in{" "}
<span className="font-mono text-xs">docs/clinical-badge-system-guide.md</span>; this page is generated
from <span className="font-mono text-xs">src/lib/semantic-flags.ts</span>.
</p>
</div>
</header>

<section className={cn(raisedCard, "p-4 sm:p-5")}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ export function UploadPanel({

return (
<form onSubmit={handleFormSubmit} className={cn(panelSubtle, "p-3")}>
<PrivacyInputNotice className="mb-2" />
<PrivacyInputNotice className="mb-2" returnMode="documents" />
<label className="block text-xs font-semibold text-[color:var(--text)]">
Guideline PDF files
<input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1586,6 +1586,7 @@ export function MasterSearchHeader({
id={composerPrivacyWarningId}
testId={composerPrivacyWarningId}
className="mt-1.5 justify-center px-3 text-center"
returnMode={searchMode === "answer" ? undefined : searchMode}
/>
) : null}
{/* Scope popover is a form sibling so the "+" menu's "Set scope" action can
Expand Down
38 changes: 28 additions & 10 deletions src/components/clinical-dashboard/settings-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { type FormEvent, type ReactNode, type UIEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
Bell,
BookOpen,
Check,
Expand Down Expand Up @@ -49,6 +50,7 @@ import {
fieldControlWithIcon,
fieldIcon,
floatingControl,
IconButton,
InlineNotice,
primaryControl,
toggleThumbSurface,
Expand Down Expand Up @@ -259,6 +261,19 @@ export function SettingsDialog({
return () => window.cancelAnimationFrame(focusFrame);
}, [emailEntryOpen]);

const backButton = (
<IconButton
label="Back from settings"
icon={ArrowLeft}
onClick={onClose}
className={cn(
floatingControl,
"rounded-full border border-[color:var(--border)] bg-[color:var(--surface)]/70 text-[color:var(--text-muted)] shadow-[var(--shadow-inset)] hover:bg-[color:var(--surface)] hover:text-[color:var(--text-heading)] lg:hidden",
)}
iconClassName="size-icon-lg"
/>
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const closeButton = (
<button
ref={closeButtonRef}
Expand Down Expand Up @@ -332,16 +347,19 @@ export function SettingsDialog({
>
<div className="edge-glass-header-backdrop lg:hidden" aria-hidden="true" />
<div className="relative mx-auto flex w-full max-w-[520px] items-center justify-between gap-3 lg:max-w-none">
<div className="min-w-0">
<h2
id="account-settings-title"
className="truncate text-lg font-semibold leading-tight tracking-normal text-[color:var(--text-heading)] sm:text-xl lg:text-2xl lg:leading-8"
>
Account &amp; app
</h2>
<p className="mt-0.5 truncate text-sm font-medium leading-5 text-[color:var(--text-muted)]">
Tune your workspace, clinical defaults, and privacy.
</p>
<div className="flex min-w-0 flex-1 items-center gap-2">
{backButton}
<div className="min-w-0">
<h2
id="account-settings-title"
className="truncate text-lg font-semibold leading-tight tracking-normal text-[color:var(--text-heading)] sm:text-xl lg:text-2xl lg:leading-8"
>
Account &amp; app
</h2>
<p className="mt-0.5 truncate text-sm font-medium leading-5 text-[color:var(--text-muted)]">
Tune your workspace, clinical defaults, and privacy.
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="hidden min-h-7 items-center rounded-full border border-[color:var(--border-lux)] bg-[color:var(--surface)] px-3 text-xs font-semibold leading-none text-[color:var(--text-muted)] shadow-[var(--shadow-inset)] lg:inline-flex">
Expand Down
44 changes: 44 additions & 0 deletions src/components/navigation-back-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";

import { cn, floatingControl, IconButton } from "@/components/ui-primitives";

type NavigationBackButtonProps = {
label?: string;
fallbackHref?: string;
className?: string;
/**
* Optional gate before navigation. Return `false` to cancel (for example a
* dirty-form confirmation). When omitted, navigation always proceeds.
*/
onBeforeNavigate?: () => boolean;
};

/**
* Deterministic in-app back control. Always navigates to `fallbackHref` rather
* than `history.back()`, so deep links / external referrers cannot eject the
* user out of Clinical KB (same contract as form detail pages).
*/
export function NavigationBackButton({
label = "Go back",
fallbackHref = "/",
className,
onBeforeNavigate,
}: NavigationBackButtonProps) {
const router = useRouter();

return (
<IconButton
label={label}
icon={ArrowLeft}
onClick={() => {
if (onBeforeNavigate && !onBeforeNavigate()) return;
router.push(fallbackHref);
}}
className={cn(floatingControl, "rounded-full text-[color:var(--text-muted)]", className)}
iconClassName="h-5 w-5"
/>
);
}
58 changes: 55 additions & 3 deletions src/components/patient-safety-plan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";

import { NavigationBackButton } from "@/components/navigation-back-button";
import { appModeHomeHref } from "@/lib/app-modes";
import {
cn,
clinicalDivider,
Expand Down Expand Up @@ -210,28 +212,39 @@ function AddRow({
primaryPlaceholder,
secondaryPlaceholder,
onAdd,
onDraftDirtyChange,
}: {
kind: StepKind;
primaryPlaceholder: string;
secondaryPlaceholder?: string;
onAdd: (primary: string, secondary?: string) => void;
onDraftDirtyChange?: (dirty: boolean) => void;
}) {
const [primary, setPrimary] = useState("");
const [secondary, setSecondary] = useState("");
const draftIsDirty = useCallback(
(nextPrimary: string, nextSecondary: string) => nextPrimary.trim() !== "" || nextSecondary.trim() !== "",
[],
);

const submit = () => {
const trimmed = primary.trim();
if (!trimmed) return;
onAdd(trimmed, secondary.trim() || undefined);
setPrimary("");
setSecondary("");
onDraftDirtyChange?.(false);
};

return (
<div className={cn("grid gap-2", kind === "contact" && "sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]")}>
<input
value={primary}
onChange={(event) => setPrimary(event.target.value)}
onChange={(event) => {
const nextPrimary = event.target.value;
setPrimary(nextPrimary);
onDraftDirtyChange?.(draftIsDirty(nextPrimary, secondary));
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
Expand All @@ -245,7 +258,11 @@ function AddRow({
{kind === "contact" ? (
<input
value={secondary}
onChange={(event) => setSecondary(event.target.value)}
onChange={(event) => {
const nextSecondary = event.target.value;
setSecondary(nextSecondary);
onDraftDirtyChange?.(draftIsDirty(primary, nextSecondary));
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
Expand Down Expand Up @@ -309,11 +326,13 @@ function StepBuilderCard({
entries,
onAdd,
onRemove,
onDraftDirtyChange,
}: {
def: StepDef;
entries: Entry[];
onAdd: (primary: string, secondary?: string) => void;
onRemove: (id: string) => void;
onDraftDirtyChange: (dirty: boolean) => void;
}) {
const Icon = def.icon;
const filled = entries.length > 0;
Expand Down Expand Up @@ -366,6 +385,7 @@ function StepBuilderCard({
primaryPlaceholder={def.primaryPlaceholder}
secondaryPlaceholder={def.secondaryPlaceholder}
onAdd={onAdd}
onDraftDirtyChange={onDraftDirtyChange}
/>
</section>
);
Expand Down Expand Up @@ -428,6 +448,7 @@ export function PatientSafetyPlan() {
const [mobileTab, setMobileTab] = useState<"build" | "preview">("build");
const [copied, setCopied] = useState(false);
const [finalised, setFinalised] = useState(false);
const [draftDirtyByRow, setDraftDirtyByRow] = useState<Record<string, boolean>>({});

// Per-instance id counter — avoids a module-level mutable that would persist
// across remounts; ids only need to be unique within this mounted plan.
Expand All @@ -447,8 +468,26 @@ export function PatientSafetyPlan() {
setFinalised(false);
}, []);

const setDraftDirty = useCallback((key: string, dirty: boolean) => {
setDraftDirtyByRow((prev) => {
if (prev[key] === dirty) return prev;
return { ...prev, [key]: dirty };
});
}, []);

const filledSteps = useMemo(() => STEPS.filter((step) => entries[step.key].length > 0).length, [entries]);
const ready = filledSteps === STEPS.length;
// Working plan content is browser-tab only and never persisted. Treat any
// entered step/reason/date as dirty so the header back control cannot discard
// an in-progress plan without an explicit confirmation.
const isDirty = useMemo(
() =>
Object.values(entries).some((rows) => rows.length > 0) ||
reasons.length > 0 ||
planDate.trim() !== "" ||
Object.values(draftDirtyByRow).some(Boolean),
[draftDirtyByRow, entries, planDate, reasons],
);

const planText = useMemo(() => {
const lines: string[] = [
Expand Down Expand Up @@ -521,7 +560,17 @@ export function PatientSafetyPlan() {
{/* Tool header */}
<header className="border-b border-[color:var(--border)] bg-[color:var(--surface)]">
<div className="mx-auto grid max-w-7xl gap-4 px-4 py-4 sm:px-6 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center lg:px-8">
<div className="grid grid-cols-[auto_minmax(0,1fr)] items-start gap-3">
<div className="grid grid-cols-[auto_auto_minmax(0,1fr)] items-start gap-3">
<NavigationBackButton
className="size-tap"
fallbackHref={appModeHomeHref("tools")}
onBeforeNavigate={() => {
if (!isDirty) return true;
return window.confirm(
"Leave this safety plan? Your entries are only in this browser tab and will be lost.",
);
}}
/>
<span className="grid size-tap shrink-0 place-items-center rounded-2xl border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)]">
<ShieldCheck className="size-icon-lg" aria-hidden="true" />
</span>
Expand Down Expand Up @@ -676,6 +725,7 @@ export function PatientSafetyPlan() {
entries={entries[def.key]}
onAdd={(primary, secondary) => addEntry(def.key, primary, secondary)}
onRemove={(id) => removeEntry(def.key, id)}
onDraftDirtyChange={(dirty) => setDraftDirty(def.key, dirty)}
/>
))}

Expand Down Expand Up @@ -727,8 +777,10 @@ export function PatientSafetyPlan() {
<AddRow
kind="list"
primaryPlaceholder="e.g. Finishing my apprenticeship"
onDraftDirtyChange={(dirty) => setDraftDirty("reason", dirty)}
onAdd={(primary) => {
setReasons((prev) => [...prev, { id: uid("reason"), primary }]);
setDraftDirty("reason", false);
setFinalised(false);
}}
/>
Expand Down
Loading
Loading