styleproof 4.0.1 → 4.0.2

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,33 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.0.2] - 2026-07-11
11
+
12
+ ### Fixed
13
+
14
+ - **Inserting a semantic sibling no longer turns every following element into a
15
+ false restyle.** Capture paths now prefer privacy-safe hashed identities from
16
+ stable attributes (`data-styleproof-key`, IDs, test IDs, link destinations,
17
+ and form names) when they are unique among siblings, falling back to
18
+ `:nth-child()` only when no stable identity exists. Raw attribute values never
19
+ enter the map.
20
+ - **Reports no longer publish an unchanged image as a misleading highlighted
21
+ twin.** The annotated crop is omitted when every changed rectangle falls
22
+ outside the rendered crop.
23
+ - **New-surface screenshots no longer preserve a blank full-page tail.** New
24
+ captures record their viewport and reports show that top viewport when the
25
+ full-page screenshot is taller.
26
+ - **Shared changes now choose exposed proof and suppress misleading crops.** When
27
+ the same change appears on an ordinary page and a modal-open state, the report
28
+ excludes modal-background DOM content before viewport width; if no captured
29
+ state visibly paints the change, it retains the audit details without images.
30
+
31
+ ### Changed
32
+
33
+ - **New pages, states, and surfaces render before element-level diffs.** A new
34
+ page can no longer be buried beneath shared-frame changes in the report.
35
+ - 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.
36
+
10
37
  ## [4.0.1] - 2026-07-07
11
38
 
12
39
  ### Fixed
package/README.md CHANGED
@@ -209,7 +209,27 @@ 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
+ When one change appears both on an ordinary page and in an open popup, the report
221
+ chooses a representative where the changed element is visibly exposed, then prefers
222
+ the ordinary page before using viewport width as a tie-breaker. Modal-background DOM
223
+ content is excluded; if no captured state visibly paints the change, the report keeps
224
+ the audit details but omits a misleading identical before/after crop.
225
+
226
+ Element identity is stable across ordinary list insertions. StyleProof prefers a
227
+ privacy-safe hash of a unique `data-styleproof-key`, ID, test ID, link destination,
228
+ or form name for each sibling and falls back to `:nth-child()` only when the DOM
229
+ offers no stable semantic identity. Attribute values are never written into the
230
+ map. This keeps inserting one navigation link from falsely restyling every link
231
+ after it; add `data-styleproof-key` when repeated controls have no other stable
232
+ attribute.
213
233
 
