diff --git a/frontend/package.json b/frontend/package.json index 11b6fd5..c734d12 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,8 @@ "preview": "vite preview", "smoke": "node smoke.test.mjs", "test:token": "node token.test.mjs", - "verify": "vite build && node smoke.test.mjs && node token.test.mjs" + "test:theme": "node theme.test.mjs", + "verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs" }, "dependencies": { "@tanstack/react-query": "^5.101.4", diff --git a/frontend/src/theme/ThemeProvider.jsx b/frontend/src/theme/ThemeProvider.jsx index fa9a61d..dc785fe 100644 --- a/frontend/src/theme/ThemeProvider.jsx +++ b/frontend/src/theme/ThemeProvider.jsx @@ -47,6 +47,35 @@ export function useTheme() { return ctx } +/** + * A counter that increments every time [data-theme] flips. Use it as an effect + * or useMemo dependency. + * + * Almost nothing needs this: a CSS custom property change repaints the whole + * document for free, which is why the app re-themes without any React + * involvement. It exists for the handful of places that CANNOT ride a variable + * — anything that bakes a colour into a canvas, a string, or a separate + * document at render time. Those read the palette through getComputedStyle + * exactly once and then hold a stale copy forever, because changing a custom + * property repaints CSS but never re-runs JavaScript. + * + * It watches the ATTRIBUTE rather than subscribing to this provider's state, on + * purpose. initTheme() runs before React mounts and applyTheme() can be called + * from outside the tree, so the attribute is the only source that is always + * current — and a component using this hook then needs no provider at all. + * Chart.jsx observes the same attribute directly, for the same reason. + */ +export function useThemeVersion() { + const [version, setVersion] = useState(0) + useEffect(() => { + if (typeof MutationObserver !== 'function') return undefined + const mo = new MutationObserver(() => setVersion((v) => v + 1)) + mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => mo.disconnect() + }, []) + return version +} + export default function ThemeProvider({ children }) { const [theme, setThemeState] = useState( () => document.documentElement.getAttribute('data-theme') || 'light', diff --git a/frontend/src/ui/EmailBody.jsx b/frontend/src/ui/EmailBody.jsx index 2c80ee3..443f400 100644 --- a/frontend/src/ui/EmailBody.jsx +++ b/frontend/src/ui/EmailBody.jsx @@ -21,9 +21,16 @@ Remote images stay blocked until the user asks for them. A tracking pixel in an applicant email would otherwise tell the sender exactly when a recruiter opened it. + + That CSS isolation has one cost worth stating plainly: custom properties do + not inherit across an iframe boundary, so this is the only component in the + app that cannot re-theme itself for free. Its palette is snapshotted into the + frame's