forgebook 0.4.0 → 0.4.1

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.
Files changed (70) hide show
  1. package/README.md +83 -3
  2. package/dist/bin/forgebook/agentText.js +7 -0
  3. package/dist/bin/forgebook/guide/commands.md +5 -0
  4. package/dist/bin/forgebook/guide/stories.md +7 -2
  5. package/dist/bin/forgebook/verifyBase.js +10 -2
  6. package/dist/bin/forgebook/verifyVerdict.js +27 -1
  7. package/dist/src/argControl.d.ts +51 -3
  8. package/dist/src/argTypes.js +4 -2
  9. package/dist/src/componentDocs.d.ts +12 -0
  10. package/dist/src/componentDocs.js +5 -1
  11. package/dist/src/controlCode.d.ts +27 -0
  12. package/dist/src/controlCode.js +41 -0
  13. package/dist/src/frame/controlValues.d.ts +39 -0
  14. package/dist/src/frame/controlValues.js +143 -0
  15. package/dist/src/frame/mountFrame.d.ts +3 -0
  16. package/dist/src/frame/mountFrame.js +8 -10
  17. package/dist/src/frame/panelControls.d.ts +67 -0
  18. package/dist/src/frame/panelControls.js +110 -0
  19. package/dist/src/shell/AuthoredHome.js +3 -1
  20. package/dist/src/shell/CodeTab.d.ts +10 -5
  21. package/dist/src/shell/CodeTab.js +44 -1
  22. package/dist/src/shell/ComponentPage.d.ts +5 -5
  23. package/dist/src/shell/ComponentPage.js +4 -2
  24. package/dist/src/shell/CustomControl.js +14 -21
  25. package/dist/src/shell/HomePage.js +5 -3
  26. package/dist/src/shell/PageWidth.d.ts +29 -0
  27. package/dist/src/shell/PageWidth.js +18 -0
  28. package/dist/src/shell/PropRows.d.ts +2 -12
  29. package/dist/src/shell/PropRows.js +6 -7
  30. package/dist/src/shell/PropsTab.js +2 -3
  31. package/dist/src/shell/Shell.js +5 -2
  32. package/dist/src/shell/StoryPicker.d.ts +4 -2
  33. package/dist/src/shell/StoryPicker.js +3 -3
  34. package/dist/src/shell/Switch.d.ts +29 -0
  35. package/dist/src/shell/Switch.js +11 -0
  36. package/dist/src/shell/customControls.d.ts +7 -0
  37. package/dist/src/shell/customControls.js +12 -1
  38. package/dist/src/shell/dockTabs.js +2 -1
  39. package/dist/src/shell/frameOrder.d.ts +35 -0
  40. package/dist/src/shell/frameOrder.js +45 -0
  41. package/dist/src/shell/laterStrings.d.ts +1 -0
  42. package/dist/src/shell/laterStrings.js +1 -0
  43. package/dist/src/shell/shell.css +36 -9
  44. package/dist/src/shell/useFrameQueue.d.ts +8 -3
  45. package/dist/src/shell/useFrameQueue.js +42 -12
  46. package/dist/src/shell/useScrollSettled.d.ts +18 -0
  47. package/dist/src/shell/useScrollSettled.js +56 -0
  48. package/dist/src/shell/valueEditors/ValueField.js +2 -1
  49. package/dist/src/storyCheck.d.ts +14 -0
  50. package/dist/src/storyCheck.js +16 -0
  51. package/dist/src/storyCodeText.d.ts +8 -2
  52. package/dist/src/storyCodeText.js +8 -5
  53. package/dist/src/storyControls.d.ts +3 -0
  54. package/dist/src/vite/agent.types.d.ts +8 -1
  55. package/dist/src/vite/agentRoutes.js +38 -10
  56. package/dist/src/vite/agentStoryCode.d.ts +3 -0
  57. package/dist/src/vite/agentStoryCode.js +8 -2
  58. package/dist/src/vite/argControlsConfig.d.ts +30 -0
  59. package/dist/src/vite/argControlsConfig.js +176 -0
  60. package/dist/src/vite/catalogueConfig.js +6 -1
  61. package/dist/src/vite/config.d.ts +38 -3
  62. package/dist/src/vite/configFile.d.ts +3 -2
  63. package/dist/src/vite/configFile.js +6 -21
  64. package/dist/src/vite/configKeys.js +2 -1
  65. package/dist/src/vite/forgebookPlugin.js +3 -1
  66. package/dist/src/vite/pages.js +12 -1
  67. package/dist/src/vite/propDocs.js +58 -3
  68. package/dist/src/vite/typedControls.d.ts +31 -0
  69. package/dist/src/vite/typedControls.js +66 -0
  70. package/package.json +2 -2
