create-obsidian-arrow 0.4.1 → 0.5.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 (31) hide show
  1. package/package.json +1 -1
  2. package/template/AGENTS.md +52 -17
  3. package/template/README.md +12 -14
  4. package/template/_gitignore +3 -0
  5. package/template/docs/prompts/agent-setup.md +2 -0
  6. package/template/docs/workflow.md +13 -7
  7. package/template/package.json +9 -2
  8. package/template/pnpm-lock.yaml +3 -0
  9. package/template/porting.config.example.json +6 -0
  10. package/template/scripts/check-orphaned-css.mjs +62 -0
  11. package/template/scripts/check-scope-classes.mjs +77 -0
  12. package/template/scripts/check-view-imports.mjs +133 -0
  13. package/template/scripts/component-hash.mjs +12 -1
  14. package/template/scripts/create-component.mjs +101 -0
  15. package/template/scripts/create-view.mjs +75 -0
  16. package/template/scripts/port-css.mjs +118 -0
  17. package/template/src/components/EmptyState/EmptyState.css +30 -0
  18. package/template/src/components/EmptyState/EmptyState.ts +35 -0
  19. package/template/src/components/LoadingState.ts +12 -0
  20. package/template/src/components/icons.ts +17 -0
  21. package/template/src/utilities.css +101 -1
  22. package/template/stories/components/EmptyState.stories.ts +25 -0
  23. package/template/stories/components/LoadingState.stories.ts +11 -0
  24. package/template/test/viewer-derive.test.mjs +6 -0
  25. package/template/test/viewer-stories.test.mjs +26 -0
  26. package/template/tools/router/client.ts +14 -3
  27. package/template/tools/router/routeToPage.ts +14 -2
  28. package/template/tools/sandbox/sandbox.css +13 -0
  29. package/template/tools/viewer/StoryPage.ts +35 -16
  30. package/template/tools/viewer/discovery.ts +6 -0
  31. package/template/tools/viewer/stories.ts +16 -0
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Usage:
4
+ * pnpm create:view ChatView → src/views/ChatView/ + stories/views/ChatView.stories.ts
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
+ const viewName = process.argv[2];
12
+
13
+ if (!viewName) {
14
+ console.error("Usage: pnpm create:view <ViewName>");
15
+ process.exit(1);
16
+ }
17
+
18
+ const viewDir = path.join(root, "src", "views", viewName);
19
+ const storyDir = path.join(root, "stories", "views");
20
+
21
+ if (fs.existsSync(viewDir)) {
22
+ console.error(`Already exists: src/views/${viewName}/`);
23
+ process.exit(1);
24
+ }
25
+
26
+ fs.mkdirSync(viewDir, { recursive: true });
27
+ fs.mkdirSync(storyDir, { recursive: true });
28
+
29
+ const tsContent = `import "./${viewName}.css";
30
+ import { component, html } from "@arrow-js/core";
31
+ import type { ArrowTemplate } from "@arrow-js/core";
32
+
33
+ export const ${viewName} = component((): ArrowTemplate => {
34
+ return html\`
35
+ <div class="oas-settings">
36
+ <div class="setting-item setting-item-heading">
37
+ <div class="setting-item-info">
38
+ <div class="setting-item-name">${viewName}</div>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ \`;
43
+ });
44
+ `;
45
+
46
+ const cssContent = `/* ${viewName} layout and chrome */\n`;
47
+
48
+ const stateContent = `import { reactive } from "@arrow-js/core";
49
+
50
+ export const state = reactive({
51
+ // TODO: add view state
52
+ });
53
+ `;
54
+
55
+ const storyContent = `import { defineStories } from "../../tools/viewer/stories";
56
+ import { ${viewName} } from "../../src/views/${viewName}/${viewName}";
57
+
58
+ export default defineStories({
59
+ kind: "view",
60
+ description: "TODO: describe ${viewName}.",
61
+ status: "draft",
62
+ variants: {
63
+ default: () => ${viewName}(),
64
+ },
65
+ });
66
+ `;
67
+
68
+ fs.writeFileSync(path.join(viewDir, `${viewName}.ts`), tsContent);
69
+ fs.writeFileSync(path.join(viewDir, `${viewName}.css`), cssContent);
70
+ fs.writeFileSync(path.join(viewDir, "state.ts"), stateContent);
71
+ fs.writeFileSync(path.join(storyDir, `${viewName}.stories.ts`), storyContent);
72
+
73
+ console.log(`Created src/views/${viewName}/`);
74
+ console.log(` ${viewName}.ts, ${viewName}.css, state.ts`);
75
+ console.log(`Created stories/views/${viewName}.stories.ts`);
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PostCSS prefix pipeline — port time only, never modifies source files.
4
+ *
5
+ * Usage: pnpm port:css
6
+ *
7
+ * Reads porting.config.json, walks CSS files matching `include` globs,
8
+ * prefixes all class selectors with `cssPrefix`, writes prefixed output
9
+ * to `outDir/`. Also generates outDir/index.css that @imports all outputs.
10
+ */
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import postcss from "postcss";
15
+
16
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
17
+ const configPath = path.join(root, "porting.config.json");
18
+
19
+ if (!fs.existsSync(configPath)) {
20
+ console.error("porting.config.json not found. Create it at the project root.");
21
+ process.exit(1);
22
+ }
23
+
24
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
25
+ const {
26
+ cssPrefix = "my-plugin-",
27
+ outDir = "port-output/css",
28
+ include = [],
29
+ viewSubScope = false,
30
+ } = config;
31
+
32
+ // Kebab-case a PascalCase view name for the ancestor selector.
33
+ function toKebab(name) {
34
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
35
+ }
36
+
37
+ // Collect CSS files matching include patterns (simple glob expansion)
38
+ function matchesGlob(file, pattern) {
39
+ // Convert glob to regex: ** → .*, * → [^/]*
40
+ const re = new RegExp(
41
+ `^${pattern.replace(/\*\*/g, "@@").replace(/\*/g, "[^/]*").replace(/@@/g, ".*")}$`
42
+ );
43
+ return re.test(file);
44
+ }
45
+
46
+ function walk(dir, results = []) {
47
+ if (!fs.existsSync(dir)) return results;
48
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
49
+ const full = path.join(dir, entry.name);
50
+ if (entry.isDirectory()) walk(full, results);
51
+ else if (entry.name.endsWith(".css")) results.push(full);
52
+ }
53
+ return results;
54
+ }
55
+
56
+ const allCss = walk(path.join(root, "src"));
57
+ const cssFiles = allCss.filter((f) => {
58
+ const rel = path.relative(root, f).replace(/\\/g, "/");
59
+ return include.some((pattern) => matchesGlob(rel, pattern));
60
+ });
61
+
62
+ if (cssFiles.length === 0) {
63
+ console.log("No CSS files matched include patterns.");
64
+ process.exit(0);
65
+ }
66
+
67
+ // PostCSS plugin: prefix all class selectors; optionally prepend an ancestor.
68
+ // When ancestorClass is provided (viewSubScope for view-folder CSS), every rule
69
+ // selector gains the ancestor: ".composer" → ".vault-mind-chat-view .vault-mind-composer"
70
+ const prefixPlugin = (prefix, ancestorClass) => ({
71
+ postcssPlugin: "postcss-prefix-classes",
72
+ Rule(rule) {
73
+ rule.selector = rule.selector.replace(/\.([a-zA-Z][\w-]*)/g, `.${prefix}$1`);
74
+ if (ancestorClass) {
75
+ rule.selector = rule.selector
76
+ .split(",")
77
+ .map((s) => `${ancestorClass} ${s.trim()}`)
78
+ .join(", ");
79
+ }
80
+ },
81
+ });
82
+ prefixPlugin.postcss = true;
83
+
84
+ const outDirAbs = path.join(root, outDir);
85
+ fs.mkdirSync(outDirAbs, { recursive: true });
86
+
87
+ const outputFiles = [];
88
+
89
+ for (const cssFile of cssFiles) {
90
+ const rel = path.relative(path.join(root, "src"), cssFile).replace(/\\/g, "/");
91
+ const outFile = path.join(outDirAbs, rel);
92
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
93
+
94
+ // Determine view ancestor class when viewSubScope is enabled.
95
+ // src/views/ChatView/X.css → ancestor ".vault-mind-chat-view"
96
+ let ancestorClass = null;
97
+ if (viewSubScope) {
98
+ const viewMatch = rel.match(/^views\/([^/]+)\//);
99
+ if (viewMatch) {
100
+ ancestorClass = `.${cssPrefix}${toKebab(viewMatch[1])}`;
101
+ }
102
+ }
103
+
104
+ const source = fs.readFileSync(cssFile, "utf8");
105
+ const result = await postcss([prefixPlugin(cssPrefix, ancestorClass)]).process(source, {
106
+ from: cssFile,
107
+ to: outFile,
108
+ });
109
+
110
+ fs.writeFileSync(outFile, result.css);
111
+ outputFiles.push(path.relative(outDirAbs, outFile).replace(/\\/g, "/"));
112
+ console.log(`Prefixed: ${rel} → ${path.relative(root, outFile)}`);
113
+ }
114
+
115
+ // Generate index.css
116
+ const indexContent = `${outputFiles.map((f) => `@import "./${f}";`).join("\n")}\n`;
117
+ fs.writeFileSync(path.join(outDirAbs, "index.css"), indexContent);
118
+ console.log(`\nGenerated: ${outDir}/index.css (${outputFiles.length} files)`);
@@ -0,0 +1,30 @@
1
+ .empty-state {
2
+ display: flex;
3
+ flex-direction: column;
4
+ align-items: center;
5
+ gap: var(--size-4-2);
6
+ padding: var(--size-4-4);
7
+ text-align: center;
8
+ color: var(--text-muted);
9
+ }
10
+
11
+ .empty-state-icon {
12
+ font-size: var(--font-ui-larger, 2rem);
13
+ line-height: 1;
14
+ color: var(--text-faint);
15
+ }
16
+
17
+ .empty-state-title {
18
+ font-size: var(--font-ui-medium);
19
+ font-weight: var(--font-semibold);
20
+ color: var(--text-normal);
21
+ }
22
+
23
+ .empty-state-description {
24
+ font-size: var(--font-ui-small);
25
+ max-width: 280px;
26
+ }
27
+
28
+ .empty-state-action {
29
+ margin-top: var(--size-4-2);
30
+ }
@@ -0,0 +1,35 @@
1
+ import "./EmptyState.css";
2
+ import { html } from "@arrow-js/core";
3
+ import type { ArrowExpression } from "@arrow-js/core";
4
+ import { icon } from "../icons";
5
+
6
+ export interface EmptyStateOptions {
7
+ icon?: string;
8
+ title: string;
9
+ description?: string;
10
+ action?: { label: string; onClick: () => void };
11
+ }
12
+
13
+ export function EmptyState(options: EmptyStateOptions): ArrowExpression {
14
+ return html`
15
+ <div class="empty-state">
16
+ ${options.icon ? html`<div class="empty-state-icon">${icon(options.icon)}</div>` : ""}
17
+ <div class="empty-state-title">${options.title}</div>
18
+ ${
19
+ options.description
20
+ ? html`<div class="empty-state-description">${options.description}</div>`
21
+ : ""
22
+ }
23
+ ${
24
+ options.action
25
+ ? html`<button
26
+ class="mod-cta empty-state-action"
27
+ @click="${options.action.onClick}"
28
+ >
29
+ ${options.action.label}
30
+ </button>`
31
+ : ""
32
+ }
33
+ </div>
34
+ `;
35
+ }
@@ -0,0 +1,12 @@
1
+ import { html } from "@arrow-js/core";
2
+ import type { ArrowExpression } from "@arrow-js/core";
3
+ import { icon } from "./icons";
4
+
5
+ export function LoadingState(message?: string): ArrowExpression {
6
+ return html`
7
+ <div class="oas-flex oas-flex-col oas-items-center oas-gap-2 oas-p-4 oas-text-muted">
8
+ <span>${icon("loader")}</span>
9
+ ${message ? html`<span class="oas-text-sm">${message}</span>` : ""}
10
+ </div>
11
+ `;
12
+ }
@@ -0,0 +1,17 @@
1
+ export const ICON_MAP: Record<string, string> = {
2
+ check: "✓",
3
+ x: "✕",
4
+ "chevron-right": "›",
5
+ "chevron-down": "⌄",
6
+ loader: "◌",
7
+ search: "⌕",
8
+ file: "◻",
9
+ folder: "◱",
10
+ info: "ℹ",
11
+ warning: "⚠",
12
+ error: "✕",
13
+ };
14
+
15
+ export function icon(name: string): string {
16
+ return ICON_MAP[name] ?? "•";
17
+ }
@@ -52,7 +52,56 @@
52
52
  flex-shrink: 0;
53
53
  }
