e3ce8abe89
A media query can sit in the built stylesheet, match the viewport, and do
nothing. Astro scopes a component's rules as
`.tree-node[data-astro-cid-lsutp3lb]`, specificity 0,2,0. A responsive
rule that arrives unscoped as `.tree-node`, 0,1,0, loses to it. The
breakpoint is present, the selector matches, the declaration never wins.
Task 15e attempt 4 shipped exactly that: `@media (max-width: 1050px)
.tree-node { width: 145px }` is in dist and the node stays 180px. The
acceptance test I had written for that task -- diff the breakpoints in
responsive.css against the breakpoints in the built CSS -- passes on it.
Checking that a value appears in a stylesheet cannot catch this; only
asking the browser what it computed can.
This walks both pages at a list of widths and compares computed styles
for every element matching the classes the legacy responsive layer moves
at a breakpoint.
node .agents/scripts/computed-style-diff.mjs full-guide
node .agents/scripts/computed-style-diff.mjs full-guide --widths 880,1050
It reports 52 differences on main at 880px and 1050px, before task 15e
changes anything: importing responsive.css into an Astro page never fully
worked, for the same specificity reason. The responsive layer has been
partly inert in the build for as long as it has been imported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
172 lines
6.0 KiB
JavaScript
172 lines
6.0 KiB
JavaScript
#!/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 <route> [--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();
|
|
}
|