315 lines
25 KiB
JavaScript
315 lines
25 KiB
JavaScript
// Theme regression matrix: every built-in theme over static x.com fixtures (route x X mode x
|
|
// viewport width), rendered in Chromium with the real theme runtime (src/inject/theme.ts).
|
|
//
|
|
// node scripts/theme-matrix.mjs [--themes=classic,paper] [--cells=home,chat] [--widths=1280] [--out=dir]
|
|
//
|
|
// Per cell it asserts: X's layers (menus, hover cards, modal sheets) are opaque; visible text
|
|
// keeps contrast against its computed backdrop; the primary column / route content is visible and
|
|
// not squeezed compared with X's own layout at the same width; nothing overflows the pane.
|
|
// The chat cells (chat-group, chat-direct, chat-menu: X's real chat markup and token layer) are held
|
|
// to more: every text at 4.5:1 and primary text at 7:1 (translucent surfaces composited over what is
|
|
// under them), readable placeholders, an opaque pinned-message pill that neither covers the header
|
|
// nor the first message, a reachable composer, and the inbox / conversation panels exactly where X
|
|
// puts them at 360, 600, 1000 and 1400px.
|
|
// Icons (every visible svg that paints with the current color, X's verification badge aside) must
|
|
// reach 3:1 on their backdrop: the composer toolbar (home-composer, compose-modal) and the post
|
|
// action row are in the fixtures as X draws them.
|
|
// Screenshots and results.json go to verification/theme-matrix/ (an index.html shows the grid).
|
|
// The fixtures are approximations of x.com (see scripts/theme-matrix/fixtures.mjs): passing here
|
|
// is not a live X test.
|
|
|
|
import { build } from 'esbuild';
|
|
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { join } from 'node:path';
|
|
import { chromium } from 'playwright';
|
|
import { CELLS, CELL_WIDTHS, MODE_NAMES, fixtureHtml } from './theme-matrix/fixtures.mjs';
|
|
|
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
const args = Object.fromEntries(process.argv.slice(2).map(arg => arg.replace(/^--/, '').split('=')));
|
|
const list = value => value ? value.split(',').filter(Boolean) : null;
|
|
const out = args.out ?? join(root, '../../verification/theme-matrix');
|
|
|
|
const bundle = (await build({
|
|
// xSite applies X's adapter (the DOM hub's shadow hosts: the chat embed's root gets the theme too).
|
|
stdin: { contents: "import './src/inject/xSite.ts'; export { installTheme, PRESETS } from './src/inject/theme.ts';", resolveDir: root, loader: 'ts' },
|
|
bundle: true, format: 'iife', globalName: 'BT', write: false, target: 'chrome120', minify: true,
|
|
})).outputFiles[0].text;
|
|
const { BUILTIN_THEMES } = await import(pathToFileURL(join(root, 'src/inject/themeSpec.ts')).href);
|
|
// Themes whose pack must cover the whole rail (the line packs sit close to X's own icons, so they may fall back).
|
|
const THEME_PACK = new Set(BUILTIN_THEMES.filter(t => t.icons?.pack === 'retro-98').map(t => t.id));
|
|
|
|
const themes = list(args.themes) ?? BUILTIN_THEMES.map(t => t.id);
|
|
const cells = list(args.cells) ?? Object.keys(CELLS);
|
|
const widths = (list(args.widths) ?? ['600', '800', '1000', '1280']).map(Number);
|
|
/** A cell's own widths unless `--widths` asks for specific ones. */
|
|
const widthsFor = cell => (args.widths ? widths : CELL_WIDTHS[cell] ?? widths);
|
|
const modes = list(args.modes) ?? MODE_NAMES;
|
|
// Narrow widths are about layout: screenshot the shells, check everything.
|
|
const NARROW_SHOTS = new Set(['home', 'home-composer', 'compose-modal', 'profile', 'notifications', 'chat', 'chat-group', 'chat-direct', 'chat-menu', 'chat-embed', 'articles', 'creator-studio', 'settings', 'edit-profile-modal', 'signed-out-landing', 'signed-out-login']);
|
|
|
|
await rm(join(out, 'cells'), { recursive: true, force: true });
|
|
await mkdir(join(out, 'fixtures'), { recursive: true });
|
|
|
|
/** Runs inside the page: the matrix checks. */
|
|
function inspect() {
|
|
const parse = c => { const m = /rgba?\(([\d.]+),? ([\d.]+),? ([\d.]+)(?:,? ?\/? ?([\d.]+))?/.exec(c); return m ? [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]] : null; };
|
|
const lum = ([r, g, b]) => { const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
|
|
const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
|
|
const blend = (top, under) => top[3] >= 1 ? top : [0, 1, 2].map(i => top[i] * top[3] + under[i] * (1 - top[3])).concat(1);
|
|
const pageColor = () => { for (const e of [document.body, document.documentElement]) { const c = parse(getComputedStyle(e).backgroundColor); if (c && c[3] > 0) return blend(c, [255, 255, 255, 1]); } return [255, 255, 255, 1]; };
|
|
// X's chat embed renders inside an open shadow root: walks go up through its host, reads go into it.
|
|
const up = e => e.parentElement ?? (e.parentNode instanceof ShadowRoot ? e.parentNode.host : null);
|
|
const roots = [document, ...[...document.querySelectorAll('[data-testid="xchatEmbedRoute"]')].map(host => host.shadowRoot).filter(Boolean)];
|
|
const pointIn = (el, x, y) => { const root = el.getRootNode(); return (root instanceof ShadowRoot ? root : document).elementFromPoint(x, y); };
|
|
const backdrop = el => {
|
|
const stack = [];
|
|
for (let e = el; e && e !== document.body && e !== document.documentElement; e = up(e)) {
|
|
const c = parse(getComputedStyle(e).backgroundColor);
|
|
if (c && c[3] > 0) { stack.push(c); if (c[3] >= 1) break; }
|
|
}
|
|
let base = pageColor();
|
|
for (const c of stack.reverse()) base = blend(c, base);
|
|
return base;
|
|
};
|
|
const visible = el => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1 && r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth; };
|
|
const dimmed = el => { for (let e = el; e; e = up(e)) { const s = getComputedStyle(e); if (+s.opacity < 1 || s.display === 'none' || s.visibility === 'hidden') return true; } return false; };
|
|
|
|
// 1. Layers
|
|
const layers = [];
|
|
const surfaces = [...document.querySelectorAll('#layers [role="menu"], #layers [data-testid="HoverCard"], #layers [role="dialog"][aria-modal="true"]')];
|
|
for (const el of surfaces) {
|
|
const own = parse(getComputedStyle(el).backgroundColor);
|
|
const child = el.firstElementChild ? parse(getComputedStyle(el.firstElementChild).backgroundColor) : null;
|
|
const effective = own && own[3] >= 1 ? own : child && child[3] >= 1 ? child : own;
|
|
layers.push({ name: el.getAttribute('data-testid') || el.getAttribute('role'), color: effective ? `rgb(${effective.slice(0, 3).join(',')})` : 'none', opaque: !!effective && effective[3] >= 1 });
|
|
}
|
|
|
|
// 2. Text contrast
|
|
const texts = [];
|
|
const seen = new Set();
|
|
for (const root of roots) {
|
|
const walker = document.createTreeWalker(root === document ? document.body : root, NodeFilter.SHOW_TEXT);
|
|
while (walker.nextNode()) {
|
|
const node = walker.currentNode, el = node.parentElement;
|
|
if (!el || seen.has(el) || !node.textContent.trim() || el.closest('script,style')) continue;
|
|
seen.add(el);
|
|
// X's chat: rows below the fold of the message scroller are read too (contrast does not depend on scroll).
|
|
const inChat = !!el.closest('[data-testid="dm-container"],[data-slot="xds-menu-content"]');
|
|
const r0 = el.getBoundingClientRect();
|
|
if (!(inChat && el.closest('[data-testid="dm-message-scroller"]') ? r0.width > 1 && r0.height > 1 : visible(el)) || dimmed(el)) continue;
|
|
// Text hidden under an opaque layer is not what the viewer reads.
|
|
const r = el.getBoundingClientRect();
|
|
const top = pointIn(el, Math.min(innerWidth - 1, Math.max(0, r.left + Math.min(r.width / 2, 20))), Math.min(innerHeight - 1, Math.max(0, r.top + r.height / 2)));
|
|
if (top && !el.contains(top) && !top.contains(el) && top.closest('#layers') && !el.closest('#layers')) continue;
|
|
const fg = parse(getComputedStyle(el).color); if (!fg) continue;
|
|
const bg = backdrop(el);
|
|
// Chat text needs 4.5:1, and 7:1 where the fixture marks primary text (`data-fx-min`).
|
|
const need = inChat ? +(el.closest('[data-fx-min]')?.getAttribute('data-fx-min') ?? 4.5) : 0;
|
|
texts.push({ text: node.textContent.trim().slice(0, 32), tag: el.tagName, ratio: +ratio(blend(fg, bg), bg).toFixed(2), inLayer: !!el.closest('#layers'), need });
|
|
}
|
|
}
|
|
texts.sort((a, b) => a.ratio - b.ratio);
|
|
|
|
// 2b. Icons: an svg painted with the current color is a foreground too (WCAG non-text contrast, 3:1).
|
|
const icons = [];
|
|
for (const svg of roots.flatMap(root => [...root.querySelectorAll('svg')])) {
|
|
if (!visible(svg) || dimmed(svg) || svg.matches('[data-testid="icon-verified"],[aria-label*="Verified"]')) continue;
|
|
const fg = parse(getComputedStyle(svg).color); if (!fg) continue;
|
|
const r = svg.getBoundingClientRect();
|
|
const top = pointIn(svg, Math.min(innerWidth - 1, Math.max(0, r.left + r.width / 2)), Math.min(innerHeight - 1, Math.max(0, r.top + r.height / 2)));
|
|
if (top && !svg.contains(top) && !top.contains(svg) && top.closest('#layers') && !svg.closest('#layers')) continue;
|
|
// A masked icon paints its own box with the current color: its backdrop is what is behind the box.
|
|
const bg = backdrop(up(svg));
|
|
const host = svg.closest('[aria-label],[data-testid]');
|
|
icons.push({ name: host?.getAttribute('aria-label') || host?.getAttribute('data-testid') || svg.getAttribute('data-icon') || 'svg', ratio: +ratio(blend(fg, bg), bg).toFixed(2) });
|
|
}
|
|
icons.sort((a, b) => a.ratio - b.ratio);
|
|
|
|
// 3. Layout. Signed-out pages (no primaryColumn, no #layers portal, no X app shell) have no
|
|
// equivalent landmark, so their own root stands in: it is always visible, and never "squeezed".
|
|
const content = document.querySelector('[data-testid="primaryColumn"]') || document.querySelector('[data-testid="articles-editor"]') || document.querySelector('main[role="main"] section:last-of-type') || document.querySelector('.jetfuel-style-root');
|
|
const rect = content?.getBoundingClientRect();
|
|
|
|
// 4. X's chat: the floating header and pinned pill, the composer, and the two panels.
|
|
let chat = null;
|
|
const dm = roots.map(root => root.querySelector('[data-testid="dm-container"]')).find(Boolean);
|
|
if (dm?.querySelector('[data-testid="dm-conversation-header"]')) {
|
|
const box = el => { if (!el) return null; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 ? { left: Math.round(r.left), top: Math.round(r.top), right: Math.round(r.right), bottom: Math.round(r.bottom), width: Math.round(r.width) } : null; };
|
|
const one = selector => dm.querySelector(selector);
|
|
const scroller = one('[data-testid="dm-message-scroller"]');
|
|
scroller.scrollTop = 0;
|
|
const header = box(one('[data-testid="dm-conversation-header"]'));
|
|
const pillEl = one('[data-testid="pinned-message-banner"] > div');
|
|
const pill = box(pillEl);
|
|
const banner = box(one('[data-testid="pinned-message-banner"]'));
|
|
const first = box(scroller.children[1]?.firstElementChild);
|
|
const input = one('[data-testid="dm-composer-input-container"]');
|
|
const composer = box(input);
|
|
const hit = composer ? pointIn(input, composer.left + 24, (composer.top + composer.bottom) / 2) : null;
|
|
const pillColor = pillEl ? parse(getComputedStyle(pillEl).backgroundColor) : null;
|
|
const placeholders = [];
|
|
for (const field of dm.querySelectorAll('input[placeholder],textarea[placeholder]')) {
|
|
if (field.value || !visible(field)) continue;
|
|
const fg = parse(getComputedStyle(field, '::placeholder').color); if (!fg) continue;
|
|
const bg = backdrop(field);
|
|
placeholders.push({ name: field.getAttribute('data-testid'), ratio: +ratio(blend(fg, bg), bg).toFixed(2) });
|
|
}
|
|
chat = {
|
|
header, pill, first, composer, placeholders,
|
|
overlayBottom: banner?.bottom ?? header?.bottom ?? 0,
|
|
pillOpaque: !pillEl || (!!pillColor && pillColor[3] >= 0.99),
|
|
pillColor: pillEl ? getComputedStyle(pillEl).backgroundColor : null,
|
|
composerReachable: !!composer && !!hit && one('[data-testid="dm-composer-container"]').contains(hit) && composer.bottom <= innerHeight + 1,
|
|
inbox: box(one('[data-testid="dm-inbox-panel"]')), conversation: box(one('[data-testid="dm-conversation-panel"]')), nav: box(document.querySelector('header[role="banner"]')),
|
|
sideScroll: scroller.scrollWidth - scroller.clientWidth,
|
|
};
|
|
}
|
|
// 5. The rail (and a theme's bar made of it): every visible item answers a click at its center.
|
|
const navMiss = [];
|
|
for (const item of document.querySelectorAll('header[role="banner"] nav[role="navigation"] > :is(a,button), header[role="banner"] [data-testid="SideNav_NewTweet_Button"]')) {
|
|
const r = item.getBoundingClientRect();
|
|
// Off-window items count: a bar that pushes items out of the window loses them.
|
|
if (r.width < 2 || r.height < 2 || dimmed(item)) continue;
|
|
const x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
const hit = x >= 0 && y >= 0 && x < innerWidth && y < innerHeight ? document.elementFromPoint(x, y) : null;
|
|
// X's own layers (a modal's mask, a menu) cover the rail on purpose.
|
|
if (hit?.closest('#layers')) continue;
|
|
if (!hit || !item.contains(hit)) navMiss.push({ name: item.getAttribute('data-testid') || item.getAttribute('aria-label') || item.tagName, over: hit ? (hit.getAttribute('data-testid') || hit.tagName + (hit.id ? '#' + hit.id : '') + '.' + String(hit.className).slice(0, 40)) : 'outside the window' });
|
|
}
|
|
// Every rail item keeps a visible icon (a bar's icons-only rule once hid X's icon-only items whole).
|
|
const navNoIcon = [...document.querySelectorAll('header[role="banner"] nav[role="navigation"] > :is(a,button)')].filter(item => {
|
|
const r = item.getBoundingClientRect(); if (r.width < 2 || dimmed(item)) return false;
|
|
const icon = item.querySelector('svg'); const b = icon?.getBoundingClientRect();
|
|
return !b || b.width < 8 || b.height < 8 || getComputedStyle(icon).visibility === 'hidden';
|
|
}).map(item => item.getAttribute('data-testid') || item.getAttribute('aria-label'));
|
|
// Rail items whose icon is X's own line drawing although the theme draws a pack: the pack paints the
|
|
// svg's background or mask, so an svg with neither kept X's icon (owner, 1.0.3: one set everywhere).
|
|
const painted = svg => { const cs = getComputedStyle(svg); return cs.backgroundImage !== 'none' || (cs.maskImage && cs.maskImage !== 'none') || (cs.webkitMaskImage && cs.webkitMaskImage !== 'none'); };
|
|
const navLineIcon = [...document.querySelectorAll('header[role="banner"] nav[role="navigation"] > :is(a,button) svg')].filter(svg => svg.getBoundingClientRect().width > 2 && !painted(svg)).map(svg => svg.closest('a,button').getAttribute('aria-label'));
|
|
return {
|
|
navMiss, navNoIcon, navLineIcon, chat, short: texts.filter(t => t.need && t.ratio < t.need).slice(0, 4),
|
|
iconCount: icons.length, faintIcons: icons.filter(i => i.ratio < 3).length, worstIcon: icons[0] ?? null,
|
|
layers,
|
|
texts: texts.slice(0, 6), low: texts.filter(t => t.ratio < 4.5).length, lowLinks: texts.filter(t => t.tag === 'A' && t.ratio < 4.5).length, fail: texts.filter(t => t.ratio < 3).length, textCount: texts.length,
|
|
contentWidth: rect ? Math.round(rect.width) : 0, contentVisible: !!rect && rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.left < innerWidth,
|
|
overflow: document.documentElement.scrollWidth - innerWidth,
|
|
};
|
|
}
|
|
|
|
/** Elements the browser restyles per DOM insertion (and removal) in the page's main list: a theme rule with a
|
|
* page-wide :has() makes it the whole page (1.0.0: about 440 on x.com, against 1.5 without a theme). */
|
|
const RESTYLE_BUDGET = +(args.restyleBudget ?? 5);
|
|
async function restylePerInsertion(page, cdp) {
|
|
const events = [];
|
|
const collect = event => events.push(...event.value);
|
|
cdp.on('Tracing.dataCollected', collect);
|
|
await cdp.send('Tracing.start', { traceConfig: { includedCategories: ['devtools.timeline'] }, transferMode: 'ReportEvents' });
|
|
const ops = await page.evaluate(() => {
|
|
const host = document.querySelector('[data-testid="cellInnerDiv"]')?.parentElement ?? document.querySelector('[data-testid="primaryColumn"]') ?? document.querySelector('main') ?? document.body;
|
|
document.body.offsetHeight;
|
|
for (let i = 0; i < 20; i++) { const d = document.createElement('div'); host.appendChild(d); d.offsetHeight; d.remove(); document.body.offsetHeight; }
|
|
return 40;
|
|
});
|
|
const complete = new Promise(resolve => cdp.once('Tracing.tracingComplete', resolve));
|
|
await cdp.send('Tracing.end');
|
|
await complete;
|
|
cdp.off('Tracing.dataCollected', collect);
|
|
let elements = 0;
|
|
for (const event of events) if (event.name === 'UpdateLayoutTree' && event.args?.elementCount) elements += event.args.elementCount;
|
|
return +(elements / ops).toFixed(1);
|
|
}
|
|
|
|
const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined });
|
|
const results = [];
|
|
const failures = [];
|
|
const baselineWarnings = [];
|
|
try {
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
|
const cdp = await page.context().newCDPSession(page);
|
|
// Measure settled colors: theme motion only runs without a reduced-motion preference.
|
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
const errors = [];
|
|
page.on('pageerror', error => errors.push(error.message));
|
|
for (const mode of modes) {
|
|
for (const cell of cells) {
|
|
const file = join(out, 'fixtures', `${cell}.${mode}.html`);
|
|
await writeFile(file, fixtureHtml(cell, mode));
|
|
for (const width of widthsFor(cell)) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.goto(pathToFileURL(file).href);
|
|
await page.addScriptTag({ content: `${bundle}\nwindow.__bt = BT.installTheme(window);` });
|
|
// Icon packs draw a group once X has shown its core icons (the gate probes every 1.5 s).
|
|
await page.waitForTimeout(1700);
|
|
// Themes animate color changes; measure and capture the settled state.
|
|
await page.addStyleTag({ content: ':root:root:root *,:root:root:root *::before,:root:root:root *::after{transition:none !important;animation-duration:0s !important}' });
|
|
let baseline = null;
|
|
for (const theme of ['x', ...themes.filter(t => t !== 'x')]) {
|
|
if (!themes.includes(theme) && theme !== 'x') continue;
|
|
await page.evaluate(preset => window.__bt.apply({ preset }), theme);
|
|
await page.evaluate(() => new Promise(requestAnimationFrame));
|
|
const check = await page.evaluate(inspect);
|
|
// Style cost of the page changing (once per cell, theme and mode).
|
|
if (width === widthsFor(cell)[0]) check.restyle = await restylePerInsertion(page, cdp);
|
|
if (theme === 'x') baseline = check;
|
|
const problems = [];
|
|
for (const layer of check.layers) if (!layer.opaque) problems.push(`layer ${layer.name} is see-through (${layer.color})`);
|
|
if (theme !== 'x' && mode === 'dark') for (const layer of check.layers) if (layer.color === 'rgb(20,20,20)') problems.push(`layer ${layer.name} kept X's Lights out surface`);
|
|
// "X default" without an accent changes no color: its contrast is X's own design (white on
|
|
// X blue in a sent chat bubble is 2.97:1), the baseline the themes are measured against.
|
|
if (check.fail && theme !== 'x') problems.push(`${check.fail} text(s) under 3:1 (worst ${check.texts[0].ratio} "${check.texts[0].text}")`);
|
|
if (check.lowLinks && theme !== 'x') problems.push(`${check.lowLinks} link text(s) under 4.5:1`);
|
|
if (check.fail && theme === 'x') baselineWarnings.push(`${cell} / ${mode} / ${width}: X fixture has ${check.fail} text(s) under 3:1 (worst ${check.texts[0].ratio} "${check.texts[0].text}")`);
|
|
if (check.faintIcons && theme !== 'x') problems.push(`${check.faintIcons} icon(s) under 3:1 (worst ${check.worstIcon.ratio} "${check.worstIcon.name}")`);
|
|
if (check.faintIcons && theme === 'x') baselineWarnings.push(`${cell} / ${mode} / ${width}: X fixture has ${check.faintIcons} icon(s) under 3:1 (worst ${check.worstIcon.ratio} "${check.worstIcon.name}")`);
|
|
if (!check.contentVisible) problems.push('primary content not visible');
|
|
if (baseline && check.contentWidth < Math.min(baseline.contentWidth * 0.85, baseline.contentWidth - 90)) problems.push(`content squeezed to ${check.contentWidth}px (X: ${baseline.contentWidth}px)`);
|
|
if (check.overflow > 1) problems.push(`page overflows the pane by ${check.overflow}px`);
|
|
if (check.restyle !== undefined && check.restyle > RESTYLE_BUDGET) problems.push(`each DOM insertion restyles ${check.restyle} elements (budget ${RESTYLE_BUDGET}; X: ${baseline?.restyle ?? '?'}): a page-wide selector such as :has() over the page`);
|
|
if (THEME_PACK.has(theme) && check.navLineIcon?.length) problems.push(`${check.navLineIcon.length} rail icon(s) left as X's line drawing (${check.navLineIcon.slice(0, 4).join(', ')})`);
|
|
if (check.navNoIcon?.length) problems.push(`${check.navNoIcon.length} rail item(s) without a visible icon (${check.navNoIcon.slice(0, 3).join(', ')})`);
|
|
if (check.navMiss.length) problems.push(`${check.navMiss.length} rail item(s) not clickable (${check.navMiss.slice(0, 3).map(m => m.name + ' under ' + m.over).join(', ')})`);
|
|
if (check.chat) {
|
|
const c = check.chat, x = baseline?.chat;
|
|
const same = (a, b) => (!a && !b) || (!!a && !!b && Math.abs(a.left - b.left) <= 4 && Math.abs(a.width - b.width) <= 4); // a theme's column frame may take its border (up to 2px a side)
|
|
if (theme !== 'x') {
|
|
if (check.short.length) problems.push(`${check.short.length}+ chat text(s) under their contrast (worst ${check.short[0].ratio}, needs ${check.short[0].need}: "${check.short[0].text}")`);
|
|
for (const p of c.placeholders) if (p.ratio < 4.5) problems.push(`placeholder of ${p.name} at ${p.ratio}:1`);
|
|
if (!c.pillOpaque) problems.push(`pinned pill is see-through over the messages (${c.pillColor})`);
|
|
} else if (check.short.length) baselineWarnings.push(`${cell} / ${mode} / ${width}: X's own chat has text under the themes' bar (worst ${check.short[0].ratio} "${check.short[0].text}")`);
|
|
if (c.pill && c.header && c.pill.top < c.header.bottom - 1) problems.push(`pinned pill overlaps the header by ${c.header.bottom - c.pill.top}px`);
|
|
if (c.first && c.first.top < c.overlayBottom - 1) problems.push(`header / pinned pill covers the first message by ${c.overlayBottom - c.first.top}px`);
|
|
if (!c.composerReachable) problems.push('composer is not reachable');
|
|
if (c.composer && c.composer.top < c.overlayBottom) problems.push('composer sits under the header');
|
|
if (c.sideScroll > 1) problems.push(`messages scroll sideways by ${c.sideScroll}px`);
|
|
if (x && !(same(c.inbox, x.inbox) && same(c.conversation, x.conversation) && same(c.nav, x.nav))) problems.push(`chat panels moved: inbox ${JSON.stringify(c.inbox)} / conversation ${JSON.stringify(c.conversation)} / nav ${JSON.stringify(c.nav)} (X: ${JSON.stringify(x.inbox)} / ${JSON.stringify(x.conversation)} / ${JSON.stringify(x.nav)})`);
|
|
}
|
|
const shot = width === 1280 || NARROW_SHOTS.has(cell);
|
|
const rel = `cells/${mode}/${width}/${cell}__${theme}.jpg`;
|
|
if (shot && themes.includes(theme)) {
|
|
await mkdir(join(out, 'cells', mode, String(width)), { recursive: true });
|
|
await page.screenshot({ path: join(out, rel), type: 'jpeg', quality: 78 });
|
|
}
|
|
if (!themes.includes(theme)) continue;
|
|
const row = { cell, mode, width, theme, shot: shot ? rel : null, ...check, problems };
|
|
results.push(row);
|
|
if (problems.length) failures.push(`${cell} / ${mode} / ${width} / ${theme}: ${problems.join('; ')}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (errors.length) failures.push(...errors.map(e => `page error: ${e}`));
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
await writeFile(join(out, 'results.json'), JSON.stringify({ generated: new Date().toISOString(), note: 'Static x.com fixtures, not live X. X baseline contrast warnings are reported separately.', failures, baselineWarnings, results }, null, 1));
|
|
const byCell = new Map();
|
|
for (const r of results.filter(r => r.shot)) byCell.set(`${r.mode} ${r.width}`, [...(byCell.get(`${r.mode} ${r.width}`) ?? []), r]);
|
|
await writeFile(join(out, 'index.html'), `<!doctype html><meta charset="utf-8"><title>Theme matrix</title>
|
|
<style>body{font:13px/1.4 system-ui,sans-serif;margin:16px;background:#111;color:#ddd}h2{margin:24px 0 8px}figure{display:inline-block;margin:0 8px 12px 0;width:300px;vertical-align:top}img{width:300px;border:1px solid #333}figcaption{font-size:11px}.bad{color:#ff8080}</style>
|
|
<h1>Theme matrix (${results.length} cells, ${failures.length} failing, ${baselineWarnings.length} X baseline contrast warnings)</h1>${failures.length ? `<pre class="bad">${failures.join('\n').replace(/</g, '<')}</pre>` : '<p>Custom-theme checks pass.</p>'}${baselineWarnings.length ? `<details><summary>X baseline contrast warnings</summary><pre>${baselineWarnings.join('\n').replace(/</g, '<')}</pre></details>` : ''}
|
|
${[...byCell].map(([group, rows]) => `<h2>${group}px</h2>${rows.map(r => `<figure><a href="${r.shot}"><img loading="lazy" src="${r.shot}"></a><figcaption class="${r.problems.length ? 'bad' : ''}">${r.cell} · ${r.theme} · min contrast ${r.texts[0]?.ratio ?? '-'} · column ${r.contentWidth}px${r.problems.length ? ` · ${r.problems.join('; ')}` : ''}</figcaption></figure>`).join('')}`).join('')}`);
|
|
|
|
console.log(`${results.length} cells, ${failures.length} failing, ${baselineWarnings.length} X baseline contrast warnings. Screenshots: ${out}`);
|
|
if (failures.length) { console.log(failures.slice(0, 60).join('\n')); process.exitCode = 1; }
|