styleproof 3.1.2 → 3.1.3

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/dist/report.js CHANGED
@@ -20,6 +20,13 @@ const union = (a, b) => {
20
20
  };
21
21
  const intersects = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
22
22
  const visible = (b) => !!b && b.w > 0 && b.h > 0;
23
+ /** Bounding box that encloses every rect (the changed-element footprint). */
24
+ function unionRects(rects) {
25
+ const boxes = rects.map(rectToBox).filter(visible);
26
+ if (!boxes.length)
27
+ return null;
28
+ return boxes.reduce(union);
29
+ }
23
30
  /** Outermost changed paths: drop any path that has a changed strict ancestor.
24
31
  * Used to ANCHOR a crop (zoom to the whole changed region, not a leaf). */
25
32
  function outermost(paths) {
@@ -161,6 +168,38 @@ function compositePair(before, after) {
161
168
  fillRect(canvas, PAD + w + GAP / 2 - 1, PAD, 2, h, [48, 54, 61]); // divider
162
169
  return canvas;
163
170
  }
171
+ // Integer nearest-neighbor upscale. Nearest-neighbor (not smoothing) so the
172
+ // zoom invents no colours that weren't captured — a magnified crop is still a
173
+ // faithful pixel-for-pixel view, just bigger.
174
+ function scalePng(src, s) {
175
+ if (s <= 1)
176
+ return src;
177
+ const out = new PNG({ width: src.width * s, height: src.height * s });
178
+ for (let y = 0; y < out.height; y++) {
179
+ const sy = Math.floor(y / s);
180
+ for (let x = 0; x < out.width; x++) {
181
+ const si = (sy * src.width + Math.floor(x / s)) << 2;
182
+ const oi = (y * out.width + x) << 2;
183
+ out.data[oi] = src.data[si];
184
+ out.data[oi + 1] = src.data[si + 1];
185
+ out.data[oi + 2] = src.data[si + 2];
186
+ out.data[oi + 3] = src.data[si + 3];
187
+ }
188
+ }
189
+ return out;
190
+ }
191
+ // A magnified crop centered on the changed box, for changes too small to see at
192
+ // 1:1 (e.g. a 2px font bump on a caret). Crops the tight context box, upscales by
193
+ // an integer factor, then outlines the changes (stroke scaled to stay visible).
194
+ function zoomCrop(src, box, rects, factor) {
195
+ const crop = cropPng(src, box, box.w, box.h);
196
+ const scaled = scalePng(crop.png, factor);
197
+ const t = Math.max(2, factor);
198
+ for (const [rx, ry, rw, rh] of rects) {
199
+ strokeRect(scaled, (rx - crop.ox) * factor, (ry - crop.oy) * factor, rw * factor, rh * factor, t);
200
+ }
201
+ return scaled;
202
+ }
164
203
  function readPng(file) {
165
204
  if (!fs.existsSync(file))
166
205
  return null;
@@ -378,9 +417,11 @@ function surfaceContext(...maps) {
378
417
  const metadata = maps.find((m) => m?.metadata)?.metadata;
379
418
  if (!metadata?.variantKey)
380
419
  return '';
381
- return metadata.variantKind === 'live-state'
382
- ? `live state \`${metadata.variantKey}\``
383
- : `variant \`${metadata.variantKey}\``;
420
+ if (metadata.variantKind === 'live-state')
421
+ return `live state \`${metadata.variantKey}\``;
422
+ if (metadata.variantKind === 'popup')
423
+ return `popup \`${metadata.variantKey}\``;
424
+ return `variant \`${metadata.variantKey}\``;
384
425
  }
385
426
  function formatSurfaceWithContext(surface, ...maps) {
386
427
  const context = surfaceContext(...maps);
@@ -401,7 +442,9 @@ function liveCandidateLabel(candidate) {
401
442
  return `${label} (${candidate.reason})`;
402
443
  }
403
444
  function captureFiles(dir) {
404
- return fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /\.json(\.gz)?$/.test(f)) : [];
445
+ return fs.existsSync(dir)
446
+ ? fs.readdirSync(dir).filter((f) => f !== 'styleproof-manifest.json' && /\.json(\.gz)?$/.test(f))
447
+ : [];
405
448
  }
406
449
  function collectLiveCandidateLabels(beforeDir, afterDir) {
407
450
  const seen = new Set();
@@ -791,7 +834,7 @@ export function generateStyleMapReport(opts) {
791
834
  const { beforeDir, afterDir, outDir, imageBaseUrl = '',
792
835
  // Tighter than before (was 24) so the change fills the frame — the annotation
793
836
  // box keeps enough context legible.
794
- pad: padBy = 12, minWidth = 320, minHeight = 180, maxHeight = 1600,
837
+ pad: padBy = 12, minWidth = 320, minHeight = 180, maxHeight = 1600, zoomBelow = 64,
795
838
  // More, smaller crops before collapsing (was 6), so distinct changes get their
796
839
  // own focused frame rather than one wide merged one.
797
840
  maxCrops = 8, foldDetailsAt = 0, } = opts;
@@ -947,10 +990,37 @@ export function generateStyleMapReport(opts) {
947
990
  const annotated = compositePair(annotateCrop(before, rectsA), annotateCrop(after, rectsB));
948
991
  writePng(path.join(outDir, `${stem}-annotated.png`), annotated);
949
992
  images = { composite: `${stem}-composite.png`, annotated: `${stem}-annotated.png` };
950
- // Clean before|after shown by default (the real UI); the annotated twin
951
- // boxes marking each change — sits one click away under a toggle. Plain
952
- // images (no link wrap) so a click opens the full-resolution file.
953
- md.push('', `![before ◀ │ ▶ after](${img(images.composite)})`, '', `<sub>◀ before · after ▶ — ${formatSurfaceWithContext(sd.surface, mapA, mapB)}</sub>`, '', '<details>', '<summary>🔍 Highlight what changed</summary>', '', `![annotated before ◀ │ ▶ after](${img(images.annotated)})`, '', `<sub>magenta boxes mark each change — ${formatSurfaceWithContext(sd.surface, mapA, mapB)}</sub>`, '', '</details>');
993
+ // Name the changed element(s) so the reviewer knows where to look without
994
+ // expanding anything (e.g. `changed: span.caret`).
995
+ const changedNames = [
996
+ ...new Set(markPaths
997
+ .map((p) => mapA.elements[p] ?? mapB.elements[p])
998
+ .filter((e) => !!e)
999
+ .map((e) => (e.cls ? `${e.tag}.${e.cls.split(/\s+/)[0]}` : e.tag))),
1000
+ ].slice(0, 3);
1001
+ const changedLabel = changedNames.length ? ` — changed: \`${changedNames.join('`, `')}\`` : '';
1002
+ const ctxLabel = formatSurfaceWithContext(sd.surface, mapA, mapB);
1003
+ // A sub-pixel change (e.g. a 2px font bump on a caret) is invisible at 1:1,
1004
+ // so when the changed-element footprint is small, add a magnified crop that
1005
+ // makes it obvious without the reviewer hunting. Anchored on the leaf rects.
1006
+ const changed = unionRects([...rectsA, ...rectsB]);
1007
+ const maxDim = changed ? Math.max(changed.w, changed.h) : 0;
1008
+ let zoomFactor = 0;
1009
+ if (zoomBelow > 0 && changed && maxDim > 0 && maxDim <= zoomBelow) {
1010
+ const zBox = pad(changed, Math.max(maxDim, 16)); // ~3× the change for context
1011
+ zoomFactor = Math.min(8, Math.max(2, Math.round(240 / Math.max(zBox.w, zBox.h))));
1012
+ const zoom = compositePair(zoomCrop(pngA, zBox, rectsA, zoomFactor), zoomCrop(pngB, zBox, rectsB, zoomFactor));
1013
+ writePng(path.join(outDir, `${stem}-zoom.png`), zoom);
1014
+ images.zoom = `${stem}-zoom.png`;
1015
+ }
1016
+ // Both views shown by default: the clean before|after (the real UI) and the
1017
+ // highlighted twin (magenta boxes on each change) so a reviewer sees WHAT
1018
+ // changed and WHERE without expanding anything. Plain images (no link wrap)
1019
+ // so a click opens the full-resolution file.
1020
+ md.push('', `![before ◀ │ ▶ after](${img(images.composite)})`, '', `<sub>◀ before · after ▶ — ${ctxLabel}</sub>`, '', `![highlighted before ◀ │ ▶ after](${img(images.annotated)})`, '', `<sub>🔍 magenta boxes mark each change${changedLabel}</sub>`);
1021
+ if (images.zoom) {
1022
+ md.push('', `![zoomed before ◀ │ ▶ after](${img(images.zoom)})`, '', `<sub>🔬 magnified ${zoomFactor}× — change too small to see at 1:1${changedLabel}</sub>`);
1023
+ }
954
1024
  }
955
1025
  else if (!region) {
956
1026
  md.push('', '_Changed element is not visible in this state (zero-size box) — see the property list._');
package/dist/runner.d.ts CHANGED
@@ -40,6 +40,12 @@ export type Surface = {
40
40
  * variants so reports and diagnostics can explain why the capture was split.
41
41
  */
42
42
  liveStates?: SurfaceLiveState[];
43
+ /**
44
+ * Opt in to automatically opening visible click-triggered popups after the base
45
+ * surface is captured. Captures persistent dialogs, popovers, menus, listboxes,
46
+ * tooltips, and open data-state overlays as `<surface>-popup-XX`.
47
+ */
48
+ popups?: boolean | PopupCaptureOptions;
43
49
  };
44
50
  export type SurfaceVariant = {
45
51
  /** Capture key suffix, joined as `<surface.key>-<variant.key>`. */
@@ -59,6 +65,18 @@ export type SurfaceVariant = {
59
65
  height?: number | ((width: number) => number);
60
66
  };
61
67
  export type SurfaceLiveState = SurfaceVariant;
68
+ export type PopupCaptureOptions = {
69
+ /** Enable/disable popup discovery for this surface or capture run. */
70
+ enabled?: boolean;
71
+ /** Max visible trigger controls to try per surface/width (default 20). */
72
+ max?: number;
73
+ /** CSS selector for visible controls to click. */
74
+ triggers?: string;
75
+ /** CSS selector for visible popup/overlay roots that mean a click opened state. */
76
+ overlays?: string;
77
+ /** Max ms to wait for a clicked control to reveal an overlay (default 750). */
78
+ timeoutMs?: number;
79
+ };
62
80
  export type DefineOptions = {
63
81
  surfaces: Surface[];
64
82
  /**
@@ -143,8 +161,15 @@ export type DefineOptions = {
143
161
  * Advisory only — never gates. See `CaptureOptions.captureComponent`.
144
162
  */
145
163
  captureComponent?: boolean;
164
+ /**
165
+ * Opt-in automatic popup/modal capture for every surface. Existing suites keep
166
+ * their exact capture set unless this is enabled.
167
+ */
168
+ popups?: boolean | PopupCaptureOptions;
146
169
  };
147
170
  export declare function selfCheckErrorMessage(surfaceKey: string, drift: Finding[], volatile?: string[], liveCandidates?: LiveRegionCandidate[]): string;
171
+ type ResolvedPopupCaptureOptions = Required<PopupCaptureOptions>;
172
+ export declare function resolvePopupCaptureOptions(input: boolean | PopupCaptureOptions | undefined): ResolvedPopupCaptureOptions;
148
173
  type ExpandedSurface = Omit<Surface, 'variants' | 'liveStates'> & {
149
174
  metadata?: CaptureMetadata;
150
175
  };
@@ -174,15 +199,14 @@ export declare function passLiveStreams(page: Page, url: string): Promise<void>;
174
199
  export declare function defaultSelfCheck(replayFrom: string | undefined, env?: string | undefined): boolean;
175
200
  /**
176
201
  * Output base dir: explicit `baseDir` wins, then `STYLEPROOF_BASEDIR`, then the
177
- * default. Lets a pre-push hook redirect capture into a COMMITTED dir (so main
178
- * always carries a base map and CI just diffs precomputed maps) without editing
179
- * the spec — same env-wiring philosophy as `STYLEPROOF_REPLAY_*`.
202
+ * default. Lets CLIs and CI redirect capture into cache/fallback dirs without
203
+ * editing the spec same env-wiring philosophy as `STYLEPROOF_REPLAY_*`.
180
204
  */
181
205
  export declare function resolveBaseDir(baseDir: string | undefined, env?: string | undefined): string;
182
206
  /**
183
207
  * Whether to save full-page screenshots: explicit `screenshots` wins, else
184
- * `STYLEPROOF_SCREENSHOTS=0` turns them off for committed maps you want the lean
185
- * `.json.gz` only, never PNGs in git history. On by default otherwise.
208
+ * `STYLEPROOF_SCREENSHOTS=0` turns them off. On by default so restored map bundles
209
+ * can generate reviewable reports without recapturing.
186
210
  */
187
211
  export declare function resolveScreenshots(screenshots: boolean | undefined, env?: string | undefined): boolean;
188
212
  /** The capture settings every capturer shares (everything bar the surface set). */
@@ -222,6 +246,8 @@ export type CrawlOptions = CaptureConfig & {
222
246
  variants?: SurfaceVariant[];
223
247
  /** First-class live product states captured for every discovered link surface. */
224
248
  liveStates?: SurfaceLiveState[];
249
+ /** Opt-in automatic popup/modal capture for every discovered link surface. */
250
+ popups?: boolean | PopupCaptureOptions;
225
251
  /** Max ms to wait for the crawl root's links to render before reading them
226
252
  * (an SPA hydrates its nav client-side). Default 15000. */
227
253
  linkTimeout?: number;
package/dist/runner.js CHANGED
@@ -56,6 +56,107 @@ function mergeIgnore(...groups) {
56
56
  const merged = [...new Set(groups.flatMap((g) => g ?? []))];
57
57
  return merged.length ? merged : undefined;
58
58
  }
59
+ const POPUP_TRIGGER_ATTR = 'data-styleproof-popup-trigger';
60
+ const DEFAULT_POPUP_TRIGGERS = [
61
+ 'button:not([disabled]):not([type="submit"]):not([type="reset"])',
62
+ '[role="button"]:not([aria-disabled="true"])',
63
+ '[aria-haspopup]',
64
+ '[popovertarget]',
65
+ 'summary',
66
+ 'a[href^="#"]',
67
+ ].join(', ');
68
+ const DEFAULT_POPUP_OVERLAYS = [
69
+ 'dialog[open]',
70
+ '[popover]',
71
+ '[role="dialog"]',
72
+ '[role="alertdialog"]',
73
+ '[role="menu"]',
74
+ '[role="listbox"]',
75
+ '[role="tooltip"]',
76
+ '[data-state="open"]:not(button):not(a):not(summary)',
77
+ ].join(', ');
78
+ export function resolvePopupCaptureOptions(input) {
79
+ if (input === false || input === undefined) {
80
+ return {
81
+ enabled: false,
82
+ max: 20,
83
+ triggers: DEFAULT_POPUP_TRIGGERS,
84
+ overlays: DEFAULT_POPUP_OVERLAYS,
85
+ timeoutMs: 750,
86
+ };
87
+ }
88
+ const options = input === true ? {} : input;
89
+ return {
90
+ enabled: options.enabled ?? true,
91
+ max: Math.max(0, Math.floor(options.max ?? 20)),
92
+ triggers: options.triggers ?? DEFAULT_POPUP_TRIGGERS,
93
+ overlays: options.overlays ?? DEFAULT_POPUP_OVERLAYS,
94
+ timeoutMs: Math.max(0, Math.floor(options.timeoutMs ?? 750)),
95
+ };
96
+ }
97
+ async function popupDomSnapshot(page, options) {
98
+ return page.evaluate(({ popupSelector, triggerSelector, attr, max = 0 }) => {
99
+ const qsa = (sel) => {
100
+ try {
101
+ return [...document.querySelectorAll(sel)];
102
+ }
103
+ catch {
104
+ return [];
105
+ }
106
+ };
107
+ const visible = (el) => {
108
+ const rect = el.getBoundingClientRect();
109
+ const style = getComputedStyle(el);
110
+ return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
111
+ };
112
+ const pathOf = (el) => {
113
+ const parts = [];
114
+ let cur = el;
115
+ while (cur && cur !== document.documentElement) {
116
+ const parent = cur.parentElement;
117
+ const tag = cur.tagName.toLowerCase();
118
+ const id = cur.id ? `#${cur.id}` : '';
119
+ const role = cur.getAttribute('role');
120
+ const sameTag = parent ? [...parent.children].filter((child) => child.tagName === cur.tagName) : [cur];
121
+ const nth = sameTag.length > 1 ? `:nth-of-type(${sameTag.indexOf(cur) + 1})` : '';
122
+ parts.unshift(`${tag}${id}${role ? `[role="${role}"]` : ''}${nth}`);
123
+ cur = cur.parentElement;
124
+ }
125
+ return parts.join(' > ');
126
+ };
127
+ const popups = qsa(popupSelector).filter(visible);
128
+ const keys = popups.map(pathOf);
129
+ if (!triggerSelector || !attr)
130
+ return { keys, indexes: [] };
131
+ const safeTrigger = (el) => {
132
+ const tag = el.tagName.toLowerCase();
133
+ if (el.matches(':disabled, [aria-disabled="true"]'))
134
+ return false;
135
+ if (tag === 'input' || tag === 'select' || tag === 'textarea')
136
+ return false;
137
+ if (tag === 'a')
138
+ return el.getAttribute('href')?.startsWith('#') ?? false;
139
+ return true;
140
+ };
141
+ for (const el of qsa(`[${attr}]`))
142
+ el.removeAttribute(attr);
143
+ const candidates = qsa(triggerSelector).filter((el) => visible(el) && !popups.some((popup) => popup !== el && popup.contains(el)) && safeTrigger(el));
144
+ candidates.slice(0, max).forEach((el, index) => el.setAttribute(attr, String(index)));
145
+ return { keys, indexes: candidates.slice(0, max).map((_, index) => index) };
146
+ }, options);
147
+ }
148
+ async function visiblePopupKeys(page, selector) {
149
+ return (await popupDomSnapshot(page, { popupSelector: selector })).keys;
150
+ }
151
+ async function markPopupCandidates(page, options) {
152
+ const snapshot = await popupDomSnapshot(page, {
153
+ triggerSelector: options.triggers,
154
+ popupSelector: options.overlays,
155
+ attr: POPUP_TRIGGER_ATTR,
156
+ max: options.max,
157
+ });
158
+ return snapshot.indexes.map((index) => ({ index }));
159
+ }
59
160
  function expandOne(surface, variant, variantKind) {
60
161
  return {
61
162
  key: `${surface.key}-${variant.key}`,
@@ -67,6 +168,7 @@ function expandOne(surface, variant, variantKind) {
67
168
  ignore: mergeIgnore(surface.ignore, variant.ignore),
68
169
  widths: variant.widths ?? surface.widths,
69
170
  height: variant.height ?? surface.height,
171
+ popups: surface.popups,
70
172
  metadata: { surfaceKey: surface.key, variantKey: variant.key, variantKind },
71
173
  };
72
174
  }
@@ -152,6 +254,85 @@ async function assertDeterministic(page, surface, first, captureText, pending) {
152
254
  throw new Error(selfCheckErrorMessage(surface.key, drift, [...new Set([...(first.volatile ?? []), ...(again.volatile ?? [])])], liveCandidates));
153
255
  }
154
256
  }
257
+ async function openPopupCandidate(page, surface, width, height, options, candidate) {
258
+ await page.setViewportSize({ width, height });
259
+ await page.keyboard.press('Escape').catch(() => { });
260
+ await surface.go(page);
261
+ const before = new Set(await visiblePopupKeys(page, options.overlays));
262
+ const candidates = await markPopupCandidates(page, options);
263
+ if (!candidates.some((c) => c.index === candidate.index))
264
+ return undefined;
265
+ await page
266
+ .locator(`[${POPUP_TRIGGER_ATTR}="${candidate.index}"]`)
267
+ .first()
268
+ .click({ timeout: Math.max(500, options.timeoutMs), noWaitAfter: true })
269
+ .catch(() => undefined);
270
+ const deadline = Date.now() + options.timeoutMs;
271
+ do {
272
+ const opened = (await visiblePopupKeys(page, options.overlays)).find((key) => !before.has(key));
273
+ if (opened)
274
+ return opened;
275
+ await page.waitForTimeout(50);
276
+ } while (Date.now() < deadline);
277
+ return undefined;
278
+ }
279
+ function popupMetadata(surface, popupId) {
280
+ return {
281
+ surfaceKey: surface.metadata?.surfaceKey ?? surface.key,
282
+ variantKey: surface.metadata?.variantKey ? `${surface.metadata.variantKey}/${popupId}` : popupId,
283
+ variantKind: 'popup',
284
+ };
285
+ }
286
+ async function captureOpenedPopupMap(page, surface, s, pending, popupId) {
287
+ return captureStyleMap(page, {
288
+ ignore: surface.ignore ?? [],
289
+ captureText: s.captureText,
290
+ captureComponent: s.captureComponent,
291
+ pendingRequests: pending,
292
+ metadata: popupMetadata(surface, popupId),
293
+ });
294
+ }
295
+ async function assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, first, s, pending) {
296
+ const againKey = await openPopupCandidate(page, surface, width, height, options, candidate);
297
+ if (!againKey)
298
+ throw new Error(`styleproof self-check failed: ${surface.key}-${popupId} popup did not reopen`);
299
+ const again = await captureOpenedPopupMap(page, surface, s, pending, popupId);
300
+ const drift = diffStyleMaps(first, again);
301
+ if (drift.length) {
302
+ throw new Error(selfCheckErrorMessage(`${surface.key}-${popupId}`, drift, first.volatile, first.liveCandidates));
303
+ }
304
+ }
305
+ async function capturePopupCandidate(page, surface, width, height, s, options, candidate, seen) {
306
+ const requests = trackInflightRequests(page);
307
+ try {
308
+ const popupKey = await openPopupCandidate(page, surface, width, height, options, candidate);
309
+ if (!popupKey || seen.has(popupKey))
310
+ return;
311
+ seen.add(popupKey);
312
+ const popupId = `popup-${String(candidate.index + 1).padStart(2, '0')}`;
313
+ const map = await captureOpenedPopupMap(page, surface, s, requests.pending, popupId);
314
+ if (s.selfCheck)
315
+ await assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, map, s, requests.pending);
316
+ const stem = path.join(s.baseDir, s.dir, `${surface.key}-${popupId}@${width}`);
317
+ saveStyleMap(`${stem}.json.gz`, map);
318
+ if (s.screenshots)
319
+ await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
320
+ }
321
+ finally {
322
+ requests.dispose();
323
+ }
324
+ }
325
+ async function capturePopupSurfaces(page, surface, width, height, s) {
326
+ const options = resolvePopupCaptureOptions(surface.popups ?? s.popups);
327
+ if (!options.enabled || options.max === 0)
328
+ return;
329
+ await surface.go(page);
330
+ const candidates = await markPopupCandidates(page, options);
331
+ const seen = new Set();
332
+ for (const candidate of candidates) {
333
+ await capturePopupCandidate(page, surface, width, height, s, options, candidate, seen);
334
+ }
335
+ }
155
336
  /** Drive one surface at one width to a settled state and save its style map (+ screenshot).
156
337
  * The caller owns the test timeout (one-per-test for explicit surfaces, one budget for
157
338
  * the whole crawl) so a multi-surface crawl can't reset its own deadline mid-loop. */
@@ -181,6 +362,7 @@ async function captureSurface(page, surface, width, s) {
181
362
  // state the map describes.
182
363
  await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
183
364
  }
365
+ await capturePopupSurfaces(page, surface, width, height, s);
184
366
  }
185
367
  finally {
186
368
  requests.dispose();
@@ -198,17 +380,16 @@ export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCH
198
380
  }
199
381
  /**
200
382
  * Output base dir: explicit `baseDir` wins, then `STYLEPROOF_BASEDIR`, then the
201
- * default. Lets a pre-push hook redirect capture into a COMMITTED dir (so main
202
- * always carries a base map and CI just diffs precomputed maps) without editing
203
- * the spec — same env-wiring philosophy as `STYLEPROOF_REPLAY_*`.
383
+ * default. Lets CLIs and CI redirect capture into cache/fallback dirs without
384
+ * editing the spec same env-wiring philosophy as `STYLEPROOF_REPLAY_*`.
204
385
  */
205
386
  export function resolveBaseDir(baseDir, env = process.env.STYLEPROOF_BASEDIR) {
206
387
  return baseDir ?? env ?? '__stylemaps__';
207
388
  }
208
389
  /**
209
390
  * Whether to save full-page screenshots: explicit `screenshots` wins, else
210
- * `STYLEPROOF_SCREENSHOTS=0` turns them off for committed maps you want the lean
211
- * `.json.gz` only, never PNGs in git history. On by default otherwise.
391
+ * `STYLEPROOF_SCREENSHOTS=0` turns them off. On by default so restored map bundles
392
+ * can generate reviewable reports without recapturing.
212
393
  */
213
394
  export function resolveScreenshots(screenshots, env = process.env.STYLEPROOF_SCREENSHOTS) {
214
395
  return screenshots ?? env !== '0';
@@ -232,6 +413,7 @@ function resolveSettings(c) {
232
413
  selfCheck: c.selfCheck ?? defaultSelfCheck(replayFrom),
233
414
  captureText: c.captureText ?? false,
234
415
  captureComponent: c.captureComponent ?? false,
416
+ popups: resolvePopupCaptureOptions(c.popups),
235
417
  };
236
418
  }
237
419
  /**
@@ -314,7 +496,7 @@ export function defineStyleMapCapture(options) {
314
496
  * ```
315
497
  */
316
498
  export function defineCrawlCapture(options) {
317
- const { from, match, key, widths, height, ignore, variants, liveStates, linkTimeout = 15_000, dir } = options;
499
+ const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir } = options;
318
500
  const settings = resolveSettings(options);
319
501
  test.describe('styleproof crawl-capture', () => {
320
502
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
@@ -339,6 +521,7 @@ export function defineCrawlCapture(options) {
339
521
  height,
340
522
  variants,
341
523
  liveStates,
524
+ popups,
342
525
  }));
343
526
  // Budget the whole sweep up front: one test captures every surface, and
344
527
  // captureSurface no longer sets its own timeout, so size it to the work found.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.1.2",
3
+ "version": "3.1.3",
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",
@@ -58,12 +58,14 @@
58
58
  "typecheck": "tsc --noEmit",
59
59
  "lint": "eslint .",
60
60
  "lint:fix": "eslint . --fix",
61
- "format": "prettier --write \"{src,bin,test,example}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
62
- "format:check": "prettier --check \"{src,bin,test,example}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
61
+ "format": "prettier --write \"{src,bin,test,example,scripts}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
62
+ "format:check": "prettier --check \"{src,bin,test,example,scripts}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
63
63
  "test": "npm run build && node --test test/*.test.mjs",
64
64
  "test:watch": "node --test --watch test/*.test.mjs",
65
65
  "test:e2e": "npm run build && playwright test",
66
66
  "bench": "npm run build && node bench/gate-speed.mjs",
67
+ "demo:report": "npm run build && node scripts/demo-report.mjs",
68
+ "demo:check": "npm run build && node scripts/demo-report.mjs --check",
67
69
  "init": "node ./bin/styleproof-init.mjs",
68
70
  "prepare": "npm run build && husky",
69
71
  "prepublishOnly": "npm run clean && npm run build && npm run typecheck && npm run lint && npm run format:check && npm test && npm run test:e2e"
@@ -1,15 +0,0 @@
1
- export interface BaseRefCaptureDirs {
2
- beforeDir: string;
3
- afterDir: string;
4
- baseRef: string;
5
- mapsDir: string;
6
- tmpBase: string;
7
- }
8
- export declare function resolveBaseRefCaptureDirs(options: {
9
- command: string;
10
- baseRef: string | null;
11
- mapsDir: string;
12
- args: string[];
13
- usage: string;
14
- }): BaseRefCaptureDirs;
15
- export declare function cleanupBaseRefCaptureDirs(captureDirs: BaseRefCaptureDirs | null): void;
@@ -1,53 +0,0 @@
1
- import fs from 'node:fs';
2
- import { inferBaseRef, materializeRef, GitRefError } from './gitref.js';
3
- import { baseInferenceMessage, baseMapsMessage, missingWorkingMapsMessage } from './cli-errors.js';
4
- function errorMessage(error) {
5
- return error instanceof Error ? error.message : String(error);
6
- }
7
- function exitUsage(usage) {
8
- throw new Error(usage);
9
- }
10
- function inferBaseRefOrExit(command) {
11
- try {
12
- return inferBaseRef();
13
- }
14
- catch (e) {
15
- throw new Error(e instanceof GitRefError ? baseInferenceMessage(command, e.message) : errorMessage(e), {
16
- cause: e,
17
- });
18
- }
19
- }
20
- function selectBaseRef(options) {
21
- const { command, args, usage } = options;
22
- if (options.baseRef) {
23
- if (args.length > 1)
24
- exitUsage(usage);
25
- return { baseRef: options.baseRef, mapsDir: args[0] ?? options.mapsDir };
26
- }
27
- if (args.length === 1)
28
- return { baseRef: args[0], mapsDir: options.mapsDir };
29
- return { baseRef: inferBaseRefOrExit(command), mapsDir: options.mapsDir };
30
- }
31
- function materializeBaseRefOrExit(command, baseRef, mapsDir) {
32
- try {
33
- return materializeRef(baseRef, mapsDir);
34
- }
35
- catch (e) {
36
- throw new Error(e instanceof GitRefError ? baseMapsMessage(command, e.message, baseRef, mapsDir) : errorMessage(e), {
37
- cause: e,
38
- });
39
- }
40
- }
41
- export function resolveBaseRefCaptureDirs(options) {
42
- const { command } = options;
43
- const { baseRef, mapsDir } = selectBaseRef(options);
44
- if (!fs.existsSync(mapsDir)) {
45
- throw new Error(missingWorkingMapsMessage(command, mapsDir));
46
- }
47
- const tmpBase = materializeBaseRefOrExit(command, baseRef, mapsDir);
48
- return { beforeDir: tmpBase, afterDir: mapsDir, baseRef, mapsDir, tmpBase };
49
- }
50
- export function cleanupBaseRefCaptureDirs(captureDirs) {
51
- if (captureDirs)
52
- fs.rmSync(captureDirs.tmpBase, { recursive: true, force: true });
53
- }