create-obsidian-arrow 0.2.2 → 0.4.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.
Files changed (45) hide show
  1. package/README.md +10 -7
  2. package/index.mjs +29 -10
  3. package/package.json +1 -1
  4. package/template/AGENTS.md +31 -5
  5. package/template/README.md +47 -5
  6. package/template/_gitignore +3 -0
  7. package/template/docs/prompts/agent-setup.md +26 -13
  8. package/template/docs/prompts/update-existing.md +13 -9
  9. package/template/docs/workflow.md +21 -8
  10. package/template/package.json +6 -2
  11. package/template/pnpm-lock.yaml +197 -0
  12. package/template/src/components/DiffViewer.ts +42 -0
  13. package/template/src/components/SettingsPanel.ts +1 -1
  14. package/template/src/main.ts +4 -3
  15. package/template/src/utilities.css +205 -0
  16. package/template/stories/DiffViewer.stories.ts +75 -0
  17. package/template/stories/SettingsPanel.stories.ts +11 -0
  18. package/template/stories/Toggle.stories.ts +28 -0
  19. package/template/test/token-utils.test.mjs +65 -0
  20. package/template/test/viewer-derive.test.mjs +65 -0
  21. package/template/test/viewer-stories.test.mjs +44 -0
  22. package/template/{src → tools}/router/client.ts +15 -2
  23. package/template/tools/router/routeToPage.ts +104 -0
  24. package/template/{src → tools}/sandbox/home.ts +55 -26
  25. package/template/tools/sandbox/sandbox.css +474 -0
  26. package/template/tools/viewer/ClassesPage.ts +37 -0
  27. package/template/tools/viewer/ComponentsIndex.ts +56 -0
  28. package/template/tools/viewer/StoryPage.ts +73 -0
  29. package/template/tools/viewer/TokensPage.ts +82 -0
  30. package/template/tools/viewer/derive.ts +91 -0
  31. package/template/tools/viewer/discovery.ts +64 -0
  32. package/template/tools/viewer/obsidian-classes.ts +269 -0
  33. package/template/tools/viewer/sidebar.ts +55 -0
  34. package/template/tools/viewer/stories.ts +83 -0
  35. package/template/tools/viewer/token-utils.ts +84 -0
  36. package/template/tools/viewer/tokens.ts +30 -0
  37. package/template/src/examples/ExamplesIndex.ts +0 -36
  38. package/template/src/examples/registry.ts +0 -26
  39. package/template/src/router/routeToPage.ts +0 -57
  40. package/template/src/sandbox/sandbox.css +0 -130
  41. /package/template/{src → tools}/sandbox/frame.ts +0 -0
  42. /package/template/{src → tools}/sandbox/layout.ts +0 -0
  43. /package/template/{src → tools}/sandbox/shell.ts +0 -0
  44. /package/template/{src → tools}/sandbox/theme.ts +0 -0
  45. /package/template/{src → tools}/sandbox/toolbar.ts +0 -0
