starchy 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Danny Clark
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,122 @@
1
- # Temporary Holding Version
1
+ # starchy
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Keep your layout crisp at every width.**
4
+
5
+ starchy finds every screen width where your page's layout actually changes, and
6
+ flags the changes that are bugs.
7
+
8
+ ```bash
9
+ npx starchy https://example.com
10
+ ```
11
+
12
+ Most responsive testing screenshots a handful of fixed breakpoints, such as 375,
13
+ 768 and 1440, so a bug that only appears at 912px goes unnoticed. starchy sweeps
14
+ the whole range and lets the page show it where the layout shifts:
15
+
16
+ 1. Load the page at a max width (default 1920px) and fingerprint the layout:
17
+ for every container, how its children sit in rows (for example `3+3+1`),
18
+ which elements are visible, and any problems.
19
+ 2. Step down 8px at a time. When the fingerprint changes, binary-search to the
20
+ exact pixel.
21
+ 3. Take before/after screenshots with the changed elements outlined, and
22
+ classify the change:
23
+ - **Media query**: the width matches a `@media` breakpoint in your CSS, so
24
+ the change is expected.
25
+ - **Fluid change**: the layout changed with no media query. It came from
26
+ flex-wrap, auto-fit, or content size, so check that it's intended.
27
+ - **Problem**: something broke at this width.
28
+ 4. Continue down to the min width (default 320px), once for each viewport
29
+ height you list.
30
+ 5. Write `report.html` and a `fingerprint.json` you can compare against later.
31
+
32
+ ## What counts as a problem
33
+
34
+ | Problem | What it means |
35
+ |---|---|
36
+ | Horizontal scroll | The page is wider than the screen. starchy names the element *causing* it by briefly hiding each candidate and checking whether the scroll width shrinks. |
37
+ | Overlap | In-flow sibling elements overlap each other. |
38
+ | Escapes container | An element sticks out of its parent's box. |
39
+ | Clipped text | Text is cut off by `overflow: hidden`. |
40
+ | Tiny text | Text is below `--min-font` (default 12px). |
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ npm install -g starchy
46
+ npx playwright install chromium
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ```bash
52
+ starchy https://example.com
53
+ starchy example.com # https:// is assumed
54
+ starchy ./index.html --min 360 --heights 900,600
55
+ ```
56
+
57
+ | Option | Default | |
58
+ |---|---|---|
59
+ | `--max` / `--min` | 1920 / 320 | Width range to sweep |
60
+ | `--step` | 8 | Coarse step before bisecting. Lower is slower but more thorough. |
61
+ | `--heights` | 900 | Comma-separated viewport heights, each swept separately |
62
+ | `--dpr` | 1 | Device pixel ratio (affects images/hairlines, not layout) |
63
+ | `--min-font` | 12 | Smallest acceptable text size in px |
64
+ | `--ignore` | | Selectors to skip, e.g. `.carousel,.ad-slot` |
65
+ | `--settle` | 0 | Extra ms to wait after each resize, for JS-driven layouts |
66
+ | `--out` | `starchy-report` | Output folder |
67
+ | `--baseline` | | An earlier `fingerprint.json` to compare against |
68
+ | `--fail-on` | none | `problems` or `new` makes starchy exit 1, for CI |
69
+ | `--no-screenshots` | | Faster runs, report without images |
70
+
71
+ ## In CI
72
+
73
+ ```bash
74
+ # once, on a known-good build:
75
+ starchy https://staging.example.com --out baseline
76
+
77
+ # on every pull request:
78
+ starchy https://staging.example.com --baseline baseline/fingerprint.json --fail-on new
79
+ ```
80
+
81
+ The comparison is structural, not pixel-based, so it doesn't suffer the
82
+ font-rendering noise that makes screenshot diffs flaky. The report lists new
83
+ problems, fixed problems, and layout changes that appeared or disappeared.
84
+
85
+ ## Try it
86
+
87
+ `test/fixture.html` is a sample landing page with six planted bugs, listed in
88
+ the comments at the top of the file:
89
+
90
+ ```bash
91
+ npm test
92
+ open test-report/report.html
93
+ ```
94
+
95
+ ## Known limitations
96
+
97
+ - **Change-and-revert inside one step is missed.** If the layout changes and
98
+ then changes back within 8px, the sweep won't see it. Use `--step 1` for an
99
+ exhaustive pass.
100
+ - **Container queries show up as "fluid".** Only `@media` widths are matched
101
+ to breakpoints.
102
+ - **Worst offender first.** When two elements both cause horizontal scroll,
103
+ the widest one is named. The other surfaces once the first is fixed.
104
+ - **Some content isn't inspected.** Shadow DOM and iframes are skipped.
105
+ Auto-rotating carousels and other animated elements need `--ignore`.
106
+ - **Inline text reflow is ignored on purpose.** It changes at almost every
107
+ pixel. Text is checked through the clipped-text and tiny-text rules instead.
108
+ - **Chromium only for now.**
109
+
110
+ ## Project layout
111
+
112
+ ```
113
+ cli.js CLI, sweep loop, bisection, screenshots
114
+ src/fingerprint.js runs in the page: layout units, row signatures, problem checks
115
+ src/analyze.js fingerprint hashing, media-query parsing, diff + classification
116
+ src/baseline.js compare against an earlier fingerprint.json
117
+ src/report.js HTML report
118
+ ```
119
+
120
+ ## License
121
+
122
+ MIT
package/cli.js ADDED
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * starchy — keep your layout crisp at every width.
6
+ *
7
+ * Finds every width where a page's layout actually changes, and flags the bugs.
8
+ *
9
+ * 1. Load the page at a max width and fingerprint its layout structure.
10
+ * 2. Step down; when the fingerprint changes, bisect to the exact pixel.
11
+ * 3. Snapshot before/after, describe what changed, and classify it:
12
+ * breakpoint — matches a media query in your CSS (expected)
13
+ * fluid — layout changed with no media query (wrap, auto-fit…) → review
14
+ * problem — a new overflow / overlap / clipped text / tiny text appeared
15
+ * 4. Repeat down to a min width, for each viewport height requested.
16
+ * 5. Write an HTML report + a JSON fingerprint you can diff against later.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { chromium } = require('playwright');
22
+ const { fingerprint, mediaConditions } = require('./src/fingerprint');
23
+ const { keyOf, parseBreakpoints, diffStates, issueRanges } = require('./src/analyze');
24
+ const { renderReport } = require('./src/report');
25
+ const { compareBaseline } = require('./src/baseline');
26
+
27
+ const HELP = `
28
+ Usage: starchy <url-or-file> [options]
29
+
30
+ e.g. starchy https://example.com
31
+ starchy ./index.html --min 360 --heights 900,600
32
+
33
+ --max <px> widest width to test (default 1920)
34
+ --min <px> narrowest width to test (default 320)
35
+ --step <px> coarse step before bisecting (default 8)
36
+ --heights <list> viewport heights, comma-separated (default 900)
37
+ --dpr <n> device pixel ratio (default 1)
38
+ --min-font <px> smallest acceptable text size (default 12)
39
+ --ignore <sels> comma-separated selectors to skip (carousels, ads…)
40
+ --settle <ms> extra wait after each resize (default 0)
41
+ --out <dir> output folder (default ./starchy-report)
42
+ --baseline <file> compare against an earlier fingerprint.json
43
+ --fail-on <mode> exit 1 on: none | problems | new (default none)
44
+ --no-screenshots skip before/after images (faster)
45
+ `;
46
+
47
+ function parseArgs(argv) {
48
+ const o = {
49
+ max: 1920, min: 320, step: 8, heights: [900], dpr: 1, minFont: 12, ignore: [],
50
+ settle: 0, out: 'starchy-report', baseline: null, failOn: 'none', screenshots: true, url: null,
51
+ };
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const a = argv[i], next = () => argv[++i];
54
+ switch (a) {
55
+ case '-h': case '--help': console.log(HELP); process.exit(0); break;
56
+ case '-v': case '--version': console.log(require('./package.json').version); process.exit(0); break;
57
+ case '--max': o.max = parseInt(next(), 10); break;
58
+ case '--min': o.min = parseInt(next(), 10); break;
59
+ case '--step': o.step = Math.max(1, parseInt(next(), 10)); break;
60
+ case '--heights': o.heights = next().split(',').map((h) => parseInt(h, 10)).filter(Boolean); break;
61
+ case '--dpr': o.dpr = parseFloat(next()); break;
62
+ case '--min-font': o.minFont = parseFloat(next()); break;
63
+ case '--ignore': o.ignore = next().split(',').map((s) => s.trim()).filter(Boolean); break;
64
+ case '--settle': o.settle = parseInt(next(), 10); break;
65
+ case '--out': o.out = next(); break;
66
+ case '--baseline': o.baseline = next(); break;
67
+ case '--fail-on': o.failOn = next(); break;
68
+ case '--no-screenshots': o.screenshots = false; break;
69
+ default:
70
+ if (a.startsWith('--')) { console.error('Unknown option ' + a); process.exit(2); }
71
+ o.url = a;
72
+ }
73
+ }
74
+ if (!o.url) { console.log(HELP); process.exit(2); }
75
+ o.url = o.url.replace(/^<|>$/g, ''); // tolerate copied placeholder brackets
76
+ if (!/^[a-z]+:\/\//i.test(o.url)) {
77
+ if (fs.existsSync(o.url)) o.url = 'file://' + path.resolve(o.url);
78
+ else if (/^(localhost|[\w-]+(\.[\w-]+)+)(:\d+)?(\/|$)/i.test(o.url)) {
79
+ o.url = (/^localhost|^127\./i.test(o.url) ? 'http://' : 'https://') + o.url;
80
+ } else { console.error(`Not a URL or an existing file: ${o.url}`); process.exit(2); }
81
+ }
82
+ if (o.min >= o.max) { console.error('--min must be below --max'); process.exit(2); }
83
+ return o;
84
+ }
85
+
86
+ async function settle(page, ms) {
87
+ // two animation frames = style + layout have run after the resize
88
+ await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
89
+ if (ms) await page.waitForTimeout(ms);
90
+ }
91
+
92
+ async function collectBreakpoints(page, context) {
93
+ const { out, blocked } = await page.evaluate(mediaConditions);
94
+ const texts = out.slice();
95
+ for (const href of blocked) {
96
+ try {
97
+ const res = await context.request.get(href);
98
+ const css = await res.text();
99
+ const re = /@media([^{]+)\{/g;
100
+ let m;
101
+ while ((m = re.exec(css))) texts.push(m[1]);
102
+ } catch (e) { /* unreachable stylesheet: its breakpoints show as "fluid" */ }
103
+ }
104
+ return parseBreakpoints(texts);
105
+ }
106
+
107
+ async function shoot(page, width, height, paths, o) {
108
+ await page.setViewportSize({ width, height });
109
+ await settle(page, o.settle);
110
+ const info = await page.evaluate((paths) => {
111
+ let u = null;
112
+ for (const p of paths) {
113
+ let el = null;
114
+ try { el = document.querySelector(p); } catch (e) { /* skip */ }
115
+ if (!el) continue;
116
+ el.setAttribute('data-ls-hl', '');
117
+ const r = el.getBoundingClientRect();
118
+ const x = r.left + scrollX, y = r.top + scrollY;
119
+ u = u ? { x1: Math.min(u.x1, x), y1: Math.min(u.y1, y), x2: Math.max(u.x2, x + r.width), y2: Math.max(u.y2, y + r.height) }
120
+ : { x1: x, y1: y, x2: x + r.width, y2: y + r.height };
121
+ }
122
+ if (!document.getElementById('ls-hl-style')) {
123
+ const s = document.createElement('style');
124
+ s.id = 'ls-hl-style';
125
+ s.textContent = '[data-ls-hl]{outline:3px solid #e5484d!important;outline-offset:-3px!important}';
126
+ document.head.appendChild(s);
127
+ }
128
+ return { u, docH: document.documentElement.scrollHeight, docW: document.documentElement.scrollWidth };
129
+ }, paths.slice(0, 25));
130
+
131
+ const MAXH = 1000, PAD = 40;
132
+ const clipW = Math.min(info.docW, width + 480); // show what spills off-screen
133
+ let y = 0, h = Math.min(info.docH, 700);
134
+ if (info.u) {
135
+ y = Math.max(0, Math.floor(info.u.y1 - PAD));
136
+ h = Math.min(MAXH, Math.ceil(info.u.y2 - info.u.y1 + PAD * 2), info.docH - y);
137
+ }
138
+ const buf = await page.screenshot({
139
+ fullPage: true, type: 'jpeg', quality: 62,
140
+ clip: { x: 0, y, width: clipW, height: Math.max(40, h) },
141
+ });
142
+ await page.evaluate(() => document.querySelectorAll('[data-ls-hl]').forEach((e) => e.removeAttribute('data-ls-hl')));
143
+ return { data: buf.toString('base64'), viewport: width, clipW };
144
+ }
145
+
146
+ async function sweepHeight(page, height, breakpoints, o) {
147
+ const cache = new Map();
148
+ let evals = 0;
149
+ const at = async (w) => {
150
+ if (cache.has(w)) return cache.get(w);
151
+ await page.setViewportSize({ width: w, height });
152
+ await settle(page, o.settle);
153
+ const fp = await page.evaluate(fingerprint, { ignore: o.ignore, minFont: o.minFont });
154
+ fp.width = w;
155
+ fp.key = keyOf(fp);
156
+ cache.set(w, fp);
157
+ evals++;
158
+ return fp;
159
+ };
160
+
161
+ const first = await at(o.max);
162
+ const transitions = [];
163
+ let cur = first;
164
+ while (cur.width > o.min) {
165
+ const nextW = Math.max(o.min, cur.width - o.step);
166
+ const next = await at(nextW);
167
+ if (next.key === cur.key) { cur = next; continue; }
168
+ // bisect to the exact pixel where the layout stops matching `cur`
169
+ let hi = cur.width, lo = nextW;
170
+ while (hi - lo > 1) {
171
+ const mid = (hi + lo) >> 1;
172
+ if ((await at(mid)).key === cur.key) hi = mid; else lo = mid;
173
+ }
174
+ const before = await at(hi), after = await at(lo);
175
+ const diff = diffStates(before, after, breakpoints, hi);
176
+ transitions.push({ hi, lo, diff, issuesBefore: before.issues });
177
+ process.stdout.write(` ${String(hi).padStart(5)}px ${diff.category}\n`);
178
+ cur = after;
179
+ }
180
+
181
+ if (o.screenshots) {
182
+ for (const t of transitions) {
183
+ const d = t.diff;
184
+ const paths = [...new Set([
185
+ ...d.newIssues.flatMap((x) => x.paths), ...d.resolved.flatMap((x) => x.paths),
186
+ ...d.regrouped.map((x) => x.path), ...d.hidden.map((x) => x.path), ...d.shown.map((x) => x.path),
187
+ ])];
188
+ t.before = await shoot(page, t.hi, height, paths, o);
189
+ t.after = await shoot(page, t.lo, height, paths, o);
190
+ }
191
+ }
192
+
193
+ return { height, first: { width: first.width, issues: first.issues }, transitions, ranges: issueRanges(first, transitions, o.min, cur), evals };
194
+ }
195
+
196
+ async function main() {
197
+ const o = parseArgs(process.argv.slice(2));
198
+ const started = Date.now();
199
+ const browser = await chromium.launch();
200
+ const context = await browser.newContext({ deviceScaleFactor: o.dpr });
201
+ const page = await context.newPage();
202
+
203
+ console.log(`starchy ${o.url}\n widths ${o.max}→${o.min}px, heights ${o.heights.join(', ')}px`);
204
+ await page.setViewportSize({ width: o.max, height: o.heights[0] });
205
+ await page.goto(o.url, { waitUntil: 'networkidle' });
206
+ await page.evaluate(() => document.fonts && document.fonts.ready);
207
+ // freeze animations/transitions so they can't masquerade as layout changes
208
+ await page.addStyleTag({ content: '*,*::before,*::after{transition:none!important;animation:none!important;caret-color:transparent!important}' });
209
+
210
+ const breakpoints = await collectBreakpoints(page, context);
211
+ console.log(` media-query breakpoints found: ${breakpoints.map((b) => b.hi).join(', ') || 'none'}`);
212
+
213
+ const runs = [];
214
+ for (const h of o.heights) {
215
+ console.log(`\n height ${h}px`);
216
+ runs.push(await sweepHeight(page, h, breakpoints, o));
217
+ }
218
+ await browser.close();
219
+
220
+ const result = {
221
+ url: o.url, generated: new Date().toISOString(), seconds: Math.round((Date.now() - started) / 100) / 10,
222
+ options: { max: o.max, min: o.min, step: o.step, heights: o.heights, dpr: o.dpr, minFont: o.minFont, ignore: o.ignore },
223
+ breakpoints, runs,
224
+ };
225
+
226
+ let comparison = null;
227
+ if (o.baseline) comparison = compareBaseline(result, JSON.parse(fs.readFileSync(o.baseline, 'utf8')));
228
+
229
+ fs.mkdirSync(o.out, { recursive: true });
230
+ const fingerprintJson = toFingerprint(result);
231
+ fs.writeFileSync(path.join(o.out, 'fingerprint.json'), JSON.stringify(fingerprintJson, null, 2));
232
+ fs.writeFileSync(path.join(o.out, 'report.html'), renderReport(result, comparison));
233
+
234
+ const all = runs.flatMap((r) => r.transitions);
235
+ const problems = runs.reduce((n, r) => n + r.ranges.length, 0);
236
+ console.log(`\n ${all.length} layout changes · ${problems} problem${problems === 1 ? '' : 's'} · ${result.seconds}s`);
237
+ console.log(` report → ${path.join(o.out, 'report.html')}`);
238
+
239
+ if (o.failOn === 'problems' && problems > 0) process.exit(1);
240
+ if (o.failOn === 'new' && comparison && (comparison.newProblems.length || comparison.newChanges.length)) process.exit(1);
241
+ }
242
+
243
+ /** Compact, screenshot-free record for baselines and CI diffs. */
244
+ function toFingerprint(result) {
245
+ return {
246
+ url: result.url, generated: result.generated, options: result.options,
247
+ runs: result.runs.map((r) => ({
248
+ height: r.height,
249
+ changes: r.transitions.map((t) => ({
250
+ at: t.hi, category: t.diff.category,
251
+ regrouped: t.diff.regrouped.map((x) => ({ path: x.path, from: x.from, to: x.to })),
252
+ hidden: t.diff.hidden.map((x) => x.path), shown: t.diff.shown.map((x) => x.path),
253
+ })),
254
+ problems: r.ranges.map((x) => ({ key: x.issue.key, kind: x.issue.kind, labels: x.issue.labels, detail: x.issue.detail, from: x.from, to: x.to })),
255
+ })),
256
+ };
257
+ }
258
+
259
+ main().catch((e) => { console.error(e); process.exit(1); });
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "starchy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Keep your layout crisp at every width. Finds every screen width where a page's layout changes, and flags the ones that are bugs.",
5
+ "homepage": "https://starchy.dev",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/dannyclarkdev/starchy.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/dannyclarkdev/starchy/issues"
12
+ },
13
+ "bin": {
14
+ "starchy": "cli.js"
15
+ },
16
+ "main": "cli.js",
17
+ "files": [
18
+ "cli.js",
19
+ "src/",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "test": "node cli.js test/fixture.html --out test-report"
25
+ },
26
+ "keywords": [
27
+ "responsive",
28
+ "layout",
29
+ "css",
30
+ "testing",
31
+ "visual-regression",
32
+ "breakpoints",
33
+ "playwright",
34
+ "overflow",
35
+ "accessibility",
36
+ "ci"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "dependencies": {
42
+ "playwright": "^1.50.0"
43
+ },
44
+ "license": "MIT"
45
+ }
package/src/analyze.js ADDED
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ /** Hash of everything that counts as "the layout changed". */
6
+ function keyOf(fp) {
7
+ const g = Object.keys(fp.groups).sort().map((k) => k + '=' + fp.groups[k]).join(';');
8
+ const v = fp.visible.slice().sort().join(';');
9
+ const i = fp.issues.map((x) => x.key).sort().join(';');
10
+ return crypto.createHash('sha1').update(g + '\n' + v + '\n' + i).digest('hex');
11
+ }
12
+
13
+ /**
14
+ * Turn media-query condition texts into the exact sweep boundary each one
15
+ * should cause. A boundary is reported as `hi`: the layout differs between
16
+ * width `hi` and `hi - 1`.
17
+ */
18
+ function parseBreakpoints(texts) {
19
+ const toPx = (n, unit) => (unit === 'px' ? n : n * 16);
20
+ const found = new Map();
21
+ const push = (hi, text) => { if (hi > 0 && !found.has(hi)) found.set(hi, text.trim()); };
22
+ for (const t of texts) {
23
+ let m;
24
+ const re1 = /(min|max)-width\s*:\s*([\d.]+)\s*(px|em|rem)/gi;
25
+ while ((m = re1.exec(t))) {
26
+ const px = toPx(parseFloat(m[2]), m[3].toLowerCase());
27
+ push(m[1].toLowerCase() === 'min' ? Math.ceil(px) : Math.floor(px) + 1, t);
28
+ }
29
+ const re2 = /\bwidth\s*(>=|<=|>|<)\s*([\d.]+)\s*(px|em|rem)/gi;
30
+ while ((m = re2.exec(t))) {
31
+ const px = toPx(parseFloat(m[2]), m[3].toLowerCase());
32
+ push(m[1] === '>=' || m[1] === '<' ? Math.ceil(px) : Math.floor(px) + 1, t);
33
+ }
34
+ const re3 = /([\d.]+)\s*(px|em|rem)\s*(>=|<=|>|<)\s*width\b/gi;
35
+ while ((m = re3.exec(t))) {
36
+ const px = toPx(parseFloat(m[1]), m[2].toLowerCase());
37
+ // "X < width" == "width > X", "X <= width" == "width >= X"
38
+ push(m[3] === '<=' || m[3] === '>' ? Math.ceil(px) : Math.floor(px) + 1, t);
39
+ }
40
+ }
41
+ return Array.from(found, ([hi, text]) => ({ hi, text })).sort((a, b) => b.hi - a.hi);
42
+ }
43
+
44
+ /** Paths in `list` that don't have an ancestor also in `list`. */
45
+ function roots(list) {
46
+ const set = new Set(list);
47
+ return list.filter((p) => {
48
+ let i = p.lastIndexOf(' > ');
49
+ while (i > 0) {
50
+ const anc = p.slice(0, i);
51
+ if (set.has(anc)) return false;
52
+ i = anc.lastIndexOf(' > ');
53
+ }
54
+ return true;
55
+ });
56
+ }
57
+
58
+ /** What changed between the state just above a boundary and just below it. */
59
+ function diffStates(a, b, breakpoints, hi) {
60
+ const va = new Set(a.visible), vb = new Set(b.visible);
61
+
62
+ const regrouped = [];
63
+ for (const p of new Set([...Object.keys(a.groups), ...Object.keys(b.groups)])) {
64
+ if (!(va.has(p) || p === 'body') || !(vb.has(p) || p === 'body')) continue;
65
+ const from = a.groups[p] || '1', to = b.groups[p] || '1';
66
+ if (from !== to) regrouped.push({ path: p, label: a.labels[p] || b.labels[p] || p, from, to });
67
+ }
68
+ const hidden = roots(a.visible.filter((p) => !vb.has(p))).map((p) => ({ path: p, label: a.labels[p] }));
69
+ const shown = roots(b.visible.filter((p) => !va.has(p))).map((p) => ({ path: p, label: b.labels[p] }));
70
+
71
+ const ia = new Map(a.issues.map((x) => [x.key, x])), ib = new Map(b.issues.map((x) => [x.key, x]));
72
+ const newIssues = b.issues.filter((x) => !ia.has(x.key));
73
+ const resolved = a.issues.filter((x) => !ib.has(x.key));
74
+
75
+ const matched = breakpoints.filter((bp) => Math.abs(bp.hi - hi) <= 1);
76
+ const layoutChanged = regrouped.length || hidden.length || shown.length;
77
+
78
+ let category;
79
+ if (newIssues.length) category = 'problem';
80
+ else if (layoutChanged && matched.length) category = 'breakpoint';
81
+ else if (layoutChanged) category = 'fluid';
82
+ else category = 'fixed';
83
+
84
+ return { category, matched, regrouped, hidden, shown, newIssues, resolved };
85
+ }
86
+
87
+ /** Width ranges over which each issue is present. */
88
+ function issueRanges(first, transitions, min, last) {
89
+ const open = new Map();
90
+ const done = [];
91
+ for (const x of first.issues) open.set(x.key, { issue: x, from: first.width });
92
+ for (const t of transitions) {
93
+ for (const x of t.diff.resolved) {
94
+ const o = open.get(x.key);
95
+ if (o) {
96
+ const atEnd = (t.issuesBefore || []).find((y) => y.key === x.key);
97
+ done.push({ ...o, issue: atEnd || o.issue, to: t.hi });
98
+ open.delete(x.key);
99
+ }
100
+ }
101
+ for (const x of t.diff.newIssues) if (!open.has(x.key)) open.set(x.key, { issue: x, from: t.lo });
102
+ }
103
+ for (const o of open.values()) {
104
+ const atEnd = last && last.issues.find((y) => y.key === o.issue.key);
105
+ done.push({ ...o, issue: atEnd || o.issue, to: min });
106
+ }
107
+ const rank = { 'page-overflow': 0, overlap: 1, 'container-overflow': 2, 'text-clipped': 3, 'tiny-text': 4 };
108
+ return done.sort((a, b) => rank[a.issue.kind] - rank[b.issue.kind] || b.from - a.from);
109
+ }
110
+
111
+ const describeSig = (s) => {
112
+ const parts = s.split('+').map(Number);
113
+ const rows = parts.length, max = Math.max(...parts);
114
+ if (rows === 1) return `${max} side by side`;
115
+ if (max === 1) return `${rows} stacked`;
116
+ return `${rows} rows, up to ${max} across`;
117
+ };
118
+
119
+ module.exports = { keyOf, parseBreakpoints, diffStates, issueRanges, describeSig, roots };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Compare a fresh sweep against an earlier fingerprint.json.
5
+ * A change "matches" the baseline if one exists within ±4px touching the same
6
+ * containers; a problem matches if the same issue key exists at any width.
7
+ */
8
+ function compareBaseline(result, base) {
9
+ const newChanges = [], goneChanges = [], newProblems = [], fixedProblems = [];
10
+ const sig = (c) => [...c.regrouped.map((x) => x.path), ...c.hidden, ...c.shown].sort().join('|');
11
+
12
+ for (const run of result.runs) {
13
+ const b = (base.runs || []).find((r) => r.height === run.height);
14
+ if (!b) continue;
15
+ const cur = run.transitions.map((t) => ({
16
+ at: t.hi, category: t.diff.category,
17
+ sig: sig({ regrouped: t.diff.regrouped, hidden: t.diff.hidden.map((x) => x.path), shown: t.diff.shown.map((x) => x.path) }),
18
+ }));
19
+ const old = b.changes.map((c) => ({ at: c.at, category: c.category, sig: sig(c) }));
20
+ const match = (x, list) => list.some((y) => Math.abs(y.at - x.at) <= 4 && y.sig === x.sig);
21
+ for (const x of cur) if (x.sig && !match(x, old)) newChanges.push({ height: run.height, ...x });
22
+ for (const x of old) if (x.sig && !match(x, cur)) goneChanges.push({ height: run.height, ...x });
23
+
24
+ const oldKeys = new Set(b.problems.map((p) => p.key));
25
+ const curKeys = new Set(run.ranges.map((r) => r.issue.key));
26
+ for (const r of run.ranges) if (!oldKeys.has(r.issue.key)) newProblems.push({ height: run.height, kind: r.issue.kind, labels: r.issue.labels, from: r.from, to: r.to });
27
+ for (const p of b.problems) if (!curKeys.has(p.key)) fixedProblems.push({ height: run.height, ...p });
28
+ }
29
+ return { baselineDate: base.generated, newChanges, goneChanges, newProblems, fixedProblems };
30
+ }
31
+
32
+ module.exports = { compareBaseline };
@@ -0,0 +1,244 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Runs INSIDE the page (via page.evaluate). Must be self-contained.
5
+ *
6
+ * Returns a structural fingerprint of the current layout:
7
+ * - groups: for every container with 2+ in-flow children, its row signature,
8
+ * e.g. "3+3+1" = three rows holding 3, 3 and 1 items.
9
+ * - visible: paths of every rendered layout unit (block-level boxes + replaced
10
+ * elements like img/svg/button). Inline text runs are deliberately
11
+ * ignored, because text reflows at nearly every pixel.
12
+ * - issues: things that are almost always bugs (page overflow, overlap,
13
+ * content escaping its container, clipped text, tiny text).
14
+ */
15
+ function fingerprint(opts) {
16
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'HEAD', 'TITLE', 'NOSCRIPT',
17
+ 'TEMPLATE', 'BR', 'WBR', 'SOURCE', 'TRACK']);
18
+ const REPLACED = new Set(['IMG', 'SVG', 'VIDEO', 'CANVAS', 'IFRAME', 'INPUT', 'BUTTON',
19
+ 'SELECT', 'TEXTAREA', 'OBJECT', 'EMBED', 'METER', 'PROGRESS']);
20
+
21
+ let ignored = [];
22
+ if (opts.ignore && opts.ignore.length) {
23
+ try { ignored = Array.from(document.querySelectorAll(opts.ignore.join(','))); } catch (e) { /* bad selector */ }
24
+ }
25
+ const vw = document.documentElement.clientWidth;
26
+
27
+ // ---- stable element paths (usable as CSS selectors) ----
28
+ const pathCache = new Map();
29
+ const getPath = (el) => {
30
+ if (pathCache.has(el)) return pathCache.get(el);
31
+ let p;
32
+ if (el === document.body) p = 'body';
33
+ else {
34
+ const tag = el.tagName.toLowerCase();
35
+ let seg;
36
+ if (el.id && /^[A-Za-z][\w-]*$/.test(el.id)) seg = `${tag}#${el.id}`;
37
+ else {
38
+ let i = 1, s = el;
39
+ while ((s = s.previousElementSibling)) if (s.tagName === el.tagName) i++;
40
+ seg = `${tag}:nth-of-type(${i})`;
41
+ }
42
+ p = getPath(el.parentElement) + ' > ' + seg;
43
+ }
44
+ pathCache.set(el, p);
45
+ return p;
46
+ };
47
+ const label = (el) => {
48
+ if (el === document.body) return 'body';
49
+ let s = el.tagName.toLowerCase();
50
+ if (el.id) s += '#' + el.id;
51
+ const cls = (typeof el.className === 'string' ? el.className : '').trim().split(/\s+/).filter(Boolean).slice(0, 2);
52
+ if (cls.length) s += '.' + cls.join('.');
53
+ else if (!el.id && el.parentElement && el.parentElement !== document.body) {
54
+ const pe = el.parentElement, pc = (typeof pe.className === 'string' ? pe.className : '').trim().split(/\s+/).filter(Boolean)[0];
55
+ s += ' in ' + pe.tagName.toLowerCase() + (pe.id ? '#' + pe.id : pc ? '.' + pc : '');
56
+ }
57
+ return s;
58
+ };
59
+ const hasDirectText = (el) => {
60
+ for (const n of el.childNodes) if (n.nodeType === 3 && n.textContent.trim()) return true;
61
+ return false;
62
+ };
63
+
64
+ // ---- collect layout units ----
65
+ const units = new Map();
66
+ for (const el of document.body.querySelectorAll('*')) {
67
+ const TAG = el.tagName.toUpperCase();
68
+ if (SKIP.has(TAG)) continue;
69
+ if (el.ownerSVGElement) continue; // inside an <svg>
70
+ if (ignored.length && ignored.some((ig) => ig.contains(el))) continue;
71
+ const cs = getComputedStyle(el);
72
+ if (cs.display === 'none' || cs.display === 'contents') continue;
73
+ if (cs.visibility === 'hidden' || cs.visibility === 'collapse') continue;
74
+ if (cs.display === 'inline' && !REPLACED.has(TAG)) continue;
75
+ const r = el.getBoundingClientRect();
76
+ if (r.width <= 1 || r.height <= 1) continue; // empty or sr-only
77
+ units.set(el, {
78
+ el, cs, r, path: getPath(el), label: label(el),
79
+ inFlow: cs.position !== 'absolute' && cs.position !== 'fixed',
80
+ });
81
+ }
82
+
83
+ // nearest ancestor that is itself a unit
84
+ const children = new Map();
85
+ for (const u of units.values()) {
86
+ let p = u.el.parentElement;
87
+ while (p && p !== document.body && !units.has(p)) p = p.parentElement;
88
+ u.parent = p && units.has(p) ? units.get(p) : null;
89
+ const key = u.parent ? u.parent.path : 'body';
90
+ if (!children.has(key)) children.set(key, []);
91
+ children.get(key).push(u);
92
+ }
93
+
94
+ // ---- row/column signature per container ----
95
+ const groups = {};
96
+ for (const [pkey, kids] of children) {
97
+ const flow = kids.filter((k) => k.inFlow);
98
+ if (flow.length < 2) continue;
99
+ const sorted = flow.slice().sort((a, b) => a.r.top - b.r.top || a.r.left - b.r.left);
100
+ const rows = [];
101
+ let cur = null;
102
+ for (const k of sorted) {
103
+ if (cur && k.r.top < cur.bottom - 1) { cur.n++; cur.bottom = Math.max(cur.bottom, k.r.bottom); }
104
+ else { cur = { n: 1, bottom: k.r.bottom }; rows.push(cur); }
105
+ }
106
+ groups[pkey] = rows.map((r) => r.n).join('+');
107
+ }
108
+
109
+ // ---- issue detection ----
110
+ const issues = [];
111
+ const add = (kind, us, detail) => issues.push({
112
+ kind, paths: us.map((u) => u.path), labels: us.map((u) => u.label), detail,
113
+ key: kind + '|' + us.map((u) => u.path).join('|'),
114
+ });
115
+ const clippedByAncestor = (el) => {
116
+ for (let a = el.parentElement; a && a !== document.documentElement; a = a.parentElement) {
117
+ if (getComputedStyle(a).overflowX !== 'visible') return true;
118
+ }
119
+ return false;
120
+ };
121
+
122
+ // 1. page overflows horizontally -> blame the deepest element(s) poking out,
123
+ // i.e. the ones actually forcing the width, not the wrapper they stretch.
124
+ const pageOverflow = new Set();
125
+ if (document.documentElement.scrollWidth > vw + 1) {
126
+ // "passive" = only this wide because it fills its parent's content box
127
+ const passive = (u) => {
128
+ const p = u.parent;
129
+ if (!p || !u.inFlow || u.cs.float !== 'none') return false;
130
+ const inner = p.r.width - parseFloat(p.cs.paddingLeft) - parseFloat(p.cs.paddingRight)
131
+ - parseFloat(p.cs.borderLeftWidth) - parseFloat(p.cs.borderRightWidth);
132
+ const own = u.r.width + parseFloat(u.cs.marginLeft) + parseFloat(u.cs.marginRight);
133
+ return Math.abs(own - inner) <= 1;
134
+ };
135
+ const out = Array.from(units.values()).filter((u) => u.r.right > vw + 1 && !clippedByAncestor(u.el));
136
+ out.forEach((u) => pageOverflow.add(u.path));
137
+ const active = out.filter((u) => !passive(u));
138
+ // Ask the page directly: does removing this element shrink the scroll width?
139
+ // That separates real causes from elements merely carried along by a
140
+ // stretched parent. Deepest candidates first, capped for big pages.
141
+ const depth = (u) => u.path.split(' > ').length;
142
+ const baseSW = document.documentElement.scrollWidth;
143
+ const deepFirst = (a, b) => depth(b) - depth(a);
144
+ const tested = [...active.slice().sort(deepFirst), ...out.filter((u) => passive(u)).sort(deepFirst)].slice(0, 60);
145
+ const shrinks = tested.filter((u) => {
146
+ const prev = u.el.getAttribute('style');
147
+ u.el.style.setProperty('display', 'none', 'important');
148
+ const sw = document.documentElement.scrollWidth;
149
+ if (prev === null) u.el.removeAttribute('style'); else u.el.setAttribute('style', prev);
150
+ return sw < baseSW - 1;
151
+ });
152
+ // the cause is the deepest element whose removal helps; if none helps on its
153
+ // own (two equally wide offenders), fall back to the deepest active elements
154
+ const pool = shrinks.length ? shrinks : active;
155
+ const causes = pool.filter((u) => !pool.some((v) => v !== u && v.path.startsWith(u.path + ' > ')));
156
+ const byParent = new Map();
157
+ for (const u of causes) {
158
+ const k = u.parent ? u.parent.path : 'body';
159
+ if (!byParent.has(k)) byParent.set(k, []);
160
+ byParent.get(k).push(u);
161
+ }
162
+ for (const [pk, us] of byParent) {
163
+ const worst = Math.round(Math.max(...us.map((u) => u.r.right)) - vw);
164
+ const names = [...new Set(us.map((u) => u.label))];
165
+ issues.push({
166
+ kind: 'page-overflow', paths: us.map((u) => u.path),
167
+ labels: names.length === 1 && us.length > 1 ? [`${names[0]} ×${us.length}`] : names,
168
+ detail: `pushes the page ${worst}px wider than the screen`,
169
+ key: 'page-overflow|' + pk + '|' + names.join(','),
170
+ });
171
+ }
172
+ }
173
+
174
+ for (const u of units.values()) {
175
+ // 2. content escaping its container
176
+ const p = u.parent;
177
+ if (p && u.inFlow && p.cs.overflowX === 'visible' && !pageOverflow.has(u.path)) {
178
+ const over = u.r.right - p.r.right;
179
+ if (over > 1) add('container-overflow', [u, p], `extends ${Math.round(over)}px outside ${p.label}`);
180
+ }
181
+ // 3. text cut off by overflow:hidden
182
+ if ((u.cs.overflowX === 'hidden' || u.cs.overflowX === 'clip') && hasDirectText(u.el) &&
183
+ u.el.scrollWidth > u.el.clientWidth + 1) {
184
+ const ell = u.cs.textOverflow === 'ellipsis';
185
+ add('text-clipped', [u], ell ? 'text truncated with an ellipsis' : `${u.el.scrollWidth - u.el.clientWidth}px of text hidden`);
186
+ }
187
+ // 4. text below the minimum readable size
188
+ if (hasDirectText(u.el)) {
189
+ const fs = parseFloat(u.cs.fontSize);
190
+ if (fs < opts.minFont) add('tiny-text', [u], `font-size ${+fs.toFixed(2)}px (min ${opts.minFont}px)`);
191
+ }
192
+ }
193
+
194
+ // 5. in-flow siblings overlapping each other
195
+ for (const kids of children.values()) {
196
+ const flow = kids.filter((k) => (k.cs.position === 'static' || k.cs.position === 'relative') && k.cs.transform === 'none');
197
+ if (flow.length < 2 || flow.length > 120) continue;
198
+ for (let i = 0; i < flow.length; i++) {
199
+ for (let j = i + 1; j < flow.length; j++) {
200
+ const a = flow[i].r, b = flow[j].r;
201
+ const ix = Math.min(a.right, b.right) - Math.max(a.left, b.left);
202
+ const iy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
203
+ if (ix > 2 && iy > 2 && ix * iy > 16) {
204
+ add('overlap', [flow[i], flow[j]], `overlap ${Math.round(ix)}×${Math.round(iy)}px`);
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ const labels = {};
211
+ for (const u of units.values()) labels[u.path] = u.label;
212
+ labels.body = 'body';
213
+
214
+ return {
215
+ viewportWidth: vw,
216
+ docWidth: document.documentElement.scrollWidth,
217
+ groups,
218
+ visible: Array.from(units.values(), (u) => u.path),
219
+ labels,
220
+ issues,
221
+ };
222
+ }
223
+
224
+ /** Runs inside the page: list media-query condition texts it can read. */
225
+ function mediaConditions() {
226
+ const out = [], blocked = [];
227
+ const walk = (rules) => {
228
+ for (const r of rules) {
229
+ if (typeof CSSMediaRule !== 'undefined' && r instanceof CSSMediaRule) out.push(r.conditionText || r.media.mediaText);
230
+ if (r.cssRules) { try { walk(r.cssRules); } catch (e) { /* ignore */ } }
231
+ }
232
+ };
233
+ for (const s of document.styleSheets) {
234
+ try {
235
+ if (s.media && s.media.mediaText) out.push(s.media.mediaText);
236
+ walk(s.cssRules);
237
+ } catch (e) {
238
+ if (s.href) blocked.push(s.href); // cross-origin sheet; fetched from Node instead
239
+ }
240
+ }
241
+ return { out, blocked };
242
+ }
243
+
244
+ module.exports = { fingerprint, mediaConditions };
package/src/report.js ADDED
@@ -0,0 +1,219 @@
1
+ 'use strict';
2
+
3
+ const { describeSig } = require('./analyze');
4
+
5
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
6
+
7
+ const KIND = {
8
+ 'page-overflow': 'Horizontal scroll',
9
+ overlap: 'Overlap',
10
+ 'container-overflow': 'Escapes container',
11
+ 'text-clipped': 'Clipped text',
12
+ 'tiny-text': 'Tiny text',
13
+ };
14
+ const CAT = {
15
+ problem: { name: 'Problem', cls: 'c-problem' },
16
+ fluid: { name: 'Fluid change', cls: 'c-fluid' },
17
+ breakpoint: { name: 'Media query', cls: 'c-bp' },
18
+ fixed: { name: 'Resolved', cls: 'c-fixed' },
19
+ };
20
+
21
+ const capList = (items, n = 8) => {
22
+ if (items.length <= n) return items.join('');
23
+ return items.slice(0, n).join('') + `<li class="more">and ${items.length - n} more</li>`;
24
+ };
25
+ const code = (s) => `<code>${esc(s)}</code>`;
26
+
27
+ function ruler(run, opts, breakpoints) {
28
+ const { max, min } = opts;
29
+ const pos = (w) => ((max - w) / (max - min)) * 100;
30
+ const bands = run.ranges
31
+ .filter((r) => r.issue.kind !== 'tiny-text')
32
+ .map((r) => `<span class="band" style="left:${pos(r.from)}%;width:${Math.max(0.4, pos(r.to) - pos(r.from))}%"></span>`).join('');
33
+ const marks = run.transitions.map((t) =>
34
+ `<a class="mark ${CAT[t.diff.category].cls}" href="#h${run.height}-w${t.hi}" style="left:${pos(t.hi)}%" title="${t.hi}px · ${CAT[t.diff.category].name}"></a>`).join('');
35
+ const ticks = breakpoints.filter((b) => b.hi <= max && b.hi >= min)
36
+ .map((b) => `<span class="tick" style="left:${pos(b.hi)}%" title="${esc(b.text)}"></span>`).join('');
37
+ const scale = [max, Math.round(max - (max - min) / 3), Math.round(max - (2 * (max - min)) / 3), min]
38
+ .map((w, i) => `<span style="left:${[0, 33.33, 66.66, 100][i]}%">${w}</span>`).join('');
39
+ return `<div class="ruler" role="img" aria-label="Layout changes from ${max}px to ${min}px">
40
+ <div class="track">${bands}${marks}</div><div class="ticks">${ticks}</div><div class="scale">${scale}</div></div>`;
41
+ }
42
+
43
+ function problemList(run) {
44
+ if (!run.ranges.length) return '<p class="ok">No problems detected at any width.</p>';
45
+ const rows = run.ranges.map((r) => `<li>
46
+ <span class="kind k-${r.issue.kind}">${KIND[r.issue.kind]}</span>
47
+ <span class="who">${r.issue.labels.map(code).join(' ↔ ')}</span>
48
+ <span class="detail">${esc(r.issue.detail)}${r.from !== r.to ? ` <span class="at">at ${r.to}px</span>` : ''}</span>
49
+ <span class="range">${r.from === r.to ? r.from + 'px' : `${r.from} → ${r.to}px`}</span>
50
+ </li>`);
51
+ return `<ul class="problems">${capList(rows, 30)}</ul>`;
52
+ }
53
+
54
+ function changeCard(run, t) {
55
+ const d = t.diff, c = CAT[d.category];
56
+ const items = [
57
+ ...d.newIssues.map((x) => `<li class="bad"><b>${KIND[x.kind]}:</b> ${x.labels.map(code).join(' ↔ ')} — ${esc(x.detail)}</li>`),
58
+ ...d.regrouped.map((x) => `<li>${code(x.label)} ${esc(describeSig(x.from))} → <b>${esc(describeSig(x.to))}</b></li>`),
59
+ ...d.hidden.map((x) => `<li>${code(x.label)} hidden</li>`),
60
+ ...d.shown.map((x) => `<li>${code(x.label)} appears</li>`),
61
+ ...d.resolved.map((x) => `<li class="good">Resolved: ${KIND[x.kind]} on ${x.labels.map(code).join(' ↔ ')}</li>`),
62
+ ];
63
+ const why = d.matched.length
64
+ ? `<p class="why">Matches ${d.matched.map((m) => code('@media ' + m.text)).join(', ')}</p>`
65
+ : (d.category === 'fluid' ? '<p class="why">No media query here — caused by wrapping, auto-fit, or content size. Confirm it\'s intended.</p>' : '');
66
+ const shots = t.before && t.after ? `<div class="shots">
67
+ <figure><img loading="lazy" src="data:image/jpeg;base64,${t.before.data}" alt="Layout at ${t.hi}px"><figcaption>${t.hi}px</figcaption></figure>
68
+ <figure><img loading="lazy" src="data:image/jpeg;base64,${t.after.data}" alt="Layout at ${t.lo}px"><figcaption>${t.lo}px</figcaption></figure>
69
+ </div>` : '';
70
+ return `<article class="change" id="h${run.height}-w${t.hi}">
71
+ <header><span class="w">${t.hi}<small>→${t.lo}px</small></span><span class="cat ${c.cls}">${c.name}</span></header>
72
+ ${why}<ul class="what">${capList(items)}</ul>${shots}</article>`;
73
+ }
74
+
75
+ /**
76
+ * Collapse consecutive fluid changes to the same container(s) into one entry,
77
+ * e.g. a nav that rewraps 7 times is one thing to review, not seven.
78
+ */
79
+ function groupTransitions(transitions) {
80
+ const items = [];
81
+ const sigOf = (t) => (t.diff.category === 'fluid' && !t.diff.hidden.length && !t.diff.shown.length
82
+ ? t.diff.regrouped.map((x) => x.path).sort().join('|') : null);
83
+ for (const t of transitions) {
84
+ const s = sigOf(t), last = items[items.length - 1];
85
+ if (s && last && last.sig === s) last.series.push(t);
86
+ else items.push({ sig: s, series: [t] });
87
+ }
88
+ return items;
89
+ }
90
+
91
+ function seriesCard(run, item) {
92
+ const s = item.series, first = s[0], last = s[s.length - 1];
93
+ const steps = s.map((t) => t.diff.regrouped.map((x) => `<li><span class="sw">${t.hi}px</span> ${code(x.label)} <b>${esc(x.to)}</b> <span class="muted">(${esc(describeSig(x.to))})</span></li>`).join(''));
94
+ const who = first.diff.regrouped.map((x) => code(x.label)).join(', ');
95
+ const shots = first.before && last.after ? `<div class="shots">
96
+ <figure><img loading="lazy" src="data:image/jpeg;base64,${first.before.data}" alt="Layout at ${first.hi}px"><figcaption>${first.hi}px</figcaption></figure>
97
+ <figure><img loading="lazy" src="data:image/jpeg;base64,${last.after.data}" alt="Layout at ${last.lo}px"><figcaption>${last.lo}px</figcaption></figure>
98
+ </div>` : '';
99
+ return `<article class="change" id="h${run.height}-w${first.hi}">
100
+ <header><span class="w">${first.hi}<small>→${last.lo}px</small></span><span class="cat c-fluid">Fluid change ×${s.length}</span></header>
101
+ <p class="why">${who} rewraps ${s.length} times with no media query, starting as ${esc(first.diff.regrouped[0].from)} (${esc(describeSig(first.diff.regrouped[0].from))}). Uneven rows like 5+1 usually mean it needs a breakpoint or a grid.</p>
102
+ <ul class="what steps">${steps.join('')}</ul>${shots}</article>`;
103
+ }
104
+
105
+ function baselineBlock(cmp) {
106
+ if (!cmp) return '';
107
+ const li = (arr, fmt) => arr.length ? `<ul>${arr.map(fmt).join('')}</ul>` : '<p class="muted">None</p>';
108
+ return `<section class="baseline"><h2>Compared with baseline <small>${esc(cmp.baselineDate)}</small></h2>
109
+ <div class="cols">
110
+ <div><h3>New problems</h3>${li(cmp.newProblems, (p) => `<li><span class="kind k-${p.kind}">${KIND[p.kind]}</span> ${p.labels.map(code).join(' ↔ ')} <span class="range">${p.from} → ${p.to}px</span></li>`)}</div>
111
+ <div><h3>Fixed problems</h3>${li(cmp.fixedProblems, (p) => `<li>${KIND[p.kind]} ${p.labels.map(code).join(' ↔ ')}</li>`)}</div>
112
+ <div><h3>New layout changes</h3>${li(cmp.newChanges, (c) => `<li>${c.at}px · ${CAT[c.category].name}</li>`)}</div>
113
+ <div><h3>Changes no longer happening</h3>${li(cmp.goneChanges, (c) => `<li>${c.at}px · ${CAT[c.category].name}</li>`)}</div>
114
+ </div></section>`;
115
+ }
116
+
117
+ function renderReport(result, comparison) {
118
+ const o = result.options;
119
+ const all = result.runs.flatMap((r) => r.transitions);
120
+ const grouped = result.runs.map((r) => groupTransitions(r.transitions));
121
+ const count = (cat) => grouped.flat().filter((g) => g.series[0].diff.category === cat).length;
122
+ const problems = result.runs.reduce((n, r) => n + r.ranges.length, 0);
123
+ let host = result.url;
124
+ try { const u = new URL(result.url); host = u.protocol === 'file:' ? u.pathname.split('/').pop() : u.host + u.pathname; } catch (e) { /* keep */ }
125
+
126
+ const runs = result.runs.map((run, ri) => `
127
+ <section class="run">
128
+ ${result.runs.length > 1 ? `<h2>Height ${run.height}px</h2>` : ''}
129
+ ${ruler(run, o, result.breakpoints)}
130
+ <div class="legend"><span class="c-problem">Problem</span><span class="c-fluid">Fluid change</span><span class="c-bp">Media query</span><span class="c-fixed">Resolved</span><span class="lg-band">Problem range</span><span class="lg-tick">Your breakpoints</span></div>
131
+ <h3>Problems by width range</h3>
132
+ ${problemList(run)}
133
+ <h3>Every layout change, widest first</h3>
134
+ ${run.transitions.length ? grouped[ri].map((g) => (g.series.length > 1 ? seriesCard(run, g) : changeCard(run, g.series[0]))).join('') : '<p class="ok">Layout never changes in this range.</p>'}
135
+ </section>`).join('');
136
+
137
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
138
+ <meta name="viewport" content="width=device-width,initial-scale=1">
139
+ <title>Starchy Report</title>
140
+ <style>
141
+ :root{--bg:#f7f7f5;--card:#fff;--ink:#1b1b1a;--muted:#6b6a66;--line:#e4e3df;--code:#f0efeb;
142
+ --red:#d93a3f;--amber:#c98a06;--green:#2f8f5b;--gray:#8a8984;--accent:#3b5bdb}
143
+ @media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#141413;--card:#1d1d1b;--ink:#ecebe7;--muted:#9b9a95;--line:#2e2e2b;--code:#262624;--red:#f0656a;--amber:#e3a826;--green:#53b582;--gray:#77766f;--accent:#7c95f0}}
144
+ :root[data-theme=dark]{--bg:#141413;--card:#1d1d1b;--ink:#ecebe7;--muted:#9b9a95;--line:#2e2e2b;--code:#262624;--red:#f0656a;--amber:#e3a826;--green:#53b582;--gray:#77766f;--accent:#7c95f0}
145
+ *{box-sizing:border-box}
146
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
147
+ main{max-width:1080px;margin:0 auto;padding:28px 16px 64px}
148
+ h1{font-size:26px;letter-spacing:-.02em;margin:0 0 4px}h1 .tag{font-size:13px;font-weight:500;letter-spacing:0;color:var(--muted);margin-left:6px}footer a{color:inherit}
149
+ h2{font-size:19px;margin:36px 0 12px}h2 small{color:var(--muted);font-weight:400;font-size:13px}
150
+ h3{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin:28px 0 10px}
151
+ .sub{color:var(--muted);margin:0 0 18px;word-break:break-all}
152
+ code{font:12.5px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--code);padding:1px 5px;border-radius:4px;word-break:break-all}
153
+ .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:18px 0 8px}
154
+ .stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px}
155
+ .stat b{display:block;font-size:26px;line-height:1.1;font-variant-numeric:tabular-nums}
156
+ .stat span{color:var(--muted);font-size:12.5px}
157
+ .stat.s-problem b{color:var(--red)}.stat.s-fluid b{color:var(--amber)}.stat.s-bp b{color:var(--green)}
158
+ .ruler{margin:22px 0 6px}
159
+ .track{position:relative;height:34px;background:var(--card);border:1px solid var(--line);border-radius:8px;overflow:hidden}
160
+ .band{position:absolute;top:0;bottom:0;background:color-mix(in srgb,var(--red) 16%,transparent)}
161
+ .mark{position:absolute;top:5px;bottom:5px;width:4px;margin-left:-2px;border-radius:2px}
162
+ .ticks{position:relative;height:9px}.tick{position:absolute;top:2px;width:2px;height:7px;margin-left:-1px;background:var(--accent)}
163
+ .scale{position:relative;height:18px;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
164
+ .scale span{position:absolute;transform:translateX(-50%)}.scale span:first-child{transform:none}.scale span:last-child{transform:translateX(-100%)}
165
+ .legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin-top:6px}
166
+ .legend span::before{content:"";display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px;vertical-align:-1px}
167
+ .mark.c-problem,.legend .c-problem::before{background:var(--red)}
168
+ .mark.c-fluid,.legend .c-fluid::before{background:var(--amber)}
169
+ .mark.c-bp,.legend .c-bp::before{background:var(--green)}
170
+ .mark.c-fixed,.legend .c-fixed::before{background:var(--gray)}
171
+ .legend .lg-band::before{background:color-mix(in srgb,var(--red) 25%,transparent)}
172
+ .legend .lg-tick::before{background:var(--accent);width:3px}
173
+ .problems{list-style:none;padding:0;margin:0;background:var(--card);border:1px solid var(--line);border-radius:10px}
174
+ .problems li{display:grid;grid-template-columns:auto 1fr auto;gap:4px 12px;padding:10px 14px;border-top:1px solid var(--line);align-items:baseline}
175
+ .problems li:first-child{border-top:0}
176
+ .problems .detail{grid-column:2;color:var(--muted);font-size:13px}
177
+ .problems .range{grid-column:3;grid-row:1;font-variant-numeric:tabular-nums;font-size:13px;white-space:nowrap}
178
+ .problems .more{display:block;color:var(--muted)}.problems .kind{justify-self:start}.at{opacity:.75}
179
+ .kind{font-size:11.5px;font-weight:600;padding:2px 8px;border-radius:99px;white-space:nowrap;color:var(--red);background:color-mix(in srgb,var(--red) 12%,transparent)}
180
+ .k-text-clipped,.k-tiny-text{color:var(--amber);background:color-mix(in srgb,var(--amber) 14%,transparent)}
181
+ .ok{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px;color:var(--green);margin:0}
182
+ .change{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin:0 0 12px;scroll-margin-top:12px}
183
+ .change header{display:flex;align-items:center;justify-content:space-between;gap:10px}
184
+ .w{font-size:20px;font-weight:650;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
185
+ .w small{font-size:12px;font-weight:400;color:var(--muted);margin-left:4px}
186
+ .cat{font-size:12px;font-weight:600;padding:3px 10px;border-radius:99px}
187
+ .cat.c-problem{color:var(--red);background:color-mix(in srgb,var(--red) 13%,transparent)}
188
+ .cat.c-fluid{color:var(--amber);background:color-mix(in srgb,var(--amber) 15%,transparent)}
189
+ .cat.c-bp{color:var(--green);background:color-mix(in srgb,var(--green) 14%,transparent)}
190
+ .cat.c-fixed{color:var(--muted);background:var(--code)}
191
+ .why{margin:6px 0 0;font-size:13px;color:var(--muted)}
192
+ .what{margin:10px 0 0;padding-left:18px}.what li{margin:3px 0}
193
+ .steps{list-style:none;padding:0}.steps li{display:flex;flex-wrap:wrap;gap:6px;align-items:baseline}.sw{font-variant-numeric:tabular-nums;color:var(--muted);min-width:52px;font-size:13px}
194
+ .what .bad{color:var(--red)}.what .bad code{color:var(--ink)}.what .good{color:var(--green)}.what .more{list-style:none;color:var(--muted)}
195
+ .shots{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:14px;align-items:start}
196
+ figure{margin:0}figure img{display:block;width:100%;height:auto;max-height:420px;object-fit:contain;object-position:top left;border:1px solid var(--line);border-radius:6px;background:#fff}
197
+ figcaption{font-size:12px;color:var(--muted);margin-top:4px;font-variant-numeric:tabular-nums}
198
+ .baseline .cols{display:grid;grid-template-columns:1fr 1fr;gap:12px}
199
+ .baseline .cols>div{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:2px 14px 10px}
200
+ .baseline ul{padding-left:18px;margin:0}.muted{color:var(--muted);margin:0}
201
+ footer{margin-top:40px;color:var(--muted);font-size:12.5px}
202
+ @media (max-width:640px){.stats{grid-template-columns:repeat(2,1fr)}.shots,.baseline .cols{grid-template-columns:1fr}
203
+ .problems li{grid-template-columns:1fr auto}.problems .who,.problems .detail{grid-column:1/-1}.problems .range{grid-column:2;grid-row:1}}
204
+ </style></head><body><main>
205
+ <h1>Starchy <span class="tag">layout report</span></h1>
206
+ <p class="sub">${esc(host)} · ${o.max}→${o.min}px${result.runs.length > 1 ? ' · heights ' + o.heights.join(', ') + 'px' : ' · ' + o.heights[0] + 'px tall'} · ${new Date(result.generated).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })}</p>
207
+ <div class="stats">
208
+ <div class="stat s-problem"><b>${problems}</b><span>problems</span></div>
209
+ <div class="stat s-fluid"><b>${count('fluid')}</b><span>fluid changes to review</span></div>
210
+ <div class="stat s-bp"><b>${count('breakpoint')}</b><span>media-query changes</span></div>
211
+ <div class="stat"><b>${all.length}</b><span>layout changes total</span></div>
212
+ </div>
213
+ ${baselineBlock(comparison)}
214
+ ${runs}
215
+ <footer><a href="https://starchy.dev">starchy</a> · swept in ${result.seconds}s · ${result.runs.reduce((n, r) => n + r.evals, 0)} widths fingerprinted · breakpoints read from your CSS: ${result.breakpoints.map((b) => b.hi + 'px').join(', ') || 'none'}</footer>
216
+ </main></body></html>`;
217
+ }
218
+
219
+ module.exports = { renderReport };