styleproof 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.3.0] - 2026-06-23
11
+
12
+ ### Added
13
+
14
+ - **`defineCrawlCapture` — discover surfaces by crawling rendered links.**
15
+ `discoverNextRoutes` reads the filesystem, so it only sees one surface per
16
+ `app/**` page — blind to a single-route SPA whose views are query params
17
+ (`/?tab=overview`) or client-routed, which exist only in the rendered nav as its
18
+ links. `defineCrawlCapture({ from, match, widths, dir })` loads a root URL, reads
19
+ its same-origin `<a href>`s (filtered by `match`), and captures each as a surface
20
+ keyed from its URL (`/?tab=overview` → `overview`; override with `key`). The
21
+ surface set _is_ the nav, so there's no hand-maintained `surfaces` list to drift
22
+ out of sync. The app just has to render its nav as real links — a button-only nav
23
+ exposes nothing to crawl. Replay, self-check and clock-freeze behave exactly as
24
+ for explicit surfaces. Also exported: `selectCrawlLinks` / `defaultLinkKey` (the
25
+ pure link-selection helpers) and the `CrawlOptions` / `CrawlLink` / `LinkMatch`
26
+ types.
27
+
28
+ ## [2.2.0] - 2026-06-23
29
+
30
+ ### Added
31
+
32
+ - **Newly-added elements now report their full resting computed style**, not just
33
+ interaction-state deltas. Previously an added element surfaced only its
34
+ `:hover`/`:focus` changes (the diff short-circuited added elements before the
35
+ style loop); its background, padding, font, radius, etc. were captured but never
36
+ shown. The diff now emits the new element's full style as `(unset) → value`
37
+ findings and the report renders them value-only (no bogus "Before" column), in
38
+ both the PR report and the `styleproof-diff` CLI. The element already gated via
39
+ its `added` finding, so this enriches detail without changing what gates.
40
+ - **`captureComponent` (opt-in, default off): surface the React component + props**
41
+ behind each element. With it on, capture reads the React fiber in-page
42
+ (`__reactFiber$*`/`__reactProps$*` on React 17+, `__reactInternalInstance$*` on
43
+ ≤16) to record the component display name and a sanitized subset of its props
44
+ (primitives only; `children`/handlers/objects dropped) on `ElementEntry.component`.
45
+ The report names it — `React component: Button (variant=primary, size=sm)` —
46
+ instead of a bare `<button>`. **Advisory only**, exactly like the content layer:
47
+ never fed to the certification diff or its blocking counts, so captures stay
48
+ deterministic. Names are mangled in minified prod builds, so it's most useful
49
+ against dev/non-minified output; a no-op on non-React pages.
50
+
10
51
  ## [2.1.0] - 2026-06-23
11
52
 
12
53
  ### Added
