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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-obsidian-arrow",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Scaffold an Obsidian-styled Arrow.js UI sandbox (pnpm create obsidian-arrow <dir>).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@ This file is the hub — everything else is linked from here:
11
11
  - [`docs/workflow.md`](docs/workflow.md) — fresh-machine → running workflow.
12
12
  - [`skills/`](skills/) — installable domain skills (`pnpm skills:install`):
13
13
  - `obsidian-arrow-sandbox` — running the sandbox, CSS scoping, porting basics.
14
+ - `obsidian-arrow-composition` — **run before designing any view area**: surveys codebase, shows file trees for confirmation, asks questions, checks DRY/primitive use, produces a locked hierarchy doc.
14
15
  - `obsidian-arrow-stories` — **component + story authoring workflow**: `defineStories` API, variants, children, status flag, DRY patterns, utilities.
15
16
  - `obsidian-arrow-css` — **CSS decision hierarchy**: Obsidian classes → oas-* utilities → custom CSS; token reference; specificity scoping; overrides via variables; auditing for excess CSS.
16
17
  - `arrow-js-obsidian-templates` — Arrow v1.0.6 template syntax + footguns.
@@ -84,19 +85,39 @@ in `boundary()`.
84
85
 
85
86
  ## Conventions
86
87
 
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`.
88
+ - **Check `/reference/classes` first** — Obsidian has semantic classes for most patterns.
89
+ - **Use `oas-*` utilities second** (`src/utilities.css`) flex, gap, padding, etc.
90
+ - **Custom CSS last** — co-locate as `ComponentName.css`, import it from `ComponentName.ts`.
91
+
92
+ ### Directory structure
93
+
94
+ - `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.
95
+ - `src/components/<ComponentName>/` reusable primitives. Folder when multiple files needed; flat `.ts` when single file.
96
+ - **View-specific components** (only used by one view) live inside that view's folder.
97
+ - **Promotion rule:** if a file inside `src/views/<View>/` is imported by 2+ views, move it to `src/components/`.
98
+ - **Import boundary:** nothing inside `src/views/<View>/` is imported from outside that folder. Enforced by lint scripts:
99
+ - `pnpm check:imports` catches cross-view boundary violations
100
+ - `pnpm check:css` — catches .css files with no importer (dead CSS)
101
+ - `pnpm check:scope` — catches CSS ancestor selectors never applied in templates
102
+ - **Shared icons:** import from `src/components/icons.ts` — do not declare local ICON_MAP copies.
103
+
104
+ ### Stories
105
+
106
+ - `stories/views/` — stories for views (`kind: "view"`)
107
+ - `stories/components/` — stories for primitives (`kind: "component"`)
108
+ - Mirror the `src/` structure. `kind` auto-detected from path; set explicitly to override.
109
+ - `kind` — specifies story type (`"view"` or `"component"`).
110
+ - `decorator?: (content: ArrowExpression) => ArrowExpression` — wraps the rendered variant in ancestor context when a component requires a parent class to render correctly.
111
+ - Scaffold: `pnpm create:view <Name>` / `pnpm create:component <Name>`
112
+
113
+ ### Composition analysis (extracting shared primitives)
114
+
115
+ When multiple views share a pattern, extract a primitive — but do it in this order:
116
+
117
+ 1. **Survey all implementations first** — read every view before designing the primitive.
118
+ 2. **Map shared shapes at the template level** — find the repeated HTML structure, not just the props.
119
+ 3. **Let the primitive emerge from actual shared code** — don't design a generic API upfront.
120
+ 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
121
 
101
122
  ## CSS scoping
102
123
 
@@ -114,12 +135,26 @@ in `boundary()`.
114
135
  pnpm typecheck # tsc --noEmit
115
136
  pnpm test # node:test
116
137
  pnpm lint # biome
