styleproof 3.7.0 → 3.8.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
+ ## [3.8.0] - 2026-07-05
11
+
12
+ ### Added
13
+
14
+ - **Zero-config out of the box** — `styleproof-init` now scaffolds a crawl-by-default
15
+ spec for any non-Next.js app: `defineCrawlCapture({ from: '/', settle, inventory: true })`
16
+ captures every surface the nav links to (the root plus each same-origin `<a href>`)
17
+ with **nothing to hand-list** — the surface set is discovered from the rendered nav and
18
+ can't drift from it. Next.js keeps filesystem route discovery; both variants now enable
19
+ the inventory guard by default.
20
+ - **`defineCrawlCapture` auto-width** — omit `widths` and StyleProof detects each
21
+ discovered surface's `@media` breakpoints and sweeps one viewport per band, the same
22
+ zero-config behaviour explicit surfaces already had.
23
+ - **`defineCrawlCapture` `settle` hook** — run an app-specific step (e.g. trigger
24
+ scroll-reveal) after navigating to each crawled surface, for parity with a hand-listed
25
+ surface's `go`.
26
+ - **`inventory` spec option** — `defineStyleMapCapture` / `defineCrawlCapture` now forward
27
+ `inventory: true` to the capture, so turning the inventory guard on (a removed nav item
28
+ fails `styleproof-diff`) is a one-line opt-in from a spec, no manual `captureStyleMap`.
29
+ - The crawl now always covers its `from` root on an unfiltered crawl, so a home page not
30
+ linked in its own nav — or a single-page app with no links — is still captured.
31
+
32
+ ### Changed
33
+
34
+ - `styleproof-init` guidance now leads with "it runs on your first PR with no extra
35
+ steps" (CI captures both sides on a cache miss); the local `npx styleproof-map`
36
+ pre-cache is framed as an optional speedup, not a required first step.
37
+
38
+ ### Tests / docs
39
+
40
+ - **Dogfood: the "100% surfaced" contract** — `test/pr-surfacing.e2e.spec.ts` runs the
41
+ real capture → diff → report flow for every change class (resting style,
42
+ `:hover/:focus/:active` drop, `::before/::after`, DOM add/remove/retag, a removed nav
43
+ item via the inventory guard, a new surface, and a clean no-op) and asserts each is
44
+ surfaced — the last two levels through the actual `styleproof-diff` / `styleproof-report`
45
+ CLIs. Closes the four classes that previously had no end-to-end proof (`:active` drop,
46
+ DOM removed, retag, pseudo-element change).
47
+ - **Dogfood: zero-config flow** — `test/cli-flow.e2e.spec.ts` runs `styleproof-init` on a
48
+ real multi-page app and proves the generated crawl spec captures every page (root +
49
+ pricing + about), multi-width, with the inventory harvested — no spec editing.
50
+ - **`docs/what-it-catches.md`** — states what StyleProof catches and its honest boundary
51
+ (surfaces it never captured), so a green check is earned, not assumed.
52
+
10
53
  ## [3.7.0] - 2026-07-04
11
54
 
12
55
  ### Added
