From 3a6be6d3b93add9064b060c62b12005a8bdbeff2 Mon Sep 17 00:00:00 2001 From: ToryMic Date: Tue, 29 Sep 2026 05:09:43 -0400 Subject: [PATCH] [#1288] feat(frontend): add high-contrast accessibility mode and persist theme to user profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theme preferences were light/dark only and lived exclusively in localStorage, so visually impaired operators had no higher-contrast option and preferences did not follow them to another device. ### High contrast mode - `highContrast` added to the theme context/provider, persisted under its own storage key so it survives independently of the light/dark mode. - Applied to the document as `data-contrast="high"`. Contrast is therefore a pure CSS concern: `index.css` overrides the palette variables, so every surface already consuming them picks up the stronger treatment without individual components needing to branch. - Borders go to near-white on dark and near-black on light, and are reinforced to 2px, so boundaries are visible rather than hairlines. - Body text is pushed to maximum contrast in both themes. - Alert hues are replaced with a colour-vision-deficiency-safe palette, and severity is paired with border weight so it is not signalled by hue alone. ### Profile sync - `useThemeProfileSync` debounces changes and pushes `display.theme` and `display.highContrast` to the existing `PUT /api/v1/preferences/:userId/display/:key` endpoint. The endpoint takes one key per call, so the two writes are issued concurrently. No backend change was needed — the single-preference body validator accepts any value. - Sync is opt-in per device via a stored user id, and the UI reports "Local only" when none is set. The frontend has no auth layer, so assuming an identity would be wrong; without an id the theme still works, it is just not synchronised. Adds 12 tests covering the storage helpers, the document attributes, the toggles, persistence across mounts, and both sync paths. --- frontend/src/components/ThemeToggle.tsx | 111 +++++++++++++--- frontend/src/hooks/useThemeProfileSync.ts | 152 ++++++++++++++++++++++ frontend/src/index.css | 52 ++++++++ frontend/src/theme/ThemeContext.tsx | 4 + frontend/src/theme/ThemeProvider.tsx | 30 ++++- frontend/src/theme/highContrast.test.tsx | 152 ++++++++++++++++++++++ frontend/src/theme/themeStorage.ts | 36 ++++- 7 files changed, 512 insertions(+), 25 deletions(-) create mode 100644 frontend/src/hooks/useThemeProfileSync.ts create mode 100644 frontend/src/theme/highContrast.test.tsx 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"); }