@codepress/design-kit 0.5.0 → 0.7.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
@@ -392,6 +408,7 @@ design-kit/
392
408
  manifest.json {kit_version, sha, built_at, counts, sources, theme?, css?}
393
409
  index.json story rows, sorted by id
394
410
  routes.json page rows
411
+ text.json {id: the row's visible text, first 600 characters}
395
412
  html/story--<id>@<w>.html, html/route--<name>@<w>.html
396
413
  css/stories.css every rule the Storybook preview loaded, deduplicated
397
414
  css/routes.css every rule the captured pages loaded, deduplicated
@@ -418,11 +435,31 @@ The tool needs only Node: no install and no network.
418
435
  ```bash
419
436
  node kit.mjs info # the manifest
420
437
  node kit.mjs find button # ≤ 30 lines: id, kind, width, bytes, title
438
+ node kit.mjs find "transfer app" # the words on screen work too
421
439
  node kit.mjs show <id> [--full] # path, size and an outline (≤ 2 KB) or the markup
422
440
  node kit.mjs compose --frame route:dashboard@1280 --slot main=new.html \
423
441
  [--theme light|dark] [--show-new] --out page.html
424
442
  ```
425
443
 
444
+ `find` lists the rows holding every word of the query in their id, title,
445
+ component, source file or visible text. Rows with the words in the id come
446
+ first, then the title, component or source file, then the text only; among
447
+ rows that tie, one whose text holds the whole query comes first. A row found
448
+ only through its text ends with a quoted excerpt of about 60 characters
449
+ around the words, so you see why it matched. When more rows match than the
450
+ 30 lines hold, rows found by name fill the list but its last 5 lines are kept
451
+ for rows found by text, and the closing line counts what each left out
452
+ (`+37 more by name, +3 more by on-screen text; narrow the query`). The text
453
+ is `text.json`, built
454
+ whenever a capture writes the kit: each row's markup without its scripts,
455
+ styles, SVG, head and templates, tags and attribute values dropped, entities
456
+ decoded and whitespace collapsed, with the row's `data-kit-slot` regions
457
+ first (so a page's own words come ahead of the navigation every page
458
+ repeats), cut to 600 characters. Rows an incremental build copies from its
459
+ base get text too, and a page capture refuses a `text.json` holding a
460
+ `forbidStrings` value as it does the markup. A kit without `text.json` finds
461
+ by id, title, component and source file only.
462
+
426
463
  `compose` writes one self-contained page: the frame's markup with slots
427
464
  filled and every `<kit-snippet id="…">` expanded, its CSS inlined, every
428
465
  relative `url()` that names a file in the kit (fonts included) as a data URI,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codepress/design-kit",
3
- "version": "0.5.0",
3
+ "version": "0.7.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": {
@@ -42,6 +42,7 @@ import {
42
42
  sourceCollisions,
43
43
  } from "./pages.mjs";
44
44
  import { ConfigError } from "./config.mjs";
45
+ import { kitText, kitTextJson, visibleText } from "./kit.mjs";
45
46
  import { loadPlaywright } from "./playwright.mjs";
46
47
 
47
48
  const STOP_TIMEOUT_MS =
@@ -601,6 +602,8 @@ export async function capturePages(config, options = {}) {
601
602
  const found = findForbidden(
602
603
  [
603
604
  html,
605
+ // The words text.json will hold, entities decoded.
606
+ visibleText(html),
604
607
  JSON.stringify(capture.htmlAttrs),
605
608
  JSON.stringify(capture.bodyAttrs),
606
609
  ].join("\n"),
@@ -774,6 +777,20 @@ export function writePagesKit(
774
777
  )
775
778
  .join("\n"),
776
779
  );
780
+ const sortedRows = mergeRoutes(existingRows, rows, owns);
781
+ // text.json covers the whole kit, stories included, and is checked for
782
+ // forbidden values before anything is removed or written.
783
+ const text = kitTextJson(
784
+ kitText(
785
+ [...asArray(readJson(path.join(out, "index.json"))), ...sortedRows],
786
+ (file) =>
787
+ files.get(file) ?? fs.readFileSync(path.join(out, file), "utf8"),
788
+ ),
789
+ );
790
+ if (findForbidden(text, needles))
791
+ throw new Error(
792
+ "text.json carries a forbidStrings value; refusing to write the kit",
793
+ );
777
794
  fs.mkdirSync(path.join(out, "html"), { recursive: true });
778
795
  fs.mkdirSync(path.join(out, "css"), { recursive: true });
779
796
  // This half's files only: earlier page captures go, story files stay. A
@@ -807,11 +824,11 @@ export function writePagesKit(
807
824
  for (const file of stale) fs.rmSync(path.join(out, file), { force: true });
808
825
  }
809
826
 
810
- const sortedRows = mergeRoutes(existingRows, rows, owns);
811
827
  const written = [
812
828
  ...[...files.entries()].sort(byKey(([file]) => file)),
813
829
  ["routes.json", kitJson(sortedRows)],
814
830
  [routeCssFile(source), css],
831
+ ["text.json", text],
815
832
  ];
816
833
  for (const [file, text] of written) {
817
834
  if (findForbidden(text, needles))
@@ -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
  */
@@ -29,6 +30,7 @@ import { extname, join, relative, resolve, sep } from "node:path";
29
30
  import { fileURLToPath } from "node:url";
30
31
 
31
32
  import { globToRegExp, matchFile } from "./glob.mjs";
33
+ import { writeKitText } from "./kit.mjs";
32
34
  import { loadPlaywright, playwrightVersion } from "./playwright.mjs";
33
35
 
34
36
  export { playwrightVersion };
@@ -45,6 +47,38 @@ const QUIET_FRAMES = 6;
45
47
  */
46
48
  const quietMs =
47
49
  Number.parseInt(process.env.DESIGN_KIT_QUIET_MS ?? "600", 10) || 0;
50
+ /**
51
+ * DESIGN_KIT_PARAMS_READ_MS: how long a failed capture waits to read the
52
+ * story's `designKit.widths` from the preview (default 1000).
53
+ */
54
+ const paramsReadMs =
55
+ Number.parseInt(process.env.DESIGN_KIT_PARAMS_READ_MS ?? "1000", 10) || 1000;
56
+
57
+ /**
58
+ * The console prefix a story's page announces its own widths with at
59
+ * storyPrepared. A console message reaches node even when the story holds
60
+ * the main thread right after, which a read of the page never would.
61
+ */
62
+ const WIDTHS_ANNOUNCEMENT = "design-kit:widths ";
63
+
64
+ /** A page that stopped answering: its main thread is held by the story. */
65
+ class Unresponsive extends Error {}
66
+
67
+ /**
68
+ * Settles with `promise`, or rejects with Unresponsive after `ms`.
69
+ * Playwright's evaluate has no timeout, and one set in the page cannot fire
70
+ * while a story holds the page's main thread, so every wait on a story's
71
+ * page goes through this node-side timer. The timer is cleared either way.
72
+ */
73
+ function bounded(promise, ms, detail) {
74
+ let timer;
75
+ return Promise.race([
76
+ promise,
77
+ new Promise((_, fail) => {
78
+ timer = setTimeout(() => fail(new Unresponsive(detail)), ms);
79
+ }),
80
+ ]).finally(() => clearTimeout(timer));
81
+ }
48
82
 
49
83
  /** Storybook's built-in viewport names (minimal set plus the iPhones in use). */
50
84
  const NAMED_VIEWPORTS = {
@@ -151,15 +185,50 @@ export function mergeManifest(existing, { sha, builtAt, stories, theme }) {
151
185
  };
152
186
  }
153
187
 
188
+ /**
189
+ * A story's own widths, from `parameters.designKit.widths`: numbers or
190
+ * `{width, height}` entries. Anything else, or an empty list, is no list,
191
+ * so a malformed parameter never drops a story.
192
+ */
193
+ export function ownWidths(raw) {
194
+ if (!Array.isArray(raw)) return null;
195
+ const list = [];
196
+ for (const entry of raw) {
197
+ const width = typeof entry === "number" ? entry : entry?.width;
198
+ const height = typeof entry === "number" ? undefined : entry?.height;
199
+ if (!Number.isInteger(width) || width <= 0) continue;
200
+ if (list.some((planned) => planned.width === width)) continue;
201
+ list.push(
202
+ Number.isInteger(height) && height > 0 ? { width, height } : { width },
203
+ );
204
+ }
205
+ return list.length ? list : null;
206
+ }
207
+
154
208
  /**
155
209
  * Which captures a story gets: its declared viewport or the default, plus
156
210
  * each configured width whose `titles` pattern matches the story's title.
211
+ * A story that lists its own widths (`parameters.designKit.widths`, see
212
+ * ownWidths) gets exactly those, each with its listed height, else the
213
+ * height the declared viewport, default or a configured width has for it,
214
+ * else the default height.
157
215
  */
158
216
  export function plannedViewports(
159
217
  title,
160
218
  declared,
161
219
  { viewport = DEFAULT_VIEWPORT, widths = [] } = {},
220
+ own = null,
162
221
  ) {
222
+ if (own?.length) {
223
+ const known = [declared, viewport, ...widths].filter(Boolean);
224
+ return own.map(({ width, height }) => ({
225
+ width,
226
+ height:
227
+ height ??
228
+ known.find((size) => size.width === width && size.height)?.height ??
229
+ viewport.height,
230
+ }));
231
+ }
163
232
  const first = declared ?? viewport;
164
233
  const list = [first];
165
234
  for (const extra of widths) {
@@ -276,7 +345,7 @@ export function fontCss(base, fonts, files = fontFiles(fonts)) {
276
345
  }
277
346
 
278
347
  /** Runs before any page script: a seeded Math.random, the font CSS, and a channel tap. */
279
- function pageInit({ css, families }) {
348
+ function pageInit({ css, families, width, announcement }) {
280
349
  let seed = 0x2f6b9a1d;
281
350
  Math.random = () => {
282
351
  seed = (seed + 0x6d2b79f5) | 0;
@@ -297,6 +366,9 @@ function pageInit({ css, families }) {
297
366
  }
298
367
  const kit = { outcome: null, detail: "", pending: 0 };
299
368
  window.__kit = kit;
369
+ // Public: a story with live data or a running animation can render a
370
+ // still state of it when this is set (README "Capture rules").
371
+ window.__DESIGN_KIT_CAPTURE__ = true;
300
372
  // In-flight requests, so settling can wait for them rather than a timer.
301
373
  const track = (promise) => {
302
374
  kit.pending += 1;
@@ -345,6 +417,25 @@ function pageInit({ css, families }) {
345
417
  finish("threw", `play: ${describe(e)}`),
346
418
  );
347
419
  value.on("storyMissing", () => finish("missing"));
420
+ // A story that lists its own widths and leaves this one out is not
421
+ // captured here: no render, no play, no gap (ownWidths).
422
+ value.on("storyPrepared", (event) => {
423
+ const own = event?.parameters?.designKit?.widths;
424
+ if (!Array.isArray(own)) return;
425
+ kit.widths = own;
426
+ try {
427
+ console.debug(`${announcement}${JSON.stringify(own)}`);
428
+ } catch {
429
+ // A list that cannot be serialized is read from __kit instead.
430
+ }
431
+ // The same rule as ownWidths: positive integer widths only, and no
432
+ // usable width means no list, so a malformed one never skips.
433
+ const listed = own
434
+ .map((entry) => (typeof entry === "number" ? entry : entry?.width))
435
+ .filter((value) => Number.isInteger(value) && value > 0);
436
+ if (listed.length && !listed.includes(width))
437
+ finish("skipped", `not listed in designKit.widths`);
438
+ });
348
439
  },
349
440
  });
350
441
  window.__kitLoaded = new WeakSet();
@@ -442,6 +533,7 @@ async function extract() {
442
533
  ".sb-preparing-story,.sb-preparing-docs,.sb-nopreview,.sb-errordisplay,#storybook-docs,script,noscript,template,link,[data-kit-inject]";
443
534
  const story = window.__STORYBOOK_PREVIEW__?.currentRender?.story;
444
535
  const parameters = story?.parameters ?? {};
536
+ const widths = parameters.designKit?.widths ?? null;
445
537
  const viewport = parameters.viewport ?? {};
446
538
  let declared = null;
447
539
  const name = viewport.defaultViewport;
@@ -577,6 +669,7 @@ async function extract() {
577
669
  html,
578
670
  sheets,
579
671
  declared,
672
+ widths,
580
673
  bodyClass,
581
674
  empty: !rootHasContent && !portalHasContent,
582
675
  };
@@ -638,7 +731,12 @@ async function freshPage({
638
731
  const page = await context.newPage();
639
732
  page.on("pageerror", () => {});
640
733
  await page.clock.setFixedTime(new Date(fixedTime));
641
- await page.addInitScript(pageInit, { css, families });
734
+ await page.addInitScript(pageInit, {
735
+ css,
736
+ families,
737
+ width: viewport.width,
738
+ announcement: WIDTHS_ANNOUNCEMENT,
739
+ });
642
740
  return { context, page, cdp: await context.newCDPSession(page) };
643
741
  }
644
742
 
@@ -659,6 +757,20 @@ async function captureTimed(
659
757
  }));
660
758
  const { context, page, cdp } = slot;
661
759
  let reusable = false;
760
+ // The widths this story announced, heard even if its page then stops
761
+ // answering (WIDTHS_ANNOUNCEMENT).
762
+ let announced;
763
+ const hear = (message) => {
764
+ if (message.type() !== "debug") return;
765
+ const text = message.text();
766
+ if (!text.startsWith(WIDTHS_ANNOUNCEMENT)) return;
767
+ try {
768
+ announced = JSON.parse(text.slice(WIDTHS_ANNOUNCEMENT.length));
769
+ } catch {
770
+ // Not ours after all; the bounded read below still runs.
771
+ }
772
+ };
773
+ page.on("console", hear);
662
774
  try {
663
775
  await cdp.send("Storage.clearDataForOrigin", {
664
776
  origin,
@@ -673,18 +785,50 @@ async function captureTimed(
673
785
  `${origin}/iframe.html?id=${encodeURIComponent(story.id)}&viewMode=story&globals=a11y.manual:!true`,
674
786
  { waitUntil: "domcontentloaded", timeout },
675
787
  );
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
- }));
788
+ // The node-side timer bounds the wait too: Playwright's own timeout never
789
+ // fires while a story holds the page's main thread.
790
+ const notRendered = {
791
+ outcome: "timeout",
792
+ detail: `no storyRendered within ${timeout}ms`,
793
+ };
794
+ const outcome = await bounded(
795
+ page
796
+ .waitForFunction(() => window.__kit?.outcome, null, {
797
+ timeout: Math.max(1000, deadline - Date.now()),
798
+ polling: 50,
799
+ })
800
+ .then(() => page.evaluate(() => ({ ...window.__kit }))),
801
+ Math.max(1000, deadline - Date.now()) + 1000,
802
+ ).catch(() => notRendered);
686
803
  if (outcome.outcome !== "rendered") {
804
+ // The story's own widths: from storyPrepared, or read from the preview
805
+ // when this Storybook sends no parameters with it.
806
+ // Bounded: a story that holds the main thread (the usual reason for a
807
+ // timeout) would never answer, and the lane would never finish.
808
+ const own = ownWidths(
809
+ outcome.widths ??
810
+ announced ??
811
+ (await bounded(
812
+ page.evaluate(
813
+ () =>
814
+ window.__STORYBOOK_PREVIEW__?.currentRender?.story?.parameters
815
+ ?.designKit?.widths ?? null,
816
+ ),
817
+ paramsReadMs,
818
+ ).catch(() => null)),
819
+ );
820
+ // Left out of the story's own widths: not a capture and not a gap.
821
+ // Any other failure stays one.
822
+ if (
823
+ outcome.outcome === "skipped" ||
824
+ (own && !own.some((entry) => entry.width === viewport.width))
825
+ ) {
826
+ // A page that timed out may still be held: never park it.
827
+ reusable = outcome.outcome !== "timeout";
828
+ return { skipped: true, widths: own };
829
+ }
687
830
  return {
831
+ widths: own,
688
832
  failure: {
689
833
  reason: outcome.outcome === "missing" ? "missing" : outcome.outcome,
690
834
  detail: outcome.detail,
@@ -701,7 +845,12 @@ async function captureTimed(
701
845
  // guests one after another. Settle the guests, then the host, until a
702
846
  // round brings no new frame and no host change.
703
847
  const settleBy = Date.now() + timeout;
704
- let settled = (await page.evaluate(settle, quiet)).settled;
848
+ // `settle` gives up in the page after `timeout`; a second more covers the
849
+ // round trip, so only a page that stopped answering reaches the bound.
850
+ const unanswered = `page stopped answering after storyRendered (${timeout}ms)`;
851
+ const settleOn = (target) =>
852
+ bounded(target.evaluate(settle, quiet), timeout + 1000, unanswered);
853
+ let settled = (await settleOn(page)).settled;
705
854
  let known = new Set();
706
855
  while (settled) {
707
856
  const guests = page
@@ -711,12 +860,16 @@ async function captureTimed(
711
860
  if (!fresh && known.size === 0) break;
712
861
  known = new Set(guests);
713
862
  const results = await Promise.all(
714
- // A frame that navigated away or detached has nothing left to wait for.
863
+ // A frame that navigated away or detached has nothing left to wait
864
+ // for; one that stopped answering is a timeout like its host.
715
865
  guests.map((frame) =>
716
- frame.evaluate(settle, quiet).catch(() => ({ settled: true })),
866
+ settleOn(frame).catch((error) => {
867
+ if (error instanceof Unresponsive) throw error;
868
+ return { settled: true };
869
+ }),
717
870
  ),
718
871
  );
719
- const host = await page.evaluate(settle, quiet);
872
+ const host = await settleOn(page);
720
873
  settled =
721
874
  results.every((result) => result.settled) &&
722
875
  host.settled &&
@@ -732,7 +885,11 @@ async function captureTimed(
732
885
  };
733
886
  }
734
887
  step("serialize");
735
- const result = await page.evaluate(extract);
888
+ const result = await bounded(
889
+ page.evaluate(extract),
890
+ timeout,
891
+ `page stopped answering while serializing (${timeout}ms)`,
892
+ );
736
893
  // An inline sheet is keyed by its text, which resolved its url()s against
737
894
  // this run's server; the port must not reach the kit.
738
895
  for (const sheet of result.sheets)
@@ -746,38 +903,48 @@ async function captureTimed(
746
903
  };
747
904
  }
748
905
  reusable = true;
749
- return { result };
906
+ return { result, widths: ownWidths(result.widths ?? outcome.widths) };
750
907
  } catch (error) {
751
908
  return {
752
909
  failure: {
753
- reason: /Timeout/i.test(String(error)) ? "timeout" : "threw",
910
+ reason:
911
+ error instanceof Unresponsive || /Timeout/i.test(String(error))
912
+ ? "timeout"
913
+ : "threw",
754
914
  detail: String(error?.message ?? error)
755
915
  .split("\n")[0]
756
916
  .slice(0, 500),
757
917
  },
758
918
  };
759
919
  } finally {
920
+ page.off("console", hear);
760
921
  step("close");
761
922
  const parked =
762
923
  reusable &&
763
924
  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
- )
925
+ (await bounded(
926
+ page
927
+ // Session storage lives with the tab, not the origin's data, so it
928
+ // is cleared on the story's own page before leaving it.
929
+ .evaluate(() => sessionStorage.clear())
930
+ .then(() => page.goto("about:blank", { timeout }))
931
+ .then(() =>
932
+ page.evaluate(() => {
933
+ window.name = "";
934
+ }),
935
+ ),
936
+ timeout,
937
+ )
774
938
  .then(() => true)
775
939
  .catch(() => false));
776
940
  if (parked) {
777
941
  const list = idlePages.get(key) ?? [];
778
942
  list.push(slot);
779
943
  idlePages.set(key, list);
780
- } else await context.close().catch(() => {});
944
+ } else {
945
+ // A context whose page is held is left to browser.close() at the end.
946
+ await bounded(context.close(), timeout).catch(() => {});
947
+ }
781
948
  }
782
949
  }
783
950
 
@@ -829,11 +996,32 @@ function storybookStories(buildDir) {
829
996
  .sort((a, b) => (a.id < b.id ? -1 : 1));
830
997
  }
831
998
 
999
+ /**
1000
+ * The reason a `design-kit: no-story <reason>` comment gives (a `//`, block or
1001
+ * JSX comment anywhere in the file), or null when there is none or its reason
1002
+ * is empty: a bare marker exempts nothing.
1003
+ */
1004
+ export function noStoryReason(source) {
1005
+ const marker =
1006
+ /(?:\/\/|\/\*|^[ \t]*\*)[ \t]*design-kit:[ \t]*no-story\b([^\n]*)/gm;
1007
+ for (const match of String(source).matchAll(marker)) {
1008
+ const reason = match[1]
1009
+ .replace(/\*\/.*$/, "")
1010
+ .replace(/[}\s]+$/, "")
1011
+ .trim();
1012
+ if (reason) return reason;
1013
+ }
1014
+ return null;
1015
+ }
1016
+
832
1017
  /**
833
1018
  * Component files nothing documents: files under `gaps.sourceRoot` whose path
834
1019
  * from the Storybook project matches `gaps.componentGlob`, minus stories,
835
1020
  * 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.
1021
+ * the rows are relative to the config file's folder. A file carrying a
1022
+ * `design-kit: no-story <reason>` comment (a context, a controller, a hook
1023
+ * wrapper) is an `exempt` row with that reason as its detail, listed apart
1024
+ * so a person can check the reason; with an empty reason it stays `no story`.
837
1025
  */
838
1026
  export function componentsWithoutStories(stories, { gaps, root, configDir }) {
839
1027
  const documented = new Set(
@@ -855,10 +1043,13 @@ export function componentsWithoutStories(stories, { gaps, root, configDir }) {
855
1043
  } else if (!/\.(stories|test|spec)\.[cm]?[jt]sx?$/.test(name.name)) {
856
1044
  const rel = relative(root, path).split(sep).join("/");
857
1045
  if (matchFile(pattern, rel) && !documented.has(rel)) {
858
- out.push({
859
- component: relative(configDir, path).split(sep).join("/"),
860
- reason: "no story",
861
- });
1046
+ const component = relative(configDir, path).split(sep).join("/");
1047
+ const exempt = noStoryReason(readFileSync(path, "utf8"));
1048
+ out.push(
1049
+ exempt
1050
+ ? { component, reason: "exempt", detail: exempt }
1051
+ : { component, reason: "no story" },
1052
+ );
862
1053
  }
863
1054
  }
864
1055
  }
@@ -1002,7 +1193,11 @@ export async function captureStories(config, options = {}) {
1002
1193
  const keepSheetsOnce = sheetStore();
1003
1194
  // What the timings report needs from a capture, without its result.
1004
1195
  const attempt = (capture) => {
1005
- const record = { timing: capture.timing, failure: capture.failure };
1196
+ const record = {
1197
+ timing: capture.timing,
1198
+ failure: capture.failure,
1199
+ skipped: capture.skipped,
1200
+ };
1006
1201
  attempts.push(record);
1007
1202
  return record;
1008
1203
  };
@@ -1019,16 +1214,28 @@ export async function captureStories(config, options = {}) {
1019
1214
  fixedTime,
1020
1215
  });
1021
1216
  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
- }
1217
+ // The story's declared viewport and own widths are known once it has
1218
+ // loaded at the default one; that first capture is kept only when the
1219
+ // plan still has the default viewport in it.
1220
+ const declared = first.result ? viewportFor(first.result.declared) : null;
1221
+ const viewports = plannedViewports(
1222
+ story.title,
1223
+ declared,
1224
+ planning,
1225
+ first.widths,
1226
+ );
1227
+ const results = new Map();
1228
+ if (
1229
+ !first.skipped &&
1230
+ !declared &&
1231
+ viewports.some(
1232
+ (viewport) =>
1233
+ viewport.width === planning.viewport.width &&
1234
+ viewport.height === planning.viewport.height,
1235
+ )
1236
+ ) {
1237
+ results.set(planning.viewport.width, first);
1238
+ } else if (!first.skipped) firstAttempt.discarded = true;
1032
1239
  for (const viewport of viewports) {
1033
1240
  if (!results.has(viewport.width)) {
1034
1241
  const capture = await captureOne({
@@ -1046,6 +1253,7 @@ export async function captureStories(config, options = {}) {
1046
1253
  }
1047
1254
  const capture = results.get(viewport.width);
1048
1255
  const id = kitId(story.id, viewport.width);
1256
+ if (capture.skipped) continue;
1049
1257
  if (capture.failure) {
1050
1258
  failures.push({ id, ...capture.failure });
1051
1259
  } else {
@@ -1179,6 +1387,9 @@ export async function captureStories(config, options = {}) {
1179
1387
  join(out, "index.json"),
1180
1388
  `${JSON.stringify(rows, null, 1)}\n`,
1181
1389
  );
1390
+ // Every row's on-screen words for `kit find`, rows kept from the base
1391
+ // and the pages' rows included, so an incremental build is complete.
1392
+ writeKitText(out);
1182
1393
 
1183
1394
  const storyGaps = [
1184
1395
  ...failures,
@@ -1236,6 +1447,7 @@ export async function captureStories(config, options = {}) {
1236
1447
  const timings = {
1237
1448
  ...timingBreakdown(attempts),
1238
1449
  discarded: attempts.filter((a) => a.discarded).length,
1450
+ skipped: attempts.filter((a) => a.skipped).length,
1239
1451
  lanes: concurrency,
1240
1452
  quiet_ms: quietMs,
1241
1453
  timeout_ms: timeout,
@@ -1255,6 +1467,9 @@ export async function captureStories(config, options = {}) {
1255
1467
  return { out, rows: rows.length, failed: storyGaps.length };
1256
1468
  } finally {
1257
1469
  await browser?.close().catch(() => {});
1470
+ // Parked pages belong to this browser; a later capture in the same
1471
+ // process must not pick one up.
1472
+ idlePages.clear();
1258
1473
  server?.close();
1259
1474
  if (ownedBuild) rmSync(ownedBuild, { recursive: true, force: true });
1260
1475
  }
package/src/kit.d.ts CHANGED
@@ -30,13 +30,48 @@ export interface Kit {
30
30
  manifest: KitManifest | null;
31
31
  rows: KitRow[];
32
32
  byId: Map<string, KitRow>;
33
+ /** Each row's visible text by id, from text.json; absent in older kits. */
34
+ text?: Map<string, string>;
33
35
  }
34
36
 
35
37
  export function loadKit(dir: string): Kit;
36
38
 
37
- /** Rows whose id, title, component or source file holds every word. */
39
+ /** One `find` result; `excerpt` is set when a word matched only the row's text. */
40
+ export interface KitMatch {
41
+ row: KitRow;
42
+ excerpt?: string;
43
+ }
44
+
45
+ /**
46
+ * Rows whose id, title, component, source file or visible text holds every
47
+ * word: id matches first, then title, component or source file, then text.
48
+ */
49
+ export function findMatches(kit: Kit, query: string): KitMatch[];
50
+
51
+ /** The rows of `findMatches`, in the same order. */
38
52
  export function find(kit: Kit, query: string): KitRow[];
39
53
 
54
+ /** Characters of visible text text.json keeps per row: 600. */
55
+ export const TEXT_LIMIT: number;
56
+
57
+ /**
58
+ * A row's on-screen words: text outside scripts, styles, SVG, the head and
59
+ * templates, entities decoded, whitespace collapsed, at most `limit` characters.
60
+ */
61
+ export function visibleText(html: string, limit?: number): string;
62
+
63
+ /** text.json's content for `rows`, reading each row's markup by its `file`. */
64
+ export function kitText(
65
+ rows: KitRow[],
66
+ read: (file: string) => string,
67
+ ): Record<string, string>;
68
+
69
+ /** text.json as written. */
70
+ export function kitTextJson(text: Record<string, string>): string;
71
+
72
+ /** Rebuilds `dir`/text.json from the rows and markup already in the kit. */
73
+ export function writeKitText(dir: string): string;
74
+
40
75
  /** Up to five candidates, closest first. */
41
76
  export function nearMatches(wanted: string, candidates: string[]): string[];
42
77
 
package/src/kit.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  * always match. Pure Node: no dependencies, no network.
5
5
  *
6
6
  * node kit.mjs info
7
- * node kit.mjs find <query>
7
+ * node kit.mjs find <query> (ids, titles and the words on screen)
8
8
  * node kit.mjs show <id> [--full]
9
9
  * node kit.mjs compose --frame <id> [--slot <name>=<file>]… [--theme light|dark]
10
10
  * [--show-new] --out <file>
@@ -17,6 +17,8 @@ import { basename, dirname, extname, join, resolve, sep } from "node:path";
17
17
  import { fileURLToPath, pathToFileURL } from "node:url";
18
18
 
19
19
  const FIND_LIMIT = 30;
20
+ /** Of FIND_LIMIT, the lines kept for rows found by on-screen text. */
21
+ const FIND_TEXT_LINES = 5;
20
22
  const SHOW_LIMIT = 2048;
21
23
  const SNIPPET_DEPTH = 8;
22
24
  /**
@@ -35,7 +37,10 @@ function readJson(file, fallback) {
35
37
  return JSON.parse(readFileSync(file, "utf8"));
36
38
  }
37
39
 
38
- /** Story rows from index.json plus route rows from routes.json, by id. */
40
+ /**
41
+ * Story rows from index.json plus route rows from routes.json, by id, and
42
+ * each row's visible text from text.json when the kit has one.
43
+ */
39
44
  export function loadKit(dir) {
40
45
  const manifest = readJson(join(dir, "manifest.json"), null);
41
46
  const rows = new Map();
@@ -50,7 +55,15 @@ export function loadKit(dir) {
50
55
  const sorted = [...rows.values()].sort((a, b) =>
51
56
  a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
52
57
  );
53
- return { dir, manifest, rows: sorted, byId: new Map(rows) };
58
+ const text = readText(dir);
59
+ return {
60
+ dir,
61
+ manifest,
62
+ rows: sorted,
63
+ byId: new Map(rows),
64
+ // Only a kit with text.json has it, so an older kit loads as before.
65
+ ...(text ? { text } : {}),
66
+ };
54
67
  }
55
68
 
56
69
  function rowSource(row) {
@@ -179,19 +192,24 @@ function parseAttributes(source) {
179
192
 
180
193
  /** Tokens with offsets: open / close / text / comment. */
181
194
  export function tokenize(html) {
182
- const tokens = [];
195
+ return [...scan(html)];
196
+ }
197
+
198
+ /** `tokenize`, one token at a time, so a reader can stop early. */
199
+ function* scan(html) {
200
+ let lower = null;
183
201
  let i = 0;
184
202
  while (i < html.length) {
185
203
  const lt = html.indexOf("<", i);
186
204
  if (lt < 0) {
187
- tokens.push({ type: "text", start: i, end: html.length });
205
+ yield { type: "text", start: i, end: html.length };
188
206
  break;
189
207
  }
190
- if (lt > i) tokens.push({ type: "text", start: i, end: lt });
208
+ if (lt > i) yield { type: "text", start: i, end: lt };
191
209
  if (html.startsWith("<!--", lt)) {
192
210
  const close = html.indexOf("-->", lt + 4);
193
211
  const end = close < 0 ? html.length : close + 3;
194
- tokens.push({ type: "comment", start: lt, end });
212
+ yield { type: "comment", start: lt, end };
195
213
  i = end;
196
214
  continue;
197
215
  }
@@ -200,7 +218,7 @@ export function tokenize(html) {
200
218
  html.slice(lt + (closing ? 2 : 1), lt + 200),
201
219
  );
202
220
  if (!nameMatch) {
203
- tokens.push({ type: "text", start: lt, end: lt + 1 });
221
+ yield { type: "text", start: lt, end: lt + 1 };
204
222
  i = lt + 1;
205
223
  continue;
206
224
  }
@@ -217,29 +235,29 @@ export function tokenize(html) {
217
235
  const end = Math.min(j + 1, html.length);
218
236
  const name = nameMatch[0].toLowerCase();
219
237
  if (closing) {
220
- tokens.push({ type: "close", name, start: lt, end });
238
+ yield { type: "close", name, start: lt, end };
221
239
  i = end;
222
240
  continue;
223
241
  }
224
242
  const inner = html.slice(lt + 1 + nameMatch[0].length, end - 1);
225
243
  const selfClosing = /\/\s*$/.test(inner);
226
- tokens.push({
244
+ yield {
227
245
  type: "open",
228
246
  name,
229
247
  attrs: parseAttributes(inner.replace(/\/\s*$/, "")),
230
248
  start: lt,
231
249
  end,
232
250
  selfClosing: selfClosing || VOID.has(name),
233
- });
251
+ };
234
252
  i = end;
235
253
  if (RAW.has(name) && !selfClosing) {
236
- const closeAt = html.toLowerCase().indexOf(`</${name}`, end);
254
+ lower ??= html.toLowerCase();
255
+ const closeAt = lower.indexOf(`</${name}`, end);
237
256
  const stop = closeAt < 0 ? html.length : closeAt;
238
- if (stop > end) tokens.push({ type: "text", start: end, end: stop });
257
+ if (stop > end) yield { type: "text", start: end, end: stop };
239
258
  i = stop;
240
259
  }
241
260
  }
242
- return tokens;
243
261
  }
244
262
 
245
263
  /** Each element with its inner range: [{name, attrs, start, end, innerStart, innerEnd, depth}]. */
@@ -912,6 +930,174 @@ ${sanitizeMarkup(body)}
912
930
  return page;
913
931
  }
914
932
 
933
+ // ── visible text (text.json) ────────────────────────────────────────────────
934
+
935
+ /** Characters of visible text text.json keeps per row. */
936
+ export const TEXT_LIMIT = 600;
937
+ /** Characters of a `find` excerpt. */
938
+ const EXCERPT_CHARS = 60;
939
+ /** Elements whose content is never read on screen as text. */
940
+ const HIDDEN_CONTENT = new Set([
941
+ "script",
942
+ "style",
943
+ "svg",
944
+ "head",
945
+ "template",
946
+ "noscript",
947
+ "title",
948
+ ]);
949
+ /** Phrasing elements that sit inside a word; every other tag separates words. */
950
+ const INLINE = new Set(
951
+ "a abbr b bdi bdo cite code data dfn em i kbd mark q s samp small strong sub sup time u var wbr".split(
952
+ " ",
953
+ ),
954
+ );
955
+
956
+ /** Each `data-kit-slot` element's inner range, for slot-first text. */
957
+ function slotRanges(html) {
958
+ if (!html.includes("data-kit-slot")) return [];
959
+ return elements(html)
960
+ .filter((element) => element.attrs["data-kit-slot"] !== undefined)
961
+ .map((element) => [element.innerStart, element.innerEnd]);
962
+ }
963
+
964
+ /**
965
+ * The words a row shows: text content outside scripts, styles, SVG, the head
966
+ * and templates, entities decoded and whitespace collapsed, at most `limit`
967
+ * characters. Attribute values are never text. The text of the row's slots
968
+ * (a page's `main`) comes first and the rest of the markup after it, so a
969
+ * page's own words are kept ahead of the navigation and top bar every page
970
+ * repeats.
971
+ */
972
+ export function visibleText(html, limit = TEXT_LIMIT) {
973
+ const ranges = slotRanges(html);
974
+ const lastSlotEnd = Math.max(0, ...ranges.map(([, end]) => end));
975
+ const inSlot = (at) => ranges.some(([from, to]) => at >= from && at < to);
976
+ // [slot text, the rest]
977
+ const parts = ["", ""];
978
+ const add = (at, words) => {
979
+ const part = inSlot(at) ? 0 : 1;
980
+ if (words === " " && (parts[part] === "" || parts[part].endsWith(" ")))
981
+ return;
982
+ parts[part] += words;
983
+ };
984
+ let hidden = null;
985
+ let depth = 0;
986
+ for (const token of scan(html)) {
987
+ if (hidden) {
988
+ if (token.name !== hidden) continue;
989
+ if (token.type === "open" && !token.selfClosing) depth += 1;
990
+ else if (token.type === "close" && (depth -= 1) === 0) hidden = null;
991
+ continue;
992
+ }
993
+ if (token.type === "open" || token.type === "close") {
994
+ if (
995
+ token.type === "open" &&
996
+ HIDDEN_CONTENT.has(token.name) &&
997
+ !token.selfClosing
998
+ ) {
999
+ hidden = token.name;
1000
+ depth = 1;
1001
+ } else if (!INLINE.has(token.name)) {
1002
+ add(token.start, " ");
1003
+ }
1004
+ continue;
1005
+ }
1006
+ if (token.type !== "text") continue;
1007
+ add(
1008
+ token.start,
1009
+ decodeEntities(html.slice(token.start, token.end)).replace(/\s+/g, " "),
1010
+ );
1011
+ // Enough collected: a row's text is a search aid, not its content.
1012
+ const [slot, rest] = parts;
1013
+ if (slot.length > limit * 2 && slot.trim().length > limit) break;
1014
+ if (
1015
+ token.end >= lastSlotEnd &&
1016
+ slot.length + rest.length > limit * 2 &&
1017
+ `${slot} ${rest}`.trim().length > limit
1018
+ )
1019
+ break;
1020
+ }
1021
+ return [...`${parts[0]} ${parts[1]}`.replace(/\s+/g, " ").trim()]
1022
+ .slice(0, limit)
1023
+ .join("");
1024
+ }
1025
+
1026
+ /**
1027
+ * text.json's content: each row's visible text by id, from its markup.
1028
+ * `read(file)` returns a row's markup by its kit-relative `file`; a row
1029
+ * whose file is missing or leaves the kit has no entry. The first row of an
1030
+ * id wins, as in `loadKit`.
1031
+ */
1032
+ export function kitText(rows, read) {
1033
+ const text = {};
1034
+ const seen = new Set();
1035
+ for (const row of rows) {
1036
+ if (!row || typeof row.id !== "string" || seen.has(row.id)) continue;
1037
+ seen.add(row.id);
1038
+ const file = row.file;
1039
+ if (typeof file !== "string" || !file || file.split(/[\\/]/).includes(".."))
1040
+ continue;
1041
+ let html;
1042
+ try {
1043
+ html = read(file);
1044
+ } catch {
1045
+ continue;
1046
+ }
1047
+ if (typeof html !== "string") continue;
1048
+ const words = visibleText(html);
1049
+ if (words) text[row.id] = words;
1050
+ }
1051
+ return Object.fromEntries(
1052
+ Object.entries(text).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
1053
+ );
1054
+ }
1055
+
1056
+ /** text.json as written: stable keys, one row per line. */
1057
+ export function kitTextJson(text) {
1058
+ return `${JSON.stringify(text, null, 1)}\n`;
1059
+ }
1060
+
1061
+ /** Rebuilds `dir`/text.json from the rows and markup already in the kit. */
1062
+ export function writeKitText(dir) {
1063
+ const kit = loadKit(dir);
1064
+ const json = kitTextJson(
1065
+ kitText(kit.rows, (file) => readFileSync(join(dir, file), "utf8")),
1066
+ );
1067
+ writeFileSync(join(dir, "text.json"), json);
1068
+ return json;
1069
+ }
1070
+
1071
+ /** text.json, if the kit has a readable one: id → visible text. */
1072
+ function readText(dir) {
1073
+ const file = join(dir, "text.json");
1074
+ if (!existsSync(file)) return undefined;
1075
+ let parsed;
1076
+ try {
1077
+ parsed = JSON.parse(readFileSync(file, "utf8"));
1078
+ } catch {
1079
+ // An optional search aid: a broken one leaves find as it is without it.
1080
+ return undefined;
1081
+ }
1082
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1083
+ return undefined;
1084
+ const text = new Map();
1085
+ for (const [id, words] of Object.entries(parsed))
1086
+ if (typeof words === "string") text.set(id, words);
1087
+ return text;
1088
+ }
1089
+
1090
+ /** About EXCERPT_CHARS of `words` around `at`, cut words marked with "…". */
1091
+ function excerpt(words, at, length) {
1092
+ const chars = [...words];
1093
+ // `at` counts UTF-16 units; recount it in characters.
1094
+ const from = [...words.slice(0, at)].length;
1095
+ let start = Math.max(0, from - Math.floor((EXCERPT_CHARS - length) / 2));
1096
+ const end = Math.min(chars.length, start + EXCERPT_CHARS);
1097
+ start = Math.max(0, end - EXCERPT_CHARS);
1098
+ return `${start > 0 ? "…" : ""}${chars.slice(start, end).join("").trim()}${end < chars.length ? "…" : ""}`;
1099
+ }
1100
+
915
1101
  // ── show / find / info ──────────────────────────────────────────────────────
916
1102
 
917
1103
  export function outline(html, limit = SHOW_LIMIT) {
@@ -948,8 +1134,17 @@ function formatBytes(bytes) {
948
1134
  return bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${bytes} B`;
949
1135
  }
950
1136
 
951
- export function find(kit, query) {
1137
+ /**
1138
+ * Rows holding every word of `query` in their id, title, component, source
1139
+ * file or visible text (text.json), best first: a word in the id ranks
1140
+ * above one in the title, component or source file, which ranks above one
1141
+ * only in the text, and among rows that tie, one whose text holds the whole
1142
+ * query as written comes first. A row a word matched only through its text
1143
+ * carries an `excerpt` around that phrase, or else the first such word.
1144
+ */
1145
+ export function findMatches(kit, query) {
952
1146
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
1147
+ const phrase = terms.join(" ");
953
1148
  const scored = [];
954
1149
  for (const row of kit.rows) {
955
1150
  const id = row.id.toLowerCase();
@@ -957,20 +1152,59 @@ export function find(kit, query) {
957
1152
  .filter(Boolean)
958
1153
  .join(" ")
959
1154
  .toLowerCase();
960
- if (!terms.every((term) => hay.includes(term))) continue;
961
- const score = terms.reduce(
962
- (sum, term) => sum + (id.includes(term) ? 0 : 1),
963
- 0,
964
- );
965
- scored.push({ row, score });
1155
+ const words = kit.text?.get(row.id) ?? "";
1156
+ let lowered = null;
1157
+ let score = 0;
1158
+ let textHit = null;
1159
+ let matched = true;
1160
+ for (const term of terms) {
1161
+ if (id.includes(term)) continue;
1162
+ if (hay.includes(term)) {
1163
+ score += 1;
1164
+ continue;
1165
+ }
1166
+ lowered ??= words.toLowerCase();
1167
+ const at = lowered.indexOf(term);
1168
+ if (at < 0) {
1169
+ matched = false;
1170
+ break;
1171
+ }
1172
+ score += 2;
1173
+ textHit ??= { at, length: term.length };
1174
+ }
1175
+ if (!matched) continue;
1176
+ // The whole query as written, in the text: the excerpt shows it, and it
1177
+ // ranks first among rows that tie.
1178
+ const at = textHit && terms.length > 1 ? lowered.indexOf(phrase) : -1;
1179
+ if (at >= 0) textHit = { at, length: phrase.length };
1180
+ scored.push({
1181
+ row,
1182
+ score,
1183
+ phrase: at >= 0 ? 0 : 1,
1184
+ ...(textHit
1185
+ ? { excerpt: excerpt(words, textHit.at, textHit.length) }
1186
+ : {}),
1187
+ });
966
1188
  }
967
- scored.sort((a, b) => a.score - b.score || (a.row.id < b.row.id ? -1 : 1));
968
- return scored.map(({ row }) => row);
1189
+ scored.sort(
1190
+ (a, b) =>
1191
+ a.score - b.score ||
1192
+ a.phrase - b.phrase ||
1193
+ (a.row.id < b.row.id ? -1 : 1),
1194
+ );
1195
+ return scored.map(({ row, excerpt: cut }) =>
1196
+ cut === undefined ? { row } : { row, excerpt: cut },
1197
+ );
1198
+ }
1199
+
1200
+ /** Rows holding every word of `query`, best first (see findMatches). */
1201
+ export function find(kit, query) {
1202
+ return findMatches(kit, query).map(({ row }) => row);
969
1203
  }
970
1204
 
971
1205
  function findLines(kit, query) {
972
- const rows = find(kit, query);
973
- if (!rows.length) {
1206
+ const matches = findMatches(kit, query);
1207
+ if (!matches.length) {
974
1208
  const near = nearMatches(
975
1209
  query,
976
1210
  kit.rows.map((row) => row.id),
@@ -979,15 +1213,40 @@ function findLines(kit, query) {
979
1213
  `nothing matches "${query}".${near.length ? ` Near: ${near.join(", ")}` : ""}`,
980
1214
  );
981
1215
  }
982
- const shown = rows.length > FIND_LIMIT ? rows.slice(0, FIND_LIMIT - 1) : rows;
983
- const lines = shown.map(
984
- (row) =>
985
- `${row.id} ${row.kind ?? rowSource(row)} ${row.width ?? "?"}w ${formatBytes(row.bytes ?? 0)} ${row.title ?? ""}`,
986
- );
987
- if (rows.length > shown.length) {
988
- lines.push(`… ${rows.length - shown.length} more; add a word to narrow it`);
1216
+ const line = ({ row, excerpt: cut }) =>
1217
+ `${row.id} ${row.kind ?? rowSource(row)} ${row.width ?? "?"}w ${formatBytes(row.bytes ?? 0)} ${row.title ?? ""}${cut === undefined ? "" : ` "${cut}"`}`;
1218
+ if (matches.length <= FIND_LIMIT) return matches.map(line);
1219
+ const room = FIND_LIMIT - 1;
1220
+ // A row with an excerpt was found by on-screen text; any other, by name.
1221
+ const byText = matches.filter((match) => match.excerpt !== undefined);
1222
+ if (!byText.length) {
1223
+ return [
1224
+ ...matches.slice(0, room).map(line),
1225
+ `… ${matches.length - room} more; add a word to narrow it`,
1226
+ ];
989
1227
  }
990
- return lines;
1228
+ // Names fill the list first, but the last lines are kept for text hits,
1229
+ // so an agent sees them even when names alone would fill it.
1230
+ const byName = matches.filter((match) => match.excerpt === undefined);
1231
+ const textRoom = Math.min(
1232
+ byText.length,
1233
+ Math.max(FIND_TEXT_LINES, room - byName.length),
1234
+ );
1235
+ const names = byName.slice(0, room - textRoom);
1236
+ const texts = byText.slice(0, room - names.length);
1237
+ const left = [
1238
+ ...(byName.length > names.length
1239
+ ? [`+${byName.length - names.length} more by name`]
1240
+ : []),
1241
+ ...(byText.length > texts.length
1242
+ ? [`+${byText.length - texts.length} more by on-screen text`]
1243
+ : []),
1244
+ ];
1245
+ return [
1246
+ ...names.map(line),
1247
+ ...texts.map(line),
1248
+ `${left.join(", ")}; narrow the query`,
1249
+ ];
991
1250
  }
992
1251
 
993
1252
  function showText(kit, id, full) {
@@ -1022,7 +1281,7 @@ function infoText(kit) {
1022
1281
 
1023
1282
  const USAGE = `usage: kit.mjs [--kit <dir>] <command>
1024
1283
  info print the manifest
1025
- find <query> list up to ${FIND_LIMIT} matching ids
1284
+ find <query> list up to ${FIND_LIMIT} rows whose id, title or on-screen text holds every word
1026
1285
  show <id> [--full] path, size and an outline (--full: the markup)
1027
1286
  compose --frame <id> [--slot <name>=<file>]… [--theme light|dark] [--show-new] --out <file>`;
1028
1287