package/README.md CHANGED
@@ -252,6 +252,9 @@ too, with a warning.
252
252
  them and hides the rest in the View menu; their browser keeps it ([below](#the-toolbar)).
253
253
  - **A light and a dark catalogue**: the reader picks Light, Dark or System in the Interface menu at the sidebar's foot
254
254
  (their browser keeps it); `appearance` in the configuration sets the default. The stories keep their own theme, from the view's switches.
255
+ - **Contained or full width**: the button beside the Stories picker, and at the top of the home, sets the home and a
256
+ component's page in a centred column (pressed, the default) or across the whole width; their browser keeps it. A
257
+ docs page keeps its reading column, and a story's page its own width controls.
255
258
  - **Keys and named views**: `?` lists the keys, as does the menu at the sidebar's foot, where a reader can turn off every
256
259
  key without a modifier for this browser; a key pressed inside a story, in a text field or in a custom element stays
257
260
  theirs. A key cycles a control's values (`t` the theme, Shift+`t` back); the catalogue sets the
@@ -727,8 +730,9 @@ The editors are one chunk that loads when the first such prop is drawn: a catalo
727
730
 
728
731
  ### Controls of your own
729
732
 
730
- A control the panel lacks is a module of yours, registered by name and loaded when a story names it. A catalogue that
731
- registers none loads nothing for this.
733
+ A control the panel lacks is a module of yours, registered by name and loaded when a story names it, or a prop it
734
+ matches by type is shown ([below](#controls-by-type-and-from-a-librarys-preset)). A catalogue that registers none loads
735
+ nothing for this.
732
736
 
733
737
  ```ts
734
738
  // forgebook.config.ts
@@ -752,7 +756,7 @@ export const mount = (element: HTMLElement, props: ArgControlProps) => {
752
756
  };
753
757
  ```
754
758
 
755
- `mount(element, { value, onChange, argType })` draws in the element and returns what takes it away. It is a function rather
759
+ `mount(element, { value, onChange, argType, strings })` draws in the element and returns what takes it away. It is a function rather
756
760
  than a component, so it can be written in any framework, whatever the stories' is: `createApp` in Vue, `mount` in Svelte,
757
761
  plain DOM. `argType` holds the arg's `name`, `label` and `description`, and the control's `options`, `min`, `max`, `step`
758
762
  and `settings`. The control keeps what it draws: it is mounted again only when the value changes from outside it.
@@ -775,6 +779,77 @@ agents its module also loads on the dev server when a command asks about a story
775
779
  touches the page inside `mount`. A name that no control has (`control: "selct"`) is read as a custom control, and says so
776
780
  beside the field.
777
781
 
782
+ ### Controls by type, and from a library's preset
783
+
784
+ A control can take a prop in every story without any story naming it: by the prop's type, its name, or both. A library
785
+ registers its controls in its preset, so every catalogue that lists the preset gets them, with no `argTypes` to write:
786
+
787
+ ```ts
788
+ // the library's preset (its `./forgebook` export)
789
+ export const acmePreset = (): ForgebookPreset =>
790
+ definePreset({
791
+ name: "acme-ui",
792
+ argControls: {
793
+ icon: {
794
+ module: "@acme/ui/forgebook/icon-picker", // mount and schema, drawn in the panel
795
+ frame: "@acme/ui/forgebook/icon-value", // toStoryValue, run in the story's frame
796
+ matches: { typeName: "IconComponent" }
797
+ }
798
+ },
799
+ strings: { "argControls.icon.none": "No icon" }
800
+ });
801
+ ```
802
+
803
+ ```ts
804
+ // @acme/ui/forgebook/icon-value
805
+ import type { ArgControlFrameModule } from "forgebook/arg-control";
806
+ import { ICONS } from "../src/icons";
807
+
808
+ export const toStoryValue: ArgControlFrameModule["toStoryValue"] = (name) =>
809
+ typeof name === "string" && Object.hasOwn(ICONS, name) ? ICONS[name] : undefined;
810
+
811
+ // optional: the story's own icon named back, so the picker shows what the story draws
812
+ export const toPanelValue: ArgControlFrameModule["toPanelValue"] = (icon) =>
813
+ Object.entries(ICONS).find(([, each]) => each === icon)?.[0];
814
+ ```
815
+
816
+ - **What matches.** `matches` is one `{ typeName, propName }` or a list of them; each field given must hold. `typeName`
817
+ is the type as the props are read: as the source writes it (`IconComponent`), or any alias it is declared through
818
+ (`type ChipIcon = IconComponent`), an import's new name, with `undefined` and `null` left out, so `icon?: IconComponent`
819
+ and `icon?: IconComponent | undefined` match; a union matches only when every member goes by the name. `forgebook story
820
+ --json` writes the names a prop's type goes by as `typeNames` when they are more than its `type`. An event never
821
+ matches.
822
+ - **Which control wins.** A control a story's `argTypes` name for the prop wins over any match; a `description` or a
823
+ `label` alone keeps the matched control. Among controls that match, one that names the prop wins over one that names
824
+ only its type, then the catalogue's own before its presets', each in the order written. Only props the component has
825
+ get a control: no story gains a prop.
826
+ - **The value.** The panel, a link and an agent keep the control's JSON value (`"IconSearch"`), checked against its
827
+ `schema`. A `frame` module turns it into what the story receives, in the frame, with `toStoryValue(value, { name,
828
+ settings })`; a value it cannot turn, or a module that fails, leaves the story its own value and says why. Without a
829
+ `frame` module the story receives the JSON value. `toPanelValue`, optional, names a value the story gives itself.
830
+ - **Its code.** The Code tab and `forgebook story` write a value as the story receives it when the control's module
831
+ exports `toCode(value, argType)`, the expression the attribute holds: `toCode: (name) => name` writes
832
+ `icon={IconStar}`, not `icon="IconStar"`. Without it, or for a value it gives `undefined`, the JSON value is written;
833
+ the story file's own code for an arg stays as written until the panel sets it. It runs in the page and on the dev
834
+ server.
835
+ - **When it loads.** The module loads when the panel first shows a prop it takes, the frame module when a story's frame
836
+ is first given a value of it: neither on the catalogue's first load. A frame reads its story's component docs, to
837
+ know which props a control takes, only once a module may have work: the panel gives a value, the story's `argTypes`
838
+ name a control with a frame module, or the story gives itself a value plain data cannot show (an icon to name back).
839
+ A story of plain values that nobody edits loads neither.
840
+ - **Texts.** A control receives `strings` (always given; optional in the type, so its own tests need not build it): the
841
+ catalogue's `strings` whose key starts with `argControls.<name>.`, by the rest of the key. A preset words its control
842
+ there, and the catalogue's own `strings` replace a preset's.
843
+ - **The catalogue decides.** Its registration of a name replaces a preset's, and `argControls: { icon: false }` removes
844
+ it (a name no preset registers is an error, so a typo is not silent). A preset names its modules by absolute path or a
845
+ package's specifier, as its preview; no module is an address (`https:`, `data:`). A preset cannot take every prop of a
846
+ keyword type (`{ typeName: "string" }`) without a `propName`.
847
+
848
+ **A preset's control runs in the catalogue's page, with a plugin's trust**, as the catalogue's own do. A preset is
849
+ already code the catalogue runs on Node (its configuration calls it, and the dev server loads a control's module for
850
+ its `schema`); what changes is that its control also runs in the page. Its frame module runs in the stories' frames, as
851
+ its preview does.
852
+
778
853
  ## Docs pages
779
854
 
780
855
  Guides, foundations and recipes are Markdown files with a little front matter, not MDX: a page is read at build time, and
@@ -1629,6 +1704,11 @@ its `frame-src`, in the preview's `frameDomains`).
1629
1704
  - **`server.fs.deny` wins over your globs.** A file it refuses stays out of the catalogue, its search and "Used in", with
1630
1705
  a warning. A file your globs name outside `server.fs.allow` is listed and served, as Vite serves any file a module of
1631
1706
  the project imports.
1707
+ - **A missing file is a 404, never your app's page.** The catalogue's two pages are its own, so the dev server has no
1708
+ single-page fallback: a story's image under a path nothing serves answers 404, and the checks list it among their
1709
+ warnings by its path, without failing.
1710
+ Your app's `index.html` is never served, so its entry and every package it reaches stay unloaded. A Vite configuration
1711
+ of your own that runs the plugin and sets `appType` keeps it.
1632
1712
 
1633
1713
  A command, an agent or `curl` sends no browser headers: `forgebook mcp`, `forgebook stories` and the agent API
1634
1714
  (`/__forgebook/api/`) work as usual, and anyone who can reach the port can use them. With `--host` that means your whole
@@ -127,6 +127,11 @@ export const checkText = (report, style) => (report.a11y === "off" ? "Accessibil
127
127
  result.view || "default",
128
128
  `${problem.kind}: ${problem.message}`
129
129
  ])),
130
+ ...(result.warnings ?? []).map((warning) => [
131
+ result.storyId,
132
+ result.view || "default",
133
+ `warning: ${warning.message}`
134
+ ]),
130
135
  ...judgementLines(result)
131
136
  ]), { columns: [{ header: "Story" }, { header: "View", tone: "muted" }, { header: "Result" }] }, uncut(style));
