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.
@@ -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 {};