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} + + + + + + + {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"); }