diff --git a/.agents/scripts/computed-style-diff.mjs b/.agents/scripts/computed-style-diff.mjs new file mode 100644 index 0000000..4a47a1e --- /dev/null +++ b/.agents/scripts/computed-style-diff.mjs @@ -0,0 +1,171 @@ +#!/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', +]; + +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(); +}