// Theme engine v3 invariant suite over the static x.com fixtures (plan 6): every built-in theme x cell x // width, rendered in Chromium with the real theme runtime, measured by waybetter's themeInvariants // (I1-I8, I10) and I9 (forced :hover over CDP). Numbers first; crops only for failures. // // node scripts/theme-invariants.mjs [--themes=a,b] [--cells=home,account-menu] [--widths=1280,1920] // [--json] [--baseline] [--strict] [--out=dir] // // --json prints per-theme, per-invariant failure counts and the delta against the committed baseline // --baseline writes verification/theme-v3/baseline.json from this run (all themes, cells, widths only) // --strict exit 1 when any non-exempt count is above 0 (report-only otherwise, plan step 2) // Output: /report.json, /REPORT.md (under 8 KB), /crops/ (at most 3 per invariant per theme). // The fixtures approximate x.com: passing here is not a live X test (the live runner is verification/theme-qa). import { build } from 'esbuild'; import { mkdir, writeFile, readFile, rm } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { join } from 'node:path'; import { chromium } from 'playwright'; import { CELLS, CELL_WIDTHS, fixtureHtml } from './theme-matrix/fixtures.mjs'; const root = fileURLToPath(new URL('../', import.meta.url)); const args = Object.fromEntries(process.argv.slice(2).map(arg => { const [k, v] = arg.replace(/^--/, '').split('='); return [k, v ?? true]; })); const list = value => (typeof value === 'string' ? value.split(',').filter(Boolean) : null); const v3 = join(root, '../../verification/theme-v3'); const out = args.out ?? v3; const IDS = ['I1', 'I1b', 'I2', 'I3', 'I4', 'I5', 'I6', 'I7', 'I8', 'I9', 'I10']; const WIDTHS = [600, 1000, 1280, 1440, 1920, 2560]; const bundle = async (contents, globalName) => (await build({ stdin: { contents, resolveDir: root, loader: 'ts' }, bundle: true, format: 'iife', globalName, write: false, target: 'chrome120', minify: true })).outputFiles[0].text; const themeJs = await bundle("import './src/inject/xSite.ts'; export { installTheme } from './src/inject/theme.ts';", 'BT'); const invJs = await bundle("export { runInvariants } from '../../waybetter/packages/runtime/src/themeInvariants.ts';", 'WBInv'); const { BUILTIN_THEMES } = await import(pathToFileURL(join(root, 'src/inject/themeSpec.ts')).href); const SPEC = Object.fromEntries(BUILTIN_THEMES.map(t => [t.id, t])); const themes = list(args.themes) ?? BUILTIN_THEMES.map(t => t.id); const cells = list(args.cells) ?? Object.keys(CELLS).filter(c => !c.startsWith('chat-') || c === 'chat-group'); const widthsFor = cell => (list(args.widths)?.map(Number) ?? (CELL_WIDTHS[cell] ? CELL_WIDTHS[cell].filter(w => w >= 600) : WIDTHS)); const optsFor = id => ({ exemptLeak: id === 'x', pack: !!SPEC[id]?.icons?.pack, access: SPEC[id]?.decor?.focusRing === 'bold' ? 'high' : 'standard' }); /** I9: rows under forced :hover. The hover fill must not be an accent-hue tint of a theme without its own hover colour. */ async function hoverCheck(page, cdp, theme) { if (theme === 'x') return { count: 0, findings: [] }; const { root: doc } = await cdp.send('DOM.getDocument', { depth: 0 }); const findings = []; const sels = ['article[data-testid="tweet"]', '[data-testid="cellInnerDiv"] > div', '[role="menuitem"]', 'nav[role="navigation"] > a']; for (const s of sels) { const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: doc.nodeId, selector: s }); for (const nodeId of nodeIds.slice(0, 3)) { const before = await page.evaluate(([sel, i]) => { const e = document.querySelectorAll(sel)[i]; return e && getComputedStyle(e).backgroundColor; }, [s, nodeIds.indexOf(nodeId)]); await cdp.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: ['hover'] }); const r = await page.evaluate(([sel, i]) => { const e = document.querySelectorAll(sel)[i]; if (!e) return null; const accent = getComputedStyle(document.documentElement).getPropertyValue('--wb-accent').trim() || getComputedStyle(document.documentElement).getPropertyValue('--bt-accent').trim(); return { bg: getComputedStyle(e).backgroundColor, accent, rect: (r => [r.left, r.top, r.width, r.height].map(Math.round))(e.getBoundingClientRect()) }; }, [s, nodeIds.indexOf(nodeId)]); await cdp.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: [] }); if (!r || r.bg === before) continue; const m = /rgba?\(([\d.]+), ([\d.]+), ([\d.]+)(?:, ([\d.]+))?/.exec(r.bg); if (!m) continue; const [rr, gg, bb] = [+m[1], +m[2], +m[3]]; const max = Math.max(rr, gg, bb), min = Math.min(rr, gg, bb); const sat = max ? (max - min) / max : 0; // A tinted hover: saturated and translucent (X's accent-alpha wash), on a theme that did not choose a hover colour. if (sat > 0.25 && m[4] !== undefined && +m[4] < 0.3 && !SPEC[theme]?.colors?.hover) findings.push({ id: 'I9', sel: s, rect: r.rect, detail: `hover ${r.bg} is an accent-hue tint` }); } } return { count: findings.length, findings }; } await rm(join(out, 'crops'), { recursive: true, force: true }); await mkdir(join(out, 'crops'), { recursive: true }); await mkdir(join(out, 'fixtures'), { recursive: true }); const totals = Object.fromEntries(themes.map(t => [t, Object.fromEntries(IDS.map(id => [id, 0]))])); const byWidth = {}; const failures = []; const crops = {}; const errors = []; const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined }); try { const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); const cdp = await page.context().newCDPSession(page); await cdp.send('DOM.enable'); await cdp.send('CSS.enable'); await page.emulateMedia({ reducedMotion: 'reduce' }); page.on('pageerror', e => errors.push(e.message)); for (const cell of cells) { // One X mode per theme: the owner's (Lights out); X default is measured in it too, as a baseline. const file = join(out, 'fixtures', `${cell}.dark.html`); await writeFile(file, fixtureHtml(cell, 'dark')); for (const width of widthsFor(cell)) { await page.setViewportSize({ width, height: 900 }); await page.goto(pathToFileURL(file).href); await page.addScriptTag({ content: `${themeJs}\n${invJs}\nwindow.__bt = BT.installTheme(window);` }); await page.waitForTimeout(1700); await page.addStyleTag({ content: ':root:root:root *,:root:root:root *::before,:root:root:root *::after{transition:none !important;animation-duration:0s !important}' }); for (const theme of themes) { await page.evaluate(preset => window.__bt.apply({ preset }), theme); await page.evaluate(() => new Promise(r => requestAnimationFrame(() => setTimeout(r, 150)))); const rep = await page.evaluate(o => WBInv.runInvariants(document, o), { ...optsFor(theme), keep: 3 }); const hover = await hoverCheck(page, cdp, theme); rep.counts.I9 = hover.count; rep.findings.push(...hover.findings); const row = (byWidth[`${theme}@${width}`] ??= Object.fromEntries(IDS.map(id => [id, 0]))); for (const id of IDS) { totals[theme][id] += rep.counts[id] ?? 0; row[id] += rep.counts[id] ?? 0; } for (const f of rep.findings) { const key = `${theme}:${f.id}`; failures.push({ theme, width, cell, ...f }); if ((crops[key] ?? 0) >= 3 || f.rect[2] <= 0 || f.rect[3] <= 0) continue; crops[key] = (crops[key] ?? 0) + 1; const [x, y, w, h] = f.rect; const pad = 24; const clip = { x: Math.max(0, x - pad), y: Math.max(0, y - pad), width: Math.min(700, w + 2 * pad), height: Math.min(400, h + 2 * pad) }; clip.width = Math.min(clip.width, width - clip.x); clip.height = Math.min(clip.height, 900 - clip.y); if (clip.width > 4 && clip.height > 4) { const name = `${theme}-${f.id}-${crops[key]}.png`; await page.screenshot({ path: join(out, 'crops', name), clip }).catch(() => {}); failures.at(-1).crop = `crops/${name}`; } } } } } } finally { await browser.close(); } // I11 theme purity (static): theme documents carry no css or selector-shaped strings; the engine (runtime, // X inject, shell) never branches on a theme id. Counted per theme for documents, under "engine" for code. import { readdirSync, readFileSync, statSync } from 'node:fs'; const purity = { engine: [] }; const SELECTORY = /[.#\[][\w-]+.*[{:>]|\{[^}]*:[^}]*\}/; const walkStrings = (v, path, hit) => { if (typeof v === 'string') { if (SELECTORY.test(v) && !/^https?:/.test(v)) hit(path); } else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) { if (k === 'css' && x) hit(`${path}.css`); else walkStrings(x, `${path}.${k}`, hit); } }; const storeDir = join(root, '../web/public/store/themes'); for (const t of themes) { const hits = []; walkStrings(SPEC[t] ?? {}, t, p => hits.push(p)); const f = join(storeDir, `${t}.json`); if (existsSync(f)) walkStrings(JSON.parse(readFileSync(f, 'utf8')), `store:${t}`, p => hits.push(p)); purity[t] = hits; totals[t].I11 = hits.length; } const ids = BUILTIN_THEMES.map(t => t.id).filter(id => id !== 'x'); const idCompare = new RegExp(`(\\.id|preset|theme)\\s*[!=]==?\\s*['"](${ids.join('|')})['"]|['"](${ids.join('|')})['"]\\s*[!=]==?`); const walk = d => readdirSync(d).flatMap(n => { const q = join(d, n); return statSync(q).isDirectory() ? (n === 'node_modules' || n === 'dist' ? [] : walk(q)) : /\.(ts|tsx)$/.test(n) && !/\.test\./.test(n) ? [q] : []; }); for (const dir of [join(root, '../../waybetter/packages/runtime/src'), join(root, 'src/inject'), join(root, '../../waybetter/packages/shell/src')]) { for (const file of walk(dir)) readFileSync(file, 'utf8').split('\n').forEach((line, i) => { if (idCompare.test(line)) purity.engine.push(`${file.slice(file.indexOf('src'))}:${i + 1}`); }); } IDS.push('I11'); for (const t of themes) totals[t].I11 ??= 0; // Report const sum = t => IDS.reduce((n, id) => n + (t === 'x' && id === 'I2' ? 0 : totals[t][id]), 0); const basePath = join(v3, 'baseline.json'); const baseline = existsSync(basePath) ? JSON.parse(await readFile(basePath, 'utf8')) : null; const delta = t => baseline?.totals?.[t] ? Object.fromEntries(IDS.map(id => [id, totals[t][id] - (baseline.totals[t][id] ?? 0)])) : null; const meta = { generated: new Date().toISOString(), cells: cells.length, widths: [...new Set(cells.flatMap(widthsFor))], themes: themes.length, note: 'Static x.com fixtures (Lights out), not live X. X default is a baseline (I2 exempt).' }; await writeFile(join(out, 'report.json'), JSON.stringify({ ...meta, totals, purity, byWidth, failures, errors }, null, 1)); let md = `# Theme invariants (fixtures)\n\n${meta.generated.slice(0, 16)}Z. ${meta.themes} themes, ${meta.cells} cells, widths ${meta.widths.join(', ')}. ${meta.note}\n\n| theme | ${IDS.join(' | ')} | total |\n|---|${IDS.map(() => '---|').join('')}---|\n`; for (const t of themes) md += `| ${t} | ${IDS.map(id => totals[t][id]).join(' | ')} | ${sum(t)} |\n`; md += '\n## Worst findings\n\n'; const seen = new Set(); for (const f of failures.sort((a, b) => (a.value ?? 99) - (b.value ?? 99))) { const k = `${f.theme}:${f.id}:${f.sel}`; if (seen.has(k)) continue; seen.add(k); const line = `- ${f.theme} ${f.width} ${f.cell} ${f.id}: ${f.detail.slice(0, 90)} \`${f.sel.slice(-70)}\`${f.crop ? ` [crop](${f.crop})` : ''}\n`; if (md.length + line.length > 7800) { md += '- (more in report.json)\n'; break; } md += line; } await writeFile(join(out, 'REPORT.md'), md); if (args.baseline) { if (list(args.themes) || list(args.cells) || list(args.widths)) throw new Error('--baseline needs the full matrix (no --themes/--cells/--widths)'); await writeFile(basePath, JSON.stringify({ generated: meta.generated, cells, widths: meta.widths, totals }, null, 1)); } if (args.json) console.log(JSON.stringify(Object.fromEntries(themes.map(t => [t, { counts: totals[t], total: sum(t), delta: delta(t) }])), null, 1)); else { console.log(md.split('\n## ')[0]); if (errors.length) console.log(`page errors: ${errors.length} (${errors[0]})`); } if (args.strict && themes.some(t => t !== 'x' && sum(t) > 0)) process.exitCode = 1;