diff --git a/job-tracker-ui/app/layout.tsx b/job-tracker-ui/app/layout.tsx
index 5f6b4be..0b256b4 100644
--- a/job-tracker-ui/app/layout.tsx
+++ b/job-tracker-ui/app/layout.tsx
@@ -1,6 +1,8 @@
import type { Metadata, Viewport } from "next";
+import Script from "next/script";
import "../src/index.css";
+import { THEME_BOOTSTRAP_SCRIPT } from "../src/themeBootstrap";
export const metadata: Metadata = {
title: "Jobbjakt",
@@ -23,8 +25,9 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
+
+
diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx
index dd66feb..707e19c 100644
--- a/job-tracker-ui/src/App.tsx
+++ b/job-tracker-ui/src/App.tsx
@@ -1,8 +1,7 @@
-import React, { Suspense, lazy, useEffect, useMemo, useState } from "react";
+import React, { Suspense, createContext, lazy, useCallback, useContext, useEffect, useMemo, useState } from "react";
-import { Box, Button, CssBaseline, IconButton, Typography } from "@mui/material";
+import { Box, Button, IconButton, Typography } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
-import { CssVarsProvider } from "@mui/material/styles";
import DashboardIcon from "@mui/icons-material/Dashboard";
import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
@@ -20,7 +19,6 @@ import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom";
-import { getTheme } from "./theme";
import { ToastProvider } from "./toast";
import { ConfirmProvider } from "./confirm";
import { PromptProvider } from "./prompt";
@@ -40,7 +38,8 @@ import { resolveCaptureUrl } from "./captureUrl";
import { clearAuthClientState, setAuthUserKey } from "./auth";
import { AccountPlanProvider } from "./accountPlan";
import AppShell, { NavItem } from "./layout/AppShell";
-import { getThemeModePref, setThemeModePref, ThemeModePref } from "./themePrefs";
+import { getThemeModePref, setThemeModePref, subscribeToThemePreferenceChanges, ThemeModePref } from "./themePrefs";
+import JobTrackerThemeProvider from "./ThemeProvider";
const AddJobModal = lazy(() => import("./components/AddJobModal"));
const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
@@ -66,6 +65,8 @@ const OperationsPage = lazy(() => import("./views/OperationsPage"));
const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
type AuthConfig = { requireAuth: boolean };
+type ThemeControls = { themeMode: ThemeModePref; onThemeModeChange: (value: ThemeModePref) => void };
+const ThemeControlsContext = createContext(null);
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
@@ -383,19 +384,23 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
);
}
+function ThemeAwareShell(props: Omit, "themeMode" | "onThemeModeChange">) {
+ const controls = useContext(ThemeControlsContext);
+ if (!controls) throw new Error("Theme controls are unavailable.");
+ return ;
+}
+
export default function App() {
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
const [themeMode, setThemeMode] = useState(() => getThemeModePref());
- const effectiveMode: "light" | "dark" = themeMode === "light" ? "light" : themeMode === "dark" ? "dark" : systemPrefersDark ? "dark" : "light";
- const theme = useMemo(() => getTheme(effectiveMode), [effectiveMode]);
useEffect(() => {
const sync = () => { setThemeMode(getThemeModePref()); };
- window.addEventListener("auth-changed", sync);
- return () => window.removeEventListener("auth-changed", sync);
+ return subscribeToThemePreferenceChanges(sync);
}, []);
- const onThemeModeChange = (v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); };
+ const onThemeModeChange = useCallback((v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); }, []);
+ const themeControls = useMemo(() => ({ themeMode, onThemeModeChange }), [onThemeModeChange, themeMode]);
const [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => {
const raw = window.localStorage.getItem("jobPageSize");
@@ -423,19 +428,20 @@ export default function App() {
{ path: "/confirm-email-change", element: , errorElement: },
{ path: "/microsoft-legacy-relink", element: , errorElement: },
{ path: "/cv/:slug", element: , errorElement: },
- { path: "/*", element: , errorElement: },
- ], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
+ { path: "/*", element: , errorElement: },
+ ], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize]);
return (
-
-
+
-
+
+
+
-
+
diff --git a/job-tracker-ui/src/ThemeProvider.tsx b/job-tracker-ui/src/ThemeProvider.tsx
new file mode 100644
index 0000000..3ac4ad1
--- /dev/null
+++ b/job-tracker-ui/src/ThemeProvider.tsx
@@ -0,0 +1,25 @@
+import React, { useLayoutEffect, useMemo } from "react";
+import { CssBaseline } from "@mui/material";
+import { CssVarsProvider, useColorScheme } from "@mui/material/styles";
+
+import { getTheme } from "./theme";
+import { resolveEffectiveThemeMode, ThemeModePref } from "./themePrefs";
+
+function ThemeModeSynchronizer({ mode }: { mode: "light" | "dark" }) {
+ const { setMode } = useColorScheme();
+ useLayoutEffect(() => { setMode(mode); }, [mode, setMode]);
+ return null;
+}
+
+export default function JobTrackerThemeProvider({ preference, systemPrefersDark, children }: { preference: ThemeModePref; systemPrefersDark: boolean; children: React.ReactNode }) {
+ const mode = resolveEffectiveThemeMode(preference, systemPrefersDark);
+ const theme = useMemo(() => getTheme("light"), []);
+
+ return (
+
+
+
+ {children}
+
+ );
+}
diff --git a/job-tracker-ui/src/auth.ts b/job-tracker-ui/src/auth.ts
index 8d047f3..c4cbb1f 100644
--- a/job-tracker-ui/src/auth.ts
+++ b/job-tracker-ui/src/auth.ts
@@ -2,6 +2,7 @@ export const AUTH_REMEMBER_ME_KEY = "authRememberMe";
const AUTH_PERSISTENCE_KEY = "authTokenPersistence";
const AUTH_USER_KEY = "authUserKey";
const AUTH_CSRF_COOKIE = "XSRF-TOKEN";
+export const AUTH_USER_CHANGED_EVENT = "auth-user-changed";
export type AuthPersistence = "local" | "session";
@@ -72,12 +73,14 @@ export function getAuthUserKey(): string {
}
export function setAuthUserKey(value: string | null | undefined, emit = true) {
+ const previous = getAuthUserKey();
const next = typeof value === "string" ? value.trim() : "";
if (!next) {
safeRemove(window.localStorage, AUTH_USER_KEY);
} else {
safeSet(window.localStorage, AUTH_USER_KEY, next);
}
+ if (previous !== (next || "anon")) window.dispatchEvent(new Event(AUTH_USER_CHANGED_EVENT));
if (emit) emitAuthChanged();
}
@@ -89,6 +92,7 @@ export function clearAuthClientState(emit = true) {
// the user is logged out (login page, expired session).
const had = safeGet(window.localStorage, AUTH_USER_KEY) != null;
safeRemove(window.localStorage, AUTH_USER_KEY);
+ if (had) window.dispatchEvent(new Event(AUTH_USER_CHANGED_EVENT));
if (emit && had) emitAuthChanged();
}
diff --git a/job-tracker-ui/src/components/SettingsView.tsx b/job-tracker-ui/src/components/SettingsView.tsx
index b5d1834..826b2e1 100644
--- a/job-tracker-ui/src/components/SettingsView.tsx
+++ b/job-tracker-ui/src/components/SettingsView.tsx
@@ -110,7 +110,7 @@ export default function SettingsView({
{t("settingsSubtitle")}
- setTab(v)} sx={{ mb: 1 }}>
+ setTab(v)} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1 }}>
diff --git a/job-tracker-ui/src/theme-state.test.tsx b/job-tracker-ui/src/theme-state.test.tsx
new file mode 100644
index 0000000..750e642
--- /dev/null
+++ b/job-tracker-ui/src/theme-state.test.tsx
@@ -0,0 +1,123 @@
+import React, { useEffect, useState } from "react";
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+
+import { clearAuthClientState, setAuthUserKey } from "./auth";
+import JobTrackerThemeProvider from "./ThemeProvider";
+import { THEME_BOOTSTRAP_SCRIPT } from "./themeBootstrap";
+import {
+ getThemeModePref,
+ resolveEffectiveThemeMode,
+ setThemeModePref,
+ subscribeToThemePreferenceChanges,
+} from "./themePrefs";
+
+function setSystemDark(matches: boolean) {
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ value: jest.fn().mockImplementation(() => ({
+ matches,
+ media: "(prefers-color-scheme: dark)",
+ onchange: null,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+ });
+}
+
+describe("deterministic theme state", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ document.documentElement.removeAttribute("data-color-scheme");
+ document.documentElement.style.colorScheme = "";
+ setSystemDark(false);
+ });
+
+ it("uses user preference, then anonymous preference, then the documented System default", () => {
+ expect(getThemeModePref()).toBe("system");
+ setThemeModePref("light");
+ expect(getThemeModePref()).toBe("light");
+
+ setAuthUserKey("user-a", false);
+ expect(getThemeModePref()).toBe("light");
+ setThemeModePref("dark");
+ expect(getThemeModePref()).toBe("dark");
+
+ setAuthUserKey("user-b", false);
+ expect(getThemeModePref()).toBe("light");
+ window.localStorage.removeItem("themeMode:anon");
+ expect(getThemeModePref()).toBe("system");
+ });
+
+ it("switches to the correct scoped preference on login and logout without a refresh", () => {
+ window.localStorage.setItem("themeMode:anon", "light");
+ window.localStorage.setItem("themeMode:user-a", "dark");
+ const observed: string[] = [];
+ const unsubscribe = subscribeToThemePreferenceChanges(() => observed.push(getThemeModePref()));
+
+ setAuthUserKey("user-a", false);
+ clearAuthClientState(false);
+
+ expect(observed).toEqual(["dark", "light"]);
+ unsubscribe();
+ });
+
+ it("synchronizes relevant cross-tab storage changes without writing them back", () => {
+ setAuthUserKey("user-a", false);
+ const sync = jest.fn();
+ const unsubscribe = subscribeToThemePreferenceChanges(sync);
+
+ window.dispatchEvent(new StorageEvent("storage", { key: "themeMode:user-a", newValue: "dark" }));
+ window.dispatchEvent(new StorageEvent("storage", { key: "unrelated", newValue: "value" }));
+ window.dispatchEvent(new StorageEvent("storage", { key: "authUserKey", newValue: "user-b" }));
+
+ expect(sync).toHaveBeenCalledTimes(2);
+ unsubscribe();
+ });
+
+ it("uses system preference only for System mode", () => {
+ expect(resolveEffectiveThemeMode("light", true)).toBe("light");
+ expect(resolveEffectiveThemeMode("dark", false)).toBe("dark");
+ expect(resolveEffectiveThemeMode("system", true)).toBe("dark");
+ expect(resolveEffectiveThemeMode("system", false)).toBe("light");
+ });
+
+ it("applies the saved preference before the client application renders", () => {
+ window.localStorage.setItem("themeMode:anon", "light");
+ setSystemDark(true);
+
+ new Function(THEME_BOOTSTRAP_SCRIPT)();
+
+ expect(document.documentElement).toHaveAttribute("data-color-scheme", "light");
+ expect(document.documentElement.style.colorScheme).toBe("light");
+ });
+
+ it("changes color scheme without remounting application state", async () => {
+ let mounts = 0;
+ function StatefulProbe() {
+ const [count, setCount] = useState(0);
+ useEffect(() => { mounts += 1; }, []);
+ return ;
+ }
+
+ const view = render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Count 0" }));
+
+ view.rerender(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(document.documentElement).toHaveAttribute("data-color-scheme", "dark"));
+ expect(screen.getByRole("button", { name: "Count 1" })).toBeInTheDocument();
+ expect(mounts).toBe(1);
+ });
+});
diff --git a/job-tracker-ui/src/themeBootstrap.ts b/job-tracker-ui/src/themeBootstrap.ts
new file mode 100644
index 0000000..2434052
--- /dev/null
+++ b/job-tracker-ui/src/themeBootstrap.ts
@@ -0,0 +1,17 @@
+export const THEME_BOOTSTRAP_SCRIPT = `(() => {
+ try {
+ const read = (key) => {
+ const value = window.localStorage.getItem(key);
+ return value === "light" || value === "dark" || value === "system" ? value : null;
+ };
+ const userKey = window.localStorage.getItem("authUserKey") || "anon";
+ const preference = read("themeMode:" + userKey) || (userKey !== "anon" ? read("themeMode:anon") : null) || "system";
+ const mode = preference === "dark" || (preference === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
+ document.documentElement.setAttribute("data-color-scheme", mode);
+ document.documentElement.style.colorScheme = mode;
+ } catch {
+ const mode = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+ document.documentElement.setAttribute("data-color-scheme", mode);
+ document.documentElement.style.colorScheme = mode;
+ }
+})();`;
diff --git a/job-tracker-ui/src/themePrefs.ts b/job-tracker-ui/src/themePrefs.ts
index 29e09ae..6a471d1 100644
--- a/job-tracker-ui/src/themePrefs.ts
+++ b/job-tracker-ui/src/themePrefs.ts
@@ -1,6 +1,10 @@
-import { getAuthUserKey } from "./auth";
+import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from "./auth";
export type ThemeModePref = "system" | "light" | "dark";
+export type EffectiveThemeMode = "light" | "dark";
+
+export const DEFAULT_THEME_MODE: ThemeModePref = "system";
+const THEME_KEY_PREFIX = "themeMode:";
export function getUserKeyFromToken(): string {
return getAuthUserKey();
@@ -10,12 +14,47 @@ function k(base: string) {
return `${base}:${getUserKeyFromToken()}`;
}
+function readThemeMode(key: string): ThemeModePref | null {
+ try {
+ const raw = window.localStorage.getItem(key);
+ return raw === "light" || raw === "dark" || raw === "system" ? raw : null;
+ } catch {
+ return null;
+ }
+}
+
export function getThemeModePref(): ThemeModePref {
- const raw = window.localStorage.getItem(k("themeMode"));
- if (raw === "light" || raw === "dark" || raw === "system") return raw;
- return "system";
+ const userKey = getUserKeyFromToken();
+ const savedUserMode = readThemeMode(`${THEME_KEY_PREFIX}${userKey}`);
+ if (savedUserMode) return savedUserMode;
+ if (userKey !== "anon") {
+ const anonymousMode = readThemeMode(`${THEME_KEY_PREFIX}anon`);
+ if (anonymousMode) return anonymousMode;
+ }
+ return DEFAULT_THEME_MODE;
}
export function setThemeModePref(v: ThemeModePref) {
- window.localStorage.setItem(k("themeMode"), v);
+ try {
+ window.localStorage.setItem(k("themeMode"), v);
+ } catch {
+ // Keep the in-memory selection even when storage is unavailable.
+ }
+}
+
+export function resolveEffectiveThemeMode(preference: ThemeModePref, systemPrefersDark: boolean): EffectiveThemeMode {
+ if (preference === "light" || preference === "dark") return preference;
+ return systemPrefersDark ? "dark" : "light";
+}
+
+export function subscribeToThemePreferenceChanges(sync: () => void) {
+ const onStorage = (event: StorageEvent) => {
+ if (event.key === "authUserKey" || event.key?.startsWith(THEME_KEY_PREFIX)) sync();
+ };
+ window.addEventListener(AUTH_USER_CHANGED_EVENT, sync);
+ window.addEventListener("storage", onStorage);
+ return () => {
+ window.removeEventListener(AUTH_USER_CHANGED_EVENT, sync);
+ window.removeEventListener("storage", onStorage);
+ };
}