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 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 fromEnv = env.GITHUB_SHA || env.GITHUB_HEAD_SHA;
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 markPaths = innermost([...new Set(regionFindings.map((f) => f.path))]);
857
- const rectsA = markPaths.map((p) => mapA.elements[p]?.rect).filter((r) => !!r);
858
- const rectsB = markPaths.map((p) => mapB.elements[p]?.rect).filter((r) => !!r);
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(markPaths
873
- .map((p) => mapA.elements[p] ?? mapB.elements[p])
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.2",
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",