214
234
  ![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
235
 
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/report.js CHANGED
@@ -127,12 +127,20 @@ function strokeRect(png, x, y, w, h, t = 2, color = HILITE) {
127
127
  function annotateCrop(crop, rects) {
128
128
  const out = new PNG({ width: crop.png.width, height: crop.png.height });
129
129
  PNG.bitblt(crop.png, out, 0, 0, crop.png.width, crop.png.height, 0, 0);
130
+ let highlighted = false;
130
131
  for (const [rx, ry, rw, rh] of rects) {
131
132
  if (rw <= 0 || rh <= 0)
132
133
  continue;
133
- strokeRect(out, rx - crop.ox, ry - crop.oy, rw, rh);
134
+ const left = Math.max(0, rx - crop.ox);
135
+ const top = Math.max(0, ry - crop.oy);
136
+ const right = Math.min(crop.png.width, rx - crop.ox + rw);
137
+ const bottom = Math.min(crop.png.height, ry - crop.oy + rh);
138
+ if (right <= left || bottom <= top)
139
+ continue;
140
+ strokeRect(out, left, top, right - left, bottom - top, Math.min(2, right - left, bottom - top));
141
+ highlighted = true;
134
142
  }
135
- return out;
143
+ return { png: out, highlighted };
136
144
  }
137
145
  /**
138
146
  * One before|after image: the two equal-size crops on a dark canvas with a
@@ -675,11 +683,63 @@ function certificationLines(beforeDir, afterDir) {
675
683
  '',
676
684
  ];
677
685
  }
686
+ /** A changed element can anchor useful visual proof only when the browser paints it. */
687
+ function isPaintedEntry(entry) {
688
+ if (!entry?.rect || !visible(rectToBox(entry.rect)))
689
+ return false;
690
+ if (entry.style.display === 'none' || entry.style.visibility === 'hidden')
691
+ return false;
692
+ return Number(entry.style.opacity ?? '1') > 0;
693
+ }
694
+ function isSameOrDescendantPath(candidatePath, ancestorPath) {
695
+ return candidatePath === ancestorPath || candidatePath.startsWith(`${ancestorPath} > `);
696
+ }
697
+ /** A modal leaves its background in the DOM but makes it unsuitable as visual proof.
698
+ * A change inside the modal itself is foreground content and remains eligible. */
699
+ function isBackgroundBehindActiveModal(map, changedPath) {
700
+ return (map.overlays ?? []).some((overlay) => overlay.ariaModal === 'true' && !isSameOrDescendantPath(changedPath, overlay.path));
701
+ }
702
+ function isExposedChangedEntry(map, changedPath) {
703
+ return isPaintedEntry(map.elements[changedPath]) && !isBackgroundBehindActiveModal(map, changedPath);
704
+ }
705
+ function hasExposedChangedEntry(mapA, mapB, changedPaths) {
706
+ return changedPaths.some((changedPath) => isExposedChangedEntry(mapA, changedPath) || isExposedChangedEntry(mapB, changedPath));
707
+ }
708
+ /** Prefer proof a reviewer can see: an exposed changed element, then a non-modal
709
+ * ordinary page over a popup state that can leave shared chrome in the background,
710
+ * then the widest width. */
711
+ function representativeScore(candidate, beforeDir, afterDir) {
712
+ const beforeMap = loadStyleMap(findCapture(beforeDir, candidate.sd.surface));
713
+ const afterMap = loadStyleMap(findCapture(afterDir, candidate.sd.surface));
714
+ const changedPaths = [...new Set(candidate.findings.map((finding) => finding.path))];
715
+ const hasExposedChange = hasExposedChangedEntry(beforeMap, afterMap, changedPaths);
716
+ const hasActiveModal = [...(beforeMap.overlays ?? []), ...(afterMap.overlays ?? [])].some((overlay) => overlay.ariaModal === 'true');
717
+ const isPopup = beforeMap.metadata?.variantKind === 'popup' || afterMap.metadata?.variantKind === 'popup';
718
+ return { hasExposedChange, hasActiveModal, isPopup, width: surfaceWidth(candidate.sd.surface) };
719
+ }
720
+ function isBetterRepresentative(candidate, current) {
721
+ if (candidate.hasExposedChange !== current.hasExposedChange)
722
+ return candidate.hasExposedChange;
723
+ if (candidate.hasActiveModal !== current.hasActiveModal)
724
+ return !candidate.hasActiveModal;
725
+ if (candidate.isPopup !== current.isPopup)
726
+ return !candidate.isPopup;
727
+ return candidate.width > current.width;
728
+ }
678
729
  // 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) {
730
+ // itself does not) so an identical change shows once, not once per surface. Select
731
+ // the representative by visible proof first; width only breaks otherwise-equal ties.
732
+ function groupBySignature(prepared, beforeDir, afterDir) {
682
733
  const bySig = new Map();
734
+ const scoreBySurface = new Map();
735
+ const score = (candidate) => {
736
+ const existing = scoreBySurface.get(candidate.sd.surface);
737
+ if (existing)
738
+ return existing;
739
+ const computed = representativeScore(candidate, beforeDir, afterDir);
740
+ scoreBySurface.set(candidate.sd.surface, computed);
741
+ return computed;
742
+ };
683
743
  for (const p of prepared) {
684
744
  if (p.sd.missing)
685
745
  continue;
@@ -687,7 +747,7 @@ function groupBySignature(prepared) {
687
747
  const existing = bySig.get(sig);
688
748
  if (existing) {
689
749
  existing.surfaces.push(p.sd.surface);
690
- if (surfaceWidth(p.sd.surface) > surfaceWidth(existing.rep.sd.surface))
750
+ if (isBetterRepresentative(score(p), score(existing.rep)))
691
751
  existing.rep = p;
692
752
  }
693
753
  else {
@@ -713,6 +773,13 @@ function countShownChanges(changeGroups) {
713
773
  }
714
774
  // The identical / changed / new-surface summary line(s). Split out (with an early
715
775
  // return for the all-identical case) so reportHeadline stays flat.
776
+ function newSurfaceSummary(missing, maxNamed = 8) {
777
+ const bases = [...new Set(missing.map((p) => surfaceBase(p.sd.surface)))].sort();
778
+ const shownBases = new Set(bases.slice(0, maxNamed));
779
+ const shownSurfaces = missing.map((p) => p.sd.surface).filter((surface) => shownBases.has(surfaceBase(surface)));
780
+ const more = bases.length > maxNamed ? `, +${bases.length - maxNamed} more` : '';
781
+ return '`' + formatSurfaceList(shownSurfaces) + '`' + more;
782
+ }
716
783
  function summaryLines(args) {
717
784
  const { changeGroups, missing, shown, changedSurfaceCount, contentCount } = args;
718
785
  if (changeGroups.length === 0 && missing.length === 0) {
@@ -723,15 +790,15 @@ function summaryLines(args) {
723
790
  ];
724
791
  }
725
792
  const md = [];
726
- if (changeGroups.length > 0) {
727
- md.push(`**${changeCountLabel(shown)}** across ${changeGroups.length} distinct change(s) in ${changedSurfaceCount} surface(s).`);
728
- }
729
793
  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. ` +
794
+ md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare: ${newSurfaceSummary(missing)}. ` +
733
795
  `Approve them before they become the baseline.`);
734
796
  }
797
+ if (changeGroups.length > 0) {
798
+ if (md.length > 0)
799
+ md.push('');
800
+ md.push(`**${changeCountLabel(shown)}** across ${changeGroups.length} distinct change(s) in ${changedSurfaceCount} existing surface(s).`);
801
+ }
735
802
  return md;
736
803
  }
737
804
  // The headline summary lines between the certification block and the per-surface
@@ -789,12 +856,16 @@ function buildRegionImages(args) {
789
856
  const markPaths = innermost([...new Set(regionFindings.map((f) => f.path))]);
790
857
  const rectsA = markPaths.map((p) => mapA.elements[p]?.rect).filter((r) => !!r);
791
858
  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);
859
+ const annotatedBefore = annotateCrop(before, rectsA);
860
+ const annotatedAfter = annotateCrop(after, rectsB);
794
861
  const images = {
795
862
  composite: `${stem}-composite.png`,
796
- annotated: `${stem}-annotated.png`,
797
863
  };
864
+ if (annotatedBefore.highlighted || annotatedAfter.highlighted) {
865
+ const annotated = compositePair(annotatedBefore.png, annotatedAfter.png);
866
+ writePng(path.join(outDir, `${stem}-annotated.png`), annotated);
867
+ images.annotated = `${stem}-annotated.png`;
868
+ }
798
869
  // Name the changed element(s) so the reviewer knows where to look without expanding
799
870
  // anything (e.g. `changed: span.caret`).
800
871
  const changedNames = [
@@ -827,11 +898,10 @@ function buildRegionImages(args) {
827
898
  `![before ◀ │ ▶ after](${img(images.composite)})`,
828
899
  '',
829
900
  `<sub>◀ before · after ▶ — ${ctxLabel}</sub>`,
830
- '',
831
- `![highlighted before ◀ │ ▶ after](${img(images.annotated)})`,
832
- '',
833
- `<sub>🔍 magenta boxes mark each change${changedLabel}</sub>`,
834
901
  ];
902
+ if (images.annotated) {
903
+ md.push('', `![highlighted before ◀ │ ▶ after](${img(images.annotated)})`, '', `<sub>🔍 magenta boxes mark each change${changedLabel}</sub>`);
904
+ }
835
905
  if (images.zoom) {
836
906
  md.push('', `![zoomed before ◀ │ ▶ after](${img(images.zoom)})`, '', `<sub>🔬 magnified ${zoomFactor}× — change too small to see at 1:1${changedLabel}</sub>`);
837
907
  }
@@ -876,9 +946,25 @@ function renderChangeGroup(cg, ctx, maxCrops, cropSeq) {
876
946
  const mapB = loadStyleMap(findCapture(ctx.afterDir, sd.surface));
877
947
  // Theme-token reverse-indexes so colour changes can name `red-200` per side.
878
948
  const describeCtx = { tokensBefore: tokenIndex(mapA.tokens), tokensAfter: tokenIndex(mapB.tokens) };
949
+ const changedPaths = outermost([...new Set(surfaceFindings.map((f) => f.path))]);
950
+ if (!hasExposedChangedEntry(mapA, mapB, changedPaths)) {
951
+ 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.';
952
+ return {
953
+ md: ['', `_${reason}_`, '', ...renderCropChanges(surfaceFindings, ctx.foldDetailsAt, describeCtx)],
954
+ json: {
955
+ surfaces: cg.surfaces,
956
+ representative: sd.surface,
957
+ regions: [],
958
+ findings: surfaceFindings,
959
+ visualEvidence: 'not-rendered',
960
+ reason,
961
+ },
962
+ findingCount: surfaceFindings.length,
963
+ cropSeq,
964
+ };
965
+ }
879
966
  const pngA = readPng(path.join(ctx.beforeDir, `${sd.surface}.png`));
880
967
  const pngB = readPng(path.join(ctx.afterDir, `${sd.surface}.png`));
881
- const changedPaths = outermost([...new Set(surfaceFindings.map((f) => f.path))]);
882
968
  let groups = groupRegions(changedPaths, mapA, mapB, ctx.padBy);
883
969
  if (groups.length > maxCrops)
884
970
  groups = collapseGroups(groups);
@@ -918,11 +1004,11 @@ function renderNewSurface(p, ctx, cropSeq) {
918
1004
  const json = { surface: p.sd.surface, missing: p.sd.missing, isNew: true };
919
1005
  if (png) {
920
1006
  cropSeq++;
921
- const h = Math.min(maxHeight, png.height);
1007
+ const h = Math.min(maxHeight, png.height, map.viewport?.height ?? png.height);
922
1008
  const crop = cropPng(png, { x: 0, y: 0, w: png.width, h }, png.width, h).png;
923
1009
  const stem = `crops/${p.sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}-new`;
924
1010
  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>`);
1011
+ md.push('', `![new surface — ${side}](${img(`${stem}.png`)})`, '', `<sub>${side} · ${formatSurfaceWithContext(p.sd.surface, map)}${png.height > h ? ' (top viewport of page)' : ''}</sub>`);
926
1012
  json.image = `${stem}.png`;
927
1013
  }
928
1014
  else {
@@ -998,7 +1084,7 @@ export function generateStyleMapReport(opts) {
998
1084
  }))
999
1085
  .filter((p) => p.sd.missing || p.findings.length > 0);
1000
1086
  const missing = prepared.filter((p) => p.sd.missing);
1001
- const changeGroups = groupBySignature(prepared);
1087
+ const changeGroups = groupBySignature(prepared, beforeDir, afterDir);
1002
1088
  // Shared-chrome tier (#193): promote a change that rode the frame every view
1003
1089
  // draws (nav rail, header) to a callout, so the reviewer reads "the nav changed
1004
1090
  // everywhere" once instead of inferring it from a long surface list on several
@@ -1073,6 +1159,18 @@ export function generateStyleMapReport(opts) {
1073
1159
  const totalSurfaceBases = new Set(surfaceKeysIn(afterDir).map(surfaceBase)).size;
1074
1160
  const chromeSet = new Set(chrome);
1075
1161
  let chromeHeaderEmitted = false;
1162
+ if (missing.length > 0) {
1163
+ md.push('', '## 🆕 New pages, states, or surfaces — review first');
1164
+ }
1165
+ for (const p of missing) {
1166
+ const r = renderNewSurface(p, ctx, cropSeq);
1167
+ json.push(r.json);
1168
+ cropSeq = r.cropSeq;
1169
+ emitDetail(r.md, `- \`${safeKey(p.sd.surface)}\` · new surface`);
1170
+ }
1171
+ if (orderedGroups.length > 0) {
1172
+ md.push('', '## Element-level changes');
1173
+ }
1076
1174
  for (const cg of orderedGroups) {
1077
1175
  const r = renderChangeGroup(cg, ctx, maxCrops, cropSeq);
1078
1176
  json.push(r.json);
@@ -1085,12 +1183,6 @@ export function generateStyleMapReport(opts) {
1085
1183
  : r.md;
1086
1184
  emitDetail(detail, compactChangeSummary(cg, r.json, img));
1087
1185
  }
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
1186
  md.push(...contentSection.md);
1095
1187
  const reportMdPath = path.join(outDir, 'report.md');
1096
1188
  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.0.2",
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",