117
- pnpm check # all of the above (format + typecheck + test)
138
+ pnpm check # all of the above + CSS orphan + import boundary + scope checks
118
139
  ```
119
140
 
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.
141
+ Then confirm the actual render:
142
+
143
+ 1. **Cold-load required.** Close and reopen the browser tab do not just navigate
144
+ within the SPA. Vite's dev server keeps CSS module state across in-tab navigation,
145
+ which masks orphaned CSS and missing ancestor classes until a genuine cold load.
146
+
147
+ 2. **Check a computed style.** For each CSS change, assert at least one computed property
148
+ (`display`, `flexDirection`, `padding`) in the browser console — element count and
149
+ console-clean alone do not prove styles are applied:
150
+ ```js
151
+ getComputedStyle(document.querySelector('.my-class')).display // → "flex"
152
+ ```
153
+
154
+ 3. **Console clean.** No `[viewer]` warnings, no Arrow errors, no unhandled rejections.
155
+
156
+ Do not claim a component works on typecheck alone. Arrow's footguns and CSS adoption bugs
157
+ only surface at render time.
123
158
 
124
159
  ## Porting a component into the plugin
125
160
 
@@ -92,6 +92,7 @@ skills under [`skills/`](skills/) — it's a skill marketplace. Scaffolds **don'
92
92
  vendor copies**; they pull from this published repo, so installs are always
93
93
  current.
94
94
 
95
+ - `obsidian-arrow-composition` — **run before designing any view area**: surveys codebase, shows proposed file trees, asks targeted questions, checks DRY and primitive use, produces a locked hierarchy doc for the migration agent to execute.
95
96
  - `obsidian-arrow-sandbox` — running and using this sandbox, CSS scoping, porting basics.
96
97
  - `obsidian-arrow-stories` — **component + story authoring workflow**: the complete `defineStories` API (variants, children, status flag, notes), DRY patterns, utility classes, story viewing, and how to structure sub-components.
97
98
  - `obsidian-arrow-css` — **CSS decision hierarchy**: Obsidian classes first, `oas-*` utilities second, custom CSS last; the live token reference (`/reference`), class catalog (`/reference/classes`), specificity scoping, overrides via CSS variables, and auditing components to minimize hand-written CSS.
@@ -142,25 +143,22 @@ swatches, size bars, a filter input, theme-aware resolved values, and a copy
142
143
  button. `/reference/classes` is a curated catalog of Obsidian pattern classes
143
144
  with live previews.
144
145
 
145
- ### Add a story
146
+ ### Add a view
146
147
 
147
- Create `stories/MyThing.stories.ts` — stories live in the top-level `stories/`
148
- directory, keeping `src/` purely for component code:
148
+ ```sh
149
+ pnpm create:view ChatView
150
+ ```
149
151
 
150
- ```ts
151
- import { defineStories } from "../tools/viewer/stories";
152
- import { MyThing } from "../src/components/MyThing";
152
+ Creates `src/views/ChatView/` (ts + css + state.ts) and `stories/views/ChatView.stories.ts`.
153
153
 
154
- export default defineStories({
155
- description: "What it demonstrates.",
156
- status: "draft",
157
- variants: { default: () => MyThing() },
158
- });
154
+ ### Add a component
155
+
156
+ ```sh
157
+ pnpm create:component Composer # primitive src/components/Composer/
158
+ pnpm create:component ChatView/Widget # view-specific → src/views/ChatView/Widget.ts
159
159
  ```
160
160
 
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.
161
+ 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
162
 
165
163
  ## Porting a component into the plugin
166
164
 
@@ -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/
@@ -44,6 +44,8 @@ READ FIRST
44
44
  - AGENTS.md (root) — operating guide + docs map (links everything below).
45
45
  - docs/workflow.md — fresh-machine → running workflow.
46
46
  - skills/*/SKILL.md — seven installable skills:
47
+ obsidian-arrow-composition design component hierarchy: surveys codebase, shows file trees,
48
+ asks questions, checks DRY/primitives, produces locked plan
47
49
  obsidian-arrow-sandbox running the sandbox, CSS scoping, porting basics
48
50
  obsidian-arrow-stories component + story authoring: defineStories API,
49
51
  variants, children, status, DRY patterns, utilities
@@ -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
+ }