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 +23 -0
- package/README.md +17 -0
- package/dist/capture.d.ts +25 -0
- package/dist/capture.js +81 -26
- package/dist/diff.d.ts +4 -0
- package/dist/diff.js +30 -2
- package/dist/report.js +54 -18
- package/dist/runner.d.ts +7 -1
- package/dist/runner.js +3 -1
- package/package.json +2 -2
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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
function
|
|
38
|
-
|
|
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 =
|
|
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
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
|
-
|
|
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({
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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) &&
|
|
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.
|
|
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": "^
|
|
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",
|