styleproof 3.1.4 → 3.1.5

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/capture.js CHANGED
@@ -283,6 +283,75 @@ function detectLiveCandidates({ ignore }) {
283
283
  : [];
284
284
  });
285
285
  }
286
+ function detectOverlayCandidates({ ignore }) {
287
+ const pathOf = window.__spPathOf;
288
+ const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
289
+ const visible = (el) => {
290
+ if (el.hidden || el.getAttribute('aria-hidden') === 'true')
291
+ return false;
292
+ const rect = el.getBoundingClientRect();
293
+ const style = getComputedStyle(el);
294
+ return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
295
+ };
296
+ const textOf = (el) => (el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120);
297
+ const toastish = (el) => {
298
+ const haystack = [
299
+ el.id,
300
+ el.getAttribute('class'),
301
+ el.getAttribute('data-testid'),
302
+ el.getAttribute('data-hot-toast'),
303
+ el.getAttribute('data-sonner-toast'),
304
+ el.getAttribute('data-toast'),
305
+ ]
306
+ .filter(Boolean)
307
+ .join(' ')
308
+ .toLowerCase();
309
+ return /\b(toast|hot-toast|sonner)\b/.test(haystack);
310
+ };
311
+ const reasonsFor = (el, role, ariaModal) => {
312
+ const reasons = [];
313
+ if (el instanceof HTMLDialogElement && el.open)
314
+ reasons.push('dialog[open]');
315
+ if (el.hasAttribute('popover'))
316
+ reasons.push('popover');
317
+ if (['dialog', 'alertdialog', 'menu', 'listbox', 'tooltip'].includes(role))
318
+ reasons.push(`role=${role}`);
319
+ if (ariaModal === 'true')
320
+ reasons.push('aria-modal=true');
321
+ if (toastish(el))
322
+ reasons.push('toast');
323
+ if (el.hasAttribute('data-hot-toast'))
324
+ reasons.push('data-hot-toast');
325
+ if (el.hasAttribute('data-sonner-toast'))
326
+ reasons.push('data-sonner-toast');
327
+ if ((role === 'status' || role === 'alert') && toastish(el))
328
+ reasons.push(`role=${role}`);
329
+ return [...new Set(reasons)];
330
+ };
331
+ return [document.documentElement, document.body, ...document.querySelectorAll('body *')]
332
+ .filter((el) => (!skipSel || !el.matches(skipSel)) && visible(el))
333
+ .flatMap((el) => {
334
+ const role = (el.getAttribute('role') ?? '').trim().toLowerCase();
335
+ const ariaModal = (el.getAttribute('aria-modal') ?? '').trim().toLowerCase();
336
+ const ariaLive = (el.getAttribute('aria-live') ?? '').trim().toLowerCase();
337
+ const reasons = reasonsFor(el, role, ariaModal);
338
+ if (!reasons.length)
339
+ return [];
340
+ const text = textOf(el);
341
+ return [
342
+ {
343
+ path: pathOf(el),
344
+ tag: el.tagName.toLowerCase(),
345
+ cls: el.getAttribute('class') || '',
346
+ reason: reasons.join(', '),
347
+ ...(role ? { role } : {}),
348
+ ...(ariaModal ? { ariaModal } : {}),
349
+ ...(ariaLive ? { ariaLive } : {}),
350
+ ...(text ? { text } : {}),
351
+ },
352
+ ];
353
+ });
354
+ }
286
355
  /** Full (unpruned) computed styles for an element and its descendants, pseudo-elements included. */
287
356
  // Serialized into the browser by page.evaluate; cannot call module helpers.
288
357
  function snapSubtree({ selector, index }) {
@@ -650,6 +719,7 @@ export async function captureStyleMap(page, options = {}) {
650
719
  await page.addStyleTag({ content: FREEZE_CSS });
651
720
  const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText, captureComponent });
652
721
  dropVolatile(base.elements, volatile);
