Add dark mode support with persisted OS-aware theme selection

This commit is contained in:
2026-04-21 19:30:29 +02:00
parent 4f91e8fdd3
commit fef8f60ee2
11 changed files with 293 additions and 51 deletions

View File

@@ -433,7 +433,9 @@ const source = timeRange === "24h" ? "fail2ban" : "archive";
--- ---
### TASK-022 — Add dark mode support and OS preference detection ### TASK-022 — Add dark mode support and OS preference detection (done)
**Where fixed:** `frontend/src/App.tsx`, `frontend/src/providers/ThemeProvider.tsx`, `frontend/src/layouts/MainLayout.tsx`, `frontend/src/theme/customTheme.ts`, `frontend/src/components/BanTrendChart.tsx`, `frontend/src/components/TopCountriesPieChart.tsx`, `frontend/src/components/TopCountriesBarChart.tsx`, `frontend/src/components/JailDistributionChart.tsx`, `Docs/Web-Design.md`
**Where found:** `frontend/src/App.tsx` line 25 — `<FluentProvider theme={lightTheme}>` is hardcoded. `frontend/src/theme/customTheme.ts` already exports `darkTheme` but it is never used. **Where found:** `frontend/src/App.tsx` line 25 — `<FluentProvider theme={lightTheme}>` is hardcoded. `frontend/src/theme/customTheme.ts` already exports `darkTheme` but it is never used.

View File

