styleproof 3.19.0 → 3.21.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 +131 -0
- package/README.md +20 -5
- package/bin/styleproof-diff.mjs +97 -22
- package/bin/styleproof-init.mjs +49 -20
- package/dist/capture.d.ts +8 -0
- package/dist/capture.js +22 -0
- package/dist/change-groups.d.ts +91 -0
- package/dist/change-groups.js +471 -0
- package/dist/cli-errors.js +7 -2
- package/dist/crawl.js +16 -4
- package/dist/diff.js +35 -0
- package/dist/report.d.ts +1 -4
- package/dist/report.js +70 -352
- package/dist/runner.js +109 -29
- package/package.json +2 -2
package/dist/report.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type PropChange } from './diff.js';
|
|
2
1
|
export { describeChange, colorName, tokenIndex, toHex } from './describe.js';
|
|
2
|
+
export { summarizeProps, prettyLabel } from './change-groups.js';
|
|
3
3
|
/**
|
|
4
4
|
* Visual diff report: for every surface with findings, crop the before/after
|
|
5
5
|
* full-page screenshots around the changed elements and write a markdown
|
|
@@ -77,8 +77,5 @@ export type ReportResult = {
|
|
|
77
77
|
reportMdPath: string;
|
|
78
78
|
reportJsonPath: string;
|
|
79
79
|
};
|
|
80
|
-
export declare function summarizeProps(props: PropChange[]): PropChange[];
|
|
81
|
-
/** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
|
|
82
|
-
export declare function prettyLabel(p: string, cls: string): string;
|
|
83
80
|
export declare function excerptPair(before: string, after: string): [string, string];
|
|
84
81
|
export declare function generateStyleMapReport(opts: ReportOptions): ReportResult;
|
package/dist/report.js
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { PNG } from 'pngjs';
|
|
4
|
-
import { loadStyleMap, readInventories, } from './capture.js';
|
|
4
|
+
import { loadStyleMap, readInventories, surfaceElementPaths, } from './capture.js';
|
|
5
5
|
import { isMapFile } from './map-store.js';
|
|
6
6
|
import { fillRect } from './png-util.js';
|
|
7
7
|
import { diffStyleMapDirs, diffContentDirs, } from './diff.js';
|
|
8
|
-
import { describeChange, tokenIndex, toHex
|
|
8
|
+
import { describeChange, tokenIndex, toHex } from './describe.js';
|
|
9
9
|
import { auditCoverage, auditDeterminism, COVERAGE_LEDGER, } from './coverage.js';
|
|
10
10
|
import { auditRunInventory, readAckFile } from './inventory.js';
|
|
11
|
+
// The pure grouping / classification brain — shared with the CLI. report.ts keeps
|
|
12
|
+
// the crop-and-PNG rendering on top of these.
|
|
13
|
+
import { cleanFindings, groupByPath, groupTitle, isNonValue, prettyLabel, safeKey, signatureOf, summarizeProps, surfaceBase, surfaceWidth, pushSurfaceWidth, renderSurfaceGroups, formatSurfaceList, classifyChrome, } from './change-groups.js';
|
|
11
14
|
// Re-export the plain-English summariser so consumers (and tests) reach it
|
|
12
15
|
// through the package's report module rather than a deep path.
|
|
13
16
|
export { describeChange, colorName, tokenIndex, toHex } from './describe.js';
|
|
17
|
+
// Re-export the grouping primitives historically exported from here so existing
|
|
18
|
+
// imports (`from 'styleproof'` → report) keep resolving.
|
|
19
|
+
export { summarizeProps, prettyLabel } from './change-groups.js';
|
|
14
20
|
// Hidden marker appended to a new-surface heading. Invisible in rendered
|
|
15
21
|
// markdown; lets the PR-comment layer recognize one-sided surfaces.
|
|
16
22
|
const NEW_SURFACE_MARKER = '<!-- styleproof-new -->';
|
|
@@ -52,16 +58,6 @@ function changeCountLabel(shown) {
|
|
|
52
58
|
parts.push(`${shown.state} state-delta difference(s)`);
|
|
53
59
|
return parts.join(' · ');
|
|
54
60
|
}
|
|
55
|
-
/** Group findings by their element path (one group per changed element). */
|
|
56
|
-
function groupByPath(findings) {
|
|
57
|
-
const byPath = new Map();
|
|
58
|
-
for (const f of findings) {
|
|
59
|
-
const arr = byPath.get(f.path) ?? [];
|
|
60
|
-
arr.push(f);
|
|
61
|
-
byPath.set(f.path, arr);
|
|
62
|
-
}
|
|
63
|
-
return [...byPath.values()];
|
|
64
|
-
}
|
|
65
61
|
function groupRegions(paths, a, b, padBy) {
|
|
66
62
|
const groups = paths.map((p) => {
|
|
67
63
|
const ra = a.elements[p]?.rect;
|
|
@@ -197,224 +193,8 @@ function readPng(file) {
|
|
|
197
193
|
return null;
|
|
198
194
|
return PNG.sync.read(fs.readFileSync(file));
|
|
199
195
|
}
|
|
200
|
-
// --- readable findings: dedupe
|
|
201
|
-
//
|
|
202
|
-
// Logical longhand → its physical equivalent (LTR, horizontal-tb). Dropped when
|
|
203
|
-
// the physical one changed identically, so each change appears once.
|
|
204
|
-
const LOGICAL_TO_PHYSICAL = (() => {
|
|
205
|
-
const m = {};
|
|
206
|
-
const sides = {
|
|
207
|
-
'block-start': 'top',
|
|
208
|
-
'block-end': 'bottom',
|
|
209
|
-
'inline-start': 'left',
|
|
210
|
-
'inline-end': 'right',
|
|
211
|
-
};
|
|
212
|
-
for (const [l, p] of Object.entries(sides)) {
|
|
213
|
-
for (const k of ['color', 'width', 'style'])
|
|
214
|
-
m[`border-${l}-${k}`] = `border-${p}-${k}`;
|
|
215
|
-
m[`margin-${l}`] = `margin-${p}`;
|
|
216
|
-
m[`padding-${l}`] = `padding-${p}`;
|
|
217
|
-
m[`inset-${l}`] = p;
|
|
218
|
-
}
|
|
219
|
-
const radius = {
|
|
220
|
-
'start-start': 'top-left',
|
|
221
|
-
'start-end': 'top-right',
|
|
222
|
-
'end-start': 'bottom-left',
|
|
223
|
-
'end-end': 'bottom-right',
|
|
224
|
-
};
|
|
225
|
-
for (const [l, p] of Object.entries(radius))
|
|
226
|
-
m[`border-${l}-radius`] = `border-${p}-radius`;
|
|
227
|
-
return m;
|
|
228
|
-
})();
|
|
229
|
-
// Length-valued 4-side families → CSS 1–4-value shorthand whenever all four
|
|
230
|
-
// sides changed (e.g. `padding: 26px 24px → 28px`).
|
|
231
|
-
const BOX4 = [
|
|
232
|
-
{ short: 'margin', parts: ['top', 'right', 'bottom', 'left'].map((s) => `margin-${s}`) },
|
|
233
|
-
{ short: 'padding', parts: ['top', 'right', 'bottom', 'left'].map((s) => `padding-${s}`) },
|
|
234
|
-
{ short: 'border-width', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-width`) },
|
|
235
|
-
{
|
|
236
|
-
short: 'border-radius',
|
|
237
|
-
parts: ['top-left', 'top-right', 'bottom-right', 'bottom-left'].map((s) => `border-${s}-radius`),
|
|
238
|
-
},
|
|
239
|
-
];
|
|
240
|
-
// Colour/keyword families → one row only when all four sides match (else
|
|
241
|
-
// per-side, which keeps each colour swatchable).
|
|
242
|
-
const UNIFORM = [
|
|
243
|
-
{ short: 'border-color', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-color`) },
|
|
244
|
-
{ short: 'border-style', parts: ['top', 'right', 'bottom', 'left'].map((s) => `border-${s}-style`) },
|
|
245
|
-
];
|
|
246
|
-
const boxShorthand = ([t, r, b, l]) => t === r && r === b && b === l
|
|
247
|
-
? t
|
|
248
|
-
: t === b && r === l
|
|
249
|
-
? `${t} ${r}`
|
|
250
|
-
: r === l
|
|
251
|
-
? `${t} ${r} ${b}`
|
|
252
|
-
: `${t} ${r} ${b} ${l}`;
|
|
253
|
-
const PROP_ORDER = [
|
|
254
|
-
'display',
|
|
255
|
-
'position',
|
|
256
|
-
'grid-template-columns',
|
|
257
|
-
'grid-template-rows',
|
|
258
|
-
'flex-direction',
|
|
259
|
-
'justify-content',
|
|
260
|
-
'align-items',
|
|
261
|
-
'gap',
|
|
262
|
-
'margin',
|
|
263
|
-
'padding',
|
|
264
|
-
'border-width',
|
|
265
|
-
'border-style',
|
|
266
|
-
'border-color',
|
|
267
|
-
'border-radius',
|
|
268
|
-
'outline',
|
|
269
|
-
'background-color',
|
|
270
|
-
'background-image',
|
|
271
|
-
'color',
|
|
272
|
-
'box-shadow',
|
|
273
|
-
'opacity',
|
|
274
|
-
'transform',
|
|
275
|
-
'font-family',
|
|
276
|
-
'font-size',
|
|
277
|
-
'font-weight',
|
|
278
|
-
'line-height',
|
|
279
|
-
'letter-spacing',
|
|
280
|
-
'text-transform',
|
|
281
|
-
'text-align',
|
|
282
|
-
];
|
|
283
|
-
const orderIdx = (p) => {
|
|
284
|
-
const i = PROP_ORDER.indexOf(p);
|
|
285
|
-
return i === -1 ? PROP_ORDER.length : i;
|
|
286
|
-
};
|
|
287
|
-
function cleanVal(v) {
|
|
288
|
-
// Verbatim by design: no rounding. Rounding once showed alpha 0.18 as 0.2 —
|
|
289
|
-
// and could erase a real 0.18→0.2 diff entirely via the no-op filter below.
|
|
290
|
-
let s = v;
|
|
291
|
-
if (!s.includes('(')) {
|
|
292
|
-
const toks = s.split(' ');
|
|
293
|
-
if (toks.length > 1 && new Set(toks).size === 1)
|
|
294
|
-
s = `${toks[0]} ×${toks.length}`;
|
|
295
|
-
}
|
|
296
|
-
return s.replace(/rgba\(\s*0,\s*0,\s*0,\s*0\s*\)/g, 'transparent');
|
|
297
|
-
}
|
|
298
|
-
// These default to `currentColor`, so a `color` change drags them all along —
|
|
299
|
-
// pure echoes, dropped when they match the `color` change.
|
|
300
|
-
const CURRENTCOLOR_FOLLOWERS = [
|
|
301
|
-
'caret-color',
|
|
302
|
-
'outline-color',
|
|
303
|
-
'column-rule-color',
|
|
304
|
-
'text-decoration-color',
|
|
305
|
-
'text-emphasis-color',
|
|
306
|
-
'-webkit-text-fill-color',
|
|
307
|
-
'-webkit-text-stroke-color',
|
|
308
|
-
];
|
|
309
|
-
// "No value here" markers: a forced-state delta that doesn't apply, an unset
|
|
310
|
-
// longhand, or a capture artifact where a path didn't line up. A change BETWEEN
|
|
311
|
-
// two of these (e.g. `— → (gone)`) is meaningless and must never read as a diff.
|
|
312
|
-
const NON_VALUE = new Set(['(state does not change it)', '(state no longer changes it)', '(unset)', '(gone)']);
|
|
313
|
-
const isNonValue = (v) => NON_VALUE.has(v);
|
|
314
|
-
/** Combine longhands into a shorthand value; all-non-value sides collapse to one. */
|
|
315
|
-
const combineValues = (vals) => (vals.every(isNonValue) ? '(unset)' : vals.join(' '));
|
|
316
|
-
export function summarizeProps(props) {
|
|
317
|
-
const map = new Map(props.map((p) => [p.prop, { ...p }]));
|
|
318
|
-
for (const [logical, physical] of Object.entries(LOGICAL_TO_PHYSICAL)) {
|
|
319
|
-
const lo = map.get(logical);
|
|
320
|
-
const ph = map.get(physical);
|
|
321
|
-
if (lo && ph && lo.before === ph.before && lo.after === ph.after)
|
|
322
|
-
map.delete(logical);
|
|
323
|
-
}
|
|
324
|
-
const color = map.get('color');
|
|
325
|
-
if (color)
|
|
326
|
-
for (const f of CURRENTCOLOR_FOLLOWERS) {
|
|
327
|
-
const m = map.get(f);
|
|
328
|
-
if (m && m.before === color.before && m.after === color.after)
|
|
329
|
-
map.delete(f);
|
|
330
|
-
}
|
|
331
|
-
for (const fam of BOX4) {
|
|
332
|
-
const members = fam.parts.map((p) => map.get(p));
|
|
333
|
-
if (members.every((m) => !!m)) {
|
|
334
|
-
fam.parts.forEach((p) => map.delete(p));
|
|
335
|
-
map.set(fam.short, {
|
|
336
|
-
prop: fam.short,
|
|
337
|
-
before: boxShorthand(members.map((m) => m.before)),
|
|
338
|
-
after: boxShorthand(members.map((m) => m.after)),
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
const rg = map.get('row-gap');
|
|
343
|
-
const cg = map.get('column-gap');
|
|
344
|
-
if (rg && cg) {
|
|
345
|
-
map.delete('row-gap');
|
|
346
|
-
map.delete('column-gap');
|
|
347
|
-
map.set('gap', {
|
|
348
|
-
prop: 'gap',
|
|
349
|
-
before: rg.before === cg.before ? rg.before : `${rg.before} ${cg.before}`,
|
|
350
|
-
after: rg.after === cg.after ? rg.after : `${rg.after} ${cg.after}`,
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
for (const fam of UNIFORM) {
|
|
354
|
-
const members = fam.parts.map((p) => map.get(p)).filter((m) => !!m);
|
|
355
|
-
if (members.length === fam.parts.length &&
|
|
356
|
-
members.every((m) => m.before === members[0].before && m.after === members[0].after)) {
|
|
357
|
-
fam.parts.forEach((p) => map.delete(p));
|
|
358
|
-
map.set(fam.short, { prop: fam.short, before: members[0].before, after: members[0].after });
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
// outline-width/-style/-color → one `outline` row (offset stays separate).
|
|
362
|
-
const ow = map.get('outline-width');
|
|
363
|
-
const os = map.get('outline-style');
|
|
364
|
-
const oc = map.get('outline-color');
|
|
365
|
-
if (ow && os && oc) {
|
|
366
|
-
map.delete('outline-width');
|
|
367
|
-
map.delete('outline-style');
|
|
368
|
-
map.delete('outline-color');
|
|
369
|
-
map.set('outline', {
|
|
370
|
-
prop: 'outline',
|
|
371
|
-
before: combineValues([ow.before, os.before, oc.before]),
|
|
372
|
-
after: combineValues([ow.after, os.after, oc.after]),
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
return ([...map.values()]
|
|
376
|
-
.map((p) => ({ prop: p.prop, before: cleanVal(p.before), after: cleanVal(p.after) }))
|
|
377
|
-
// Drop no-ops: a value that didn't actually change, or a change between two
|
|
378
|
-
// "no value here" markers (`— → (gone)`), which carries no information.
|
|
379
|
-
.filter((p) => p.before !== p.after && !(isNonValue(p.before) && isNonValue(p.after)))
|
|
380
|
-
.sort((a, b) => orderIdx(a.prop) - orderIdx(b.prop) || a.prop.localeCompare(b.prop)));
|
|
381
|
-
}
|
|
382
|
-
/** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
|
|
383
|
-
export function prettyLabel(p, cls) {
|
|
384
|
-
const tag = (p.split('>').pop() ?? '').trim().replace(/:nth-child\(\d+\)/, '') || 'el';
|
|
385
|
-
const first = cls.split(/\s+/)[0] ?? '';
|
|
386
|
-
return /^[a-z][a-z0-9-]*$/.test(first) ? `${tag}.${first}` : tag;
|
|
387
|
-
}
|
|
388
|
-
// Surface keys originate from artifact filenames — attacker-controlled in the
|
|
389
|
-
// fork capture/report split, and they flow into the PRIVILEGED PR-comment summary
|
|
390
|
-
// (the Action slices report.md above the first `### `). Strip the Markdown/HTML
|
|
391
|
-
// control characters (`` ` ``, [ ] ( ), < >, |) that could inject a link, image,
|
|
392
|
-
// or table into that bot comment. Escaping at the render boundary — the keys stay
|
|
393
|
-
// legible; only the injection surface is removed. (Crop FILENAMES are separately
|
|
394
|
-
// restricted to [a-z0-9-]; this is the display-side equivalent.)
|
|
395
|
-
const safeKey = (s) => s.replace(/[`[\]()<>|]/g, '-');
|
|
396
|
-
const surfaceBase = (s) => s.replace(/@\d+$/, '');
|
|
397
|
-
const surfaceWidth = (s) => Number(s.match(/@(\d+)$/)?.[1] ?? 0);
|
|
398
|
-
function pushSurfaceWidth(byBase, base, surface) {
|
|
399
|
-
const arr = byBase.get(base) ?? [];
|
|
400
|
-
arr.push(surfaceWidth(surface));
|
|
401
|
-
byBase.set(base, arr);
|
|
402
|
-
}
|
|
403
|
-
function renderSurfaceGroups(byBase) {
|
|
404
|
-
return [...byBase]
|
|
405
|
-
.map(([base, ws]) => {
|
|
406
|
-
const widths = ws.filter((w) => w > 0).sort((a, b) => b - a);
|
|
407
|
-
return widths.length ? `${safeKey(base)} @ ${widths.join(', ')}` : safeKey(base);
|
|
408
|
-
})
|
|
409
|
-
.join(' · ');
|
|
410
|
-
}
|
|
411
|
-
/** "landing @ 1280, 1080, 390 · landing-nav-open @ 1080" from the surface keys. */
|
|
412
|
-
function formatSurfaceList(surfaces) {
|
|
413
|
-
const byBase = new Map();
|
|
414
|
-
for (const s of surfaces)
|
|
415
|
-
pushSurfaceWidth(byBase, surfaceBase(s), s);
|
|
416
|
-
return renderSurfaceGroups(byBase);
|
|
417
|
-
}
|
|
196
|
+
// --- readable findings: the dedupe/summarise/label brain lives in
|
|
197
|
+
// change-groups.ts (shared with the CLI). report.ts renders crops on top. ---
|
|
418
198
|
function surfaceContext(...maps) {
|
|
419
199
|
const metadata = maps.find((m) => m?.metadata)?.metadata;
|
|
420
200
|
if (!metadata?.variantKey)
|
|
@@ -457,47 +237,6 @@ function collectLiveCandidateLabels(beforeDir, afterDir) {
|
|
|
457
237
|
}
|
|
458
238
|
return [...seen].sort();
|
|
459
239
|
}
|
|
460
|
-
// Grid-track longhands compute to width-dependent pixels (`282px ×2` at one width,
|
|
461
|
-
// `282px 228px` at another), so the SAME responsive change would otherwise get a
|
|
462
|
-
// different signature per width. Key them by track COUNT — what actually
|
|
463
|
-
// identifies the change — so responsive variants group into one section.
|
|
464
|
-
function sigValue(c) {
|
|
465
|
-
if (c.prop === 'grid-template-columns' || c.prop === 'grid-template-rows') {
|
|
466
|
-
return `${c.prop}=${trackCount(c.before)}t>${trackCount(c.after)}t`;
|
|
467
|
-
}
|
|
468
|
-
return `${c.prop}=${c.before}>${c.after}`;
|
|
469
|
-
}
|
|
470
|
-
/** Canonical signature of a surface's findings: surfaces that changed in the
|
|
471
|
-
* same way collapse into one section + one image (the rects differ per width;
|
|
472
|
-
* the change itself does not). */
|
|
473
|
-
function signatureOf(findings) {
|
|
474
|
-
return JSON.stringify(findings
|
|
475
|
-
.map((f) => ({
|
|
476
|
-
p: f.path,
|
|
477
|
-
k: f.kind,
|
|
478
|
-
t: f.kind === 'dom' ? f.change : f.kind === 'state' ? f.state : (f.pseudo ?? ''),
|
|
479
|
-
v: f.kind === 'dom' ? '' : summarizeProps(f.props).map(sigValue).join('|'),
|
|
480
|
-
}))
|
|
481
|
-
.sort((a, b) => `${a.p}|${a.k}|${a.t}`.localeCompare(`${b.p}|${b.k}|${b.t}`)));
|
|
482
|
-
}
|
|
483
|
-
/** A one-line heading for a change group: "1 element added", "2 elements restyled". */
|
|
484
|
-
function groupTitle(findings) {
|
|
485
|
-
const added = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'added').map((f) => f.path));
|
|
486
|
-
const removed = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'removed').map((f) => f.path));
|
|
487
|
-
const retagged = new Set(findings.filter((f) => f.kind === 'dom' && f.change === 'retagged').map((f) => f.path));
|
|
488
|
-
const restyled = new Set(findings.filter((f) => f.kind !== 'dom' && !added.has(f.path) && !removed.has(f.path)).map((f) => f.path));
|
|
489
|
-
const n = (c, w) => `${c} ${w}${c === 1 ? '' : 's'}`;
|
|
490
|
-
const parts = [];
|
|
491
|
-
if (added.size)
|
|
492
|
-
parts.push(`${n(added.size, 'element')} added`);
|
|
493
|
-
if (removed.size)
|
|
494
|
-
parts.push(`${n(removed.size, 'element')} removed`);
|
|
495
|
-
if (retagged.size)
|
|
496
|
-
parts.push(`${n(retagged.size, 'element')} retagged`);
|
|
497
|
-
if (restyled.size)
|
|
498
|
-
parts.push(`${n(restyled.size, 'element')} restyled`);
|
|
499
|
-
return parts.join(', ') || `${n(new Set(findings.map((f) => f.path)).size, 'element')} changed`;
|
|
500
|
-
}
|
|
501
240
|
/**
|
|
502
241
|
* A crop's heading: the element it's anchored on, then what happened inside it —
|
|
503
242
|
* `` `who-grid` · 5 elements restyled ``. Naming the anchor is what ties the
|
|
@@ -704,80 +443,6 @@ function renderCropChanges(findings, foldAt, ctx) {
|
|
|
704
443
|
const summary = bullets.length ? bullets.map((b) => `- ${b}`) : ['_see changes_'];
|
|
705
444
|
return ['', ...summary, '', '<details>', `<summary>${foldSummary(findings)}</summary>`, ...tables, '', '</details>'];
|
|
706
445
|
}
|
|
707
|
-
// Computed values that follow from an element's box size or position rather than
|
|
708
|
-
// its styling. On any reflow they change all the way up the ancestor chain
|
|
709
|
-
// (body, main, section…), so an element whose ONLY changes are these is a reflow
|
|
710
|
-
// casualty: it must not anchor a crop region (that would zoom to the whole page)
|
|
711
|
-
// nor clutter the findings. The certification differ keeps them — a reflow IS a
|
|
712
|
-
// change to certify — but the visual report focuses on styling intent.
|
|
713
|
-
const DERIVED_PROPS = new Set([
|
|
714
|
-
'width',
|
|
715
|
-
'height',
|
|
716
|
-
'block-size',
|
|
717
|
-
'inline-size',
|
|
718
|
-
'min-width',
|
|
719
|
-
'min-height',
|
|
720
|
-
'max-width',
|
|
721
|
-
'max-height',
|
|
722
|
-
'perspective-origin',
|
|
723
|
-
'transform-origin',
|
|
724
|
-
// position offsets shift with the document on any reflow
|
|
725
|
-
'top',
|
|
726
|
-
'right',
|
|
727
|
-
'bottom',
|
|
728
|
-
'left',
|
|
729
|
-
'inset-block-start',
|
|
730
|
-
'inset-block-end',
|
|
731
|
-
'inset-inline-start',
|
|
732
|
-
'inset-inline-end',
|
|
733
|
-
]);
|
|
734
|
-
// Props stripped from forced :hover/:focus/:active deltas specifically. Layout
|
|
735
|
-
// and grid-track values that shift when a state forces a relayout are capture
|
|
736
|
-
// noise, not interaction feedback — a state finding is meant to catch a changed
|
|
737
|
-
// hover/focus/active *style* (colour, outline, shadow), not a reflow.
|
|
738
|
-
const STATE_STRIP = new Set([
|
|
739
|
-
...DERIVED_PROPS,
|
|
740
|
-
'grid-template-columns',
|
|
741
|
-
'grid-template-rows',
|
|
742
|
-
'grid-template-areas',
|
|
743
|
-
'grid-auto-columns',
|
|
744
|
-
'grid-auto-rows',
|
|
745
|
-
'grid-auto-flow',
|
|
746
|
-
]);
|
|
747
|
-
/**
|
|
748
|
-
* Strip the noise the visual report shouldn't carry, cross-referencing each
|
|
749
|
-
* element's layers so the forced-state layer stops echoing the base:
|
|
750
|
-
* - base/pseudo styles: drop size/position-derived longhands (reflow casualties);
|
|
751
|
-
* - forced states: drop derived + grid-track props, drop a delta the BASE
|
|
752
|
-
* already changed (a `:hover color` that just follows a recoloured base is an
|
|
753
|
-
* echo, not a dropped variant), and drop non-value↔non-value rows;
|
|
754
|
-
* - any finding left with no props is removed entirely.
|
|
755
|
-
*/
|
|
756
|
-
function cleanFindings(findings) {
|
|
757
|
-
const out = [];
|
|
758
|
-
for (const group of groupByPath(findings)) {
|
|
759
|
-
const base = group.find((f) => f.kind === 'style' && f.pseudo === null);
|
|
760
|
-
const baseChanged = new Set(base?.props.map((p) => p.prop) ?? []);
|
|
761
|
-
// For an ADDED element the base style is a full (unset)→value snapshot, not a
|
|
762
|
-
// delta — so a forced-state value is never an "echo" of a base *change*; keep
|
|
763
|
-
// every state row (suppressing them would drop a real :hover/:focus value).
|
|
764
|
-
const isAdded = group.some((f) => f.kind === 'dom' && f.change === 'added');
|
|
765
|
-
for (const f of group) {
|
|
766
|
-
if (f.kind === 'dom') {
|
|
767
|
-
out.push(f);
|
|
768
|
-
continue;
|
|
769
|
-
}
|
|
770
|
-
const props = f.kind === 'style'
|
|
771
|
-
? f.props.filter((p) => !DERIVED_PROPS.has(p.prop))
|
|
772
|
-
: f.props.filter((p) => !STATE_STRIP.has(p.prop) &&
|
|
773
|
-
(isAdded || !baseChanged.has(p.prop)) &&
|
|
774
|
-
!(isNonValue(p.before) && isNonValue(p.after)));
|
|
775
|
-
if (props.length)
|
|
776
|
-
out.push({ ...f, props });
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
return out;
|
|
780
|
-
}
|
|
781
446
|
/** Per-element view for the plain-English summariser: the base deltas (summarised)
|
|
782
447
|
* plus which interactive states genuinely changed. */
|
|
783
448
|
function buildElementChanges(findings) {
|
|
@@ -923,13 +588,29 @@ function determinismLine(det) {
|
|
|
923
588
|
return `- **Determinism** — ✗ NOT proven (base ${det.base}, head ${det.head}) — a clean diff could be two nondeterministic reads`;
|
|
924
589
|
return '- **Determinism** — ⚠ unknown (a capture predates the determinism ledger)';
|
|
925
590
|
}
|
|
591
|
+
// Truncated, escaped, comma-joined key list — the same discipline for removals and
|
|
592
|
+
// additions, so neither can inject Markdown into the privileged PR-comment summary.
|
|
593
|
+
function keyList(items) {
|
|
594
|
+
const keys = items.map((i) => safeKey(i.key));
|
|
595
|
+
return `${keys.slice(0, 8).join(', ')}${keys.length > 8 ? ', …' : ''}`;
|
|
596
|
+
}
|
|
597
|
+
// Additions never gate, but the report must not contradict the diff (which prints
|
|
598
|
+
// them) — so echo them as an informational, still-✓-class clause. Returns a leading
|
|
599
|
+
// `; …` fragment to append after whatever the removal side decided, or '' when none.
|
|
600
|
+
function additionsClause(added) {
|
|
601
|
+
if (added.length === 0)
|
|
602
|
+
return '';
|
|
603
|
+
return `; ${added.length} navigable affordance(s) added: ${keyList(added)} (additions don't gate)`;
|
|
604
|
+
}
|
|
926
605
|
function inventoryLine(inv) {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
return `- **Inventory** — ⚠ ${inv.unexplained.length} navigable affordance(s) removed, unacknowledged: ${
|
|
930
|
-
}
|
|
606
|
+
const added = additionsClause(inv.delta.added);
|
|
607
|
+
if (inv.unexplained.length > 0)
|
|
608
|
+
return `- **Inventory** — ⚠ ${inv.unexplained.length} navigable affordance(s) removed, unacknowledged: ${keyList(inv.unexplained)}${added}`;
|
|
931
609
|
if (inv.delta.removed.length > 0)
|
|
932
|
-
return `- **Inventory** — ✓ ${inv.delta.removed.length} removal(s), all acknowledged`;
|
|
610
|
+
return `- **Inventory** — ✓ ${inv.delta.removed.length} removal(s), all acknowledged${added}`;
|
|
611
|
+
// Addition-only: drop the leading `; ` so the clause reads as the whole ✓ line.
|
|
612
|
+
if (added)
|
|
613
|
+
return `- **Inventory** — ✓${added.slice(1)}`;
|
|
933
614
|
return '- **Inventory** — ✓ navigable set unchanged';
|
|
934
615
|
}
|
|
935
616
|
// Acknowledged removals for the report — lenient: a missing OR malformed ack file
|
|
@@ -1221,6 +902,24 @@ function renderNewSurface(p, ctx, cropSeq) {
|
|
|
1221
902
|
md.push('', `_No baseline to compare against — this surface is new. Review and approve it before it becomes part of the baseline._`);
|
|
1222
903
|
return { md, json, cropSeq };
|
|
1223
904
|
}
|
|
905
|
+
/** The shared-chrome tier banner (#193), emitted once above the promoted groups:
|
|
906
|
+
* "the frame every view draws changed" — so the reviewer reads it as one global
|
|
907
|
+
* change, not a per-view surprise. The affected group(s) render in full beneath.
|
|
908
|
+
* `nChrome` is how many distinct chrome changes were promoted; `nSurfaces` is the
|
|
909
|
+
* captured-surface-base count they span. */
|
|
910
|
+
function chromeCalloutLines(nChrome, nSurfaces) {
|
|
911
|
+
const what = nChrome === 1 ? 'change' : 'changes';
|
|
912
|
+
return [
|
|
913
|
+
'',
|
|
914
|
+
'---',
|
|
915
|
+
'',
|
|
916
|
+
`## 🧱 Global chrome ${what} — across all ${nSurfaces} surface(s)`,
|
|
917
|
+
'',
|
|
918
|
+
`_${nChrome} change(s) rode the shared frame every view draws (a persistent nav, header, or footer): ` +
|
|
919
|
+
`each touched every surface that renders the affected element, so it reads as ONE global change, not a ` +
|
|
920
|
+
`per-view one. The detail is folded beneath — review it once._`,
|
|
921
|
+
];
|
|
922
|
+
}
|
|
1224
923
|
/** The one-time banner where report.md switches from full detail to one-line
|
|
1225
924
|
* summaries, so the reader knows nothing is missing — only relocated to report.json. */
|
|
1226
925
|
function cappedNoticeLines(budget) {
|
|
@@ -1271,6 +970,14 @@ export function generateStyleMapReport(opts) {
|
|
|
1271
970
|
.filter((p) => p.sd.missing || p.findings.length > 0);
|
|
1272
971
|
const missing = prepared.filter((p) => p.sd.missing);
|
|
1273
972
|
const changeGroups = groupBySignature(prepared);
|
|
973
|
+
// Shared-chrome tier (#193): promote a change that rode the frame every view
|
|
974
|
+
// draws (nav rail, header) to a callout, so the reviewer reads "the nav changed
|
|
975
|
+
// everywhere" once instead of inferring it from a long surface list on several
|
|
976
|
+
// entries. Purely presentational — counts, groups, exit code, and report.json
|
|
977
|
+
// are unchanged; only the render order and one heading differ. In the common
|
|
978
|
+
// small-surface case (e.g. the demo) nothing qualifies and this is a no-op.
|
|
979
|
+
const { chrome, rest } = classifyChrome(changeGroups, surfaceElementPaths(beforeDir, afterDir));
|
|
980
|
+
const orderedGroups = [...chrome, ...rest];
|
|
1274
981
|
const shown = countShownChanges(changeGroups);
|
|
1275
982
|
// Surfaces carrying a reviewable change — NOT the new (one-sided) ones, which
|
|
1276
983
|
// have no baseline to compare and are summarised on their own line below so the
|
|
@@ -1331,12 +1038,23 @@ export function generateStyleMapReport(opts) {
|
|
|
1331
1038
|
md.push(summary);
|
|
1332
1039
|
reportBytes += summary.length + 1;
|
|
1333
1040
|
};
|
|
1334
|
-
|
|
1041
|
+
// The captured-surface-base count (all surfaces, not just changed ones) so the
|
|
1042
|
+
// chrome callout can read "N of M surfaces". M is bases, matching the tier's
|
|
1043
|
+
// base-keyed coverage rule.
|
|
1044
|
+
const totalSurfaceBases = new Set(surfaceKeysIn(afterDir).map(surfaceBase)).size;
|
|
1045
|
+
const chromeSet = new Set(chrome);
|
|
1046
|
+
let chromeHeaderEmitted = false;
|
|
1047
|
+
for (const cg of orderedGroups) {
|
|
1335
1048
|
const r = renderChangeGroup(cg, ctx, maxCrops, cropSeq);
|
|
1336
1049
|
json.push(r.json);
|
|
1337
1050
|
totalFindings += r.findingCount;
|
|
1338
1051
|
cropSeq = r.cropSeq;
|
|
1339
|
-
|
|
1052
|
+
// Prepend the shared-chrome banner once, above the first promoted group. It
|
|
1053
|
+
// rides on the same emitDetail budget so the cap still applies.
|
|
1054
|
+
const detail = chromeSet.has(cg) && !chromeHeaderEmitted
|
|
1055
|
+
? ((chromeHeaderEmitted = true), [...chromeCalloutLines(chrome.length, totalSurfaceBases), ...r.md])
|
|
1056
|
+
: r.md;
|
|
1057
|
+
emitDetail(detail, compactChangeSummary(cg, r.json, img));
|
|
1340
1058
|
}
|
|
1341
1059
|
for (const p of missing) {
|
|
1342
1060
|
const r = renderNewSurface(p, ctx, cropSeq);
|