styleproof 2.1.0 → 2.2.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,29 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.2.0] - 2026-06-23
11
+
12
+ ### Added
13
+
14
+ - **Newly-added elements now report their full resting computed style**, not just
15
+ interaction-state deltas. Previously an added element surfaced only its
16
+ `:hover`/`:focus` changes (the diff short-circuited added elements before the
17
+ style loop); its background, padding, font, radius, etc. were captured but never
18
+ shown. The diff now emits the new element's full style as `(unset) → value`
19
+ findings and the report renders them value-only (no bogus "Before" column), in
20
+ both the PR report and the `styleproof-diff` CLI. The element already gated via
21
+ its `added` finding, so this enriches detail without changing what gates.
22
+ - **`captureComponent` (opt-in, default off): surface the React component + props**
23
+ behind each element. With it on, capture reads the React fiber in-page
24
+ (`__reactFiber$*`/`__reactProps$*` on React 17+, `__reactInternalInstance$*` on
25
+ ≤16) to record the component display name and a sanitized subset of its props
26
+ (primitives only; `children`/handlers/objects dropped) on `ElementEntry.component`.
27
+ The report names it — `React component: Button (variant=primary, size=sm)` —
28
+ instead of a bare `<button>`. **Advisory only**, exactly like the content layer:
29
+ never fed to the certification diff or its blocking counts, so captures stay
30
+ deterministic. Names are mangled in minified prod builds, so it's most useful
31
+ against dev/non-minified output; a no-op on non-React pages.
32
+
10
33
  ## [2.1.0] - 2026-06-23
11
34
 
12
35
  ### Added
package/README.md CHANGED
@@ -228,6 +228,23 @@ The report then carries a separate **📝 Content changes (advisory)** section:
228
228
 
229
229
  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
230
 
231
+ ## Optional: React component layer (advisory)
232
+
233
+ 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`:
234
+
235
+ ```ts
236
+ // styleproof.spec.ts — record the React component + props behind each element
237
+ defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR, captureComponent: true });
238
+ ```
239
+
240
+ 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>`.
241
+
242
+ 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.
243
+
244
+ ## Newly-added elements show their full style
245
+
246
+ 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.
247
+
231
248
  ## Reference
232
249
 
233
250
  **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);
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/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
@@ -98,6 +98,12 @@ export type DefineOptions = {
98
98
  * `CaptureOptions.captureText` and the README's "Optional: content layer".
99
99
  */
100
100
  captureText?: boolean;
101
+ /**
102
+ * Opt-in React layer (default OFF). Record the component + sanitized props that
103
+ * rendered each element so the report can name `Button (variant=primary)`.
104
+ * Advisory only — never gates. See `CaptureOptions.captureComponent`.
105
+ */
106
+ captureComponent?: boolean;
101
107
  };
102
108
  /**
103
109
  * Let SSE (EventSource) requests bypass HAR record/replay and reach the live
@@ -135,4 +141,4 @@ export declare function defaultSelfCheck(replayFrom: string | undefined, env?: s
135
141
  * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
136
142
  * ```
137
143
  */
138
- export declare function defineStyleMapCapture({ surfaces, expected, exclude, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, captureText, }: DefineOptions): void;
144
+ export declare function defineStyleMapCapture({ surfaces, expected, exclude, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, captureText, captureComponent, }: DefineOptions): void;
package/dist/runner.js CHANGED
@@ -96,6 +96,7 @@ async function captureSurface(page, surface, width, s) {
96
96
  const map = await captureStyleMap(page, {
97
97
  ignore: surface.ignore ?? [],
98
98
  captureText: s.captureText,
99
+ captureComponent: s.captureComponent,
99
100
  pendingRequests: requests.pending,
100
101
  });
101
102
  if (s.selfCheck)
@@ -135,7 +136,7 @@ export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCH
135
136
  * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
136
137
  * ```
137
138
  */
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
+ 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, captureComponent = false, }) {
139
140
  const settings = {
140
141
  dir: dir,
141
142
  baseDir,
@@ -146,6 +147,7 @@ export function defineStyleMapCapture({ surfaces, expected, exclude = {}, dir, b
146
147
  clockTime,
147
148
  selfCheck,
148
149
  captureText,
150
+ captureComponent,
149
151
  };
150
152
  // Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
151
153
  // a route added without a surface fails the app's own tests — long before, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "2.1.0",
3
+ "version": "2.2.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",