54
54
 
55
- /* ── Spacing (Obsidian 4-px step scale) ─────────────────────── */
55
+ /*
56
+ * Spacing — two Obsidian token families:
57
+ *
58
+ * 4-px scale oas-gap-N / oas-p-N / oas-px-N / oas-py-N / oas-mt-N / oas-mb-N
59
+ * N=1 → --size-4-1 (4px) N=2 → --size-4-2 (8px) N=3 → --size-4-3 (12px)
60
+ * N=4 → --size-4-4 (16px) N=5 → --size-4-5 (20px) N=6 → --size-4-6 (24px)
61
+ *
62
+ * 2-px sub-scale oas-gap-2-1 / oas-p-2-1 … (mirrors Obsidian token name)
63
+ * 2-1 → --size-2-1 (2px) 2-3 → --size-2-3 (6px)
64
+ */
65
+
66
+ /* 2-px sub-scale ── */
67
+ .oas-gap-2-1 {
68
+ gap: var(--size-2-1);
69
+ }
70
+ .oas-p-2-1 {
71
+ padding: var(--size-2-1);
72
+ }
73
+ .oas-px-2-1 {
74
+ padding-inline: var(--size-2-1);
75
+ }
76
+ .oas-py-2-1 {
77
+ padding-block: var(--size-2-1);
78
+ }
79
+ .oas-mt-2-1 {
80
+ margin-top: var(--size-2-1);
81
+ }
82
+ .oas-mb-2-1 {
83
+ margin-bottom: var(--size-2-1);
84
+ }
85
+ .oas-gap-2-3 {
86
+ gap: var(--size-2-3);
87
+ }
88
+ .oas-p-2-3 {
89
+ padding: var(--size-2-3);
90
+ }
91
+ .oas-px-2-3 {
92
+ padding-inline: var(--size-2-3);
93
+ }
94
+ .oas-py-2-3 {
95
+ padding-block: var(--size-2-3);
96
+ }
97
+ .oas-mt-2-3 {
98
+ margin-top: var(--size-2-3);
99
+ }
100
+ .oas-mb-2-3 {
101
+ margin-bottom: var(--size-2-3);
102
+ }
103
+
104
+ /* 4-px scale ── */
56
105
  .oas-gap-1 {
57
106
  gap: var(--size-4-1);
58
107
  }
