// 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'), `Theme matrix

Theme matrix (${results.length} cells, ${failures.length} failing, ${baselineWarnings.length} X baseline contrast warnings)

${failures.length ? `
${failures.join('\n').replace(/` : '

Custom-theme checks pass.

'}${baselineWarnings.length ? `
X baseline contrast warnings
${baselineWarnings.join('\n').replace(/
` : ''} ${[...byCell].map(([group, rows]) => `

${group}px

${rows.map(r => `
${r.cell} · ${r.theme} · min contrast ${r.texts[0]?.ratio ?? '-'} · column ${r.contentWidth}px${r.problems.length ? ` · ${r.problems.join('; ')}` : ''}
`).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; }