theme updated
parent
8e95adb320
commit
039a37747a
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue