styleproof 4.0.1 → 4.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/CHANGELOG.md +42 -0
- package/README.md +27 -1
- package/dist/capture.d.ts +5 -0
- package/dist/capture.js +36 -21
- package/dist/change-groups.js +4 -1
- package/dist/map-store.js +17 -1
- package/dist/report.js +194 -34
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,48 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.1.0] - 2026-07-12
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Annotated report crops no longer paint path-shifted subtrees as visual
|
|
15
|
+
changes.** When an unkeyed sibling insertion renumbers `:nth-child()` paths,
|
|
16
|
+
the report reconciles exact-equivalent entries across paths for annotation
|
|
17
|
+
only. Genuine additions and removals stay highlighted, while exhaustive
|
|
18
|
+
structural findings remain in the certification and audit details.
|
|
19
|
+
- **Pull-request captures and reports now stay bound to the real head commit.**
|
|
20
|
+
GitHub's synthetic merge SHA no longer becomes the map or report provenance,
|
|
21
|
+
and published report links point to the immutable report commit.
|
|
22
|
+
- **Certify-mode report comments now carry their source head marker**, so stale
|
|
23
|
+
comments can be distinguished from the current PR head.
|
|
24
|
+
|
|
25
|
+
## [4.0.2] - 2026-07-11
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **Inserting a semantic sibling no longer turns every following element into a
|
|
30
|
+
false restyle.** Capture paths now prefer privacy-safe hashed identities from
|
|
31
|
+
stable attributes (`data-styleproof-key`, IDs, test IDs, link destinations,
|
|
32
|
+
and form names) when they are unique among siblings, falling back to
|
|
33
|
+
`:nth-child()` only when no stable identity exists. Raw attribute values never
|
|
34
|
+
enter the map.
|
|
35
|
+
- **Reports no longer publish an unchanged image as a misleading highlighted
|
|
36
|
+
twin.** The annotated crop is omitted when every changed rectangle falls
|
|
37
|
+
outside the rendered crop.
|
|
38
|
+
- **New-surface screenshots no longer preserve a blank full-page tail.** New
|
|
39
|
+
captures record their viewport and reports show that top viewport when the
|
|
40
|
+
full-page screenshot is taller.
|
|
41
|
+
- **Shared changes now choose exposed proof and suppress misleading crops.** When
|
|
42
|
+
the same change appears on an ordinary page and a modal-open state, the report
|
|
43
|
+
excludes modal-background DOM content before viewport width; if no captured
|
|
44
|
+
state visibly paints the change, it retains the audit details without images.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **New pages, states, and surfaces render before element-level diffs.** A new
|
|
49
|
+
page can no longer be buried beneath shared-frame changes in the report.
|
|
50
|
+
- StyleProof reports now lead with named new surfaces in the summary that PR comments quote, and render new-surface screenshots before existing-surface diff groups so new-page PRs do not read like broad restyle churn.
|
|
51
|
+
|
|
10
52
|
## [4.0.1] - 2026-07-07
|
|
11
53
|
|
|
12
54
|
### Fixed
|
package/README.md
CHANGED
|
@@ -209,7 +209,33 @@ element's whole look is reviewable, not just its `:hover` changes.
|
|
|
209
209
|
|
|
210
210
|
### What a report looks like
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
New pages, states, and surfaces appear first, before lower-level element diffs, and
|
|
213
|
+
show the captured top viewport when there is no baseline side to compare. Existing
|
|
214
|
+
surfaces then render one change per section: a side-by-side before/after crop, an
|
|
215
|
+
annotated twin when a visible changed element can actually be boxed, a one-line
|
|
216
|
+
summary, and the exact property change folded under a toggle. A change too small
|
|
217
|
+
to see at 1:1 (say a 2px icon tweak) also gets a magnified zoom crop, so a
|
|
218
|
+
sub-pixel change can't slip past a reviewer.
|
|
219
|
+
|
|
220
|
+
Annotation boxes reconcile exact-equivalent elements that moved to a different
|
|
221
|
+
structural path. When an unkeyed sibling insertion shifts unchanged descendants,
|
|
222
|
+
the clean comparison still shows the complete rendered result and the audit keeps
|
|
223
|
+
every structural finding, but magenta boxes mark only unmatched additions,
|
|
224
|
+
removals, and restyles instead of painting the displaced subtree as changed.
|
|
225
|
+
|
|
226
|
+
When one change appears both on an ordinary page and in an open popup, the report
|
|
227
|
+
chooses a representative where the changed element is visibly exposed, then prefers
|
|
228
|
+
the ordinary page before using viewport width as a tie-breaker. Modal-background DOM
|
|
229
|
+
content is excluded; if no captured state visibly paints the change, the report keeps
|
|
230
|
+
the audit details but omits a misleading identical before/after crop.
|
|
231
|
+
|
|
232
|
+
Element identity is stable across ordinary list insertions. StyleProof prefers a
|
|
233
|
+
privacy-safe hash of a unique `data-styleproof-key`, ID, test ID, link destination,
|
|
234
|
+
or form name for each sibling and falls back to `:nth-child()` only when the DOM
|
|
235
|
+
offers no stable semantic identity. Attribute values are never written into the
|
|
236
|
+
map. This keeps inserting one navigation link from falsely restyling every link
|
|
237
|
+
after it; add `data-styleproof-key` when repeated controls have no other stable
|
|
238
|
+
attribute.
|
|
213
239
|
|
|
214
240
|

