@codepress/design-kit 0.5.0 → 0.6.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/README.md CHANGED
@@ -252,7 +252,12 @@ temporary folder that is removed afterwards.
252
252
  - `viewport`: each story's first capture, unless the story sets
253
253
  `parameters.viewport.defaultViewport` (default 1280×800).
254
254
  - `widths`: more captures for stories whose title matches the `titles`
255
- regular expression.
255
+ regular expression. A story that only works at some widths (a phone-only
256
+ drawer whose `play` opens what the desktop layout never shows) lists its
257
+ own with `parameters: { designKit: { widths: [390] } }` (numbers or
258
+ `{width, height}`): it is captured at exactly those, and a width it leaves
259
+ out is neither a row nor a gap. A failure at a width it lists is still a
260
+ gap, and an empty or malformed list is ignored.
256
261
  - `fonts`: `{dir, faces: [{family, file, weight?, style?, variable?,
257
262
  fallback?}]}` gives every story these faces (`file` is a name glob in `dir`;
258
263
  `variable` sets a CSS variable on `:root` for apps that read their font from
@@ -261,6 +266,11 @@ fallback?}]}` gives every story these faces (`file` is a name glob in `dir`;
261
266
  - `fixedTime`: the instant `Date` returns (default `2026-01-01T12:00:00.000Z`).
262
267
  - `gaps`: lists the component files no story covers in `gaps.json`.
263
268
  `componentGlob` is relative to `root`; stories, tests and specs never count.
269
+ A file with nothing to show (a context provider, a controller, a hook
270
+ wrapper) says so with a comment, `// design-kit: no-story <reason>` (a block
271
+ or JSX comment works too): its row has reason `exempt` and the reason as
272
+ `detail`, so a report can list it apart. A marker with no reason exempts
273
+ nothing; the row stays `no story`.
264
274
 
265
275
  ### `pages`
266
276
 
@@ -362,12 +372,15 @@ both with the same `--out`.
362
372
  Exit codes: 0 done, 1 a capture could not run (no Storybook build, the app
363
373
  did not start, a forbidden string) or `init` found the repository already set
364
374
  up, 2 a config or usage error. A story or page that fails is a
365
- `gaps.json` row and does not fail the run.
375
+ `gaps.json` row and does not fail the run. That includes a story that holds
376
+ the page's main thread: every wait on its page has a timer outside the page,
377
+ so it becomes a `timeout` gap instead of stalling the run.
366
378
 
367
379
  Operational knobs: `DESIGN_KIT_CONCURRENCY` (browser pages at once, default
368
380
  4), `DESIGN_KIT_STORY_TIMEOUT_MS` (render and, separately, settle budget per
369
381
  story, default 15000), `DESIGN_KIT_QUIET_MS` (how long the DOM must stay
370
- unchanged, default 600), `DESIGN_KIT_TIMINGS_FILE` (per-phase capture times),
382
+ unchanged, default 600), `DESIGN_KIT_PARAMS_READ_MS` (how long a failed capture
383
+ waits to read the story's `designKit.widths`, default 1000), `DESIGN_KIT_TIMINGS_FILE` (per-phase capture times),
371
384
  `DESIGN_KIT_STOP_TIMEOUT_MS` (how long stopping the app may take).
372
385
 
373
386
  ## Capture rules
@@ -378,7 +391,10 @@ unchanged, default 600), `DESIGN_KIT_TIMINGS_FILE` (per-phase capture times),
378
391
  - A story is captured after Storybook's `storyRendered` event (its `play`
379
392
  function has finished), fonts, frame and image loads, no request in flight,
380
393
  and six frames plus `DESIGN_KIT_QUIET_MS` with no DOM change. A story that
381
- throws, never renders or never stops changing is a gap.
394
+ throws, never renders or never stops changing is a gap. A story driven by a
395
+ live timer or a looping script can tell it is being captured from
396
+ `window.__DESIGN_KIT_CAPTURE__ === true`, set before any page script runs,
397
+ and render one still state of it instead.
382
398
  - Attributes are serialized in sorted order, and React `useId` values are
