diff --git a/docs/verification/ux-002-deterministic-theme-state.md b/docs/verification/ux-002-deterministic-theme-state.md index 55076ff..21daf95 100644 --- a/docs/verification/ux-002-deterministic-theme-state.md +++ b/docs/verification/ux-002-deterministic-theme-state.md @@ -1,6 +1,6 @@ # UX-002 deterministic theme state -Updated: 2026-08-09 +Updated: 2026-08-15 Status: `IMPLEMENTED — NOT VERIFIED`. Automated, build and local browser checks pass. Production and authenticated multi-user browser checks remain. @@ -12,19 +12,20 @@ Theme changes also keyed `CssVarsProvider` and captured `themeMode` in the route ## Implemented contract -- Preference resolution is explicit user key, then anonymous preference for a user without a saved choice, then the documented `System` default. +- `jobtracker.themeMode` is the single browser preference. Login, logout and delayed `/auth/me` resolution cannot change it. +- The first read migrates the former current-user/anonymous value into the canonical key, preserving existing choices. - Explicit Light and Dark ignore operating-system changes. Only System resolves through the current media query. -- Auth user-key changes have a dedicated event. It updates theme state without re-running authentication requests or creating an event loop. -- Relevant `storage` events update another tab without writing back. Unrelated storage events are ignored. +- Canonical `storage` events update another tab without writing back. Auth and unrelated storage events are ignored. - MUI mode is changed in place through its color-scheme context with its private persistence disabled. The app/router tree is not keyed or recreated by theme changes. -- A Next `beforeInteractive` bootstrap applies the same user/anonymous/System resolution before client application paint. +- A Next `beforeInteractive` bootstrap applies the same canonical/migration/System resolution before client application paint. +- Semantic Alert variants use explicit theme severity surfaces and foreground tokens; dark warning/error/info/success text no longer inherits dark-on-dark defaults. - Settings tabs are scrollable at narrow widths; this removes the mobile overflow discovered during the required theme browser pass. No backend, database, dependency, entitlement or production configuration changed. ## Verification -- Focused deterministic theme suite: 6/6. +- Focused deterministic theme and confirmation suites: 8/8. - Full frontend: 48/48 suites and 172/172 tests. - Production frontend build/TypeScript and `git diff --check`: pass. - Browser: explicit Light persisted across Settings → Dashboard navigation and refresh; explicit Dark switched without navigation; System selected the browser's dark preference; a second tab inherited Dark and changing it to Light updated the first tab without reload. @@ -33,7 +34,7 @@ No backend, database, dependency, entitlement or production configuration change ## Remaining gates -- Authenticated User A/User B scoped preference switching was proven at the storage/event unit boundary but not with two live browser accounts because no isolated API authentication environment was running. +- Refresh/navigation and migration are automated; the revised canonical store still needs a real authenticated browser refresh pass before production verification. - A real operating-system preference-change event was tested at the resolver/provider boundary, not by changing the host OS during browser automation. - Production deployment/browser smoke is not authorized/configured. @@ -42,8 +43,8 @@ No backend, database, dependency, entitlement or production configuration change - Screenshots: `docs/audits/evidence/ux-002/` - Commands/results: `docs/audits/verification-log.md` V-111–V-113 - Tests: `job-tracker-ui/src/theme-state.test.tsx` -- Implementation commit: `11734ee` +- Initial implementation commit: `11734ee`; canonical-store/contrast correction: pending this change set. ## Rollback -Revert `11734ee`. No data migration is required; existing `themeMode:` and `themeMode:anon` values remain compatible. Reversion restores the prior delayed-login theme and provider/router remount behavior. +Revert the canonical-store correction to restore account-scoped lookup. No destructive data migration is required; old `themeMode:` values remain untouched and the canonical key can be removed independently. diff --git a/job-tracker-ui/src/theme-state.test.tsx b/job-tracker-ui/src/theme-state.test.tsx index 750e642..cd10158 100644 --- a/job-tracker-ui/src/theme-state.test.tsx +++ b/job-tracker-ui/src/theme-state.test.tsx @@ -10,6 +10,7 @@ import { resolveEffectiveThemeMode, setThemeModePref, subscribeToThemePreferenceChanges, + THEME_MODE_KEY, } from "./themePrefs"; function setSystemDark(matches: boolean) { @@ -36,7 +37,7 @@ describe("deterministic theme state", () => { setSystemDark(false); }); - it("uses user preference, then anonymous preference, then the documented System default", () => { + it("uses one browser preference and falls back to System", () => { expect(getThemeModePref()).toBe("system"); setThemeModePref("light"); expect(getThemeModePref()).toBe("light"); @@ -47,12 +48,12 @@ describe("deterministic theme state", () => { expect(getThemeModePref()).toBe("dark"); setAuthUserKey("user-b", false); - expect(getThemeModePref()).toBe("light"); - window.localStorage.removeItem("themeMode:anon"); + expect(getThemeModePref()).toBe("dark"); + window.localStorage.removeItem(THEME_MODE_KEY); expect(getThemeModePref()).toBe("system"); }); - it("switches to the correct scoped preference on login and logout without a refresh", () => { + it("does not change theme on login or logout", () => { window.localStorage.setItem("themeMode:anon", "light"); window.localStorage.setItem("themeMode:user-a", "dark"); const observed: string[] = []; @@ -61,11 +62,12 @@ describe("deterministic theme state", () => { setAuthUserKey("user-a", false); clearAuthClientState(false); - expect(observed).toEqual(["dark", "light"]); + expect(observed).toEqual([]); + expect(getThemeModePref()).toBe("light"); unsubscribe(); }); - it("synchronizes relevant cross-tab storage changes without writing them back", () => { + it("synchronizes only the canonical cross-tab preference", () => { setAuthUserKey("user-a", false); const sync = jest.fn(); const unsubscribe = subscribeToThemePreferenceChanges(sync); @@ -73,11 +75,20 @@ describe("deterministic theme state", () => { 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" })); + window.dispatchEvent(new StorageEvent("storage", { key: THEME_MODE_KEY, newValue: "dark" })); - expect(sync).toHaveBeenCalledTimes(2); + expect(sync).toHaveBeenCalledTimes(1); unsubscribe(); }); + it("migrates the previous account-scoped preference", () => { + setAuthUserKey("user-a", false); + window.localStorage.setItem("themeMode:user-a", "light"); + + expect(getThemeModePref()).toBe("light"); + expect(window.localStorage.getItem(THEME_MODE_KEY)).toBe("light"); + }); + it("uses system preference only for System mode", () => { expect(resolveEffectiveThemeMode("light", true)).toBe("light"); expect(resolveEffectiveThemeMode("dark", false)).toBe("dark"); @@ -86,7 +97,7 @@ describe("deterministic theme state", () => { }); it("applies the saved preference before the client application renders", () => { - window.localStorage.setItem("themeMode:anon", "light"); + window.localStorage.setItem(THEME_MODE_KEY, "light"); setSystemDark(true); new Function(THEME_BOOTSTRAP_SCRIPT)(); diff --git a/job-tracker-ui/src/theme.ts b/job-tracker-ui/src/theme.ts index 68d03f2..9a0a7a3 100644 --- a/job-tracker-ui/src/theme.ts +++ b/job-tracker-ui/src/theme.ts @@ -306,6 +306,21 @@ export const getTheme = (_mode: "light" | "dark") => { }), }, }, + MuiAlert: { + styleOverrides: { + root: { + alignItems: "flex-start", + }, + standardError: ({ theme }: any) => ({ backgroundColor: theme.vars.palette.error.lighter, color: theme.vars.palette.error.darker, "& .MuiAlert-icon": { color: theme.vars.palette.error.main } }), + standardWarning: ({ theme }: any) => ({ backgroundColor: theme.vars.palette.warning.lighter, color: theme.vars.palette.warning.darker, "& .MuiAlert-icon": { color: theme.vars.palette.warning.main } }), + standardInfo: ({ theme }: any) => ({ backgroundColor: theme.vars.palette.info.lighter, color: theme.vars.palette.info.darker, "& .MuiAlert-icon": { color: theme.vars.palette.info.main } }), + standardSuccess: ({ theme }: any) => ({ backgroundColor: theme.vars.palette.success.lighter, color: theme.vars.palette.success.darker, "& .MuiAlert-icon": { color: theme.vars.palette.success.main } }), + outlinedError: ({ theme }: any) => ({ borderColor: theme.vars.palette.error.main, color: theme.vars.palette.error.darker, "& .MuiAlert-icon": { color: theme.vars.palette.error.main } }), + outlinedWarning: ({ theme }: any) => ({ borderColor: theme.vars.palette.warning.main, color: theme.vars.palette.warning.darker, "& .MuiAlert-icon": { color: theme.vars.palette.warning.main } }), + outlinedInfo: ({ theme }: any) => ({ borderColor: theme.vars.palette.info.main, color: theme.vars.palette.info.darker, "& .MuiAlert-icon": { color: theme.vars.palette.info.main } }), + outlinedSuccess: ({ theme }: any) => ({ borderColor: theme.vars.palette.success.main, color: theme.vars.palette.success.darker, "& .MuiAlert-icon": { color: theme.vars.palette.success.main } }), + }, + }, MuiPopover: { styleOverrides: { paper: ({ theme }: any) => ({ diff --git a/job-tracker-ui/src/themeBootstrap.ts b/job-tracker-ui/src/themeBootstrap.ts index 2434052..e8cc7a9 100644 --- a/job-tracker-ui/src/themeBootstrap.ts +++ b/job-tracker-ui/src/themeBootstrap.ts @@ -5,7 +5,9 @@ export const THEME_BOOTSTRAP_SCRIPT = `(() => { 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 legacyPreference = read("themeMode:" + userKey) || (userKey !== "anon" ? read("themeMode:anon") : null); + const preference = read("jobtracker.themeMode") || legacyPreference || "system"; + if (!read("jobtracker.themeMode") && legacyPreference) window.localStorage.setItem("jobtracker.themeMode", legacyPreference); 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; diff --git a/job-tracker-ui/src/themePrefs.ts b/job-tracker-ui/src/themePrefs.ts index 6a471d1..390392a 100644 --- a/job-tracker-ui/src/themePrefs.ts +++ b/job-tracker-ui/src/themePrefs.ts @@ -1,19 +1,16 @@ -import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from "./auth"; +import { getAuthUserKey } from "./auth"; export type ThemeModePref = "system" | "light" | "dark"; export type EffectiveThemeMode = "light" | "dark"; export const DEFAULT_THEME_MODE: ThemeModePref = "system"; +export const THEME_MODE_KEY = "jobtracker.themeMode"; const THEME_KEY_PREFIX = "themeMode:"; export function getUserKeyFromToken(): string { return getAuthUserKey(); } -function k(base: string) { - return `${base}:${getUserKeyFromToken()}`; -} - function readThemeMode(key: string): ThemeModePref | null { try { const raw = window.localStorage.getItem(key); @@ -24,19 +21,28 @@ function readThemeMode(key: string): ThemeModePref | null { } export function getThemeModePref(): ThemeModePref { + const savedMode = readThemeMode(THEME_MODE_KEY); + if (savedMode) return savedMode; + + // Migrate the former account-scoped values once. Theme is a browser UI + // preference, so changing auth state must never change the visible scheme. 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; + const legacyMode = savedUserMode ?? (userKey !== "anon" ? readThemeMode(`${THEME_KEY_PREFIX}anon`) : null); + if (legacyMode) { + try { + window.localStorage.setItem(THEME_MODE_KEY, legacyMode); + } catch { + // The migrated value still applies for this render when storage is unavailable. + } + return legacyMode; } return DEFAULT_THEME_MODE; } export function setThemeModePref(v: ThemeModePref) { try { - window.localStorage.setItem(k("themeMode"), v); + window.localStorage.setItem(THEME_MODE_KEY, v); } catch { // Keep the in-memory selection even when storage is unavailable. } @@ -49,12 +55,10 @@ export function resolveEffectiveThemeMode(preference: ThemeModePref, systemPrefe export function subscribeToThemePreferenceChanges(sync: () => void) { const onStorage = (event: StorageEvent) => { - if (event.key === "authUserKey" || event.key?.startsWith(THEME_KEY_PREFIX)) sync(); + if (event.key === THEME_MODE_KEY) sync(); }; - window.addEventListener(AUTH_USER_CHANGED_EVENT, sync); window.addEventListener("storage", onStorage); return () => { - window.removeEventListener(AUTH_USER_CHANGED_EVENT, sync); window.removeEventListener("storage", onStorage); }; }