722
+ const overlays = (await page.evaluate(detectOverlayCandidates, { ignore })).filter((overlay) => base.elements[overlay.path]);
653
723
  warnUntraversed(base.shadowHosts, base.sameOriginFrames);
654
724
  mergeMotion(base.elements, motion.elements);
655
725
  let states = {};
@@ -668,6 +738,7 @@ export async function captureStyleMap(page, options = {}) {
668
738
  ...(statesSkipped ? { statesSkipped: true } : {}),
669
739
  ...(volatile.length ? { volatile } : {}),
670
740
  ...(liveCandidates.length ? { liveCandidates } : {}),
741
+ ...(overlays.length ? { overlays } : {}),
671
742
  ...(Object.keys(tokens).length ? { tokens } : {}),
672
743
  };
673
744
  }
@@ -0,0 +1,41 @@
1
+ import type { Surface } from './runner.js';
2
+ export type DiscoveredComponent = {
3
+ /** Stable surface key, e.g. `component-dashboard-pr-card`. */
4
+ key: string;
5
+ /** Path relative to `cwd`, using `/` separators. */
6
+ path: string;
7
+ };
8
+ export type DiscoverComponentFilesOptions = {
9
+ /** Project root. Defaults to the current working directory. */
10
+ cwd?: string;
11
+ /** Component roots to scan, relative to `cwd` unless absolute. */
12
+ roots: string[];
13
+ /** Capture-key prefix. Defaults to `component`. */
14
+ prefix?: string;
15
+ /** File extensions to include. Defaults to common JS + framework component files. */
16
+ extensions?: string[];
17
+ /** Extra regexes matched against the cwd-relative path. */
18
+ ignore?: RegExp[];
19
+ };
20
+ export type ComponentCatalogSurfaceOptions = {
21
+ /** Map a discovered component to the app-owned catalog URL that renders it. */
22
+ url?: (component: DiscoveredComponent) => string;
23
+ /** Viewport widths to apply to every generated component surface. */
24
+ widths?: number[];
25
+ /** Viewport height to apply to every generated component surface. */
26
+ height?: Surface['height'];
27
+ /** Selectors ignored on every generated component surface. */
28
+ ignore?: string[];
29
+ };
30
+ /**
31
+ * Discover component files so apps can make StyleProof coverage explicit:
32
+ * map these keys to a Storybook/Ladle/custom catalog route, then pass the keys
33
+ * to `expected`. StyleProof inventories files; the app still owns rendering
34
+ * because props, providers, data, portals, and framework bootstraps are app-specific.
35
+ */
36
+ export declare function discoverComponentFiles(options: DiscoverComponentFilesOptions): DiscoveredComponent[];
37
+ /**
38
+ * Turn discovered components into StyleProof surfaces for an app-owned catalog
39
+ * route. Default URL: `/styleproof/components/<component.key>`.
40
+ */
41
+ export declare function componentCatalogSurfaces(components: DiscoveredComponent[], options?: ComponentCatalogSurfaceOptions): Surface[];
@@ -0,0 +1,101 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const DEFAULT_EXTENSIONS = ['.jsx', '.tsx', '.vue', '.svelte', '.astro'];
4
+ const DEFAULT_IGNORE = [
5
+ /(^|\/)__tests__(\/|$)/,
6
+ /(^|\/)(test|tests|fixtures|mocks)(\/|$)/,
7
+ /(^|\/)node_modules(\/|$)/,
8
+ /(^|\/)(dist|build|coverage|\.next|\.nuxt|\.svelte-kit)(\/|$)/,
9
+ /(^|\/)index\.[^/]+$/,
10
+ /\.(?:test|spec|stories|story)\.[^/]+$/,
11
+ /\.d\.ts$/,
12
+ ];
13
+ function toSlash(file) {
14
+ return file.split(path.sep).join('/');
15
+ }
16
+ function componentKey(root, file, prefix) {
17
+ const rel = toSlash(path.relative(root, file))
18
+ .replace(/\.[^.]+$/, '')
19
+ .replace(/\/index$/, '')
20
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
21
+ .replace(/[^a-zA-Z0-9]+/g, '-')
22
+ .replace(/^-+|-+$/g, '')
23
+ .toLowerCase();
24
+ return [prefix, rel].filter(Boolean).join('-');
25
+ }
26
+ function readDir(dir) {
27
+ try {
28
+ return fs.readdirSync(dir, { withFileTypes: true });
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
34
+ function assertComponentRoot(root, rootInput) {
35
+ let stat;
36
+ try {
37
+ stat = fs.statSync(root);
38
+ }
39
+ catch {
40
+ throw new Error(`StyleProof component root not found: ${rootInput}`);
41
+ }
42
+ if (!stat.isDirectory()) {
43
+ throw new Error(`StyleProof component root is not a directory: ${rootInput}`);
44
+ }
45
+ }
46
+ /**
47
+ * Discover component files so apps can make StyleProof coverage explicit:
48
+ * map these keys to a Storybook/Ladle/custom catalog route, then pass the keys
49
+ * to `expected`. StyleProof inventories files; the app still owns rendering
50
+ * because props, providers, data, portals, and framework bootstraps are app-specific.
51
+ */
52
+ export function discoverComponentFiles(options) {
53
+ const cwd = options.cwd ?? process.cwd();
54
+ const prefix = options.prefix ?? 'component';
55
+ const extensions = new Set(options.extensions ?? DEFAULT_EXTENSIONS);
56
+ const ignore = [...DEFAULT_IGNORE, ...(options.ignore ?? [])];
57
+ const components = [];
58
+ for (const rootInput of options.roots) {
59
+ const root = path.resolve(cwd, rootInput);
60
+ assertComponentRoot(root, rootInput);
61
+ const walk = (dir) => {
62
+ for (const entry of readDir(dir)) {
63
+ const file = path.join(dir, entry.name);
64
+ const rel = toSlash(path.relative(cwd, file));
65
+ if (ignore.some((pattern) => pattern.test(rel)))
66
+ continue;
67
+ if (entry.isDirectory()) {
68
+ walk(file);
69
+ }
70
+ else if (entry.isFile() && extensions.has(path.extname(entry.name))) {
71
+ components.push({ key: componentKey(root, file, prefix), path: rel });
72
+ }
73
+ }
74
+ };
75
+ walk(root);
76
+ }
77
+ const seen = new Map();
78
+ for (const component of components) {
79
+ const previous = seen.get(component.key);
80
+ if (previous) {
81
+ throw new Error(`StyleProof component key collision: ${component.key} from ${previous} and ${component.path}`);
82
+ }
83
+ seen.set(component.key, component.path);
84
+ }
85
+ return components.sort((a, b) => a.key.localeCompare(b.key));
86
+ }
87
+ /**
88
+ * Turn discovered components into StyleProof surfaces for an app-owned catalog
89
+ * route. Default URL: `/styleproof/components/<component.key>`.
90
+ */
91
+ export function componentCatalogSurfaces(components, options = {}) {
92
+ return components.map((component) => ({
93
+ key: component.key,
94
+ go: async (page) => {
95
+ await page.goto(options.url?.(component) ?? `/styleproof/components/${component.key}`);
96
+ },
97
+ ...(options.widths ? { widths: options.widths } : {}),
98
+ ...(options.height ? { height: options.height } : {}),
99
+ ...(options.ignore ? { ignore: options.ignore } : {}),
100
+ }));
101
+ }
@@ -8,10 +8,10 @@
8
8
  * This is the one failure StyleProof can't catch from the captures alone, because
9
9
  * it's about a capture that was never taken.
10
10
  *
11
- * `expected` closes the hole: a spec declares its full route/surface universe
12
- * (e.g. an app's view registry), and the guard fails when that universe drifts
13
- * from what's actually captured — turning a silent coverage hole into a red test,
14
- * in the app's own suite, the moment the route is added.
11
+ * `expected` closes the hole: a spec declares its full route/view/state universe
12
+ * (e.g. an app's route + overlay-flow registry), and the guard fails when that
13
+ * universe drifts from what's actually captured — turning a silent coverage hole
14
+ * into a red test, in the app's own suite, the moment the route or flow is added.
15
15
  */
16
16
  export type CoverageGaps = {
17
17
  /** Expected surfaces that are neither captured nor explicitly excluded. */
@@ -25,8 +25,9 @@ export type CoverageGaps = {
25
25
  *
26
26
  * A surface is covered if it's captured OR listed in `exclude` (a deliberate,
27
27
  * documented opt-out — `key → reason`). Captured surfaces NOT in `expected` are
28
- * allowed: one route legitimately has several captured states (`landing`,
29
- * `landing-nav-open`), and only the routes themselves form the universe.
28
+ * allowed: a project may start by requiring only route keys, then tighten the
29
+ * universe with explicit state keys such as `landing-nav-open` or
30
+ * `dashboard-dialog-open`.
30
31
  *
31
32
  * Pure and side-effect-free so it's unit-testable; `defineStyleMapCapture` wraps
32
33
  * it in a Playwright test that runs in the normal suite (not gated on a capture
package/dist/coverage.js CHANGED
@@ -8,18 +8,19 @@
8
8
  * This is the one failure StyleProof can't catch from the captures alone, because
9
9
  * it's about a capture that was never taken.
10
10
  *
11
- * `expected` closes the hole: a spec declares its full route/surface universe
12
- * (e.g. an app's view registry), and the guard fails when that universe drifts
13
- * from what's actually captured — turning a silent coverage hole into a red test,
14
- * in the app's own suite, the moment the route is added.
11
+ * `expected` closes the hole: a spec declares its full route/view/state universe
12
+ * (e.g. an app's route + overlay-flow registry), and the guard fails when that
13
+ * universe drifts from what's actually captured — turning a silent coverage hole
14
+ * into a red test, in the app's own suite, the moment the route or flow is added.
15
15
  */
16
16
  /**
17
17
  * Compare the captured surface keys against a declared `expected` universe.
18
18
  *
19
19
  * A surface is covered if it's captured OR listed in `exclude` (a deliberate,
20
20
  * documented opt-out — `key → reason`). Captured surfaces NOT in `expected` are
21
- * allowed: one route legitimately has several captured states (`landing`,
22
- * `landing-nav-open`), and only the routes themselves form the universe.
21
+ * allowed: a project may start by requiring only route keys, then tighten the
22
+ * universe with explicit state keys such as `landing-nav-open` or
23
+ * `dashboard-dialog-open`.
23
24
  *
24
25
  * Pure and side-effect-free so it's unit-testable; `defineStyleMapCapture` wraps
25
26
  * it in a Playwright test that runs in the normal suite (not gated on a capture
package/dist/diff.js CHANGED
@@ -19,6 +19,8 @@ const LAYOUT_EQUIVALENT_MARGIN_PROPS = new Set([
19
19
  'margin-inline-start',
20
20
  'margin-inline-end',
21
21
  ]);
22
+ const SUBPIXEL_ORIGIN_PROPS = new Set(['perspective-origin', 'transform-origin']);
23
+ const ORIGIN_EPSILON_PX = 0.05;
22
24
  function sameRect(a, b) {
23
25
  return !!a && !!b && a.every((v, i) => v === b[i]);
24
26
  }
@@ -27,6 +29,24 @@ function dropLayoutEquivalentMarginProps(props, a, b) {
27
29
  return props;
28
30
  return props.filter((p) => !LAYOUT_EQUIVALENT_MARGIN_PROPS.has(p.prop));
29
31
  }
32
+ function pxParts(value) {
33
+ const parts = value.trim().split(/\s+/);
34
+ if (parts.length < 2 || parts.length > 3)
35
+ return null;
36
+ const values = parts.map((part) => {
37
+ const match = /^(-?\d+(?:\.\d+)?)px$/.exec(part);
38
+ return match ? Number(match[1]) : Number.NaN;
39
+ });
40
+ return values.every(Number.isFinite) ? values : null;
41
+ }
42
+ function sameSubpixelOrigin(before, after) {
43
+ const a = pxParts(before);
44
+ const b = pxParts(after);
45
+ return !!a && !!b && a.length === b.length && a.every((v, i) => Math.abs(v - b[i]) <= ORIGIN_EPSILON_PX);
46
+ }
47
+ function dropSubpixelOriginProps(props) {
48
+ return props.filter((p) => !SUBPIXEL_ORIGIN_PROPS.has(p.prop) || !sameSubpixelOrigin(p.before, p.after));
49
+ }
30
50
  /** Union of both captures' live-region paths — skipped by every diff layer so a
31
51
  * region volatile on either side never reads as a change. */
32
52
  function volatilePaths(a, b) {
@@ -95,7 +115,7 @@ export function diffStyleMaps(a, b) {
95
115
  const pdefsA = pseudo ? (a.defaults[ea.tag + pseudo] ?? defsA) : defsA;
96
116
  const pdefsB = pseudo ? (b.defaults[eb.tag + pseudo] ?? defsB) : defsB;
97
117
  const rawProps = diffProps(propsA, propsB, pdefsA, pdefsB, '(unset)', '(unset)');
98
- const props = pseudo ? rawProps : dropLayoutEquivalentMarginProps(rawProps, ea, eb);
118
+ const props = dropSubpixelOriginProps(pseudo ? rawProps : dropLayoutEquivalentMarginProps(rawProps, ea, eb));
99
119
  if (props.length)
100
120
  findings.push({ kind: 'style', path: p, cls: ea.cls, pseudo, props });
101
121
  }
@@ -131,7 +151,7 @@ export function diffStyleMaps(a, b) {
131
151
  const db = sb[state] ?? {};
132
152
  for (const sub of new Set([...Object.keys(da), ...Object.keys(db)])) {
133
153
  const props = diffProps(da[sub] ?? {}, db[sub] ?? {}, {}, {}, '(state does not change it)', '(state no longer changes it)');
134
- const filtered = dropLayoutEquivalentMarginProps(props, a.elements[sub], b.elements[sub]);
154
+ const filtered = dropSubpixelOriginProps(dropLayoutEquivalentMarginProps(props, a.elements[sub], b.elements[sub]));
135
155
  if (filtered.length)
136
156
  findings.push({ kind: 'state', path: p, cls, state, sub, props: filtered });
137
157
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
2
- export type { StyleMap, CaptureOptions, CaptureMetadata, ElementEntry, LiveRegionCandidate, Rect } from './capture.js';
2
+ export type { StyleMap, CaptureOptions, CaptureMetadata, ElementEntry, LiveRegionCandidate, CapturedOverlay, Rect, } from './capture.js';
3
3
  export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
4
4
  export type { Surface, SurfaceLiveState, SurfaceVariant, PopupCaptureOptions, DefineOptions, CrawlOptions, } from './runner.js';
5
5
  export { coverageGaps } from './coverage.js';
@@ -7,8 +7,12 @@ export type { CoverageGaps } from './coverage.js';
7
7
  export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
8
8
  export { discoverNextRoutes } from './routes.js';
9
9
  export type { DiscoveredRoute } from './routes.js';
10
+ export { discoverComponentFiles, componentCatalogSurfaces } from './components.js';
11
+ export type { DiscoveredComponent, DiscoverComponentFilesOptions, ComponentCatalogSurfaceOptions, } from './components.js';
10
12
  export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
11
13
  export type { CrawlLink, LinkMatch, SelectLinksOptions } from './crawl.js';
14
+ export { harvestStyleVariants } from './variant-crawler.js';
15
+ export type { HarvestAction, HarvestedLiveState, HarvestedRoute, HarvestedVariant, HarvestRoute, HarvestSkip, VariantHarvest, VariantHarvestOptions, } from './variant-crawler.js';
12
16
  export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
13
17
  export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
14
18
  export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
3
3
  export { coverageGaps } from './coverage.js';
4
4
  export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
5
5
  export { discoverNextRoutes } from './routes.js';
6
+ export { discoverComponentFiles, componentCatalogSurfaces } from './components.js';
6
7
  export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
8
+ export { harvestStyleVariants } from './variant-crawler.js';
7
9
  export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
8
10
  export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
package/dist/runner.d.ts CHANGED
@@ -80,8 +80,10 @@ export type PopupCaptureOptions = {
80
80
  export type DefineOptions = {
81
81
  surfaces: Surface[];
82
82
  /**
83
- * The full set of surface keys the app knows it has — its route/view universe,
84
- * typically derived from a registry (e.g. an app's list of routes or view ids).
83
+ * The full set of surface keys the app knows it has — its route/view/state
84
+ * universe, typically derived from registries (e.g. routes plus modal/menu
85
+ * flows). Include expanded variant keys such as `dashboard-dialog-open` when
86
+ * those states must be certified.
85
87
  * When set, StyleProof emits a coverage-guard test (in the NORMAL suite, not
86
88
  * gated on a capture dir) that fails if any expected key is neither captured (a
87
89
  * surface) nor in `exclude`. This is what stops a newly added route from
package/dist/runner.js CHANGED
@@ -68,11 +68,19 @@ const DEFAULT_POPUP_TRIGGERS = [
68
68
  const DEFAULT_POPUP_OVERLAYS = [
69
69
  'dialog[open]',
70
70
  '[popover]',
71
+ '[aria-modal="true"]',
71
72
  '[role="dialog"]',
72
73
  '[role="alertdialog"]',
73
74
  '[role="menu"]',
74
75
  '[role="listbox"]',
75
76
  '[role="tooltip"]',
77
+ '[data-hot-toast]',
78
+ '[data-sonner-toast]',
79
+ '[data-toast]',
80
+ '.hot-toast',
81
+ '[class*="toast" i]',
82
+ '[role="alert"]',
83
+ '[role="status"]',
76
84
  '[data-state="open"]:not(button):not(a):not(summary)',
77
85
  ].join(', ');
78
86
  export function resolvePopupCaptureOptions(input) {
@@ -124,8 +132,22 @@ async function popupDomSnapshot(page, options) {
124
132
  }
125
133
  return parts.join(' > ');
126
134
  };
135
+ const popupKey = (el) => {
136
+ const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120);
137
+ return [
138
+ pathOf(el),
139
+ el.getAttribute('role') ?? '',
140
+ el.getAttribute('aria-modal') ?? '',
141
+ el.getAttribute('aria-live') ?? '',
142
+ el.getAttribute('data-state') ?? '',
143
+ el.getAttribute('data-hot-toast') ?? '',
144
+ el.getAttribute('data-sonner-toast') ?? '',
145
+ el.getAttribute('data-toast') ?? '',
146
+ text,
147
+ ].join('|');
148
+ };
127
149
  const popups = qsa(popupSelector).filter(visible);
128
- const keys = popups.map(pathOf);
150
+ const keys = popups.map(popupKey);
129
151
  if (!triggerSelector || !attr)
130
152
  return { keys, indexes: [] };
131
153
  const safeTrigger = (el) => {
@@ -175,16 +197,17 @@ function expandOne(surface, variant, variantKind) {
175
197
  export function expandSurfaceVariants(surface) {
176
198
  const variants = surface.variants ?? [];
177
199
  const liveStates = surface.liveStates ?? [];
200
+ const { variants: _variants, liveStates: _liveStates, ...base } = surface;
201
+ const baseSurface = { ...base, metadata: { surfaceKey: surface.key } };
202
+ void _variants;
203
+ void _liveStates;
178
204
  if (!variants.length && !liveStates.length) {
179
- const { variants: _variants, liveStates: _liveStates, ...base } = surface;
180
- void _variants;
181
- void _liveStates;
182
- return [{ ...base, metadata: { surfaceKey: surface.key } }];
205
+ return [baseSurface];
183
206
  }
184
- return [
185
- ...variants.map((variant) => expandOne(surface, variant, 'variant')),
186
- ...liveStates.map((state) => expandOne(surface, state, 'live-state')),
187
- ];
207
+ const expandedVariants = variants.map((variant) => expandOne(surface, variant, 'variant'));
208
+ if (!liveStates.length)
209
+ return [baseSurface, ...expandedVariants];
210
+ return [...expandedVariants, ...liveStates.map((state) => expandOne(surface, state, 'live-state'))];
188
211
  }
189
212
  /**
190
213
  * Let SSE (EventSource) requests bypass HAR record/replay and reach the live
@@ -439,7 +462,7 @@ export function defineStyleMapCapture(options) {
439
462
  if (expected) {
440
463
  test.describe('styleproof coverage', () => {
441
464
  test('every expected surface is captured or explicitly excluded', () => {
442
- const { uncovered, staleExclusions } = coverageGaps(surfaces.map((s) => s.key), expected, exclude);
465
+ const { uncovered, staleExclusions } = coverageGaps(captureSurfaces.map((s) => s.key), expected, exclude);
443
466
  expect(uncovered, `StyleProof coverage gap: ${uncovered.length} expected surface(s) are neither captured ` +
444
467
  `nor excluded — add each to \`surfaces\`, or to \`exclude\` with a reason. ` +
445
468
  `Missing: ${uncovered.join(', ')}`).toEqual([]);
@@ -0,0 +1,60 @@
1
+ import type { Page } from '@playwright/test';
2
+ import { type CaptureOptions } from './capture.js';
3
+ export type HarvestRoute = {
4
+ /** Stable route/surface key in the generated manifest. */
5
+ key: string;
6
+ /** Absolute URL or path resolved against `baseUrl`. */
7
+ url: string;
8
+ };
9
+ export type HarvestAction = 'click' | 'select-option' | 'submit-empty';
10
+ export type HarvestedVariant = {
11
+ key: string;
12
+ action: HarvestAction;
13
+ selector: string;
14
+ reason: string;
15
+ label: string;
16
+ findings: number;
17
+ diffHash: string;
18
+ value?: string;
19
+ };
20
+ export type HarvestedLiveState = {
21
+ key: string;
22
+ selector: string;
23
+ reason: string;
24
+ label: string;
25
+ fixtureRequired: true;
26
+ role?: string;
27
+ ariaLive?: string;
28
+ ariaBusy?: string;
29
+ };
30
+ export type HarvestSkip = {
31
+ reason: 'unsafe-label' | 'navigated' | 'action-failed';
32
+ selector: string;
33
+ label: string;
34
+ detail?: string;
35
+ };
36
+ export type HarvestedRoute = {
37
+ key: string;
38
+ url: string;
39
+ variants: HarvestedVariant[];
40
+ liveStates: HarvestedLiveState[];
41
+ skipped: HarvestSkip[];
42
+ };
43
+ export type VariantHarvest = {
44
+ routes: HarvestedRoute[];
45
+ };
46
+ export type VariantHarvestOptions = {
47
+ baseUrl?: string;
48
+ routes: HarvestRoute[];
49
+ /** Max attempted actions per route. Default 40. */
50
+ maxActionsPerRoute?: number;
51
+ /** Extra selectors to skip during capture. */
52
+ ignore?: string[];
53
+ /** Forwarded to the cheap discovery captures; forced states stay off here. */
54
+ stabilize?: CaptureOptions['stabilize'];
55
+ };
56
+ /**
57
+ * Discover one-step UI states by trying semantic controls and keeping only
58
+ * actions whose rendered computed-style map differs from the route baseline.
59
+ */
60
+ export declare function harvestStyleVariants(page: Page, options: VariantHarvestOptions): Promise<VariantHarvest>;