styleproof 3.18.0 → 3.20.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/dist/diff.js CHANGED
@@ -63,14 +63,51 @@ const ORIGIN_EPSILON_PX = 0.05;
63
63
  function sameRect(a, b) {
64
64
  return !!a && !!b && a.every((v, i) => v === b[i]);
65
65
  }
66
+ const HORIZONTAL_MARGIN_PAIRS = [
67
+ ['margin-left', 'margin-right'],
68
+ ['margin-inline-start', 'margin-inline-end'],
69
+ ];
70
+ function marginPxDelta(p) {
71
+ const before = pxParts(p.before);
72
+ const after = pxParts(p.after);
73
+ return before?.length === 1 && after?.length === 1 ? after[0] - before[0] : null;
74
+ }
75
+ // True when one horizontal side moved by a different px amount than its
76
+ // opposite. Such a change would shift the box on its own, so an *identical*
77
+ // rect means something else compensated — a real restyle, not layout-equivalent
78
+ // drift. Non-px or state-sentinel values (`(state no longer changes it)`) aren't
79
+ // demonstrable, so they fall through to the balanced (drop) path unchanged.
80
+ function marginChangeHasPxImbalance(props) {
81
+ const delta = new Map();
82
+ for (const p of props) {
83
+ if (LAYOUT_EQUIVALENT_MARGIN_PROPS.has(p.prop))
84
+ delta.set(p.prop, marginPxDelta(p));
85
+ }
86
+ for (const [start, end] of HORIZONTAL_MARGIN_PAIRS) {
87
+ const ds = delta.has(start) ? delta.get(start) : 0;
88
+ const de = delta.has(end) ? delta.get(end) : 0;
89
+ if (ds !== null && de !== null && ds !== de)
90
+ return true;
91
+ }
92
+ return false;
93
+ }
66
94
  function dropLayoutEquivalentMarginProps(props, a, b) {
67
95
  if (!sameRect(a?.rect, b?.rect))
68
96
  return props;
97
+ // ponytail: a balanced margin change with an unchanged rect is treated as
98
+ // layout-equivalent from computed style alone. That still drops the rare case
99
+ // where a *balanced* change was held in place by external compensation — a
100
+ // consciously-deferred, low-reach soundness corner; closing it needs
101
+ // cross-element layout reasoning. The common one-sided case is caught here.
102
+ if (marginChangeHasPxImbalance(props))
103
+ return props;
69
104
  return props.filter((p) => !LAYOUT_EQUIVALENT_MARGIN_PROPS.has(p.prop));
70
105
  }