@@ -65,6 +114,12 @@
65
114
  .oas-gap-4 {
66
115
  gap: var(--size-4-4);
67
116
  }
117
+ .oas-gap-5 {
118
+ gap: var(--size-4-5);
119
+ }
120
+ .oas-gap-6 {
121
+ gap: var(--size-4-6);
122
+ }
68
123
  .oas-p-1 {
69
124
  padding: var(--size-4-1);
70
125
  }
@@ -77,12 +132,27 @@
77
132
  .oas-p-4 {
78
133
  padding: var(--size-4-4);
79
134
  }
135
+ .oas-p-5 {
136
+ padding: var(--size-4-5);
137
+ }
138
+ .oas-p-6 {
139
+ padding: var(--size-4-6);
140
+ }
80
141
  .oas-px-2 {
81
142
  padding-inline: var(--size-4-2);
82
143
  }
83
144
  .oas-px-3 {
84
145
  padding-inline: var(--size-4-3);
85
146
  }
147
+ .oas-px-4 {
148
+ padding-inline: var(--size-4-4);
149
+ }
150
+ .oas-px-5 {
151
+ padding-inline: var(--size-4-5);
152
+ }
153
+ .oas-px-6 {
154
+ padding-inline: var(--size-4-6);
155
+ }
86
156
  .oas-py-1 {
87
157
  padding-block: var(--size-4-1);
88
158
  }
