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
111 changes: 90 additions & 21 deletions frontend/src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={toggle}
role="switch"
aria-checked={isDark}
className="inline-flex items-center gap-2 rounded-full border border-stellar-border bg-stellar-card px-2 py-1 text-xs font-medium text-stellar-text-primary hover:border-stellar-blue focus:outline-none focus:ring-2 focus:ring-stellar-blue"
aria-label={label}
title={mode === "system" ? `Theme: ${text} (System)` : `Theme: ${text}`}
>
<span
aria-hidden="true"
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-stellar-border transition-colors ${
isDark ? "bg-stellar-blue" : "bg-stellar-border/60"
}`}
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggle}
role="switch"
aria-checked={isDark}
className="inline-flex items-center gap-2 rounded-full border border-stellar-border bg-stellar-card px-2 py-1 text-xs font-medium text-stellar-text-primary hover:border-stellar-blue focus:outline-none focus:ring-2 focus:ring-stellar-blue"
aria-label={label}
title={mode === "system" ? `Theme: ${text} (System)` : `Theme: ${text}`}
>
<span
aria-hidden="true"
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
isDark ? "translate-x-4" : "translate-x-0.5"
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-stellar-border transition-colors ${
isDark ? "bg-stellar-blue" : "bg-stellar-border/60"
}`}
/>
</span>
<span className="tabular-nums">{text}</span>
</button>
>
<span
aria-hidden="true"
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
isDark ? "translate-x-4" : "translate-x-0.5"
}`}
/>
</span>
<span className="tabular-nums">{text}</span>
</button>

<button
type="button"
onClick={toggleHighContrast}
role="switch"
aria-checked={highContrast}
className="inline-flex items-center gap-2 rounded-full border border-stellar-border bg-stellar-card px-2 py-1 text-xs font-medium text-stellar-text-primary hover:border-stellar-blue focus:outline-none focus:ring-2 focus:ring-stellar-blue"
aria-label="High contrast mode"
title="High contrast mode (WCAG AAA)"
>
<span
aria-hidden="true"
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-stellar-border transition-colors ${
highContrast ? "bg-stellar-blue" : "bg-stellar-border/60"
}`}
>
<span
aria-hidden="true"
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
highContrast ? "translate-x-4" : "translate-x-0.5"
}`}
/>
</span>
<span className="tabular-nums">HC</span>
</button>

<button
type="button"
onClick={() => 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
<span className="sr-only"> theme across devices</span>
</button>

{showSync && (
<div className="absolute right-4 top-16 z-50 w-72 rounded-lg border border-stellar-border bg-stellar-card p-3 text-xs shadow-xl">
<p className="font-medium text-stellar-text-primary">Sync theme to your profile</p>
<p className="mt-1 text-stellar-text-secondary">
Enter the account id used by the preferences API. Leave empty to keep this device
local only.
</p>
<label className="mt-2 block text-stellar-text-secondary" htmlFor="theme-sync-user-id">
User id
</label>
<input
id="theme-sync-user-id"
type="text"
defaultValue=""
placeholder="e.g. operator-1"
onBlur={(event) => 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"
/>
<p className="mt-2" role="status" aria-live="polite">
{status === "syncing" && "Syncing…"}
{status === "synced" && "Theme synced."}
{status === "error" && <span className="text-red-400">{error}</span>}
{status === "disabled" && "Local only — no user id set."}
</p>
</div>
)}
</div>
);
}
152 changes: 152 additions & 0 deletions frontend/src/hooks/useThemeProfileSync.ts
Original file line number Diff line number Diff line change
@@ -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<ThemeSyncStatus>("idle");
const errorRef = useRef<string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [userId, setUserId] = useState<string | null>(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<string, unknown>) => {
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<string, unknown>) => {
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 });
},
};
}
52 changes: 52 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/theme/ThemeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThemeContextValue | null>(null);
Loading
Loading