@@ -153,64 +153,37 @@ defineStyleMapCapture({
153
153
  exclude: Object.fromEntries(
154
154
  ROUTES.filter((r) => r.dynamic).map((r) => [r.key, \`dynamic route (\${r.path}) — add a surface with a concrete param\`]),
155
155
  ),
156
+ inventory: true, // also fail the diff when a nav item / route the UI used to offer disappears
156
157
  dir: process.env.STYLEMAP_DIR,
157
158
  });
158
159
  `;
159
160
 
160
- // Non-Next project: one sample surface + a commented guard block to wire to
161
- // whatever the project uses as a route/view registry.
161
+ // Non-Next project: crawl every surface the nav links to, so ANY app captures its
162
+ // whole reachable surface out of the box with nothing to hand-list. The crawl reads
163
+ // the rendered nav; the surface set can't drift from it.
162
164
  const GENERIC_SPEC = `import type { Page } from '@playwright/test';
163
- import { defineStyleMapCapture, type Surface } from 'styleproof';
165
+ import { defineCrawlCapture } from 'styleproof';
164
166
 
165
167
  ${HEADER}
166
168
 
167
169
  ${SETTLE}
168
170
 
169
- const SURFACES: Surface[] = [
170
- {
171
- key: 'home',
172
- go: async (page) => {
173
- await page.goto('/');
174
- await settle(page);
175
- },
176
- ignore: [], // e.g. ['.live-feed', '.ad-slot'] for nondeterministic regions
177
- // No widths StyleProof detects your @media breakpoints from the loaded CSS and
178
- // sweeps one viewport per band. Pass an explicit array (e.g. 1280, 768, 390) to pin them (or to
179
- // cover a JS-only matchMedia breakpoint that has no CSS @media rule).
180
- // Non-live UI states belong here as variants, so the base branch's
181
- // dialog-open state compares to the head branch's dialog-open state.
182
- // variants: [
183
- // {
184
- // key: 'dialog-open',
185
- // go: async (page) => {
186
- // await page.getByRole('button', { name: /open settings/i }).click();
187
- // await page.getByRole('dialog').waitFor();
188
- // },
189
- // },
190
- // {
191
- // key: 'popover-open',
192
- // go: async (page) => {
193
- // await page.getByRole('button', { name: /more/i }).click();
194
- // await page.locator('[popover], [role="menu"]').first().waitFor();
195
- // },
196
- // },
197
- // ],
198
- },
199
- // Add more surfaces for distinct routes/views; add menus, dialogs, popovers,
200
- // selected tabs, and form errors as variants of the route/view that owns them.
201
- ];
202
-
203
- defineStyleMapCapture({
204
- surfaces: SURFACES,
205
- // Coverage guard (recommended): declare every route your app knows about so a
206
- // newly added page can't ship without a surface. Wire \`expected\` to your route
207
- // registry — a routes/views list, your router config, or a glob of your pages —
208
- // and StyleProof fails the suite (no STYLEMAP_DIR needed) when a route has no
209
- // surface and isn't excluded. (A Next.js app gets this auto-wired; see
210
- // \`discoverNextRoutes\` in the README.)
211
- // expected: ROUTES.map((r) => r.id),
212
- // exclude: { checkout: 'auth-gated — fixture pending' },
171
+ // Zero-config capture: crawl every surface your nav links to from '/'. The surface set
172
+ // is DISCOVERED from the rendered nav, so it can't drift from it — no hand-listed
173
+ // \`surfaces\` array to maintain, and a page you add to the nav is captured automatically.
174
+ // The root (/) is always captured, plus every same-origin <a href> it links to.
175
+ defineCrawlCapture({
176
+ from: '/',
177
+ settle, // trigger scroll-reveal per surface (StyleProof handles fonts/animation/network itself)
178
+ // No \`widths\` StyleProof detects each surface's @media breakpoints and sweeps one
179
+ // viewport per band. Pass an array (e.g. [1440, 768, 390]) to pin them.
180
+ inventory: true, // also fail the diff when a nav item / route the UI used to offer disappears
181
+ ignore: [], // e.g. ['.live-feed', '.ad-slot'] for nondeterministic regions
213
182
  dir: process.env.STYLEMAP_DIR,
183
+ // A single-route SPA whose views are ?tab= / client-routed? Keep only those:
184
+ // match: /\\?tab=/,
185
+ // Certify menus, dialogs, tabs, and form-error states on every surface as variants:
186
+ // variants: [{ key: 'menu-open', go: async (page) => { await page.getByRole('button', { name: /menu/i }).click(); } }],
214
187
  });
215
188
  `;
216
189
 
@@ -472,7 +445,8 @@ if (spec.wrote) {
472
445
  (dynamic ? ` (${dynamic} dynamic route(s) excluded pending a concrete param)` : ''),
473
446
  );
474
447
  } else {
475
- console.log(' no Next.js routes detected — wrote a starter surface + a commented coverage-guard block');
448
+ console.log(' no Next.js routes detected — wrote a crawl-by-default spec that captures every');
449
+ console.log(' surface your nav links to from / (nothing to hand-list; the inventory guard is on)');
476
450
  }
477
451
  wroteSomething = true;
478
452
  } else {
@@ -508,15 +482,14 @@ if (ci.wrote) {
508
482
  console.log(`${CI_PATH} already exists — left untouched`);
509
483
  }
510
484
 
511
- console.log('\nHow the gate works — local-first maps, CI report when cached:');
512
- console.log(' 1. Commit your code, then run:');
485
+ console.log('\nHow the gate works — it runs on your first PR with no extra steps:');
486
+ console.log(' 1. Commit and open a PR. CI captures the base and head surfaces in one pinned');
487
+ console.log(' environment and posts the StyleProof report — no local step required.');
488
+ console.log(' 2. (Optional, faster) Pre-cache this commit so CI skips recapturing the base:');
513
489
  console.log(' npx styleproof-map');
514
- console.log(' It captures a production-build map into .styleproof/ and uploads the bundle');
515
- console.log(' to the styleproof-maps branch when the git remote is available.');
516
- console.log(' 2. Open the PR. CI restores the base/head bundles and generates the report');
517
- console.log(' without a browser when both maps are present and compatible.');
518
- console.log(' 3. If a bundle is missing or incompatible, CI recaptures both sides in the same');
519
- console.log(' environment before generating the report. Correctness beats a stale cache.');
490
+ console.log(' It captures a production-build map into .styleproof/ and uploads the bundle to');
491
+ console.log(' the styleproof-maps branch when the git remote is available; CI then restores');
492
+ console.log(' it and generates the report without a browser.');
520
493
 
521
494
  if (!wroteSomething) console.log('\nnothing to write — project already scaffolded.');
522
495
  process.exit(0);
package/dist/crawl.d.ts CHANGED
@@ -37,6 +37,10 @@ export type SelectLinksOptions = {
37
37
  match?: LinkMatch;
38
38
  /** Derive the surface key from a link URL. Default: {@link defaultLinkKey}. */
39
39
  key?: (url: URL) => string;
40
+ /** Also capture the crawled page itself as the first surface, so `from` is always
41
+ * covered — even if the nav doesn't link back to it, or it's a single-page app with
42
+ * no links at all. Default false. Used for an unfiltered "capture everything" crawl. */
43
+ includeSelf?: boolean;
40
44
  };
41
45
  /**
42
46
  * Filename-safe, readable key from a link URL. Joins the path segments and the
package/dist/crawl.js CHANGED
@@ -83,6 +83,11 @@ export function selectCrawlLinks(hrefs, opts) {
83
83
  const keyFor = opts.key ?? defaultLinkKey;
84
84
  const seen = new Set();
85
85
  const out = [];
86
+ if (opts.includeSelf) {
87
+ const selfUrl = base.pathname + base.search;
88
+ seen.add(selfUrl);
89
+ out.push({ key: keyFor(base), url: selfUrl });
90
+ }
86
91
  for (const href of hrefs) {
87
92
  const link = href ? toLink(href, base, keyFor, opts.match) : null;
88
93
  if (!link || seen.has(link.url))
package/dist/runner.d.ts CHANGED
@@ -168,6 +168,15 @@ export type DefineOptions = {
168
168
  * their exact capture set unless this is enabled.
169
169
  */
170
170
  popups?: boolean | PopupCaptureOptions;
171
+ /**
172
+ * Opt-in inventory guard (default OFF). Harvest each surface's navigable
173
+ * affordances — route links, `role=tab`/`menuitem`, button-only nav — into
174
+ * `StyleMap.inventory`, so `styleproof-diff` fails when a nav item / route the UI
175
+ * used to offer disappears (acknowledge intentional removals in
176
+ * `styleproof.inventory.json`). Additive; ignored by the certification style diff.
177
+ * See `docs/inventory-guard.md`.
178
+ */
179
+ inventory?: boolean;
171
180
  };
172
181
  export declare function selfCheckErrorMessage(surfaceKey: string, drift: Finding[], volatile?: string[], liveCandidates?: LiveRegionCandidate[]): string;
173
182
  type ResolvedPopupCaptureOptions = Required<PopupCaptureOptions>;
@@ -238,10 +247,16 @@ export type CrawlOptions = CaptureConfig & {
238
247
  match?: LinkMatch;
239
248
  /** Derive a surface key from a link URL. Default: path+query slug (`/?tab=x` → `x`). */
240
249
  key?: (url: URL) => string;
241
- /** Viewport widths swept for every discovered surface one per @media band. */
242
- widths: number[];
250
+ /** Viewport widths swept for every discovered surface. Omit to auto-detect each
251
+ * surface's @media breakpoints (one viewport per band) — the same zero-config
252
+ * behaviour as an explicit surface with no `widths`. */
253
+ widths?: number[];
243
254
  /** Viewport height per width (default 800). */
244
255
  height?: number | ((width: number) => number);
256
+ /** Run after navigating to each discovered link, before capture — e.g. to trigger
257
+ * scroll-reveal content. The built-in font/animation/network settle always runs;
258
+ * this is the app-specific hook, the crawl's parity with a hand-listed surface's `go`. */
259
+ settle?: (page: Page) => Promise<void>;
245
260
  /** Selectors skipped on every surface (live regions, third-party embeds). */
246
261
  ignore?: string[];
247
262
  /** Deterministic variants captured for every discovered link surface. */
package/dist/runner.js CHANGED
@@ -371,6 +371,7 @@ async function captureSurface(page, surface, width, s) {
371
371
  ignore: surface.ignore ?? [],
372
372
  captureText: s.captureText,
373
373
  captureComponent: s.captureComponent,
374
+ inventory: s.inventory,
374
375
  pendingRequests: requests.pending,
375
376
  metadata: surface.metadata,
376
377
  });
@@ -435,6 +436,7 @@ function resolveSettings(c) {
435
436
  captureText: c.captureText ?? false,
436
437
  captureComponent: c.captureComponent ?? false,
437
438
  popups: resolvePopupCaptureOptions(c.popups),
439
+ inventory: c.inventory ?? false,
438
440
  };
439
441
  }
440
442
  /**
@@ -517,17 +519,28 @@ export function defineStyleMapCapture(options) {
517
519
  * ```
518
520
  */
519
521
  export function defineCrawlCapture(options) {
520
- const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir } = options;
522
+ const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir, settle, } = options;
521
523
  const settings = resolveSettings(options);
522
- test.describe('styleproof crawl-capture', () => {
524
+ // Title contains "styleproof capture" so the same `--grep 'styleproof capture'`
525
+ // that styleproof-map uses to select capture tests picks up crawl specs too.
526
+ test.describe('styleproof capture (crawl)', () => {
523
527
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
524
528
  test('discover surfaces by crawling links, then capture each', async ({ page }) => {
525
529
  // 1. Load the root and wait for its nav links to hydrate — an SPA renders them
526
530
  // client-side, so they aren't in the initial HTML.
527
531
  await page.goto(from, { waitUntil: 'load' });
528
- await page.waitForSelector('a[href]', { timeout: linkTimeout });
532
+ // Wait for the nav's links to hydrate (an SPA renders them client-side). A link-less
533
+ // page is fine for an unfiltered crawl — it still captures `from` itself (includeSelf);
534
+ // a `match`-filtered crawl genuinely needs links, so let its timeout surface.
535
+ await page.waitForSelector('a[href]', { timeout: linkTimeout }).catch((e) => {
536
+ if (match !== undefined)
537
+ throw e;
538
+ });
529
539
  const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href')));
530
- const links = selectCrawlLinks(hrefs, { base: page.url(), match, key });
540
+ // Unfiltered crawl also capture `from` itself, so the root is always covered and
541
+ // a single-page (or no-nav-self-link) app still yields a surface. A `match`-filtered
542
+ // crawl captures only the links the caller asked for.
543
+ const links = selectCrawlLinks(hrefs, { base: page.url(), match, key, includeSelf: match === undefined });
531
544
  if (links.length === 0) {
532
545
  throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
533
546
  `<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
@@ -536,6 +549,8 @@ export function defineCrawlCapture(options) {
536
549
  key: link.key,
537
550
  go: async (p) => {
538
551
  await p.goto(link.url, { waitUntil: 'load' });
552
+ if (settle)
553
+ await settle(p);
539
554
  },
540
555
  widths,
541
556
  ignore,
@@ -546,12 +561,27 @@ export function defineCrawlCapture(options) {
546
561
  }));
547
562
  // Budget the whole sweep up front: one test captures every surface, and
548
563
  // captureSurface no longer sets its own timeout, so size it to the work found.
549
- test.setTimeout(Math.max(180_000, captureSurfaces.reduce((sum, surface) => sum + (surface.widths?.length ?? 0), 0) * 60_000));
564
+ // With auto-width the band count isn't known until each surface renders, so
565
+ // assume up to 4 bands per surface.
566
+ test.setTimeout(Math.max(180_000, captureSurfaces.reduce((sum, surface) => sum + (surface.widths?.length ?? 4), 0) * 60_000));
550
567
  // 2. Capture each discovered surface. Aggregate failures so one bad surface
551
568
  // reports without skipping the rest — they're an independent set, not a chain.
552
569
  const failures = [];
553
570
  for (const surface of captureSurfaces) {
554
- for (const width of surface.widths ?? []) {
571
+ // Auto-width parity with explicit surfaces: no widths given navigate once and
572
+ // detect this surface's @media bands, then sweep one viewport per band.
573
+ let sweep = surface.widths;
574
+ if (!sweep || sweep.length === 0) {
575
+ try {
576
+ await surface.go(page);
577
+ sweep = await detectViewportWidths(page);
578
+ }
579
+ catch (e) {
580
+ failures.push(`${surface.key} @ auto: ${e.message}`);
581
+ continue;
582
+ }
583
+ }
584
+ for (const width of sweep) {
555
585
  try {
556
586
  await captureSurface(page, surface, width, settings);
557
587
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.7.0",
3
+ "version": "3.8.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",