styleproof 3.1.5 → 3.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.
@@ -0,0 +1,500 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
5
+ // Exhaustive by default — these ceilings are safety backstops, not budgets.
6
+ export const CRAWL_DEFAULTS = {
7
+ height: 900,
8
+ screenshots: true,
9
+ maxDepth: 1000,
10
+ maxActionsPerState: 100000,
11
+ maxStates: 100000,
12
+ resetStorage: true,
13
+ };
14
+ function slug(value) {
15
+ return (value
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, '-')
18
+ .replace(/^-+|-+$/g, '')
19
+ .slice(0, 24) || 'state');
20
+ }
21
+ function pathAndSearch(url) {
22
+ try {
23
+ const u = new URL(url);
24
+ return u.pathname + u.search;
25
+ }
26
+ catch {
27
+ return url;
28
+ }
29
+ }
30
+ function deriveKey(steps, used) {
31
+ const base = steps.length === 0 ? 'base' : slug(steps[steps.length - 1].label);
32
+ let key = base;
33
+ for (let i = 2; used.has(key); i++)
34
+ key = `${base}-${i}`;
35
+ used.add(key);
36
+ return key;
37
+ }
38
+ /** Runs in the browser: every visible, enabled, non-navigating control worth trying. */
39
+ /* c8 ignore start */ // fallow-ignore-next-line complexity
40
+ function collectClickable() {
41
+ const SEMANTIC = 'button,summary,[role="button"],[role="tab"],[role="menuitem"],[role="combobox"],select,form';
42
+ const DANGER = /\b(delete|remove|destroy|logout|log ?out|sign ?out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect|revoke|reset|wipe|drop)\b/i;
43
+ const esc = (v) => CSS.escape(v);
44
+ const quote = (v) => JSON.stringify(v);
45
+ const visible = (el) => {
46
+ const b = el.getBoundingClientRect();
47
+ const cs = getComputedStyle(el);
48
+ return (b.width > 0 && b.height > 0 && cs.display !== 'none' && cs.visibility !== 'hidden' && cs.pointerEvents !== 'none');
49
+ };
50
+ const unique = (s) => document.querySelectorAll(s).length === 1;
51
+ const pathSelector = (el) => {
52
+ const parts = [];
53
+ let cur = el;
54
+ while (cur && cur !== document.documentElement) {
55
+ let i = 1;
56
+ for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling)
57
+ if (sib.tagName === cur.tagName)
58
+ i++;
59
+ parts.unshift(`${cur.tagName.toLowerCase()}:nth-of-type(${i})`);
60
+ cur = cur.parentElement;
61
+ }
62
+ return parts.join(' > ');
63
+ };
64
+ const selectorFor = (el) => {
65
+ const id = el.getAttribute('id');
66
+ if (id && unique(`#${esc(id)}`))
67
+ return `#${esc(id)}`;
68
+ for (const attr of ['data-testid', 'data-test', 'aria-label', 'name']) {
69
+ const v = el.getAttribute(attr);
70
+ if (v && unique(`${el.tagName.toLowerCase()}[${attr}=${quote(v)}]`))
71
+ return `${el.tagName.toLowerCase()}[${attr}=${quote(v)}]`;
72
+ }
73
+ return pathSelector(el);
74
+ };
75
+ const labelFor = (el) => (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || '')
76
+ .replace(/\s+/g, ' ')
77
+ .trim()
78
+ .slice(0, 80) || el.tagName.toLowerCase();
79
+ // Semantic controls first (stable, meaningful), then anything styled clickable.
80
+ // `grab` counts too: a draggable card is routinely ALSO a click target (open on
81
+ // click, drag to move), and we never drag — el.click() fires no drag gesture.
82
+ const pool = new Set([...document.querySelectorAll(SEMANTIC)]);
83
+ for (const el of document.querySelectorAll('body *')) {
84
+ if (pool.has(el))
85
+ continue;
86
+ const cursor = getComputedStyle(el).cursor;
87
+ if (cursor === 'pointer' || cursor === 'grab')
88
+ pool.add(el);
89
+ }
90
+ const seen = new Set();
91
+ const out = [];
92
+ for (const el of pool) {
93
+ if (el instanceof HTMLAnchorElement && el.href)
94
+ continue; // links navigate — handled by link crawl, not here
95
+ if (el.closest(':disabled,[aria-disabled="true"]'))
96
+ continue;
97
+ if (!visible(el))
98
+ continue;
99
+ const selector = selectorFor(el);
100
+ if (seen.has(selector))
101
+ continue;
102
+ seen.add(selector);
103
+ const label = labelFor(el);
104
+ const unsafe = DANGER.test(label);
105
+ if (el instanceof HTMLSelectElement) {
106
+ const next = [...el.options].find((o) => !o.disabled && o.value !== el.value);
107
+ if (next)
108
+ out.push({ action: 'select-option', selector, label, reason: 'select-option', value: next.value, unsafe });
109
+ }
110
+ else {
111
+ out.push({
112
+ action: 'click',
113
+ selector,
114
+ label,
115
+ reason: el.getAttribute('role') === 'tab' ? 'tab' : 'click',
116
+ unsafe,
117
+ });
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ /* c8 ignore stop */
123
+ /** Runs in the browser: the DOM's structural shape — every element's tag + class
124
+ * in document order (scripts/styles/meta skipped). Text and computed styles are
125
+ * deliberately excluded, so a ticking clock or animated opacity never reads as a
126
+ * new state, while any mount/unmount/class flip does. ~30ms vs the full
127
+ * computed-style walk it replaces for change detection. */
128
+ /* c8 ignore start */
129
+ function domShape() {
130
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'META', 'LINK', 'NOSCRIPT', 'TEMPLATE']);
131
+ const parts = [];
132
+ const classes = new Set();
133
+ const add = (el) => {
134
+ const cls = el.getAttribute('class') ?? '';
135
+ parts.push(`${el.tagName}.${cls}`);
136
+ for (const c of cls.split(/\s+/))
137
+ if (c)
138
+ classes.add(c);
139
+ };
140
+ // html/body themselves first: state classes there (a theme switch, modal-open)
141
+ // are a common pattern and restyle the whole page without touching any
142
+ // descendant's class — invisible to a descendants-only walk.
143
+ add(document.documentElement);
144
+ add(document.body);
145
+ for (const el of document.body.getElementsByTagName('*')) {
146
+ if (SKIP.has(el.tagName))
147
+ continue;
148
+ add(el);
149
+ }
150
+ return { shape: parts.join('\n'), elements: parts.length, classes: [...classes] };
151
+ }
152
+ /* c8 ignore stop */
153
+ /** Runs in the browser: every class name the page's OWN stylesheets select on —
154
+ * the design's defined vocabulary, read from the parsed CSSOM (inline and
155
+ * same-origin sheets; unreadable cross-origin sheets are skipped). Coverage is
156
+ * checked against this, so "fully covered" means every class the design styles
157
+ * was seen rendered in at least one captured surface. */
158
+ /* c8 ignore start */
159
+ function collectDefinedClasses() {
160
+ const out = new Set();
161
+ const scan = (rules) => {
162
+ if (!rules)
163
+ return;
164
+ for (const rule of rules) {
165
+ const r = rule;
166
+ if (r.selectorText) {
167
+ const re = /\.([A-Za-z_][A-Za-z0-9_-]*)/g;
168
+ for (let m = re.exec(r.selectorText); m; m = re.exec(r.selectorText))
169
+ out.add(m[1]);
170
+ }
171
+ if (r.cssRules)
172
+ scan(r.cssRules);
173
+ }
174
+ };
175
+ for (const sheet of document.styleSheets) {
176
+ try {
177
+ scan(sheet.cssRules);
178
+ }
179
+ catch {
180
+ /* cross-origin sheet — not the design's own vocabulary */
181
+ }
182
+ }
183
+ return [...out];
184
+ }
185
+ /* c8 ignore stop */
186
+ /** Structural fingerprint of the page's CURRENT state. Dedup key for surfaces. */
187
+ async function fingerprint(page) {
188
+ const fp = await page.evaluate(domShape);
189
+ return {
190
+ sig: createHash('sha256').update(fp.shape).digest('hex').slice(0, 16),
191
+ elements: fp.elements,
192
+ classes: fp.classes,
193
+ };
194
+ }
195
+ /** Wait for the DOM to stop changing (element count stable) — a cheap post-click
196
+ * settle that also covers content painted by a click-triggered fetch. */
197
+ async function settleDom(page, maxMs = 1200) {
198
+ let prev = -1;
199
+ const deadline = Date.now() + maxMs;
200
+ while (Date.now() < deadline) {
201
+ const n = await page.evaluate(() => document.body.getElementsByTagName('*').length);
202
+ if (n === prev)
203
+ return;
204
+ prev = n;
205
+ await page.waitForTimeout(90);
206
+ }
207
+ }
208
+ /** Wait for an async-rendered app to finish mounting: the DOM stops growing and is
209
+ * non-trivial. Generic — no app-specific selector needed, so a bare crawl of a
210
+ * Babel/React/Vue page that boots after `load` still captures the mounted UI. */
211
+ async function waitSettled(page) {
212
+ await page.waitForLoadState('networkidle').catch(() => { });
213
+ let prev = -1;
214
+ for (let i = 0; i < 40; i++) {
215
+ const n = await page.evaluate(() => document.body.getElementsByTagName('*').length);
216
+ if (n > 5 && n === prev)
217
+ return;
218
+ prev = n;
219
+ await page.waitForTimeout(100);
220
+ }
221
+ }
222
+ /**
223
+ * Load the URL from a clean slate (storage cleared by the init script armed in
224
+ * crawlAndCapture — one navigation, no clear-then-reload) and wait for the app to
225
+ * mount. Pins the viewport to widths[0] so DISCOVERY always happens at one
226
+ * consistent width — captureInPlace sweeps the other widths and would otherwise
227
+ * leave the viewport wherever it finished (e.g. a mobile band where half the
228
+ * controls are hidden).
229
+ */
230
+ async function gotoFresh(page, opts) {
231
+ await page.setViewportSize({ width: opts.widths[0] ?? 1280, height: opts.height });
232
+ await page.goto(opts.url, { waitUntil: 'load' });
233
+ // Tolerant wait: the generic settle below is the real readiness signal; an
234
+ // optional waitSelector just accelerates it and must not fail the crawl.
235
+ const ready = opts.waitSelector ? page.locator(opts.waitSelector).first() : null;
236
+ if (ready)
237
+ await ready.waitFor({ state: 'visible' }).catch(() => { });
238
+ await waitSettled(page);
239
+ }
240
+ async function perform(page, s) {
241
+ const target = page.locator(s.selector).first();
242
+ if (s.action === 'select-option') {
243
+ await target.selectOption(s.value ?? '');
244
+ return;
245
+ }
246
+ await target.waitFor({ state: 'attached', timeout: 5000 });
247
+ await target.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => { });
248
+ // Dispatch the click IN-PAGE rather than through Playwright's actionability
249
+ // pipeline. `el.click()` fires the app's real (delegated) click handler but
250
+ // skips the stability/visibility/hit-testing waits that make Playwright's own
251
+ // click flake on an opening modal — the intermittent failure that silently
252
+ // dropped deep states on re-drive. We only need to REACH the state to map it.
253
+ const dispatched = await target
254
+ .evaluate((el) => {
255
+ if (el instanceof HTMLElement) {
256
+ el.click();
257
+ return true;
258
+ }
259
+ return false;
260
+ })
261
+ .catch(() => false);
262
+ if (!dispatched)
263
+ await target.click({ timeout: 3000 }); // non-HTMLElement (SVG etc.)
264
+ }
265
+ async function replay(page, steps) {
266
+ for (const s of steps) {
267
+ await perform(page, s);
268
+ await settleDom(page);
269
+ }
270
+ }
271
+ /** Reset to a known state and VERIFY arrival by fingerprint (retry once). A false
272
+ * return means the path won't reproduce right now — the caller abandons the rest
273
+ * of that state's sweep (fail-soft) rather than mis-attributing children. */
274
+ async function resetToState(page, opts, steps, wantSig) {
275
+ for (let attempt = 0; attempt < 2; attempt++) {
276
+ try {
277
+ await gotoFresh(page, opts);
278
+ await replay(page, steps);
279
+ if ((await fingerprint(page)).sig === wantSig)
280
+ return true;
281
+ }
282
+ catch {
283
+ /* retry */
284
+ }
285
+ }
286
+ return false;
287
+ }
288
+ /** Capture the page's CURRENT state (already driven to) at every width, resizing
289
+ * in place. Capturing where discovery already stands means a fragile click-path
290
+ * is never replayed a second time — the failure mode of re-driving deep, animated
291
+ * controls. */
292
+ async function captureInPlace(page, key, opts) {
293
+ for (const width of opts.widths) {
294
+ await page.setViewportSize({ width, height: opts.height });
295
+ const requests = trackInflightRequests(page);
296
+ try {
297
+ const map = await captureStyleMap(page, {
298
+ ignore: opts.ignore,
299
+ pendingRequests: requests.pending,
300
+ metadata: { surfaceKey: key },
301
+ });
302
+ const stem = path.join(opts.out, `${key}@${width}`);
303
+ saveStyleMap(`${stem}.json.gz`, map);
304
+ if (opts.screenshots)
305
+ await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
306
+ }
307
+ finally {
308
+ requests.dispose();
309
+ }
310
+ }
311
+ }
312
+ /** Identity of a state for the changer registry: the click-path that defines it. */
313
+ function stateKey(steps) {
314
+ return steps.map((s) => s.selector + (s.value ?? '')).join(' → ');
315
+ }
316
+ /** Record a newly-found surface, capture it in place (page is already there), and
317
+ * queue it for its own sweep. Streams progress via onSurface. */
318
+ async function record(page, opts, newPath, depth, fp, st) {
319
+ const key = deriveKey(newPath, st.used);
320
+ const surface = { key, depth, path: newPath, elements: fp.elements };
321
+ st.surfaces.push(surface);
322
+ st.queue.push({ path: newPath, depth, sig: fp.sig });
323
+ for (const c of fp.classes)
324
+ st.classes.add(c);
325
+ let ok = true;
326
+ try {
327
+ await captureInPlace(page, key, opts);
328
+ st.captured++;
329
+ }
330
+ catch {
331
+ st.failed.push(key);
332
+ ok = false;
333
+ }
334
+ opts.onSurface?.(surface, ok);
335
+ }
336
+ /** Click one candidate where the page stands; classify the outcome. */
337
+ async function tryInPlace(page, c) {
338
+ const startUrl = pathAndSearch(page.url());
339
+ const before = (await fingerprint(page)).sig;
340
+ try {
341
+ await perform(page, c);
342
+ }
343
+ catch {
344
+ return 'failed';
345
+ }
346
+ await settleDom(page);
347
+ if (pathAndSearch(page.url()) !== startUrl)
348
+ return 'navigated';
349
+ return (await fingerprint(page)).sig === before ? 'noop' : 'changed';
350
+ }
351
+ /** Drive one candidate from where the page stands. Returns whether the page is
352
+ * still in the swept state (no-op click) and whether the action was a skip. */
353
+ async function driveCandidate(page, opts, entry, c, st) {
354
+ const outcome = await tryInPlace(page, c);
355
+ if (outcome === 'noop')
356
+ return { inState: true, skipped: false }; // still in the state — no reset needed
357
+ if (outcome === 'failed' || outcome === 'navigated')
358
+ return { inState: false, skipped: true };
359
+ // Register the changer for family retry — persistence checked in the state its
360
+ // own click produced (a tab survives its switch; an approve row consumes itself).
361
+ const persists = await page
362
+ .locator(c.selector)
363
+ .first()
364
+ .isVisible()
365
+ .catch(() => false);
366
+ const from = stateKey(entry.path);
367
+ const list = st.changersFrom.get(from) ?? [];
368
+ if (!list.some((x) => x.c.selector === c.selector))
369
+ list.push({ c, persists });
370
+ st.changersFrom.set(from, list);
371
+ const fp = await fingerprint(page);
372
+ if (st.seen.has(fp.sig))
373
+ return { inState: false, skipped: false }; // same surface reached another way
374
+ st.seen.add(fp.sig);
375
+ const step = {
376
+ action: c.action,
377
+ selector: c.selector,
378
+ label: c.label,
379
+ reason: c.reason,
380
+ ...(c.value ? { value: c.value } : {}),
381
+ };
382
+ await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st);
383
+ return { inState: false, skipped: false };
384
+ }
385
+ /**
386
+ * Sweep one state: drive every not-yet-tried control reachable from it, IN PLACE.
387
+ * A no-op click leaves the page in the state, so the sweep just continues; only a
388
+ * state-changing click (new surface, dup, navigation, or failure) pays a verified
389
+ * reset before the next candidate.
390
+ */
391
+ /** The family-retry list for a state: its parent's persistent mode-switchers that
392
+ * are visible right now — minus the step that created this state itself. */
393
+ function familyRetries(entry, all, st) {
394
+ if (entry.path.length === 0)
395
+ return [];
396
+ const parentKey = stateKey(entry.path.slice(0, -1));
397
+ const ownSelector = entry.path[entry.path.length - 1].selector;
398
+ const visibleNow = new Set(all.map((c) => c.selector));
399
+ return (st.changersFrom.get(parentKey) ?? [])
400
+ .filter((x) => x.persists && x.c.selector !== ownSelector && visibleNow.has(x.c.selector))
401
+ .map((x) => x.c);
402
+ }
403
+ async function sweepState(page, opts, entry, st) {
404
+ if (!(await resetToState(page, opts, entry.path, entry.sig)))
405
+ return { tried: 0, skipped: 0 };
406
+ const all = await page.evaluate(collectClickable).catch(() => []);
407
+ // Fresh controls first (already-driven global chrome would otherwise starve a
408
+ // deep surface's own controls; the throttle applies to fresh ones), then the
409
+ // parent's persistent mode-switchers re-tried in THIS sibling mode.
410
+ const fresh = all.filter((c) => !st.tried.has(c.selector)).slice(0, opts.maxActionsPerState);
411
+ const work = [
412
+ ...fresh.map((c) => ({ c, retry: false })),
413
+ ...familyRetries(entry, all, st).map((c) => ({ c, retry: true })),
414
+ ];
415
+ let tried = 0;
416
+ let skipped = 0;
417
+ let inState = true;
418
+ for (const { c, retry } of work) {
419
+ if (st.surfaces.length >= opts.maxStates)
420
+ break;
421
+ if (!inState) {
422
+ if (!(await resetToState(page, opts, entry.path, entry.sig)))
423
+ break; // abandon rest, fail-soft
424
+ inState = true;
425
+ }
426
+ if (!retry)
427
+ st.tried.add(c.selector);
428
+ if (c.unsafe) {
429
+ skipped++;
430
+ continue;
431
+ }
432
+ tried++;
433
+ const r = await driveCandidate(page, opts, entry, c, st);
434
+ inState = r.inState;
435
+ if (r.skipped)
436
+ skipped++;
437
+ }
438
+ return { tried, skipped };
439
+ }
440
+ /** Depth-first discovery + in-place capture of every reachable surface. Depth-first
441
+ * so a surface's OWN sub-states (a modal's tab → its toggles) are mapped while the
442
+ * branch is fresh; with no budget, order affects time-to-depth, not coverage. */
443
+ async function discover(page, opts) {
444
+ fs.mkdirSync(opts.out, { recursive: true });
445
+ await gotoFresh(page, opts);
446
+ const defined = await page.evaluate(collectDefinedClasses).catch(() => []);
447
+ const fp = await fingerprint(page);
448
+ const st = {
449
+ seen: new Set([fp.sig]),
450
+ used: new Set(),
451
+ tried: new Set(),
452
+ changersFrom: new Map(),
453
+ classes: new Set(),
454
+ surfaces: [],
455
+ queue: [],
456
+ captured: 0,
457
+ failed: [],
458
+ };
459
+ await record(page, opts, [], 0, fp, st);
460
+ let actionsTried = 0;
461
+ let skipped = 0;
462
+ while (st.queue.length > 0 && st.surfaces.length < opts.maxStates) {
463
+ const entry = st.queue.pop(); // LIFO → depth-first
464
+ if (entry.depth >= opts.maxDepth)
465
+ continue;
466
+ const r = await sweepState(page, opts, entry, st);
467
+ actionsTried += r.tried;
468
+ skipped += r.skipped;
469
+ }
470
+ const missing = defined.filter((c) => !st.classes.has(c)).sort();
471
+ return {
472
+ surfaces: st.surfaces,
473
+ actionsTried,
474
+ skipped,
475
+ captured: st.captured,
476
+ failed: st.failed,
477
+ coverage: { defined: defined.length, rendered: defined.length - missing.length, missing },
478
+ };
479
+ }
480
+ /**
481
+ * Crawl `opts.url` and capture every reachable surface at every width — runs to
482
+ * natural termination by default. Returns the surfaces mapped (with the click-path
483
+ * that reached each), how many actions were tried/skipped, and captured/failed.
484
+ */
485
+ export async function crawlAndCapture(page, opts) {
486
+ if (opts.resetStorage) {
487
+ // Clear storage before the app's code runs on EVERY load, so each gotoFresh is
488
+ // a clean slate in one navigation (no clear-then-reload round trip).
489
+ await page.addInitScript(() => {
490
+ try {
491
+ localStorage.clear();
492
+ sessionStorage.clear();
493
+ }
494
+ catch {
495
+ /* storage unavailable (e.g. file://) — ignore */
496
+ }
497
+ });
498
+ }
499
+ return discover(page, opts);
500
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
+ export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
3
+ export type { CaptureUrlOptions, CaptureUrlResult } from './capture-url.js';
4
+ export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
5
+ export type { SurfaceCrawlOptions, CrawlReport, CrawlCoverage, CrawledSurface, CrawlStep } from './crawl-surfaces.js';
2
6
  export type { StyleMap, CaptureOptions, CaptureMetadata, ElementEntry, LiveRegionCandidate, CapturedOverlay, Rect, } from './capture.js';
3
7
  export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
4
8
  export type { Surface, SurfaceLiveState, SurfaceVariant, PopupCaptureOptions, DefineOptions, CrawlOptions, } from './runner.js';
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
+ export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
3
+ export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
2
4
  export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
3
5
  export { coverageGaps } from './coverage.js';
4
6
  export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
package/dist/map-store.js CHANGED
@@ -10,6 +10,7 @@ export const DEFAULT_MAP_LABEL = 'current';
10
10
  export const DEFAULT_MAP_STORE_BRANCH = 'styleproof-maps';
11
11
  export const DEFAULT_REMOTE = 'origin';
12
12
  export const MAP_MANIFEST = 'styleproof-manifest.json';
13
+ const GENERATED_DIRTY_ALLOWLIST = new Set(['next-env.d.ts']);
13
14
  export class MapStoreError extends Error {
14
15
  }
15
16
  function runGit(cwd, args, maxBuffer = 1 << 28) {
@@ -112,7 +113,14 @@ export function refSha(ref, cwd = process.cwd()) {
112
113
  return sha;
113
114
  }
114
115
  export function workingTreeDirty(cwd = process.cwd()) {
115
- return gitOutput(cwd, ['status', '--porcelain']) !== '';
116
+ const r = runGit(cwd, ['status', '--porcelain']);
117
+ const status = r.status === 0 ? r.stdout.trimEnd() : '';
118
+ if (!status)
119
+ return false;
120
+ return status.split(/\r?\n/).some((line) => {
121
+ const file = line.slice(3).trim();
122
+ return file && !GENERATED_DIRTY_ALLOWLIST.has(file);
123
+ });
116
124
  }
117
125
  export function remoteExists(remote = DEFAULT_REMOTE, cwd = process.cwd()) {
118
126
  return runGit(cwd, ['remote', 'get-url', remote], 1 << 20).status === 0;
package/dist/report.js CHANGED
@@ -8,8 +8,7 @@ import { describeChange, tokenIndex, toHex, trackCount } from './describe.js';
8
8
  // through the package's report module rather than a deep path.
9
9
  export { describeChange, colorName, tokenIndex, toHex } from './describe.js';
10
10
  // Hidden marker appended to a new-surface heading. Invisible in rendered
11
- // markdown; lets the PR-comment layer attach an OPTIONAL "approve" box to a new
12
- // surface (vs the required box on a real change), so new surfaces never gate.
11
+ // markdown; lets the PR-comment layer recognize one-sided surfaces.
13
12
  const NEW_SURFACE_MARKER = '<!-- styleproof-new -->';
14
13
  const rectToBox = (r) => ({ x: r[0], y: r[1], w: r[2], h: r[3] });
15
14
  const pad = (b, by) => ({ x: b.x - by, y: b.y - by, w: b.w + 2 * by, h: b.h + 2 * by });
@@ -910,8 +909,8 @@ export function generateStyleMapReport(opts) {
910
909
  if (missing.length > 0) {
911
910
  if (changeGroups.length > 0)
912
911
  md.push('');
913
- md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare — shown below for reference. ` +
914
- `New surfaces don't block the check.`);
912
+ md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare — shown below for review. ` +
913
+ `Approve them before they become the baseline.`);
915
914
  }
916
915
  }
917
916
  if (volatileCount > 0) {
@@ -1037,8 +1036,7 @@ export function generateStyleMapReport(opts) {
1037
1036
  json.push(surfaceJson);
1038
1037
  }
1039
1038
  // New surfaces: present on only one side, so there's nothing to diff. Show the
1040
- // captured side as a single screenshot for reference and mark the heading so the
1041
- // PR comment can attach an OPTIONAL approval box — these never gate the check.
1039
+ // captured side as a single screenshot and mark the heading for the PR comment.
1042
1040
  for (const p of missing) {
1043
1041
  const side = p.sd.missing === 'before' ? 'after' : 'before';
1044
1042
  const srcDir = side === 'after' ? afterDir : beforeDir;
@@ -1058,7 +1056,7 @@ export function generateStyleMapReport(opts) {
1058
1056
  else {
1059
1057
  md.push('', `_Captured only in the **${side}** set; no screenshot saved (run captures with \`screenshots: true\`)._`);
1060
1058
  }
1061
- md.push('', `_No baseline to compare against — this surface is new, so it doesn't block the check. It becomes part of the baseline once this merges._`);
1059
+ md.push('', `_No baseline to compare against — this surface is new. Review and approve it before it becomes part of the baseline._`);
1062
1060
  json.push(surfaceJson);
1063
1061
  }
1064
1062
  md.push(...contentSection.md);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.1.5",
3
+ "version": "3.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",
@@ -38,6 +38,7 @@
38
38
  "bin": {
39
39
  "styleproof-init": "./bin/styleproof-init.mjs",
40
40
  "styleproof-map": "./bin/styleproof-map.mjs",
41
+ "styleproof-capture": "./bin/styleproof-capture.mjs",
41
42
  "styleproof-diff": "./bin/styleproof-diff.mjs",
42
43
  "styleproof-report": "./bin/styleproof-report.mjs",
43
44
  "styleproof-variants": "./bin/styleproof-variants.mjs"