Compare commits
5 Commits
138d7c5e4e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 12c32d2fd7 | |||
| 5c76a13b4d | |||
| 91e8d380d0 | |||
| 0a12e9cbcc | |||
| e3ce8abe89 |
@@ -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 <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();
|
||||
}
|
||||
@@ -36,10 +36,16 @@ together.
|
||||
|
||||
## Done when
|
||||
|
||||
- [ ] Screenshot diffs empty at all four widths without `responsive.css`
|
||||
- [ ] Ported rules live with the component that needs them, or in `tokens.css`
|
||||
- [ ] `responsive.css` deleted, and nothing references it
|
||||
- [ ] `pnpm run gate` green; 42 assertions intact
|
||||
Superseded by the legacy-page amendment below. `responsive.css` cannot be
|
||||
deleted before task 20 because `full-guide/index.html` is still the live legacy
|
||||
route and deliberately links it.
|
||||
|
||||
- [ ] Screenshot diffs reviewed at all required widths without an Astro import
|
||||
of `responsive.css`
|
||||
- [x] Ported rules live with the component that needs them, or in `tokens.css`
|
||||
- [x] No Astro file or layout references `responsive.css`; the legacy page link
|
||||
remains unchanged until task 20
|
||||
- [x] `pnpm run gate` green; assertions intact
|
||||
|
||||
## Amendment — the screenshot step now actually works
|
||||
|
||||
@@ -109,6 +115,21 @@ everything up to the deletion:**
|
||||
- A list, in this file, of the rules that remain in `responsive.css` solely for
|
||||
the legacy page — that list is the deletion checklist task 20 will execute.
|
||||
|
||||
### Task 20 deletion checklist
|
||||
|
||||
`responsive.css` has no Astro consumer after this task. Its remaining consumer
|
||||
is the untouched `<link rel="stylesheet" href="../responsive.css">` in
|
||||
`full-guide/index.html`. Task 20 must retire that page before deleting the
|
||||
stylesheet. Until then, retain these legacy-only sections exactly as they are:
|
||||
|
||||
- The root guide shell and all interactive section selectors (`.topbar` through
|
||||
`.copy-status`), including their dark-surface colour values.
|
||||
- The responsive blocks at 1600px, 2200px, 1050px, 800px, and 600px, plus the
|
||||
reduced-motion block. They continue to style the legacy HTML only.
|
||||
- The legacy page's `.route-meter span { height: var(--score) }` transition.
|
||||
Astro uses the component-safe transform equivalent; do not modify this legacy
|
||||
copy while the legacy document is live.
|
||||
|
||||
Amend the "Done when" boxes to match before you start, and say in your final
|
||||
report that the file is intentionally still present.
|
||||
|
||||
@@ -196,3 +217,66 @@ diff <(grep -oh '(max\|min)-width:[0-9]*px' responsive.css | sort -u) \
|
||||
|
||||
Anything only the legacy page needs may be missing from the built sheet — say
|
||||
which, and why, in the final report.
|
||||
|
||||
## Attempt 4 rejected: the ported rules are in the sheet and do nothing
|
||||
|
||||
Tagged `rejected/15e-attempt-4` (`b6b6200`). This attempt fixed everything
|
||||
attempt 3 got wrong — the 880px and 1050px media queries are ported, the tokens
|
||||
are named for their roles rather than their hex values, `--white-31` is
|
||||
untouched, `responsive.css` is unchanged and still linked by
|
||||
`full-guide/index.html`, no `.astro` file imports it, the route-meter is
|
||||
`transform: scaleY()`, the four off-scale breakpoints carry honest token-gap
|
||||
markers, no screenshots were committed, and the gate passes with 84 assertions.
|
||||
The acceptance test in this brief passes on it.
|
||||
|
||||
**It is still wrong, and the acceptance test was the problem.**
|
||||
|
||||
Astro scopes a component's styles. `WorktreeMap.astro`'s rules compile to
|
||||
`.tree-node[data-astro-cid-lsutp3lb] { width: 180px }`, specificity 0,2,0. A
|
||||
rule ported verbatim out of `responsive.css` arrives as
|
||||
`@media (max-width: 1050px) { .tree-node { width: 145px } }`, specificity 0,1,0.
|
||||
The breakpoint is in the built sheet, the media query matches, the selector
|
||||
matches — and the declaration loses. At 1050px the node stays 180px wide.
|
||||
Measured:
|
||||
|
||||
```
|
||||
node .agents/scripts/computed-style-diff.mjs full-guide --widths 880,1050
|
||||
```
|
||||
|
||||
56 differences on attempt 4. Diffing breakpoints between `responsive.css` and
|
||||
`dist/_astro/*.css` cannot see any of it, which is my error, not the agent's: I
|
||||
wrote that test.
|
||||
|
||||
**Port rules into the component that owns the selector**, so they compile with
|
||||
the same scope as the base rule they are overriding. A rule that has no owning
|
||||
component belongs in a global sheet, but then the base rule it overrides has to
|
||||
be global too.
|
||||
|
||||
### The baseline is not zero, and that is the real finding
|
||||
|
||||
The same command reports **52 differences on `main`**, before this task changes
|
||||
anything. `full-guide.astro` currently imports `responsive.css`, and that import
|
||||
has never fully worked, for the identical reason: a global stylesheet cannot
|
||||
override scoped component styles. The responsive layer has been partly inert in
|
||||
the Astro build for as long as it has been imported, on `.tree-node`, `.branch`,
|
||||
`.worker-card`, `.tree-stage`, `.tree-lab`, `.hero`, `.fleet`, `.chapter-links`
|
||||
and more.
|
||||
|
||||
So this task is not "keep parity". Parity is already broken, and porting the
|
||||
rules properly is what fixes it.
|
||||
|
||||
### Acceptance, restated again
|
||||
|
||||
```
|
||||
node .agents/scripts/computed-style-diff.mjs full-guide
|
||||
```
|
||||
|
||||
- Must report **fewer than 52** differences at 880px and 1050px — that is the
|
||||
`main` baseline, and anything above it is a regression.
|
||||
- Drive it to **zero** where you can. Every difference you leave must be listed
|
||||
in your final report with the reason it is not fixable inside this task's
|
||||
scope.
|
||||
- Keep everything attempt 4 got right; the list above is not a set of problems,
|
||||
it is the standard to match.
|
||||
- The breakpoint diff against the built CSS stays as a _necessary_ check. It is
|
||||
no longer a _sufficient_ one.
|
||||
|
||||
@@ -204,4 +204,11 @@ const { orchestrator, workers, initial = workers[0]?.id } = Astro.props;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.worker-card {
|
||||
min-height: 210px;
|
||||
padding: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -214,4 +214,111 @@ const { branches, initial = 'main', rootLabel = 'ROOT', rootSmall = '● clean'
|
||||
justify-self: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
/* token-gap: preserve the legacy worktree layout threshold; this behavior is required by task 15e */
|
||||
@media (max-width: 1050px) {
|
||||
.tree-stage {
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
.tree-node,
|
||||
.tree-node.root {
|
||||
min-height: 0;
|
||||
width: 145px;
|
||||
padding: 13px 15px;
|
||||
gap: 6px;
|
||||
}
|
||||
.tree-node span,
|
||||
.tree-node small {
|
||||
font: 500 var(--step-08) var(--font-mono);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.tree-node strong {
|
||||
font: 600 var(--step-12) var(--font-mono);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.tree-stage {
|
||||
height: 390px;
|
||||
}
|
||||
.tree-stage svg {
|
||||
height: 330px;
|
||||
top: 30px;
|
||||
}
|
||||
.tree-node.root {
|
||||
top: 45px;
|
||||
}
|
||||
.tree-node.branch {
|
||||
top: 255px;
|
||||
}
|
||||
.tree-node {
|
||||
width: 190px;
|
||||
padding: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 2200px) {
|
||||
.tree-stage {
|
||||
height: 460px;
|
||||
}
|
||||
.tree-stage svg {
|
||||
height: 380px;
|
||||
top: 45px;
|
||||
}
|
||||
.tree-node.root {
|
||||
top: 65px;
|
||||
}
|
||||
.tree-node.branch {
|
||||
top: 305px;
|
||||
}
|
||||
.tree-node {
|
||||
width: 240px;
|
||||
padding: 22px;
|
||||
}
|
||||
.tree-node strong {
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
}
|
||||
|
||||
/* token-gap: preserve the legacy full-guide layout threshold; task 20 can retire it with the legacy stylesheet */
|
||||
@media (max-width: 600px) {
|
||||
.tree-stage {
|
||||
height: auto;
|
||||
min-height: 560px;
|
||||
padding: 24px;
|
||||
}
|
||||
.tree-stage svg {
|
||||
display: none;
|
||||
}
|
||||
.tree-node,
|
||||
.tree-node.root,
|
||||
.tree-node.branch,
|
||||
.tree-node.ui,
|
||||
.tree-node.tests,
|
||||
.tree-node.docs {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
left: auto;
|
||||
width: 100%;
|
||||
margin: 0 0 34px;
|
||||
transform: none;
|
||||
}
|
||||
.tree-node:not(:last-child)::after {
|
||||
content: '↓';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -28px;
|
||||
color: var(--gold);
|
||||
}
|
||||
.tree-node:hover,
|
||||
.tree-node.active,
|
||||
.tree-node.root:hover,
|
||||
.tree-node.root.active,
|
||||
.tree-node.tests:hover,
|
||||
.tree-node.tests.active {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+1823
-1
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,13 @@
|
||||
--step-0: 11px;
|
||||
--step-00: 10px;
|
||||
--step-09: 9px;
|
||||
--step-08: 8px;
|
||||
--step-42: 42px;
|
||||
--step-code: 10.5px;
|
||||
--step-exercise-title: clamp(20px, 2.6vw, 34px);
|
||||
--step-skill-title: clamp(30px, 4vw, 60px);
|
||||
--step-pull-quote: clamp(20px, 2.5vw, 34px);
|
||||
--step-display-wide: clamp(150px, 7vw, 220px);
|
||||
|
||||
/* On-dark palette. These are the values the source actually uses on the
|
||||
--ink and --accent surfaces. They are not near-misses of the palette
|
||||
@@ -58,6 +65,62 @@
|
||||
--white-25: rgb(255 255 255 / 25.098%);
|
||||
--white-31: rgb(255 255 255 / 31.3725%);
|
||||
|
||||
/* Full-guide supporting surfaces. These preserve the exact values from the
|
||||
legacy responsive layer; they are deliberately not substitutions from the
|
||||
light-background palette. */
|
||||
--guide-toolbar-text: #9eabb4;
|
||||
--guide-panel-blue: #244760;
|
||||
--guide-worker-detail-surface: #edf0f1;
|
||||
--guide-tree-rule: #41596b;
|
||||
--guide-code-surface: #0b1b27;
|
||||
--guide-tree-shadow: #081621;
|
||||
--guide-live: #80c69a;
|
||||
--guide-live-glow: #80c69a22;
|
||||
--guide-grid-line: #ffffff06;
|
||||
--guide-tree-border: #527085;
|
||||
--guide-tree-node: #112a3b;
|
||||
--guide-tree-node-label: #8ca1af;
|
||||
--guide-tree-node-muted: #a9b6be;
|
||||
--guide-tree-node-hover: #1c425a;
|
||||
--guide-gold-glow: #efc76b18;
|
||||
--guide-tree-detail-text: #aebbc3;
|
||||
--guide-phase-hover: #e8ecee;
|
||||
--guide-meter-border: #496274;
|
||||
--guide-route-copy: #b5c0c7;
|
||||
--guide-skill-hover: #315f80;
|
||||
--guide-skill-copy: #c4cdd3;
|
||||
--guide-skill-index-hover: #eceff0;
|
||||
--guide-provider-copy: #cbd9e1;
|
||||
--guide-provider-detail-copy: #b7c7d1;
|
||||
--guide-provider-rule: #ffffff2b;
|
||||
--guide-effort-surface: #18364a;
|
||||
--guide-effort-copy: #aebfc9;
|
||||
--guide-effort-active: #e9ecee;
|
||||
--guide-effort-active-copy: #eeedf6;
|
||||
--guide-builder-surface: #132b3b;
|
||||
--guide-builder-rule: #ffffff30;
|
||||
--guide-builder-action-copy: #d5dde2;
|
||||
--guide-builder-copy: #a9bcc8;
|
||||
--guide-builder-border: #344c5d;
|
||||
--guide-builder-live-glow: #80c69a20;
|
||||
--guide-builder-code: #bed0dc;
|
||||
--guide-builder-grid: #ffffff05;
|
||||
--guide-verify-rule: #ffffff1f;
|
||||
--guide-verify-copy: #bfccd4;
|
||||
--guide-verify-code: #0f2230;
|
||||
--guide-verify-code-border: #2a4150;
|
||||
--guide-accent-rule: #ffffff42;
|
||||
--guide-accent-note: #6c6898;
|
||||
--guide-accent-copy: #f1f0f7;
|
||||
--guide-install-rule: #ffffff2d;
|
||||
--guide-install-copy: #b9c8d1;
|
||||
--guide-control-border: #ffffff50;
|
||||
--guide-exercise-copy: #afbec7;
|
||||
--guide-prompt-surface: #19364a;
|
||||
--guide-prompt-enhanced: #596f9a;
|
||||
--guide-prompt-rule: #ffffff32;
|
||||
--guide-prompt-enhanced-copy: #e5e3ef;
|
||||
|
||||
/* Breakpoints */
|
||||
--bp-sm: 560px;
|
||||
--bp-md: 800px;
|
||||
|
||||
Reference in New Issue
Block a user