styleproof 4.0.2 → 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 +15 -0
- package/README.md +6 -0
- package/dist/map-store.js +17 -1
- package/dist/report.js +73 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ 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
|
+
|
|
10
25
|
## [4.0.2] - 2026-07-11
|
|
11
26
|
|
|
12
27
|
### Fixed
|
package/README.md
CHANGED
|
@@ -217,6 +217,12 @@ summary, and the exact property change folded under a toggle. A change too small
|
|
|
217
217
|
to see at 1:1 (say a 2px icon tweak) also gets a magnified zoom crop, so a
|
|
218
218
|
sub-pixel change can't slip past a reviewer.
|
|
219
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
|
+
|
|
220
226
|
When one change appears both on an ordinary page and in an open popup, the report
|
|
221
227
|
chooses a representative where the changed element is visibly exposed, then prefers
|
|
222
228
|
the ordinary page before using viewport width as a tie-breaker. Modal-background DOM
|
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 = [];
|
|
@@ -853,9 +919,9 @@ function buildRegionImages(args) {
|
|
|
853
919
|
// cards) on each side — not the merged container the crop anchors on, whose box would
|
|
854
920
|
// just trace the whole frame. An element present on only one side (added/removed) is
|
|
855
921
|
// boxed only there.
|
|
856
|
-
const
|
|
857
|
-
const rectsA =
|
|
858
|
-
const rectsB =
|
|
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);
|
|
859
925
|
const annotatedBefore = annotateCrop(before, rectsA);
|
|
860
926
|
const annotatedAfter = annotateCrop(after, rectsB);
|
|
861
927
|
const images = {
|
|
@@ -869,8 +935,10 @@ function buildRegionImages(args) {
|
|
|
869
935
|
// Name the changed element(s) so the reviewer knows where to look without expanding
|
|
870
936
|
// anything (e.g. `changed: span.caret`).
|
|
871
937
|
const changedNames = [
|
|
872
|
-
...new Set(
|
|
873
|
-
.map((
|
|
938
|
+
...new Set([
|
|
939
|
+
...markedPaths.before.map((elementPath) => mapA.elements[elementPath]),
|
|
940
|
+
...markedPaths.after.map((elementPath) => mapB.elements[elementPath]),
|
|
941
|
+
]
|
|
874
942
|
.filter((e) => !!e)
|
|
875
943
|
.map((e) => (e.cls ? `${e.tag}.${e.cls.split(/\s+/)[0]}` : e.tag))),
|
|
876
944
|
].slice(0, 3);
|
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",
|