#!/usr/bin/env node // Compare the *computed* styles of a legacy page against its Astro // replacement, at several viewport widths. // // node .agents/scripts/computed-style-diff.mjs full-guide // node .agents/scripts/computed-style-diff.mjs full-guide --widths 560,880,1050 // // Why this exists: a ported media query can sit in the built stylesheet, // match the viewport, and still do nothing. Astro scopes a component's rules // as `.tree-node[data-astro-cid-lsutp3lb]` (specificity 0,2,0); a rule ported // verbatim as `.tree-node` (0,1,0) loses to it and never applies. Task 15e // attempt 4 shipped exactly that: `@media (max-width: 1050px) .tree-node // { width: 145px }` was present in dist and the node stayed 180px wide. // // Checking that the breakpoint *appears* in the built CSS cannot catch this. // Only asking the browser what it actually computed can. import { spawn } from 'node:child_process'; import { cpSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { chromium } from 'playwright'; const route = process.argv[2]; if (!route) { console.error('usage: computed-style-diff.mjs [--widths a,b,c]'); process.exit(2); } const widthsArg = process.argv.indexOf('--widths'); const widths = widthsArg === -1 ? [520, 560, 600, 620, 720, 800, 880, 1050, 1100, 1600] : process.argv[widthsArg + 1].split(',').map(Number); // The selectors worth checking are the ones the responsive layer moves at a // breakpoint, so read them out of the legacy stylesheet's @media blocks only. // Taking every class in the file buries the signal under generic ones like // `.active`, whose state the islands own anyway. const responsive = readFileSync(new URL('../../responsive.css', import.meta.url), 'utf8'); const mediaBlocks = []; for (const match of responsive.matchAll(/@media[^{]*\{/g)) { let depth = 0; for (let i = match.index; i < responsive.length; i += 1) { if (responsive[i] === '{') depth += 1; else if (responsive[i] === '}') { depth -= 1; if (depth === 0) { mediaBlocks.push(responsive.slice(match.index + match[0].length, i)); break; } } } } const selectors = [...new Set(mediaBlocks.join('\n').match(/\.[a-z][a-z0-9-]*/g) || [])].sort(); // Properties a responsive rule actually moves. Comparing every property would // drown the signal in font stacks and inherited colour. const PROPERTIES = [ 'display', 'grid-template-columns', 'grid-template-rows', 'flex-direction', 'width', 'height', 'max-width', 'padding', 'margin', 'gap', 'font-size', 'position', 'inset', 'overflow', ]; // The legacy pages were deleted at cutover; run this from a pre-cutover // worktree, or the legacy side will 404. const legacyPath = route === 'index' ? 'index.html' : `${route}/index.html`; const astroPath = route === 'index' ? '' : `${route}/`; const staging = mkdtempSync(join(tmpdir(), 'af-csd-')); cpSync('dist', join(staging, 'ai-for-dummies'), { recursive: true }); const freePort = () => new Promise((resolve, reject) => { const probe = createServer(); probe.on('error', reject); probe.listen(0, '127.0.0.1', () => { const { port } = probe.address(); probe.close(() => resolve(port)); }); }); const legacyPort = await freePort(); const astroPort = await freePort(); const serve = (dir, port) => spawn('python3', ['-m', 'http.server', String(port), '-d', dir], { stdio: 'ignore' }); const servers = [serve('.', legacyPort), serve(staging, astroPort)]; const stop = () => { servers.forEach((s) => s.kill()); rmSync(staging, { recursive: true, force: true }); }; // Every element matching each selector, so a rule that applies to the first // node and not the rest cannot pass. const collect = ([selectors, properties]) => { const out = {}; for (const selector of selectors) { const nodes = [...document.querySelectorAll(selector)]; out[selector] = nodes.map((node) => { const style = getComputedStyle(node); return properties .map((property) => `${property}:${style.getPropertyValue(property)}`) .join(';'); }); } return out; }; let failures = 0; try { const browser = await chromium.launch(); const read = async (url, width) => { const page = await browser.newPage({ viewport: { width, height: 900 } }); const response = await page.goto(url, { waitUntil: 'load' }); if (!response || !response.ok()) { throw new Error(`${url} returned ${response ? response.status() : 'no response'}`); } await page.waitForTimeout(1500); const styles = await page.evaluate(collect, [selectors, PROPERTIES]); await page.close(); return styles; }; for (const width of widths) { const legacy = await read(`http://localhost:${legacyPort}/${legacyPath}`, width); const astro = await read(`http://localhost:${astroPort}/ai-for-dummies/${astroPath}`, width); for (const selector of selectors) { const before = legacy[selector]; const after = astro[selector]; if (before.length === 0 && after.length === 0) continue; if (before.length !== after.length) { console.log( `${width}px ${selector} legacy ${before.length} nodes, astro ${after.length}`, ); failures += 1; continue; } let reported = 0; before.forEach((expected, index) => { if (expected === after[index]) return; failures += 1; // Three examples is enough to identify a rule that did not apply. reported += 1; if (reported > 3) return; const differing = expected .split(';') .filter((pair, i) => pair !== after[index].split(';')[i]); const got = after[index].split(';').filter((pair, i) => pair !== expected.split(';')[i]); console.log(`${width}px ${selector}[${index}]`); console.log(` legacy ${differing.join(' ')}`); console.log(` astro ${got.join(' ')}`); }); } } await browser.close(); console.log(failures === 0 ? 'computed styles match' : `${failures} computed-style differences`); process.exitCode = failures === 0 ? 0 : 1; } finally { stop(); }