styleproof 3.4.0 → 3.5.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,49 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Fixed
11
+
12
+ - Report tables never show an equal-looking Before/After pair for a real diff.
13
+ A colour embedded in a compound value (gradient, shadow) no longer stands in
14
+ for the whole value — `toHex` only converts values that ARE a colour — and
15
+ long value pairs (gradients, data URIs) are excerpted around the differing
16
+ substring with a little shared context on each side.
17
+ - Report values render verbatim: display rounding of decimals is gone (alpha
18
+ `0.18` was shown as `0.2`, and a real `0.18 → 0.2` change could be dropped
19
+ as a no-op after rounding).
20
+
21
+ ### Changed
22
+
23
+ - Control identity is now SEMANTIC, not positional: the driven-once dedup keys
24
+ on tag ancestry (no indices, no classes) + label + role — what stays stable
25
+ across every re-render — instead of nth-of-type selectors. Mode switches
26
+ re-render subtrees and re-mint positional selectors for the same logical
27
+ controls, which made every combination view refill the fresh-candidate pool
28
+ (a crawl inflated past 2,500 surfaces through that leak). Positional clones
29
+ with identical tag-path AND label (a repeated row's expand button) merge —
30
+ one is explored; distinctly-labeled controls stay distinct; anything hidden
31
+ behind a merge is still NAMED by the coverage verifier.
32
+ - Family retries no longer compound: a state reached via a retry still explores
33
+ its genuinely-new UI, but never re-retries mode-switchers. Every PAIRWISE
34
+ combination of independent modes is captured (a tab's edit state, a decided
35
+ list's other tab); 3-way-and-deeper toggle products — which multiply surfaces
36
+ without new render vocabulary — are not walked. Observed live: an exhaustive
37
+ crawl inflated past 725 surfaces in the N-way product tail; the pairwise walk
38
+ covers the same vocabulary in a fraction of the states. Anything class-visible
39
+ only at deeper combination depth is still NAMED by the coverage verifier.
40
+
41
+ ## [3.5.0] - 2026-07-02
42
+
43
+ ### Added
44
+
45
+ - Parallel crawl: `--workers <n>` (default 4) sweeps queued states concurrently,
46
+ each worker on its own browser context. The surface set is identical to a
47
+ serial crawl — dedup sets are shared, and a state's children only join the
48
+ queue when its sweep completes, so family retry never reads a half-built
49
+ changer registry. Only dup-key suffix attribution can vary with timing; pass
50
+ `--workers 1` for byte-stable keys. Exhaustive runs drop from hours to
51
+ minutes on lattice-heavy designs.
52
+
10
53
  ## [3.4.0] - 2026-07-02
11
54
 
12
55
  ### Fixed
package/README.md CHANGED
@@ -424,7 +424,7 @@ styleproof-diff design .styleproof/maps/current # diff the whole
424
424
 
425
425
  It's **exhaustive by default**: the crawl stops when there is nothing left to drive — every control tried once, every structurally new surface captured — not at a budget. Termination is guaranteed by dedup (controls dedup by selector, surfaces by a structural fingerprint), and the `--max-depth` / `--max-actions` / `--max-states` flags exist only as deliberate throttles. It's deterministic (document order; the same surface reached two ways is captured once) and self-settling — it waits for an async app (React/Vue/Babel that boots after `load`) to mount before reading, so a bare crawl of a client-rendered page still captures the mounted UI.
426
426
 
427
- What makes exhaustive affordable is that the sweep works **in place**: standing in a state, each control is clicked right where the page is, and a cheap DOM fingerprint decides what happened — a no-op click costs nothing, and only a state-changing click pays a reset (fresh navigation + replay of the click-path), which is then **verified by fingerprint** so children are never attributed to the wrong parent. New surfaces are captured at every width the moment they're reached — a deep or animated click-path is never re-driven to capture, so it can't be the thing that drops a surface. Progress streams as it goes, one line per captured surface.
427
+ What makes exhaustive affordable is that the sweep works **in place**: standing in a state, each control is clicked right where the page is, and a cheap DOM fingerprint decides what happened — a no-op click costs nothing, and only a state-changing click pays a reset (fresh navigation + replay of the click-path), which is then **verified by fingerprint** so children are never attributed to the wrong parent. New surfaces are captured at every width the moment they're reached — a deep or animated click-path is never re-driven to capture, so it can't be the thing that drops a surface. Progress streams as it goes, one line per captured surface. And it's **parallel by default** — `--workers <n>` (default 4) sweeps states concurrently on isolated browser contexts with the exact same surface set as a serial crawl (dedup is shared; children only enter the queue when their parent's sweep completes); `--workers 1` if you want byte-stable dup-key attribution.
428
428
 
429
429
  **And it proves nothing was missed.** After the crawl, StyleProof compares every class the page's own stylesheets define (read from the parsed CSSOM) against the classes actually rendered across the captured surfaces, and prints what — if anything — was never seen. `--require-full-coverage` turns any residue into exit code 4, so "the design is fully covered" is a CI-checkable property, not a judgement call. What's left is either dead CSS (delete it) or a state the crawl couldn't reach (drive it with a spec, or file the gap).
430
430
 
@@ -51,6 +51,8 @@ whole surface: --crawl
51
51
  --no-data-states skip the automatic loading/error captures of the entry page
52
52
  (on by default: data requests stalled → loading skeleton;
53
53
  fulfilled with 500 → error render)
54
+ --workers <n> concurrent sweep workers (default 4); same surface set as a
55
+ serial crawl — pass 1 for byte-stable key attribution
54
56
  --max-depth <n> throttle recursion depth (default: unbounded)
55
57
  --max-actions <n> throttle controls tried per state (default: unbounded)
56
58
  --max-states <n> throttle total surfaces (default: unbounded)
@@ -105,6 +107,9 @@ async function runCrawl() {
105
107
  resetStorage: opts.resetStorage,
106
108
  setup: setupSteps,
107
109
  dataStates: opts.dataStates,
110
+ workers: opts.workers,
111
+ // each worker page in its OWN context, so storage resets can't interfere
112
+ newPage: async () => (await browser.newContext()).newPage(),
108
113
  // Stream each surface as it is captured, so progress is visible live and an
109
114
  // interrupted run still shows exactly what it mapped.
110
115
  onSurface: (s, ok) =>
@@ -65,6 +65,8 @@ export type CaptureUrlOptions = {
65
65
  /** crawl: also capture automatic `loading`/`error` data states of the entry
66
66
  * page (default true). */
67
67
  dataStates: boolean;
68
+ /** crawl: concurrent sweep workers (default 4). 1 = byte-stable key attribution. */
69
+ workers: number;
68
70
  };
69
71
  /**
70
72
  * Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
@@ -32,6 +32,7 @@ const DEFAULTS = {
32
32
  resetStorage: true,
33
33
  requireFullCoverage: false,
34
34
  dataStates: true,
35
+ workers: 4,
35
36
  };
36
37
  function positiveNumber(raw, flag) {
37
38
  const n = Number(raw);
@@ -62,6 +63,7 @@ const VALUE_FLAGS = {
62
63
  '--max-actions': (o, v) => (o.maxActionsPerState = positiveNumber(v, '--max-actions')),
63
64
  '--max-states': (o, v) => (o.maxStates = positiveNumber(v, '--max-states')),
64
65
  '--setup': (o, v) => (o.setupFile = v),
66
+ '--workers': (o, v) => (o.workers = positiveNumber(v, '--workers')),
65
67
  };
66
68
  const BOOL_FLAGS = {
67
69
  '--screenshots': (o) => (o.screenshots = true),
@@ -119,6 +121,7 @@ export function parseCaptureUrlArgs(argv) {
119
121
  requireFullCoverage: DEFAULTS.requireFullCoverage,
120
122
  setupFile: undefined,
121
123
  dataStates: DEFAULTS.dataStates,
124
+ workers: DEFAULTS.workers,
122
125
  };
123
126
  const positional = [];
124
127
  for (let i = 0; i < argv.length; i++)
@@ -101,6 +101,15 @@ export type SurfaceCrawlOptions = {
101
101
  /** Called as each surface is recorded (captured=false when its full capture failed).
102
102
  * Lets CLIs stream progress instead of reporting only at the end. */
103
103
  onSurface?: (surface: CrawledSurface, captured: boolean) => void;
104
+ /** Concurrent sweep workers (default 4). Each worker gets its own page from
105
+ * `newPage`; without a factory the crawl runs single-page regardless. The
106
+ * surface SET is identical to a serial crawl (same dedup sets); only which
107
+ * path first claims a shape — and so dup-key suffixes — can vary run to run.
108
+ * Use workers: 1 for byte-stable key attribution. */
109
+ workers?: number;
110
+ /** Factory for worker pages — create each in its OWN browser context so
111
+ * storage resets cannot interfere across concurrent sweeps. */
112
+ newPage?: () => Promise<Page>;
104
113
  };
105
114
  export declare const CRAWL_DEFAULTS: {
106
115
  height: number;
@@ -109,14 +118,10 @@ export declare const CRAWL_DEFAULTS: {
109
118
  maxActionsPerState: number;
110
119
  maxStates: number;
111
120
  resetStorage: boolean;
121
+ workers: number;
112
122
  };
113
123
  /** Run the caller's deterministic setup steps (login, unlock, seed input). A
114
124
  * non-optional step that fails throws loudly — a half-established gate must
115
125
  * never silently crawl the ungated page instead. */
116
126
  export declare function runSetup(page: Page, steps: SetupStep[]): Promise<void>;
117
- /**
118
- * Crawl `opts.url` and capture every reachable surface at every width — runs to
119
- * natural termination by default. Returns the surfaces mapped (with the click-path
120
- * that reached each), how many actions were tried/skipped, and captured/failed.
121
- */
122
127
  export declare function crawlAndCapture(page: Page, opts: SurfaceCrawlOptions): Promise<CrawlReport>;
@@ -11,6 +11,7 @@ export const CRAWL_DEFAULTS = {
11
11
  maxActionsPerState: 100000,
12
12
  maxStates: 100000,
13
13
  resetStorage: true,
14
+ workers: 4,
14
15
  };
15
16
  function slug(value) {
16
17
  return (value
@@ -77,6 +78,19 @@ function collectClickable() {
77
78
  .replace(/\s+/g, ' ')
78
79
  .trim()
79
80
  .slice(0, 80) || el.tagName.toLowerCase();
81
+ const identityFor = (el) => {
82
+ // Tag-path only — NO classes and NO indices: classes carry state (body.alt,
83
+ // .on) and would re-contextualize the same control per mode; indices carry
84
+ // position and drift on re-render. Tag ancestry + label is what stays
85
+ // stable across every context the same logical control appears in.
86
+ const parts = [];
87
+ let cur = el;
88
+ while (cur && cur !== document.documentElement) {
89
+ parts.unshift(cur.tagName.toLowerCase());
90
+ cur = cur.parentElement;
91
+ }
92
+ return `${parts.join('>')}|${labelFor(el)}|${el.getAttribute('role') ?? ''}`;
93
+ };
80
94
  // Semantic controls first (stable, meaningful), then anything styled clickable.
81
95
  // `grab` counts too: a draggable card is routinely ALSO a click target (open on
82
96
  // click, drag to move), and we never drag — el.click() fires no drag gesture.
@@ -117,6 +131,7 @@ function collectClickable() {
117
131
  out.push({
118
132
  action: 'fill-input',
119
133
  selector,
134
+ identity: identityFor(el),
120
135
  label: labelFor(el) === el.tagName.toLowerCase() ? (el.getAttribute('placeholder') ?? 'input') : labelFor(el),
121
136
  reason: 'auto-fill',
122
137
  value: AUTO_VALUE[kind] ?? 'sample text',
@@ -139,12 +154,21 @@ function collectClickable() {
139
154
  if (el instanceof HTMLSelectElement) {
140
155
  const next = [...el.options].find((o) => !o.disabled && o.value !== el.value);
141
156
  if (next)
142
- out.push({ action: 'select-option', selector, label, reason: 'select-option', value: next.value, unsafe });
157
+ out.push({
158
+ action: 'select-option',
159
+ selector,
160
+ identity: identityFor(el),
161
+ label,
162
+ reason: 'select-option',
163
+ value: next.value,
164
+ unsafe,
165
+ });
143
166
  }
144
167
  else {
145
168
  out.push({
146
169
  action: 'click',
147
170
  selector,
171
+ identity: identityFor(el),
148
172
  label,
149
173
  reason: el.getAttribute('role') === 'tab' ? 'tab' : 'click',
150
174
  unsafe,
@@ -412,11 +436,14 @@ function stateKey(steps) {
412
436
  }
413
437
  /** Record a newly-found surface, capture it in place (page is already there), and
414
438
  * queue it for its own sweep. Streams progress via onSurface. */
415
- async function record(page, opts, newPath, depth, fp, st, retryOnly = false) {
439
+ async function record(page, opts, newPath, depth, fp, st, sink, retryOnly = false, viaRetry = false) {
416
440
  const key = deriveKey(newPath, st.used);
417
441
  const surface = { key, depth, path: newPath, elements: fp.elements };
418
442
  st.surfaces.push(surface);
419
- st.queue.push({ path: newPath, depth, sig: fp.sig, retryOnly });
443
+ // Children buffer in the sweep's sink and enter the shared queue only when the
444
+ // parent's sweep completes — family retry reads the parent's changer registry,
445
+ // which is only complete then. (Serial mode passes st.queue directly.)
446
+ sink.push({ path: newPath, depth, sig: fp.sig, retryOnly, viaRetry });
420
447
  for (const c of fp.classes)
421
448
  st.classes.add(c);
422
449
  await captureAndReport(page, opts, surface, st);
@@ -451,7 +478,7 @@ async function tryInPlace(page, c) {
451
478
  }
452
479
  /** Drive one candidate from where the page stands. Returns whether the page is
453
480
  * still in the swept state (no-op click) and whether the action was a skip. */
454
- async function driveCandidate(page, opts, entry, c, st) {
481
+ async function driveCandidate(page, opts, entry, c, st, sink, viaRetry) {
455
482
  // (retry-only lineage is inherited: a consumed state's descendants can also
456
483
  // only be mode-switch views, never fresh-candidate exploration.)
457
484
  const outcome = await tryInPlace(page, c);
@@ -468,7 +495,7 @@ async function driveCandidate(page, opts, entry, c, st) {
468
495
  .catch(() => false);
469
496
  const from = stateKey(entry.path);
470
497
  const list = st.changersFrom.get(from) ?? [];
471
- if (!list.some((x) => x.c.selector === c.selector))
498
+ if (!list.some((x) => x.c.identity === c.identity))
472
499
  list.push({ c, persists });
473
500
  st.changersFrom.set(from, list);
474
501
  const fp = await fingerprint(page);
@@ -482,7 +509,7 @@ async function driveCandidate(page, opts, entry, c, st) {
482
509
  reason: c.reason,
483
510
  ...(c.value ? { value: c.value } : {}),
484
511
  };
485
- await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st, entry.retryOnly || !persists);
512
+ await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st, sink, entry.retryOnly || !persists, viaRetry);
486
513
  return { inState: false, skipped: false };
487
514
  }
488
515
  /**
@@ -494,14 +521,26 @@ async function driveCandidate(page, opts, entry, c, st) {
494
521
  /** The family-retry list for a state: its parent's persistent mode-switchers that
495
522
  * are visible right now — minus the step that created this state itself. */
496
523
  function familyRetries(entry, all, st) {
524
+ // RETRIES DO NOT COMPOUND: a state reached via a family retry still explores
525
+ // its genuinely-new UI (fresh selectors), but never re-retries mode-switchers.
526
+ // Every PAIRWISE mode combination is captured; N-way products of independent
527
+ // toggles are not walked — they multiply states without new render vocabulary,
528
+ // and anything class-visible only at 3-way depth is still NAMED by the
529
+ // coverage verifier.
530
+ if (entry.viaRetry)
531
+ return [];
497
532
  if (entry.path.length === 0)
498
533
  return [];
499
534
  const parentKey = stateKey(entry.path.slice(0, -1));
500
535
  const ownSelector = entry.path[entry.path.length - 1].selector;
501
- const visibleNow = new Set(all.map((c) => c.selector));
536
+ // Match registered changers to THIS state's candidates by semantic identity —
537
+ // the mode switch re-rendered the subtree, so positional selectors drifted;
538
+ // the current candidate carries the right selector for this state.
539
+ const byIdentity = new Map(all.map((c) => [c.identity, c]));
502
540
  return (st.changersFrom.get(parentKey) ?? [])
503
- .filter((x) => x.persists && x.c.selector !== ownSelector && visibleNow.has(x.c.selector))
504
- .map((x) => x.c);
541
+ .filter((x) => x.persists && x.c.selector !== ownSelector)
542
+ .map((x) => byIdentity.get(x.c.identity))
543
+ .filter((c) => Boolean(c));
505
544
  }
506
545
  /** The work list for one state's sweep: fresh controls first (already-driven
507
546
  * global chrome would otherwise starve a deep surface's own controls; the
@@ -509,13 +548,13 @@ function familyRetries(entry, all, st) {
509
548
  * re-tried in THIS sibling mode. A state reached through a consuming action
510
549
  * collects NO fresh candidates — see QueueEntry.retryOnly. */
511
550
  function sweepWorkList(entry, all, opts, st) {
512
- const fresh = entry.retryOnly ? [] : all.filter((c) => !st.tried.has(c.selector)).slice(0, opts.maxActionsPerState);
551
+ const fresh = entry.retryOnly ? [] : all.filter((c) => !st.tried.has(c.identity)).slice(0, opts.maxActionsPerState);
513
552
  return [
514
553
  ...fresh.map((c) => ({ c, retry: false })),
515
554
  ...familyRetries(entry, all, st).map((c) => ({ c, retry: true })),
516
555
  ];
517
556
  }
518
- async function sweepState(page, opts, entry, st) {
557
+ async function sweepState(page, opts, entry, st, sink) {
519
558
  if (!(await resetToState(page, opts, entry.path, entry.sig)))
520
559
  return { tried: 0, skipped: 0 };
521
560
  const all = await page.evaluate(collectClickable).catch(() => []);
@@ -532,13 +571,13 @@ async function sweepState(page, opts, entry, st) {
532
571
  inState = true;
533
572
  }
534
573
  if (!retry)
535
- st.tried.add(c.selector);
574
+ st.tried.add(c.identity);
536
575
  if (c.unsafe) {
537
576
  skipped++;
538
577
  continue;
539
578
  }
540
579
  tried++;
541
- const r = await driveCandidate(page, opts, entry, c, st);
580
+ const r = await driveCandidate(page, opts, entry, c, st, sink, retry);
542
581
  inState = r.inState;
543
582
  if (r.skipped)
544
583
  skipped++;
@@ -578,6 +617,53 @@ async function recordDataState(page, opts, mode, st) {
578
617
  await page.unroute('**/*');
579
618
  }
580
619
  }
620
+ /**
621
+ * Sweep the queue with N concurrent workers, each on its own page. LIFO keeps
622
+ * the depth-first bias; a worker's discovered children enter the shared queue
623
+ * only when its sweep completes (see record). The surface SET matches a serial
624
+ * crawl — dedup sets are shared and mutated synchronously — only dup-key
625
+ * suffix attribution can vary with timing.
626
+ */
627
+ async function runPool(primary, opts, st, counters) {
628
+ const target = Math.max(1, opts.workers ?? 1);
629
+ const pages = [primary];
630
+ while (opts.newPage && pages.length < target) {
631
+ const extra = await opts.newPage();
632
+ if (opts.resetStorage)
633
+ await armResetStorage(extra);
634
+ pages.push(extra);
635
+ }
636
+ let active = 0;
637
+ await new Promise((resolve) => {
638
+ const pump = () => {
639
+ while (pages.length > 0 && st.queue.length > 0 && st.surfaces.length < opts.maxStates) {
640
+ const entry = st.queue.pop(); // LIFO → depth-first
641
+ if (entry.depth >= opts.maxDepth)
642
+ continue;
643
+ const worker = pages.pop();
644
+ active++;
645
+ const sink = [];
646
+ sweepState(worker, opts, entry, st, sink)
647
+ .then((r) => {
648
+ counters.tried += r.tried;
649
+ counters.skipped += r.skipped;
650
+ })
651
+ .catch(() => {
652
+ /* fail-soft: the state's surface was already captured in place */
653
+ })
654
+ .finally(() => {
655
+ st.queue.push(...sink);
656
+ pages.push(worker);
657
+ active--;
658
+ pump();
659
+ });
660
+ }
661
+ if (active === 0 && (st.queue.length === 0 || st.surfaces.length >= opts.maxStates))
662
+ resolve();
663
+ };
664
+ pump();
665
+ });
666
+ }
581
667
  /** Depth-first discovery + in-place capture of every reachable surface. Depth-first
582
668
  * so a surface's OWN sub-states (a modal's tab → its toggles) are mapped while the
583
669
  * branch is fresh; with no budget, order affects time-to-depth, not coverage. */
@@ -617,7 +703,7 @@ async function discover(page, opts) {
617
703
  captured: 0,
618
704
  failed: [],
619
705
  };
620
- await record(page, opts, [], 0, fp, st, false);
706
+ await record(page, opts, [], 0, fp, st, st.queue, false, false);
621
707
  // Automatic data states of the entry page — the loading skeleton and the
622
708
  // error render exist in every data-driven app but almost never in a click
623
709
  // path. Captured out of the box; identical-to-base renders dedup away.
@@ -625,21 +711,13 @@ async function discover(page, opts) {
625
711
  await recordDataState(page, opts, 'loading', st);
626
712
  await recordDataState(page, opts, 'error', st);
627
713
  }
628
- let actionsTried = 0;
629
- let skipped = 0;
630
- while (st.queue.length > 0 && st.surfaces.length < opts.maxStates) {
631
- const entry = st.queue.pop(); // LIFO → depth-first
632
- if (entry.depth >= opts.maxDepth)
633
- continue;
634
- const r = await sweepState(page, opts, entry, st);
635
- actionsTried += r.tried;
636
- skipped += r.skipped;
637
- }
714
+ const counters = { tried: 0, skipped: 0 };
715
+ await runPool(page, opts, st, counters);
638
716
  const missing = defined.filter((c) => !st.classes.has(c)).sort();
639
717
  return {
640
718
  surfaces: st.surfaces,
641
- actionsTried,
642
- skipped,
719
+ actionsTried: counters.tried,
720
+ skipped: counters.skipped,
643
721
  captured: st.captured,
644
722
  failed: st.failed,
645
723
  coverage: { defined: defined.length, rendered: defined.length - missing.length, missing },
@@ -650,19 +728,21 @@ async function discover(page, opts) {
650
728
  * natural termination by default. Returns the surfaces mapped (with the click-path
651
729
  * that reached each), how many actions were tried/skipped, and captured/failed.
652
730
  */
731
+ /** Clear storage before the app's code runs on EVERY load, so each gotoFresh is
732
+ * a clean slate in one navigation (no clear-then-reload round trip). */
733
+ async function armResetStorage(page) {
734
+ await page.addInitScript(() => {
735
+ try {
736
+ localStorage.clear();
737
+ sessionStorage.clear();
738
+ }
739
+ catch {
740
+ /* storage unavailable (e.g. file://) — ignore */
741
+ }
742
+ });
743
+ }
653
744
  export async function crawlAndCapture(page, opts) {
654
- if (opts.resetStorage) {
655
- // Clear storage before the app's code runs on EVERY load, so each gotoFresh is
656
- // a clean slate in one navigation (no clear-then-reload round trip).
657
- await page.addInitScript(() => {
658
- try {
659
- localStorage.clear();
660
- sessionStorage.clear();
661
- }
662
- catch {
663
- /* storage unavailable (e.g. file://) — ignore */
664
- }
665
- });
666
- }
745
+ if (opts.resetStorage)
746
+ await armResetStorage(page);
667
747
  return discover(page, opts);
668
748
  }
package/dist/describe.js CHANGED
@@ -18,7 +18,10 @@ const PALETTE = [
18
18
  ['pink', [236, 64, 122]],
19
19
  ];
20
20
  function parseColor(v) {
21
- const m = v.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,/\s]+([\d.]+))?\s*\)/i);
21
+ // Anchored: only a value that IS a colour parses. An embedded colour inside a
22
+ // gradient/shadow/url must not stand in for the whole value — that once made a
23
+ // report show the same "representative" rgba on both sides of a real diff.
24
+ const m = v.match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,/\s]+([\d.]+))?\s*\)$/i);
22
25
  if (!m)
23
26
  return null;
24
27
  return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])];
package/dist/report.d.ts CHANGED
@@ -71,4 +71,5 @@ export type ReportResult = {
71
71
  export declare function summarizeProps(props: PropChange[]): PropChange[];
72
72
  /** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
73
73
  export declare function prettyLabel(p: string, cls: string): string;
74
+ export declare function excerptPair(before: string, after: string): [string, string];
74
75
  export declare function generateStyleMapReport(opts: ReportOptions): ReportResult;
package/dist/report.js CHANGED
@@ -292,7 +292,9 @@ const orderIdx = (p) => {
292
292
  return i === -1 ? PROP_ORDER.length : i;
293
293
  };
294
294
  function cleanVal(v) {
295
- let s = v.replace(/-?\d+\.\d+/g, (m) => String(Math.round(parseFloat(m) * 10) / 10));
295
+ // Verbatim by design: no rounding. Rounding once showed alpha 0.18 as 0.2
296
+ // and could erase a real 0.18→0.2 diff entirely via the no-op filter below.
297
+ let s = v;
296
298
  if (!s.includes('(')) {
297
299
  const toks = s.split(' ');
298
300
  if (toks.length > 1 && new Set(toks).size === 1)
@@ -512,11 +514,48 @@ function regionHeading(regionPaths, findings) {
512
514
  // A "no value here" marker renders as an em dash; colours render as `#hex` so the
513
515
  // table cell shows GitHub's live swatch.
514
516
  const cell = (v) => (isNonValue(v) ? '—' : `\`${toHex(v)}\``);
517
+ // Long values (gradients, data URIs) would swamp the table, but truncating each
518
+ // side independently can show two IDENTICAL cells for a real diff: both
519
+ // sides of a gradient rendered as the same rgba while the actual change — a
520
+ // dropped `0px` stop — was elsewhere in the string. Instead, trim the shared
521
+ // prefix/suffix and show each side's differing substring with a little context.
522
+ const EXCERPT_AT = 64; // both sides at or under this → show whole values
523
+ const EXCERPT_CTX = 12; // chars of shared context kept around the diff
524
+ const EXCERPT_MAX = 96; // hard cap per excerpt; the diff itself may be huge
525
+ export function excerptPair(before, after) {
526
+ if (before.length <= EXCERPT_AT && after.length <= EXCERPT_AT)
527
+ return [before, after];
528
+ let p = 0;
529
+ while (p < before.length && p < after.length && before[p] === after[p])
530
+ p++;
531
+ let s = 0;
532
+ const maxS = Math.min(before.length, after.length) - p;
533
+ while (s < maxS && before[before.length - 1 - s] === after[after.length - 1 - s])
534
+ s++;
535
+ const cut = (v) => {
536
+ const start = Math.max(0, p - EXCERPT_CTX);
537
+ let end = Math.min(v.length, v.length - s + EXCERPT_CTX);
538
+ if (end - start > EXCERPT_MAX)
539
+ end = start + EXCERPT_MAX;
540
+ return (start > 0 ? '…' : '') + v.slice(start, end) + (end < v.length ? '…' : '');
541
+ };
542
+ return [cut(before), cut(after)];
543
+ }
544
+ /** Before/After cells as a pair, so long values excerpt around their actual diff. */
545
+ function cellPair(before, after) {
546
+ if (isNonValue(before) || isNonValue(after))
547
+ return [cell(before), cell(after)];
548
+ const [b, a] = excerptPair(before, after);
549
+ return [`\`${toHex(b)}\``, `\`${toHex(a)}\``];
550
+ }
515
551
  function beforeAfterTable(rows) {
516
552
  return [
517
553
  '| Property | Before | After |',
518
554
  '| --- | --- | --- |',
519
- ...rows.map((r) => `| \`${r.prop}\` | ${cell(r.before)} | ${cell(r.after)} |`),
555
+ ...rows.map((r) => {
556
+ const [b, a] = cellPair(r.before, r.after);
557
+ return `| \`${r.prop}\` | ${b} | ${a} |`;
558
+ }),
520
559
  ];
521
560
  }
522
561
  // A brand-new element has no meaningful "before", so its resting style renders
@@ -546,10 +585,12 @@ function styleSection(styles, added) {
546
585
  function statesSection(states, added) {
547
586
  const rows = [];
548
587
  for (const st of states)
549
- for (const c of summarizeProps(st.props))
588
+ for (const c of summarizeProps(st.props)) {
589
+ const [b, a] = cellPair(c.before, c.after);
550
590
  rows.push(added
551
591
  ? `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.after)} |`
552
- : `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.before)} → ${cell(c.after)} |`);
592
+ : `| \`:${st.state}\` | \`${c.prop}\` | ${b} → ${a} |`);
593
+ }
553
594
  if (!rows.length)
554
595
  return [];
555
596
  return [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.4.0",
3
+ "version": "3.5.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",