@@ -41,6 +41,7 @@ BanGUI uses a **single custom theme** generated with the [Fluent UI Theme Design
- The primary colour must have a **contrast ratio of at least 4.5 : 1** against `white` for text and **3 : 1** for large text and UI elements. - The primary colour must have a **contrast ratio of at least 4.5 : 1** against `white` for text and **3 : 1** for large text and UI elements.
- Provide a **dark theme variant** alongside the default light theme. Both must share the same semantic slot names — only the palette values differ. - Provide a **dark theme variant** alongside the default light theme. Both must share the same semantic slot names — only the palette values differ.
- Persist the user's explicit theme choice in `localStorage` and otherwise follow the operating system's `prefers-color-scheme` setting.
- Never reference Fluent UI palette slots (`themeDarker`, `neutralLight`, etc.) directly in components. Always go through semantic slots so theme switching works seamlessly. - Never reference Fluent UI palette slots (`themeDarker`, `neutralLight`, etc.) directly in components. Always go through semantic slots so theme switching works seamlessly.
### Colour Rules ### Colour Rules

View File

@@ -22,8 +22,9 @@
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { FluentProvider, Spinner } from "@fluentui/react-components"; import { FluentProvider, Spinner } from "@fluentui/react-components";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { lightTheme } from "./theme/customTheme"; import { darkTheme, lightTheme } from "./theme/customTheme";
import { AuthProvider } from "./providers/AuthProvider"; import { AuthProvider } from "./providers/AuthProvider";
import { ThemeProvider, useThemeMode } from "./providers/ThemeProvider";
import { TimezoneProvider } from "./providers/TimezoneProvider"; import { TimezoneProvider } from "./providers/TimezoneProvider";
import { RequireAuth } from "./components/RequireAuth"; import { RequireAuth } from "./components/RequireAuth";
import { SetupGuard } from "./components/SetupGuard"; import { SetupGuard } from "./components/SetupGuard";
@@ -43,9 +44,12 @@ const BlocklistsPage = lazy(() => import("./pages/BlocklistsPage").then((m) => (
/** /**
* Root application component — mounts providers and top-level routes. * Root application component — mounts providers and top-level routes.
*/ */
function App(): React.JSX.Element { function AppContents(): React.JSX.Element {
const { colorMode } = useThemeMode();
const theme = colorMode === "dark" ? darkTheme : lightTheme;
return ( return (
<FluentProvider theme={lightTheme}> <FluentProvider theme={theme}>
<ErrorBoundary> <ErrorBoundary>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}> <BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Suspense fallback={<Spinner size="large" label="Loading…" />}> <Suspense fallback={<Spinner size="large" label="Loading…" />}>
@@ -96,4 +100,12 @@ function App(): React.JSX.Element {
); );
} }
function App(): React.JSX.Element {
return (
<ThemeProvider>
<AppContents />
</ThemeProvider>
);
}
export default App; export default App;

View File

@@ -25,6 +25,7 @@ import {
CHART_PALETTE, CHART_PALETTE,
resolveFluentToken, resolveFluentToken,
} from "../utils/chartTheme"; } from "../utils/chartTheme";
import { useThemeMode } from "../providers/ThemeProvider";
import { ChartStateWrapper } from "./ChartStateWrapper"; import { ChartStateWrapper } from "./ChartStateWrapper";
import { useBanTrend } from "../hooks/useBanTrend"; import { useBanTrend } from "../hooks/useBanTrend";
import type { BanOriginFilter, TimeRange } from "../types/ban"; import type { BanOriginFilter, TimeRange } from "../types/ban";
@@ -200,29 +201,36 @@ export const BanTrendChart = memo(function BanTrendChart({
source = "fail2ban", source = "fail2ban",
}: BanTrendChartProps): React.JSX.Element { }: BanTrendChartProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const { colorMode } = useThemeMode();
const { buckets, loading, error, reload } = useBanTrend(timeRange, origin, source); const { buckets, loading, error, reload } = useBanTrend(timeRange, origin, source);
const isEmpty = buckets.every((b) => b.count === 0); const isEmpty = buckets.every((b) => b.count === 0);
const entries = buildEntries(buckets, timeRange); const entries = buildEntries(buckets, timeRange);
const { primaryColour, axisColour, gridColour } = useMemo( const { primaryColour, axisColour, gridColour } = useMemo(
() => ({ () => {
primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""), void colorMode;
axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN), return {
gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN), primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""),
}), axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN),
[], gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN),
};
},
[colorMode],
); );
const tickInterval = TICK_INTERVAL[timeRange]; const tickInterval = TICK_INTERVAL[timeRange];
const tooltipContent = useMemo( const tooltipContent = useMemo(
() => ( () => {
<TrendTooltip void colorMode;
backgroundColor={resolveFluentToken(tokens.colorNeutralBackground1)} return (
borderColor={resolveFluentToken(tokens.colorNeutralStroke2)} <TrendTooltip
textColor={resolveFluentToken(tokens.colorNeutralForeground1)} backgroundColor={resolveFluentToken(tokens.colorNeutralBackground1)}
/> borderColor={resolveFluentToken(tokens.colorNeutralStroke2)}
), textColor={resolveFluentToken(tokens.colorNeutralForeground1)}
[], />
);
},
[colorMode],
); );
return ( return (

View File

@@ -24,6 +24,7 @@ import {
CHART_PALETTE, CHART_PALETTE,
resolveFluentToken, resolveFluentToken,
} from "../utils/chartTheme"; } from "../utils/chartTheme";
import { useThemeMode } from "../providers/ThemeProvider";
import { ChartStateWrapper } from "./ChartStateWrapper"; import { ChartStateWrapper } from "./ChartStateWrapper";
import { ChartTooltip } from "./ChartTooltip"; import { ChartTooltip } from "./ChartTooltip";
import { useJailDistribution } from "../hooks/useJailDistribution"; import { useJailDistribution } from "../hooks/useJailDistribution";
@@ -134,17 +135,21 @@ export const JailDistributionChart = memo(function JailDistributionChart({
origin, origin,
}: JailDistributionChartProps): React.JSX.Element { }: JailDistributionChartProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const { colorMode } = useThemeMode();
const { jails, loading, error, reload } = useJailDistribution(timeRange, origin); const { jails, loading, error, reload } = useJailDistribution(timeRange, origin);
const entries = buildEntries(jails); const entries = buildEntries(jails);
const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT); const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT);
const { primaryColour, axisColour, gridColour } = useMemo( const { primaryColour, axisColour, gridColour } = useMemo(
() => ({ () => {
primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""), void colorMode;
axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN), return {
gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN), primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""),
}), axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN),
[], gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN),
};
},
[colorMode],
); );
return ( return (

View File

@@ -21,6 +21,7 @@ import {
CHART_GRID_LINE_TOKEN, CHART_GRID_LINE_TOKEN,
resolveFluentToken, resolveFluentToken,
} from "../utils/chartTheme"; } from "../utils/chartTheme";
import { useThemeMode } from "../providers/ThemeProvider";
import { ChartTooltip } from "./ChartTooltip"; import { ChartTooltip } from "./ChartTooltip";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -142,16 +143,20 @@ export const TopCountriesBarChart = memo(function TopCountriesBarChart({
countryNames, countryNames,
}: TopCountriesBarChartProps): React.JSX.Element { }: TopCountriesBarChartProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const { colorMode } = useThemeMode();
const entries = buildEntries(countries, countryNames); const entries = buildEntries(countries, countryNames);
const { primaryColour, axisColour, gridColour } = useMemo( const { primaryColour, axisColour, gridColour } = useMemo(
() => ({ () => {
primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""), void colorMode;
axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN), return {
gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN), primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""),
}), axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN),
[], gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN),
};
},
[colorMode],
); );
if (entries.length === 0) { if (entries.length === 0) {

View File

@@ -17,6 +17,7 @@ import type { LegendPayload } from "recharts/types/component/DefaultLegendConten
import type { TooltipContentProps } from "recharts/types/component/Tooltip"; import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { tokens, makeStyles, Text } from "@fluentui/react-components"; import { tokens, makeStyles, Text } from "@fluentui/react-components";
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme"; import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
import { useThemeMode } from "../providers/ThemeProvider";
import { ChartTooltip } from "./ChartTooltip"; import { ChartTooltip } from "./ChartTooltip";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -134,10 +135,14 @@ export const TopCountriesPieChart = memo(function TopCountriesPieChart({
countryNames, countryNames,
}: TopCountriesPieChartProps): React.JSX.Element { }: TopCountriesPieChartProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const { colorMode } = useThemeMode();
const resolvedPalette = useMemo( const resolvedPalette = useMemo(
() => CHART_PALETTE.map(resolveFluentToken), () => {
[], void colorMode;
return CHART_PALETTE.map(resolveFluentToken);
},
[colorMode],
); );
const slices = buildSlices(countries, countryNames, resolvedPalette); const slices = buildSlices(countries, countryNames, resolvedPalette);
const total = slices.reduce((sum, s) => sum + s.value, 0); const total = slices.reduce((sum, s) => sum + s.value, 0);

View File

@@ -28,11 +28,14 @@ import {
ListRegular, ListRegular,
SignOutRegular, SignOutRegular,
NavigationRegular, NavigationRegular,
WeatherMoonRegular,
WeatherSunnyRegular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { useServerStatus } from "../hooks/useServerStatus"; import { useServerStatus } from "../hooks/useServerStatus";
import { useBlocklistStatus } from "../hooks/useBlocklist"; import { useBlocklistStatus } from "../hooks/useBlocklist";
import { useThemeMode } from "../providers/ThemeProvider";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Styles // Styles
@@ -146,11 +149,20 @@ const useStyles = makeStyles({
padding: tokens.spacingVerticalS, padding: tokens.spacingVerticalS,
flexShrink: 0, flexShrink: 0,
}, },
footerActions: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXS,
},
themeButton: {
width: "100%",
justifyContent: "flex-start",
},
logoutButton: { logoutButton: {
width: "100%", width: "100%",
justifyContent: "flex-start", justifyContent: "flex-start",
}, },
logoutButtonCollapsed: { sidebarButtonCollapsed: {
justifyContent: "center", justifyContent: "center",
}, },
versionText: { versionText: {
@@ -220,6 +232,7 @@ const NAV_ITEMS: NavItem[] = [
export function MainLayout(): React.JSX.Element { export function MainLayout(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const { logout } = useAuth(); const { logout } = useAuth();
const { colorMode, toggleColorMode } = useThemeMode();
const navigate = useNavigate(); const navigate = useNavigate();
const readSavedCollapsed = (): boolean => { const readSavedCollapsed = (): boolean => {
@@ -346,31 +359,51 @@ export function MainLayout(): React.JSX.Element {
})} })}
</ul> </ul>
{/* Footer — Logout */} {/* Footer — Theme toggle and logout */}
<div className={styles.sidebarFooter}> <div className={styles.sidebarFooter}>
{!collapsed && ( {!collapsed && (
<Text className={styles.versionText}> <Text className={styles.versionText}>
BanGUI BanGUI
</Text> </Text>
)} )}
<Tooltip <div className={styles.footerActions}>
content={collapsed ? "Sign out" : ""} <Tooltip
relationship="label" content={collapsed ? (colorMode === "dark" ? "Switch to light mode" : "Switch to dark mode") : ""}
positioning="after" relationship="label"
> positioning="after"
<Button
appearance="subtle"
icon={<SignOutRegular />}
onClick={() => void handleLogout()}
aria-label="Sign out"
className={mergeClasses(
styles.logoutButton,
collapsed && styles.logoutButtonCollapsed,
)}
> >
{!collapsed && "Sign out"} <Button
</Button> appearance="subtle"
</Tooltip> icon={colorMode === "dark" ? <WeatherSunnyRegular /> : <WeatherMoonRegular />}
onClick={toggleColorMode}
aria-label={colorMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
className={mergeClasses(
styles.themeButton,
collapsed && styles.sidebarButtonCollapsed,
)}
>
{!collapsed && (colorMode === "dark" ? "Light mode" : "Dark mode")}
</Button>
</Tooltip>
<Tooltip
content={collapsed ? "Sign out" : ""}
relationship="label"
positioning="after"
>
<Button
appearance="subtle"
icon={<SignOutRegular />}
onClick={() => void handleLogout()}
aria-label="Sign out"
className={mergeClasses(
styles.logoutButton,
collapsed && styles.sidebarButtonCollapsed,
)}
>
{!collapsed && "Sign out"}
</Button>
</Tooltip>
</div>
</div> </div>
</nav> </nav>

View File

@@ -89,4 +89,9 @@ describe("MainLayout", () => {
await userEvent.click(toggleButton); await userEvent.click(toggleButton);
expect(localStorage.getItem("bangui_sidebar_collapsed")).toBe("true"); expect(localStorage.getItem("bangui_sidebar_collapsed")).toBe("true");
}); });
it("renders a theme toggle button in the sidebar footer", () => {
renderLayout();
expect(screen.getByRole("button", { name: /switch to dark mode/i })).toBeInTheDocument();
});
}); });

