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 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
- One change — the hero CTA recoloured cyan → amber — appears as a single section in the report: a side-by-side before/after cropped screenshot, the same crop again with magenta boxes marking exactly what changed, a one-line summary, then the exact property change folded under a toggle. A change too small to see at 1:1 (say a 2px icon tweak) also gets a magnified zoom crop, so a sub-pixel change can't slip past a reviewer.
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
  ![A StyleProof report: the CTA button before (cyan) and after (amber), side by side](https://raw.githubusercontent.com/BenSheridanEdwards/StyleProof/main/docs/demo-composite.png)
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 n = el;
67
- while (n && n !== document.body) {
68
- const parent = n.parentElement;
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(`${n.tagName.toLowerCase()}:nth-child(${Array.prototype.indexOf.call(parent.children, n) + 1})`);
72
- n = parent;
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 = (n) => {
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,
@@ -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() ?? '').trim().replace(/:nth-child\(\d+\)/, '') || 'el';
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 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 = [];
@@ -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
- strokeRect(out, rx - crop.ox, ry - crop.oy, rw, rh);
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 — keeping
680
- // the widest surface as the representative image.
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 (surfaceWidth(p.sd.surface) > surfaceWidth(existing.rep.sd.surface))
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
- if (changeGroups.length > 0)
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 markPaths = innermost([...new Set(regionFindings.map((f) => f.path))]);
790
- const rectsA = markPaths.map((p) => mapA.elements[p]?.rect).filter((r) => !!r);
791
- const rectsB = markPaths.map((p) => mapB.elements[p]?.rect).filter((r) => !!r);
792
- const annotated = compositePair(annotateCrop(before, rectsA), annotateCrop(after, rectsB));
793
- writePng(path.join(outDir, `${stem}-annotated.png`), annotated);
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(markPaths
802
- .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
+ ]
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
  `![before ◀ │ ▶ after](${img(images.composite)})`,
828
967
  '',
829
968
  `<sub>◀ before · after ▶ — ${ctxLabel}</sub>`,
830
- '',
831
- `![highlighted before ◀ │ ▶ after](${img(images.annotated)})`,
832
- '',
833
- `<sub>🔍 magenta boxes mark each change${changedLabel}</sub>`,
834
969
  ];
970
+ if (images.annotated) {
971
+ md.push('', `![highlighted before ◀ │ ▶ after](${img(images.annotated)})`, '', `<sub>🔍 magenta boxes mark each change${changedLabel}</sub>`);
972
+ }
835
973
  if (images.zoom) {
836
974
  md.push('', `![zoomed before ◀ │ ▶ after](${img(images.zoom)})`, '', `<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('', `![new surface — ${side}](${img(`${stem}.png`)})`, '', `<sub>${side} · ${formatSurfaceWithContext(p.sd.surface, map)}${png.height > h ? ' (top of page)' : ''}</sub>`);
1079
+ md.push('', `![new surface — ${side}](${img(`${stem}.png`)})`, '', `<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.1",
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",