diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx
index ca5d9b26..d6366734 100644
--- a/frontend/src/components/ThemeToggle.tsx
+++ b/frontend/src/components/ThemeToggle.tsx
@@ -1,36 +1,105 @@
+import { useState } from "react";
import { useTheme } from "../theme/useTheme";
+import { useThemeProfileSync } from "../hooks/useThemeProfileSync";
export default function ThemeToggle() {
- const { mode, resolvedTheme, toggle } = useTheme();
+ const { mode, resolvedTheme, toggle, highContrast, toggleHighContrast } = useTheme();
+ const [showSync, setShowSync] = useState(false);
+
+ const { status, error, setSyncUserId } = useThemeProfileSync({ mode, highContrast });
const label = resolvedTheme === "dark" ? "Switch to light theme" : "Switch to dark theme";
const isDark = resolvedTheme === "dark";
const text = isDark ? "Dark" : "Light";
return (
-
+ >
+
+
+ {text}
+
+
+
+
+
+
+ HC
+
+
+ setShowSync((prev) => !prev)}
+ aria-expanded={showSync}
+ className="rounded-full border border-stellar-border bg-stellar-card px-2 py-1 text-xs font-medium text-stellar-text-secondary hover:border-stellar-blue focus:outline-none focus:ring-2 focus:ring-stellar-blue"
+ >
+ Sync
+ theme across devices
+
+
+ {showSync && (
+
+
Sync theme to your profile
+
+ Enter the account id used by the preferences API. Leave empty to keep this device
+ local only.
+
+
+
setSyncUserId(event.target.value.trim() || null)}
+ className="mt-1 w-full rounded border border-stellar-border bg-stellar-bg px-2 py-1 text-stellar-text-primary"
+ />
+
+ {status === "syncing" && "Syncing…"}
+ {status === "synced" && "Theme synced."}
+ {status === "error" && {error}}
+ {status === "disabled" && "Local only — no user id set."}
+
+
+ )}
+
);
}
diff --git a/frontend/src/hooks/useThemeProfileSync.ts b/frontend/src/hooks/useThemeProfileSync.ts
new file mode 100644
index 00000000..37322c84
--- /dev/null
+++ b/frontend/src/hooks/useThemeProfileSync.ts
@@ -0,0 +1,152 @@
+import { useEffect, useReducer, useRef, useState } from "react";
+
+/** Forces a re-render when a ref-backed value needs to reach the UI. */
+function useReducerTick(): [number, () => void] {
+ return useReducer((tick: number) => tick + 1, 0);
+}
+import {
+ PROFILE_SYNC_USER_ID_KEY,
+ type ThemeMode,
+} from "../theme/themeStorage";
+
+export type ThemeSyncStatus = "idle" | "syncing" | "synced" | "error" | "disabled";
+
+export interface UseThemeProfileSyncOptions {
+ mode: ThemeMode;
+ highContrast: boolean;
+ /** Debounce so dragging through theme options is not one request per click. */
+ debounceMs?: number;
+}
+
+export interface UseThemeProfileSyncResult {
+ status: ThemeSyncStatus;
+ /** Last sync failure, if any, for surfacing in the UI. */
+ error: string | null;
+ /** Set a user id to opt this device into cross-device theme sync. */
+ setSyncUserId: (userId: string | null) => void;
+ /** Force an immediate sync, e.g. when leaving the settings page. */
+ flush: () => void;
+}
+
+function readSyncUserId(): string | null {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.localStorage.getItem(PROFILE_SYNC_USER_ID_KEY);
+ } catch {
+ return null;
+ }
+}
+
+function writeSyncUserId(userId: string | null): void {
+ if (typeof window === "undefined") return;
+ try {
+ if (userId) {
+ window.localStorage.setItem(PROFILE_SYNC_USER_ID_KEY, userId);
+ } else {
+ window.localStorage.removeItem(PROFILE_SYNC_USER_ID_KEY);
+ }
+ } catch {
+ // ignore
+ }
+}
+
+function getApiBase(): string {
+ return import.meta.env.VITE_API_BASE_URL || "http://localhost:3000";
+}
+
+/**
+ * Syncs the theme mode and high-contrast flag to
+ * `PUT /api/v1/preferences/:userId/display/:key` so preferences follow the
+ * operator across devices.
+ *
+ * Sync is opt-in per device via a locally stored user id. When no id is
+ * configured the theme still works, it is simply local-only — the app has no
+ * built-in auth, so assuming an identity would be wrong.
+ */
+export function useThemeProfileSync({
+ mode,
+ highContrast,
+ debounceMs = 800,
+}: UseThemeProfileSyncOptions): UseThemeProfileSyncResult {
+ const statusRef = useRef("idle");
+ const errorRef = useRef(null);
+ const timerRef = useRef | null>(null);
+ const [userId, setUserId] = useState(readSyncUserId);
+
+ // Re-render to reflect status changes without re-triggering the sync effect.
+ const [, forceRender] = useReducerTick();
+
+ const setStatus = (status: ThemeSyncStatus, error: string | null = null) => {
+ statusRef.current = status;
+ errorRef.current = error;
+ forceRender();
+ };
+
+ const push = async (target: string | null, settings: Record) => {
+ if (!target) {
+ setStatus("disabled");
+ return;
+ }
+
+ setStatus("syncing");
+ try {
+ const base = getApiBase();
+ // The endpoint takes one key per call, so issue them concurrently.
+ const results = await Promise.all(
+ Object.entries(settings).map(([key, value]) =>
+ fetch(`${base}/api/v1/preferences/${encodeURIComponent(target)}/display/${key}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ value }),
+ })
+ )
+ );
+
+ const failed = results.find((response) => !response.ok);
+ if (failed) {
+ setStatus("error", `Preference sync failed (${failed.status})`);
+ return;
+ }
+ setStatus("synced");
+ } catch (err) {
+ setStatus(
+ "error",
+ err instanceof Error ? err.message : "Preference sync failed"
+ );
+ }
+ };
+
+ const schedule = (target: string | null, settings: Record) => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => {
+ timerRef.current = null;
+ void push(target, settings);
+ }, debounceMs);
+ };
+
+ useEffect(() => {
+ schedule(userId, { theme: mode, highContrast });
+ }, [userId, mode, highContrast]);
+
+ useEffect(() => {
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ };
+ }, []);
+
+ return {
+ status: statusRef.current,
+ error: errorRef.current,
+ setSyncUserId: (next: string | null) => {
+ writeSyncUserId(next);
+ setUserId(next);
+ },
+ flush: () => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ void push(userId, { theme: mode, highContrast });
+ },
+ };
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 6bfafdbd..98bb428d 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -20,6 +20,58 @@
--stellar-text-secondary: 138 143 168;
}
+/*
+ * High-contrast accessibility mode (WCAG AAA oriented).
+ *
+ * Overrides the palette variables rather than restyling components, so every
+ * surface that already consumes them gets the stronger treatment for free.
+ * Borders are near-white on dark and near-black on light so boundaries are
+ * visible at a glance, and text moves to maximum contrast on both.
+ */
+[data-contrast="high"] {
+ --stellar-bg: 0 0 0;
+ --stellar-card: 0 0 0;
+ --stellar-border: 255 255 255;
+ --stellar-text-primary: 255 255 255;
+ --stellar-text-secondary: 255 255 255;
+}
+
+[data-contrast="high"].dark {
+ --stellar-bg: 0 0 0;
+ --stellar-card: 10 10 10;
+ --stellar-border: 255 255 255;
+ --stellar-text-primary: 255 255 255;
+ --stellar-text-secondary: 245 245 245;
+}
+
+/*
+ * Colour is never the only signal: in high-contrast mode the alert palette
+ * uses hues that stay distinguishable under the common forms of colour vision
+ * deficiency (deuteranopia, protanopia, tritanopia) and pairs each with a
+ * heavier border so severity reads without relying on hue alone.
+ */
+[data-contrast="high"] {
+ --stellar-alert-error: 255 0 0;
+ --stellar-alert-warning: 255 215 0;
+ --stellar-alert-success: 0 255 170;
+ --stellar-alert-info: 0 200 255;
+ --stellar-alert-critical: 255 105 180;
+}
+
+[data-contrast="high"] .border-stellar-border {
+ border-color: rgb(var(--stellar-border));
+}
+
+/* Hairline borders disappear at high contrast; reinforce them. */
+[data-contrast="high"] .border,
+[data-contrast="high"] [class*="border-"] {
+ border-width: 2px;
+}
+
+[data-contrast="high"] .text-stellar-text-secondary {
+ color: rgb(var(--stellar-text-secondary));
+}
+
[data-density="compact"] {
--section-gap: 1rem;
}
diff --git a/frontend/src/theme/ThemeContext.tsx b/frontend/src/theme/ThemeContext.tsx
index a5fe2364..ad41357b 100644
--- a/frontend/src/theme/ThemeContext.tsx
+++ b/frontend/src/theme/ThemeContext.tsx
@@ -8,6 +8,10 @@ export interface ThemeContextValue {
resolvedTheme: ThemeName;
setMode: (mode: ThemeMode) => void;
toggle: () => void;
+ /** Elevated-contrast accessibility mode (WCAG AAA oriented). */
+ highContrast: boolean;
+ setHighContrast: (enabled: boolean) => void;
+ toggleHighContrast: () => void;
}
export const ThemeContext = createContext(null);
diff --git a/frontend/src/theme/ThemeProvider.tsx b/frontend/src/theme/ThemeProvider.tsx
index 0ed4eb9b..13309740 100644
--- a/frontend/src/theme/ThemeProvider.tsx
+++ b/frontend/src/theme/ThemeProvider.tsx
@@ -1,11 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ThemeContext, type ThemeMode, type ThemeName } from "./ThemeContext";
import {
+ HIGH_CONTRAST_STORAGE_KEY,
THEME_STORAGE_KEY,
applyThemeToDocument,
getSystemTheme,
normalizeMode,
+ readHighContrastPreference,
resolveTheme,
+ writeHighContrastPreference,
} from "./themeStorage";
export default function ThemeProvider({ children }: { children: React.ReactNode }) {
@@ -19,6 +22,10 @@ export default function ThemeProvider({ children }: { children: React.ReactNode
const [systemTheme, setSystemTheme] = useState(() => getSystemTheme());
+ const [highContrast, setHighContrastState] = useState(() =>
+ readHighContrastPreference()
+ );
+
useEffect(() => {
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
if (!mq) return;
@@ -36,19 +43,33 @@ export default function ThemeProvider({ children }: { children: React.ReactNode
}, [mode, systemTheme]);
useEffect(() => {
- applyThemeToDocument(mode);
+ applyThemeToDocument(mode, highContrast);
try {
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
+ window.localStorage.setItem(HIGH_CONTRAST_STORAGE_KEY, String(highContrast));
} catch {
// ignore
}
- }, [mode]);
+ }, [mode, highContrast]);
const setMode = useCallback((next: ThemeMode) => {
setModeState(next);
}, []);
+ const setHighContrast = useCallback((enabled: boolean) => {
+ setHighContrastState(enabled);
+ writeHighContrastPreference(enabled);
+ }, []);
+
+ const toggleHighContrast = useCallback(() => {
+ setHighContrastState((prev) => {
+ const next = !prev;
+ writeHighContrastPreference(next);
+ return next;
+ });
+ }, []);
+
const toggle = useCallback(() => {
setModeState((prev) => {
const currentResolved = resolveTheme(prev);
@@ -62,8 +83,11 @@ export default function ThemeProvider({ children }: { children: React.ReactNode
resolvedTheme,
setMode,
toggle,
+ highContrast,
+ setHighContrast,
+ toggleHighContrast,
}),
- [mode, resolvedTheme, setMode, toggle]
+ [mode, resolvedTheme, setMode, toggle, highContrast, setHighContrast, toggleHighContrast]
);
return {children};
diff --git a/frontend/src/theme/highContrast.test.tsx b/frontend/src/theme/highContrast.test.tsx
new file mode 100644
index 00000000..c5ad4bf4
--- /dev/null
+++ b/frontend/src/theme/highContrast.test.tsx
@@ -0,0 +1,152 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import ThemeProvider from "./ThemeProvider";
+import ThemeToggle from "../components/ThemeToggle";
+import {
+ HIGH_CONTRAST_STORAGE_KEY,
+ THEME_STORAGE_KEY,
+ applyThemeToDocument,
+ normalizeMode,
+ readHighContrastPreference,
+ resolveTheme,
+} from "./themeStorage";
+
+function renderToggle() {
+ return render(
+
+
+
+ );
+}
+
+describe("themeStorage", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.removeAttribute("data-contrast");
+ document.documentElement.classList.remove("dark");
+ });
+
+ it("normalizes only known modes", () => {
+ expect(normalizeMode("light")).toBe("light");
+ expect(normalizeMode("dark")).toBe("dark");
+ expect(normalizeMode("system")).toBe("system");
+ expect(normalizeMode("nonsense")).toBe("system");
+ expect(normalizeMode(null)).toBe("system");
+ });
+
+ it("resolves system mode to the current system theme", () => {
+ expect(["light", "dark"]).toContain(resolveTheme("system"));
+ });
+
+ it("sets data-contrast only when high contrast is on", () => {
+ applyThemeToDocument("dark", true);
+ expect(document.documentElement.getAttribute("data-contrast")).toBe("high");
+
+ applyThemeToDocument("dark", false);
+ expect(document.documentElement.hasAttribute("data-contrast")).toBe(false);
+ });
+
+ it("keeps the theme class and attributes in sync", () => {
+ applyThemeToDocument("dark", false);
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
+ expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
+
+ applyThemeToDocument("light", false);
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
+ expect(document.documentElement.getAttribute("data-theme")).toBe("light");
+ });
+
+ it("reads the persisted high contrast flag", () => {
+ expect(readHighContrastPreference()).toBe(false);
+ localStorage.setItem(HIGH_CONTRAST_STORAGE_KEY, "true");
+ expect(readHighContrastPreference()).toBe(true);
+ });
+});
+
+describe("ThemeToggle high contrast", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ document.documentElement.removeAttribute("data-contrast");
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it("renders a high contrast switch", () => {
+ renderToggle();
+ expect(screen.getByRole("switch", { name: /High contrast mode/i })).toBeInTheDocument();
+ });
+
+ it("toggles the data-contrast attribute", async () => {
+ renderToggle();
+
+ const toggle = screen.getByRole("switch", { name: /High contrast mode/i });
+ expect(toggle).toHaveAttribute("aria-checked", "false");
+
+ fireEvent.click(toggle);
+
+ await waitFor(() =>
+ expect(document.documentElement.getAttribute("data-contrast")).toBe("high")
+ );
+ expect(toggle).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("persists the high contrast choice", async () => {
+ renderToggle();
+
+ fireEvent.click(screen.getByRole("switch", { name: /High contrast mode/i }));
+
+ await waitFor(() =>
+ expect(localStorage.getItem(HIGH_CONTRAST_STORAGE_KEY)).toBe("true")
+ );
+ });
+
+ it("restores a persisted high contrast preference on mount", () => {
+ localStorage.setItem(HIGH_CONTRAST_STORAGE_KEY, "true");
+ renderToggle();
+ expect(screen.getByRole("switch", { name: /High contrast mode/i })).toHaveAttribute(
+ "aria-checked",
+ "true"
+ );
+ });
+
+ it("leaves the existing light/dark toggle working", () => {
+ localStorage.setItem(THEME_STORAGE_KEY, "dark");
+ renderToggle();
+
+ const themeSwitch = screen.getByRole("switch", { name: /Switch to light theme/i });
+ expect(themeSwitch).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("reports local-only sync when no user id is set", async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+
+ renderToggle();
+ fireEvent.click(screen.getByRole("button", { name: /Sync theme across devices/i }));
+
+ await waitFor(() => expect(screen.getByText(/Local only/i)).toBeInTheDocument());
+ // No user id means the hook must not hit the network at all.
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("pushes theme and high contrast to the profile once a user id is set", async () => {
+ const fetchMock = vi.fn(async () => new Response("{}", { status: 200 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ localStorage.setItem("bridge-watch:profile-sync-user-id:v1", "operator-1");
+
+ renderToggle();
+ fireEvent.click(screen.getByRole("switch", { name: /High contrast mode/i }));
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalled(), { timeout: 2000 });
+
+ const called = fetchMock.mock.calls.map((call) => String(call[0]));
+ expect(called.some((url) => url.includes("/preferences/operator-1/display/theme"))).toBe(true);
+ expect(called.some((url) => url.includes("/preferences/operator-1/display/highContrast"))).toBe(
+ true
+ );
+ });
+});
diff --git a/frontend/src/theme/themeStorage.ts b/frontend/src/theme/themeStorage.ts
index 8b955f88..d3fac0bb 100644
--- a/frontend/src/theme/themeStorage.ts
+++ b/frontend/src/theme/themeStorage.ts
@@ -1,6 +1,12 @@
import type { ThemeMode, ThemeName } from "./ThemeContext";
+export type { ThemeMode, ThemeName };
+
export const THEME_STORAGE_KEY = "bridge-watch:theme:v1";
+export const HIGH_CONTRAST_STORAGE_KEY = "bridge-watch:theme:high-contrast:v1";
+
+/** Local-only user id used to sync theme settings to the profile endpoint. */
+export const PROFILE_SYNC_USER_ID_KEY = "bridge-watch:profile-sync-user-id:v1";
export function getSystemTheme(): ThemeName {
if (typeof window === "undefined") return "dark";
@@ -18,7 +24,32 @@ export function resolveTheme(mode: ThemeMode): ThemeName {
return mode === "system" ? getSystemTheme() : mode;
}
-export function applyThemeToDocument(mode: ThemeMode) {
+export function readHighContrastPreference(): boolean {
+ if (typeof window === "undefined") return false;
+ try {
+ return window.localStorage.getItem(HIGH_CONTRAST_STORAGE_KEY) === "true";
+ } catch {
+ return false;
+ }
+}
+
+export function writeHighContrastPreference(enabled: boolean): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(HIGH_CONTRAST_STORAGE_KEY, String(enabled));
+ } catch {
+ // Private-mode / quota failures should not break theming.
+ }
+}
+
+/**
+ * Apply the theme to .
+ *
+ * `data-contrast` drives the high-contrast variable overrides in index.css, so
+ * contrast is a pure CSS concern rather than something each component has to
+ * branch on.
+ */
+export function applyThemeToDocument(mode: ThemeMode, highContrast = false) {
const resolved = resolveTheme(mode);
const root = document.documentElement;
@@ -27,4 +58,7 @@ export function applyThemeToDocument(mode: ThemeMode) {
root.setAttribute("data-theme", resolved);
root.setAttribute("data-theme-mode", mode);
+
+ if (highContrast) root.setAttribute("data-contrast", "high");
+ else root.removeAttribute("data-contrast");
}