package/README.md CHANGED
@@ -64,6 +64,21 @@ defineStyleMapCapture({
64
64
 
65
65
  `discoverNextRoutes(cwd?)` reads the filesystem only (route groups `(group)` and `@slots` stripped, `[param]`/`[...catchall]` flagged `dynamic`) — a heuristic, not a router; edit the generated spec for exotic routing. For any other framework, point `expected` at your own route registry as above.
66
66
 
67
+ **Single-route SPAs: crawl the nav instead.** Filesystem discovery can't see a surface that isn't a page — a tab SPA where every view is `/?tab=overview` on one `app/page.tsx`, or anything client-routed. There the surfaces exist only in the rendered nav, as its links. `defineCrawlCapture` discovers them at run time: it loads a root URL, reads its same-origin `<a href>`s, and captures each — so the surface set _is_ the nav, with no list to hand-maintain (and so none to drift).
68
+
69
+ ```ts
70
+ import { defineCrawlCapture } from 'styleproof';
71
+
72
+ defineCrawlCapture({
73
+ from: '/', // crawl the app root for links
74
+ match: /\?tab=/, // keep just the tab views (omit to take every same-origin link)
75
+ widths: [1440, 1024, 768],
76
+ dir: process.env.STYLEMAP_DIR,
77
+ });
78
+ ```
79
+
80
+ Each discovered link becomes a surface keyed by its URL (`/?tab=overview` → `overview`; pass `key` for a different scheme). The app only has to render its nav as real `<a href>` links — a button-only nav (`<button onClick>`) exposes nothing to crawl. Replay, self-check and clock-freeze behave exactly as for explicit surfaces; one Playwright test runs the whole sweep (the link set isn't known until the page renders).
81
+
67
82
  ## What a report looks like
68
83
 
69
84
  One change — the hero CTA recoloured cyan → amber — appears as a single section in the report: a side-by-side before/after cropped screenshot, a one-line summary, then the exact property change folded under a toggle.
@@ -228,6 +243,23 @@ The report then carries a separate **📝 Content changes (advisory)** section:
228
243
 
229
244
  Notes: only an element's _own_ text is recorded (so a parent and child never double-report the same string); text churn in a live region is auto-excluded by the same settle pass that guards styles; and the certification CLI (`styleproof-diff`) is deliberately left content-blind.
230
245
 
246
+ ## Optional: React component layer (advisory)
247
+
248
+ For a React app, knowing _which component_ rendered an element is often the fastest way to read a change. Off by default, opt in with `captureComponent`:
249
+
250
+ ```ts
251
+ // styleproof.spec.ts — record the React component + props behind each element
252
+ defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR, captureComponent: true });
253
+ ```
254
+
255
+ Capture reads the React fiber in-page (`__reactFiber$*`/`__reactProps$*` on React 17+, `__reactInternalInstance$*` on ≤16) and records the component display name plus a **sanitized** subset of its props (primitives only — `children`, handlers, and objects are dropped) on `ElementEntry.component`. The report then names the element — **`React component: Button (variant=primary, size=sm)`** — instead of showing a bare `<button>`.
256
+
257
+ Like the content layer it is **advisory**: never fed to the certification diff or the gate, so captures stay deterministic. Component names are mangled in minified production builds, so it's most useful against a dev / non-minified target; on a non-React page the fiber keys are absent and the field is simply omitted.
258
+
259
+ ## Newly-added elements show their full style
260
+
261
+ When a PR **adds** an element, StyleProof now reports its **full resting computed style** (background, padding, font, radius, …), value-only, in addition to any interaction-state deltas — previously an added element surfaced only its `:hover`/`:focus` changes. The new element already gates via its `added` finding; this only enriches what you see, in both the report and the `styleproof-diff` CLI.
262
+
231
263
  ## Reference
232
264
 
233
265
  **Action `BenSheridanEdwards/StyleProof@v2`** — key inputs:
package/dist/capture.d.ts CHANGED
@@ -42,6 +42,18 @@ export type ElementEntry = {
42
42
  * advisory, not a computed-style outcome. See README "Optional: content layer".
43
43
  */
44
44
  text?: string;
45
+ /**
46
+ * The React component that rendered this element, and a sanitized subset of its
47
+ * props — present only when capture ran with `captureComponent: true` (opt-in,
48
+ * off by default). Extracted in-page from the React fiber. ADVISORY: never fed
49
+ * to the certification diff or its counts (like {@link ElementEntry.text}); it
50
+ * only enriches the report so a reviewer sees `Button (variant=primary)` rather
51
+ * than a bare `<button>`. Best in dev/non-minified builds (prod mangles names).
52
+ */
53
+ component?: {
54
+ name: string;
55
+ props?: Record<string, string>;
56
+ };
45
57
  };
46
58
  export type StyleMap = {
47
59
  defaults: Record<string, Props>;
@@ -137,6 +149,19 @@ export type CaptureOptions = {
137
149
  * guards styles, since text now participates in change detection.
138
150
  */
139
151
  captureText?: boolean;
152
+ /**
153
+ * Opt-in React layer (default OFF). When true, each element records the React
154
+ * component that rendered it (display name) and a sanitized subset of its props
155
+ * on {@link ElementEntry.component}, read in-page from the React fiber
156
+ * (`__reactFiber$*`/`__reactProps$*` on React 17+, `__reactInternalInstance$*`
157
+ * on ≤16). Lets the report name `Button (variant=primary)` instead of a bare
158
+ * `<button>`. ADVISORY — like `captureText` it never feeds the certification
159
+ * diff or its blocking counts. Only primitive props (string/number/boolean) are
160
+ * kept (children/handlers/objects dropped); names are mangled in minified prod
161
+ * builds, so this is most useful against dev/non-minified output. No-op on
162
+ * non-React pages (fiber keys absent → field omitted).
163
+ */
164
+ captureComponent?: boolean;
140
165
  /**
141
166
  * Advanced/internal: a getter for the count of in-flight data requests, supplied by
142
167
  * a tracker ({@link trackInflightRequests}) armed BEFORE navigation so the page's own
package/dist/capture.js CHANGED
@@ -30,15 +30,12 @@ const FRAMEWORK_IGNORE = [
30
30
  export function isUnder(path, roots) {
31
31
  return roots.some((r) => path === r || path.startsWith(r + ' > '));
32
32
  }
33
- // Serialized into the browser by page.evaluate; cannot call module helpers.
34
- // Pre-existing, grandfathered in the health baseline; the content layer adds
35
- // one small captureText block, not new structure.
36
- // fallow-ignore-next-line complexity
37
- function capturePage({ ignore, motionOnly, captureText }) {
38
- const MOTION = /^(transition|animation)/;
39
- const PSEUDOS = ['::before', '::after', '::marker', '::placeholder'];
40
- const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
41
- const pathOf = (el) => {
33
+ // Defines the structural-path helper on `window` once so the two functions
34
+ // serialized into the page (capturePage, markInteractiveElements) share ONE
35
+ // implementation page.evaluate can't reference a module-scope helper, so the
36
+ // alternative is an identical copy in each. Injected at the top of captureStyleMap.
37
+ function injectPathOf() {
38
+ window.__spPathOf = (el) => {
42
39
  if (el === document.documentElement)
43
40
  return 'html';
44
41
  if (el === document.body)
@@ -54,6 +51,16 @@ function capturePage({ ignore, motionOnly, captureText }) {
54
51
  }
55
52
  return 'body > ' + parts.join(' > ');
56
53
  };
54
+ }
55
+ // Serialized into the browser by page.evaluate; cannot call module helpers.
56
+ // Pre-existing, grandfathered in the health baseline; the content layer adds
57
+ // one small captureText block, not new structure.
58
+ // fallow-ignore-next-line complexity
59
+ function capturePage({ ignore, motionOnly, captureText, captureComponent }) {
60
+ const MOTION = /^(transition|animation)/;
61
+ const PSEUDOS = ['::before', '::after', '::marker', '::placeholder'];
62
+ const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
63
+ const pathOf = window.__spPathOf;
57
64
  // Per-tag (and per-tag-per-pseudo) UA defaults from a stylesheet-free iframe,
58
65
  // used to prune the maps. A pseudo-element's UA defaults are NOT the host
59
66
  // element's defaults, so they are measured and cached separately under a
@@ -96,6 +103,53 @@ function capturePage({ ignore, motionOnly, captureText }) {
96
103
  }
97
104
  return o;
98
105
  };
106
+ // Display name from a fiber `type`: function/class, or a forwardRef/memo wrapper
107
+ // object; '' for host fibers (string type), which keeps the walk going upward.
108
+ const nameOfType = (t) => {
109
+ if (typeof t === 'function') {
110
+ const f = t;
111
+ return f.displayName || f.name || '';
112
+ }
113
+ if (t && typeof t === 'object') {
114
+ const w = t;
115
+ const inner = w.render || w.type;
116
+ return w.displayName || inner?.displayName || inner?.name || '';
117
+ }
118
+ return '';
119
+ };
120
+ // Keep only primitive props (drop children/className/style/handlers/objects),
121
+ // capped — advisory, so never anything that could be huge or non-serializable.
122
+ const sanitizeProps = (mp) => {
123
+ const props = {};
124
+ for (const k of Object.keys(mp)) {
125
+ if (k === 'children' || k === 'className' || k === 'style')
126
+ continue;
127
+ const ty = typeof mp[k];
128
+ if (ty === 'string' || ty === 'number' || ty === 'boolean')
129
+ props[k] = String(mp[k]).slice(0, 80);
130
+ }
131
+ return props;
132
+ };
133
+ const reactComponent = (el) => {
134
+ const fiberKey = Object.keys(el).find((k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'));
135
+ if (!fiberKey)
136
+ return undefined;
137
+ let fiber = el[fiberKey] ?? null;
138
+ for (let hops = 0; fiber && hops < 30; fiber = fiber.return, hops++) {
139
+ const name = nameOfType(fiber.type);
140
+ if (!name || name === 'Symbol(react.fragment)')
141
+ continue;
142
+ const out = { name };
143
+ const mp = fiber.memoizedProps;
144
+ if (mp && typeof mp === 'object') {
145
+ const props = sanitizeProps(mp);
146
+ if (Object.keys(props).length)
147
+ out.props = props;
148
+ }
149
+ return out;
150
+ }
151
+ return undefined;
152
+ };
99
153
  const elements = {};
100
154
  const all = [document.documentElement, document.body, ...document.querySelectorAll('body *')];
101
155
  // Surface untraversed shadow roots and same-origin iframes so a refactor
@@ -147,6 +201,16 @@ function capturePage({ ignore, motionOnly, captureText }) {
147
201
  if (t)
148
202
  entry.text = t;
149
203
  }
204
+ if (captureComponent) {
205
+ try {
206
+ const comp = reactComponent(el);
207
+ if (comp)
208
+ entry.component = comp;
209
+ }
210
+ catch {
211
+ // non-React node or inaccessible fiber — component stays absent
212
+ }
213
+ }
150
214
  }
151
215
  for (const ps of PSEUDOS) {
152
216
  if (ps === '::marker' && getComputedStyle(el).display !== 'list-item')
@@ -233,22 +297,7 @@ const STATE_ID_ATTR = 'data-styleproof-state-id';
233
297
  * but different ordering, which produces phantom forced-state diffs.
234
298
  */
235
299
  function markInteractiveElements({ selector, skipSel, attr }) {
236
- const pathOf = (el) => {
237
- if (el === document.documentElement)
238
- return 'html';
239
- if (el === document.body)
240
- return 'body';
241
- const parts = [];
242
- let n = el;
243
- while (n && n !== document.body) {
244
- const parent = n.parentElement;
245
- if (!parent)
246
- break;
247
- parts.unshift(`${n.tagName.toLowerCase()}:nth-child(${Array.prototype.indexOf.call(parent.children, n) + 1})`);
248
- n = parent;
249
- }
250
- return 'body > ' + parts.join(' > ');
251
- };
300
+ const pathOf = window.__spPathOf;
252
301
  let i = 0;
253
302
  return [...document.querySelectorAll(selector)].flatMap((el) => {
254
303
  if (skipSel && el.matches(skipSel))
@@ -497,6 +546,7 @@ export async function captureStyleMap(page, options = {}) {
497
546
  const maxInteractive = options.maxInteractive ?? 800;
498
547
  const stabilize = options.stabilize ?? true;
499
548
  const captureText = options.captureText ?? false;
549
+ const captureComponent = options.captureComponent ?? false;
500
550
  // Neutralise real focus the same way FREEZE_CSS neutralises motion: blur
501
551
  // whatever element holds focus so every read below is the no-interaction
502
552
  // resting state. Real :focus is nondeterministic across runs (autofocus, late
@@ -507,6 +557,11 @@ export async function captureStyleMap(page, options = {}) {
507
557
  // deterministic" failure). Interaction states are certified deterministically
508
558
  // via CDP forcePseudoState below, never via whatever happened to be focused.
509
559
  await page.evaluate(() => document.activeElement?.blur?.());
560
+ // Inject the structural-path helper once so both capturePage and
561
+ // markInteractiveElements (each serialized into the page by page.evaluate, which
562
+ // can't share a module-scope function) call ONE definition — no duplicated source.
563
+ // It persists on `window` for every evaluate below (no navigation between them).
564
+ await page.evaluate(injectPathOf);
510
565
  // Motion longhands first (FREEZE_CSS would null them), then everything else.
511
566
  // Motion never carries text — it's a settled end state of declared motion.
512
567
  const motion = await page.evaluate(capturePage, { ignore, motionOnly: true, captureText: false });
@@ -516,7 +571,7 @@ export async function captureStyleMap(page, options = {}) {
516
571
  // (a live stream/ticker) to exclude — animations are frozen above, so only
517
572
  // real content/layout churn lands here.
518
573
  const volatile = await detectVolatile(page, ignore, stabilize, captureText, options.pendingRequests);
519
- const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText });
574
+ const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText, captureComponent });
520
575
  dropVolatile(base.elements, volatile);
521
576
  warnUntraversed(base.shadowHosts, base.sameOriginFrames);
522
577
  mergeMotion(base.elements, motion.elements);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Link-crawl surface discovery, for apps whose surfaces aren't filesystem routes.
3
+ *
4
+ * {@link discoverNextRoutes} reads the filesystem, so it sees one route per
5
+ * `app/.../page.*` — perfect for multi-page apps, blind to a single-route SPA that
6
+ * expresses every view as a query param (`/?tab=overview`) or client-side push.
7
+ * Those surfaces only exist in the *rendered* DOM, as the nav's links. This module
8
+ * turns that rendered link set into a surface list: navigate the app's root, read
9
+ * its `<a href>`s, and capture each — no hand-maintained `surfaces` array to drift
10
+ * out of sync with the nav (the same drift the coverage guard exists to catch,
11
+ * removed at the source).
12
+ *
13
+ * The DOM read happens at run time inside a Playwright test (a browser is needed to
14
+ * see hydrated links), so this file holds only the PURE part — turning a list of
15
+ * raw href strings into deduped, keyed, navigable surfaces — which is unit-testable
16
+ * with no browser. {@link defineCrawlCapture} in `runner.ts` does the navigation and
17
+ * feeds the hrefs here.
18
+ */
19
+ /** A discovered surface: a filename-safe key and the same-origin path to navigate. */
20
+ export type CrawlLink = {
21
+ /** Capture file-name prefix, derived from the URL (see {@link defaultLinkKey}). */
22
+ key: string;
23
+ /** Root-relative path+query to navigate (`/?tab=overview`, `/about`). */
24
+ url: string;
25
+ };
26
+ /**
27
+ * Keep only links whose resolved URL matches: a substring tested against the full
28
+ * href, a RegExp tested against it, or a predicate over the parsed URL. Omit to keep
29
+ * every same-origin link.
30
+ */
31
+ export type LinkMatch = string | RegExp | ((url: URL) => boolean);
32
+ export type SelectLinksOptions = {
33
+ /** Absolute URL of the crawled page. Relative hrefs resolve against it and only
34
+ * same-origin links are kept (external nav, mailto:, tel:, javascript: dropped). */
35
+ base: string;
36
+ /** Narrow the kept links. Default: every same-origin link. */
37
+ match?: LinkMatch;
38
+ /** Derive the surface key from a link URL. Default: {@link defaultLinkKey}. */
39
+ key?: (url: URL) => string;
40
+ };
41
+ /**
42
+ * Filename-safe, readable key from a link URL. Joins the path segments and the
43
+ * query-param *values* (the discriminator for a tab SPA — `/?tab=overview` →
44
+ * `overview`), so the common single-route-with-`?tab=` case reads cleanly while a
45
+ * multi-segment route (`/blog/post`) still keys as `blog-post`. Param names are
46
+ * dropped (values carry the meaning); pass `key` to {@link selectCrawlLinks} when a
47
+ * project needs a different scheme.
48
+ */
49
+ export declare function defaultLinkKey(url: URL): string;
50
+ /**
51
+ * Turn a page's raw `<a href>` values into a deduped, keyed surface list.
52
+ *
53
+ * Each href is classified by {@link toLink} (resolve against `base`, keep http(s)
54
+ * same-origin, drop a bare in-page fragment of the crawl root, apply `match`); the
55
+ * survivors are deduped by path+query. Order follows first appearance in `hrefs`, so
56
+ * the capture order is the nav's order — stable across runs.
57
+ */
58
+ export declare function selectCrawlLinks(hrefs: Iterable<string | null | undefined>, opts: SelectLinksOptions): CrawlLink[];
package/dist/crawl.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Link-crawl surface discovery, for apps whose surfaces aren't filesystem routes.
3
+ *
4
+ * {@link discoverNextRoutes} reads the filesystem, so it sees one route per
5
+ * `app/.../page.*` — perfect for multi-page apps, blind to a single-route SPA that
6
+ * expresses every view as a query param (`/?tab=overview`) or client-side push.
7
+ * Those surfaces only exist in the *rendered* DOM, as the nav's links. This module
8
+ * turns that rendered link set into a surface list: navigate the app's root, read
9
+ * its `<a href>`s, and capture each — no hand-maintained `surfaces` array to drift
10
+ * out of sync with the nav (the same drift the coverage guard exists to catch,
11
+ * removed at the source).
12
+ *
13
+ * The DOM read happens at run time inside a Playwright test (a browser is needed to
14
+ * see hydrated links), so this file holds only the PURE part — turning a list of
15
+ * raw href strings into deduped, keyed, navigable surfaces — which is unit-testable
16
+ * with no browser. {@link defineCrawlCapture} in `runner.ts` does the navigation and
17
+ * feeds the hrefs here.
18
+ */
19
+ /**
20
+ * Filename-safe, readable key from a link URL. Joins the path segments and the
21
+ * query-param *values* (the discriminator for a tab SPA — `/?tab=overview` →
22
+ * `overview`), so the common single-route-with-`?tab=` case reads cleanly while a
23
+ * multi-segment route (`/blog/post`) still keys as `blog-post`. Param names are
24
+ * dropped (values carry the meaning); pass `key` to {@link selectCrawlLinks} when a
25
+ * project needs a different scheme.
26
+ */
27
+ export function defaultLinkKey(url) {
28
+ const segs = url.pathname.split('/').filter(Boolean);
29
+ const values = [...url.searchParams].map(([, v]) => v).filter(Boolean);
30
+ const slug = [...segs, ...values]
31
+ .join('-')
32
+ .replace(/[^a-zA-Z0-9]+/g, '-')
33
+ .replace(/^-+|-+$/g, '')
34
+ .toLowerCase();
35
+ return slug || 'index';
36
+ }
37
+ function matches(url, match) {
38
+ if (match === undefined)
39
+ return true;
40
+ if (typeof match === 'string')
41
+ return url.href.includes(match);
42
+ if (match instanceof RegExp)
43
+ return match.test(url.href);
44
+ return match(url);
45
+ }
46
+ /**
47
+ * Resolve one raw href to a navigable same-origin surface, or `null` to skip it
48
+ * (malformed, mailto:/tel:/javascript:, external, a bare fragment of the crawl root,
49
+ * or filtered out by `match`). Pure per-href classification — the loop in
50
+ * {@link selectCrawlLinks} only has to dedupe what this keeps.
51
+ */
52
+ function toLink(href, base, keyFor, match) {
53
+ let url;
54
+ try {
55
+ url = new URL(href, base);
56
+ }
57
+ catch {
58
+ return null; // malformed href — skip, never throw into a spec
59
+ }
60
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
61
+ return null; // mailto:/tel:/javascript:
62
+ if (url.origin !== base.origin)
63
+ return null; // external link
64
+ const path = url.pathname + url.search;
65
+ // A pure fragment of the crawl root isn't a new surface (it's the same page).
66
+ if (url.hash && path === base.pathname + base.search)
67
+ return null;
68
+ if (!matches(url, match))
69
+ return null;
70
+ url.hash = ''; // navigate the surface, not a scroll anchor within it
71
+ return { key: keyFor(url), url: path };
72
+ }
73
+ /**
74
+ * Turn a page's raw `<a href>` values into a deduped, keyed surface list.
75
+ *
76
+ * Each href is classified by {@link toLink} (resolve against `base`, keep http(s)
77
+ * 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
79
+ * the capture order is the nav's order — stable across runs.
80
+ */
81
+ export function selectCrawlLinks(hrefs, opts) {
82
+ const base = new URL(opts.base);
83
+ const keyFor = opts.key ?? defaultLinkKey;
84
+ const seen = new Set();
85
+ const out = [];
86
+ for (const href of hrefs) {
87
+ const link = href ? toLink(href, base, keyFor, opts.match) : null;
88
+ if (!link || seen.has(link.url))
89
+ continue;
90
+ seen.add(link.url);
91
+ out.push(link);
92
+ }
93
+ return out;
94
+ }
package/dist/diff.d.ts CHANGED
@@ -15,6 +15,10 @@ export type Finding = {
15
15
  cls: string;
16
16
  change: 'added' | 'removed' | 'retagged';
17
17
  detail?: string;
18
+ component?: {
19
+ name: string;
20
+ props?: Record<string, string>;
21
+ };
18
22
  } | {
19
23
  kind: 'style';
20
24
  path: string;
package/dist/diff.js CHANGED
@@ -35,11 +35,39 @@ export function diffStyleMaps(a, b) {
35
35
  const ea = a.elements[p];
36
36
  const eb = b.elements[p];
37
37
  if (!ea || !eb) {
38
- findings.push({ kind: 'dom', path: p, cls: (ea ?? eb).cls, change: !ea ? 'added' : 'removed' });
38
+ const present = (ea ?? eb);
39
+ findings.push({
40
+ kind: 'dom',
41
+ path: p,
42
+ cls: present.cls,
43
+ change: !ea ? 'added' : 'removed',
44
+ ...(!ea && eb.component ? { component: eb.component } : {}),
45
+ });
46
+ // An added element has no "before", so the style loop below is skipped —
47
+ // surface its full resting computed style (+ pseudos) as (unset)→value so a
48
+ // new element's styling is reviewable, not just its interaction-state
49
+ // deltas. Removed elements get none (there is no "after" to show).
50
+ if (!ea && eb) {
51
+ const defsB = b.defaults[eb.tag] ?? {};
52
+ for (const pseudo of [null, ...Object.keys(eb.pseudo ?? {})]) {
53
+ const propsB = pseudo ? (eb.pseudo?.[pseudo] ?? {}) : eb.style;
54
+ const pdefsB = pseudo ? (b.defaults[eb.tag + pseudo] ?? defsB) : defsB;
55
+ const props = diffProps({}, propsB, {}, pdefsB, '(unset)', '(unset)');
56
+ if (props.length)
57
+ findings.push({ kind: 'style', path: p, cls: eb.cls, pseudo, props });
58
+ }
59
+ }
39
60
  continue;
40
61
  }
41
62
  if (ea.tag !== eb.tag) {
42
- findings.push({ kind: 'dom', path: p, cls: ea.cls, change: 'retagged', detail: `<${ea.tag}> → <${eb.tag}>` });
63
+ findings.push({
64
+ kind: 'dom',
65
+ path: p,
66
+ cls: ea.cls,
67
+ change: 'retagged',
68
+ detail: `<${ea.tag}> → <${eb.tag}>`,
69
+ ...(eb.component ? { component: eb.component } : {}),
70
+ });
43
71
  continue;
44
72
  }
45
73
  const defsA = a.defaults[ea.tag] ?? {};
package/dist/index.d.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
2
  export type { StyleMap, CaptureOptions, ElementEntry, Rect } from './capture.js';
3
- export { defineStyleMapCapture } from './runner.js';
4
- export type { Surface, DefineOptions } from './runner.js';
3
+ export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
4
+ export type { Surface, DefineOptions, CrawlOptions } from './runner.js';
5
5
  export { coverageGaps } from './coverage.js';
6
6
  export type { CoverageGaps } from './coverage.js';
7
7
  export { discoverNextRoutes } from './routes.js';
8
8
  export type { DiscoveredRoute } from './routes.js';
9
+ export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
10
+ export type { CrawlLink, LinkMatch, SelectLinksOptions } from './crawl.js';
9
11
  export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
10
12
  export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
11
13
  export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
- export { defineStyleMapCapture } from './runner.js';
2
+ export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
3
3
  export { coverageGaps } from './coverage.js';
4
4
  export { discoverNextRoutes } from './routes.js';
5
+ export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
5
6
  export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
6
7
  export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
package/dist/report.js CHANGED
@@ -432,12 +432,51 @@ function beforeAfterTable(rows) {
432
432
  ...rows.map((r) => `| \`${r.prop}\` | ${cell(r.before)} | ${cell(r.after)} |`),
433
433
  ];
434
434
  }
435
+ // A brand-new element has no meaningful "before", so its resting style renders
436
+ // value-only (the After column), mirroring the added-element interaction-states table.
437
+ function valueTable(rows) {
438
+ return ['| Property | Value |', '| --- | --- |', ...rows.map((r) => `| \`${r.prop}\` | ${cell(r.after)} |`)];
439
+ }
440
+ /** `Button (variant=primary, size=sm)` — the React component + sanitized props
441
+ * the element captured (advisory; present only with captureComponent). */
442
+ function renderComponent(c) {
443
+ const entries = Object.entries(c.props ?? {});
444
+ const props = entries.length ? ` (${entries.map(([k, v]) => `${k}=${v}`).join(', ')})` : '';
445
+ return `\`${c.name}\`${props}`;
446
+ }
435
447
  /** One element's heading + body lines (no leading blank, no ×N suffix). */
448
+ // Base/pseudo style rows. Added elements render value-only (no meaningful before).
449
+ function styleSection(styles, added) {
450
+ const out = [];
451
+ for (const s of styles) {
452
+ const rows = summarizeProps(s.props);
453
+ if (rows.length)
454
+ out.push('', s.pseudo ? `On \`${s.pseudo}\`:` : 'Style:', '', ...(added ? valueTable(rows) : beforeAfterTable(rows)));
455
+ }
456
+ return out;
457
+ }
458
+ // Forced :hover/:focus/:active rows. Added: value-only; changed: before → after.
459
+ function statesSection(states, added) {
460
+ const rows = [];
461
+ for (const st of states)
462
+ for (const c of summarizeProps(st.props))
463
+ rows.push(added
464
+ ? `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.after)} |`
465
+ : `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.before)} → ${cell(c.after)} |`);
466
+ if (!rows.length)
467
+ return [];
468
+ return [
469
+ '',
470
+ added ? 'Interactive states:' : 'Interactive-state changes:',
471
+ '',
472
+ added ? '| State | Property | Value |' : '| State | Property | Before → After |',
473
+ '| --- | --- | --- |',
474
+ ...rows,
475
+ ];
476
+ }
436
477
  function renderOneElement(group) {
437
478
  const label = prettyLabel(group[0].path, group[0].cls);
438
479
  const dom = group.find((f) => f.kind === 'dom');
439
- const styles = group.filter((f) => f.kind === 'style');
440
- const states = group.filter((f) => f.kind === 'state');
441
480
  if (dom?.change === 'removed')
442
481
  return { head: `**Removed** \`${label}\``, body: [] };
443
482
  const added = dom?.change === 'added';
@@ -447,21 +486,12 @@ function renderOneElement(group) {
447
486
  ? `**Retagged** \`${label}\` ${dom.detail ?? ''}`
448
487
  : `**\`${label}\`**`;
449
488
  const body = [];
450
- for (const s of styles) {
451
- const rows = summarizeProps(s.props);
452
- if (rows.length)
453
- body.push('', s.pseudo ? `On \`${s.pseudo}\`:` : 'Style:', '', ...beforeAfterTable(rows));
454
- }
455
- if (states.length) {
456
- const rows = [];
457
- for (const st of states)
458
- for (const c of summarizeProps(st.props))
459
- rows.push(added
460
- ? `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.after)} |`
461
- : `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.before)} → ${cell(c.after)} |`);
462
- if (rows.length)
463
- body.push('', added ? 'Interactive states:' : 'Interactive-state changes:', '', added ? '| State | Property | Value |' : '| State | Property | Before → After |', '| --- | --- | --- |', ...rows);
464
- }
489
+ // React component that rendered the element (added/retagged carry it on the dom
490
+ // finding) surfaced first so a reviewer sees `Button (variant=primary)`.
491
+ if (dom?.component)
492
+ body.push('', `React component: ${renderComponent(dom.component)}`);
493
+ body.push(...styleSection(group.filter((f) => f.kind === 'style'), added));
494
+ body.push(...statesSection(group.filter((f) => f.kind === 'state'), added));
465
495
  // Existing element with nothing left to show (all derived) → skip; an
466
496
  // added/removed/retagged element always renders its heading.
467
497
  if (!dom && !body.length)
@@ -584,6 +614,10 @@ function cleanFindings(findings) {
584
614
  for (const group of groupByPath(findings)) {
585
615
  const base = group.find((f) => f.kind === 'style' && f.pseudo === null);
586
616
  const baseChanged = new Set(base?.props.map((p) => p.prop) ?? []);
617
+ // For an ADDED element the base style is a full (unset)→value snapshot, not a
618
+ // delta — so a forced-state value is never an "echo" of a base *change*; keep
619
+ // every state row (suppressing them would drop a real :hover/:focus value).
620
+ const isAdded = group.some((f) => f.kind === 'dom' && f.change === 'added');
587
621
  for (const f of group) {
588
622
  if (f.kind === 'dom') {
589
623
  out.push(f);
@@ -591,7 +625,9 @@ function cleanFindings(findings) {
591
625
  }
592
626
  const props = f.kind === 'style'
593
627
  ? f.props.filter((p) => !DERIVED_PROPS.has(p.prop))
594
- : f.props.filter((p) => !STATE_STRIP.has(p.prop) && !baseChanged.has(p.prop) && !(isNonValue(p.before) && isNonValue(p.after)));
628
+ : f.props.filter((p) => !STATE_STRIP.has(p.prop) &&
629
+ (isAdded || !baseChanged.has(p.prop)) &&
630
+ !(isNonValue(p.before) && isNonValue(p.after)));
595
631
  if (props.length)
596
632
  out.push({ ...f, props });
597
633
  }
package/dist/runner.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type LinkMatch } from './crawl.js';
1
2
  import type { Page } from '@playwright/test';
2
3
  /**
3
4
  * A surface is one deterministic page state worth certifying: a route plus
@@ -98,6 +99,12 @@ export type DefineOptions = {
98
99
  * `CaptureOptions.captureText` and the README's "Optional: content layer".
99
100
  */
100
101
  captureText?: boolean;
102
+ /**
103
+ * Opt-in React layer (default OFF). Record the component + sanitized props that
104
+ * rendered each element so the report can name `Button (variant=primary)`.
105
+ * Advisory only — never gates. See `CaptureOptions.captureComponent`.
106
+ */
107
+ captureComponent?: boolean;
101
108
  };
102
109
  /**
103
110
  * Let SSE (EventSource) requests bypass HAR record/replay and reach the live
@@ -122,6 +129,8 @@ export declare function passLiveStreams(page: Page, url: string): Promise<void>;
122
129
  * forces it on either way.
123
130
  */
124
131
  export declare function defaultSelfCheck(replayFrom: string | undefined, env?: string | undefined): boolean;
132
+ /** The capture settings every capturer shares (everything bar the surface set). */
133
+ type CaptureConfig = Omit<DefineOptions, 'surfaces' | 'expected' | 'exclude'>;
125
134
  /**
126
135
  * Generate one Playwright test per surface × width that captures the style
127
136
  * map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
@@ -135,4 +144,46 @@ export declare function defaultSelfCheck(replayFrom: string | undefined, env?: s
135
144
  * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
136
145
  * ```
137
146
  */
138
- export declare function defineStyleMapCapture({ surfaces, expected, exclude, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, captureText, }: DefineOptions): void;
147
+ export declare function defineStyleMapCapture(options: DefineOptions): void;
148
+ /** Options for {@link defineCrawlCapture}: where to crawl, how to filter/key the
149
+ * links, and the viewport sweep — plus the shared capture settings. */
150
+ export type CrawlOptions = CaptureConfig & {
151
+ /** URL to crawl for surface links (e.g. `/`). Its same-origin `<a href>`s become
152
+ * the surface set. */
153
+ from: string;
154
+ /** Narrow the discovered links — substring, RegExp, or predicate over the URL
155
+ * (e.g. `/\?tab=/` to capture only the tab views). Default: every same-origin link. */
156
+ match?: LinkMatch;
157
+ /** Derive a surface key from a link URL. Default: path+query slug (`/?tab=x` → `x`). */
158
+ key?: (url: URL) => string;
159
+ /** Viewport widths swept for every discovered surface — one per @media band. */
160
+ widths: number[];
161
+ /** Viewport height per width (default 800). */
162
+ height?: number | ((width: number) => number);
163
+ /** Selectors skipped on every surface (live regions, third-party embeds). */
164
+ ignore?: string[];
165
+ /** Max ms to wait for the crawl root's links to render before reading them
166
+ * (an SPA hydrates its nav client-side). Default 15000. */
167
+ linkTimeout?: number;
168
+ };
169
+ /**
170
+ * Like {@link defineStyleMapCapture}, but the surface set is DISCOVERED at run time
171
+ * by crawling a page's links instead of being hand-listed — for a single-route SPA
172
+ * whose views are `?tab=`/client-routed and so invisible to the filesystem
173
+ * {@link discoverNextRoutes}. It navigates `from`, reads its same-origin `<a href>`s
174
+ * (filtered by `match`), and captures each as a surface keyed by `key`. The app just
175
+ * has to render its nav as real links; nothing to hand-maintain, so the surface list
176
+ * can't drift from the nav.
177
+ *
178
+ * One Playwright test does the whole sweep (the link set isn't known until a browser
179
+ * has rendered the page, so per-surface tests can't be generated at collection time).
180
+ * Per-surface failures are aggregated — one bad surface reports without hiding the
181
+ * rest. Replay/self-check/clock-freeze behave exactly as for explicit surfaces.
182
+ *
183
+ * ```ts
184
+ * // styleproof.spec.ts — capture every tab the nav links to
185
+ * defineCrawlCapture({ from: '/', match: /\?tab=/, widths: [1440, 1024, 768], dir: process.env.STYLEMAP_DIR });
186
+ * ```
187
+ */
188
+ export declare function defineCrawlCapture(options: CrawlOptions): void;
189
+ export {};
package/dist/runner.js CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
5
5
  import { diffStyleMaps } from './diff.js';
6
6
  import { coverageGaps } from './coverage.js';
7
+ import { selectCrawlLinks } from './crawl.js';
7
8
  /** One-line description of the first drift finding, for the self-check error. */
8
9
  function driftDesc(f) {
9
10
  if (f.kind === 'dom')
@@ -81,9 +82,10 @@ async function assertDeterministic(page, surface, first, captureText, pending) {
81
82
  `First: ${driftDesc(drift[0])}`);
82
83
  }
83
84
  }
84
- /** Drive one surface at one width to a settled state and save its style map (+ screenshot). */
85
+ /** Drive one surface at one width to a settled state and save its style map (+ screenshot).
86
+ * The caller owns the test timeout (one-per-test for explicit surfaces, one budget for
87
+ * the whole crawl) so a multi-surface crawl can't reset its own deadline mid-loop. */
85
88
  async function captureSurface(page, surface, width, s) {
86
- test.setTimeout(180_000);
87
89
  await pinInputs(page, `${surface.key}@${width}.har`, s);
88
90
  const height = typeof surface.height === 'function' ? surface.height(width) : (surface.height ?? 800);
89
91
  await page.setViewportSize({ width, height });
@@ -96,6 +98,7 @@ async function captureSurface(page, surface, width, s) {
96
98
  const map = await captureStyleMap(page, {
97
99
  ignore: surface.ignore ?? [],
98
100
  captureText: s.captureText,
101
+ captureComponent: s.captureComponent,
99
102
  pendingRequests: requests.pending,
100
103
  });
101
104
  if (s.selfCheck)
@@ -122,6 +125,27 @@ async function captureSurface(page, surface, width, s) {
122
125
  export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCHECK) {
123
126
  return env === '1' || !replayFrom;
124
127
  }
128
+ /**
129
+ * Apply the capture defaults once, so explicit-surface and crawl capture can't
130
+ * drift — the replay boundary, frozen clock and self-check policy resolve to the
131
+ * same thing whichever entry point you use. Env fallbacks (`STYLEPROOF_REPLAY_*`)
132
+ * live here too, so a single spec line keeps the documented behaviour.
133
+ */
134
+ function resolveSettings(c) {
135
+ const replayFrom = c.replayFrom ?? process.env.STYLEPROOF_REPLAY_FROM;
136
+ return {
137
+ dir: c.dir,
138
+ baseDir: c.baseDir ?? '__stylemaps__',
139
+ screenshots: c.screenshots ?? true,
140
+ replayFrom,
141
+ replayUrl: c.replayUrl ?? process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**',
142
+ freezeClock: c.freezeClock ?? true,
143
+ clockTime: c.clockTime ?? '2025-01-01T00:00:00Z',
144
+ selfCheck: c.selfCheck ?? defaultSelfCheck(replayFrom),
145
+ captureText: c.captureText ?? false,
146
+ captureComponent: c.captureComponent ?? false,
147
+ };
148
+ }
125
149
  /**
126
150
  * Generate one Playwright test per surface × width that captures the style
127
151
  * map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
@@ -135,18 +159,9 @@ export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCH
135
159
  * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
136
160
  * ```
137
161
  */
138
- export function defineStyleMapCapture({ surfaces, expected, exclude = {}, dir, baseDir = '__stylemaps__', screenshots = true, replayFrom = process.env.STYLEPROOF_REPLAY_FROM, replayUrl = process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**', freezeClock = true, clockTime = '2025-01-01T00:00:00Z', selfCheck = defaultSelfCheck(replayFrom), captureText = false, }) {
139
- const settings = {
140
- dir: dir,
141
- baseDir,
142
- screenshots,
143
- replayFrom,
144
- replayUrl,
145
- freezeClock,
146
- clockTime,
147
- selfCheck,
148
- captureText,
149
- };
162
+ export function defineStyleMapCapture(options) {
163
+ const { surfaces, expected, exclude = {}, dir } = options;
164
+ const settings = resolveSettings(options);
150
165
  // Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
151
166
  // a route added without a surface fails the app's own tests — long before, and
152
167
  // independent of, a capture run. This is the one gap captures can't catch: a
@@ -168,8 +183,77 @@ export function defineStyleMapCapture({ surfaces, expected, exclude = {}, dir, b
168
183
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
169
184
  for (const surface of surfaces) {
170
185
  for (const width of surface.widths) {
171
- test(`${surface.key} @ ${width}`, ({ page }) => captureSurface(page, surface, width, settings));
186
+ test(`${surface.key} @ ${width}`, ({ page }) => {
187
+ test.setTimeout(180_000);
188
+ return captureSurface(page, surface, width, settings);
189
+ });
172
190
  }
173
191
  }
174
192
  });
175
193
  }
194
+ /**
195
+ * Like {@link defineStyleMapCapture}, but the surface set is DISCOVERED at run time
196
+ * by crawling a page's links instead of being hand-listed — for a single-route SPA
197
+ * whose views are `?tab=`/client-routed and so invisible to the filesystem
198
+ * {@link discoverNextRoutes}. It navigates `from`, reads its same-origin `<a href>`s
199
+ * (filtered by `match`), and captures each as a surface keyed by `key`. The app just
200
+ * has to render its nav as real links; nothing to hand-maintain, so the surface list
201
+ * can't drift from the nav.
202
+ *
203
+ * One Playwright test does the whole sweep (the link set isn't known until a browser
204
+ * has rendered the page, so per-surface tests can't be generated at collection time).
205
+ * Per-surface failures are aggregated — one bad surface reports without hiding the
206
+ * rest. Replay/self-check/clock-freeze behave exactly as for explicit surfaces.
207
+ *
208
+ * ```ts
209
+ * // styleproof.spec.ts — capture every tab the nav links to
210
+ * defineCrawlCapture({ from: '/', match: /\?tab=/, widths: [1440, 1024, 768], dir: process.env.STYLEMAP_DIR });
211
+ * ```
212
+ */
213
+ export function defineCrawlCapture(options) {
214
+ const { from, match, key, widths, height, ignore, linkTimeout = 15_000, dir } = options;
215
+ const settings = resolveSettings(options);
216
+ test.describe('styleproof crawl-capture', () => {
217
+ test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
218
+ test('discover surfaces by crawling links, then capture each', async ({ page }) => {
219
+ // 1. Load the root and wait for its nav links to hydrate — an SPA renders them
220
+ // client-side, so they aren't in the initial HTML.
221
+ await page.goto(from, { waitUntil: 'load' });
222
+ await page.waitForSelector('a[href]', { timeout: linkTimeout });
223
+ const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href')));
224
+ const links = selectCrawlLinks(hrefs, { base: page.url(), match, key });
225
+ if (links.length === 0) {
226
+ throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
227
+ `<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
228
+ }
229
+ // Budget the whole sweep up front: one test captures every surface, and
230
+ // captureSurface no longer sets its own timeout, so size it to the work found.
231
+ test.setTimeout(Math.max(180_000, links.length * widths.length * 60_000));
232
+ // 2. Capture each discovered surface. Aggregate failures so one bad surface
233
+ // reports without skipping the rest — they're an independent set, not a chain.
234
+ const failures = [];
235
+ for (const link of links) {
236
+ for (const width of widths) {
237
+ const surface = {
238
+ key: link.key,
239
+ go: async (p) => {
240
+ await p.goto(link.url, { waitUntil: 'load' });
241
+ },
242
+ widths: [width],
243
+ ignore,
244
+ height,
245
+ };
246
+ try {
247
+ await captureSurface(page, surface, width, settings);
248
+ }
249
+ catch (e) {
250
+ failures.push(`${link.key} @ ${width}: ${e.message}`);
251
+ }
252
+ }
253
+ }
254
+ if (failures.length) {
255
+ throw new Error(`styleproof crawl-capture: ${failures.length} surface(s) failed:\n${failures.join('\n')}`);
256
+ }
257
+ });
258
+ });
259
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "2.1.0",
3
+ "version": "2.3.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",
@@ -75,7 +75,7 @@
75
75
  "devDependencies": {
76
76
  "@eslint/js": "^10.0.1",
77
77
  "@playwright/test": "^1.60.0",
78
- "@types/node": "^25.9.3",
78
+ "@types/node": "^26.0.0",
79
79
  "@types/pngjs": "^6.0.5",
80
80
  "eslint": "^10.5.0",
81
81
  "globals": "^17.6.0",