383
399
  renumbered in pages, so the markup depends on the final state, not on timing.
384
400
  - Story captures only reach the local Storybook server. Same-origin images
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codepress/design-kit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Capture your Storybook and real app pages as a static design kit, and compose self-contained mockups from it",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,6 +6,7 @@
6
6
  * DESIGN_KIT_CONCURRENCY caps the browser pages working at once (default 4);
7
7
  * DESIGN_KIT_STORY_TIMEOUT_MS bounds each capture (default 15000);
8
8
  * DESIGN_KIT_QUIET_MS is how long the DOM must stay unchanged (default 600);
9
+ * DESIGN_KIT_PARAMS_READ_MS bounds reading a failed story's widths (default 1000);
9
10
  * DESIGN_KIT_TIMINGS_FILE, when set, receives the per-phase time breakdown.
10
11
  * Needs Playwright's Chromium (`npx playwright install chromium`).
11
12
  */
@@ -45,6 +46,38 @@ const QUIET_FRAMES = 6;
45
46
  */
46
47
  const quietMs =
47
48
  Number.parseInt(process.env.DESIGN_KIT_QUIET_MS ?? "600", 10) || 0;
49
+ /**
50
+ * DESIGN_KIT_PARAMS_READ_MS: how long a failed capture waits to read the
51
+ * story's `designKit.widths` from the preview (default 1000).
52
+ */
53
+ const paramsReadMs =
54
+ Number.parseInt(process.env.DESIGN_KIT_PARAMS_READ_MS ?? "1000", 10) || 1000;
55
+
56
+ /**
57
+ * The console prefix a story's page announces its own widths with at
58
+ * storyPrepared. A console message reaches node even when the story holds
59
+ * the main thread right after, which a read of the page never would.
60
+ */
61
+ const WIDTHS_ANNOUNCEMENT = "design-kit:widths ";
62
+
63
+ /** A page that stopped answering: its main thread is held by the story. */
64
+ class Unresponsive extends Error {}
65
+
66
+ /**
67
+ * Settles with `promise`, or rejects with Unresponsive after `ms`.
68
+ * Playwright's evaluate has no timeout, and one set in the page cannot fire
69
+ * while a story holds the page's main thread, so every wait on a story's
70
+ * page goes through this node-side timer. The timer is cleared either way.
71
+ */
72
+ function bounded(promise, ms, detail) {
73
+ let timer;
74
+ return Promise.race([
75
+ promise,
76
+ new Promise((_, fail) => {
77
+ timer = setTimeout(() => fail(new Unresponsive(detail)), ms);
78
+ }),
79
+ ]).finally(() => clearTimeout(timer));
80
+ }
48
81
 
49
82
  /** Storybook's built-in viewport names (minimal set plus the iPhones in use). */
50
83
  const NAMED_VIEWPORTS = {
@@ -151,15 +184,50 @@ export function mergeManifest(existing, { sha, builtAt, stories, theme }) {
151
184
  };
152
185
  }
153
186
 
187
+ /**
188
+ * A story's own widths, from `parameters.designKit.widths`: numbers or
189
+ * `{width, height}` entries. Anything else, or an empty list, is no list,
190
+ * so a malformed parameter never drops a story.
191
+ */
192
+ export function ownWidths(raw) {
193
+ if (!Array.isArray(raw)) return null;
194
+ const list = [];
195
+ for (const entry of raw) {
196
+ const width = typeof entry === "number" ? entry : entry?.width;
197
+ const height = typeof entry === "number" ? undefined : entry?.height;
198
+ if (!Number.isInteger(width) || width <= 0) continue;
199
+ if (list.some((planned) => planned.width === width)) continue;
200
+ list.push(
201
+ Number.isInteger(height) && height > 0 ? { width, height } : { width },
202
+ );
203
+ }
204
+ return list.length ? list : null;
205
+ }
206
+
154
207
  /**
155
208
  * Which captures a story gets: its declared viewport or the default, plus
156
209
  * each configured width whose `titles` pattern matches the story's title.
210
+ * A story that lists its own widths (`parameters.designKit.widths`, see
211
+ * ownWidths) gets exactly those, each with its listed height, else the
212
+ * height the declared viewport, default or a configured width has for it,
213
+ * else the default height.
157
214
  */