132
137
  /**
@@ -332,6 +337,7 @@ export const verifyText = (document, style) => {
332
337
  const outdated = (document.outdated ?? []).map((each) => `${each.file} out of date: written for props ${each.written}, now ${each.current}. Fix: ${each.fix}`);
333
338
  const notes = document.code.notes.map((each) => `${each.file}${each.line === undefined ? "" : `:${each.line}`} note: ${each.message}`);
334
339
  const advice = (document.code.advice ?? []).map((each) => `${each.file}:${each.line} ${each.message}`);
340
+ const warnings = document.warnings.map((each) => `${each.path} warning: ${each.message}, in ${each.stories.length === 1 ? "1 story" : `${each.stories.length} stories`}`);
335
341
  const results = [
336
342
  ...document.failures,
337
343
  ...document.notVerified.map((each) => ({ ...each, problems: [] }))
@@ -344,6 +350,7 @@ export const verifyText = (document, style) => {
344
350
  ...outdated,
345
351
  ...notes,
346
352
  ...advice,
353
+ ...warnings,
347
354
  ...stories,
348
355
  document.summary
349
356
  ].join("\n");
@@ -46,6 +46,11 @@ not report `partly-verified` as verified: say what `skipped` says. A file in `ui
46
46
  `verify` again. `coverage: "through"` means it ran in another component's stories. A UI file is a component or a style
47
47
  sheet by its extension, a story file, or a script that defines or draws a component; anything else does not fail.
48
48
 
49
+ A static file a story asks for that is not there (an image, a font, a style sheet answering 404) is a warning, not a
50
+ failure: `verify` lists it once in `warnings` with its `path` and the `stories` that asked for it, and says it in
51
+ `summary`; `check` gives it in each result's `warnings`. Name it when you report, and add the file to the public folder
52
+ if the change should have one. A module or a script that answers 404, and any error on the page, still fail.
53
+
49
54
  A story file `forgebook new` wrote carries `// forgebook-props: <signature>`, the props it was written for. When the change
50
55
  reaches it and its component's props no longer give that signature, `verify` lists it in `outdated` and fails: its args
51
56
  may pass a prop that is gone or miss one now required. Check them against `story <id>`'s props, then set the line to
@@ -27,8 +27,13 @@ The framework's own guide (`forgebook guide <framework>`) has what differs in yo
27
27
  value over the story's own; an empty text is `""`; a `Date` is `{ "forgebookDate": "<ISO>" }`. Give a person the `address` link to look at them: `forgebook shot` draws
28
28
  a frame alone, with the story's own values.
29
29
  - A custom control (`control: "custom"`, `custom` its name) takes a value that fits `schema`, in `controls`; set it as any
30
- other. A new one is a module exporting `mount(el, { value, onChange, argType })`, returning an unmount, and `schema`,
31
- registered in `argControls` of `forgebook.config`; keep what touches the page inside `mount`, so the commands can read it.
30
+ other. A new one is a module exporting `mount(el, { value, onChange, argType, strings })`, returning an unmount, and
31
+ `schema`, registered in `argControls` of `forgebook.config` or of a library's preset; keep what touches the page inside
32
+ `mount`, so the commands can read it. Registered as `{ module, frame, matches: { typeName: "IconComponent" } }`, it takes
33
+ every prop of that type (or `propName`) in stories whose `argTypes` name no control for it, with no story code: a prop's
34
+ `typeNames` say the aliases its type goes through. The value stays JSON (an icon's name); the `frame` module's
35
+ `toStoryValue(value, argType)` turns it into what the story receives; the module's optional `toCode(value, argType)`
36
+ gives the expression the story's code writes (`icon={IconStar}`).
32
37
  - A prop's TSDoc can ask for design tokens (`@token color`, a token's type or a part of its name), a story's arg with
33
38
  `argTypes: { x: { control: { type: "token", of: "space", format: "var(--{name})" } } }`.
34
39
  - Story ids are Storybook's (`inputs-readmore--open-by-default`, prefixed by the source's id); the commands also take
@@ -16,6 +16,7 @@
16
16
  * @module
17
17
  */