71
106
  function pxParts(value) {
72
107
  const parts = value.trim().split(/\s+/);
73
- if (parts.length < 2 || parts.length > 3)
108
+ // 1–3 components: a single-value origin (`50px`) jitters the same way as the
109
+ // 2/3-component form and must be suppressed identically.
110
+ if (parts.length < 1 || parts.length > 3)
74
111
  return null;
75
112
  const values = parts.map((part) => {
76
113
  const match = /^(-?\d+(?:\.\d+)?)px$/.exec(part);
package/dist/index.d.ts CHANGED
@@ -23,5 +23,5 @@ export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, find
23
23
  export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
24
24
  export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
25
25
  export type { ReportOptions, ReportResult } from './report.js';
26
- export { affectedSurfaces, classifyStyleChange } from './affected-surfaces.js';
26
+ export { affectedSurfaces, classifyStyleChange, explainAffectedSurfaces } from './affected-surfaces.js';
27
27
  export type { ModuleEdge, AffectedSurfacesInput, AffectedSurfaces } from './affected-surfaces.js';
package/dist/index.js CHANGED
@@ -12,4 +12,4 @@ export { selectCrawlLinks, defaultLinkKey, crawlCoverageGaps, crawlCoverageError
12
12
  export { harvestStyleVariants } from './variant-crawler.js';
13
13
  export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
14
14
  export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
15
- export { affectedSurfaces, classifyStyleChange } from './affected-surfaces.js';
15
+ export { affectedSurfaces, classifyStyleChange, explainAffectedSurfaces } from './affected-surfaces.js';
package/dist/inventory.js CHANGED
@@ -50,7 +50,9 @@ export function collectNavAffordances() {
50
50
  };
51
51
  const nameOf = (el) => (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80);
52
52
  const internalPath = (el) => {
53
- const raw = (el.getAttribute('href') || '').trim();
53
+ // SVG anchors carry the target in `xlink:href` (legacy) or `href`; fall back so
54
+ // an <svg><a> nav link resolves like an HTML one.
55
+ const raw = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
54
56
  if (!raw || raw.startsWith('#'))
55
57
  return null;
56
58
  try {
@@ -67,14 +69,18 @@ export function collectNavAffordances() {
67
69
  // Erring broad is correct here: a stray non-nav button is harmless noise, but a
68
70
  // MISSED nav item defeats the guard. Prefer semantic markup (role=tablist) for
69
71
  // fully reliable harvesting; see docs/inventory-guard.md.
70
- const SEL = 'a[href], [role="tab"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], nav button, [role="navigation"] button, [role="tablist"] button, [class*="navtab" i] button, [class*="nav-tab" i] button, [class*="subnav" i] button, [class*="subtab" i] button, [class*="tabs" i] button';
72
+ // `a[*|href]` (any-namespace href) not `a[href]`: an SVG anchor may carry only the
73
+ // XLink-namespaced `xlink:href`, which `a[href]` never selects — so the xlink:href
74
+ // fallback in internalPath would be dead without it.
75
+ const SEL = 'a[*|href], [role="tab"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], nav button, [role="navigation"] button, [role="tablist"] button, [class*="navtab" i] button, [class*="nav-tab" i] button, [class*="subnav" i] button, [class*="subtab" i] button, [class*="tabs" i] button';
71
76
  return Array.from(document.querySelectorAll(SEL))
72
77
  .filter(visible)
73
78
  .map((el) => ({
74
79
  tag: el.tagName.toLowerCase(),
75
80
  role: (el.getAttribute('role') || '').toLowerCase(),
76
81
  name: nameOf(el),
77
- internalPath: el.tagName === 'A' ? internalPath(el) : null,
82
+ // SVG anchors report tagName `a` (lowercase), HTML reports `A` — match either.
83
+ internalPath: el.tagName.toLowerCase() === 'a' ? internalPath(el) : null,
78
84
  testId: el.getAttribute('data-testid'),
79
85
  domId: el.getAttribute('id'),
80
86
  controls: el.getAttribute('aria-controls'),
@@ -48,7 +48,11 @@ export interface CachedCaptureDirs {
48
48
  tmpRoot: string;
49
49
  }
50
50
  /** Record the real browser build into the capture dir. Called from a capture run, where a
51
- * Playwright browser handle is in scope. Best-effort: a missing version is simply not written. */
51
+ * Playwright browser handle is in scope. Write-or-CLEAR semantics: an undefined version
52
+ * REMOVES any existing sidecar rather than leaving it, so a reused capture dir (e.g. the
53
+ * default `.styleproof/maps/current`) can never carry a PRIOR run's build into this run's
54
+ * manifest — that would stamp a false browser-build fingerprint the compatibility guard
55
+ * then trusts. Best-effort: the delete is forced and ignores a missing file. */
52
56
  export declare function writeBrowserBuildSidecar(dir: string, browserVersion: string | undefined): void;
53
57
  export declare function expectedCompatibilityKey(options?: {
54
58
  cwd?: string;
package/dist/map-store.js CHANGED
@@ -80,12 +80,19 @@ function detectLockfile(cwd) {
80
80
  return {};
81
81
  }
82
82
  /** Record the real browser build into the capture dir. Called from a capture run, where a
83
- * Playwright browser handle is in scope. Best-effort: a missing version is simply not written. */
83
+ * Playwright browser handle is in scope. Write-or-CLEAR semantics: an undefined version
84
+ * REMOVES any existing sidecar rather than leaving it, so a reused capture dir (e.g. the
85
+ * default `.styleproof/maps/current`) can never carry a PRIOR run's build into this run's
86
+ * manifest — that would stamp a false browser-build fingerprint the compatibility guard
87
+ * then trusts. Best-effort: the delete is forced and ignores a missing file. */
84
88
  export function writeBrowserBuildSidecar(dir, browserVersion) {
85
- if (!browserVersion)
89
+ const sidecar = path.join(dir, BROWSER_BUILD_SIDECAR);
90
+ if (!browserVersion) {
91
+ fs.rmSync(sidecar, { force: true });
86
92
  return;
93
+ }
87
94
  fs.mkdirSync(dir, { recursive: true });
88
- fs.writeFileSync(path.join(dir, BROWSER_BUILD_SIDECAR), JSON.stringify({ browserVersion }, null, 2));
95
+ fs.writeFileSync(sidecar, JSON.stringify({ browserVersion }, null, 2));
89
96
  }
90
97
  function readBrowserBuildSidecar(dir) {
91
98
  try {
package/dist/report.js CHANGED
@@ -510,9 +510,26 @@ function regionHeading(regionPaths, findings) {
510
510
  const label = anchors.length > 1 ? `\`${head}\` + ${anchors.length - 1} more` : `\`${head}\``;
511
511
  return `${label} · ${groupTitle(findings)}`;
512
512
  }
513
+ // CSS values are author/attacker-influenced (content:"…", url("…"), font-family
514
+ // strings), so at the render boundary they get their OWN escaper — distinct from
515
+ // safeKey, which strips control chars from surface keys. Values must stay READABLE
516
+ // (a mangled url(…) is useless), so we ESCAPE rather than strip:
517
+ // • `|` → `\|` — an unescaped pipe splits the table row (GitHub honours the
518
+ // backslash even inside a code span).
519
+ // • backticks — a bare backtick would close the code span and leak live
520
+ // Markdown; widen the fence to one more backtick than the
521
+ // value's longest run, padding a space when it touches an edge
522
+ // (GitHub's rule for a code span that starts/ends with a tick).
523
+ function codeValue(v) {
524
+ const escaped = v.replace(/\|/g, '\\|');
525
+ const longestRun = Math.max(0, ...(escaped.match(/`+/g) ?? []).map((r) => r.length));
526
+ const fence = '`'.repeat(longestRun + 1);
527
+ const pad = /^`|`$/.test(escaped) ? ' ' : '';
528
+ return `${fence}${pad}${escaped}${pad}${fence}`;
529
+ }
513
530
  // A "no value here" marker renders as an em dash; colours render as `#hex` so the
514
531
  // table cell shows GitHub's live swatch.
515
- const cell = (v) => (isNonValue(v) ? '—' : `\`${toHex(v)}\``);
532
+ const cell = (v) => (isNonValue(v) ? '—' : codeValue(toHex(v)));
516
533
  // Long values (gradients, data URIs) would swamp the table, but truncating each
517
534
  // side independently can show two IDENTICAL cells for a real diff: both
518
535
  // sides of a gradient rendered as the same rgba while the actual change — a
@@ -545,7 +562,7 @@ function cellPair(before, after) {
545
562
  if (isNonValue(before) || isNonValue(after))
546
563
  return [cell(before), cell(after)];
547
564
  const [b, a] = excerptPair(before, after);
548
- return [`\`${toHex(b)}\``, `\`${toHex(a)}\``];
565
+ return [codeValue(toHex(b)), codeValue(toHex(a))];
549
566
  }
550
567
  function beforeAfterTable(rows) {
551
568
  return [
@@ -553,14 +570,14 @@ function beforeAfterTable(rows) {
553
570
  '| --- | --- | --- |',
554
571
  ...rows.map((r) => {
555
572
  const [b, a] = cellPair(r.before, r.after);
556
- return `| \`${r.prop}\` | ${b} | ${a} |`;
573
+ return `| ${codeValue(r.prop)} | ${b} | ${a} |`;
557
574
  }),
558
575
  ];
559
576
  }
560
577
  // A brand-new element has no meaningful "before", so its resting style renders
561
578
  // value-only (the After column), mirroring the added-element interaction-states table.
562
579
  function valueTable(rows) {
563
- return ['| Property | Value |', '| --- | --- |', ...rows.map((r) => `| \`${r.prop}\` | ${cell(r.after)} |`)];
580
+ return ['| Property | Value |', '| --- | --- |', ...rows.map((r) => `| ${codeValue(r.prop)} | ${cell(r.after)} |`)];
564
581
  }
565
582
  /** `Button (variant=primary, size=sm)` — the React component + sanitized props
566
583
  * the element captured (advisory; present only with captureComponent). */
@@ -587,8 +604,8 @@ function statesSection(states, added) {
587
604
  for (const c of summarizeProps(st.props)) {
588
605
  const [b, a] = cellPair(c.before, c.after);
589
606
  rows.push(added
590
- ? `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.after)} |`
591
- : `| \`:${st.state}\` | \`${c.prop}\` | ${b} → ${a} |`);
607
+ ? `| ${codeValue(`:${st.state}`)} | ${codeValue(c.prop)} | ${cell(c.after)} |`
608
+ : `| ${codeValue(`:${st.state}`)} | ${codeValue(c.prop)} | ${b} → ${a} |`);
592
609
  }
593
610
  if (!rows.length)
594
611
  return [];
package/dist/runner.d.ts CHANGED
@@ -185,6 +185,23 @@ type ExpandedSurface = Omit<Surface, 'variants' | 'liveStates'> & {
185
185
  metadata?: CaptureMetadata;
186
186
  };
187
187
  export declare function expandSurfaceVariants(surface: Surface): ExpandedSurface[];
188
+ /** The identity fields of an expanded surface a collision check needs. */
189
+ type ExpandedKeyed = {
190
+ key: string;
191
+ metadata?: CaptureMetadata;
192
+ };
193
+ /**
194
+ * Fail LOUDLY on two expanded surfaces sharing a capture key.
195
+ *
196
+ * The expanded key is `surface.key-variant.key`, and that key is the map filename
197
+ * (`<key>@<width>.json.gz`) and the report identity — so it's public and can't
198
+ * change without breaking backward compatibility. But the `-` join is ambiguous:
199
+ * surface `a` + variant `b-c` and surface `a-b` + variant `c` both expand to
200
+ * `a-b-c`, and the second capture would silently overwrite the first, dropping a
201
+ * surface with no error. Rather than mangle the public key format, we assert
202
+ * uniqueness up front and name BOTH origins so the author can rename one.
203
+ */
204
+ export declare function assertUniqueExpandedKeys(surfaces: ExpandedKeyed[]): void;
188
205
  /**
189
206
  * Let SSE (EventSource) requests bypass HAR record/replay and reach the live
190
207
  * server. A long-lived stream can't round-trip through a HAR entry: recording
package/dist/runner.js CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { captureStyleMap, saveStyleMap, trackInflightRequests, } from './capture.js';
5
5
  import { diffStyleMaps } from './diff.js';
6
- import { coverageGaps, COVERAGE_LEDGER } from './coverage.js';
6
+ import { coverageGaps, coverageKeys, translateExpected, COVERAGE_LEDGER, } from './coverage.js';
7
7
  import { writeBrowserBuildSidecar } from './map-store.js';
8
8
  import { detectViewportWidths } from './breakpoints.js';
9
9
  import { selectCrawlLinks, crawlCoverageError } from './crawl.js';
@@ -104,7 +104,7 @@ export function resolvePopupCaptureOptions(input) {
104
104
  };
105
105
  }
106
106
  async function popupDomSnapshot(page, options) {
107
- return page.evaluate(({ popupSelector, triggerSelector, attr, max = 0 }) => {
107
+ return page.evaluate(({ popupSelector, triggerSelector, attr, max = 0, relocatePath, relocateLabel }) => {
108
108
  const qsa = (sel) => {
109
109
  try {
110
110
  return [...document.querySelectorAll(sel)];
@@ -133,6 +133,13 @@ async function popupDomSnapshot(page, options) {
133
133
  }
134
134
  return parts.join(' > ');
135
135
  };
136
+ // The trigger's accessible name — mirrors crawl-surfaces' labelFor (aria-label
137
+ // || name || textContent || title). `path` is positional for an id-less trigger;
138
+ // this pins identity so a shifted same-tag sibling can't silently steal the bind.
139
+ const labelOf = (el) => (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || el.getAttribute('title') || '')
140
+ .replace(/\s+/g, ' ')
141
+ .trim()
142
+ .slice(0, 80) || el.tagName.toLowerCase();
136
143
  const popupKey = (el) => {
137
144
  const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120);
138
145
  return [
@@ -150,7 +157,15 @@ async function popupDomSnapshot(page, options) {
150
157
  const popups = qsa(popupSelector).filter(visible);
151
158
  const keys = popups.map(popupKey);
152
159
  if (!triggerSelector || !attr)
153
- return { keys, indexes: [] };
160
+ return { keys, candidates: [], found: false };
161
+ for (const el of qsa(`[${attr}]`))
162
+ el.removeAttribute(attr);
163
+ if (relocatePath) {
164
+ const target = qsa(triggerSelector).find((el) => pathOf(el) === relocatePath && labelOf(el) === relocateLabel);
165
+ if (target)
166
+ target.setAttribute(attr, 'target');
167
+ return { keys, candidates: [], found: Boolean(target) };
168
+ }
154
169
  const safeTrigger = (el) => {
155
170
  const tag = el.tagName.toLowerCase();
156
171
  if (el.matches(':disabled, [aria-disabled="true"]'))
@@ -161,24 +176,30 @@ async function popupDomSnapshot(page, options) {
161
176
  return el.getAttribute('href')?.startsWith('#') ?? false;
162
177
  return true;
163
178
  };
164
- for (const el of qsa(`[${attr}]`))
165
- el.removeAttribute(attr);
166
- const candidates = qsa(triggerSelector).filter((el) => visible(el) && !popups.some((popup) => popup !== el && popup.contains(el)) && safeTrigger(el));
167
- candidates.slice(0, max).forEach((el, index) => el.setAttribute(attr, String(index)));
168
- return { keys, indexes: candidates.slice(0, max).map((_, index) => index) };
179
+ const candidates = qsa(triggerSelector)
180
+ .filter((el) => visible(el) && !popups.some((popup) => popup !== el && popup.contains(el)) && safeTrigger(el))
181
+ .slice(0, max);
182
+ candidates.forEach((el, index) => el.setAttribute(attr, String(index)));
183
+ return {
184
+ keys,
185
+ candidates: candidates.map((el, index) => ({ index, path: pathOf(el), label: labelOf(el) })),
186
+ found: false,
187
+ };
169
188
  }, options);
170
189
  }
171
190
  async function visiblePopupKeys(page, selector) {
172
191
  return (await popupDomSnapshot(page, { popupSelector: selector })).keys;
173
192
  }
193
+ /** Enumerate + mark the surface's popup triggers ONCE, and record the pristine
194
+ * overlay keys in the same DOM snapshot — the reset baseline every reopen is
195
+ * verified against. */
174
196
  async function markPopupCandidates(page, options) {
175
- const snapshot = await popupDomSnapshot(page, {
197
+ return popupDomSnapshot(page, {
176
198
  triggerSelector: options.triggers,
177
199
  popupSelector: options.overlays,
178
200
  attr: POPUP_TRIGGER_ATTR,
179
201
  max: options.max,
180
202
  });
181
- return snapshot.indexes.map((index) => ({ index }));
182
203
  }
183
204
  function expandOne(surface, variant, variantKind) {
184
205
  return {
@@ -210,6 +231,36 @@ export function expandSurfaceVariants(surface) {
210
231
  return [baseSurface, ...expandedVariants];
211
232
  return [...expandedVariants, ...liveStates.map((state) => expandOne(surface, state, 'live-state'))];
212
233
  }
234
+ /** Human-readable origin of an expanded surface for a collision message. */
235
+ function expandedOrigin(s) {
236
+ const surfaceKey = s.metadata?.surfaceKey ?? s.key;
237
+ const variantKey = s.metadata?.variantKey;
238
+ return variantKey ? `surface '${surfaceKey}' variant '${variantKey}'` : `surface '${surfaceKey}'`;
239
+ }
240
+ /**
241
+ * Fail LOUDLY on two expanded surfaces sharing a capture key.
242
+ *
243
+ * The expanded key is `surface.key-variant.key`, and that key is the map filename
244
+ * (`<key>@<width>.json.gz`) and the report identity — so it's public and can't
245
+ * change without breaking backward compatibility. But the `-` join is ambiguous:
246
+ * surface `a` + variant `b-c` and surface `a-b` + variant `c` both expand to
247
+ * `a-b-c`, and the second capture would silently overwrite the first, dropping a
248
+ * surface with no error. Rather than mangle the public key format, we assert
249
+ * uniqueness up front and name BOTH origins so the author can rename one.
250
+ */
251
+ export function assertUniqueExpandedKeys(surfaces) {
252
+ const byKey = new Map();
253
+ for (const s of surfaces) {
254
+ const prior = byKey.get(s.key);
255
+ if (prior) {
256
+ throw new Error(`styleproof: capture key '${s.key}' is produced by two surfaces — ` +
257
+ `${expandedOrigin(prior)} collides with ${expandedOrigin(s)}. ` +
258
+ `Keys must expand uniquely (they name the map files and report entries); ` +
259
+ `rename one surface or variant.`);
260
+ }
261
+ byKey.set(s.key, s);
262
+ }
263
+ }
213
264
  /**
214
265
  * Let SSE (EventSource) requests bypass HAR record/replay and reach the live
215
266
  * server. A long-lived stream can't round-trip through a HAR entry: recording
@@ -278,16 +329,37 @@ async function assertDeterministic(page, surface, first, captureText, pending) {
278
329
  throw new Error(selfCheckErrorMessage(surface.key, drift, [...new Set([...(first.volatile ?? []), ...(again.volatile ?? [])])], liveCandidates));
279
330
  }
280
331
  }
281
- async function openPopupCandidate(page, surface, width, height, options, candidate) {
332
+ /**
333
+ * Reset the surface (Escape + `go()`), then open ONE candidate's popup.
334
+ *
335
+ * Two guarantees the naive open loop lacked:
336
+ * - the reset is VERIFIED against the pristine overlay keys, not assumed —
337
+ * Escape is not a universal close, and a non-navigating `go()` clears nothing;
338
+ * - the trigger is re-bound by the (path, label) identity recorded at first
339
+ * enumeration, never by its position in a fresh enumeration (the trigger set can
340
+ * shift between opens and silently key the popup under a different trigger; the
341
+ * label pins identity where the path alone is still positional for id-less triggers).
342
+ * Either check failing is reported for the caller to skip loudly.
343
+ */
344
+ async function openPopupCandidate(page, surface, width, height, options, candidate, pristine) {
282
345
  await page.setViewportSize({ width, height });
283
346
  await page.keyboard.press('Escape').catch(() => { });
284
347
  await surface.go(page);
285
- const before = new Set(await visiblePopupKeys(page, options.overlays));
286
- const candidates = await markPopupCandidates(page, options);
287
- if (!candidates.some((c) => c.index === candidate.index))
288
- return undefined;
348
+ const snapshot = await popupDomSnapshot(page, {
349
+ popupSelector: options.overlays,
350
+ triggerSelector: options.triggers,
351
+ attr: POPUP_TRIGGER_ATTR,
352
+ relocatePath: candidate.path,
353
+ relocateLabel: candidate.label,
354
+ });
355
+ const leaked = snapshot.keys.filter((key) => !pristine.has(key));
356
+ if (leaked.length)
357
+ return { status: 'leaked', leaked };
358
+ if (!snapshot.found)
359
+ return { status: 'missing' };
360
+ const before = new Set(snapshot.keys);
289
361
  await page
290
- .locator(`[${POPUP_TRIGGER_ATTR}="${candidate.index}"]`)
362
+ .locator(`[${POPUP_TRIGGER_ATTR}="target"]`)
291
363
  .first()
292
364
  .click({ timeout: Math.max(500, options.timeoutMs), noWaitAfter: true })
293
365
  .catch(() => undefined);
@@ -295,10 +367,19 @@ async function openPopupCandidate(page, surface, width, height, options, candida
295
367
  do {
296
368
  const opened = (await visiblePopupKeys(page, options.overlays)).find((key) => !before.has(key));
297
369
  if (opened)
298
- return opened;
370
+ return { status: 'opened', key: opened };
299
371
  await page.waitForTimeout(50);
300
372
  } while (Date.now() < deadline);
301
- return undefined;
373
+ return { status: 'none' };
374
+ }
375
+ /** A popup candidate skipped instead of captured wrong must be NAMED — a silent
376
+ * skip reads as "nothing to capture" when the truth is "couldn't capture safely". */
377
+ function warnPopupSkipped(surface, popupId, width, reason) {
378
+ // eslint-disable-next-line no-console
379
+ console.warn(`styleproof: skipped ${surface.key}-${popupId}@${width} — ${reason}`);
380
+ }
381
+ function leakedOverlaysDesc(leaked) {
382
+ return `overlay(s) the reset (Escape + go()) could not clear: ${leaked.join('; ')}`;
302
383
  }
303
384
  function popupMetadata(surface, popupId) {
304
385
  return {
@@ -316,26 +397,52 @@ async function captureOpenedPopupMap(page, surface, s, pending, popupId) {
316
397
  metadata: popupMetadata(surface, popupId),
317
398
  });
318
399
  }
319
- async function assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, first, s, pending) {
320
- const againKey = await openPopupCandidate(page, surface, width, height, options, candidate);
321
- if (!againKey)
322
- throw new Error(`styleproof self-check failed: ${surface.key}-${popupId} popup did not reopen`);
400
+ /** Reopen the popup and throw on drift/no-reopen. Returns the leaked overlay keys
401
+ * instead when the popup itself defeats the reset (e.g. it IS a toast Escape can't
402
+ * dismiss) — the reopen can't run, so the caller discards the capture loudly
403
+ * rather than saving a map whose determinism was never proven. */
404
+ async function assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, first, s, pending, pristine) {
405
+ const reopened = await openPopupCandidate(page, surface, width, height, options, candidate, pristine);
406
+ if (reopened.status === 'leaked')
407
+ return reopened.leaked;
408
+ if (reopened.status !== 'opened') {
409
+ throw new Error(`styleproof self-check failed: ${surface.key}-${popupId} popup did not reopen` +
410
+ (reopened.status === 'missing' ? ' (its trigger disappeared from the DOM or changed identity)' : ''));
411
+ }
323
412
  const again = await captureOpenedPopupMap(page, surface, s, pending, popupId);
324
413
  const drift = diffStyleMaps(first, again);
325
414
  if (drift.length) {
326
415
  throw new Error(selfCheckErrorMessage(`${surface.key}-${popupId}`, drift, first.volatile, first.liveCandidates));
327
416
  }
417
+ return undefined;
328
418
  }
329
- async function capturePopupCandidate(page, surface, width, height, s, options, candidate) {
419
+ async function capturePopupCandidate(page, surface, width, height, s, options, candidate, pristine) {
330
420
  const requests = trackInflightRequests(page);
421
+ const popupId = `popup-${String(candidate.index + 1).padStart(2, '0')}`;
331
422
  try {
332
- const popupKey = await openPopupCandidate(page, surface, width, height, options, candidate);
333
- if (!popupKey)
423
+ const opened = await openPopupCandidate(page, surface, width, height, options, candidate, pristine);
424
+ if (opened.status === 'none')
425
+ return;
426
+ if (opened.status === 'leaked') {
427
+ warnPopupSkipped(surface, popupId, width, `${leakedOverlaysDesc(opened.leaked)} — capturing now would include the previous ` +
428
+ `popup's residue. Dismiss it in the surface's go(), or capture it as an explicit variant.`);
429
+ return;
430
+ }
431
+ if (opened.status === 'missing') {
432
+ warnPopupSkipped(surface, popupId, width, `its originally-enumerated trigger is no longer identifiable after the reset (Escape + go()) — ` +
433
+ `gone from the DOM, or a shifted same-tag sibling no longer matches its recorded label; ` +
434
+ `skipping rather than re-binding to a different trigger.`);
334
435
  return;
335
- const popupId = `popup-${String(candidate.index + 1).padStart(2, '0')}`;
436
+ }
336
437
  const map = await captureOpenedPopupMap(page, surface, s, requests.pending, popupId);
337
- if (s.selfCheck)
338
- await assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, map, s, requests.pending);
438
+ if (s.selfCheck) {
439
+ const leaked = await assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, map, s, requests.pending, pristine);
440
+ if (leaked) {
441
+ warnPopupSkipped(surface, popupId, width, `reopening for the self-check found ${leakedOverlaysDesc(leaked)} — the popup itself ` +
442
+ `defeats the reset, so its determinism can't be verified and the capture is discarded.`);
443
+ return;
444
+ }
445
+ }
339
446
  const stem = path.join(s.baseDir, s.dir, `${surface.key}-${popupId}@${width}`);
340
447
  saveStyleMap(`${stem}.json.gz`, map);
341
448
  if (s.screenshots)
@@ -350,9 +457,12 @@ async function capturePopupSurfaces(page, surface, width, height, s) {
350
457
  if (!options.enabled || options.max === 0)
351
458
  return;
352
459
  await surface.go(page);
353
- const candidates = await markPopupCandidates(page, options);
460
+ const { keys, candidates } = await markPopupCandidates(page, options);
461
+ // Overlays legitimately visible in the surface's settled state (e.g. a permanent
462
+ // status region) — every reopen is verified back to this baseline before capture.
463
+ const pristine = new Set(keys);
354
464
  for (const candidate of candidates) {
355
- await capturePopupCandidate(page, surface, width, height, s, options, candidate);
465
+ await capturePopupCandidate(page, surface, width, height, s, options, candidate, pristine);
356
466
  }
357
467
  }
358
468
  /** Drive one surface at one width to a settled state and save its style map (+ screenshot).
@@ -459,7 +569,7 @@ function resolveSettings(c) {
459
569
  * `expected: null` records that the spec declared no registry, so a green can only
460
570
  * certify the captured surfaces. Runs on a capture run (dir set) only.
461
571
  */
462
- function writeCoverageLedgerTest(settings, dir, expected, exclude) {
572
+ function writeCoverageLedgerTest(settings, dir, expected, exclude, captureSurfaces) {
463
573
  test('styleproof coverage ledger', () => {
464
574
  const outDir = path.join(settings.baseDir, dir);
465
575
  fs.mkdirSync(outDir, { recursive: true });
@@ -470,7 +580,11 @@ function writeCoverageLedgerTest(settings, dir, expected, exclude) {
470
580
  : settings.replayFrom
471
581
  ? 'replayed'
472
582
  : 'unproven';
473
- const ledger = { version: 1, expected, exclude, determinism };
583
+ // Pre-translate the declared universe into the keys actually captured to disk, so
584
+ // the GATE (which reads expanded map filenames and can't see `surfaceKey` metadata)
585
+ // compares literally — a liveStates surface's `-loading`/`-loaded` splits satisfy it.
586
+ const ledgerExpected = expected == null ? null : translateExpected(expected, captureSurfaces);
587
+ const ledger = { version: 1, expected: ledgerExpected, exclude, determinism };
474
588
  fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
475
589
  });
476
590
  }
@@ -489,6 +603,7 @@ export function defineStyleMapCapture(options) {
489
603
  const { surfaces, expected, exclude = {}, dir } = options;
490
604
  const settings = resolveSettings(options);
491
605
  const captureSurfaces = surfaces.flatMap(expandSurfaceVariants);
606
+ assertUniqueExpandedKeys(captureSurfaces);
492
607
  // Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
493
608
  // a route added without a surface fails the app's own tests — long before, and
494
609
  // independent of, a capture run. This is the one gap captures can't catch: a
@@ -497,7 +612,10 @@ export function defineStyleMapCapture(options) {
497
612
  if (expected) {
498
613
  test.describe('styleproof coverage', () => {
499
614
  test('every expected surface is captured or explicitly excluded', () => {
500
- const { uncovered, staleExclusions } = coverageGaps(captureSurfaces.map((s) => s.key), expected, exclude);
615
+ const { uncovered, staleExclusions } = coverageGaps(
616
+ // A liveStates surface is captured only as its `-loading`/`-loaded` splits;
617
+ // map each back to the declared base key so the split satisfies `expected`.
618
+ coverageKeys(captureSurfaces), expected, exclude);
501
619
  expect(uncovered, `StyleProof coverage gap: ${uncovered.length} expected surface(s) are neither captured ` +
502
620
  `nor excluded — add each to \`surfaces\`, or to \`exclude\` with a reason. ` +
503
621
  `Missing: ${uncovered.join(', ')}`).toEqual([]);
@@ -509,7 +627,7 @@ export function defineStyleMapCapture(options) {
509
627
  test.describe('styleproof capture', () => {
510
628
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
511
629
  if (dir)
512
- writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
630
+ writeCoverageLedgerTest(settings, dir, expected ?? null, exclude, captureSurfaces);
513
631
  if (dir)
514
632
  writeBrowserBuildTest(settings, dir);
515
633
  for (const surface of captureSurfaces) {
@@ -623,8 +741,13 @@ export function defineCrawlCapture(options) {
623
741
  // captures what the nav links to, and can't prove that's every route). With
624
742
  // `expected` the crawl reconciles the DISCOVERED link set against it below and the
625
743
  // ledger travels with the declared universe.
744
+ // The crawl applies the SAME variants/liveStates to every discovered link, so the
745
+ // expansion of a declared key is knowable up front (before discovery): expand each
746
+ // `expected` key with this crawl's variants/liveStates to get the keys captured to
747
+ // disk, so a liveStates crawl's ledger is pre-translated like the spec-driven one.
748
+ const ledgerSurfaces = (expected ?? []).flatMap((key) => expandSurfaceVariants({ key, go: async () => { }, variants, liveStates }));
626
749
  if (dir)
627
- writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
750
+ writeCoverageLedgerTest(settings, dir, expected ?? null, exclude, ledgerSurfaces);
628
751
  if (dir)
629
752
  writeBrowserBuildTest(settings, dir);
630
753
  test('discover surfaces by crawling links, then capture each', async ({ page }) => {
@@ -654,6 +777,7 @@ export function defineCrawlCapture(options) {
654
777
  liveStates,
655
778
  popups,
656
779
  }));
780
+ assertUniqueExpandedKeys(captureSurfaces);
657
781
  // Budget the whole sweep up front: one test captures every surface, and
658
782
  // captureSurface no longer sets its own timeout, so size it to the work found.
659
783
  // With auto-width the band count isn't known until each surface renders, so
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { captureStyleMap } from './capture.js';
3
3
  import { diffStyleMaps } from './diff.js';
4
+ import { DANGER_SOURCE } from './danger.js';
4
5
  function slug(value) {
5
6
  return (value
6
7
  .toLowerCase()
@@ -39,8 +40,11 @@ function liveKey(candidate) {
39
40
  function liveSelector(candidate) {
40
41
  return candidate.path;
41
42
  }
43
+ // `dangerSource` is the shared destructive-label pattern (see {@link DANGER_SOURCE}),
44
+ // passed in because this function is serialized into the browser and can't close over
45
+ // a Node `RegExp`.
42
46
  // fallow-ignore-next-line complexity
43
- function collectCandidates() {
47
+ function collectCandidates(dangerSource) {
44
48
  const controls = [
45
49
  '[aria-expanded]',
46
50
  '[aria-haspopup]',
@@ -53,7 +57,7 @@ function collectCandidates() {
53
57
  'select',
54
58
  'form',
55
59
  ].join(',');
56
- const dangerous = /\b(delete|remove|destroy|logout|log out|sign out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect)\b/i;
60
+ const dangerous = new RegExp(dangerSource, 'i');
57
61
  const esc = (value) => CSS.escape(value);
58
62
  const quote = (value) => JSON.stringify(value);
59
63
  const visible = (el) => {
@@ -93,7 +97,15 @@ function collectCandidates() {
93
97
  return pathSelector(el);
94
98
  };
95
99
  const labelFor = (el) => {
96
- const own = (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || '').trim();
100
+ // Include `title` so an icon-only control (no text, no aria-label) announcing
101
+ // itself via a native tooltip — `<button title="Delete">🗑</button>` — still
102
+ // yields a real label. Without it the label is "button", slipping past the
103
+ // destructive guard below that this harvester's clicks must respect.
104
+ const own = (el.getAttribute('aria-label') ||
105
+ el.getAttribute('name') ||
106
+ el.textContent ||
107
+ el.getAttribute('title') ||
108
+ '').trim();
97
109
  return own.replace(/\s+/g, ' ').slice(0, 80) || el.tagName.toLowerCase();
98
110
  };
99
111
  const reasonFor = (el) => {
@@ -144,7 +156,7 @@ function collectCandidates() {
144
156
  return out;
145
157
  }
146
158
  async function discoverCandidates(page) {
147
- return page.evaluate(collectCandidates);
159
+ return page.evaluate(collectCandidates, DANGER_SOURCE);
148
160
  }
149
161
  async function perform(page, candidate) {
150
162
  const target = page.locator(candidate.selector).first();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.18.0",
3
+ "version": "3.20.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",
@@ -90,6 +90,6 @@
90
90
  "typescript": "^6.0.3",
91
91
  "typescript-eslint": "^8.18.0",
92
92
  "husky": "^9.1.7",
93
- "fallow": "^2.96.0"
93
+ "fallow": "^3.2.0"
94
94
  }
95
95
  }