|
|
215
241
|
|
package/dist/capture.d.ts
CHANGED
|
@@ -84,6 +84,11 @@ export type CapturedOverlay = {
|
|
|
84
84
|
export type StyleMap = {
|
|
85
85
|
/** Optional runner-supplied context; ignored by the certification diff. */
|
|
86
86
|
metadata?: CaptureMetadata;
|
|
87
|
+
/** Browser viewport used for this capture. Report-only; ignored by the certification diff. */
|
|
88
|
+
viewport?: {
|
|
89
|
+
width: number;
|
|
90
|
+
height: number;
|
|
91
|
+
};
|
|
87
92
|
defaults: Record<string, Props>;
|
|
88
93
|
elements: Record<string, ElementEntry>;
|
|
89
94
|
states: Record<string, Record<string, Record<string, Props>>>;
|
package/dist/capture.js
CHANGED
|
@@ -62,14 +62,42 @@ function injectPathOf() {
|
|
|
62
62
|
return 'html';
|
|
63
63
|
if (el === document.body)
|
|
64
64
|
return 'body';
|
|
65
|
+
const identityCandidates = (element) => {
|
|
66
|
+
const tag = element.tagName.toLowerCase();
|
|
67
|
+
const attributes = [
|
|
68
|
+
['styleproof', element.getAttribute('data-styleproof-key')],
|
|
69
|
+
['id', element.getAttribute('id')],
|
|
70
|
+
['testid', element.getAttribute('data-testid')],
|
|
71
|
+
['test', element.getAttribute('data-test')],
|
|
72
|
+
...(tag === 'a' ? [['href', element.getAttribute('href')]] : []),
|
|
73
|
+
...(['input', 'select', 'textarea'].includes(tag)
|
|
74
|
+
? [['name', element.getAttribute('name')]]
|
|
75
|
+
: []),
|
|
76
|
+
];
|
|
77
|
+
return attributes.flatMap(([name, value]) => (value ? [`${name}:${value}`] : []));
|
|
78
|
+
};
|
|
79
|
+
const privacySafeHash = (value) => {
|
|
80
|
+
let hash = 2166136261;
|
|
81
|
+
for (let characterIndex = 0; characterIndex < value.length; characterIndex++) {
|
|
82
|
+
hash ^= value.charCodeAt(characterIndex);
|
|
83
|
+
hash = Math.imul(hash, 16777619);
|
|
84
|
+
}
|
|
85
|
+
return (hash >>> 0).toString(36);
|
|
86
|
+
};
|
|
87
|
+
const stableSegment = (element, parent) => {
|
|
88
|
+
const candidate = identityCandidates(element).find((identity) => [...parent.children].filter((sibling) => identityCandidates(sibling).includes(identity)).length === 1);
|
|
89
|
+
if (candidate)
|
|
90
|
+
return `${element.tagName.toLowerCase()}:sp-key(${privacySafeHash(candidate)})`;
|
|
91
|
+
return `${element.tagName.toLowerCase()}:nth-child(${Array.prototype.indexOf.call(parent.children, element) + 1})`;
|
|
92
|
+
};
|
|
65
93
|
const parts = [];
|
|
66
|
-
let
|
|
67
|
-
while (
|
|
68
|
-
const parent =
|
|
94
|
+
let element = el;
|
|
95
|
+
while (element && element !== document.body) {
|
|
96
|
+
const parent = element.parentElement;
|
|
69
97
|
if (!parent)
|
|
70
98
|
break;
|
|
71
|
-
parts.unshift(
|
|
72
|
-
|
|
99
|
+
parts.unshift(stableSegment(element, parent));
|
|
100
|
+
element = parent;
|
|
73
101
|
}
|
|
74
102
|
return 'body > ' + parts.join(' > ');
|
|
75
103
|
};
|
|
@@ -361,22 +389,7 @@ function detectOverlayCandidates({ ignore }) {
|
|
|
361
389
|
// fallow-ignore-next-line complexity
|
|
362
390
|
function snapSubtree({ selector, index }) {
|
|
363
391
|
const el = document.querySelectorAll(selector)[index];
|
|
364
|
-
const pathOf =
|
|
365
|
-
if (n === document.documentElement)
|
|
366
|
-
return 'html';
|
|
367
|
-
if (n === document.body)
|
|
368
|
-
return 'body';
|
|
369
|
-
const parts = [];
|
|
370
|
-
let c = n;
|
|
371
|
-
while (c && c !== document.body) {
|
|
372
|
-
const parent = c.parentElement;
|
|
373
|
-
if (!parent)
|
|
374
|
-
break;
|
|
375
|
-
parts.unshift(`${c.tagName.toLowerCase()}:nth-child(${Array.prototype.indexOf.call(parent.children, c) + 1})`);
|
|
376
|
-
c = parent;
|
|
377
|
-
}
|
|
378
|
-
return 'body > ' + parts.join(' > ');
|
|
379
|
-
};
|
|
392
|
+
const pathOf = window.__spPathOf;
|
|
380
393
|
const out = {};
|
|
381
394
|
if (!el)
|
|
382
395
|
return out;
|
|
@@ -773,6 +786,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
773
786
|
const stabilize = options.stabilize ?? true;
|
|
774
787
|
const captureText = options.captureText ?? false;
|
|
775
788
|
const captureComponent = options.captureComponent ?? false;
|
|
789
|
+
const viewport = page.viewportSize();
|
|
776
790
|
// Neutralise real hover/focus the same way FREEZE_CSS neutralises motion: park
|
|
777
791
|
// the pointer over an ignored 1px sink and blur whatever element holds focus so
|
|
778
792
|
// every read below is the no-interaction resting state. Real :hover/:focus is
|
|
@@ -841,6 +855,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
841
855
|
const inventory = await harvestInventoryFor(page, options.inventory);
|
|
842
856
|
return {
|
|
843
857
|
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
858
|
+
...(viewport ? { viewport } : {}),
|
|
844
859
|
defaults: base.defaults,
|
|
845
860
|
elements: base.elements,
|
|
846
861
|
states,
|
package/dist/change-groups.js
CHANGED
|
@@ -204,7 +204,10 @@ export function summarizeProps(props) {
|
|
|
204
204
|
}
|
|
205
205
|
/** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
|
|
206
206
|
export function prettyLabel(p, cls) {
|
|
207
|
-
const tag = (p.split('>').pop() ?? '')
|
|
207
|
+
const tag = (p.split('>').pop() ?? '')
|
|
208
|
+
.trim()
|
|
209
|
+
.replace(/:nth-child\(\d+\)/, '')
|
|
210
|
+
.replace(/:sp-key\([a-z0-9]+\)/, '') || 'el';
|
|
208
211
|
const first = cls.split(/\s+/)[0] ?? '';
|
|
209
212
|
return /^[a-z][a-z0-9-]*$/.test(first) ? `${tag}.${first}` : tag;
|
|
210
213
|
}
|
package/dist/map-store.js
CHANGED
|
@@ -139,7 +139,23 @@ export function expectedCompatibilityKey(options = {}) {
|
|
|
139
139
|
}))).slice(0, 16);
|
|
140
140
|
}
|
|
141
141
|
export function currentGitSha(cwd = process.cwd(), env = process.env) {
|
|
142
|
-
const
|
|
142
|
+
const fromEvent = (() => {
|
|
143
|
+
if (!env.GITHUB_EVENT_PATH ||
|
|
144
|
+
!['pull_request', 'pull_request_target', 'workflow_run'].includes(env.GITHUB_EVENT_NAME ?? '')) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const event = JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
|
|
149
|
+
return event.pull_request?.head?.sha ?? event.workflow_run?.head_sha;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
})();
|
|
155
|
+
// STYLEPROOF_SHA/GITHUB_HEAD_SHA are explicit overrides. On a pull_request
|
|
156
|
+
// run, GITHUB_SHA is the synthetic merge commit, so consult the trusted event
|
|
157
|
+
// payload before falling back to it.
|
|
158
|
+
const fromEnv = env.STYLEPROOF_SHA || env.GITHUB_HEAD_SHA || fromEvent || env.GITHUB_SHA;
|
|
143
159
|
if (fromEnv && /^[0-9a-f]{7,40}$/i.test(fromEnv))
|
|
144
160
|
return fromEnv;
|
|
145
161
|
const sha = gitOutput(cwd, ['rev-parse', 'HEAD']);
|
package/dist/report.js
CHANGED
|
@@ -48,6 +48,72 @@ function outermost(paths) {
|
|
|
48
48
|
function innermost(paths) {
|
|
49
49
|
return paths.filter((p) => !paths.some((q) => q !== p && q.startsWith(p + ' > ')));
|
|
50
50
|
}
|
|
51
|
+
function annotationIdentity(entry) {
|
|
52
|
+
const sortedPseudo = Object.fromEntries(Object.entries(entry.pseudo ?? {})
|
|
53
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
54
|
+
.map(([pseudo, properties]) => [
|
|
55
|
+
pseudo,
|
|
56
|
+
Object.entries(properties).sort(([left], [right]) => left.localeCompare(right)),
|
|
57
|
+
]));
|
|
58
|
+
return JSON.stringify([
|
|
59
|
+
entry.tag,
|
|
60
|
+
entry.cls,
|
|
61
|
+
entry.rect?.[2] ?? null,
|
|
62
|
+
entry.rect?.[3] ?? null,
|
|
63
|
+
Object.entries(entry.style).sort(([left], [right]) => left.localeCompare(right)),
|
|
64
|
+
sortedPseudo,
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
function indexAnnotationIdentities(map) {
|
|
68
|
+
const pathsByIdentity = new Map();
|
|
69
|
+
for (const [elementPath, entry] of Object.entries(map.elements)) {
|
|
70
|
+
const identity = annotationIdentity(entry);
|
|
71
|
+
pathsByIdentity.set(identity, [...(pathsByIdentity.get(identity) ?? []), elementPath]);
|
|
72
|
+
}
|
|
73
|
+
return pathsByIdentity;
|
|
74
|
+
}
|
|
75
|
+
function hasEquivalentEntryAtAnotherPath(elementPath, entry, samePathsByIdentity, otherPathsByIdentity) {
|
|
76
|
+
if (!entry)
|
|
77
|
+
return false;
|
|
78
|
+
const identity = annotationIdentity(entry);
|
|
79
|
+
const samePaths = samePathsByIdentity.get(identity) ?? [];
|
|
80
|
+
const otherPaths = otherPathsByIdentity.get(identity) ?? [];
|
|
81
|
+
// Reconcile only an unambiguous one-to-one move. If an identity is duplicated
|
|
82
|
+
// on either side, an arbitrary cross-path match can hide a real restyle or an
|
|
83
|
+
// inserted/removed sibling from the annotated proof.
|
|
84
|
+
return samePaths.length === 1 && otherPaths.length === 1 && otherPaths[0] !== elementPath;
|
|
85
|
+
}
|
|
86
|
+
function annotationSides(finding, beforeMoved, afterMoved, reconcileMoved) {
|
|
87
|
+
// A style/state finding already proves that the element at this path changed.
|
|
88
|
+
// Only reconcile it when the same surface also has structural churn; without a
|
|
89
|
+
// DOM finding, cross-path identity matching can erase a real style swap.
|
|
90
|
+
if (finding.kind !== 'dom')
|
|
91
|
+
return reconcileMoved ? { before: !beforeMoved, after: !afterMoved } : { before: true, after: true };
|
|
92
|
+
if (finding.change === 'removed')
|
|
93
|
+
return { before: !beforeMoved, after: false };
|
|
94
|
+
if (finding.change === 'added')
|
|
95
|
+
return { before: false, after: !afterMoved };
|
|
96
|
+
return { before: true, after: true };
|
|
97
|
+
}
|
|
98
|
+
function annotationPaths(findings, beforeMap, afterMap) {
|
|
99
|
+
const beforePathsByIdentity = indexAnnotationIdentities(beforeMap);
|
|
100
|
+
const afterPathsByIdentity = indexAnnotationIdentities(afterMap);
|
|
101
|
+
const reconcileMoved = findings.some((finding) => finding.kind === 'dom');
|
|
102
|
+
const beforePaths = new Set();
|
|
103
|
+
const afterPaths = new Set();
|
|
104
|
+
for (const finding of findings) {
|
|
105
|
+
const beforeEntry = beforeMap.elements[finding.path];
|
|
106
|
+
const afterEntry = afterMap.elements[finding.path];
|
|
107
|
+
const beforeMoved = hasEquivalentEntryAtAnotherPath(finding.path, beforeEntry, beforePathsByIdentity, afterPathsByIdentity);
|
|
108
|
+
const afterMoved = hasEquivalentEntryAtAnotherPath(finding.path, afterEntry, afterPathsByIdentity, beforePathsByIdentity);
|
|
109
|
+
const sides = annotationSides(finding, beforeMoved, afterMoved, reconcileMoved);
|
|
110
|
+
if (sides.before)
|
|
111
|
+
beforePaths.add(finding.path);
|
|
112
|
+
if (sides.after)
|
|
113
|
+
afterPaths.add(finding.path);
|
|
114
|
+
}
|
|
115
|
+
return { before: innermost([...beforePaths]), after: innermost([...afterPaths]) };
|
|
116
|
+
}
|
|
51
117
|
/** Headline counts with the zeros dropped — `0 state-delta difference(s)` is noise. */
|
|
52
118
|
function changeCountLabel(shown) {
|
|
53
119
|
const parts = [];
|
|
@@ -127,12 +193,20 @@ function strokeRect(png, x, y, w, h, t = 2, color = HILITE) {
|
|
|
127
193
|
function annotateCrop(crop, rects) {
|
|
128
194
|
const out = new PNG({ width: crop.png.width, height: crop.png.height });
|
|
129
195
|
PNG.bitblt(crop.png, out, 0, 0, crop.png.width, crop.png.height, 0, 0);
|
|
196
|
+
let highlighted = false;
|
|
130
197
|
for (const [rx, ry, rw, rh] of rects) {
|
|
131
198
|
if (rw <= 0 || rh <= 0)
|
|
132
199
|
continue;
|
|
133
|
-
|
|
200
|
+
const left = Math.max(0, rx - crop.ox);
|
|
201
|
+
const top = Math.max(0, ry - crop.oy);
|
|
202
|
+
const right = Math.min(crop.png.width, rx - crop.ox + rw);
|
|
203
|
+
const bottom = Math.min(crop.png.height, ry - crop.oy + rh);
|
|
204
|
+
if (right <= left || bottom <= top)
|
|
205
|
+
continue;
|
|
206
|
+
strokeRect(out, left, top, right - left, bottom - top, Math.min(2, right - left, bottom - top));
|
|
207
|
+
highlighted = true;
|
|
134
208
|
}
|
|
135
|
-
return out;
|
|
209
|
+
return { png: out, highlighted };
|
|
136
210
|
}
|
|
137
211
|
/**
|
|
138
212
|
* One before|after image: the two equal-size crops on a dark canvas with a
|
|
@@ -675,11 +749,63 @@ function certificationLines(beforeDir, afterDir) {
|
|
|
675
749
|
'',
|
|
676
750
|
];
|
|
677
751
|
}
|
|
752
|
+
/** A changed element can anchor useful visual proof only when the browser paints it. */
|
|
753
|
+
function isPaintedEntry(entry) {
|
|
754
|
+
if (!entry?.rect || !visible(rectToBox(entry.rect)))
|
|
755
|
+
return false;
|
|
756
|
+
if (entry.style.display === 'none' || entry.style.visibility === 'hidden')
|
|
757
|
+
return false;
|
|
758
|
+
return Number(entry.style.opacity ?? '1') > 0;
|
|
759
|
+
}
|
|
760
|
+
function isSameOrDescendantPath(candidatePath, ancestorPath) {
|
|
761
|
+
return candidatePath === ancestorPath || candidatePath.startsWith(`${ancestorPath} > `);
|
|
762
|
+
}
|
|
763
|
+
/** A modal leaves its background in the DOM but makes it unsuitable as visual proof.
|
|
764
|
+
* A change inside the modal itself is foreground content and remains eligible. */
|
|
765
|
+
function isBackgroundBehindActiveModal(map, changedPath) {
|
|
766
|
+
return (map.overlays ?? []).some((overlay) => overlay.ariaModal === 'true' && !isSameOrDescendantPath(changedPath, overlay.path));
|
|
767
|
+
}
|
|
768
|
+
function isExposedChangedEntry(map, changedPath) {
|
|
769
|
+
return isPaintedEntry(map.elements[changedPath]) && !isBackgroundBehindActiveModal(map, changedPath);
|
|
770
|
+
}
|
|
771
|
+
function hasExposedChangedEntry(mapA, mapB, changedPaths) {
|
|
772
|
+
return changedPaths.some((changedPath) => isExposedChangedEntry(mapA, changedPath) || isExposedChangedEntry(mapB, changedPath));
|
|
773
|
+
}
|
|
774
|
+
/** Prefer proof a reviewer can see: an exposed changed element, then a non-modal
|
|
775
|
+
* ordinary page over a popup state that can leave shared chrome in the background,
|
|
776
|
+
* then the widest width. */
|
|
777
|
+
function representativeScore(candidate, beforeDir, afterDir) {
|
|
778
|
+
const beforeMap = loadStyleMap(findCapture(beforeDir, candidate.sd.surface));
|
|
779
|
+
const afterMap = loadStyleMap(findCapture(afterDir, candidate.sd.surface));
|
|
780
|
+
const changedPaths = [...new Set(candidate.findings.map((finding) => finding.path))];
|
|
781
|
+
const hasExposedChange = hasExposedChangedEntry(beforeMap, afterMap, changedPaths);
|
|
782
|
+
const hasActiveModal = [...(beforeMap.overlays ?? []), ...(afterMap.overlays ?? [])].some((overlay) => overlay.ariaModal === 'true');
|
|
783
|
+
const isPopup = beforeMap.metadata?.variantKind === 'popup' || afterMap.metadata?.variantKind === 'popup';
|
|
784
|
+
return { hasExposedChange, hasActiveModal, isPopup, width: surfaceWidth(candidate.sd.surface) };
|
|
785
|
+
}
|
|
786
|
+
function isBetterRepresentative(candidate, current) {
|
|
787
|
+
if (candidate.hasExposedChange !== current.hasExposedChange)
|
|
788
|
+
return candidate.hasExposedChange;
|
|
789
|
+
if (candidate.hasActiveModal !== current.hasActiveModal)
|
|
790
|
+
return !candidate.hasActiveModal;
|
|
791
|
+
if (candidate.isPopup !== current.isPopup)
|
|
792
|
+
return !candidate.isPopup;
|
|
793
|
+
return candidate.width > current.width;
|
|
794
|
+
}
|
|
678
795
|
// Group surfaces that changed in the SAME way (the rects differ per width; the change
|
|
679
|
-
// itself does not) so an identical change shows once, not once per surface
|
|
680
|
-
// the
|
|
681
|
-
function groupBySignature(prepared) {
|
|
796
|
+
// itself does not) so an identical change shows once, not once per surface. Select
|
|
797
|
+
// the representative by visible proof first; width only breaks otherwise-equal ties.
|
|
798
|
+
function groupBySignature(prepared, beforeDir, afterDir) {
|
|
682
799
|
const bySig = new Map();
|
|
800
|
+
const scoreBySurface = new Map();
|
|
801
|
+
const score = (candidate) => {
|
|
802
|
+
const existing = scoreBySurface.get(candidate.sd.surface);
|
|
803
|
+
if (existing)
|
|
804
|
+
return existing;
|
|
805
|
+
const computed = representativeScore(candidate, beforeDir, afterDir);
|
|
806
|
+
scoreBySurface.set(candidate.sd.surface, computed);
|
|
807
|
+
return computed;
|
|
808
|
+
};
|
|
683
809
|
for (const p of prepared) {
|
|
684
810
|
if (p.sd.missing)
|
|
685
811
|
continue;
|
|
@@ -687,7 +813,7 @@ function groupBySignature(prepared) {
|
|
|
687
813
|
const existing = bySig.get(sig);
|
|
688
814
|
if (existing) {
|
|
689
815
|
existing.surfaces.push(p.sd.surface);
|
|
690
|
-
if (
|
|
816
|
+
if (isBetterRepresentative(score(p), score(existing.rep)))
|
|
691
817
|
existing.rep = p;
|
|
692
818
|
}
|
|
693
819
|
else {
|
|
@@ -713,6 +839,13 @@ function countShownChanges(changeGroups) {
|
|
|
713
839
|
}
|
|
714
840
|
// The identical / changed / new-surface summary line(s). Split out (with an early
|
|
715
841
|
// return for the all-identical case) so reportHeadline stays flat.
|
|
842
|
+
function newSurfaceSummary(missing, maxNamed = 8) {
|
|
843
|
+
const bases = [...new Set(missing.map((p) => surfaceBase(p.sd.surface)))].sort();
|
|
844
|
+
const shownBases = new Set(bases.slice(0, maxNamed));
|
|
845
|
+
const shownSurfaces = missing.map((p) => p.sd.surface).filter((surface) => shownBases.has(surfaceBase(surface)));
|
|
846
|
+
const more = bases.length > maxNamed ? `, +${bases.length - maxNamed} more` : '';
|
|
847
|
+
return '`' + formatSurfaceList(shownSurfaces) + '`' + more;
|
|
848
|
+
}
|
|
716
849
|
function summaryLines(args) {
|
|
717
850
|
const { changeGroups, missing, shown, changedSurfaceCount, contentCount } = args;
|
|
718
851
|
if (changeGroups.length === 0 && missing.length === 0) {
|
|
@@ -723,15 +856,15 @@ function summaryLines(args) {
|
|
|
723
856
|
];
|
|
724
857
|
}
|
|
725
858
|
const md = [];
|
|
726
|
-
if (changeGroups.length > 0) {
|
|
727
|
-
md.push(`**${changeCountLabel(shown)}** across ${changeGroups.length} distinct change(s) in ${changedSurfaceCount} surface(s).`);
|
|
728
|
-
}
|
|
729
859
|
if (missing.length > 0) {
|
|
730
|
-
|
|
731
|
-
md.push('');
|
|
732
|
-
md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare — shown below for review. ` +
|
|
860
|
+
md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare: ${newSurfaceSummary(missing)}. ` +
|
|
733
861
|
`Approve them before they become the baseline.`);
|
|
734
862
|
}
|
|
863
|
+
if (changeGroups.length > 0) {
|
|
864
|
+
if (md.length > 0)
|
|
865
|
+
md.push('');
|
|
866
|
+
md.push(`**${changeCountLabel(shown)}** across ${changeGroups.length} distinct change(s) in ${changedSurfaceCount} existing surface(s).`);
|
|
867
|
+
}
|
|
735
868
|
return md;
|
|
736
869
|
}
|
|
737
870
|
// The headline summary lines between the certification block and the per-surface
|
|
@@ -786,20 +919,26 @@ function buildRegionImages(args) {
|
|
|
786
919
|
// cards) on each side — not the merged container the crop anchors on, whose box would
|
|
787
920
|
// just trace the whole frame. An element present on only one side (added/removed) is
|
|
788
921
|
// boxed only there.
|
|
789
|
-
const
|
|
790
|
-
const rectsA =
|
|
791
|
-
const rectsB =
|
|
792
|
-
const
|
|
793
|
-
|
|
922
|
+
const markedPaths = annotationPaths(regionFindings, mapA, mapB);
|
|
923
|
+
const rectsA = markedPaths.before.map((p) => mapA.elements[p]?.rect).filter((r) => !!r);
|
|
924
|
+
const rectsB = markedPaths.after.map((p) => mapB.elements[p]?.rect).filter((r) => !!r);
|
|
925
|
+
const annotatedBefore = annotateCrop(before, rectsA);
|
|
926
|
+
const annotatedAfter = annotateCrop(after, rectsB);
|
|
794
927
|
const images = {
|
|
795
928
|
composite: `${stem}-composite.png`,
|
|
796
|
-
annotated: `${stem}-annotated.png`,
|
|
797
929
|
};
|
|
930
|
+
if (annotatedBefore.highlighted || annotatedAfter.highlighted) {
|
|
931
|
+
const annotated = compositePair(annotatedBefore.png, annotatedAfter.png);
|
|
932
|
+
writePng(path.join(outDir, `${stem}-annotated.png`), annotated);
|
|
933
|
+
images.annotated = `${stem}-annotated.png`;
|
|
934
|
+
}
|
|
798
935
|
// Name the changed element(s) so the reviewer knows where to look without expanding
|
|
799
936
|
// anything (e.g. `changed: span.caret`).
|
|
800
937
|
const changedNames = [
|
|
801
|
-
...new Set(
|
|
802
|
-
.map((
|
|
938
|
+
...new Set([
|
|
939
|
+
...markedPaths.before.map((elementPath) => mapA.elements[elementPath]),
|
|
940
|
+
...markedPaths.after.map((elementPath) => mapB.elements[elementPath]),
|
|
941
|
+
]
|
|
803
942
|
.filter((e) => !!e)
|
|
804
943
|
.map((e) => (e.cls ? `${e.tag}.${e.cls.split(/\s+/)[0]}` : e.tag))),
|
|
805
944
|
].slice(0, 3);
|
|
@@ -827,11 +966,10 @@ function buildRegionImages(args) {
|
|
|
827
966
|
`})`,
|
|
828
967
|
'',
|
|
829
968
|
`<sub>◀ before · after ▶ — ${ctxLabel}</sub>`,
|
|
830
|
-
'',
|
|
831
|
-
`})`,
|
|
832
|
-
'',
|
|
833
|
-
`<sub>🔍 magenta boxes mark each change${changedLabel}</sub>`,
|
|
834
969
|
];
|
|
970
|
+
if (images.annotated) {
|
|
971
|
+
md.push('', `})`, '', `<sub>🔍 magenta boxes mark each change${changedLabel}</sub>`);
|
|
972
|
+
}
|
|
835
973
|
if (images.zoom) {
|
|
836
974
|
md.push('', `})`, '', `<sub>🔬 magnified ${zoomFactor}× — change too small to see at 1:1${changedLabel}</sub>`);
|
|
837
975
|
}
|
|
@@ -876,9 +1014,25 @@ function renderChangeGroup(cg, ctx, maxCrops, cropSeq) {
|
|
|
876
1014
|
const mapB = loadStyleMap(findCapture(ctx.afterDir, sd.surface));
|
|
877
1015
|
// Theme-token reverse-indexes so colour changes can name `red-200` per side.
|
|
878
1016
|
const describeCtx = { tokensBefore: tokenIndex(mapA.tokens), tokensAfter: tokenIndex(mapB.tokens) };
|
|
1017
|
+
const changedPaths = outermost([...new Set(surfaceFindings.map((f) => f.path))]);
|
|
1018
|
+
if (!hasExposedChangedEntry(mapA, mapB, changedPaths)) {
|
|
1019
|
+
const reason = 'The changed element is not visibly painted in this representative state (it is hidden at this breakpoint or is background content behind an active modal), so a before/after crop would be misleading.';
|
|
1020
|
+
return {
|
|
1021
|
+
md: ['', `_${reason}_`, '', ...renderCropChanges(surfaceFindings, ctx.foldDetailsAt, describeCtx)],
|
|
1022
|
+
json: {
|
|
1023
|
+
surfaces: cg.surfaces,
|
|
1024
|
+
representative: sd.surface,
|
|
1025
|
+
regions: [],
|
|
1026
|
+
findings: surfaceFindings,
|
|
1027
|
+
visualEvidence: 'not-rendered',
|
|
1028
|
+
reason,
|
|
1029
|
+
},
|
|
1030
|
+
findingCount: surfaceFindings.length,
|
|
1031
|
+
cropSeq,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
879
1034
|
const pngA = readPng(path.join(ctx.beforeDir, `${sd.surface}.png`));
|
|
880
1035
|
const pngB = readPng(path.join(ctx.afterDir, `${sd.surface}.png`));
|
|
881
|
-
const changedPaths = outermost([...new Set(surfaceFindings.map((f) => f.path))]);
|
|
882
1036
|
let groups = groupRegions(changedPaths, mapA, mapB, ctx.padBy);
|
|
883
1037
|
if (groups.length > maxCrops)
|
|
884
1038
|
groups = collapseGroups(groups);
|
|
@@ -918,11 +1072,11 @@ function renderNewSurface(p, ctx, cropSeq) {
|
|
|
918
1072
|
const json = { surface: p.sd.surface, missing: p.sd.missing, isNew: true };
|
|
919
1073
|
if (png) {
|
|
920
1074
|
cropSeq++;
|
|
921
|
-
const h = Math.min(maxHeight, png.height);
|
|
1075
|
+
const h = Math.min(maxHeight, png.height, map.viewport?.height ?? png.height);
|
|
922
1076
|
const crop = cropPng(png, { x: 0, y: 0, w: png.width, h }, png.width, h).png;
|
|
923
1077
|
const stem = `crops/${p.sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}-new`;
|
|
924
1078
|
writePng(path.join(outDir, `${stem}.png`), crop);
|
|
925
|
-
md.push('', `})`, '', `<sub>${side} · ${formatSurfaceWithContext(p.sd.surface, map)}${png.height > h ? ' (top of page)' : ''}</sub>`);
|
|
1079
|
+
md.push('', `})`, '', `<sub>${side} · ${formatSurfaceWithContext(p.sd.surface, map)}${png.height > h ? ' (top viewport of page)' : ''}</sub>`);
|
|
926
1080
|
json.image = `${stem}.png`;
|
|
927
1081
|
}
|
|
928
1082
|
else {
|
|
@@ -998,7 +1152,7 @@ export function generateStyleMapReport(opts) {
|
|
|
998
1152
|
}))
|
|
999
1153
|
.filter((p) => p.sd.missing || p.findings.length > 0);
|
|
1000
1154
|
const missing = prepared.filter((p) => p.sd.missing);
|
|
1001
|
-
const changeGroups = groupBySignature(prepared);
|
|
1155
|
+
const changeGroups = groupBySignature(prepared, beforeDir, afterDir);
|
|
1002
1156
|
// Shared-chrome tier (#193): promote a change that rode the frame every view
|
|
1003
1157
|
// draws (nav rail, header) to a callout, so the reviewer reads "the nav changed
|
|
1004
1158
|
// everywhere" once instead of inferring it from a long surface list on several
|
|
@@ -1073,6 +1227,18 @@ export function generateStyleMapReport(opts) {
|
|
|
1073
1227
|
const totalSurfaceBases = new Set(surfaceKeysIn(afterDir).map(surfaceBase)).size;
|
|
1074
1228
|
const chromeSet = new Set(chrome);
|
|
1075
1229
|
let chromeHeaderEmitted = false;
|
|
1230
|
+
if (missing.length > 0) {
|
|
1231
|
+
md.push('', '## 🆕 New pages, states, or surfaces — review first');
|
|
1232
|
+
}
|
|
1233
|
+
for (const p of missing) {
|
|
1234
|
+
const r = renderNewSurface(p, ctx, cropSeq);
|
|
1235
|
+
json.push(r.json);
|
|
1236
|
+
cropSeq = r.cropSeq;
|
|
1237
|
+
emitDetail(r.md, `- \`${safeKey(p.sd.surface)}\` · new surface`);
|
|
1238
|
+
}
|
|
1239
|
+
if (orderedGroups.length > 0) {
|
|
1240
|
+
md.push('', '## Element-level changes');
|
|
1241
|
+
}
|
|
1076
1242
|
for (const cg of orderedGroups) {
|
|
1077
1243
|
const r = renderChangeGroup(cg, ctx, maxCrops, cropSeq);
|
|
1078
1244
|
json.push(r.json);
|
|
@@ -1085,12 +1251,6 @@ export function generateStyleMapReport(opts) {
|
|
|
1085
1251
|
: r.md;
|
|
1086
1252
|
emitDetail(detail, compactChangeSummary(cg, r.json, img));
|
|
1087
1253
|
}
|
|
1088
|
-
for (const p of missing) {
|
|
1089
|
-
const r = renderNewSurface(p, ctx, cropSeq);
|
|
1090
|
-
json.push(r.json);
|
|
1091
|
-
cropSeq = r.cropSeq;
|
|
1092
|
-
emitDetail(r.md, `- \`${safeKey(p.sd.surface)}\` · new surface`);
|
|
1093
|
-
}
|
|
1094
1254
|
md.push(...contentSection.md);
|
|
1095
1255
|
const reportMdPath = path.join(outDir, 'report.md');
|
|
1096
1256
|
const reportJsonPath = path.join(outDir, 'report.json');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|