View File

@@ -0,0 +1,98 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
type ThemeMode = "light" | "dark";
interface ThemeProviderContext {
colorMode: ThemeMode;
toggleColorMode: () => void;
setColorMode: (mode: ThemeMode) => void;
hasExplicitPreference: boolean;
}
const THEME_STORAGE_KEY = "bangui_theme";
const ThemeModeContext = createContext<ThemeProviderContext | null>(null);
function readSavedTheme(): ThemeMode | null {
try {
const value = localStorage.getItem(THEME_STORAGE_KEY);
if (value === "dark" || value === "light") {
return value;
}
} catch {
// Ignore localStorage failures.
}
return null;
}
function getSystemTheme(): ThemeMode {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps): React.JSX.Element {
const savedTheme = readSavedTheme();
const [colorMode, setColorModeState] = useState<ThemeMode>(() => savedTheme ?? getSystemTheme());
const [hasExplicitPreference, setHasExplicitPreference] = useState<boolean>(() => savedTheme !== null);
const setColorMode = useCallback((mode: ThemeMode): void => {
try {
localStorage.setItem(THEME_STORAGE_KEY, mode);
} catch {
// Ignore storage failures.
}
setHasExplicitPreference(true);
setColorModeState(mode);
}, []);
const toggleColorMode = useCallback((): void => {
setColorMode(colorMode === "dark" ? "light" : "dark");
}, [colorMode, setColorMode]);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (event: MediaQueryListEvent): void => {
if (!hasExplicitPreference) {
setColorModeState(event.matches ? "dark" : "light");
}
};
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handleChange);
function cleanup(): void {
mediaQuery.removeEventListener("change", handleChange);
}
return cleanup;
}
return undefined;
}, [hasExplicitPreference]);
const value = useMemo(
() => ({ colorMode, toggleColorMode, setColorMode, hasExplicitPreference }),
[colorMode, toggleColorMode, setColorMode, hasExplicitPreference],
);
return <ThemeModeContext.Provider value={value}>{children}</ThemeModeContext.Provider>;
}
export function useThemeMode(): ThemeProviderContext {
const context = useContext(ThemeModeContext);
if (context != null) {
return context;
}
return {
colorMode: "light",
toggleColorMode: (): void => {
/* no-op when provider is missing */
},
setColorMode: (): void => {
/* no-op when provider is missing */
},
hasExplicitPreference: false,
};
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, it, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ThemeProvider, useThemeMode } from "../ThemeProvider";
function ThemeConsumer(): React.JSX.Element {
const { colorMode, toggleColorMode } = useThemeMode();
return (
<div>
<span data-testid="color-mode">{colorMode}</span>
<button type="button" onClick={toggleColorMode}>
Toggle
</button>
</div>
);
}
function renderWithProvider(): void {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
}
describe("ThemeProvider", () => {
beforeEach(() => {
vi.restoreAllMocks();
localStorage.clear();
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
});
});
it("uses the saved theme from localStorage when present", () => {
localStorage.setItem("bangui_theme", "dark");
renderWithProvider();
expect(screen.getByTestId("color-mode")).toHaveTextContent("dark");
});
it("falls back to the OS preference when no explicit theme is saved", () => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: vi.fn().mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
});
renderWithProvider();
expect(screen.getByTestId("color-mode")).toHaveTextContent("dark");
});
it("persists the user selection to localStorage when toggled", async () => {
renderWithProvider();
expect(screen.getByTestId("color-mode")).toHaveTextContent("light");
await screen.getByRole("button", { name: /toggle/i }).click();
expect(screen.getByTestId("color-mode")).toHaveTextContent("dark");
expect(localStorage.getItem("bangui_theme")).toBe("dark");
});
});