styleproof 3.18.0 → 3.19.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/coverage.js CHANGED
@@ -34,6 +34,60 @@ export function coverageGaps(capturedKeys, expected, exclude = {}) {
34
34
  const staleExclusions = Object.keys(exclude).filter((k) => !expectedSet.has(k));
35
35
  return { uncovered, staleExclusions };
36
36
  }
37
+ /**
38
+ * Translate captured surface keys into the DECLARED keys they satisfy for coverage.
39
+ *
40
+ * `expected` is stated in base surface keys (`home`), but a surface with `liveStates`
41
+ * is captured ONLY as its split expansions (`home-loading`, `home-loaded`) — the bare
42
+ * base key is dropped by design (the base live state is fuzzy). Comparing the expanded
43
+ * keys literally against `expected` would report the declared `home` as uncovered on a
44
+ * fully-captured app. Each expansion still carries its originating `surfaceKey`, so the
45
+ * declared base key is exactly recoverable: a capture satisfies its own key AND its
46
+ * `surfaceKey`. This is precise (it maps only real expansions back to their real base),
47
+ * not a suffix heuristic — an unrelated `home-banner` never satisfies an uncaptured `home`.
48
+ */
49
+ export function coverageKeys(captured) {
50
+ const keys = new Set();
51
+ for (const c of captured) {
52
+ keys.add(c.key);
53
+ if (c.metadata?.surfaceKey)
54
+ keys.add(c.metadata.surfaceKey);
55
+ }
56
+ return [...keys];
57
+ }
58
+ /**
59
+ * Rewrite a declared `expected` universe (base keys) into the keys that are actually
60
+ * captured to disk, so the GATE — which reads expanded map filenames (`home-loading`)
61
+ * and can't see each capture's `surfaceKey` metadata — can compare literally.
62
+ *
63
+ * A declared key `K` is replaced by its captured liveState expansions when `K` is NOT
64
+ * itself a captured key but expansions carrying `surfaceKey === K` exist. A directly
65
+ * captured `K` is kept; a genuinely uncaptured `K` is kept verbatim so the gate still
66
+ * flags it. This is the write-time half of {@link coverageKeys}: the ledger travels
67
+ * pre-translated, so `auditCoverage` needs no metadata at gate time.
68
+ */
69
+ export function translateExpected(expected, captured) {
70
+ const capturedKeys = new Set();
71
+ const expansionsByBase = new Map();
72
+ for (const c of captured) {
73
+ capturedKeys.add(c.key);
74
+ const base = c.metadata?.surfaceKey;
75
+ if (base && base !== c.key) {
76
+ const list = expansionsByBase.get(base) ?? [];
77
+ list.push(c.key);
78
+ expansionsByBase.set(base, list);
79
+ }
80
+ }
81
+ const out = new Set();
82
+ for (const k of expected) {
83
+ if (capturedKeys.has(k) || !expansionsByBase.has(k))
84
+ out.add(k);
85
+ else
86
+ for (const exp of expansionsByBase.get(k))
87
+ out.add(exp);
88
+ }
89
+ return [...out];
90
+ }
37
91
  // ── coverage provenance (the gate-level completeness assertion) ──────────────────
38
92
  // The guard above runs in the app's SUITE. That fails a build when the spec forgets a
39
93
  // route, but the GATE (styleproof-diff, reading captured maps) never learns whether
@@ -3,6 +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 { detectViewportWidths } from './breakpoints.js';
6
+ import { DANGER_SOURCE } from './danger.js';
6
7
  // Exhaustive by default — these ceilings are safety backstops, not budgets.
