580293867d
Deletes the pre-Astro pages, scripts, and stylesheets that the migration replaced, and moves the ones it did not replace out of the way. Deleted (32 files): app.js, responsive.css, landing.css, rules/app.js, rules/styles.css, skills/app.js, the ten route index.html files, and the root hands-on/ copy, which is byte-identical to public/hands-on/ -- the one the build actually ships. Moved to legacy/ (12 files): styles.css, full-guide/audit.css, chapters.css, skills/styles.css, skills-review/styles.css, skills-review/change-lens.css, and the skills-review/app.js module graph. These are not dead. The Astro pages import them and the build fails without them, which the plan had not accounted for. They go to legacy/ rather than src/ because check-tokens.mjs sweeps src, and these files are full of raw hex and unnamed breakpoints: moving one into src/ should mean migrating it to tokens in the same change, not adding a scan exclusion. The prettier, stylelint, and eslint ignore lists that already named these files at their old paths now name legacy/ instead. verify.mjs no longer reads app.js. The 102 Portuguese strings were extracted from its translations.pt object before deletion into .agents/snapshots/full-guide-pt.json -- a legacy capture, not a snapshot of the Astro build, so the assertion still compares against an independent source. The brace-matching helper's assertion is replaced by one that rejects an empty snapshot entry, without which trimming the snapshot would make the presence check pass vacuously. Count stays at 84. audit-ui.mjs reads the ten pages from dist/ and resolves Astro's base-absolute hrefs against it. Before deleting anything, rendered-text-diff was run across all ten routes plus both Portuguese pages: every one at parity, 0 missing and 0 extra. That comparison is not repeatable once the legacy files are gone. computed-style-diff on /full-guide/ stays at 32 differences, so the moves are style-neutral. Docs updated to match: README, AGENTS.md, GATES.md, the architecture context, the operations guide's lab instructions, and the three skills that told you to serve the vanilla site. Publishing is not part of this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
6.1 KiB
JavaScript
174 lines
6.1 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',
|
|
];
|
|
|
|
// 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();
|
|
}
|