styleproof 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +204 -0
- package/LICENSE +21 -0
- package/README.md +661 -0
- package/bin/styleproof-diff.mjs +110 -0
- package/bin/styleproof-init.mjs +190 -0
- package/bin/styleproof-report.mjs +106 -0
- package/dist/capture.d.ts +79 -0
- package/dist/capture.js +323 -0
- package/dist/diff.d.ts +51 -0
- package/dist/diff.js +121 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/report.d.ts +45 -0
- package/dist/report.js +463 -0
- package/dist/runner.d.ts +45 -0
- package/dist/runner.js +35 -0
- package/docs/demo-composite.png +0 -0
- package/package.json +86 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Diff two computed-style map captures (see styleproof).
|
|
4
|
+
*
|
|
5
|
+
* styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>]
|
|
6
|
+
*
|
|
7
|
+
* Reports, per surface:
|
|
8
|
+
* - DOM changes (elements added/removed/retagged) — a CSS-only refactor
|
|
9
|
+
* must produce none; class attributes are deliberately NOT compared.
|
|
10
|
+
* - Style changes: any computed longhand that resolved differently,
|
|
11
|
+
* including ::before/::after/::marker/::placeholder.
|
|
12
|
+
* - State changes: anything :hover/:focus/:active used to change but no
|
|
13
|
+
* longer does (or now changes differently) — the classic dropped
|
|
14
|
+
* `hover:` variant a screenshot can never catch.
|
|
15
|
+
*
|
|
16
|
+
* Custom properties (--*) are ignored: they are inputs, not outcomes (see
|
|
17
|
+
* README). Exit code 0 = identical, 1 = differences, 2 = usage/capture error.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import { diffStyleMapDirs, findingLabel } from '../dist/diff.js';
|
|
21
|
+
|
|
22
|
+
const HELP = `styleproof-diff — certify a CSS refactor by diffing two computed-style captures
|
|
23
|
+
|
|
24
|
+
usage: styleproof-diff <beforeDir> <afterDir> [options]
|
|
25
|
+
|
|
26
|
+
options:
|
|
27
|
+
--max <n> max lines printed per surface before truncating (default: 40)
|
|
28
|
+
--json <file> also write the full structured diff to <file>
|
|
29
|
+
-h, --help show this help
|
|
30
|
+
|
|
31
|
+
exit: 0 identical (certified), 1 differences found, 2 usage/capture error.
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const argv = process.argv.slice(2);
|
|
35
|
+
const args = [];
|
|
36
|
+
let MAX = 40;
|
|
37
|
+
let jsonOut = null;
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
if (argv[i] === '-h' || argv[i] === '--help') {
|
|
40
|
+
process.stdout.write(HELP);
|
|
41
|
+
process.exit(0);
|
|
42
|
+
} else if (argv[i] === '--max') MAX = Number(argv[++i]);
|
|
43
|
+
else if (argv[i].startsWith('--max=')) MAX = Number(argv[i].slice(6));
|
|
44
|
+
else if (argv[i] === '--json') jsonOut = argv[++i];
|
|
45
|
+
else if (argv[i].startsWith('--json=')) jsonOut = argv[i].slice(7);
|
|
46
|
+
else if (argv[i].startsWith('--')) {
|
|
47
|
+
console.error(`unknown flag: ${argv[i]}`);
|
|
48
|
+
process.exit(2);
|
|
49
|
+
} else args.push(argv[i]);
|
|
50
|
+
}
|
|
51
|
+
if (args.length !== 2 || !Number.isFinite(MAX)) {
|
|
52
|
+
console.error('usage: styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>] (--help for all options)');
|
|
53
|
+
process.exit(2);
|
|
54
|
+
}
|
|
55
|
+
const [dirA, dirB] = args;
|
|
56
|
+
for (const d of [dirA, dirB]) {
|
|
57
|
+
if (!fs.existsSync(d)) {
|
|
58
|
+
console.error(`no capture at ${d}`);
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let result;
|
|
64
|
+
try {
|
|
65
|
+
result = diffStyleMapDirs(dirA, dirB);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.error(e.message);
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
const { surfaces, counts } = result;
|
|
71
|
+
|
|
72
|
+
for (const sd of surfaces) {
|
|
73
|
+
if (sd.missing) {
|
|
74
|
+
console.log(`\n${sd.surface}: captured in only one set — re-run both captures`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const lines = [];
|
|
78
|
+
for (const f of sd.findings) {
|
|
79
|
+
if (f.kind === 'dom') {
|
|
80
|
+
lines.push(
|
|
81
|
+
f.change === 'retagged'
|
|
82
|
+
? ` DOM retagged: ${f.path} ${f.detail}`
|
|
83
|
+
: ` DOM ${f.change}: ${findingLabel(f.path, f.cls)}`,
|
|
84
|
+
);
|
|
85
|
+
} else if (f.kind === 'style') {
|
|
86
|
+
lines.push(` ${findingLabel(f.path, f.cls)}${f.pseudo || ''}`);
|
|
87
|
+
for (const p of f.props) lines.push(` ${p.prop}: ${p.before} → ${p.after}`);
|
|
88
|
+
} else {
|
|
89
|
+
lines.push(` [:${f.state}] ${findingLabel(f.path, f.cls)}${f.sub !== f.path ? ` ⇒ ${f.sub}` : ''}`);
|
|
90
|
+
for (const p of f.props) lines.push(` ${p.prop}: ${p.before} → ${p.after}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
console.log(`\n${sd.surface}: ${lines.filter((l) => !l.startsWith(' ')).length} element(s) differ`);
|
|
94
|
+
for (const line of lines.slice(0, MAX)) console.log(line);
|
|
95
|
+
if (lines.length > MAX) console.log(` ... and ${lines.length - MAX} more lines (re-run with --max ${lines.length})`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces }, null, 2));
|
|
99
|
+
|
|
100
|
+
const total = counts.dom + counts.style + counts.state;
|
|
101
|
+
const surfaceCount = new Set([
|
|
102
|
+
...fs.readdirSync(dirA).filter((f) => /\.json(\.gz)?$/.test(f)),
|
|
103
|
+
...fs.readdirSync(dirB).filter((f) => /\.json(\.gz)?$/.test(f)),
|
|
104
|
+
]).size;
|
|
105
|
+
console.log(
|
|
106
|
+
total === 0
|
|
107
|
+
? `\n✓ ${surfaceCount} surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches`
|
|
108
|
+
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces`,
|
|
109
|
+
);
|
|
110
|
+
process.exit(total === 0 ? 0 : 1);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Scaffold a styleproof capture spec into a project.
|
|
4
|
+
*
|
|
5
|
+
* styleproof-init [--dir <path>] [--base-url <url>] [--force] [-h|--help]
|
|
6
|
+
*
|
|
7
|
+
* Writes:
|
|
8
|
+
* - <dir> (default e2e/styleproof.spec.ts): a starter capture spec with a
|
|
9
|
+
* robust settle() helper (waits fonts + neutralises scroll-reveal and CSS
|
|
10
|
+
* animation/transition) and one sample Surface.
|
|
11
|
+
* - playwright.config.ts: only if one does not already exist, so an existing
|
|
12
|
+
* Playwright project is never disturbed.
|
|
13
|
+
*
|
|
14
|
+
* Idempotent: re-running never overwrites an existing spec (use --force) and
|
|
15
|
+
* never touches an existing playwright.config.ts. Exit 0 = done (or nothing to
|
|
16
|
+
* do), 2 = usage error.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
const HELP = `styleproof-init — scaffold a styleproof capture spec
|
|
22
|
+
|
|
23
|
+
usage: styleproof-init [options]
|
|
24
|
+
|
|
25
|
+
options:
|
|
26
|
+
--dir <path> spec output path (default: e2e/styleproof.spec.ts)
|
|
27
|
+
--base-url <url> baseURL for a generated playwright.config.ts
|
|
28
|
+
(default: http://localhost:3000)
|
|
29
|
+
--force overwrite the spec if it already exists
|
|
30
|
+
-h, --help show this help
|
|
31
|
+
|
|
32
|
+
What it writes:
|
|
33
|
+
- the spec at --dir, with a settle() helper (fonts + reveal/animation freeze)
|
|
34
|
+
and one sample Surface sweeping widths [1280, 768, 390]
|
|
35
|
+
- playwright.config.ts, only if absent (an existing one is left untouched)
|
|
36
|
+
|
|
37
|
+
After running, capture a baseline against a PRODUCTION build:
|
|
38
|
+
STYLEMAP_DIR=baseline npx playwright test styleproof
|
|
39
|
+
git add e2e/__stylemaps__/baseline && git commit -m "chore: stylemap baseline"
|
|
40
|
+
|
|
41
|
+
To certify a refactor:
|
|
42
|
+
STYLEMAP_DIR=before npx playwright test styleproof
|
|
43
|
+
# ...refactor your CSS...
|
|
44
|
+
STYLEMAP_DIR=after npx playwright test styleproof
|
|
45
|
+
npx styleproof-diff __stylemaps__/before __stylemaps__/after
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const argv = process.argv.slice(2);
|
|
49
|
+
let specPath = 'e2e/styleproof.spec.ts';
|
|
50
|
+
let baseUrl = 'http://localhost:3000';
|
|
51
|
+
let force = false;
|
|
52
|
+
for (let i = 0; i < argv.length; i++) {
|
|
53
|
+
const a = argv[i];
|
|
54
|
+
if (a === '-h' || a === '--help') {
|
|
55
|
+
process.stdout.write(HELP);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
} else if (a === '--dir') specPath = argv[++i];
|
|
58
|
+
else if (a.startsWith('--dir=')) specPath = a.slice(6);
|
|
59
|
+
else if (a === '--base-url') baseUrl = argv[++i];
|
|
60
|
+
else if (a.startsWith('--base-url=')) baseUrl = a.slice(11);
|
|
61
|
+
else if (a === '--force') force = true;
|
|
62
|
+
else {
|
|
63
|
+
console.error(`unknown argument: ${a}\n`);
|
|
64
|
+
process.stderr.write(HELP);
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!specPath) {
|
|
69
|
+
console.error('--dir requires a path');
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SPEC = `import type { Page } from '@playwright/test';
|
|
74
|
+
import { defineStyleMapCapture, type Surface } from 'styleproof';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* styleproof capture spec (generated by \`styleproof-init\`).
|
|
78
|
+
*
|
|
79
|
+
* Each surface is one deterministic page state; widths sweep one viewport per
|
|
80
|
+
* @media band so breakpoint rules are certified too. Capture against a
|
|
81
|
+
* PRODUCTION build — dev servers inject their own styles.
|
|
82
|
+
*
|
|
83
|
+
* STYLEMAP_DIR=before npx playwright test styleproof # capture baseline
|
|
84
|
+
* ...refactor your CSS...
|
|
85
|
+
* STYLEMAP_DIR=after npx playwright test styleproof # capture again
|
|
86
|
+
* npx styleproof-diff __stylemaps__/before __stylemaps__/after
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
// Captures read whatever is in front of them, so the page must be settled and
|
|
90
|
+
// deterministic first. settle() (1) waits for web fonts, (2) freezes CSS
|
|
91
|
+
// animations/transitions and forces common scroll-reveal markers to their final
|
|
92
|
+
// state so nothing is mid-fade, then (3) scrolls the page to trigger any
|
|
93
|
+
// IntersectionObserver-driven reveals and returns to the top. Tune the reveal
|
|
94
|
+
// selectors below to match your project.
|
|
95
|
+
async function settle(page: Page) {
|
|
96
|
+
await page.addStyleTag({
|
|
97
|
+
content: \`
|
|
98
|
+
*, *::before, *::after {
|
|
99
|
+
animation: none !important;
|
|
100
|
+
transition: none !important;
|
|
101
|
+
animation-duration: 0s !important;
|
|
102
|
+
transition-duration: 0s !important;
|
|
103
|
+
}
|
|
104
|
+
.reveal, [data-reveal], .fade-in, .animate-in {
|
|
105
|
+
opacity: 1 !important;
|
|
106
|
+
transform: none !important;
|
|
107
|
+
visibility: visible !important;
|
|
108
|
+
}
|
|
109
|
+
\`,
|
|
110
|
+
});
|
|
111
|
+
await page.evaluate(async () => {
|
|
112
|
+
await document.fonts.ready;
|
|
113
|
+
for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) {
|
|
114
|
+
window.scrollTo(0, y);
|
|
115
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
116
|
+
}
|
|
117
|
+
window.scrollTo(0, 0);
|
|
118
|
+
});
|
|
119
|
+
await page.waitForTimeout(300);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const SURFACES: Surface[] = [
|
|
123
|
+
{
|
|
124
|
+
key: 'home',
|
|
125
|
+
go: async (page) => {
|
|
126
|
+
await page.goto('/', { waitUntil: 'networkidle' });
|
|
127
|
+
await settle(page);
|
|
128
|
+
},
|
|
129
|
+
ignore: [], // e.g. ['.live-feed', '.ad-slot'] for nondeterministic regions
|
|
130
|
+
widths: [1280, 768, 390],
|
|
131
|
+
},
|
|
132
|
+
// Add one surface per distinct page state: open menus, dialogs, selected
|
|
133
|
+
// tabs, form errors — anything whose styling you want certified.
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
const CONFIG = `import { defineConfig, devices } from '@playwright/test';
|
|
140
|
+
|
|
141
|
+
// Generated by styleproof-init. Point BASE_URL at a PRODUCTION build of the site
|
|
142
|
+
// to capture — dev servers inject their own styles.
|
|
143
|
+
export default defineConfig({
|
|
144
|
+
timeout: 120_000,
|
|
145
|
+
use: {
|
|
146
|
+
baseURL: process.env.BASE_URL || '${baseUrl}',
|
|
147
|
+
},
|
|
148
|
+
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
|
149
|
+
});
|
|
150
|
+
`;
|
|
151
|
+
|
|
152
|
+
function writeFileSafe(file, contents, { force: f } = {}) {
|
|
153
|
+
const exists = fs.existsSync(file);
|
|
154
|
+
if (exists && !f) return { wrote: false, exists: true };
|
|
155
|
+
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
|
156
|
+
fs.writeFileSync(file, contents);
|
|
157
|
+
return { wrote: true, exists };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let wroteSomething = false;
|
|
161
|
+
|
|
162
|
+
const spec = writeFileSafe(specPath, SPEC, { force });
|
|
163
|
+
if (spec.wrote) {
|
|
164
|
+
console.log(`${spec.exists ? 'overwrote' : 'created'} ${specPath}`);
|
|
165
|
+
wroteSomething = true;
|
|
166
|
+
} else {
|
|
167
|
+
console.log(`${specPath} already exists — left untouched (use --force to overwrite)`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const configPath = 'playwright.config.ts';
|
|
171
|
+
if (fs.existsSync(configPath) || fs.existsSync('playwright.config.js')) {
|
|
172
|
+
console.log('playwright.config exists — left untouched; ensure its baseURL points at a production build');
|
|
173
|
+
} else {
|
|
174
|
+
fs.writeFileSync(configPath, CONFIG);
|
|
175
|
+
console.log(`created ${configPath} (baseURL ${baseUrl})`);
|
|
176
|
+
wroteSomething = true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
console.log('\nNext steps (run against a PRODUCTION build, not a dev server):');
|
|
180
|
+
console.log(' 1. capture the baseline:');
|
|
181
|
+
console.log(' STYLEMAP_DIR=baseline npx playwright test styleproof');
|
|
182
|
+
console.log(' 2. commit it:');
|
|
183
|
+
console.log(' git add __stylemaps__/baseline && git commit -m "chore: stylemap baseline"');
|
|
184
|
+
console.log(' 3. after a CSS change, certify it:');
|
|
185
|
+
console.log(' STYLEMAP_DIR=after npx playwright test styleproof');
|
|
186
|
+
console.log(' npx styleproof-diff __stylemaps__/baseline __stylemaps__/after');
|
|
187
|
+
console.log(' an empty diff is the certificate; a non-empty one names every drift.');
|
|
188
|
+
|
|
189
|
+
if (!wroteSomething) console.log('\nnothing to write — project already scaffolded.');
|
|
190
|
+
process.exit(0);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Visual diff report: side-by-side before/after crops of every changed
|
|
4
|
+
* region, plus the exact property changes, as markdown ready for a PR
|
|
5
|
+
* comment.
|
|
6
|
+
*
|
|
7
|
+
* styleproof-report <beforeDir> <afterDir> --out <dir> [options]
|
|
8
|
+
*
|
|
9
|
+
* Both capture dirs need the .json.gz maps; side-by-side images additionally
|
|
10
|
+
* need the .png screenshots that `defineStyleMapCapture` saves by default.
|
|
11
|
+
* Exit code 0 = no changes, 1 = report generated, 2 = usage error.
|
|
12
|
+
*/
|
|
13
|
+
import { generateStyleMapReport } from '../dist/report.js';
|
|
14
|
+
|
|
15
|
+
const HELP = `styleproof-report — reviewable before/after report from two captures
|
|
16
|
+
|
|
17
|
+
usage: styleproof-report <beforeDir> <afterDir> --out <dir> [options]
|
|
18
|
+
|
|
19
|
+
options:
|
|
20
|
+
--out <dir> output directory (default: styleproof-report)
|
|
21
|
+
--image-base-url <url> prefix for image URLs in report.md (default: relative)
|
|
22
|
+
--pad <px> padding around changed rects when cropping (default: 24)
|
|
23
|
+
--max-crops <n> max crop regions per surface before collapsing (default: 6)
|
|
24
|
+
--min-width <px> minimum crop width, for context (default: 320)
|
|
25
|
+
--min-height <px> minimum crop height, for context (default: 180)
|
|
26
|
+
--include-layout-noise keep size/position-derived longhands (height, width,
|
|
27
|
+
transform-origin, top…) that a reflow changes up the
|
|
28
|
+
whole ancestor chain (off by default)
|
|
29
|
+
-h, --help show this help
|
|
30
|
+
|
|
31
|
+
exit: 0 no changes, 1 report generated, 2 usage error.
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
const argv = process.argv.slice(2);
|
|
35
|
+
const args = [];
|
|
36
|
+
const flags = { out: 'styleproof-report', imageBaseUrl: '' };
|
|
37
|
+
let pad;
|
|
38
|
+
let maxCrops;
|
|
39
|
+
let minWidth;
|
|
40
|
+
let minHeight;
|
|
41
|
+
let includeLayoutNoise = false;
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const a = argv[i];
|
|
44
|
+
if (a === '-h' || a === '--help') {
|
|
45
|
+
process.stdout.write(HELP);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
} else if (a === '--out') flags.out = argv[++i];
|
|
48
|
+
else if (a.startsWith('--out=')) flags.out = a.slice(6);
|
|
49
|
+
else if (a === '--image-base-url') flags.imageBaseUrl = argv[++i];
|
|
50
|
+
else if (a.startsWith('--image-base-url=')) flags.imageBaseUrl = a.slice(17);
|
|
51
|
+
else if (a === '--pad') pad = Number(argv[++i]);
|
|
52
|
+
else if (a.startsWith('--pad=')) pad = Number(a.slice(6));
|
|
53
|
+
else if (a === '--max-crops') maxCrops = Number(argv[++i]);
|
|
54
|
+
else if (a.startsWith('--max-crops=')) maxCrops = Number(a.slice(12));
|
|
55
|
+
else if (a === '--min-width') minWidth = Number(argv[++i]);
|
|
56
|
+
else if (a.startsWith('--min-width=')) minWidth = Number(a.slice(12));
|
|
57
|
+
else if (a === '--min-height') minHeight = Number(argv[++i]);
|
|
58
|
+
else if (a.startsWith('--min-height=')) minHeight = Number(a.slice(13));
|
|
59
|
+
else if (a === '--include-layout-noise') includeLayoutNoise = true;
|
|
60
|
+
else if (a.startsWith('--include-layout-noise=')) includeLayoutNoise = a.slice(23) !== 'false';
|
|
61
|
+
else if (a.startsWith('--')) {
|
|
62
|
+
console.error(`unknown flag: ${a}`);
|
|
63
|
+
process.exit(2);
|
|
64
|
+
} else args.push(a);
|
|
65
|
+
}
|
|
66
|
+
if (args.length !== 2) {
|
|
67
|
+
console.error('usage: styleproof-report <beforeDir> <afterDir> --out <dir> [options] (--help for all options)');
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
for (const [name, val] of [
|
|
71
|
+
['--pad', pad],
|
|
72
|
+
['--max-crops', maxCrops],
|
|
73
|
+
['--min-width', minWidth],
|
|
74
|
+
['--min-height', minHeight],
|
|
75
|
+
]) {
|
|
76
|
+
if (val !== undefined && !Number.isFinite(val)) {
|
|
77
|
+
console.error(`${name} must be a number`);
|
|
78
|
+
process.exit(2);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let result;
|
|
83
|
+
try {
|
|
84
|
+
result = generateStyleMapReport({
|
|
85
|
+
beforeDir: args[0],
|
|
86
|
+
afterDir: args[1],
|
|
87
|
+
outDir: flags.out,
|
|
88
|
+
imageBaseUrl: flags.imageBaseUrl || undefined,
|
|
89
|
+
pad,
|
|
90
|
+
maxCrops,
|
|
91
|
+
minWidth,
|
|
92
|
+
minHeight,
|
|
93
|
+
includeLayoutNoise,
|
|
94
|
+
});
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error(e.message);
|
|
97
|
+
process.exit(2);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log(
|
|
101
|
+
result.changedSurfaces === 0
|
|
102
|
+
? '✓ no changes — empty report written'
|
|
103
|
+
: `✗ ${result.changedSurfaces} changed surface(s), ${result.totalFindings} finding(s)`,
|
|
104
|
+
);
|
|
105
|
+
console.log(`report: ${result.reportMdPath}`);
|
|
106
|
+
process.exit(result.changedSurfaces === 0 ? 0 : 1);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
/**
|
|
3
|
+
* Computed-style capture: the browser's final resolved value for every CSS
|
|
4
|
+
* longhand on every element, keyed by DOM structure (never by class name, so
|
|
5
|
+
* a CSS-to-Tailwind migration can rewrite classes freely while the map stays
|
|
6
|
+
* comparable). Three layers per capture:
|
|
7
|
+
*
|
|
8
|
+
* elements — every element's computed style, pruned against per-tag UA
|
|
9
|
+
* defaults (measured in a clean iframe) to keep files small,
|
|
10
|
+
* plus ::before / ::after / ::marker / ::placeholder.
|
|
11
|
+
* states — for interactive elements, what :hover, :focus(-visible) and
|
|
12
|
+
* :active change (forced via CDP, no mouse involved), captured
|
|
13
|
+
* as a delta over the element's subtree. Screenshots cannot see
|
|
14
|
+
* these; this is where dropped `hover:` variants get caught.
|
|
15
|
+
* motion — transition/animation longhands are captured before the
|
|
16
|
+
* freeze-CSS below nulls them, so declared motion is verified
|
|
17
|
+
* too, while every other captured value is a settled end state.
|
|
18
|
+
*
|
|
19
|
+
* LIMITATIONS (documented, and warned at capture time):
|
|
20
|
+
* - Shadow DOM (open or closed) is NOT traversed: styles inside a web
|
|
21
|
+
* component's shadow root are invisible to the diff. A refactor inside a
|
|
22
|
+
* shadow tree would be falsely certified identical, so capture emits a
|
|
23
|
+
* one-time warning naming the shadow hosts it skipped.
|
|
24
|
+
* - Iframe content (same- or cross-origin) is NOT traversed for the same
|
|
25
|
+
* reason; same-origin frames are listed in the same warning.
|
|
26
|
+
*/
|
|
27
|
+
type Props = Record<string, string>;
|
|
28
|
+
/** Document-space bounding box: [x, y, width, height], rounded. */
|
|
29
|
+
export type Rect = [number, number, number, number];
|
|
30
|
+
export type ElementEntry = {
|
|
31
|
+
tag: string;
|
|
32
|
+
cls: string;
|
|
33
|
+
rect?: Rect;
|
|
34
|
+
style: Props;
|
|
35
|
+
pseudo?: Record<string, Props>;
|
|
36
|
+
};
|
|
37
|
+
export type StyleMap = {
|
|
38
|
+
defaults: Record<string, Props>;
|
|
39
|
+
elements: Record<string, ElementEntry>;
|
|
40
|
+
states: Record<string, Record<string, Record<string, Props>>>;
|
|
41
|
+
/**
|
|
42
|
+
* True when the forced :hover/:focus/:active layer was skipped for this
|
|
43
|
+
* capture because of CDP/page interactive-element count skew. Persisted so a
|
|
44
|
+
* diff against a fully-captured side flags that the state layer wasn't
|
|
45
|
+
* certified, instead of silently reading as "identical".
|
|
46
|
+
*/
|
|
47
|
+
statesSkipped?: boolean;
|
|
48
|
+
};
|
|
49
|
+
export type CaptureOptions = {
|
|
50
|
+
/**
|
|
51
|
+
* Selectors for nondeterministic regions (live data, embeds, ads). The
|
|
52
|
+
* matching elements and their descendants are skipped entirely.
|
|
53
|
+
*/
|
|
54
|
+
ignore?: string[];
|
|
55
|
+
/**
|
|
56
|
+
* Capture forced :hover/:focus/:active state deltas (default true). This is
|
|
57
|
+
* the expensive layer — O(interactive elements × 3 states) with a subtree
|
|
58
|
+
* evaluate each. Set false to skip it on surfaces where you don't need
|
|
59
|
+
* state certification, or where the page has thousands of interactive nodes.
|
|
60
|
+
*/
|
|
61
|
+
captureStates?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Cap on interactive elements forced-state-captured per surface (default
|
|
64
|
+
* 800). Beyond this the capture warns and truncates rather than hanging for
|
|
65
|
+
* minutes; raise it deliberately if you need full coverage of a huge page.
|
|
66
|
+
*/
|
|
67
|
+
maxInteractive?: number;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Capture the page's complete style map. Drive the page to the state you
|
|
71
|
+
* want first (navigate, open menus, settle fonts/animations) — the capture
|
|
72
|
+
* reads whatever is in front of it.
|
|
73
|
+
*/
|
|
74
|
+
export declare function captureStyleMap(page: Page, options?: CaptureOptions): Promise<StyleMap>;
|
|
75
|
+
/** Write a style map to disk; gzipped when the path ends in `.gz`. */
|
|
76
|
+
export declare function saveStyleMap(filePath: string, map: StyleMap): void;
|
|
77
|
+
/** Read a style map written by {@link saveStyleMap} (`.json` or `.json.gz`). */
|
|
78
|
+
export declare function loadStyleMap(filePath: string): StyleMap;
|
|
79
|
+
export {};
|