@@ -0,0 +1,82 @@
1
+ import { component, html, reactive } from "@arrow-js/core";
2
+ import { themeState } from "../sandbox/theme";
3
+ import { copyText } from "./StoryPage";
4
+ import type { TokenDecl } from "./token-utils";
5
+ import { classifyValue, filterTokens, groupTokens } from "./token-utils";
6
+ import { collectTokenDecls, resolveToken } from "./tokens";
7
+
8
+ const state = reactive({ query: "" });
9
+
10
+ function tokenRow(decl: TokenDecl) {
11
+ // Depend on themeState.theme so rows re-resolve when the theme toggles.
12
+ const resolved = (): string => {
13
+ void themeState.theme;
14
+ return resolveToken(decl.name) || decl.value;
15
+ };
16
+ return html`
17
+ <div class="oas-token-row">
18
+ <code class="oas-token-name">${decl.name}</code>
19
+ <span class="oas-token-value">${() => resolved()}</span>
20
+ ${() => {
21
+ const kind = classifyValue(resolved());
22
+ if (kind === "color") {
23
+ return html`<span class="oas-swatch" style="${() => `background: ${resolved()};`}"></span>`;
24
+ }
25
+ if (kind === "length") {
26
+ return html`<span class="oas-sizebar" style="${() => `width: ${resolved()};`}"></span>`;
27
+ }
28
+ return html`<span class="oas-swatch-none"></span>`;
29
+ }}
30
+ <button class="oas-copy" @click="${() => copyText(`var(${decl.name})`)}">Copy</button>
31
+ </div>
32
+ `;
33
+ }
34
+
35
+ export const TokensPage = component(() => {
36
+ const decls = collectTokenDecls();
37
+ return html`
38
+ <div class="oas-reference">
39
+ <div class="setting-item setting-item-heading">
40
+ <div class="setting-item-info">
41
+ <div class="setting-item-name">Obsidian tokens (${String(decls.length)})</div>
42
+ <div class="setting-item-description">
43
+ Parsed live from the loaded app.css; values resolve in the current theme. Copy gives
44
+ you the var() reference.
45
+ </div>
46
+ </div>
47
+ </div>
48
+ ${
49
+ decls.length === 0
50
+ ? html`<div class="setting-item">
51
+ <div class="setting-item-info">
52
+ <div class="setting-item-name">No tokens found</div>
53
+ <div class="setting-item-description">
54
+ app.css doesn't appear to be loaded — run \`pnpm pull-css\`, then reload.
55
+ </div>
56
+ </div>
57
+ </div>`
58
+ : ""
59
+ }
60
+ <input
61
+ class="oas-token-filter"
62
+ type="search"
63
+ placeholder="Filter tokens…"
64
+ .value="${() => state.query}"
65
+ @input="${(e: Event) => {
66
+ state.query = (e.target as HTMLInputElement).value;
67
+ }}"
68
+ />
69
+ ${() =>
70
+ groupTokens(filterTokens(decls, state.query)).map((group) =>
71
+ html`
72
+ <div class="oas-token-group">
73
+ <div class="vertical-tab-header-group-title">
74
+ ${group.label} (${String(group.tokens.length)})
75
+ </div>
76
+ ${group.tokens.map((decl) => tokenRow(decl))}
77
+ </div>
78
+ `.key(group.label)
79
+ )}
80
+ </div>
81
+ `;
82
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Pure derivation helpers for the component viewer. DOM-free so node:test can
3
+ * cover them directly (via --experimental-strip-types).
4
+ *
5
+ * Glob keys come from import.meta.glob in src/viewer/discovery.ts, relative to
6
+ * src/viewer/ — e.g. "../../stories/SettingsPanel.stories.ts".
7
+ */
8
+
9
+ export interface StoryPathMeta {
10
+ slug: string;
11
+ storiesPath: string;
12
+ componentPath: string;
13
+ }
14
+
15
+ export function kebabCase(name: string): string {
16
+ return name
17
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
18
+ .replace(/[\s_]+/g, "-")
19
+ .toLowerCase();
20
+ }
21
+
22
+ export function titleFromSlug(slug: string): string {
23
+ return slug
24
+ .split("-")
25
+ .filter(Boolean)
26
+ .map((word) => word[0].toUpperCase() + word.slice(1))
27
+ .join(" ");
28
+ }
29
+
30
+ /**
31
+ * "../../stories/Foo.stories.ts" → project-relative paths + slug.
32
+ *
33
+ * Glob keys are relative to src/viewer/, so "../../" reaches the project root.
34
+ * Default componentPath mirrors the stories/ subtree into src/components/:
35
+ * stories/Foo.stories.ts → src/components/Foo.ts
36
+ * stories/chat/Foo.stories.ts → src/components/chat/Foo.ts
37
+ * Override with componentPath in the story def when the component lives elsewhere.
38
+ */
39
+ export function storyMetaFromGlobKey(globKey: string): StoryPathMeta {
40
+ const storiesPath = globKey.replace(/^(\.\.\/)+/, "");
41
+ const componentPath = storiesPath
42
+ .replace(/^stories\//, "src/components/")
43
+ .replace(/\.stories\.ts$/, ".ts");
44
+ const base = storiesPath.split("/").pop() ?? "";
45
+ const slug = kebabCase(base.replace(/\.stories\.ts$/, ""));
46
+ return { slug, storiesPath, componentPath };
47
+ }
48
+
49
+ export interface TreeNode {
50
+ slug: string;
51
+ children: TreeNode[];
52
+ }
53
+
54
+ /**
55
+ * Build the sidebar tree: stories referenced as another story's child nest
56
+ * under it; unreferenced stories are roots. Cycles are guarded (a slug never
57
+ * nests under itself); if a cycle leaves no natural roots, all items become
58
+ * flat roots so nothing disappears from the sidebar.
59
+ */
60
+ export function buildStoryTree(items: { slug: string; children?: string[] }[]): {
61
+ roots: TreeNode[];
62
+ unknownChildren: { parent: string; child: string }[];
63
+ } {
64
+ const bySlug = new Map(items.map((item) => [item.slug, item]));
65
+ const referenced = new Set<string>();
66
+ const unknownChildren: { parent: string; child: string }[] = [];
67
+
68
+ for (const item of items) {
69
+ for (const child of item.children ?? []) {
70
+ if (bySlug.has(child)) {
71
+ referenced.add(child);
72
+ } else {
73
+ unknownChildren.push({ parent: item.slug, child });
74
+ }
75
+ }
76
+ }
77
+
78
+ const build = (slug: string, seen: Set<string>): TreeNode => {
79
+ const next = new Set(seen).add(slug);
80
+ const children = (bySlug.get(slug)?.children ?? [])
81
+ .filter((child) => bySlug.has(child) && !next.has(child))
82
+ .map((child) => build(child, next));
83
+ return { slug, children };
84
+ };
85
+
86
+ let rootItems = items.filter((item) => !referenced.has(item.slug));
87
+ if (rootItems.length === 0 && items.length > 0) {
88
+ rootItems = items;
89
+ }
90
+ return { roots: rootItems.map((item) => build(item.slug, new Set())), unknownChildren };
91
+ }
@@ -0,0 +1,64 @@
1
+ import { buildStoryTree, storyMetaFromGlobKey, titleFromSlug } from "./derive";
2
+ import type { StoryDef, StoryVariant } from "./stories";
3
+ import { normalizeVariants, validateStoryDef } from "./stories";
4
+
5
+ /**
6
+ * Discovers stories/*.stories.ts files at build time. Stories live in the
7
+ * top-level stories/ directory (not in src/), keeping src/ pure component code.
8
+ * The glob key IS the file path, so src locations shown in the viewer are
9
+ * derived, never hand-maintained. One malformed story file is skipped (with a
10
+ * console.warn and an entry in invalidStories) — it never blanks the viewer.
11
+ */
12
+
13
+ export interface DiscoveredStory {
14
+ slug: string;
15
+ title: string;
16
+ description?: string;
17
+ storiesPath: string;
18
+ componentPath: string;
19
+ variants: Record<string, StoryVariant>;
20
+ children: string[];
21
+ status: "live" | "draft";
22
+ }
23
+
24
+ export interface InvalidStory {
25
+ storiesPath: string;
26
+ reason: string;
27
+ }
28
+
29
+ const modules = import.meta.glob("../../stories/**/*.stories.ts", { eager: true }) as Record<
30
+ string,
31
+ { default?: unknown }
32
+ >;
33
+
34
+ export const stories: DiscoveredStory[] = [];
35
+ export const invalidStories: InvalidStory[] = [];
36
+
37
+ for (const [globKey, mod] of Object.entries(modules)) {
38
+ const meta = storyMetaFromGlobKey(globKey);
39
+ const check = validateStoryDef(mod.default);
40
+ if (!check.ok) {
41
+ console.warn(`[viewer] skipping ${meta.storiesPath}: ${check.reason}`);
42
+ invalidStories.push({ storiesPath: meta.storiesPath, reason: check.reason });
43
+ continue;
44
+ }
45
+ const def = mod.default as StoryDef;
46
+ stories.push({
47
+ slug: meta.slug,
48
+ title: def.title ?? titleFromSlug(meta.slug),
49
+ description: def.description,
50
+ storiesPath: meta.storiesPath,
51
+ componentPath: def.componentPath ?? meta.componentPath,
52
+ variants: normalizeVariants(def.variants),
53
+ children: def.children ?? [],
54
+ status: def.status ?? "draft",
55
+ });
56
+ }
57
+
58
+ stories.sort((a, b) => a.title.localeCompare(b.title));
59
+
60
+ export const storyTree = buildStoryTree(stories);
61
+
62
+ export function findStory(slug: string): DiscoveredStory | undefined {
63
+ return stories.find((story) => story.slug === slug);
64
+ }
@@ -0,0 +1,269 @@
1
+ import { html, reactive } from "@arrow-js/core";
2
+ import type { ArrowExpression } from "@arrow-js/core";
3
+
4
+ /**
5
+ * Curated catalog of the Obsidian pattern classes worth leveraging, each with
6
+ * a live preview rendered against the real app.css. Curated on purpose:
7
+ * extracting selectors from app.css yields thousands of internal one-offs.
8
+ * When the utility-class layer lands (spec 2), its classes get documented here.
9
+ */
10
+
11
+ export interface ClassEntry {
12
+ className: string;
13
+ whenToUse: string;
14
+ preview: () => ArrowExpression;
15
+ }
16
+
17
+ export interface ClassGroup {
18
+ label: string;
19
+ entries: ClassEntry[];
20
+ }
21
+
22
+ const toggleState = reactive({ on: true });
23
+
24
+ export const classGroups: ClassGroup[] = [
25
+ {
26
+ label: "Settings",
27
+ entries: [
28
+ {
29
+ className: ".setting-item",
30
+ whenToUse: "Any labeled row with a control (with .setting-item-info / -control).",
31
+ preview: () => html`
32
+ <div class="setting-item">
33
+ <div class="setting-item-info">
34
+ <div class="setting-item-name">Setting name</div>
35
+ <div class="setting-item-description">One-line description.</div>
36
+ </div>
37
+ <div class="setting-item-control"><button class="mod-cta">Action</button></div>
38
+ </div>
39
+ `,
40
+ },
41
+ {
42
+ className: ".setting-item-heading",
43
+ whenToUse: "Section header row inside a settings-style list.",
44
+ preview: () => html`
45
+ <div class="setting-item setting-item-heading">
46
+ <div class="setting-item-info">
47
+ <div class="setting-item-name">Section heading</div>
48
+ </div>
49
+ </div>
50
+ `,
51
+ },
52
+ ],
53
+ },
54
+ {
55
+ label: "Controls",
56
+ entries: [
57
+ {
58
+ className: "button.mod-cta",
59
+ whenToUse: "Primary call-to-action button.",
60
+ preview: () => html`<button class="mod-cta">Primary action</button>`,
61
+ },
62
+ {
63
+ className: ".clickable-icon",
64
+ whenToUse: "Icon button — escapes Obsidian's global button background rule.",
65
+ preview: () => html`<button class="clickable-icon" aria-label="Example">☾</button>`,
66
+ },
67
+ {
68
+ className: ".checkbox-container (.is-enabled)",
69
+ whenToUse: "Obsidian's toggle; flip is-enabled to switch state.",
70
+ preview: () => html`
71
+ <div
72
+ class="${() => (toggleState.on ? "checkbox-container is-enabled" : "checkbox-container")}"
73
+ @click="${() => {
74
+ toggleState.on = !toggleState.on;
75
+ }}"
76
+ >
77
+ <input type="checkbox" tabindex="0" .checked="${() => toggleState.on}" />
78
+ </div>
79
+ `,
80
+ },
81
+ ],
82
+ },
83
+ {
84
+ label: "Navigation",
85
+ entries: [
86
+ {
87
+ className: ".vertical-tab-nav-item (.is-active)",
88
+ whenToUse: "Settings-style vertical tab rows (as used by this sidebar).",
89
+ preview: () => html`
90
+ <div class="vertical-tab-nav-item is-active">Active tab</div>
91
+ <div class="vertical-tab-nav-item">Inactive tab</div>
92
+ `,
93
+ },
94
+ ],
95
+ },
96
+ {
97
+ label: "Modal & prompt",
98
+ entries: [
99
+ {
100
+ className: ".modal",
101
+ whenToUse:
102
+ "Dialog card (.modal-title + .modal-content). Preview is contained by a transformed wrapper.",
103
+ preview: () => html`
104
+ <div class="modal oas-modal-preview">
105
+ <div class="modal-title">Modal title</div>
106
+ <div class="modal-content">Modal content goes here.</div>
107
+ </div>
108
+ `,
109
+ },
110
+ {
111
+ className: ".prompt-input",
112
+ whenToUse: "Fuzzy-finder style text input inside prompts.",
113
+ preview: () =>
114
+ html`<input class="prompt-input" type="text" placeholder="Type to filter…" />`,
115
+ },
116
+ ],
117
+ },
118
+ {
119
+ label: "Workspace chrome",
120
+ entries: [
121
+ {
122
+ className: ".view-header / .view-header-title",
123
+ whenToUse: "Pane header bar, as used by this sandbox's Frame.",
124
+ preview: () => html`
125
+ <div class="view-header">
126
+ <div class="view-header-title">View title</div>
127
+ </div>
128
+ `,
129
+ },
130
+ ],
131
+ },
132
+ {
133
+ label: "Callout",
134
+ entries: [
135
+ {
136
+ className: '.callout[data-callout="info"]',
137
+ whenToUse:
138
+ 'Info callout block. Swap data-callout for "warning", "danger", "tip", "success", "note" etc.',
139
+ preview: () => html`
140
+ <div class="callout" data-callout="info">
141
+ <div class="callout-title"><div class="callout-title-inner">Info</div></div>
142
+ <div class="callout-content"><p>Callout body text.</p></div>
143
+ </div>
144
+ `,
145
+ },
146
+ {
147
+ className: '.callout[data-callout="warning"]',
148
+ whenToUse:
149
+ "Warning callout — same structure, different data-callout triggers Obsidian's color + icon.",
150
+ preview: () => html`
151
+ <div class="callout" data-callout="warning">
152
+ <div class="callout-title"><div class="callout-title-inner">Warning</div></div>
153
+ <div class="callout-content"><p>Something to be aware of.</p></div>
154
+ </div>
155
+ `,
156
+ },
157
+ ],
158
+ },
159
+ {
160
+ label: "Tags & badges",
161
+ entries: [
162
+ {
163
+ className: ".tag",
164
+ whenToUse: "Inline tag pill — styled like Obsidian's note tags.",
165
+ preview: () => html`<span class="tag">#plugin</span> <span class="tag">#arrow-js</span>`,
166
+ },
167
+ {
168
+ className: ".badge",
169
+ whenToUse: "Numeric counter badge (e.g. unread count on a nav item).",
170
+ preview: () => html`
171
+ <div style="display:flex;align-items:center;gap:8px;">
172
+ <span>Notifications</span><span class="badge">3</span>
173
+ </div>
174
+ `,
175
+ },
176
+ ],
177
+ },
178
+ {
179
+ label: "Suggestion list",
180
+ entries: [
181
+ {
182
+ className: ".suggestion-item (.is-selected)",
183
+ whenToUse:
184
+ "Fuzzy-finder / autocomplete row. .suggestion-container wraps the list; .is-selected highlights the focused item.",
185
+ preview: () => html`
186
+ <div class="suggestion-container">
187
+ <div class="suggestion">
188
+ <div class="suggestion-item is-selected">selected-file.md</div>
189
+ <div class="suggestion-item">another-file.md</div>
190
+ <div class="suggestion-item">third-file.md</div>
191
+ </div>
192
+ </div>
193
+ `,
194
+ },
195
+ ],
196
+ },
197
+ {
198
+ label: "Status modifiers",
199
+ entries: [
200
+ {
201
+ className: "button.mod-warning",
202
+ whenToUse: "Secondary/warning button variant.",
203
+ preview: () => html`<button class="mod-warning">Warning action</button>`,
204
+ },
205
+ {
206
+ className: "button.mod-destructive",
207
+ whenToUse: "Destructive / danger button.",
208
+ preview: () => html`<button class="mod-destructive">Delete</button>`,
209
+ },
210
+ {
211
+ className: ".mod-muted (text)",
212
+ whenToUse: "Muted text color applied to any element via this modifier.",
213
+ preview: () => html`<span class="mod-muted">Secondary text</span>`,
214
+ },
215
+ ],
216
+ },
217
+ {
218
+ label: "File tree",
219
+ entries: [
220
+ {
221
+ className: ".nav-file-title (.is-active)",
222
+ whenToUse: "File row in a nav/file-tree pane. Use .is-active for the currently open file.",
223
+ preview: () => html`
224
+ <div class="nav-files-container">
225
+ <div class="nav-file">
226
+ <div class="nav-file-title is-active">active-note.md</div>
227
+ </div>
228
+ <div class="nav-file">
229
+ <div class="nav-file-title">another-note.md</div>
230
+ </div>
231
+ </div>
232
+ `,
233
+ },
234
+ {
235
+ className: ".nav-folder-title",
236
+ whenToUse: "Folder row — collapsible; pair with .nav-folder-collapse-indicator.",
237
+ preview: () => html`
238
+ <div class="nav-folder">
239
+ <div class="nav-folder-title">
240
+ <div class="nav-folder-collapse-indicator collapse-icon"></div>
241
+ My Folder
242
+ </div>
243
+ </div>
244
+ `,
245
+ },
246
+ ],
247
+ },
248
+ {
249
+ label: "Metadata",
250
+ entries: [
251
+ {
252
+ className: ".metadata-container",
253
+ whenToUse: "Frontmatter property table rendered by Obsidian's properties view.",
254
+ preview: () => html`
255
+ <div class="metadata-container">
256
+ <div class="metadata-property">
257
+ <div class="metadata-property-key">
258
+ <div class="metadata-property-key-input">status</div>
259
+ </div>
260
+ <div class="metadata-property-value">
261
+ <div class="metadata-input-markdown">draft</div>
262
+ </div>
263
+ </div>
264
+ </div>
265
+ `,
266
+ },
267
+ ],
268
+ },
269
+ ];
@@ -0,0 +1,55 @@
1
+ import { html } from "@arrow-js/core";
2
+ import type { ArrowTemplate } from "@arrow-js/core";
3
+ import type { ArrowExpression } from "@arrow-js/core";
4
+ import type { TreeNode } from "./derive";
5
+ import { findStory, invalidStories, storyTree } from "./discovery";
6
+
7
+ /**
8
+ * Viewer navigation: component tree (children indented under parents) plus the
9
+ * Reference section. Rendered OUTSIDE the pane, as the first child of the
10
+ * stage. Sandbox chrome — never ports to a plugin.
11
+ */
12
+
13
+ function navClass(active: boolean): string {
14
+ return active
15
+ ? "vertical-tab-nav-item oas-nav-item is-active"
16
+ : "vertical-tab-nav-item oas-nav-item";
17
+ }
18
+
19
+ function nodeRows(node: TreeNode, activePath: string, depth: number): ArrowTemplate | string {
20
+ const story = findStory(node.slug);
21
+ if (!story) {
22
+ return "";
23
+ }
24
+ const href = `/components/${node.slug}`;
25
+ return html`
26
+ <a
27
+ class="${navClass(activePath === href)}"
28
+ style="${`padding-left: calc(var(--size-4-3) * ${depth + 1});`}"
29
+ href="${href}"
30
+ >${story.title}</a>
31
+ ${node.children.map((child) => nodeRows(child, activePath, depth + 1))}
32
+ `;
33
+ }
34
+
35
+ export function ViewerSidebar(activePath: string): ArrowExpression {
36
+ return html`
37
+ <nav class="oas-sidebar">
38
+ <div class="vertical-tab-header-group">
39
+ <div class="vertical-tab-header-group-title">Components</div>
40
+ ${storyTree.roots.map((node) => nodeRows(node, activePath, 0))}
41
+ ${invalidStories.map(
42
+ (bad) =>
43
+ html`<div class="vertical-tab-nav-item oas-nav-invalid" title="${bad.reason}">
44
+ invalid: ${bad.storiesPath}
45
+ </div>`
46
+ )}
47
+ </div>
48
+ <div class="vertical-tab-header-group">
49
+ <div class="vertical-tab-header-group-title">Reference</div>
50
+ <a class="${navClass(activePath === "/reference")}" href="/reference">Tokens</a>
51
+ <a class="${navClass(activePath === "/reference/classes")}" href="/reference/classes">Classes</a>
52
+ </div>
53
+ </nav>
54
+ `;
55
+ }
@@ -0,0 +1,83 @@
1
+ import type { ArrowExpression } from "@arrow-js/core";
2
+
3
+ /**
4
+ * Single-object story format (see the 2026-06-30 spec's decision record: CSF 3
5
+ * and Ladle were the references; we diverged because no Storybook renderer
6
+ * exists for Arrow, and one validated object is harder to get wrong).
7
+ */
8
+
9
+ export interface StoryVariant {
10
+ render: () => ArrowExpression;
11
+ notes?: string;
12
+ }
13
+
14
+ export type VariantInput = StoryVariant | (() => ArrowExpression);
15
+
16
+ export interface StoryDef {
17
+ /** Display name; defaults to the start-cased filename. */
18
+ title?: string;
19
+ /** One-line description shown in the tree and header. */
20
+ description?: string;
21
+ /** Repo-relative override for where the component lives (e.g. a subcomponent
22
+ * defined inside its parent's file). Defaults to the stories path minus `.stories`. */
23
+ componentPath?: string;
24
+ /** Named variants; keys are human strings ("default", "dev mode off"). */
25
+ variants: Record<string, VariantInput>;
26
+ /** Slugs of subcomponent stories for drill-in nesting. */
27
+ children?: string[];
28
+ /** Whether this component is production-ready or still in development. */
29
+ status?: "live" | "draft";
30
+ }
31
+
32
+ export function defineStories(def: StoryDef): StoryDef {
33
+ return def;
34
+ }
35
+
36
+ export type ValidationResult = { ok: true } | { ok: false; reason: string };
37
+
38
+ function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }
41
+
42
+ export function validateStoryDef(def: unknown): ValidationResult {
43
+ if (!isRecord(def)) {
44
+ return { ok: false, reason: "default export is not an object (use defineStories({...}))" };
45
+ }
46
+ for (const field of ["title", "description", "componentPath"] as const) {
47
+ if (field in def && typeof def[field] !== "string") {
48
+ return { ok: false, reason: `"${field}" must be a string` };
49
+ }
50
+ }
51
+ if (!isRecord(def.variants) || Object.keys(def.variants).length === 0) {
52
+ return { ok: false, reason: '"variants" must be a non-empty object' };
53
+ }
54
+ for (const [name, variant] of Object.entries(def.variants)) {
55
+ const valid =
56
+ typeof variant === "function" || (isRecord(variant) && typeof variant.render === "function");
57
+ if (!valid) {
58
+ return { ok: false, reason: `variant "${name}" must be a render fn or { render }` };
59
+ }
60
+ }
61
+ if ("children" in def) {
62
+ const children = def.children;
63
+ if (!Array.isArray(children) || children.some((child) => typeof child !== "string")) {
64
+ return { ok: false, reason: '"children" must be an array of story slugs' };
65
+ }
66
+ }
67
+ if ("status" in def) {
68
+ if (def.status !== "live" && def.status !== "draft") {
69
+ return { ok: false, reason: '"status" must be "live" or "draft"' };
70
+ }
71
+ }
72
+ return { ok: true };
73
+ }
74
+
75
+ export function normalizeVariants(
76
+ variants: Record<string, VariantInput>
77
+ ): Record<string, StoryVariant> {
78
+ const out: Record<string, StoryVariant> = {};
79
+ for (const [name, variant] of Object.entries(variants)) {
80
+ out[name] = typeof variant === "function" ? { render: variant } : variant;
81
+ }
82
+ return out;
83
+ }