@@ -92,6 +162,15 @@
92
162
  .oas-py-3 {
93
163
  padding-block: var(--size-4-3);
94
164
  }
165
+ .oas-py-4 {
166
+ padding-block: var(--size-4-4);
167
+ }
168
+ .oas-py-5 {
169
+ padding-block: var(--size-4-5);
170
+ }
171
+ .oas-py-6 {
172
+ padding-block: var(--size-4-6);
173
+ }
95
174
  .oas-mt-1 {
96
175
  margin-top: var(--size-4-1);
97
176
  }
@@ -101,6 +180,15 @@
101
180
  .oas-mt-3 {
102
181
  margin-top: var(--size-4-3);
103
182
  }
183
+ .oas-mt-4 {
184
+ margin-top: var(--size-4-4);
185
+ }
186
+ .oas-mt-5 {
187
+ margin-top: var(--size-4-5);
188
+ }
189
+ .oas-mt-6 {
190
+ margin-top: var(--size-4-6);
191
+ }
104
192
  .oas-mb-1 {
105
193
  margin-bottom: var(--size-4-1);
106
194
  }
@@ -110,6 +198,15 @@
110
198
  .oas-mb-3 {
111
199
  margin-bottom: var(--size-4-3);
112
200
  }
201
+ .oas-mb-4 {
202
+ margin-bottom: var(--size-4-4);
203
+ }
204
+ .oas-mb-5 {
205
+ margin-bottom: var(--size-4-5);
206
+ }
207
+ .oas-mb-6 {
208
+ margin-bottom: var(--size-4-6);
209
+ }
113
210
  .oas-ml-auto {
114
211
  margin-left: auto;
115
212
  }