18
18
  import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
19
+ import { rm } from "node:fs/promises";
19
20
  import { basename, dirname, join, relative, sep } from "node:path";
20
21
  import { findConfigFile } from "../../src/vite/configFile.js";
21
22
  import { gitOutput, gitOutputIn } from "../../src/vite/gitChange.js";
@@ -74,6 +75,13 @@ const twinModules = (from, to, root, folder) => {
74
75
  symlinkSync(linkTarget(entry, root, folder), join(to, name), "junction");
75
76
  }
76
77
  };
78
+ /**
79
+ * How the base's folder is removed: tried again for a moment while it is still being written in. A server Vite closes
80
+ * while its first pre-bundle runs (a base with none of the stories asked for closes at once) cancels the run, but the
81
+ * bundler still writes its files into the cache folder inside, and the removal would fail on a folder not empty. The
82
+ * removal waits between tries without holding the process, so what is still writing can finish.
83
+ */
84
+ const BASE_REMOVAL = { recursive: true, force: true, maxRetries: 10, retryDelay: 50 };
77
85
  /** The prefix of the folders a base runs in, beside the repository. */
78
86
  const folderPrefix = (root) => `.${basename(root)}-forgebook-base-`;
79
87
  /**
@@ -189,8 +197,8 @@ export const runOnBase = async (config, base, ids, isAllVariants, runEnv) => {
189
197
  finally {
190
198
  // the links go first, so nothing that removes the worktree can follow one into the working tree
191
199
  for (const twin of twins)
192
- rmSync(twin, { recursive: true, force: true });
200
+ await rm(twin, BASE_REMOVAL);
193
201
  await gitOutputIn(root, env, "worktree", "remove", "--force", folder).catch(() => undefined);
194
- rmSync(folder, { recursive: true, force: true });
202
+ await rm(folder, BASE_REMOVAL);
195
203
  }
196
204
  };
@@ -198,6 +198,30 @@ const failuresOf = (results, links) => results
198
198
  const storyLinks = links.get(storyId);
199
199
  return { storyId, view, problems: found, ...(storyLinks === undefined ? {} : { links: storyLinks }) };
200
200
  });
201
+ /**
202
+ * The warnings of the pairs that ran, one per file, with the stories that asked for it.
203
+ *
204
+ * @param results - the pairs that ran.
205
+ */
206
+ const warningsOf = (results) => {
207
+ const byPath = new Map();
208
+ for (const result of results)
209
+ for (const warning of result.warnings ?? []) {
210
+ const found = byPath.get(warning.path) ?? { warning, stories: new Set() };
211
+ found.stories.add(result.storyId);
212
+ byPath.set(warning.path, found);
213
+ }
214
+ return [...byPath.values()].map(({ warning, stories }) => ({ ...warning, stories: [...stories] }));
215
+ };
216
+ /**
217
+ * The end of the verdict's line for the warnings: a missing file does not change the verdict, so the line names it, so
218
+ * it is not missed; nothing without any.
219
+ *
220
+ * @param warnings - the warnings.
221
+ */
222
+ const warningWords = (warnings) => warnings.length === 0
223
+ ? ""
224
+ : `; ${counted(warnings.length, "warning")}: ${warnings.map((each) => `${each.path} is missing`).join(", ")}`;
201
225
  /** What the comparison with the base leaves unchecked: a base that could not run, stories it never saw. */
