fix(theme): persist one canonical preference
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# UX-002 deterministic theme state
|
# 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.
|
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
|
## 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.
|
- 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.
|
- Canonical `storage` events update another tab without writing back. Auth and unrelated storage events are ignored.
|
||||||
- Relevant `storage` events update another tab without writing back. 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.
|
- 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.
|
- 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.
|
No backend, database, dependency, entitlement or production configuration changed.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- Focused deterministic theme suite: 6/6.
|
- Focused deterministic theme and confirmation suites: 8/8.
|
||||||
- Full frontend: 48/48 suites and 172/172 tests.
|
- Full frontend: 48/48 suites and 172/172 tests.
|
||||||
- Production frontend build/TypeScript and `git diff --check`: pass.
|
- 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.
|
- 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
|
## 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.
|
- 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.
|
- 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/`
|
- Screenshots: `docs/audits/evidence/ux-002/`
|
||||||
- Commands/results: `docs/audits/verification-log.md` V-111–V-113
|
- Commands/results: `docs/audits/verification-log.md` V-111–V-113
|
||||||
- Tests: `job-tracker-ui/src/theme-state.test.tsx`
|
- 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
|
## Rollback
|
||||||
|
|
||||||
Revert `11734ee`. No data migration is required; existing `themeMode:<user>` 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:<user>` values remain untouched and the canonical key can be removed independently.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
resolveEffectiveThemeMode,
|
resolveEffectiveThemeMode,
|
||||||
setThemeModePref,
|
setThemeModePref,
|
||||||
subscribeToThemePreferenceChanges,
|
subscribeToThemePreferenceChanges,
|
||||||
|
THEME_MODE_KEY,
|
||||||
} from "./themePrefs";
|
} from "./themePrefs";
|
||||||
|
|
||||||
function setSystemDark(matches: boolean) {
|
function setSystemDark(matches: boolean) {
|
||||||
@@ -36,7 +37,7 @@ describe("deterministic theme state", () => {
|
|||||||
setSystemDark(false);
|
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");
|
expect(getThemeModePref()).toBe("system");
|
||||||
setThemeModePref("light");
|
setThemeModePref("light");
|
||||||
expect(getThemeModePref()).toBe("light");
|
expect(getThemeModePref()).toBe("light");
|
||||||
@@ -47,12 +48,12 @@ describe("deterministic theme state", () => {
|
|||||||
expect(getThemeModePref()).toBe("dark");
|
expect(getThemeModePref()).toBe("dark");
|
||||||
|
|
||||||
setAuthUserKey("user-b", false);
|
setAuthUserKey("user-b", false);
|
||||||
expect(getThemeModePref()).toBe("light");
|
expect(getThemeModePref()).toBe("dark");
|
||||||
window.localStorage.removeItem("themeMode:anon");
|
window.localStorage.removeItem(THEME_MODE_KEY);
|
||||||
expect(getThemeModePref()).toBe("system");
|
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:anon", "light");
|
||||||
window.localStorage.setItem("themeMode:user-a", "dark");
|
window.localStorage.setItem("themeMode:user-a", "dark");
|
||||||
const observed: string[] = [];
|
const observed: string[] = [];
|
||||||
@@ -61,11 +62,12 @@ describe("deterministic theme state", () => {
|
|||||||
setAuthUserKey("user-a", false);
|
setAuthUserKey("user-a", false);
|
||||||
clearAuthClientState(false);
|
clearAuthClientState(false);
|
||||||
|
|
||||||
expect(observed).toEqual(["dark", "light"]);
|
expect(observed).toEqual([]);
|
||||||
|
expect(getThemeModePref()).toBe("light");
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("synchronizes relevant cross-tab storage changes without writing them back", () => {
|
it("synchronizes only the canonical cross-tab preference", () => {
|
||||||
setAuthUserKey("user-a", false);
|
setAuthUserKey("user-a", false);
|
||||||
const sync = jest.fn();
|
const sync = jest.fn();
|
||||||
const unsubscribe = subscribeToThemePreferenceChanges(sync);
|
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: "themeMode:user-a", newValue: "dark" }));
|
||||||
window.dispatchEvent(new StorageEvent("storage", { key: "unrelated", newValue: "value" }));
|
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: "authUserKey", newValue: "user-b" }));
|
||||||
|
window.dispatchEvent(new StorageEvent("storage", { key: THEME_MODE_KEY, newValue: "dark" }));
|
||||||
|
|
||||||
expect(sync).toHaveBeenCalledTimes(2);
|
expect(sync).toHaveBeenCalledTimes(1);
|
||||||
unsubscribe();
|
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", () => {
|
it("uses system preference only for System mode", () => {
|
||||||
expect(resolveEffectiveThemeMode("light", true)).toBe("light");
|
expect(resolveEffectiveThemeMode("light", true)).toBe("light");
|
||||||
expect(resolveEffectiveThemeMode("dark", false)).toBe("dark");
|
expect(resolveEffectiveThemeMode("dark", false)).toBe("dark");
|
||||||
@@ -86,7 +97,7 @@ describe("deterministic theme state", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("applies the saved preference before the client application renders", () => {
|
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);
|
setSystemDark(true);
|
||||||
|
|
||||||
new Function(THEME_BOOTSTRAP_SCRIPT)();
|
new Function(THEME_BOOTSTRAP_SCRIPT)();
|
||||||
|
|||||||
@@ -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: {
|
MuiPopover: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
paper: ({ theme }: any) => ({
|
paper: ({ theme }: any) => ({
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export const THEME_BOOTSTRAP_SCRIPT = `(() => {
|
|||||||
return value === "light" || value === "dark" || value === "system" ? value : null;
|
return value === "light" || value === "dark" || value === "system" ? value : null;
|
||||||
};
|
};
|
||||||
const userKey = window.localStorage.getItem("authUserKey") || "anon";
|
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";
|
const mode = preference === "dark" || (preference === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
|
||||||
document.documentElement.setAttribute("data-color-scheme", mode);
|
document.documentElement.setAttribute("data-color-scheme", mode);
|
||||||
document.documentElement.style.colorScheme = mode;
|
document.documentElement.style.colorScheme = mode;
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from "./auth";
|
import { getAuthUserKey } from "./auth";
|
||||||
|
|
||||||
export type ThemeModePref = "system" | "light" | "dark";
|
export type ThemeModePref = "system" | "light" | "dark";
|
||||||
export type EffectiveThemeMode = "light" | "dark";
|
export type EffectiveThemeMode = "light" | "dark";
|
||||||
|
|
||||||
export const DEFAULT_THEME_MODE: ThemeModePref = "system";
|
export const DEFAULT_THEME_MODE: ThemeModePref = "system";
|
||||||
|
export const THEME_MODE_KEY = "jobtracker.themeMode";
|
||||||
const THEME_KEY_PREFIX = "themeMode:";
|
const THEME_KEY_PREFIX = "themeMode:";
|
||||||
|
|
||||||
export function getUserKeyFromToken(): string {
|
export function getUserKeyFromToken(): string {
|
||||||
return getAuthUserKey();
|
return getAuthUserKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
function k(base: string) {
|
|
||||||
return `${base}:${getUserKeyFromToken()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readThemeMode(key: string): ThemeModePref | null {
|
function readThemeMode(key: string): ThemeModePref | null {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(key);
|
const raw = window.localStorage.getItem(key);
|
||||||
@@ -24,19 +21,28 @@ function readThemeMode(key: string): ThemeModePref | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getThemeModePref(): ThemeModePref {
|
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 userKey = getUserKeyFromToken();
|
||||||
const savedUserMode = readThemeMode(`${THEME_KEY_PREFIX}${userKey}`);
|
const savedUserMode = readThemeMode(`${THEME_KEY_PREFIX}${userKey}`);
|
||||||
if (savedUserMode) return savedUserMode;
|
const legacyMode = savedUserMode ?? (userKey !== "anon" ? readThemeMode(`${THEME_KEY_PREFIX}anon`) : null);
|
||||||
if (userKey !== "anon") {
|
if (legacyMode) {
|
||||||
const anonymousMode = readThemeMode(`${THEME_KEY_PREFIX}anon`);
|
try {
|
||||||
if (anonymousMode) return anonymousMode;
|
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;
|
return DEFAULT_THEME_MODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setThemeModePref(v: ThemeModePref) {
|
export function setThemeModePref(v: ThemeModePref) {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(k("themeMode"), v);
|
window.localStorage.setItem(THEME_MODE_KEY, v);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the in-memory selection even when storage is unavailable.
|
// 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) {
|
export function subscribeToThemePreferenceChanges(sync: () => void) {
|
||||||
const onStorage = (event: StorageEvent) => {
|
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);
|
window.addEventListener("storage", onStorage);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(AUTH_USER_CHANGED_EVENT, sync);
|
|
||||||
window.removeEventListener("storage", onStorage);
|
window.removeEventListener("storage", onStorage);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user