158
215
  export function plannedViewports(
159
216
  title,
160
217
  declared,
161
218
  { viewport = DEFAULT_VIEWPORT, widths = [] } = {},
219
+ own = null,
162
220
  ) {
221
+ if (own?.length) {
222
+ const known = [declared, viewport, ...widths].filter(Boolean);
223
+ return own.map(({ width, height }) => ({
224
+ width,
225
+ height:
226
+ height ??
227
+ known.find((size) => size.width === width && size.height)?.height ??
228
+ viewport.height,
229
+ }));
230
+ }
163
231
  const first = declared ?? viewport;
164
232
  const list = [first];
165
233
  for (const extra of widths) {
@@ -276,7 +344,7 @@ export function fontCss(base, fonts, files = fontFiles(fonts)) {
276
344
  }
277
345
 
278
346
  /** Runs before any page script: a seeded Math.random, the font CSS, and a channel tap. */
279
- function pageInit({ css, families }) {
347
+ function pageInit({ css, families, width, announcement }) {
280
348
  let seed = 0x2f6b9a1d;
281
349
  Math.random = () => {
282
350
  seed = (seed + 0x6d2b79f5) | 0;
@@ -297,6 +365,9 @@ function pageInit({ css, families }) {
297
365
  }
298
366
  const kit = { outcome: null, detail: "", pending: 0 };
299
367
  window.__kit = kit;
368
+ // Public: a story with live data or a running animation can render a
369
+ // still state of it when this is set (README "Capture rules").
370
+ window.__DESIGN_KIT_CAPTURE__ = true;
300
371
  // In-flight requests, so settling can wait for them rather than a timer.
301
372
  const track = (promise) => {
302
373
  kit.pending += 1;
@@ -345,6 +416,25 @@ function pageInit({ css, families }) {
345
416
  finish("threw", `play: ${describe(e)}`),
346
417
  );
347
418
  value.on("storyMissing", () => finish("missing"));
419
+ // A story that lists its own widths and leaves this one out is not
420
+ // captured here: no render, no play, no gap (ownWidths).
421
+ value.on("storyPrepared", (event) => {
422
+ const own = event?.parameters?.designKit?.widths;
423
+ if (!Array.isArray(own)) return;
424
+ kit.widths = own;
425
+ try {
426
+ console.debug(`${announcement}${JSON.stringify(own)}`);
427
+ } catch {
428
+ // A list that cannot be serialized is read from __kit instead.
429
+ }
430
+ // The same rule as ownWidths: positive integer widths only, and no
431
+ // usable width means no list, so a malformed one never skips.
432
+ const listed = own
433
+ .map((entry) => (typeof entry === "number" ? entry : entry?.width))
434
+ .filter((value) => Number.isInteger(value) && value > 0);
435
+ if (listed.length && !listed.includes(width))
436
+ finish("skipped", `not listed in designKit.widths`);
437
+ });
348
438
  },
349
439
  });
350
440
  window.__kitLoaded = new WeakSet();
@@ -442,6 +532,7 @@ async function extract() {
442
532
  ".sb-preparing-story,.sb-preparing-docs,.sb-nopreview,.sb-errordisplay,#storybook-docs,script,noscript,template,link,[data-kit-inject]";
443
533
  const story = window.__STORYBOOK_PREVIEW__?.currentRender?.story;
444
534
  const parameters = story?.parameters ?? {};
535
+ const widths = parameters.designKit?.widths ?? null;
445
536
  const viewport = parameters.viewport ?? {};
446
537
  let declared = null;
447
538
  const name = viewport.defaultViewport;
@@ -577,6 +668,7 @@ async function extract() {
577
668
  html,
578
669
  sheets,
579
670
  declared,
671
+ widths,
580
672
  bodyClass,
581
673
  empty: !rootHasContent && !portalHasContent,
582
674
  };
@@ -638,7 +730,12 @@ async function freshPage({
638
730
  const page = await context.newPage();
639
731
  page.on("pageerror", () => {});
640
732
  await page.clock.setFixedTime(new Date(fixedTime));
641
- await page.addInitScript(pageInit, { css, families });
733
+ await page.addInitScript(pageInit, {
734
+ css,
735
+ families,
736
+ width: viewport.width,
737
+ announcement: WIDTHS_ANNOUNCEMENT,
738
+ });
642
739
  return { context, page, cdp: await context.newCDPSession(page) };
643
740
  }
644
741
 
@@ -659,6 +756,20 @@ async function captureTimed(
659
756
  }));
660
757
  const { context, page, cdp } = slot;
661
758
  let reusable = false;
759
+ // The widths this story announced, heard even if its page then stops
760
+ // answering (WIDTHS_ANNOUNCEMENT).
761
+ let announced;
762
+ const hear = (message) => {
763
+ if (message.type() !== "debug") return;
764
+ const text = message.text();
765
+ if (!text.startsWith(WIDTHS_ANNOUNCEMENT)) return;
766
+ try {
767
+ announced = JSON.parse(text.slice(WIDTHS_ANNOUNCEMENT.length));
768
+ } catch {
769
+ // Not ours after all; the bounded read below still runs.
770
+ }
771
+ };
772
+ page.on("console", hear);
662
773
  try {
663
774
  await cdp.send("Storage.clearDataForOrigin", {
664
775
  origin,
@@ -673,18 +784,50 @@ async function captureTimed(
673
784
  `${origin}/iframe.html?id=${encodeURIComponent(story.id)}&viewMode=story&globals=a11y.manual:!true`,
674
785
  { waitUntil: "domcontentloaded", timeout },
675
786
  );
676
- const outcome = await page
677
- .waitForFunction(() => window.__kit?.outcome, null, {
678
- timeout: Math.max(1000, deadline - Date.now()),
679
- polling: 50,
680
- })
681
- .then(() => page.evaluate(() => ({ ...window.__kit })))
682
- .catch(() => ({
683
- outcome: "timeout",
684
- detail: `no storyRendered within ${timeout}ms`,
685
- }));
787
+ // The node-side timer bounds the wait too: Playwright's own timeout never
788
+ // fires while a story holds the page's main thread.
789
+ const notRendered = {
790
+ outcome: "timeout",
791
+ detail: `no storyRendered within ${timeout}ms`,
792
+ };
793
+ const outcome = await bounded(
794
+ page
795
+ .waitForFunction(() => window.__kit?.outcome, null, {
796
+ timeout: Math.max(1000, deadline - Date.now()),
797
+ polling: 50,
798
+ })
799
+ .then(() => page.evaluate(() => ({ ...window.__kit }))),
800
+ Math.max(1000, deadline - Date.now()) + 1000,
801
+ ).catch(() => notRendered);
686
802
  if (outcome.outcome !== "rendered") {
803
+ // The story's own widths: from storyPrepared, or read from the preview
804
+ // when this Storybook sends no parameters with it.
805
+ // Bounded: a story that holds the main thread (the usual reason for a
806
+ // timeout) would never answer, and the lane would never finish.
807
+ const own = ownWidths(
808
+ outcome.widths ??
809
+ announced ??
810
+ (await bounded(
811
+ page.evaluate(
812
+ () =>
813
+ window.__STORYBOOK_PREVIEW__?.currentRender?.story?.parameters
814
+ ?.designKit?.widths ?? null,
815
+ ),
816
+ paramsReadMs,
817
+ ).catch(() => null)),
818
+ );
819
+ // Left out of the story's own widths: not a capture and not a gap.
820
+ // Any other failure stays one.
821
+ if (
822
+ outcome.outcome === "skipped" ||
823
+ (own && !own.some((entry) => entry.width === viewport.width))
824
+ ) {
825
+ // A page that timed out may still be held: never park it.
826
+ reusable = outcome.outcome !== "timeout";
827
+ return { skipped: true, widths: own };
828
+ }
687
829
  return {
830
+ widths: own,
688
831
  failure: {
689
832
  reason: outcome.outcome === "missing" ? "missing" : outcome.outcome,
690
833
  detail: outcome.detail,
@@ -701,7 +844,12 @@ async function captureTimed(
701
844
  // guests one after another. Settle the guests, then the host, until a
702
845
  // round brings no new frame and no host change.
703
846
  const settleBy = Date.now() + timeout;
704
- let settled = (await page.evaluate(settle, quiet)).settled;
847
+ // `settle` gives up in the page after `timeout`; a second more covers the
848
+ // round trip, so only a page that stopped answering reaches the bound.
849
+ const unanswered = `page stopped answering after storyRendered (${timeout}ms)`;
850
+ const settleOn = (target) =>
851
+ bounded(target.evaluate(settle, quiet), timeout + 1000, unanswered);
852
+ let settled = (await settleOn(page)).settled;
705
853
  let known = new Set();
706
854
  while (settled) {
707
855
  const guests = page
@@ -711,12 +859,16 @@ async function captureTimed(
711
859
  if (!fresh && known.size === 0) break;
712
860
  known = new Set(guests);
713
861
  const results = await Promise.all(
714
- // A frame that navigated away or detached has nothing left to wait for.
862
+ // A frame that navigated away or detached has nothing left to wait
863
+ // for; one that stopped answering is a timeout like its host.
715
864
  guests.map((frame) =>
716
- frame.evaluate(settle, quiet).catch(() => ({ settled: true })),
865
+ settleOn(frame).catch((error) => {
866
+ if (error instanceof Unresponsive) throw error;
867
+ return { settled: true };
868
+ }),
717
869
  ),
718
870
  );
719
- const host = await page.evaluate(settle, quiet);
871
+ const host = await settleOn(page);
720
872
  settled =
721
873
  results.every((result) => result.settled) &&
722
874
  host.settled &&
@@ -732,7 +884,11 @@ async function captureTimed(
732
884
  };
733
885
  }
734
886
  step("serialize");
735
- const result = await page.evaluate(extract);
887
+ const result = await bounded(
888
+ page.evaluate(extract),
889
+ timeout,
890
+ `page stopped answering while serializing (${timeout}ms)`,
891
+ );
736
892
  // An inline sheet is keyed by its text, which resolved its url()s against
737
893
  // this run's server; the port must not reach the kit.
738
894
  for (const sheet of result.sheets)
@@ -746,38 +902,48 @@ async function captureTimed(
746
902
  };
747
903
  }
748
904
  reusable = true;
749
- return { result };
905
+ return { result, widths: ownWidths(result.widths ?? outcome.widths) };
750
906
  } catch (error) {
751
907
  return {
752
908
  failure: {
753
- reason: /Timeout/i.test(String(error)) ? "timeout" : "threw",
909
+ reason:
910
+ error instanceof Unresponsive || /Timeout/i.test(String(error))
911
+ ? "timeout"
912
+ : "threw",
754
913
  detail: String(error?.message ?? error)
755
914
  .split("\n")[0]
756
915
  .slice(0, 500),
757
916
  },
758
917
  };
759
918
  } finally {
919
+ page.off("console", hear);
760
920
  step("close");
761
921
  const parked =
762
922
  reusable &&
763
923
  parkedCount() < parkLimit &&
764
- (await page
765
- // Session storage lives with the tab, not the origin's data, so it
766
- // is cleared on the story's own page before leaving it.
767
- .evaluate(() => sessionStorage.clear())
768
- .then(() => page.goto("about:blank"))
769
- .then(() =>
770
- page.evaluate(() => {
771
- window.name = "";
772
- }),
773
- )
924
+ (await bounded(
925
+ page
926
+ // Session storage lives with the tab, not the origin's data, so it
927
+ // is cleared on the story's own page before leaving it.
928
+ .evaluate(() => sessionStorage.clear())
929
+ .then(() => page.goto("about:blank", { timeout }))
930
+ .then(() =>
931
+ page.evaluate(() => {
932
+ window.name = "";
933
+ }),
934
+ ),
935
+ timeout,
936
+ )
774
937
  .then(() => true)
775
938
  .catch(() => false));
776
939
  if (parked) {
777
940
  const list = idlePages.get(key) ?? [];
778
941
  list.push(slot);
779
942
  idlePages.set(key, list);
780
- } else await context.close().catch(() => {});
943
+ } else {
944
+ // A context whose page is held is left to browser.close() at the end.
945
+ await bounded(context.close(), timeout).catch(() => {});
946
+ }
781
947
  }
782
948
  }
783
949
 
@@ -829,11 +995,32 @@ function storybookStories(buildDir) {
829
995
  .sort((a, b) => (a.id < b.id ? -1 : 1));
830
996
  }
831
997
 
998
+ /**
999
+ * The reason a `design-kit: no-story <reason>` comment gives (a `//`, block or
1000
+ * JSX comment anywhere in the file), or null when there is none or its reason
1001
+ * is empty: a bare marker exempts nothing.
1002
+ */
1003
+ export function noStoryReason(source) {
1004
+ const marker =
1005
+ /(?:\/\/|\/\*|^[ \t]*\*)[ \t]*design-kit:[ \t]*no-story\b([^\n]*)/gm;
1006
+ for (const match of String(source).matchAll(marker)) {
1007
+ const reason = match[1]
1008
+ .replace(/\*\/.*$/, "")
1009
+ .replace(/[}\s]+$/, "")
1010
+ .trim();
1011
+ if (reason) return reason;
1012
+ }
1013
+ return null;
1014
+ }
1015
+
832
1016
  /**
833
1017
  * Component files nothing documents: files under `gaps.sourceRoot` whose path
834
1018
  * from the Storybook project matches `gaps.componentGlob`, minus stories,
835
1019
  * tests and specs, that no story names as its component or import. Paths in
836
- * the rows are relative to the config file's folder.
1020
+ * the rows are relative to the config file's folder. A file carrying a
1021
+ * `design-kit: no-story <reason>` comment (a context, a controller, a hook
1022
+ * wrapper) is an `exempt` row with that reason as its detail, listed apart
1023
+ * so a person can check the reason; with an empty reason it stays `no story`.
837
1024
  */
838
1025
  export function componentsWithoutStories(stories, { gaps, root, configDir }) {
839
1026
  const documented = new Set(
@@ -855,10 +1042,13 @@ export function componentsWithoutStories(stories, { gaps, root, configDir }) {
855
1042
  } else if (!/\.(stories|test|spec)\.[cm]?[jt]sx?$/.test(name.name)) {
856
1043
  const rel = relative(root, path).split(sep).join("/");
857
1044
  if (matchFile(pattern, rel) && !documented.has(rel)) {
858
- out.push({
859
- component: relative(configDir, path).split(sep).join("/"),
860
- reason: "no story",
861
- });
1045
+ const component = relative(configDir, path).split(sep).join("/");
1046
+ const exempt = noStoryReason(readFileSync(path, "utf8"));
1047
+ out.push(
1048
+ exempt
1049
+ ? { component, reason: "exempt", detail: exempt }
1050
+ : { component, reason: "no story" },
1051
+ );
862
1052
  }
863
1053
  }
864
1054
  }
@@ -1002,7 +1192,11 @@ export async function captureStories(config, options = {}) {
1002
1192
  const keepSheetsOnce = sheetStore();
1003
1193
  // What the timings report needs from a capture, without its result.
1004
1194
  const attempt = (capture) => {
1005
- const record = { timing: capture.timing, failure: capture.failure };
1195
+ const record = {
1196
+ timing: capture.timing,
1197
+ failure: capture.failure,
1198
+ skipped: capture.skipped,
1199
+ };
1006
1200
  attempts.push(record);
1007
1201
  return record;
1008
1202
  };
@@ -1019,16 +1213,28 @@ export async function captureStories(config, options = {}) {
1019
1213
  fixedTime,
1020
1214
  });
1021
1215
  const firstAttempt = attempt(first);
1022
- let viewports = plannedViewports(story.title, null, planning);
1023
- const results = new Map([[planning.viewport.width, first]]);
1024
- if (first.result) {
1025
- const declared = viewportFor(first.result.declared);
1026
- viewports = plannedViewports(story.title, declared, planning);
1027
- if (declared) {
1028
- results.delete(planning.viewport.width);
1029
- firstAttempt.discarded = true;
1030
- }
1031
- }
1216
+ // The story's declared viewport and own widths are known once it has
1217
+ // loaded at the default one; that first capture is kept only when the
1218
+ // plan still has the default viewport in it.
1219
+ const declared = first.result ? viewportFor(first.result.declared) : null;
1220
+ const viewports = plannedViewports(
1221
+ story.title,
1222
+ declared,
1223
+ planning,
1224
+ first.widths,
1225
+ );
1226
+ const results = new Map();
1227
+ if (
1228
+ !first.skipped &&
1229
+ !declared &&
1230
+ viewports.some(
1231
+ (viewport) =>
1232
+ viewport.width === planning.viewport.width &&
1233
+ viewport.height === planning.viewport.height,
1234
+ )
1235
+ ) {
1236
+ results.set(planning.viewport.width, first);
1237
+ } else if (!first.skipped) firstAttempt.discarded = true;
1032
1238
  for (const viewport of viewports) {
1033
1239
  if (!results.has(viewport.width)) {
1034
1240
  const capture = await captureOne({
@@ -1046,6 +1252,7 @@ export async function captureStories(config, options = {}) {
1046
1252
  }
1047
1253
  const capture = results.get(viewport.width);
1048
1254
  const id = kitId(story.id, viewport.width);
1255
+ if (capture.skipped) continue;
1049
1256
  if (capture.failure) {
1050
1257
  failures.push({ id, ...capture.failure });
1051
1258
  } else {
@@ -1236,6 +1443,7 @@ export async function captureStories(config, options = {}) {
1236
1443
  const timings = {
1237
1444
  ...timingBreakdown(attempts),
1238
1445
  discarded: attempts.filter((a) => a.discarded).length,
1446
+ skipped: attempts.filter((a) => a.skipped).length,
1239
1447
  lanes: concurrency,
1240
1448
  quiet_ms: quietMs,
1241
1449
  timeout_ms: timeout,
@@ -1255,6 +1463,9 @@ export async function captureStories(config, options = {}) {
1255
1463
  return { out, rows: rows.length, failed: storyGaps.length };
1256
1464
  } finally {
1257
1465
  await browser?.close().catch(() => {});
1466
+ // Parked pages belong to this browser; a later capture in the same
1467
+ // process must not pick one up.
1468
+ idlePages.clear();
1258
1469
  server?.close();
1259
1470
  if (ownedBuild) rmSync(ownedBuild, { recursive: true, force: true });
1260
1471
  }