202
226
  const baseSkips = (compared) => [
203
227
  ...(compared.notCompared === undefined
@@ -226,6 +250,7 @@ export const verifyDocument = (input) => {
226
250
  const uiFiles = changed.uiFiles.map((file) => verifiedFile(file, ran, notRun));
227
251
  const links = new Map(changed.stories.map((story) => [story.id, story.links]));
228
252
  const failures = failuresOf(results, links);
253
+ const warnings = warningsOf(results);
229
254
  const notVerified = results.flatMap(({ storyId, view, notVerified: reason }) => {
230
255
  if (reason === undefined)
231
256
  return [];
@@ -272,6 +297,7 @@ export const verifyDocument = (input) => {
272
297
  ran: seenResults.length,
273
298
  failures,
274
299
  notVerified,
300
+ warnings,
275
301
  code: {
276
302
  checked: code.checked,
277
303
  problems: codeProblems,
@@ -292,6 +318,6 @@ export const verifyDocument = (input) => {
292
318
  const old = existingProblems === 0
293
319
  ? ""
294
320
  : `; ${counted(existingProblems, "problem")} the stories had before the change not counted (existing; --all-problems counts them)`;
295
- const summary = `${summaryOf({ ...body, verdict, skipped: allSkipped }, catalogueProblems)}${old}`;
321
+ const summary = `${summaryOf({ ...body, verdict, skipped: allSkipped }, catalogueProblems)}${old}${warningWords(warnings)}`;
296
322
  return { schema: 2, verdict, summary, skipped: allSkipped, ...body };
297
323
  };
@@ -1,11 +1,12 @@
1
1
  /**
2
- * A custom control's contract: a module a catalogue registers by name (`argControls` in `forgebook.config`) and a story
3
- * uses as `argTypes: { x: { control: "name" } }`.
2
+ * A custom control's contract: a module a catalogue or a library's preset registers by name (`argControls`) and a story
3
+ * uses as `argTypes: { x: { control: "name" } }`, or that takes the props of a type in every story (`matches`); and its
4
+ * frame module, which turns the control's value into the story's.
4
5
  *
5
6
  * @remarks
6
7
  * `mount` rather than a component, so a team writes one in whatever its stories are written in: the shell knows nothing of
7
8
  * how it draws. The value is always JSON with a declared schema, so a link keeps working and an agent can set it with no
8
- * UI. The module runs in the shell's page, with a plugin's trust.
9
+ * UI. The module runs in the shell's page, with a plugin's trust; the frame module in the stories' frames, as a preview.
9
10
  *
10
11
  * @module
11
12
  */
@@ -84,6 +85,13 @@ export type ArgControlProps = {
84
85
  readonly onChange: (value: JsonValue | undefined) => void;
85
86
  /** What the story says of the arg. */
86
87
  readonly argType: ControlArgType;
88
+ /**
89
+ * The control's texts as the catalogue words them: its `strings` (a preset's, then the catalogue's own over them) whose
90
+ * key starts with `argControls.<name>.`, by the rest of the key (`none` for `argControls.icon.none`). A control falls
91
+ * back to its own words for a key it does not find. Always given by the panel; optional so a control's own tests need
92
+ * not build it.
93
+ */
94
+ readonly strings?: Readonly<Record<string, string>>;
87
95
  };
88
96
  /** A custom control's module. */
89
97
  export type ArgControlModule = {
@@ -97,4 +105,44 @@ export type ArgControlModule = {
97
105
  readonly mount: (element: HTMLElement, props: ArgControlProps) => () => void;
98
106
  /** What its value looks like: a link's value and each `onChange` are checked against it. */
99
107
  readonly schema: ValueSchema;
108
+ /**
109
+ * The code a value is written as in the story's code (the Code tab, `forgebook story`), when it is not the value as
110
+ * JSON: the expression the attribute holds, `IconStar` for the icon the name `"IconStar"` gives the story (written
111
+ * `icon={IconStar}`). None, or `undefined` for a value, writes the JSON value. Runs in the page and on the dev server,
112
+ * so it reads nothing of either.
113
+ *
114
+ * @param value - the panel's value.
115
+ * @param argType - what the story says of the arg.
116
+ * @returns the expression, as code.
117
+ */
118
+ readonly toCode?: (value: JsonValue, argType: ControlArgType) => string | undefined;
119
+ };
120
+ /** What a frame module is told of the arg whose value it turns into the story's. */
121
+ export type FrameArgType = {
122
+ /** The arg's name. */
123
+ readonly name: string;
124
+ /** The control's `settings` the story's `argTypes` write, as JSON; none when they write none. */
125
+ readonly settings?: JsonValue;
126
+ };
127
+ /** A custom control's frame module: loaded in a story's frame when the panel first gives it a value of that control. */
128
+ export type ArgControlFrameModule = {
129
+ /**
130
+ * The value the story receives for the panel's: a name turned into the component it names, say. Links and agents keep
131
+ * the JSON value; this runs only in the frame, after the shell checked the value against the control's schema. A link
132
+ * chose it, so read it as any JSON: look a name up among your own entries (`Object.hasOwn`), never through a prototype.
133
+ *
134
+ * @param value - the panel's value, as JSON.
135
+ * @param argType - the arg.
136
+ * @returns what the story receives; `undefined` gives the story no value.
137
+ */
138
+ readonly toStoryValue: (value: JsonValue, argType: FrameArgType) => unknown;
139
+ /**
140
+ * The panel's value for a value the story gives itself (the icon its `args` name back into its name), so the control
141
+ * shows what the story draws; none leaves the control without one, as for any value JSON cannot hold.
142
+ *
143
+ * @param value - the story's own value.
144
+ * @param argType - the arg.
145
+ * @returns the panel's value, which the control's schema must take; `undefined` when it has none.
146
+ */
147
+ readonly toPanelValue?: (value: unknown, argType: FrameArgType) => JsonValue | undefined;
100
148
  };
@@ -97,9 +97,11 @@ const withArgType = (prop, argType, own) => {
97
97
  const { control } = argType;
98
98
  // a control the panel cannot draw makes the prop one it lists for reading, never one that takes a text
99
99
  const isReadOnly = control === "none";
100
- const { canTakeText: _canTakeText, ...rest } = prop;
100
+ // a control the story names wins over the one the catalogue gives the prop's type
101
+ const { customControl: _typed, ...untyped } = prop;
102
+ const { canTakeText: _canTakeText, ...rest } = untyped;
101
103
  // a control the story names decides what its value is: the type's shape no longer draws nor checks it
102
- const { shape: _shape, ...shapeless } = isReadOnly || argType.customControl !== undefined ? rest : prop;
104
+ const { shape: _shape, ...shapeless } = isReadOnly || argType.customControl !== undefined ? rest : untyped;
103
105
  return {
104
106
  ...(control === undefined && argType.customControl === undefined ? prop : shapeless),
105
107
  ...(control === undefined ? {} : { kind: KIND_OF_CONTROL[control], control }),
@@ -129,12 +129,24 @@ export type PropDocs = {
129
129
  * union by the types it happened to meet first, so it changed between reads of a program kept in memory.
130
130
  */
131
131
  readonly type: string;
132
+ /**
133
+ * The names its type goes by, when they are more than `type`: the name written, the name it was declared with when an
134
+ * import renamed it, and each alias it is declared through (`ButtonIcon`, `IconComponent` for
135
+ * `type ButtonIcon = IconComponent`), without `undefined` and `null`; for a union, the names every member goes by. Left
136
+ * out when `type` says them all.
137
+ */
138
+ readonly typeNames?: readonly string[];
132
139
  /** The first paragraph of its TSDoc; empty when it has none. */
133
140
  readonly description: string;
134
141
  /** Its `@defaultValue`, when the TSDoc gives one. */
135
142
  readonly defaultValue?: string;
136
143
  /** Whether the prop must be passed. */
137
144
  readonly isRequired: boolean;
145
+ /**
146
+ * The custom control the catalogue gives it by its type or its name (a control's `matches`), when one does: the
147
+ * plugin adds it as it serves the docs, so the panel draws it in every story that names no control for the prop.
148
+ */
149
+ readonly customControl?: string;
138
150
  /** For a choice, its values in the order of the type. */
139
151
  readonly choices?: readonly (string | number)[];
140
152
  /** For an event, its signature. */
@@ -32,7 +32,11 @@ const isMemberDocs = (value) => isRecord(value) &&
32
32
  (value["defaultValue"] === undefined || typeof value["defaultValue"] === "string");
33
33
  /** Whether a value is a note with a reason: what the reference reads first. */
34
34
  const isNote = (value) => isRecord(value) && typeof value["reason"] === "string";
35
- /** Whether a value is one prop's docs. */
35
+ /**
36
+ * Whether a value is one prop's docs. Some fields are left to what reads them, as a choice's values and a signature are:
37
+ * a custom control's name is only looked up among the catalogue's own controls, so a wrong one finds none and says so
38
+ * beside the field; the shell reads no type's names.
39
+ */
36
40
  const isPropDocs = (value) => isRecord(value) &&
37
41
  typeof value["name"] === "string" &&
38
42
  PROP_KINDS.some((kind) => kind === value["kind"]) &&
@@ -0,0 +1,27 @@
1
+ /**
2
+ * What a custom control is told of its arg, and the code its values are written as in a story's code: the expression a
3
+ * control's `toCode` gives (`IconStar` for the name `"IconStar"`), for the Code tab and `forgebook story`. Pure.
4
+ *
5
+ * @module
6
+ */
7
+ import type { ArgControlModule, ControlArgType } from "./argControl.ts";
8
+ import type { PanelProp } from "./argTypes.ts";
9
+ import { type PanelArgs } from "./messages.ts";
10
+ /**
11
+ * What a control is told of its arg.
12
+ *
13
+ * @param prop - the prop, with the story's `argTypes` laid over it.
14
+ */
15
+ export declare const controlArgTypeOf: (prop: PanelProp) => ControlArgType;
16
+ /** A control's `toCode`, by control: what writes its values as code. */
17
+ export type ControlCoders = Readonly<Record<string, ArgControlModule["toCode"]>>;
18
+ /**
19
+ * The code each arg of a custom control is written as, by arg: the expression its control's `toCode` gives for its
20
+ * value. An arg without a value, with the panel's "no value", whose control writes no code, or whose `toCode` throws
21
+ * or gives nothing, is written from its value as before.
22
+ *
23
+ * @param props - the props the panel sets, with the story's `argTypes` laid over them.
24
+ * @param args - the values the code is written from.
25
+ * @param coders - each control's `toCode`, by control.
26
+ */
27
+ export declare const controlCodeOf: (props: readonly PanelProp[], args: PanelArgs, coders: ControlCoders) => Readonly<Record<string, string>>;
@@ -0,0 +1,41 @@
1
+ import { isArgValue, isUnsetArg } from "./messages.js";
2
+ /**
3
+ * What a control is told of its arg.
4
+ *
5
+ * @param prop - the prop, with the story's `argTypes` laid over it.
6
+ */
7
+ export const controlArgTypeOf = (prop) => ({
8
+ name: prop.name,
9
+ label: prop.label ?? prop.name,
10
+ description: prop.description,
11
+ ...(prop.choices === undefined ? {} : { options: prop.choices }),
12
+ ...(prop.min === undefined ? {} : { min: prop.min }),
13
+ ...(prop.max === undefined ? {} : { max: prop.max }),
14
+ ...(prop.step === undefined ? {} : { step: prop.step }),
15
+ // the frame's word, read here since only a control needs it: what is no plain data never reaches a control
16
+ ...(prop.settings === undefined || !isArgValue(prop.settings) ? {} : { settings: prop.settings })
17
+ });
18
+ /**
19
+ * The code each arg of a custom control is written as, by arg: the expression its control's `toCode` gives for its
20
+ * value. An arg without a value, with the panel's "no value", whose control writes no code, or whose `toCode` throws
21
+ * or gives nothing, is written from its value as before.
22
+ *
23
+ * @param props - the props the panel sets, with the story's `argTypes` laid over them.
24
+ * @param args - the values the code is written from.
25
+ * @param coders - each control's `toCode`, by control.
26
+ */
27
+ export const controlCodeOf = (props, args, coders) => Object.fromEntries(props.flatMap((prop) => {
28
+ const control = prop.customControl;
29
+ const value = Object.hasOwn(args, prop.name) ? args[prop.name] : undefined;
30
+ const toCode = control !== undefined && Object.hasOwn(coders, control) ? coders[control] : undefined;
31
+ if (toCode === undefined || value === undefined || isUnsetArg(value))
32
+ return [];
33
+ try {
34
+ const code = toCode(value, controlArgTypeOf(prop));
35
+ return typeof code === "string" && code !== "" ? [[prop.name, code]] : [];
36
+ }
37
+ catch {
38
+ // the control's code is its own: a failure writes the value as JSON, as a control without `toCode`
39
+ return [];
40
+ }
41
+ }));
@@ -0,0 +1,39 @@
1
+ import { type PanelArgs } from "../messages.ts";
2
+ import { panelArgTypesOf } from "../storyArgTypes.ts";
3
+ import type { ControlFrameLoaders } from "./panelControls.ts";
4
+ /** What the frame modules made of the values. */
5
+ export type ControlValues = {
6
+ /** What the story receives, by arg, over what the panel's JSON gives it. */
7
+ readonly values: Readonly<Record<string, unknown>>;
8
+ /** The args whose value could not be made: the story keeps its own. */
9
+ readonly dropped: readonly string[];
10
+ /** The story's own values as the panel shows them, by arg, for those a frame module names. */
11
+ readonly own: PanelArgs;
12
+ /** What went wrong, one sentence per failure. */
13
+ readonly problems: readonly string[];
14
+ };
15
+ /** The story, as the frame modules read it. */
16
+ export type ControlStory = {
17
+ /** Its own args. */
18
+ readonly args: Readonly<Record<string, unknown>>;
19
+ /** Its `argTypes`: the controls they name, and a control's `settings`. */
20
+ readonly argTypes: Parameters<typeof panelArgTypesOf>[0];
21
+ /** Loads its component's docs, which name the controls the catalogue gives props by type; none without docs. */
22
+ readonly loadDocs?: (() => Promise<unknown>) | undefined;
23
+ };
24
+ /**
25
+ * The custom control of each arg, as the panel draws it: a control the story's `argTypes` name, else the one the
26
+ * catalogue gives the prop by type, unless the `argTypes` name another control for it.
27
+ *
28
+ * @param argTypes - the story's `argTypes`, as the panel reads them.
29
+ * @param docs - its component's docs, as the plugin served them.
30
+ */
31
+ export declare const argControlsOf: (argTypes: ReturnType<typeof panelArgTypesOf>, docs: unknown) => Readonly<Record<string, string>>;
32
+ /**
33
+ * The values of the controls with a frame module, as the story and the panel take them.
34
+ *
35
+ * @param args - the panel's values, checked by the shell.
36
+ * @param loaders - the frame modules, by control.
37
+ * @param story - the story's own args, its `argTypes` and its docs.
38
+ */
39
+ export declare const controlValuesOf: (args: PanelArgs, loaders: ControlFrameLoaders, story: ControlStory) => Promise<ControlValues>;