@@ -195,6 +292,9 @@
195
292
  .oas-rounded-m {
196
293
  border-radius: var(--radius-m);
197
294
  }
295
+ .oas-rounded-l {
296
+ border-radius: var(--radius-l);
297
+ }
198
298
 
199
299
  /* ── Interaction ─────────────────────────────────────────────── */
200
300
  .oas-cursor-pointer {
@@ -0,0 +1,25 @@
1
+ import { EmptyState } from "../../src/components/EmptyState/EmptyState";
2
+ import { defineStories } from "../../tools/viewer/stories";
3
+
4
+ export default defineStories({
5
+ description: "Reusable empty state for any view — icon, title, description, optional action.",
6
+ status: "live",
7
+ variants: {
8
+ default: () => EmptyState({ title: "Nothing here yet" }),
9
+ "with description": () => {
10
+ return EmptyState({
11
+ icon: "file",
12
+ title: "No files found",
13
+ description: "Try a different search or create a new file.",
14
+ });
15
+ },
16
+ "with action": () => {
17
+ return EmptyState({
18
+ icon: "search",
19
+ title: "No results",
20
+ description: "Your search returned no matches.",
21
+ action: { label: "Clear search", onClick: () => {} },
22
+ });
23
+ },
24
+ },
25
+ });
@@ -0,0 +1,11 @@
1
+ import { LoadingState } from "../../src/components/LoadingState";
2
+ import { defineStories } from "../../tools/viewer/stories";
3
+
4
+ export default defineStories({
5
+ description: "Loading indicator for async view content.",
6
+ status: "live",
7
+ variants: {
8
+ default: () => LoadingState(),
9
+ "with message": () => LoadingState("Loading sessions…"),
10
+ },
11
+ });
@@ -63,3 +63,9 @@ test("buildStoryTree guards cycles: mutual refs fall back to flat roots", () =>
63
63
  assert.equal(roots[0].children[0].slug, "b");
64
64
  assert.equal(roots[0].children[0].children.length, 0);
65
65
  });
66
+
67
+ test("storyMetaFromGlobKey: stories/views/ path auto-kind is view", () => {
68
+ const meta = storyMetaFromGlobKey("../../stories/views/ChatView.stories.ts");
69
+ // auto-kind logic lives in discovery.ts, not derive.ts — just confirm storiesPath prefix
70
+ assert.ok(meta.storiesPath.startsWith("stories/views/"));
71
+ });
@@ -42,3 +42,29 @@ test("normalizeVariants wraps bare functions and passes objects through", () =>
42
42
  assert.equal(out.full.render, fn);
43
43
  assert.equal(out.full.notes, "n");
44
44
  });
45
+
46
+ test("validateStoryDef accepts kind: view and kind: component", () => {
47
+ const base = { variants: { default: () => {} } };
48
+ assert.deepEqual(validateStoryDef({ ...base, kind: "view" }), { ok: true });
49
+ assert.deepEqual(validateStoryDef({ ...base, kind: "component" }), { ok: true });
50
+ });
51
+
52
+ test("validateStoryDef rejects invalid kind", () => {
53
+ const r = validateStoryDef({ variants: { default: () => {} }, kind: "panel" });
54
+ assert.equal(r.ok, false);
55
+ assert.match(r.reason, /kind/);
56
+ });
57
+
58
+ test("validateStoryDef accepts decorator function", () => {
59
+ const r = validateStoryDef({
60
+ variants: { default: () => {} },
61
+ decorator: (c) => c,
62
+ });
63
+ assert.deepEqual(r, { ok: true });
64
+ });
65
+
66
+ test("validateStoryDef rejects non-function decorator", () => {
67
+ const r = validateStoryDef({ variants: { default: () => {} }, decorator: "bad" });
68
+ assert.equal(r.ok, false);
69
+ assert.match(r.reason, /decorator/);
70
+ });
@@ -1,4 +1,5 @@
1
1
  import { html } from "@arrow-js/core";
