create-obsidian-arrow 0.4.0 → 0.5.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/index.mjs CHANGED
@@ -181,6 +181,14 @@ function update(targetArg) {
181
181
  }
182
182
  const pkgChanges = mergePackageJson(path.join(root, "package.json"));
183
183
 
184
+ // Create stories/ if missing — user-owned (never overwritten), but must
185
+ // exist before the first story file can be added.
186
+ const storiesDir = path.join(root, "stories");
187
+ const storiesCreated = !fs.existsSync(storiesDir);
188
+ if (storiesCreated && !dryRun) {
189
+ fs.mkdirSync(storiesDir);
190
+ }
191
+
184
192
  const verb = dryRun ? "Would refresh" : "Refreshed";
185
193
  console.log(
186
194
  `${verb} ${written.length} managed file(s) in ${path.relative(process.cwd(), root) || "."}:`
@@ -188,6 +196,11 @@ function update(targetArg) {
188
196
  for (const file of written) {
189
197
  console.log(` ${file}`);
190
198
  }
199
+ if (storiesCreated) {
200
+ console.log(
201
+ ` stories/ ${dryRun ? "(would create empty)" : "(created empty — add your *.stories.ts files here)"}`
202
+ );
203
+ }
191
204
  if (pkgChanges.length > 0) {
192
205
  console.log(`package.json: ${dryRun ? "would update" : "updated"} ${pkgChanges.join(", ")}`);
193
206
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-obsidian-arrow",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Scaffold an Obsidian-styled Arrow.js UI sandbox (pnpm create obsidian-arrow <dir>).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,19 +84,39 @@ in `boundary()`.
84
84
 
85
85
  ## Conventions
86
86
 
87
- - **Check `/reference/classes` first** — Obsidian has semantic classes for most
88
- common patterns. Use them before writing any custom CSS.
89
- - **Use `oas-*` utilities second** (`src/utilities.css`) flex, gap, padding,
90
- margin, typography, border, overflow helpers built on Obsidian's token scale.
91
- Prefer `class="oas-flex oas-gap-2"` over `style="display:flex;gap:8px"`.
92
- - **Custom CSS last** — only when Obsidian has no class and utilities don't cover
93
- it. Scope under container + element type, use `var(--…)` tokens for values.
94
- - Add a story by creating `stories/MyThing.stories.ts` stories live in the
95
- top-level `stories/` directory (not in `src/`). Import via
96
- `"../tools/viewer/stories"` and `"../src/components/MyThing"`. See the
97
- `obsidian-arrow-stories` skill for the full `defineStories` API (variants,
98
- children, status, notes). Browse live tokens at `/reference`, curated classes
99
- at `/reference/classes`.
87
+ - **Check `/reference/classes` first** — Obsidian has semantic classes for most patterns.
88
+ - **Use `oas-*` utilities second** (`src/utilities.css`) flex, gap, padding, etc.
89
+ - **Custom CSS last** — co-locate as `ComponentName.css`, import it from `ComponentName.ts`.
90
+
91
+ ### Directory structure
92
+
93
+ - `src/views/<ViewName>/` full-pane components mounted from `ItemView.onOpen()`. Named `*View.ts` or `*Panel.ts`. May have CSS, state.ts, and view-specific sub-components.
94
+ - `src/components/<ComponentName>/` reusable primitives. Folder when multiple files needed; flat `.ts` when single file.
95
+ - **View-specific components** (only used by one view) live inside that view's folder.
96
+ - **Promotion rule:** if a file inside `src/views/<View>/` is imported by 2+ views, move it to `src/components/`.
97
+ - **Import boundary:** nothing inside `src/views/<View>/` is imported from outside that folder. Enforced by lint scripts:
98
+ - `pnpm check:imports` catches cross-view boundary violations
99
+ - `pnpm check:css` — catches .css files with no importer (dead CSS)
100
+ - `pnpm check:scope` — catches CSS ancestor selectors never applied in templates
101
+ - **Shared icons:** import from `src/components/icons.ts` — do not declare local ICON_MAP copies.
102
+
103
+ ### Stories
104
+
105
+ - `stories/views/` — stories for views (`kind: "view"`)
106
+ - `stories/components/` — stories for primitives (`kind: "component"`)
107
+ - Mirror the `src/` structure. `kind` auto-detected from path; set explicitly to override.
108
+ - `kind` — specifies story type (`"view"` or `"component"`).
109
+ - `decorator?: (content: ArrowExpression) => ArrowExpression` — wraps the rendered variant in ancestor context when a component requires a parent class to render correctly.
110
+ - Scaffold: `pnpm create:view <Name>` / `pnpm create:component <Name>`
111
+
112
+ ### Composition analysis (extracting shared primitives)
113
+
114
+ When multiple views share a pattern, extract a primitive — but do it in this order:
115
+
116
+ 1. **Survey all implementations first** — read every view before designing the primitive.
117
+ 2. **Map shared shapes at the template level** — find the repeated HTML structure, not just the props.
118
+ 3. **Let the primitive emerge from actual shared code** — don't design a generic API upfront.
119
+ 4. **Extract and migrate simultaneously** — the primitive + its consumers must be shorter than the originals. If they aren't, the abstraction hasn't paid for itself.
100
120
 
101
121
  ## CSS scoping
102
122
 
@@ -114,12 +134,26 @@ in `boundary()`.
114
134
  pnpm typecheck # tsc --noEmit
115
135
  pnpm test # node:test
116
136
  pnpm lint # biome
117
- pnpm check # all of the above (format + typecheck + test)
137
+ pnpm check # all of the above + CSS orphan + import boundary + scope checks
118
138
  ```
119
139
 
120
- Then confirm the actual render in the browser at the `pnpm dev` URL — check the
121
- console is clean and the component looks like a real Obsidian pane. Do not claim
122
- a component works on typecheck alone; Arrow's footguns only surface at render.
140
+ Then confirm the actual render:
141
+
142
+ 1. **Cold-load required.** Close and reopen the browser tab do not just navigate
143
+ within the SPA. Vite's dev server keeps CSS module state across in-tab navigation,
144
+ which masks orphaned CSS and missing ancestor classes until a genuine cold load.
145
+
146
+ 2. **Check a computed style.** For each CSS change, assert at least one computed property
147
+ (`display`, `flexDirection`, `padding`) in the browser console — element count and
148
+ console-clean alone do not prove styles are applied:
149
+ ```js
150
+ getComputedStyle(document.querySelector('.my-class')).display // → "flex"
151
+ ```
152
+
153
+ 3. **Console clean.** No `[viewer]` warnings, no Arrow errors, no unhandled rejections.
154
+
155
+ Do not claim a component works on typecheck alone. Arrow's footguns and CSS adoption bugs
156
+ only surface at render time.
123
157
 
124
158
  ## Porting a component into the plugin
125
159
 
@@ -142,25 +142,22 @@ swatches, size bars, a filter input, theme-aware resolved values, and a copy
142
142
  button. `/reference/classes` is a curated catalog of Obsidian pattern classes
143
143
  with live previews.
144
144
 
145
- ### Add a story
145
+ ### Add a view
146
146
 
147
- Create `stories/MyThing.stories.ts` — stories live in the top-level `stories/`
148
- directory, keeping `src/` purely for component code:
147
+ ```sh
148
+ pnpm create:view ChatView
149
+ ```
149
150
 
150
- ```ts
151
- import { defineStories } from "../tools/viewer/stories";
152
- import { MyThing } from "../src/components/MyThing";
151
+ Creates `src/views/ChatView/` (ts + css + state.ts) and `stories/views/ChatView.stories.ts`.
153
152
 
154
- export default defineStories({
155
- description: "What it demonstrates.",
156
- status: "draft",
157
- variants: { default: () => MyThing() },
158
- });
153
+ ### Add a component
154
+
155
+ ```sh
156
+ pnpm create:component Composer # primitive src/components/Composer/
157
+ pnpm create:component ChatView/Widget # view-specific → src/views/ChatView/Widget.ts
159
158
  ```
160
159
 
161
- It appears in the sidebar and at `/components` automatically; the component path
162
- shown in the viewer is derived from the filename. Stories are sandbox-only —
163
- they never port to the plugin.
160
+ View stories use `kind: "view"` full pane frame. Component stories use `kind: "component"` — centered canvas. Both auto-detected from the `stories/views/` vs `stories/components/` path.
164
161
 
165
162
  ## Porting a component into the plugin
166
163
 
@@ -13,3 +13,6 @@ public/app.css
13
13
 
14
14
  # subagent-driven-development scratch (briefs, reports, ledger)
15
15
  .superpowers/
16
+
17
+ # PostCSS porting output (port-time only; never commit)
18
+ port-output/
@@ -58,19 +58,25 @@ in `src/` — never `src/components/` or `stories/`):
58
58
  ```sh
59
59
  # 1. Check /reference/classes in the running sandbox — does Obsidian have a class
60
60
  # for your pattern? Use it before writing custom CSS.
61
- # 2. Add src/components/MyThing.ts (Arrow component)
62
- # 3. Add stories/MyThing.stories.ts (story file lives in stories/, not src/)
63
- pnpm dev # iterate with HMR
61
+ # 2. Scaffold (or add manually):
62
+ pnpm create:view ChatView # full-pane view src/views/ChatView/ + stories/views/
63
+ pnpm create:component Composer # reusable primitive → src/components/Composer/ + stories/components/
64
+ pnpm create:component ChatView/Widget # view-specific sub-component (not promoted yet)
65
+ # 3. Iterate with HMR
66
+ pnpm dev
64
67
  # /components → index of all discovered stories
65
- # /components/my-thing → story for MyThing
68
+ # /components/my-thing → story for MyThing (kind: "component" → centered canvas)
69
+ # /components/chat-view → story for ChatView (kind: "view" → full pane)
66
70
  # /reference → live Obsidian token reference
67
71
  # /reference/classes → curated class catalog with live previews
68
72
  pnpm run ci # biome + typecheck + tests + build before trusting it
69
73
  ```
70
74
 
71
- Always confirm the actual render in the browser — Arrow's footguns surface only
72
- at render, so a passing `tsc` is not proof a component works. See the footguns in
73
- [`AGENTS.md`](../AGENTS.md) and the `arrow-js-obsidian-templates` skill.
75
+ Always confirm the actual render in the browser — Arrow's footguns and CSS scoping bugs
76
+ surface only at render time. **Close and reopen the tab** before each verification step
77
+ (do not rely on Vite's hot-module cache, which keeps CSS registered across SPA
78
+ navigation). Assert at least one computed CSS property per component, not just "console
79
+ clean." See [`AGENTS.md`](../AGENTS.md) for the full verification checklist.
74
80
 
75
81
  For the complete story authoring workflow (`defineStories` API, variants, children,
76
82
  status flag, DRY patterns) see the `obsidian-arrow-stories` skill.
@@ -11,12 +11,18 @@
11
11
  "preview": "vite preview",
12
12
  "typecheck": "tsc -p tsconfig.json --noEmit",
13
13
  "pull-css": "node scripts/pull-app-css.mjs",
14
+ "port:css": "node scripts/port-css.mjs",
14
15
  "create:sync": "node create-obsidian-arrow/scripts/sync-template.mjs",
16
+ "create:component": "node scripts/create-component.mjs",
17
+ "create:view": "node scripts/create-view.mjs",
15
18
  "lint": "biome check .",
16
19
  "format": "biome check --write .",
17
20
  "test": "node --experimental-strip-types --test test/*.test.mjs",
18
- "check": "biome check --write . && pnpm typecheck && pnpm test",
19
- "ci": "biome ci . && pnpm typecheck && pnpm test && pnpm build",
21
+ "check:css": "node scripts/check-orphaned-css.mjs",
22
+ "check:scope": "node scripts/check-scope-classes.mjs",
23
+ "check:imports": "node scripts/check-view-imports.mjs",
24
+ "check": "biome check . && pnpm typecheck && pnpm test && pnpm check:css && pnpm check:scope && pnpm check:imports",
25
+ "ci": "biome ci . && pnpm typecheck && pnpm test && pnpm check:css && pnpm check:scope && pnpm check:imports && pnpm build",
20
26
  "skills:install": "node scripts/install-skills.mjs --force",
21
27
  "skills:update": "node scripts/install-skills.mjs --update",
22
28
  "postinstall": "node scripts/install-skills.mjs",
@@ -38,6 +44,7 @@
38
44
  "@types/node": "^22.16.5",
39
45
  "husky": "^9.1.6",
40
46
  "lint-staged": "^15.2.10",
47
+ "postcss": "^8.5.16",
41
48
  "typescript": "^5.9.3",
42
49
  "vite": "^8.0.0"
43
50
  },
@@ -39,6 +39,9 @@ importers:
39
39
  lint-staged:
40
40
  specifier: ^15.2.10
41
41
  version: 15.5.2
42
+ postcss:
43
+ specifier: ^8.5.16
44
+ version: 8.5.16
42
45
  typescript:
43
46
  specifier: ^5.9.3
44
47
  version: 5.9.3
@@ -0,0 +1,6 @@
1
+ {
2
+ "cssPrefix": "my-plugin-",
3
+ "viewSubScope": false,
4
+ "include": ["src/components/**/*.css", "src/views/**/*.css"],
5
+ "outDir": "port-output/css"
6
+ }
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Finds .css files under src/ and tools/ that have no TypeScript importer.
4
+ * A .css file with no importer is dead CSS — safe to delete.
5
+ * Exit 1 on violations.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
+ const srcDir = path.join(root, "src");
13
+ const toolsDir = path.join(root, "tools");
14
+
15
+ function walk(dir, results = []) {
16
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
17
+ const full = path.join(dir, entry.name);
18
+ if (entry.isDirectory()) walk(full, results);
19
+ else results.push(full);
20
+ }
21
+ return results;
22
+ }
23
+
24
+ const allFiles = [];
25
+ if (fs.existsSync(srcDir)) {
26
+ allFiles.push(...walk(srcDir));
27
+ }
28
+ if (fs.existsSync(toolsDir)) {
29
+ allFiles.push(...walk(toolsDir));
30
+ }
31
+
32
+ const cssFiles = allFiles.filter((f) => f.endsWith(".css"));
33
+ const tsFiles = allFiles.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
34
+
35
+ // Build a set of all imported CSS paths (resolved)
36
+ const importedCss = new Set();
37
+ const importRe = /import\s+["']([^"']+\.css)["']/g;
38
+
39
+ for (const tsFile of tsFiles) {
40
+ const content = fs.readFileSync(tsFile, "utf8");
41
+ importRe.lastIndex = 0;
42
+ let m = importRe.exec(content);
43
+ while (m !== null) {
44
+ const resolved = path.resolve(path.dirname(tsFile), m[1]);
45
+ importedCss.add(resolved);
46
+ m = importRe.exec(content);
47
+ }
48
+ }
49
+
50
+ const orphaned = cssFiles.filter((f) => !importedCss.has(f));
51
+
52
+ if (orphaned.length === 0) {
53
+ console.log("check:css — no orphaned CSS files found ✓");
54
+ process.exit(0);
55
+ }
56
+
57
+ console.error(`check:css — ${orphaned.length} orphaned CSS file(s) found:`);
58
+ for (const f of orphaned) {
59
+ console.error(` ${path.relative(root, f)}`);
60
+ }
61
+ console.error("Each .css file must be imported by a .ts file in the same directory.");
62
+ process.exit(1);
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Checks that every class used as an ancestor selector in CSS
4
+ * (e.g. ".my-shell .descendant" — the ".my-shell" part) also appears
5
+ * in at least one Arrow template in src/ or tools/.
6
+ *
7
+ * Catches "designed layer never wired up" bugs: CSS exists, is imported,
8
+ * but the ancestor class is never applied to a component root.
9
+ *
10
+ * Exit 1 on violations (ancestor classes with zero template occurrences).
11
+ */
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
17
+ const srcDir = path.join(root, "src");
18
+ const toolsDir = path.join(root, "tools");
19
+
20
+ function walk(dir, ext, results = []) {
21
+ if (!fs.existsSync(dir)) return results;
22
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
23
+ const full = path.join(dir, entry.name);
24
+ if (entry.isDirectory()) walk(full, ext, results);
25
+ else if (entry.name.endsWith(ext)) results.push(full);
26
+ }
27
+ return results;
28
+ }
29
+
30
+ // Collect all ancestor selector classes from CSS files in src/ and tools/
31
+ // Pattern: .ancestor-class followed by whitespace and another selector
32
+ const ancestorRe = /\.([\w-]+)\s+[.#\[a-zA-Z]/g;
33
+ const cssFiles = [...walk(srcDir, ".css"), ...walk(toolsDir, ".css")];
34
+ const ancestorClasses = new Map(); // class → [cssFile, ...]
35
+
36
+ for (const cssFile of cssFiles) {
37
+ const content = fs.readFileSync(cssFile, "utf8");
38
+ ancestorRe.lastIndex = 0;
39
+ let m = ancestorRe.exec(content);
40
+ while (m !== null) {
41
+ const cls = m[1];
42
+ if (!ancestorClasses.has(cls)) ancestorClasses.set(cls, []);
43
+ ancestorClasses.get(cls).push(path.relative(root, cssFile));
44
+ m = ancestorRe.exec(content);
45
+ }
46
+ }
47
+
48
+ if (ancestorClasses.size === 0) {
49
+ console.log("check:scope — no ancestor selectors found ✓");
50
+ process.exit(0);
51
+ }
52
+
53
+ // Check each ancestor class appears in at least one .ts template in src/ or tools/
54
+ const tsFiles = [...walk(srcDir, ".ts"), ...walk(toolsDir, ".ts")];
55
+ const tsContent = tsFiles.map((f) => fs.readFileSync(f, "utf8")).join("\n");
56
+
57
+ let violations = 0;
58
+ for (const [cls, cssFilePaths] of ancestorClasses) {
59
+ // Check if the class string appears anywhere in templates
60
+ // (as a class="..." value or in a reactive class expression)
61
+ if (!tsContent.includes(cls)) {
62
+ console.error(
63
+ `VIOLATION: .${cls} used as ancestor selector in CSS but never applied in any template:`
64
+ );
65
+ for (const f of cssFilePaths) console.error(` defined in: ${f}`);
66
+ violations++;
67
+ }
68
+ }
69
+
70
+ if (violations > 0) {
71
+ console.error(
72
+ `check:scope — ${violations} ancestor class(es) never applied in templates.\nAdd the class to a component root or remove the CSS rules.`
73
+ );
74
+ process.exit(1);
75
+ }
76
+
77
+ console.log(`check:scope — all ${ancestorClasses.size} ancestor classes verified in templates ✓`);
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Two checks:
4
+ * 1. Cross-view boundary: files inside src/views/<View>/ must not be imported
5
+ * from outside src/views/<View>/. Exit 1 on violation.
6
+ * 2. Primitive promotion: files inside any src/views/<View>/ imported by 2+
7
+ * different view directories — suggest promotion to src/components/.
8
+ * Warning only, exit 0.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
15
+ const srcDir = path.join(root, "src");
16
+ const viewsDir = path.join(srcDir, "views");
17
+ const toolsDir = path.join(root, "tools");
18
+
19
+ function walk(dir, results = []) {
20
+ if (!fs.existsSync(dir)) return results;
21
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
22
+ const full = path.join(dir, entry.name);
23
+ if (entry.isDirectory()) walk(full, results);
24
+ else if (entry.name.endsWith(".ts") || entry.name.endsWith(".mjs")) results.push(full);
25
+ }
26
+ return results;
27
+ }
28
+
29
+ // Collect all .ts and .mjs files from src/ and tools/
30
+ const allTs = [...walk(srcDir), ...walk(toolsDir)];
31
+
32
+ // Map: absolute path → set of importing files
33
+ const importedBy = new Map();
34
+
35
+ const importRe = /(?:import|from)\s+["']([^"']+)["']/g;
36
+
37
+ for (const file of allTs) {
38
+ const content = fs.readFileSync(file, "utf8");
39
+ importRe.lastIndex = 0;
40
+ let m = importRe.exec(content);
41
+ while (m !== null) {
42
+ const imp = m[1];
43
+ if (imp.startsWith(".")) {
44
+ const dir = path.dirname(file);
45
+ const resolved = path.resolve(dir, imp);
46
+
47
+ // Try with .ts extension if not already present
48
+ let targetPath = resolved;
49
+ if (!resolved.endsWith(".ts") && !resolved.endsWith(".mjs") && !fs.existsSync(resolved)) {
50
+ if (fs.existsSync(`${resolved}.ts`)) {
51
+ targetPath = `${resolved}.ts`;
52
+ } else if (fs.existsSync(`${resolved}.mjs`)) {
53
+ targetPath = `${resolved}.mjs`;
54
+ }
55
+ }
56
+
57
+ if (!importedBy.has(targetPath)) importedBy.set(targetPath, new Set());
58
+ importedBy.get(targetPath).add(file);
59
+ }
60
+ m = importRe.exec(content);
61
+ }
62
+ }
63
+
64
+ // Get all view directories
65
+ const viewDirs = fs.existsSync(viewsDir)
66
+ ? fs
67
+ .readdirSync(viewsDir, { withFileTypes: true })
68
+ .filter((e) => e.isDirectory())
69
+ .map((e) => path.join(viewsDir, e.name))
70
+ : [];
71
+
72
+ if (viewDirs.length === 0) {
73
+ console.log("check:imports — no cross-view boundary violations ✓");
74
+ process.exit(0);
75
+ }
76
+
77
+ let violations = 0;
78
+ const promotionCandidates = [];
79
+
80
+ for (const viewDir of viewDirs) {
81
+ const viewFiles = walk(viewDir);
82
+ for (const viewFile of viewFiles) {
83
+ const importers = importedBy.get(viewFile) ?? new Set();
84
+
85
+ // Check 1: any importer outside this view dir?
86
+ for (const importer of importers) {
87
+ if (!importer.startsWith(viewDir)) {
88
+ console.error(
89
+ `VIOLATION: ${path.relative(root, viewFile)} imported from outside its view folder:\n` +
90
+ ` by ${path.relative(root, importer)}`
91
+ );
92
+ violations++;
93
+ }
94
+ }
95
+
96
+ // Check 2: imported by 2+ different view dirs?
97
+ const importingViewDirs = new Set(
98
+ [...importers]
99
+ .map((imp) => {
100
+ const rel = path.relative(viewsDir, imp);
101
+ if (rel.startsWith("..")) return null;
102
+ if (!rel.includes("/")) return null;
103
+ return path.join(viewsDir, rel.split("/")[0]);
104
+ })
105
+ .filter(Boolean)
106
+ );
107
+ if (importingViewDirs.size >= 2) {
108
+ promotionCandidates.push({
109
+ file: path.relative(root, viewFile),
110
+ importedBy: importingViewDirs.size,
111
+ });
112
+ }
113
+ }
114
+ }
115
+
116
+ if (promotionCandidates.length > 0) {
117
+ console.warn(
118
+ "\nPromotion candidates (used in 2+ view directories — consider moving to src/components/):"
119
+ );
120
+ for (const c of promotionCandidates) {
121
+ console.warn(` ${c.file} (${c.importedBy} view directories)`);
122
+ }
123
+ }
124
+
125
+ if (violations > 0) {
126
+ console.error(
127
+ `\ncheck:imports — ${violations} cross-view boundary violation(s). Move the file to src/components/ or keep it internal.`
128
+ );
129
+ process.exit(1);
130
+ }
131
+
132
+ console.log("check:imports — no cross-view boundary violations ✓");
133
+ process.exit(0);
@@ -38,7 +38,18 @@ function fail(message) {
38
38
 
39
39
  function hashFile(file) {
40
40
  try {
41
- return hashSource(fs.readFileSync(file, "utf8"));
41
+ let source = fs.readFileSync(file, "utf8");
42
+
43
+ // For .ts files, include co-located CSS if it exists
44
+ if (file.endsWith(".ts")) {
45
+ const cssPath = file.replace(/\.ts$/, ".css");
46
+ if (fs.existsSync(cssPath)) {
47
+ const cssSource = fs.readFileSync(cssPath, "utf8");
48
+ source = `${source}\n/* CSS */\n${cssSource}`;
49
+ }
50
+ }
51
+
52
+ return hashSource(source);
42
53
  } catch (error) {
43
54
  fail(`cannot read ${file} (${error.code ?? error.message})`);
44
55
  return ""; // unreachable; fail() exits
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Usage:
4
+ * pnpm create:component Composer → src/components/Composer/ (with CSS)
5
+ * pnpm create:component icons → src/components/icons.ts (flat, no args)
6
+ * pnpm create:component ChatView/ChatComposer → src/views/ChatView/ChatComposer.ts (flat)
7
+ * pnpm create:component ChatView/Widget --css → src/views/ChatView/Widget/ (with CSS)
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
14
+ const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
15
+ const flags = process.argv.slice(2).filter((a) => a.startsWith("--"));
16
+ const withCss = flags.includes("--css");
17
+
18
+ if (!args[0]) {
19
+ console.error("Usage: pnpm create:component <[ViewName/]ComponentName> [--css]");
20
+ process.exit(1);
21
+ }
22
+
23
+ const parts = args[0].split("/");
24
+ const componentName = parts.pop();
25
+ const parentView = parts[0]; // e.g. "ChatView" or undefined
26
+
27
+ let srcDir;
28
+ let storyPath;
29
+ let isFolder;
30
+
31
+ if (parentView) {
32
+ // View-specific: flat by default, folder with --css
33
+ const viewDir = path.join(root, "src", "views", parentView);
34
+ isFolder = withCss;
35
+ srcDir = isFolder ? path.join(viewDir, componentName) : viewDir;
36
+ storyPath = path.join(root, "stories", "views", parentView, `${componentName}.stories.ts`);
37
+ } else {
38
+ // Primitive: folder with CSS by default
39
+ isFolder = true;
40
+ srcDir = path.join(root, "src", "components", componentName);
41
+ storyPath = path.join(root, "stories", "components", `${componentName}.stories.ts`);
42
+ }
43
+
44
+ const tsFile = path.join(srcDir, `${componentName}.ts`);
45
+ const cssFile = isFolder ? path.join(srcDir, `${componentName}.css`) : null;
46
+
47
+ // Component .ts stub
48
+ const cssImport = cssFile ? `import "./${componentName}.css";\n` : "";
49
+ const tsContent = `${cssImport}import { html } from "@arrow-js/core";
50
+ import type { ArrowExpression } from "@arrow-js/core";
51
+
52
+ export function ${componentName}(): ArrowExpression {
53
+ return html\`<div class="${componentName
54
+ .replace(/([A-Z])/g, (m, l, i) => (i ? "-" : "") + l.toLowerCase())
55
+ .replace(/^-/, "")}">TODO</div>\`;
56
+ }
57
+ `;
58
+
59
+ // Story stub
60
+ const importPath = "../../tools/viewer/stories";
61
+ const componentImport = parentView
62
+ ? `../../src/views/${parentView}/${componentName}`
63
+ : `../../src/components/${isFolder ? `${componentName}/` : ""}${componentName}`;
64
+ const kindLine = parentView ? `\tkind: "component",\n` : "";
65
+
66
+ const storyContent = `import { defineStories } from "${importPath}";
67
+ import { ${componentName} } from "${componentImport}";
68
+
69
+ export default defineStories({
70
+ ${kindLine}\tdescription: "TODO: describe ${componentName}.",
71
+ \tstatus: "draft",
72
+ \tvariants: {
73
+ \t\tdefault: () => ${componentName}(),
74
+ \t},
75
+ });
76
+ `;
77
+
78
+ // Write files
79
+ if (isFolder) fs.mkdirSync(srcDir, { recursive: true });
80
+ else fs.mkdirSync(path.dirname(tsFile), { recursive: true });
81
+
82
+ if (fs.existsSync(tsFile)) {
83
+ console.error(`Already exists: ${tsFile}`);
84
+ process.exit(1);
85
+ }
86
+
87
+ fs.writeFileSync(tsFile, tsContent);
88
+ console.log(`Created: ${path.relative(root, tsFile)}`);
89
+
90
+ if (cssFile) {
91
+ fs.writeFileSync(cssFile, `/* ${componentName} styles */\n`);
92
+ console.log(`Created: ${path.relative(root, cssFile)}`);
93
+ }
94
+
95
+ fs.mkdirSync(path.dirname(storyPath), { recursive: true });
96
+ if (!fs.existsSync(storyPath)) {
97
+ fs.writeFileSync(storyPath, storyContent);
98
+ console.log(`Created: ${path.relative(root, storyPath)}`);
99
+ } else {
100
+ console.log(`Story already exists, skipped: ${path.relative(root, storyPath)}`);
101
+ }
@@ -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
+ }
@@ -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);
@@ -3,7 +3,6 @@ import type { ArrowExpression } from "@arrow-js/core";
3
3
  import type { DiscoveredStory } from "./discovery";
4
4
  import { findStory } from "./discovery";
5
5
 
6
- /** Copy to clipboard, best-effort (clipboard API needs a secure context). */
7
6
  export function copyText(text: string): void {
8
7
  void navigator.clipboard?.writeText(text);
9
8
  }
@@ -18,18 +17,23 @@ function pathRow(label: string, path: string): ArrowExpression {
18
17
  `;
19
18
  }
20
19
 
21
- export function StoryPage(story: DiscoveredStory, variantName: string): ArrowExpression {
22
- const variant = story.variants[variantName];
20
+ /** Metadata panel: title, status, variant tabs, notes, paths, children.
21
+ * No rendered component — used for kind: "component" left panel. */
22
+ export function StoryPageDetails(story: DiscoveredStory, variantName: string): ArrowExpression {
23
23
  const variantNames = Object.keys(story.variants);
24
- const badge =
25
- story.status === "live"
26
- ? html`<span class="oas-badge is-live">live</span>`
27
- : html`<span class="oas-badge is-draft">draft</span>`;
24
+ const variant = story.variants[variantName];
28
25
  return html`
29
26
  <div class="oas-story">
30
27
  <div class="setting-item setting-item-heading">
31
28
  <div class="setting-item-info">
32
- <div class="setting-item-name">${story.title} ${badge}</div>
29
+ <div class="setting-item-name">
30
+ ${story.title}
31
+ ${
32
+ story.status === "live"
33
+ ? html`<span class="oas-badge is-live">live</span>`
34
+ : html`<span class="oas-badge is-draft">draft</span>`
35
+ }
36
+ </div>
33
37
  ${
34
38
  story.description
35
39
  ? html`<div class="setting-item-description">${story.description}</div>`
@@ -54,20 +58,35 @@ export function StoryPage(story: DiscoveredStory, variantName: string): ArrowExp
54
58
  ? html`<div class="oas-story-children">
55
59
  ${story.children.map((slug) => {
56
60
  const child = findStory(slug);
61
+ const childHref = `/components/${slug}`;
57
62
  return child
58
- ? html`<a class="oas-child" href="${`/components/${slug}`}">${child.title} →</a>`
63
+ ? html`<a class="oas-child" href="${childHref}">${child.title} →</a>`
59
64
  : html`<span class="oas-child-missing">${slug} (missing story)</span>`;
60
65
  })}
61
66
  </div>`
62
67
  : ""
63
68
  }
64
- <div class="oas-story-canvas">
65
- ${
66
- variant
67
- ? variant.render()
68
- : html`<div class="oas-story-missing">No variant "${variantName}" pick one above.</div>`
69
- }
70
- </div>
69
+ </div>
70
+ `;
71
+ }
72
+
73
+ /** Rendered component onlydecorator applied if present.
74
+ * Used for kind: "component" canvas area. */
75
+ export function StoryPageCanvas(story: DiscoveredStory, variantName: string): ArrowExpression {
76
+ const variant = story.variants[variantName];
77
+ const rendered = variant
78
+ ? variant.render()
79
+ : html`<div class="oas-story-missing">No variant "${variantName}" — pick one above.</div>`;
80
+ return story.decorator ? story.decorator(rendered) : rendered;
81
+ }
82
+
83
+ /** Full story page: details + canvas together.
84
+ * Used for kind: "view" — StoryPageDetails provides the .oas-story wrapper. */
85
+ export function StoryPageView(story: DiscoveredStory, variantName: string): ArrowExpression {
86
+ return html`
87
+ ${StoryPageDetails(story, variantName)}
88
+ <div class="oas-story-canvas">
89
+ ${StoryPageCanvas(story, variantName)}
71
90
  </div>
72
91
  `;
73
92
  }
@@ -1,3 +1,4 @@
1
+ import type { ArrowExpression } from "@arrow-js/core";
1
2
  import { buildStoryTree, storyMetaFromGlobKey, titleFromSlug } from "./derive";
2
3
  import type { StoryDef, StoryVariant } from "./stories";
3
4
  import { normalizeVariants, validateStoryDef } from "./stories";
@@ -19,6 +20,8 @@ export interface DiscoveredStory {
19
20
  variants: Record<string, StoryVariant>;
20
21
  children: string[];
21
22
  status: "live" | "draft";
23
+ kind: "view" | "component";
24
+ decorator?: (content: ArrowExpression) => ArrowExpression;
22
25
  }
23
26
 
24
27
  export interface InvalidStory {
@@ -43,6 +46,7 @@ for (const [globKey, mod] of Object.entries(modules)) {
43
46
  continue;
44
47
  }
45
48
  const def = mod.default as StoryDef;
49
+ const autoKind = meta.storiesPath.startsWith("stories/views/") ? "view" : "component";
46
50
  stories.push({
47
51
  slug: meta.slug,
48
52
  title: def.title ?? titleFromSlug(meta.slug),
@@ -52,6 +56,8 @@ for (const [globKey, mod] of Object.entries(modules)) {
52
56
  variants: normalizeVariants(def.variants),
53
57
  children: def.children ?? [],
54
58
  status: def.status ?? "draft",
59
+ kind: def.kind ?? autoKind,
60
+ decorator: def.decorator,
55
61
  });
56
62
  }
57
63
 
@@ -21,6 +21,12 @@ export interface StoryDef {
21
21
  /** Repo-relative override for where the component lives (e.g. a subcomponent
22
22
  * defined inside its parent's file). Defaults to the stories path minus `.stories`. */
23
23
  componentPath?: string;
24
+ /** "view" = full pane frame. "component" = centered canvas with separate details panel.
25
+ * Auto-detected from stories/ path if omitted. */
26
+ kind?: "view" | "component";
27
+ /** Wraps the rendered variant — use for ancestor class context.
28
+ * Example: (content) => html`<div class="my-shell">${content}</div>` */
29
+ decorator?: (content: ArrowExpression) => ArrowExpression;
24
30
  /** Named variants; keys are human strings ("default", "dev mode off"). */
25
31
  variants: Record<string, VariantInput>;
26
32
  /** Slugs of subcomponent stories for drill-in nesting. */
@@ -69,6 +75,16 @@ export function validateStoryDef(def: unknown): ValidationResult {
69
75
  return { ok: false, reason: '"status" must be "live" or "draft"' };
70
76
  }
71
77
  }
78
+ if ("kind" in def) {
79
+ if (def.kind !== "view" && def.kind !== "component") {
80
+ return { ok: false, reason: '"kind" must be "view" or "component"' };
81
+ }
82
+ }
83
+ if ("decorator" in def) {
84
+ if (typeof def.decorator !== "function") {
85
+ return { ok: false, reason: '"decorator" must be a function' };
86
+ }
87
+ }
72
88
  return { ok: true };
73
89
  }
74
90