7
8
  export const CRAWL_DEFAULTS = {
8
9
  height: 900,
@@ -44,11 +45,14 @@ function deriveKey(steps, used) {
44
45
  used.add(key);
45
46
  return key;
46
47
  }
47
- /** Runs in the browser: every visible, enabled, non-navigating control worth trying. */
48
+ /** Runs in the browser: every visible, enabled, non-navigating control worth trying.
49
+ * `dangerSource` is the shared destructive-label pattern (see {@link DANGER_SOURCE}),
50
+ * passed in because this function is serialized into the browser and can't close over
51
+ * a Node `RegExp`. */
48
52
  /* c8 ignore start */ // fallow-ignore-next-line complexity
49
- function collectClickable() {
53
+ function collectClickable(dangerSource) {
50
54
  const SEMANTIC = 'button,summary,[role="button"],[role="tab"],[role="menuitem"],[role="combobox"],select,form';
51
- const DANGER = /\b(delete|remove|destroy|logout|log ?out|sign ?out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect|revoke|reset|wipe|drop|rotate|provision|seal|regenerate|renew)\b/i;
55
+ const DANGER = new RegExp(dangerSource, 'i');
52
56
  const esc = (v) => CSS.escape(v);
53
57
  const quote = (v) => JSON.stringify(v);
54
58
  const visible = (el) => {
@@ -81,7 +85,12 @@ function collectClickable() {
81
85
  }
82
86
  return pathSelector(el);
83
87
  };
84
- const labelFor = (el) => (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || '')
88
+ const labelFor = (el) =>
89
+ // `title` is included so an icon-only control (no text, no aria-label) that
90
+ // announces itself via a native tooltip — `<button title="Delete">🗑</button>` —
91
+ // still yields a meaningful label. Without it such a button labels as "button"
92
+ // and slips past the destructive guard below, which mapping must never click.
93
+ (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || el.getAttribute('title') || '')
85
94
  .replace(/\s+/g, ' ')
86
95
  .trim()
87
96
  .slice(0, 80) || el.tagName.toLowerCase();
@@ -684,7 +693,7 @@ function sweepWorkList(entry, all, opts, st, freshOnly = false, excludeIds = new
684
693
  * in place — reaching that surface via a forward click is reliable, so its deep
685
694
  * descendants are captured on the first visit instead of via a later reset. */
686
695
  async function sweepCandidatesHere(page, opts, entry, st, sink, freshOnly = false, excludeIds = new Set()) {
687
- const all = await page.evaluate(collectClickable).catch(() => []);
696
+ const all = await page.evaluate(collectClickable, DANGER_SOURCE).catch(() => []);
688
697
  const work = sweepWorkList(entry, all, opts, st, freshOnly, excludeIds);
689
698
  // Controls present HERE — passed to each child's in-place descent as its
690
699
  // exclude set, so the descent skips this surface's mode-switchers/chrome and
package/dist/crawl.d.ts CHANGED
@@ -49,6 +49,12 @@ export type SelectLinksOptions = {
49
49
  * multi-segment route (`/blog/post`) still keys as `blog-post`. Param names are
50
50
  * dropped (values carry the meaning); pass `key` to {@link selectCrawlLinks} when a
51
51
  * project needs a different scheme.
52
+ *
53
+ * Params are sorted by name before their values are joined, so the SAME logical
54
+ * route keys identically regardless of the order the nav happened to render its
55
+ * query string (`/?tab=a&x=b` and `/?x=b&tab=a` both → `a-b`). Without this the
56
+ * key flaps with render order and the coverage guard reports phantom
57
+ * nav-regressions / unowned routes for a route that never changed.
52
58
  */
53
59
  export declare function defaultLinkKey(url: URL): string;
54
60
  /**
@@ -56,8 +62,17 @@ export declare function defaultLinkKey(url: URL): string;
56
62
  *
57
63
  * Each href is classified by {@link toLink} (resolve against `base`, keep http(s)
58
64
  * same-origin, drop a bare in-page fragment of the crawl root, apply `match`); the
59
- * survivors are deduped by path+query. Order follows first appearance in `hrefs`, so
65
+ * survivors are deduped by path+query (trailing slash normalized `/about` and
66
+ * `/about/` are one surface, not two). Order follows first appearance in `hrefs`, so
60
67
  * the capture order is the nav's order — stable across runs.
68
+ *
69
+ * Keys are then disambiguated: two GENUINELY different surfaces whose derived keys
70
+ * collide (e.g. `/a?tab=x` and `/b?tab=x` both → `x` under {@link defaultLinkKey})
71
+ * would otherwise both write `<key>@<width>.json.gz` and the second would silently
72
+ * overwrite the first — a captured surface vanishing without a trace. Instead the
73
+ * second gets a `-2` suffix (mirroring the surface crawler's `deriveKey`), so both
74
+ * survive as distinct maps. Trailing-slash duplicates never reach here — they're
75
+ * already deduped to one surface above — so this only fires on real collisions.
61
76
  */
62
77
  export declare function selectCrawlLinks(hrefs: Iterable<string | null | undefined>, opts: SelectLinksOptions): CrawlLink[];
63
78
  /**
package/dist/crawl.js CHANGED
@@ -23,10 +23,19 @@
23
23
  * multi-segment route (`/blog/post`) still keys as `blog-post`. Param names are
24
24
  * dropped (values carry the meaning); pass `key` to {@link selectCrawlLinks} when a
25
25
  * project needs a different scheme.
26
+ *
27
+ * Params are sorted by name before their values are joined, so the SAME logical
28
+ * route keys identically regardless of the order the nav happened to render its
29
+ * query string (`/?tab=a&x=b` and `/?x=b&tab=a` both → `a-b`). Without this the
30
+ * key flaps with render order and the coverage guard reports phantom
31
+ * nav-regressions / unowned routes for a route that never changed.
26
32
  */
27
33
  export function defaultLinkKey(url) {
28
34
  const segs = url.pathname.split('/').filter(Boolean);
29
- const values = [...url.searchParams].map(([, v]) => v).filter(Boolean);
35
+ const values = [...url.searchParams]
36
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
37
+ .map(([, v]) => v)
38
+ .filter(Boolean);
30
39
  const slug = [...segs, ...values]
31
40
  .join('-')
32
41
  .replace(/[^a-zA-Z0-9]+/g, '-')
@@ -70,30 +79,69 @@ function toLink(href, base, keyFor, match) {
70
79
  url.hash = ''; // navigate the surface, not a scroll anchor within it
71
80
  return { key: keyFor(url), url: path };
72
81
  }
82
+ /**
83
+ * Dedup identity for a navigable path+query: a trailing slash on the path is not a
84
+ * distinct surface (`/about` and `/about/` render the same route), so it's stripped
85
+ * from the identity — but never from the root `/` itself, and never from the query.
86
+ * The navigable url the caller returns keeps its original form; only the SET
87
+ * membership test is normalized, so the first-seen href still wins.
88
+ */
89
+ function dedupIdentity(pathAndSearch) {
90
+ const q = pathAndSearch.indexOf('?');
91
+ const path = q === -1 ? pathAndSearch : pathAndSearch.slice(0, q);
92
+ const search = q === -1 ? '' : pathAndSearch.slice(q);
93
+ const normPath = path.length > 1 ? path.replace(/\/+$/, '') || '/' : path;
94
+ return normPath + search;
95
+ }
73
96
  /**
74
97
  * Turn a page's raw `<a href>` values into a deduped, keyed surface list.
75
98
  *
76
99
  * Each href is classified by {@link toLink} (resolve against `base`, keep http(s)
77
100
  * same-origin, drop a bare in-page fragment of the crawl root, apply `match`); the
78
- * survivors are deduped by path+query. Order follows first appearance in `hrefs`, so
101
+ * survivors are deduped by path+query (trailing slash normalized `/about` and
102
+ * `/about/` are one surface, not two). Order follows first appearance in `hrefs`, so
79
103
  * the capture order is the nav's order — stable across runs.
104
+ *
105
+ * Keys are then disambiguated: two GENUINELY different surfaces whose derived keys
106
+ * collide (e.g. `/a?tab=x` and `/b?tab=x` both → `x` under {@link defaultLinkKey})
107
+ * would otherwise both write `<key>@<width>.json.gz` and the second would silently
108
+ * overwrite the first — a captured surface vanishing without a trace. Instead the
109
+ * second gets a `-2` suffix (mirroring the surface crawler's `deriveKey`), so both
110
+ * survive as distinct maps. Trailing-slash duplicates never reach here — they're
111
+ * already deduped to one surface above — so this only fires on real collisions.
80
112
  */
81
113
  export function selectCrawlLinks(hrefs, opts) {
82
114
  const base = new URL(opts.base);
83
115
  const keyFor = opts.key ?? defaultLinkKey;
84
116
  const seen = new Set();
117
+ const usedKeys = new Set();
85
118
  const out = [];
119
+ // Disambiguate a key against those already emitted, mirroring deriveKey: first
120
+ // wins bare, the next collider gets `-2`, `-3`, … — deterministic in nav order.
121
+ const uniqueKey = (key) => {
122
+ let k = key;
123
+ for (let i = 2; usedKeys.has(k); i++)
124
+ k = `${key}-${i}`;
125
+ usedKeys.add(k);
126
+ return k;
127
+ };
128
+ const push = (link) => {
129
+ out.push({ key: uniqueKey(link.key), url: link.url });
130
+ };
86
131
  if (opts.includeSelf) {
87
132
  const selfUrl = base.pathname + base.search;
88
- seen.add(selfUrl);
89
- out.push({ key: keyFor(base), url: selfUrl });
133
+ seen.add(dedupIdentity(selfUrl));
134
+ push({ key: keyFor(base), url: selfUrl });
90
135
  }
91
136
  for (const href of hrefs) {
92
137
  const link = href ? toLink(href, base, keyFor, opts.match) : null;
93
- if (!link || seen.has(link.url))
138
+ if (!link)
139
+ continue;
140
+ const id = dedupIdentity(link.url);
141
+ if (seen.has(id))
94
142
  continue;
95
- seen.add(link.url);
96
- out.push(link);
143
+ seen.add(id);
144
+ push(link);
97
145
  }
98
146
  return out;
99
147
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The ONE destructive-action guard, shared by every surface-discovery crawler
3
+ * (the exhaustive surface crawler in `crawl-surfaces.ts` and the one-step variant
4
+ * harvester in `variant-crawler.ts`). Mapping must never mutate: a control whose
5
+ * label matches this pattern is recorded but never clicked.
6
+ *
7
+ * Kept as a plain string (not a `RegExp`) because both crawlers build their
8
+ * candidate list inside `page.evaluate` — the classifier function is serialized
9
+ * into the browser, so it cannot close over a `RegExp` from Node. The source is
10
+ * passed in as an argument and recompiled in the browser; this module is the
11
+ * single source of truth for what "destructive" means.
12
+ */
13
+ export declare const DANGER_SOURCE = "\\b(delete|remove|destroy|logout|log ?out|sign ?out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect|revoke|reset|wipe|drop|rotate|provision|seal|regenerate|renew)\\b";
package/dist/danger.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The ONE destructive-action guard, shared by every surface-discovery crawler
3
+ * (the exhaustive surface crawler in `crawl-surfaces.ts` and the one-step variant
4
+ * harvester in `variant-crawler.ts`). Mapping must never mutate: a control whose
5
+ * label matches this pattern is recorded but never clicked.
6
+ *
7
+ * Kept as a plain string (not a `RegExp`) because both crawlers build their
8
+ * candidate list inside `page.evaluate` — the classifier function is serialized
9
+ * into the browser, so it cannot close over a `RegExp` from Node. The source is
10
+ * passed in as an argument and recompiled in the browser; this module is the
11
+ * single source of truth for what "destructive" means.
12
+ */
13
+ export const DANGER_SOURCE = '\\b(delete|remove|destroy|logout|log ?out|sign ?out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect|revoke|reset|wipe|drop|rotate|provision|seal|regenerate|renew)\\b';
package/dist/diff.js CHANGED
@@ -70,7 +70,9 @@ function dropLayoutEquivalentMarginProps(props, a, b) {
70
70
  }
71
71
  function pxParts(value) {
72
72
  const parts = value.trim().split(/\s+/);
73
- if (parts.length < 2 || parts.length > 3)
73
+ // 1–3 components: a single-value origin (`50px`) jitters the same way as the
74
+ // 2/3-component form and must be suppressed identically.
75
+ if (parts.length < 1 || parts.length > 3)
74
76
  return null;
75
77
  const values = parts.map((part) => {
76
78
  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';
@@ -210,6 +210,36 @@ export function expandSurfaceVariants(surface) {
210
210
  return [baseSurface, ...expandedVariants];
211
211
  return [...expandedVariants, ...liveStates.map((state) => expandOne(surface, state, 'live-state'))];
212
212
  }
213
+ /** Human-readable origin of an expanded surface for a collision message. */
214
+ function expandedOrigin(s) {
215
+ const surfaceKey = s.metadata?.surfaceKey ?? s.key;
216
+ const variantKey = s.metadata?.variantKey;
217
+ return variantKey ? `surface '${surfaceKey}' variant '${variantKey}'` : `surface '${surfaceKey}'`;
218
+ }
219
+ /**
220
+ * Fail LOUDLY on two expanded surfaces sharing a capture key.
221
+ *
222
+ * The expanded key is `surface.key-variant.key`, and that key is the map filename
223
+ * (`<key>@<width>.json.gz`) and the report identity — so it's public and can't
224
+ * change without breaking backward compatibility. But the `-` join is ambiguous:
225
+ * surface `a` + variant `b-c` and surface `a-b` + variant `c` both expand to
226
+ * `a-b-c`, and the second capture would silently overwrite the first, dropping a
227
+ * surface with no error. Rather than mangle the public key format, we assert
228
+ * uniqueness up front and name BOTH origins so the author can rename one.
229
+ */
230
+ export function assertUniqueExpandedKeys(surfaces) {
231
+ const byKey = new Map();
232
+ for (const s of surfaces) {
233
+ const prior = byKey.get(s.key);
234
+ if (prior) {
235
+ throw new Error(`styleproof: capture key '${s.key}' is produced by two surfaces — ` +
236
+ `${expandedOrigin(prior)} collides with ${expandedOrigin(s)}. ` +
237
+ `Keys must expand uniquely (they name the map files and report entries); ` +
238
+ `rename one surface or variant.`);
239
+ }
240
+ byKey.set(s.key, s);
241
+ }
242
+ }
213
243
  /**
214
244
  * Let SSE (EventSource) requests bypass HAR record/replay and reach the live
215
245
  * server. A long-lived stream can't round-trip through a HAR entry: recording
@@ -459,7 +489,7 @@ function resolveSettings(c) {
459
489
  * `expected: null` records that the spec declared no registry, so a green can only
460
490
  * certify the captured surfaces. Runs on a capture run (dir set) only.
461
491
  */
462
- function writeCoverageLedgerTest(settings, dir, expected, exclude) {
492
+ function writeCoverageLedgerTest(settings, dir, expected, exclude, captureSurfaces) {
463
493
  test('styleproof coverage ledger', () => {
464
494
  const outDir = path.join(settings.baseDir, dir);
465
495
  fs.mkdirSync(outDir, { recursive: true });
@@ -470,7 +500,11 @@ function writeCoverageLedgerTest(settings, dir, expected, exclude) {
470
500
  : settings.replayFrom
471
501
  ? 'replayed'
472
502
  : 'unproven';
473
- const ledger = { version: 1, expected, exclude, determinism };
503
+ // Pre-translate the declared universe into the keys actually captured to disk, so
504
+ // the GATE (which reads expanded map filenames and can't see `surfaceKey` metadata)
505
+ // compares literally — a liveStates surface's `-loading`/`-loaded` splits satisfy it.
506
+ const ledgerExpected = expected == null ? null : translateExpected(expected, captureSurfaces);
507
+ const ledger = { version: 1, expected: ledgerExpected, exclude, determinism };
474
508
  fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
475
509
  });
476
510
  }
@@ -489,6 +523,7 @@ export function defineStyleMapCapture(options) {
489
523
  const { surfaces, expected, exclude = {}, dir } = options;
490
524
  const settings = resolveSettings(options);
491
525
  const captureSurfaces = surfaces.flatMap(expandSurfaceVariants);
526
+ assertUniqueExpandedKeys(captureSurfaces);
492
527
  // Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
493
528
  // a route added without a surface fails the app's own tests — long before, and
494
529
  // independent of, a capture run. This is the one gap captures can't catch: a
@@ -497,7 +532,10 @@ export function defineStyleMapCapture(options) {
497
532
  if (expected) {
498
533
  test.describe('styleproof coverage', () => {
499
534
  test('every expected surface is captured or explicitly excluded', () => {
500
- const { uncovered, staleExclusions } = coverageGaps(captureSurfaces.map((s) => s.key), expected, exclude);
535
+ const { uncovered, staleExclusions } = coverageGaps(
536
+ // A liveStates surface is captured only as its `-loading`/`-loaded` splits;
537
+ // map each back to the declared base key so the split satisfies `expected`.
538
+ coverageKeys(captureSurfaces), expected, exclude);
501
539
  expect(uncovered, `StyleProof coverage gap: ${uncovered.length} expected surface(s) are neither captured ` +
502
540
  `nor excluded — add each to \`surfaces\`, or to \`exclude\` with a reason. ` +
503
541
  `Missing: ${uncovered.join(', ')}`).toEqual([]);
@@ -509,7 +547,7 @@ export function defineStyleMapCapture(options) {
509
547
  test.describe('styleproof capture', () => {
510
548
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
511
549
  if (dir)
512
- writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
550
+ writeCoverageLedgerTest(settings, dir, expected ?? null, exclude, captureSurfaces);
513
551
  if (dir)
514
552
  writeBrowserBuildTest(settings, dir);
515
553
  for (const surface of captureSurfaces) {
@@ -623,8 +661,13 @@ export function defineCrawlCapture(options) {
623
661
  // captures what the nav links to, and can't prove that's every route). With
624
662
  // `expected` the crawl reconciles the DISCOVERED link set against it below and the
625
663
  // ledger travels with the declared universe.
664
+ // The crawl applies the SAME variants/liveStates to every discovered link, so the
665
+ // expansion of a declared key is knowable up front (before discovery): expand each
666
+ // `expected` key with this crawl's variants/liveStates to get the keys captured to
667
+ // disk, so a liveStates crawl's ledger is pre-translated like the spec-driven one.
668
+ const ledgerSurfaces = (expected ?? []).flatMap((key) => expandSurfaceVariants({ key, go: async () => { }, variants, liveStates }));
626
669
  if (dir)
627
- writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
670
+ writeCoverageLedgerTest(settings, dir, expected ?? null, exclude, ledgerSurfaces);
628
671
  if (dir)
629
672
  writeBrowserBuildTest(settings, dir);
630
673
  test('discover surfaces by crawling links, then capture each', async ({ page }) => {
@@ -654,6 +697,7 @@ export function defineCrawlCapture(options) {
654
697
  liveStates,
655
698
  popups,
656
699
  }));
700
+ assertUniqueExpandedKeys(captureSurfaces);
657
701
  // Budget the whole sweep up front: one test captures every surface, and
658
702
  // captureSurface no longer sets its own timeout, so size it to the work found.
659
703
  // 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.19.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",