2
+ import type { ArrowExpression } from "@arrow-js/core";
2
3
  import { Frame } from "../sandbox/frame";
3
4
  import { Shell } from "../sandbox/shell";
4
5
  import type { Page } from "./routeToPage";
@@ -29,6 +30,10 @@ function getNavigation(): NavigationLike | undefined {
29
30
  return (window as unknown as { navigation?: NavigationLike }).navigation;
30
31
  }
31
32
 
33
+ function ComponentCanvas(content: ArrowExpression): ArrowExpression {
34
+ return html`<div class="oas-component-canvas">${content}</div>`;
35
+ }
36
+
32
37
  export function startRouter(root: HTMLElement): void {
33
38
  const render = (url: string): void => {
34
39
  let resolved = routeToPage(url);
@@ -42,9 +47,15 @@ export function startRouter(root: HTMLElement): void {
42
47
  const page: Page = resolved;
43
48
  document.title = page.title;
44
49
  root.replaceChildren();
45
- const content = page.sidebar
46
- ? html`${page.sidebar}${Frame(page.title, page.view)}`
47
- : Frame(page.title, page.view);
50
+
51
+ let content: ArrowExpression;
52
+ if (page.sidebar && page.canvas) {
53
+ content = html`${page.sidebar}${Frame(page.title, page.view)}${ComponentCanvas(page.canvas)}`;
54
+ } else if (page.sidebar) {
55
+ content = html`${page.sidebar}${Frame(page.title, page.view)}`;
56
+ } else {
57
+ content = Frame(page.title, page.view);
58
+ }
48
59
  Shell(content)(root);
49
60
  };
50
61
 
@@ -3,7 +3,7 @@ import type { ArrowExpression } from "@arrow-js/core";
3
3
  import { Home } from "../sandbox/home";
4
4
  import { ClassesPage } from "../viewer/ClassesPage";
5
5
  import { ComponentsIndex } from "../viewer/ComponentsIndex";
6
- import { StoryPage } from "../viewer/StoryPage";
6
+ import { StoryPageCanvas, StoryPageDetails, StoryPageView } from "../viewer/StoryPage";
7
7
  import { TokensPage } from "../viewer/TokensPage";
8
8
  import { findStory } from "../viewer/discovery";
9
9
  import { ViewerSidebar } from "../viewer/sidebar";
@@ -19,6 +19,7 @@ export interface Page {
19
19
  title: string;
20
20
  view: ArrowExpression;
21
21
  sidebar?: ArrowExpression;
22
+ canvas?: ArrowExpression;
22
23
  }
23
24
 
24
25
  export interface Redirect {
@@ -74,11 +75,22 @@ export function routeToPage(url: string): Page | Redirect {
74
75
  }
75
76
  const requested = searchParams.get("variant");
76
77
  const variantName = requested ?? Object.keys(story.variants)[0];
78
+ // kind: "view" → full page in the frame
79
+ if (story.kind === "view") {
80
+ return {
81
+ status: story.variants[variantName] ? 200 : 404,
82
+ title: `${story.title} · ${APP_NAME}`,
83
+ view: StoryPageView(story, variantName),
84
+ sidebar: ViewerSidebar(`/components/${story.slug}`),
85
+ };
86
+ }
87
+ // kind: "component" → details in frame, canvas separate
77
88
  return {
78
89
  status: story.variants[variantName] ? 200 : 404,
79
90
  title: `${story.title} · ${APP_NAME}`,
80
- view: StoryPage(story, variantName),
91
+ view: StoryPageDetails(story, variantName),
81
92
  sidebar: ViewerSidebar(`/components/${story.slug}`),
93
+ canvas: StoryPageCanvas(story, variantName),
82
94
  };
83
95
  }
84
96
 
@@ -130,6 +130,19 @@ body {
130
130
  opacity: 0.5;
131
131
  }
132
132
 
133
+ /* Component canvas — fills remaining stage width for kind: "component" stories */
134
+ .oas-component-canvas {
135
+ flex: 1;
136
+ min-width: 0;
137
+ min-height: 0;
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ padding: var(--size-4-4);
142
+ overflow: auto;
143
+ background: var(--background-secondary);
144
+ }
145
+
133
146
  /* Collapsible card — Getting Started accordion and other expandable sections. */
134
147
  .oas-card {
135
148
  border-bottom: 1px solid var(--background-modifier-border);