+
-
+
-
- {label}
-
-
+ {label}
+
{value}
@@ -492,90 +1145,3 @@ function SettingsSummaryTile({
);
}
-
-function SettingsRow({
- icon: Icon,
- label,
- value,
- active = false,
- onClick,
- actionLabel,
-}: {
- icon: typeof UserRound;
- label: string;
- value: string;
- active?: boolean;
- onClick?: () => void;
- actionLabel?: string;
-}) {
- const content = (
- <>
-
-
-
-
- {label}
- {value ? (
-
- {value}
-
- ) : null}
-
-
- >
- );
-
- const className =
- "flex min-h-[50px] w-full items-center gap-2.5 border-b border-[color:var(--border)]/70 px-3 py-1.5 text-left last:border-b-0 transition hover:bg-[color:var(--surface)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] sm:min-h-[54px] sm:gap-3 sm:px-3.5 sm:py-2 lg:min-h-10 lg:gap-3 lg:px-0 lg:py-0 lg:hover:bg-[color:var(--surface-lux)]/55";
- const testId = `settings-row-${label
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/(^-|-$)/g, "")}`;
-
- if (onClick) {
- return (
-
- );
- }
-
- return (
-
- {content}
-
- );
-}
-
-function SettingsHelpFooter({ onClick }: { onClick: () => void }) {
- return (
-
-
-
- );
-}
diff --git a/src/components/clinical-dashboard/use-app-preferences.ts b/src/components/clinical-dashboard/use-app-preferences.ts
new file mode 100644
index 000000000..3987875c4
--- /dev/null
+++ b/src/components/clinical-dashboard/use-app-preferences.ts
@@ -0,0 +1,226 @@
+"use client";
+
+import { useCallback, useEffect, useSyncExternalStore } from "react";
+
+/**
+ * App-wide, non-clinical preferences persisted per browser. This mirrors the
+ * external-store pattern in use-theme.ts / use-sidebar-collapsed.ts so a choice
+ * made in the settings surface survives route changes across every shell and
+ * stays in sync between open tabs. Nothing here is PHI; values are plain enums.
+ */
+
+export type DensityPreference = "comfortable" | "compact" | "spacious";
+export type MotionPreference = "system" | "reduced";
+export type PopulationPreference = "adults" | "older-adults" | "adolescents" | "all";
+export type AnswerStylePreference = "conservative" | "balanced" | "comprehensive";
+export type LandingPreference = "ask" | "search" | "browse";
+
+export type AppPreferences = {
+ density: DensityPreference;
+ motion: MotionPreference;
+ jurisdiction: string;
+ population: PopulationPreference;
+ answerStyle: AnswerStylePreference;
+ landing: LandingPreference;
+ showRecentOnHome: boolean;
+ showProtocolsOnHome: boolean;
+ compactCitations: boolean;
+ notifyGuidelineUpdates: boolean;
+ notifyProductNews: boolean;
+ notifySavedChanges: boolean;
+};
+
+export const JURISDICTION_OPTIONS = [
+ { value: "wa", label: "Western Australia" },
+ { value: "nsw", label: "New South Wales" },
+ { value: "vic", label: "Victoria" },
+ { value: "qld", label: "Queensland" },
+ { value: "sa", label: "South Australia" },
+ { value: "tas", label: "Tasmania" },
+ { value: "act", label: "Australian Capital Territory" },
+ { value: "nt", label: "Northern Territory" },
+ { value: "national", label: "National (Australia)" },
+] as const;
+
+export const POPULATION_OPTIONS: ReadonlyArray<{ value: PopulationPreference; label: string }> = [
+ { value: "adults", label: "Adults" },
+ { value: "older-adults", label: "Older adults" },
+ { value: "adolescents", label: "Adolescents" },
+ { value: "all", label: "All ages" },
+];
+
+export const ANSWER_STYLE_OPTIONS: ReadonlyArray<{
+ value: AnswerStylePreference;
+ label: string;
+ description: string;
+}> = [
+ { value: "conservative", label: "Conservative", description: "Guideline-first, cautious phrasing" },
+ { value: "balanced", label: "Balanced", description: "Guidelines with practical context" },
+ { value: "comprehensive", label: "Comprehensive", description: "Fuller detail and alternatives" },
+];
+
+export const DENSITY_OPTIONS: ReadonlyArray<{ value: DensityPreference; label: string }> = [
+ { value: "comfortable", label: "Comfortable" },
+ { value: "compact", label: "Compact" },
+ { value: "spacious", label: "Spacious" },
+];
+
+export const LANDING_OPTIONS: ReadonlyArray<{ value: LandingPreference; label: string }> = [
+ { value: "ask", label: "Ask" },
+ { value: "search", label: "Search" },
+ { value: "browse", label: "Browse" },
+];
+
+export const DEFAULT_PREFERENCES: AppPreferences = {
+ density: "comfortable",
+ motion: "system",
+ jurisdiction: "wa",
+ population: "adults",
+ answerStyle: "conservative",
+ landing: "ask",
+ showRecentOnHome: true,
+ showProtocolsOnHome: true,
+ compactCitations: false,
+ notifyGuidelineUpdates: true,
+ notifyProductNews: false,
+ notifySavedChanges: true,
+};
+
+const storageKey = "clinical-kb-preferences";
+const changeEvent = "clinical-kb-preferences-change";
+
+// In-memory fallback when localStorage is unavailable (private mode, quota).
+let inMemoryFallback: AppPreferences | null = null;
+// Cache the parsed snapshot so useSyncExternalStore gets a stable reference
+// between reads (a fresh object each call would loop the store forever).
+let cachedRaw: string | null = null;
+let cachedValue: AppPreferences = DEFAULT_PREFERENCES;
+
+function isPlainObject(value: unknown): value is Record
{
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function coerceEnum(value: unknown, allowed: ReadonlyArray, fallback: T): T {
+ return typeof value === "string" && (allowed as ReadonlyArray).includes(value) ? (value as T) : fallback;
+}
+
+function coerceBoolean(value: unknown, fallback: boolean): boolean {
+ return typeof value === "boolean" ? value : fallback;
+}
+
+export function normalizePreferences(input: unknown): AppPreferences {
+ if (!isPlainObject(input)) return DEFAULT_PREFERENCES;
+ const jurisdiction =
+ typeof input.jurisdiction === "string" && JURISDICTION_OPTIONS.some((option) => option.value === input.jurisdiction)
+ ? input.jurisdiction
+ : DEFAULT_PREFERENCES.jurisdiction;
+ return {
+ density: coerceEnum(input.density, ["comfortable", "compact", "spacious"], DEFAULT_PREFERENCES.density),
+ motion: coerceEnum(input.motion, ["system", "reduced"], DEFAULT_PREFERENCES.motion),
+ jurisdiction,
+ population: coerceEnum(
+ input.population,
+ ["adults", "older-adults", "adolescents", "all"],
+ DEFAULT_PREFERENCES.population,
+ ),
+ answerStyle: coerceEnum(
+ input.answerStyle,
+ ["conservative", "balanced", "comprehensive"],
+ DEFAULT_PREFERENCES.answerStyle,
+ ),
+ landing: coerceEnum(input.landing, ["ask", "search", "browse"], DEFAULT_PREFERENCES.landing),
+ showRecentOnHome: coerceBoolean(input.showRecentOnHome, DEFAULT_PREFERENCES.showRecentOnHome),
+ showProtocolsOnHome: coerceBoolean(input.showProtocolsOnHome, DEFAULT_PREFERENCES.showProtocolsOnHome),
+ compactCitations: coerceBoolean(input.compactCitations, DEFAULT_PREFERENCES.compactCitations),
+ notifyGuidelineUpdates: coerceBoolean(input.notifyGuidelineUpdates, DEFAULT_PREFERENCES.notifyGuidelineUpdates),
+ notifyProductNews: coerceBoolean(input.notifyProductNews, DEFAULT_PREFERENCES.notifyProductNews),
+ notifySavedChanges: coerceBoolean(input.notifySavedChanges, DEFAULT_PREFERENCES.notifySavedChanges),
+ };
+}
+
+function readStored(): AppPreferences {
+ let raw: string | null = null;
+ try {
+ raw = window.localStorage.getItem(storageKey);
+ } catch {
+ return DEFAULT_PREFERENCES;
+ }
+ if (raw === cachedRaw) return cachedValue;
+ cachedRaw = raw;
+ if (!raw) {
+ cachedValue = DEFAULT_PREFERENCES;
+ return cachedValue;
+ }
+ try {
+ cachedValue = normalizePreferences(JSON.parse(raw));
+ } catch {
+ cachedValue = DEFAULT_PREFERENCES;
+ }
+ return cachedValue;
+}
+
+function getSnapshot(): AppPreferences {
+ if (inMemoryFallback) return inMemoryFallback;
+ if (typeof window === "undefined") return DEFAULT_PREFERENCES;
+ return readStored();
+}
+
+function getServerSnapshot(): AppPreferences {
+ return DEFAULT_PREFERENCES;
+}
+
+function subscribe(onChange: () => void) {
+ if (typeof window === "undefined") return () => undefined;
+ window.addEventListener("storage", onChange);
+ window.addEventListener(changeEvent, onChange);
+ return () => {
+ window.removeEventListener("storage", onChange);
+ window.removeEventListener(changeEvent, onChange);
+ };
+}
+
+function persist(next: AppPreferences) {
+ try {
+ window.localStorage.setItem(storageKey, JSON.stringify(next));
+ inMemoryFallback = null;
+ } catch {
+ inMemoryFallback = next;
+ }
+ window.dispatchEvent(new Event(changeEvent));
+}
+
+/** Reflects density/motion onto so the choice takes real visual effect. */
+export function applyPreferenceSideEffects(preferences: AppPreferences) {
+ if (typeof document === "undefined") return;
+ const root = document.documentElement;
+ if (preferences.density === "comfortable") {
+ root.removeAttribute("data-density");
+ } else {
+ root.setAttribute("data-density", preferences.density);
+ }
+ if (preferences.motion === "reduced") {
+ root.setAttribute("data-motion", "reduced");
+ } else {
+ root.removeAttribute("data-motion");
+ }
+}
+
+export function useAppPreferences() {
+ const preferences = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+
+ useEffect(() => {
+ applyPreferenceSideEffects(preferences);
+ }, [preferences]);
+
+ const setPreference = useCallback((key: Key, value: AppPreferences[Key]) => {
+ const current = getSnapshot();
+ if (current[key] === value) return;
+ persist({ ...current, [key]: value });
+ }, []);
+
+ const resetPreferences = useCallback(() => {
+ persist(DEFAULT_PREFERENCES);
+ }, []);
+
+ return { preferences, setPreference, resetPreferences };
+}
diff --git a/src/components/clinical-dashboard/use-theme.ts b/src/components/clinical-dashboard/use-theme.ts
index 922cd8ac5..112ec3317 100644
--- a/src/components/clinical-dashboard/use-theme.ts
+++ b/src/components/clinical-dashboard/use-theme.ts
@@ -1,29 +1,67 @@
"use client";
-import { useEffect, useSyncExternalStore } from "react";
+import { useCallback, useEffect, useSyncExternalStore } from "react";
-import { APP_THEME_COLORS, DEFAULT_THEME, nextTheme, resolveThemePreference, type ResolvedTheme } from "@/lib/theme";
+import {
+ APP_THEME_COLORS,
+ DEFAULT_THEME,
+ nextTheme,
+ readThemePreference,
+ resolveThemePreference,
+ type ResolvedTheme,
+ type ThemePreference,
+} from "@/lib/theme";
const themeStorageKey = "clinical-kb-theme";
const themeChangeEvent = "clinical-kb-theme-change";
+// In-memory fallback when localStorage is unavailable (Safari private mode,
+// blocked cookies, quota). Null means storage is the source of truth; otherwise
+// the chosen preference is kept for the session so the theme still applies.
+// Mirrors the fallback pattern in use-sidebar-collapsed.ts / use-app-preferences.ts.
+let inMemoryPreference: ThemePreference | null = null;
+
+function readStoredThemeValue(): string | null {
+ if (inMemoryPreference !== null) {
+ return inMemoryPreference === "system" ? null : inMemoryPreference;
+ }
+ try {
+ return window.localStorage.getItem(themeStorageKey);
+ } catch {
+ return null;
+ }
+}
+
function getThemeSnapshot(): ResolvedTheme {
if (typeof window === "undefined") return DEFAULT_THEME;
- const storedTheme = window.localStorage.getItem(themeStorageKey);
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
- return resolveThemePreference(storedTheme, prefersDark);
+ return resolveThemePreference(readStoredThemeValue(), prefersDark);
}
function getServerThemeSnapshot(): ResolvedTheme {
return DEFAULT_THEME;
}
+function getPreferenceSnapshot(): ThemePreference {
+ if (typeof window === "undefined") return "system";
+ return readThemePreference(readStoredThemeValue());
+}
+
+function getServerPreferenceSnapshot(): ThemePreference {
+ return "system";
+}
+
function syncThemeColorMetadata(theme: ResolvedTheme) {
for (const element of document.querySelectorAll('meta[name="theme-color"]')) {
element.content = APP_THEME_COLORS[theme];
}
}
+function applyResolvedTheme(theme: ResolvedTheme) {
+ document.documentElement.classList.toggle("dark", theme === "dark");
+ syncThemeColorMetadata(theme);
+}
+
function subscribeTheme(onStoreChange: () => void) {
if (typeof window === "undefined") return () => undefined;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
@@ -42,19 +80,34 @@ function subscribeTheme(onStoreChange: () => void) {
export function useTheme() {
const theme = useSyncExternalStore(subscribeTheme, getThemeSnapshot, getServerThemeSnapshot);
+ const preference = useSyncExternalStore(subscribeTheme, getPreferenceSnapshot, getServerPreferenceSnapshot);
useEffect(() => {
- document.documentElement.classList.toggle("dark", theme === "dark");
- syncThemeColorMetadata(theme);
+ applyResolvedTheme(theme);
}, [theme]);
- function toggleTheme() {
- const resolved = nextTheme(theme);
- window.localStorage.setItem(themeStorageKey, resolved);
- document.documentElement.classList.toggle("dark", resolved === "dark");
- syncThemeColorMetadata(resolved);
+ const setPreference = useCallback((next: ThemePreference) => {
+ try {
+ // Clearing the stored pin lets the OS preference (and its live media
+ // query) drive the theme again, matching the pre-hydration script.
+ if (next === "system") window.localStorage.removeItem(themeStorageKey);
+ else window.localStorage.setItem(themeStorageKey, next);
+ inMemoryPreference = null;
+ } catch {
+ // Storage blocked: keep the choice in memory so the theme still applies
+ // (and reads back correctly) for the rest of this session.
+ inMemoryPreference = next;
+ }
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+ applyResolvedTheme(resolveThemePreference(next === "system" ? null : next, prefersDark));
window.dispatchEvent(new Event(themeChangeEvent));
- }
+ }, []);
+
+ const toggleTheme = useCallback(() => {
+ // A direct toggle always pins an explicit light/dark choice so a single tap
+ // has a predictable result even when the current theme came from the OS.
+ setPreference(nextTheme(getThemeSnapshot()));
+ }, [setPreference]);
- return { theme, toggleTheme };
+ return { theme, preference, toggleTheme, setPreference };
}
diff --git a/src/lib/theme.ts b/src/lib/theme.ts
index 22bd96c15..50f0470c6 100644
--- a/src/lib/theme.ts
+++ b/src/lib/theme.ts
@@ -1,5 +1,13 @@
export type ResolvedTheme = "light" | "dark";
+/**
+ * User-facing appearance choice. "system" follows the OS `prefers-color-scheme`
+ * while "light"/"dark" pin the theme regardless of the OS. Only "light" and
+ * "dark" are persisted; "system" is represented by the absence of a stored
+ * value so the OS preference keeps flowing through on later visits.
+ */
+export type ThemePreference = ResolvedTheme | "system";
+
export const DEFAULT_THEME: ResolvedTheme = "dark";
export const APP_THEME_COLORS = {
@@ -12,6 +20,15 @@ export function resolveThemePreference(storedTheme: string | null | undefined, p
return prefersDark ? "dark" : "light";
}
+/**
+ * Maps a raw stored value to the appearance choice shown in settings. Anything
+ * that is not an explicit "light"/"dark" pin (null, "system", or a stale value)
+ * reads back as "system" so the control mirrors what the app actually renders.
+ */
+export function readThemePreference(storedTheme: string | null | undefined): ThemePreference {
+ return storedTheme === "light" || storedTheme === "dark" ? storedTheme : "system";
+}
+
export function nextTheme(currentTheme: ResolvedTheme): ResolvedTheme {
return currentTheme === "dark" ? "light" : "dark";
}
diff --git a/tests/app-preferences.test.ts b/tests/app-preferences.test.ts
new file mode 100644
index 000000000..7c2a97252
--- /dev/null
+++ b/tests/app-preferences.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ ANSWER_STYLE_OPTIONS,
+ DEFAULT_PREFERENCES,
+ DENSITY_OPTIONS,
+ JURISDICTION_OPTIONS,
+ LANDING_OPTIONS,
+ POPULATION_OPTIONS,
+ normalizePreferences,
+} from "../src/components/clinical-dashboard/use-app-preferences";
+
+describe("app preference normalisation", () => {
+ it("returns defaults for non-object or empty input", () => {
+ expect(normalizePreferences(null)).toEqual(DEFAULT_PREFERENCES);
+ expect(normalizePreferences(undefined)).toEqual(DEFAULT_PREFERENCES);
+ expect(normalizePreferences("nope")).toEqual(DEFAULT_PREFERENCES);
+ expect(normalizePreferences([])).toEqual(DEFAULT_PREFERENCES);
+ expect(normalizePreferences({})).toEqual(DEFAULT_PREFERENCES);
+ });
+
+ it("keeps valid stored values", () => {
+ const stored = {
+ density: "compact",
+ motion: "reduced",
+ jurisdiction: "nsw",
+ population: "older-adults",
+ answerStyle: "comprehensive",
+ landing: "search",
+ showRecentOnHome: false,
+ showProtocolsOnHome: false,
+ compactCitations: true,
+ notifyGuidelineUpdates: false,
+ notifyProductNews: true,
+ notifySavedChanges: false,
+ };
+ expect(normalizePreferences(stored)).toEqual(stored);
+ });
+
+ it("falls back per-field when individual values are invalid", () => {
+ const result = normalizePreferences({
+ density: "microscopic",
+ motion: 3,
+ jurisdiction: "atlantis",
+ population: null,
+ answerStyle: "chatty",
+ landing: "teleport",
+ showRecentOnHome: "yes",
+ compactCitations: 1,
+ });
+ expect(result.density).toBe(DEFAULT_PREFERENCES.density);
+ expect(result.motion).toBe(DEFAULT_PREFERENCES.motion);
+ expect(result.jurisdiction).toBe(DEFAULT_PREFERENCES.jurisdiction);
+ expect(result.population).toBe(DEFAULT_PREFERENCES.population);
+ expect(result.answerStyle).toBe(DEFAULT_PREFERENCES.answerStyle);
+ expect(result.landing).toBe(DEFAULT_PREFERENCES.landing);
+ expect(result.showRecentOnHome).toBe(DEFAULT_PREFERENCES.showRecentOnHome);
+ expect(result.compactCitations).toBe(DEFAULT_PREFERENCES.compactCitations);
+ });
+
+ it("keeps every default within its published option set", () => {
+ expect(DENSITY_OPTIONS.some((option) => option.value === DEFAULT_PREFERENCES.density)).toBe(true);
+ expect(POPULATION_OPTIONS.some((option) => option.value === DEFAULT_PREFERENCES.population)).toBe(true);
+ expect(ANSWER_STYLE_OPTIONS.some((option) => option.value === DEFAULT_PREFERENCES.answerStyle)).toBe(true);
+ expect(LANDING_OPTIONS.some((option) => option.value === DEFAULT_PREFERENCES.landing)).toBe(true);
+ expect(JURISDICTION_OPTIONS.some((option) => option.value === DEFAULT_PREFERENCES.jurisdiction)).toBe(true);
+ });
+});
diff --git a/tests/theme.test.ts b/tests/theme.test.ts
index 648c2ddc5..1d4368033 100644
--- a/tests/theme.test.ts
+++ b/tests/theme.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { APP_THEME_COLORS, nextTheme, resolveThemePreference } from "../src/lib/theme";
+import { APP_THEME_COLORS, nextTheme, readThemePreference, resolveThemePreference } from "../src/lib/theme";
describe("theme helpers", () => {
it("uses stored explicit theme before system preference", () => {
@@ -20,4 +20,15 @@ describe("theme helpers", () => {
it("keeps installed-app browser chrome aligned with both application themes", () => {
expect(APP_THEME_COLORS).toEqual({ light: "#ffffff", dark: "#060708" });
});
+
+ it("reads the appearance preference from the stored value", () => {
+ expect(readThemePreference("light")).toBe("light");
+ expect(readThemePreference("dark")).toBe("dark");
+ // No pin and stale/system values both resolve to "system" so the OS
+ // preference keeps flowing through the resolved theme.
+ expect(readThemePreference(null)).toBe("system");
+ expect(readThemePreference(undefined)).toBe("system");
+ expect(readThemePreference("system")).toBe("system");
+ expect(readThemePreference("sepia")).toBe("system");
+ });
});