theme updated

UI_CHANGES
ahmed.mujtaba 2026-08-19 19:34:16 +05:00
parent 8e95adb320
commit 039a37747a
4 changed files with 207 additions and 6 deletions

View File

@ -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",

View File

@ -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',

View File

@ -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 <style> at build time, and useThemeVersion is what reruns that build.
============================================================ */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useThemeVersion } from '../theme/ThemeProvider'
/** Elements that have no business in a rendered email. */
const STRIP_TAGS = [
@ -64,12 +71,20 @@ function sanitize(html) {
return doc.body?.innerHTML || ''
}
/** Inherit the host theme's colours so the email does not glare in dark mode. */
/**
* Inherit the host theme's colours so the email does not glare in dark mode.
*
* Called fresh on every srcDoc build see the themeVersion dependency below.
* Read the attribute for color-scheme rather than sniffing whether --bg happens
* to start with an "f": that guess quietly inverts the frame's form controls and
* scrollbars the moment a palette changes shade.
*/
function frameStyles() {
const css = getComputedStyle(document.documentElement)
const pick = (name, fallback) => (css.getPropertyValue(name) || fallback).trim()
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
return `
:root { color-scheme: ${pick('--bg', '#fff').startsWith('#f') ? 'light' : 'dark'}; }
:root { color-scheme: ${dark ? 'dark' : 'light'}; }
body {
margin: 0;
background: ${pick('--bg-sunken', '#f7f7f8')};
@ -113,8 +128,21 @@ export default function EmailBody({ html, maxHeight }) {
const [allowRemoteImages, setAllowRemoteImages] = useState(false)
const [height, setHeight] = useState(320)
const [blockedImages, setBlockedImages] = useState(0)
const themeVersion = useThemeVersion()
const clean = sanitize(html)
// Both memoised because srcDoc IS the frame's source: handing React a new but
// equal string tears the document down and rebuilds it. Unmemoised, every
// unrelated parent render re-parsed the mail and reloaded the frame.
const clean = useMemo(() => sanitize(html), [html])
const srcDoc = useMemo(
// themeVersion is the entire reason this list exists. frameStyles()
// snapshots the CSS variables into the frame's <style>, and custom
// properties do not cross into an iframe, so without a rebuild the email
// keeps the palette it was born with while the rest of the app flips.
() => buildSrcDoc(clean, allowRemoteImages),
// eslint-disable-next-line react-hooks/exhaustive-deps
[clean, allowRemoteImages, themeVersion],
)
const measure = useCallback(() => {
const frame = ref.current
@ -171,7 +199,7 @@ export default function EmailBody({ html, maxHeight }) {
onLoad={onLoad}
// No allow-scripts. Ever. See the header comment.
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
srcDoc={buildSrcDoc(clean, allowRemoteImages)}
srcDoc={srcDoc}
style={{ height: maxHeight ? Math.min(height, maxHeight) : height }}
/>
</div>

143
frontend/theme.test.mjs Normal file
View File

@ -0,0 +1,143 @@
/**
* Theme-propagation test.
*
* npm run test:theme
*
* Flipping [data-theme] repaints the entire app for free, because every colour
* in styles.css is a custom property. This test guards the one component that
* cannot ride that: EmailBody renders into an iframe, and custom properties do
* not inherit across a document boundary, so its palette is snapshotted into the
* frame's <style> and has to be rebuilt by hand.
*
* That made it the one place where a theme switch left stale colours on screen
* on /inbox (both tabs) and /matching. The assertions below are what stops it
* regressing: a real MutationObserver, a real attribute flip, and the frame's
* own srcDoc read back afterwards.
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import esbuild from 'esbuild'
import { JSDOM } from 'jsdom'
// ---------------------------------------------------------------- environment
const dom = new JSDOM('<!doctype html><html data-theme="light"><body><div id="root"></div></body></html>', {
url: 'http://localhost:5173/',
pretendToBeVisual: true,
})
globalThis.window = dom.window
globalThis.document = dom.window.document
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
globalThis.HTMLElement = dom.window.HTMLElement
globalThis.Element = dom.window.Element
globalThis.Node = dom.window.Node
globalThis.DOMParser = dom.window.DOMParser
globalThis.getComputedStyle = dom.window.getComputedStyle
globalThis.localStorage = dom.window.localStorage
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0)
globalThis.cancelAnimationFrame = clearTimeout
globalThis.IS_REACT_ACT_ENVIRONMENT = true
// jsdom ships a real MutationObserver, and this test depends on it — the render
// smoke test stubs it out, which would silently make every assertion here pass.
globalThis.MutationObserver = dom.window.MutationObserver
// ---------------------------------------------------------------- bundle
const outDir = mkdtempSync(join(tmpdir(), 'tf-theme-'))
const outFile = join(outDir, 'entry.mjs')
await esbuild.build({
stdin: {
contents: `
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import EmailBody from './ui/EmailBody'
export async function mount(container, html) {
const root = createRoot(container)
await act(async () => { root.render(<EmailBody html={html} />) })
return root
}
export async function flush() { await act(async () => {}) }
`,
resolveDir: 'src',
sourcefile: 'theme-entry.jsx',
loader: 'jsx',
},
outfile: outFile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
jsx: 'automatic',
loader: { '.js': 'jsx', '.jsx': 'jsx' },
logLevel: 'error',
define: {
'process.env.NODE_ENV': '"development"',
'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }),
},
})
// ---------------------------------------------------------------- run
let failed = 0
const check = (label, ok, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL '} ${label}${ok || !detail ? '' : `\n ${detail}`}`)
if (!ok) failed++
}
/** The palette a real stylesheet would supply, set inline so jsdom resolves it. */
function applyPalette(theme) {
const root = dom.window.document.documentElement
const vars = theme === 'dark'
? { '--bg-sunken': '#0e1d1f', '--text': '#e8f2ef', '--border': '#24403f', '--primary': '#ceff71' }
: { '--bg-sunken': '#f1f7f4', '--text': '#10231f', '--border': '#dbe8e2', '--primary': '#004d43' }
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v)
root.setAttribute('data-theme', theme)
}
const srcDocOf = (container) => container.querySelector('iframe')?.getAttribute('srcdoc') || ''
try {
const mod = await import(pathToFileURL(outFile).href)
applyPalette('light')
const container = dom.window.document.createElement('div')
dom.window.document.body.appendChild(container)
await mod.mount(container, '<p>Hello from a candidate.</p>')
const light = srcDocOf(container)
check('renders an iframe', Boolean(light), 'no iframe in the output')
check('light frame declares color-scheme: light', light.includes('color-scheme: light'))
check('light frame picks up the light background', light.includes('#f1f7f4'), light.slice(0, 200))
check('light frame picks up the light text colour', light.includes('#10231f'))
check('the mail itself still renders', light.includes('Hello from a candidate.'))
// The flip under test. MutationObserver delivers on a microtask, so the
// act() flush below is what lets the re-render land before we read back.
applyPalette('dark')
await mod.flush()
await mod.flush()
const dark = srcDocOf(container)
check('THE REGRESSION: frame rebuilds on theme flip', dark !== light,
'srcDoc is byte-identical after the flip — the email kept the light palette')
check('dark frame declares color-scheme: dark', dark.includes('color-scheme: dark'),
dark.slice(0, 200))
check('dark frame picks up the dark background', dark.includes('#0e1d1f'))
check('dark frame picks up the dark text colour', dark.includes('#e8f2ef'))
check('light colours are gone', !dark.includes('#f1f7f4') && !dark.includes('#10231f'))
check('the mail survives the rebuild', dark.includes('Hello from a candidate.'))
// And back, so this is a toggle rather than a one-way upgrade.
applyPalette('light')
await mod.flush()
await mod.flush()
check('flips back to light', srcDocOf(container).includes('color-scheme: light'))
} finally {
rmSync(outDir, { recursive: true, force: true })
}
console.log(failed ? `\n${failed} theme check(s) failed` : '\nAll theme checks passed')
process.exit(failed ? 1 : 0)