create-obsidian-arrow 0.1.5 → 0.2.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/README.md CHANGED
@@ -24,18 +24,54 @@ pnpm dev
24
24
  > `public/app.css` is git-ignored and never bundled — it's Obsidian's proprietary
25
25
  > CSS, so each developer extracts it from their own install via `pnpm pull-css`.
26
26
 
27
- Local dev of the initializer itself (from the sandbox repo, before publishing):
27
+ ### Install the agent skills
28
+
29
+ Skills aren't vendored into the scaffold — `skills:install` pulls them from the
30
+ published repo (source of truth, always current) via the
31
+ [`skills`](https://github.com/vercel-labs/skills) CLI:
32
+
33
+ ```sh
34
+ pnpm skills:install --yes # non-interactive — installs all
35
+ pnpm skills:install # interactive picker
36
+ pnpm skills:install --yes --project-dir=<root> # install at another repo root (nesting)
37
+ pnpm skills:update # update an existing setup
38
+ ```
39
+
40
+ If you scaffold **inside an existing repo**, skills install scoped to the
41
+ scaffold folder (the CLI is cwd-relative); use `--project-dir=<outer-repo>` (or
42
+ `--global`) to install where an agent at the outer repo looks. The scaffolder
43
+ prints this hint when it detects nesting.
44
+
45
+ ## Update an existing project
46
+
47
+ The scaffolder is create-only, but `update` refreshes an existing project's
48
+ **managed** tooling (`scripts/`, `skills/`, `docs/`, `.github/`, `.husky/`,
49
+ `biome.json`, agent guides) and merges new `package.json` scripts/deps — it never
50
+ touches your `src/`, `public/`, `index.html`, or build configs:
51
+
52
+ ```sh
53
+ npx create-obsidian-arrow update # in the project (or: update <dir>)
54
+ npx create-obsidian-arrow update --dry-run # preview
55
+ ```
56
+
57
+ Then `pnpm install && pnpm check`.
58
+
59
+ ## Local dev of the initializer
60
+
61
+ From the sandbox repo, before publishing:
28
62
 
29
63
  ```sh
30
- node create-obsidian-arrow/index.mjs ../my-app
64
+ node create-obsidian-arrow/index.mjs ../my-app # scaffold
65
+ node create-obsidian-arrow/index.mjs update ../my-app # update
31
66
  ```
32
67
 
33
68
  ## What you get
34
69
 
35
70
  A full sandbox: client-only Vite + TS, `@arrow-js/core` + `@arrow-js/framework`
36
71
  (no SSR), `routeToPage` + Navigation-API router with an `/example` demo, Biome +
37
- husky pre-commit + `node:test` + GitHub Actions CI, bundled agent skills, and the
38
- `pull-css` script that extracts Obsidian's `app.css`.
72
+ husky pre-commit + `node:test` + GitHub Actions CI, a `skills:install` that pulls
73
+ the agent skills from the published repo, and the `pull-css` script that extracts
74
+ Obsidian's `app.css`.
39
75
 
40
76
  ## Maintaining the template
41
77
 
package/index.mjs CHANGED
@@ -1,13 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * create-obsidian-arrow — scaffold a new Obsidian-styled Arrow.js UI sandbox.
3
+ * create-obsidian-arrow — scaffold or update an Obsidian-styled Arrow.js sandbox.
4
4
  *
5
- * pnpm create obsidian-arrow my-app # once published
6
- * node create-obsidian-arrow/index.mjs my-app # locally, before publishing
5
+ * create-obsidian-arrow <dir> scaffold a new project into <dir>
6
+ * create-obsidian-arrow update [dir] refresh an existing project's tooling
7
+ * (default dir: cwd) — preserves your code
7
8
  *
8
- * Copies the vendored template/ into <dir>, restores .gitignore (npm strips it
9
- * from packages, so it's vendored as _gitignore), rewrites the project name,
10
- * and runs `git init`. The template is a full, verified sandbox — see template/.
9
+ * pnpm create obsidian-arrow my-app
10
+ * node create-obsidian-arrow/index.mjs my-app # locally
11
+ * node create-obsidian-arrow/index.mjs update ./my-app
12
+ *
13
+ * Scaffold copies the vendored template/, restores .gitignore (vendored as
14
+ * _gitignore), names the project, and runs `git init`.
15
+ *
16
+ * Update refreshes only the *managed* tooling files from the template and merges
17
+ * package.json scripts + missing deps — it never touches your src/, public/,
18
+ * index.html, or the core build configs. Use --dry-run to preview.
11
19
  */
12
20
  import { spawnSync } from "node:child_process";
13
21
  import fs from "node:fs";
@@ -17,60 +25,165 @@ import { fileURLToPath } from "node:url";
17
25
  const here = path.dirname(fileURLToPath(import.meta.url));
18
26
  const templateDir = path.join(here, "template");
19
27
 
28
+ // Files/dirs the template owns and `update` may overwrite/merge. Everything else
29
+ // (src/, public/, index.html, vite.config.ts, tsconfig.json, lockfile, .gitignore,
30
+ // port-parity.json, …) is treated as user-owned and left alone. Skills aren't
31
+ // here — they're pulled from the published repo via `pnpm skills:update`, not
32
+ // vendored into the scaffold.
33
+ const MANAGED = ["scripts", "docs", ".github", ".husky", "biome.json", "AGENTS.md", "CLAUDE.md"];
34
+
35
+ const argv = process.argv.slice(2);
36
+ const dryRun = argv.includes("--dry-run");
37
+
20
38
  function fail(message) {
21
39
  console.error(`create-obsidian-arrow: ${message}`);
22
40
  process.exit(1);
23
41
  }
24
42
 
25
- const targetArg = process.argv[2];
26
- if (!targetArg) {
27
- fail("usage: create-obsidian-arrow <directory>");
43
+ if (!fs.existsSync(templateDir)) {
44
+ fail("template/ is missing — run scripts/sync-template.mjs to build it.");
28
45
  }
29
46
 
30
- const destRoot = path.resolve(process.cwd(), targetArg);
31
- const appName = path.basename(destRoot);
47
+ /** Nearest ancestor *above* `dir` that is a git repo, or null. */
48
+ function outerRepoAbove(dir) {
49
+ let current = path.dirname(path.resolve(dir));
50
+ const fsRoot = path.parse(current).root;
51
+ while (current && current !== fsRoot) {
52
+ if (fs.existsSync(path.join(current, ".git"))) {
53
+ return current;
54
+ }
55
+ current = path.dirname(current);
56
+ }
57
+ return null;
58
+ }
32
59
 
33
- if (fs.existsSync(destRoot) && fs.readdirSync(destRoot).length > 0) {
34
- fail(`target "${targetArg}" already exists and is not empty.`);
60
+ /** Recursively copy src→dest; returns the list of written file paths (relative
61
+ * to `base`). Honors --dry-run (records but doesn't write). */
62
+ function copyTree(src, dest, base, written) {
63
+ const stat = fs.statSync(src);
64
+ if (stat.isDirectory()) {
65
+ if (!dryRun) {
66
+ fs.mkdirSync(dest, { recursive: true });
67
+ }
68
+ for (const entry of fs.readdirSync(src)) {
69
+ copyTree(path.join(src, entry), path.join(dest, entry), base, written);
70
+ }
71
+ return;
72
+ }
73
+ written.push(path.relative(base, dest));
74
+ if (!dryRun) {
75
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
76
+ fs.copyFileSync(src, dest);
77
+ }
35
78
  }
36
- if (!fs.existsSync(templateDir)) {
37
- fail("template/ is missing — run scripts/sync-template.mjs to build it.");
79
+
80
+ function scaffold(targetArg) {
81
+ const destRoot = path.resolve(process.cwd(), targetArg);
82
+ const appName = path.basename(destRoot);
83
+
84
+ if (fs.existsSync(destRoot) && fs.readdirSync(destRoot).length > 0) {
85
+ fail(`target "${targetArg}" already exists and is not empty (use \`update\` to refresh it).`);
86
+ }
87
+
88
+ const written = [];
89
+ copyTree(templateDir, destRoot, destRoot, written);
90
+ // Restore .gitignore (npm omits .gitignore from packages; vendored as _gitignore).
91
+ const vendoredIgnore = path.join(destRoot, "_gitignore");
92
+ if (fs.existsSync(vendoredIgnore)) {
93
+ fs.renameSync(vendoredIgnore, path.join(destRoot, ".gitignore"));
94
+ }
95
+
96
+ // Personalize the project name via targeted replace (keeps Biome formatting).
97
+ const pkgPath = path.join(destRoot, "package.json");
98
+ const renamed = fs
99
+ .readFileSync(pkgPath, "utf8")
100
+ .replace(/("name":\s*)"[^"]*"/, (_m, prefix) => `${prefix}${JSON.stringify(appName)}`);
101
+ fs.writeFileSync(pkgPath, renamed);
102
+
103
+ spawnSync("git", ["init", "-q"], { cwd: destRoot, stdio: "ignore" });
104
+
105
+ console.log(`\nScaffolded ${appName} in ${path.relative(process.cwd(), destRoot) || "."}\n`);
106
+ console.log("Next steps:");
107
+ console.log(` cd ${targetArg}`);
108
+ console.log(" pnpm install");
109
+ console.log(" pnpm pull-css # extract Obsidian's app.css (macOS auto-detect)");
110
+ console.log(" pnpm dev\n");
111
+
112
+ const outer = outerRepoAbove(destRoot);
113
+ if (outer) {
114
+ console.log(`Note: this project is nested inside the repo at ${outer}.`);
115
+ console.log(" Bundled skills install scoped to THIS project. To install them at the");
116
+ console.log(` outer repo instead: pnpm skills:install --yes --project-dir=${outer}\n`);
117
+ }
118
+ }
119
+
120
+ /** Merge template package.json scripts + missing deps into the target's,
121
+ * preserving name/version/identity and existing dep versions. */
122
+ function mergePackageJson(targetPkgPath) {
123
+ const tpl = JSON.parse(fs.readFileSync(path.join(templateDir, "package.json"), "utf8"));
124
+ const pkg = JSON.parse(fs.readFileSync(targetPkgPath, "utf8"));
125
+ const changes = [];
126
+
127
+ pkg.scripts ??= {};
128
+ for (const [name, cmd] of Object.entries(tpl.scripts ?? {})) {
129
+ if (pkg.scripts[name] !== cmd) {
130
+ changes.push(`script ${name}`);
131
+ pkg.scripts[name] = cmd;
132
+ }
133
+ }
134
+ for (const field of ["dependencies", "devDependencies"]) {
135
+ pkg[field] ??= {};
136
+ for (const [name, version] of Object.entries(tpl[field] ?? {})) {
137
+ if (!(name in pkg[field])) {
138
+ changes.push(`${field}: ${name}`);
139
+ pkg[field][name] = version;
140
+ }
141
+ }
142
+ }
143
+
144
+ if (changes.length > 0 && !dryRun) {
145
+ fs.writeFileSync(targetPkgPath, `${JSON.stringify(pkg, null, "\t")}\n`);
146
+ }
147
+ return changes;
38
148
  }
39
149
 
40
- function copyDir(src, dest) {
41
- fs.mkdirSync(dest, { recursive: true });
42
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
43
- const srcPath = path.join(src, entry.name);
44
- // Vendored as _gitignore because npm omits .gitignore from published tarballs.
45
- const destName = entry.name === "_gitignore" ? ".gitignore" : entry.name;
46
- const destPath = path.join(dest, destName);
47
- if (entry.isDirectory()) {
48
- copyDir(srcPath, destPath);
49
- } else {
50
- fs.copyFileSync(srcPath, destPath);
150
+ function update(targetArg) {
151
+ const root = path.resolve(process.cwd(), targetArg ?? ".");
152
+ if (!fs.existsSync(path.join(root, "package.json"))) {
153
+ fail(`no package.json in ${root} — is this a scaffolded project?`);
154
+ }
155
+
156
+ const written = [];
157
+ for (const name of MANAGED) {
158
+ const src = path.join(templateDir, name);
159
+ if (fs.existsSync(src)) {
160
+ copyTree(src, path.join(root, name), root, written);
51
161
  }
52
162
  }
163
+ const pkgChanges = mergePackageJson(path.join(root, "package.json"));
164
+
165
+ const verb = dryRun ? "Would refresh" : "Refreshed";
166
+ console.log(
167
+ `${verb} ${written.length} managed file(s) in ${path.relative(process.cwd(), root) || "."}:`
168
+ );
169
+ for (const file of written) {
170
+ console.log(` ${file}`);
171
+ }
172
+ if (pkgChanges.length > 0) {
173
+ console.log(`package.json: ${dryRun ? "would update" : "updated"} ${pkgChanges.join(", ")}`);
174
+ }
175
+ console.log(
176
+ dryRun
177
+ ? "\n(dry run — nothing written.)"
178
+ : "\nLeft alone: src/, public/, index.html, vite.config.ts, tsconfig.json, .gitignore.\nRun `pnpm install` then `pnpm check`. Update skills separately with `pnpm skills:update`.\n"
179
+ );
53
180
  }
54
181
 
55
- copyDir(templateDir, destRoot);
56
-
57
- // Personalize the project name. Use a targeted replace (not JSON.parse +
58
- // stringify) so the template's existing Biome formatting stays byte-identical
59
- // and the fresh project passes `pnpm lint` out of the box.
60
- const pkgPath = path.join(destRoot, "package.json");
61
- const pkgText = fs.readFileSync(pkgPath, "utf8");
62
- const renamed = pkgText.replace(
63
- /("name":\s*)"[^"]*"/,
64
- (_match, prefix) => `${prefix}${JSON.stringify(appName)}`
65
- );
66
- fs.writeFileSync(pkgPath, renamed);
67
-
68
- // Initialize a git repo (best-effort; ignore if git is unavailable).
69
- spawnSync("git", ["init", "-q"], { cwd: destRoot, stdio: "ignore" });
70
-
71
- console.log(`\nScaffolded ${appName} in ${path.relative(process.cwd(), destRoot) || "."}\n`);
72
- console.log("Next steps:");
73
- console.log(` cd ${targetArg}`);
74
- console.log(" pnpm install");
75
- console.log(" pnpm pull-css # extract Obsidian's app.css (macOS auto-detect)");
76
- console.log(" pnpm dev\n");
182
+ const command = argv[0];
183
+ if (command === "update") {
184
+ update(argv.find((a) => a !== "update" && !a.startsWith("--")));
185
+ } else if (!command || command.startsWith("--")) {
186
+ fail("usage: create-obsidian-arrow <directory> | update [directory]");
187
+ } else {
188
+ scaffold(command);
189
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-obsidian-arrow",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold an Obsidian-styled Arrow.js UI sandbox (pnpm create obsidian-arrow <dir>).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,8 @@ 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, arrow-js-obsidian-templates, arrow-js-obsidian-patterns,
14
- arrow-js-obsidian-porting (sandbox→plugin parity check).
14
+ arrow-js-obsidian-porting (sandbox→plugin parity check), obsidian-arrow-maintenance
15
+ (updating an existing project).
15
16
  - [`docs/prompts/agent-setup.md`](docs/prompts/agent-setup.md) — prompt for
16
17
  briefing a fresh agent (scaffold + orient).
17
18
 
@@ -40,6 +41,15 @@ pnpm dev # Vite + HMR
40
41
  `public/app.css` is **git-ignored** (Obsidian's proprietary CSS — not
41
42
  redistributed); run `pnpm pull-css` once before `pnpm dev`.
42
43
 
44
+ Install the skills (pulled from the published repo, not vendored):
45
+ `pnpm skills:install --yes` (non-interactive, all skills) or `pnpm skills:install`
46
+ (TUI); update with `pnpm skills:update`. Scope
47
+ flags: `--agent <name>`, `--project-dir=<path>`, `--global`. **Nested inside
48
+ another repo?** Skills install cwd-relative — use `--project-dir=<outer-repo>` so
49
+ they land where an agent at the outer repo looks. To refresh an existing
50
+ project's tooling, run `npx create-obsidian-arrow update` (see the
51
+ obsidian-arrow-maintenance skill).
52
+
43
53
  ## Arrow v1.0.6 footguns — READ BEFORE WRITING TEMPLATES
44
54
 
45
55
  These are hard runtime errors, not style nits. They are encoded in CI
@@ -24,6 +24,23 @@ Then `cd my-app && pnpm install && pnpm pull-css && pnpm dev`. A freshly
24
24
  scaffolded project passes `pnpm run ci` out of the box. The initializer's
25
25
  template is generated from this repo (`pnpm create:sync`), so it never drifts.
26
26
 
27
+ **Update an existing project's tooling** (refreshes the managed files — scripts,
28
+ skills, docs, agent guides, CI, `biome.json` — and merges new `package.json`
29
+ scripts/deps; never touches `src/`, `public/`, `index.html`, or build configs):
30
+
31
+ ```sh
32
+ npx create-obsidian-arrow update # in the project (or: update <dir>)
33
+ npx create-obsidian-arrow update --dry-run # preview first
34
+ ```
35
+
36
+ > **Nested in another repo?** If you scaffold inside an existing repo, skills
37
+ > install scoped to the scaffold folder (the `skills` CLI is cwd-relative), so the
38
+ > session/`skill://` registry — which only indexes the **repo root** + global —
39
+ > won't see them. Install at the outer repo instead:
40
+ > `pnpm skills:install --yes --project-dir=<outer-repo>` (or, from the outer repo,
41
+ > `npx skills add kylebrodeur/obsidian-arrow-sandbox --all --yes`), then **reload
42
+ > the session**. The scaffolder prints this hint when it detects nesting.
43
+
27
44
  > This repo (the full sandbox) is **not** published to npm — only the
28
45
  > `create-obsidian-arrow/` initializer is. An agent-onboarding prompt lives in
29
46
  > [`docs/prompts/agent-setup.md`](docs/prompts/agent-setup.md).
@@ -66,10 +83,12 @@ Obsidian install.
66
83
  A husky `pre-commit` hook runs `lint-staged` (Biome on staged files) + a full
67
84
  typecheck. CI (`.github/workflows/ci.yml`) runs the `ci` script on push/PR.
68
85
 
69
- ## Bundled agent skills
86
+ ## Agent skills
70
87
 
71
- This repo ships [`skills`](https://github.com/vercel-labs/skills)-compatible skills
72
- under [`skills/`](skills/) — it doubles as a local skill marketplace:
88
+ This repo is the source of truth for five [`skills`](https://github.com/vercel-labs/skills)-compatible
89
+ skills under [`skills/`](skills/) — it's a skill marketplace. Scaffolds **don't
90
+ vendor copies**; they pull from this published repo, so installs are always
91
+ current.
73
92
 
74
93
  - `obsidian-arrow-sandbox` — running and using this sandbox, and porting to a plugin.
75
94
  - `arrow-js-obsidian-templates` — Arrow v1.0.6 template rules + footguns.
@@ -78,23 +97,32 @@ under [`skills/`](skills/) — it doubles as a local skill marketplace:
78
97
  - `arrow-js-obsidian-porting` — content-addressed porting parity: the
79
98
  `component-hash` tool + a husky/CI check that the plugin copy hasn't drifted
80
99
  from the sandbox source.
100
+ - `obsidian-arrow-maintenance` — updating an existing project: `create-obsidian-arrow
101
+ update`, `skills:update`, nesting/`--project-dir`, re-pull styling.
81
102
 
82
- Install them into your agent:
103
+ Install them into your agent (pulls from the published repo; the sandbox repo
104
+ itself uses its local `skills/`):
83
105
 
84
106
  ```sh
85
- pnpm skills:install # interactive picker (TUI) on a terminal
86
- pnpm skills:install --yes # non-interactive — installs ALL bundled skills
87
- pnpm skills:install --yes --agent claude-code # install for one agent only
88
- pnpm skills:update # update an existing setup to the latest
107
+ pnpm skills:install # interactive picker (TUI) on a terminal
108
+ pnpm skills:install --yes # non-interactive — installs all skills
109
+ pnpm skills:install --yes --agent claude-code # install for one agent only
110
+ pnpm skills:install --yes --project-dir=<repo-root> # install into another project root
111
+ pnpm skills:update # update an existing setup to the latest
89
112
  ```
90
113
 
114
+ Anywhere, with no project at all:
115
+ `npx skills add kylebrodeur/obsidian-arrow-sandbox --all --yes`.
116
+
91
117
  `postinstall` offers the picker automatically after `pnpm install`, but only in
92
118
  an interactive terminal — in CI / non-TTY it just prints how to install (never
93
- hangs). For agents/CI, use `--yes` (it runs `npx skills add . --all --yes`);
94
- `--agent <name>` (or `--agent=<name>`) targets one agent instead of all.
95
- `pnpm skills:update` runs `npx skills update -y`. The auto `postinstall` step
96
- takes no CLI args, so use `SKILLS_AGENT=<name>` (and `SKIP_SKILLS_INSTALL=1` to
97
- opt out) to influence *that* path.
119
+ hangs). For agents/CI, use `--yes`.
120
+ Scope flags: `--agent <name>` (one agent), `--project-dir=<path>` (install into a
121
+ different project root, e.g. an outer repo a scaffold is nested in), `--global`
122
+ (user-level, available everywhere). `pnpm skills:update` runs
123
+ `npx skills update -y`. The auto `postinstall` step takes no CLI args, so the env
124
+ forms `SKILLS_AGENT` / `SKILLS_PROJECT_DIR` / `SKILLS_GLOBAL` (and
125
+ `SKIP_SKILLS_INSTALL=1`) influence *that* path.
98
126
 
99
127
  ## Porting a component into the plugin
100
128
 
@@ -3,6 +3,10 @@ dist
3
3
  .DS_Store
4
4
  *.tgz
5
5
 
6
+ # `skills` install artifacts (regenerated by `pnpm skills:install`); the source
7
+ # of truth is skills/. (skills-lock.json is intentionally kept tracked.)
8
+ .agents/
9
+
6
10
  # Obsidian's app.css is proprietary — extract it locally with `pnpm pull-css`,
7
11
  # don't commit/redistribute it.
8
12
  public/app.css
@@ -32,16 +32,21 @@ Then:
32
32
  # or set OBSIDIAN_ASAR=<path>.
33
33
  pnpm dev # open the printed URL: / is the examples index, /example the demo.
34
34
  # The toolbar slider/presets + edge drag handle resize the panel.
35
- pnpm skills:install --yes # install ALL bundled agent skills non-interactively
36
- # (runs `npx skills add . --all --yes`) — this loads
35
+ pnpm skills:install --yes # install all agent skills non-interactively, pulled
36
+ # from the published repo (not vendored) — this loads
37
37
  # the domain knowledge. Drop --yes for an interactive picker.
38
+ # NESTED inside another repo? Skills install cwd-relative,
39
+ # so add --project-dir=<outer-repo> (or --global) to put them
40
+ # where an agent at the outer repo will find them.
38
41
 
39
42
  READ FIRST
40
43
  - AGENTS.md (root) — operating guide + docs map (links everything below).
41
44
  - docs/workflow.md — fresh-machine → running workflow.
42
45
  - skills/*/SKILL.md — obsidian-arrow-sandbox (workflow), arrow-js-obsidian-
43
46
  templates (template syntax + footguns), arrow-js-obsidian-patterns (icons via
44
- Lucide/data-icon sweep, CSS scoping, mount/unmount lifecycle, reactive state).
47
+ Lucide/data-icon sweep, CSS scoping, mount/unmount lifecycle, reactive state),
48
+ arrow-js-obsidian-porting (sandbox→plugin parity check),
49
+ obsidian-arrow-maintenance (updating an existing project).
45
50
 
46
51
  ARROW v1.0.6 FOOTGUNS — do not relearn these the hard way:
47
52
  1. NO literal HTML comments inside html`` templates — Arrow treats HTML comments
@@ -76,7 +81,12 @@ PORTING TO A PLUGIN
76
81
  Copy the component file into the plugin's view dir and mount from
77
82
  ItemView.onOpen() via `template(this.contentEl)`. If it uses boundary()/async
78
83
  components, add @arrow-js/framework to the plugin and the side-effect
79
- `import '@arrow-js/framework'`. Leave src/sandbox/* behind.
84
+ `import '@arrow-js/framework'`. Leave src/sandbox/* behind. Guard against drift
85
+ with the porting-parity check (see the arrow-js-obsidian-porting skill).
86
+
87
+ MAINTENANCE (existing project)
88
+ Refresh tooling later with `npx create-obsidian-arrow update` (preserves src/),
89
+ update skills with `pnpm skills:update`. See the obsidian-arrow-maintenance skill.
80
90
 
81
91
  Start by scaffolding, running setup steps, then read AGENTS.md and confirm
82
92
  `pnpm dev` renders /example correctly. Report what you see.
@@ -37,13 +37,20 @@ From inside the scaffolded project:
37
37
 
38
38
  ```sh
39
39
  pnpm skills:install # interactive picker (TUI) on a terminal
40
- pnpm skills:install --yes # non-interactive — installs ALL bundled skills (for agents/CI)
40
+ pnpm skills:install --yes # non-interactive — installs all skills from the published repo (agents/CI)
41
41
  pnpm skills:update # update an already-installed setup to the latest
42
42
  ```
43
43
 
44
44
  Then point the agent at [`AGENTS.md`](../AGENTS.md), or brief a fresh agent with
45
45
  [`docs/prompts/agent-setup.md`](prompts/agent-setup.md).
46
46
 
47
+ **Nested inside another repo?** Skills install scoped to the current folder (the
48
+ `skills` CLI is cwd-relative). To install them at the outer repo instead:
49
+ `pnpm skills:install --yes --project-dir=<outer-repo>` (or `--global`).
50
+
51
+ **Refresh an existing project's tooling** (scripts, skills, docs, CI — never your
52
+ `src/`): `npx create-obsidian-arrow update` (add `--dry-run` to preview).
53
+
47
54
  ## Build → verify → port loop
48
55
 
49
56
  ```sh
@@ -1,36 +1,46 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Install / update the skills bundled in this repo (skills/*) via the `skills`
4
- * CLI (https://github.com/vercel-labs/skills). This repo is a local skills
5
- * marketplace: the source `.` resolves to skills/<name>/SKILL.md.
3
+ * Install / update the obsidian-arrow agent skills via the `skills` CLI
4
+ * (https://github.com/vercel-labs/skills).
5
+ *
6
+ * Skills are NOT vendored into scaffolds — they're pulled from the published
7
+ * repo (the source of truth), so installs are always current and don't depend on
8
+ * a bundled copy. The sandbox repo itself (which has a local `skills/` dir) uses
9
+ * that local copy instead, so you can test edits before publishing.
6
10
  *
7
11
  * Modes (auto-detected):
8
12
  * - Interactive terminal → opens the picker TUI (choose what to install).
9
- * - Non-interactive / CI / -y → installs ALL bundled skills, no prompts.
13
+ * - Non-interactive / CI / -y → installs ALL skills, no prompts.
10
14
  * - --update → updates an already-installed setup in place.
11
15
  *
12
16
  * Wiring:
13
17
  * - `postinstall` (auto on `pnpm install`): only acts in an interactive
14
18
  * terminal; in CI/non-TTY it prints how to install and exits 0 (never hangs).
15
- * - `pnpm skills:install` → picker (TUI) on a terminal, else installs all.
16
- * - `pnpm skills:install --yes` always non-interactive: install all skills.
17
- * - `pnpm skills:update` → update installed skills to the latest.
19
+ * - `pnpm skills:install [--yes]` → picker, or non-interactive install of all.
20
+ * - `pnpm skills:update` update installed skills to the latest.
18
21
  *
19
22
  * Flags / env:
23
+ * --source <ref> | SKILLS_SOURCE=<ref> where to pull skills from (default: the
24
+ * published repo; a local `skills/` dir is used if present)
20
25
  * --update / -u update installed skills (runs `skills update -y`)
21
- * --yes / -y non-interactive install of all bundled skills
22
- * --agent <name> | --agent=<name> install for one agent (e.g. claude-code)
23
- * instead of all detected agents
24
- * SKILLS_AGENT=<name> same as --agent, for the auto `postinstall` step
25
- * (which can't take CLI args) and CI
26
+ * --yes / -y non-interactive install of all skills
27
+ * --agent <name> | --agent=<name> install for one agent instead of all
28
+ * --project-dir=<path> install into another project root (e.g. the outer repo
29
+ * a scaffold is nested in); project-scoped there
30
+ * --global / -g install at user level (~/.<agent>/…), everywhere
31
+ * SKILLS_AGENT / SKILLS_PROJECT_DIR / SKILLS_GLOBAL env forms (for the auto
32
+ * `postinstall` step, which can't take CLI args, and CI)
26
33
  * --dry-run / SKILLS_DRY_RUN=1 print the command instead of running it
27
34
  * SKIP_SKILLS_INSTALL=1 opt out of the postinstall auto-step
28
35
  */
29
36
  import { spawnSync } from "node:child_process";
37
+ import fs from "node:fs";
38
+ import path from "node:path";
30
39
  import process from "node:process";
31
40
 
32
- const BUNDLED =
33
- "obsidian-arrow-sandbox, arrow-js-obsidian-templates, arrow-js-obsidian-patterns, arrow-js-obsidian-porting";
41
+ const REPO = "kylebrodeur/obsidian-arrow-sandbox";
42
+ const SKILLS =
43
+ "obsidian-arrow-sandbox, arrow-js-obsidian-templates, arrow-js-obsidian-patterns, arrow-js-obsidian-porting, obsidian-arrow-maintenance";
34
44
 
35
45
  const has = (flag) => process.argv.includes(flag);
36
46
  // Accepts both `--flag value` and `--flag=value`.
@@ -43,29 +53,61 @@ const flagValue = (flag) => {
43
53
  return i >= 0 ? process.argv[i + 1] : undefined;
44
54
  };
45
55
 
56
+ /** Nearest ancestor *above* `dir` that is a git repo, or null. */
57
+ function outerRepoAbove(dir) {
58
+ let current = path.dirname(path.resolve(dir));
59
+ const fsRoot = path.parse(current).root;
60
+ while (current && current !== fsRoot) {
61
+ if (fs.existsSync(path.join(current, ".git"))) {
62
+ return current;
63
+ }
64
+ current = path.dirname(current);
65
+ }
66
+ return null;
67
+ }
68
+
46
69
  const forced = has("--force"); // set by `pnpm skills:install`
47
70
  const update = has("--update") || has("-u");
48
71
  const yes = has("--yes") || has("-y");
72
+ const global = has("--global") || has("-g") || process.env.SKILLS_GLOBAL === "1";
49
73
  const agent = flagValue("--agent") || process.env.SKILLS_AGENT;
74
+ const projectDir = flagValue("--project-dir") || process.env.SKILLS_PROJECT_DIR;
50
75
  const dryRun = has("--dry-run") || process.env.SKILLS_DRY_RUN === "1";
51
76
  const isCI = Boolean(process.env.CI);
52
77
  const interactive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
53
78
  const optedOut = process.env.SKIP_SKILLS_INSTALL === "1";
54
79
 
55
- function run(args) {
80
+ // Pull from the published repo by default; prefer a local `skills/` dir if
81
+ // present (the sandbox repo itself); honor an explicit --source override.
82
+ const explicitSource = flagValue("--source") || process.env.SKILLS_SOURCE;
83
+ const hasLocalSkills = fs.existsSync(path.join(process.cwd(), "skills"));
84
+ const baseSource = explicitSource ?? (hasLocalSkills ? "." : REPO);
85
+ // A repo ref is cwd-independent; a local "." must be made absolute when
86
+ // --project-dir redirects cwd so it still resolves.
87
+ const source = projectDir && baseSource === "." ? path.resolve(".") : baseSource;
88
+ const targetCwd = projectDir ? path.resolve(projectDir) : process.cwd();
89
+
90
+ if (projectDir && !fs.existsSync(targetCwd)) {
91
+ console.error(`[skills] --project-dir not found: ${targetCwd}`);
92
+ process.exit(1);
93
+ }
94
+
95
+ function run(args, cwd = process.cwd()) {
96
+ const where = cwd === process.cwd() ? "" : ` (in ${cwd})`;
56
97
  const pretty = ["npx", "skills", ...args].join(" ");
57
98
  if (dryRun) {
58
- console.log(`[skills] (dry-run) would run: ${pretty}`);
99
+ console.log(`[skills] (dry-run) would run: ${pretty}${where}`);
59
100
  process.exit(0);
60
101
  }
61
- console.log(`[skills] ${pretty}`);
102
+ console.log(`[skills] ${pretty}${where}`);
62
103
  const result = spawnSync("npx", ["--yes", "skills", ...args], {
104
+ cwd,
63
105
  stdio: "inherit",
64
106
  shell: process.platform === "win32",
65
107
  });
66
108
  if (result.error) {
67
109
  console.error(
68
- `[skills] could not run npx skills (${result.error.message}). Install manually: npx skills add . --all --yes`
110
+ `[skills] could not run npx skills (${result.error.message}). Install manually: npx skills add ${REPO} --all --yes`
69
111
  );
70
112
  process.exit(0); // never fail an install over an optional convenience step
71
113
  }
@@ -87,23 +129,30 @@ if (update) {
87
129
  // `pnpm install` in CI never hangs or installs things unprompted.
88
130
  if (!forced && (isCI || !interactive)) {
89
131
  console.log(
90
- `[skills] Bundled skills: ${BUNDLED}\n Install: pnpm skills:install (interactive picker on a terminal)\n pnpm skills:install --yes (non-interactive — installs all)\n Update: pnpm skills:update`
132
+ `[skills] Agent skills: ${SKILLS}\n Install: pnpm skills:install (interactive picker on a terminal)\n pnpm skills:install --yes (non-interactive — installs all)\n Update: pnpm skills:update`
91
133
  );
92
134
  process.exit(0);
93
135
  }
94
136
 
95
- // Non-interactive (CI/agent/no TTY, or --yes, or an agent target): install ALL
96
- // bundled skills with no prompts. Target one agent if asked, else all agents.
97
- if (!interactive || yes || agent) {
98
- console.log(`[skills] Installing all bundled skills non-interactively: ${BUNDLED}`);
99
- if (agent) {
100
- // `--all` is shorthand for `-s * -a * -y`, so to target one agent we
101
- // spell out all-skills + that agent explicitly.
102
- run(["add", ".", "-s", "*", "-a", agent, "-y"]);
137
+ // Non-interactive (CI/agent/no TTY, or --yes, or an agent/global target): install
138
+ // ALL skills with no prompts. Target one agent if asked, else all agents.
139
+ if (!interactive || yes || agent || global || projectDir) {
140
+ console.log(`[skills] Installing all skills non-interactively from ${source}: ${SKILLS}`);
141
+ const outer = global || projectDir ? null : outerRepoAbove(process.cwd());
142
+ if (outer) {
143
+ console.log(
144
+ `[skills] note: this folder is nested inside ${outer}, and skills install scoped to cwd. For session/skill:// discovery they must live at the repo root — install there with:\n pnpm skills:install --yes --project-dir=${outer}\n (or, from ${outer}: npx skills add ${REPO} --all --yes). Reload the session afterwards.`
145
+ );
146
+ }
147
+ // `--all` is shorthand for `-s * -a * -y`; to target one agent we spell out
148
+ // all-skills + that agent explicitly.
149
+ const args = agent ? ["add", source, "-s", "*", "-a", agent, "-y"] : ["add", source, "--all"];
150
+ if (global) {
151
+ args.push("--global");
103
152
  }
104
- run(["add", ".", "--all"]);
153
+ run(args, targetCwd);
105
154
  }
106
155
 
107
156
  // Interactive terminal: let the user pick in the TUI.
108
- console.log(`[skills] Opening the skills picker. Bundled: ${BUNDLED}`);
109
- run(["add", "."]);
157
+ console.log(`[skills] Opening the skills picker (source: ${source}). Skills: ${SKILLS}`);
158
+ run(["add", source]);
@@ -1,94 +0,0 @@
1
- ---
2
- name: arrow-js-obsidian-patterns
3
- description: Use when building reactive Obsidian plugin UI with @arrow-js/core beyond basic templates — icons (Obsidian uses Lucide; the data-icon sweep since setIcon can't run inside templates), CSS scoping/specificity against Obsidian's global rules, component mount/unmount lifecycle in an ItemView, and organizing shared reactive state. Complements arrow-js-obsidian-templates (syntax) with integration patterns.
4
- ---
5
-
6
- # Arrow.js + Obsidian integration patterns
7
-
8
- How to build real components (not just template syntax) for an Obsidian plugin
9
- view with `@arrow-js/core`. Pairs with **arrow-js-obsidian-templates** (the
10
- template rules + footguns).
11
-
12
- ## Mount / unmount lifecycle
13
-
14
- A view mounts a component imperatively, the same call the sandbox makes:
15
-
16
- ```ts
17
- import { html } from "@arrow-js/core"
18
- // in ItemView.onOpen():
19
- this.contentEl.empty()
20
- html`${MyComponent()}`(this.contentEl)
21
- ```
22
-
23
- - `html\`\`(container)` does **not** reliably return an unmount function — don't
24
- gate cleanup on its return value. Always `container.empty()` before remounting.
25
- - Re-rendering on route/state change: clear the container, then mount fresh.
26
- Reactive bindings (`${() => …}`) update in place — don't remount the whole tree
27
- on every state change; let Arrow patch the slots that read changed state.
28
-
29
- ## Shared reactive state
30
-
31
- One `reactive()` object is the single source of truth; components read it via
32
- getters so they stay in sync without prop-drilling.
33
-
34
- ```ts
35
- import { reactive } from "@arrow-js/core"
36
- export const state = reactive({ model: "", streaming: false, items: [] })
37
- // pass getters into components: Toggle(() => state.streaming, () => { ... })
38
- ```
39
-
40
- ## Icons (Obsidian uses Lucide)
41
-
42
- `setIcon(el, name)` needs a real DOM element, so it **cannot** be called inside
43
- an Arrow template expression (the element doesn't exist yet). Two options:
44
-
45
- 1. **In-plugin — the data-icon sweep.** Emit a placeholder, then sweep after
46
- mount:
47
-
48
- ```ts
49
- html`<span class="svg-icon" data-icon="copy"></span>`
50
- // after template(container) — scope the query to the container, not document:
51
- for (const el of Array.from(container.querySelectorAll<HTMLElement>("[data-icon]"))) {
52
- const name = el.getAttribute("data-icon")
53
- if (name) setIcon(el, name) // setIcon from "obsidian"
54
- }
55
- ```
56
- For sections that open after mount (dropdowns/popovers), run the sweep in a
57
- `nextTick(...)` after they render, scoped to that section's element.
58
-
59
- 2. **In the sandbox (no `obsidian` module).** Use a Lucide import or inline SVG,
60
- or plain text/emoji for chrome. The baseline uses text glyphs to stay
61
- shim-free; add a Lucide-backed `setIcon` shim (aliased as `obsidian`) when you
62
- port icon-using components in.
63
-
64
- ## CSS scoping & specificity (the big one)
65
-
66
- Obsidian's `app.css` applies global rules like
67
- `button:not(.clickable-icon) { background: var(--interactive-normal) }` at
68
- specificity **(0,1,1)**. A plain `.my-btn` selector is **(0,1,0)** and loses
69
- regardless of cascade order.
70
-
71
- ```css
72
- /* (0,1,0) — LOSES to Obsidian's global button rule */
73
- .my-action { background: var(--interactive-accent); }
74
-
75
- /* (0,2,1) — WINS, and can't leak */
76
- .my-panel button.my-action { background: var(--interactive-accent); }
77
- ```
78
-
79
- Rules: prefer Obsidian's own classes (`.setting-item`, `.clickable-icon`,
80
- `.workspace-leaf`, `.vertical-tab-*`, `.modal`) and `var(--…)` tokens first; only
81
- add custom CSS where there's no Obsidian class; always scope custom rules under a
82
- container class + element type.
83
-
84
- ## Common Obsidian layout classes
85
-
86
- - `.workspace-leaf` > `.workspace-leaf-content` — a view pane.
87
- - `.view-header` / `.view-content` — pane header + body.
88
- - `.setting-item` (+ `.setting-item-info` / `.setting-item-name` /
89
- `.setting-item-description` / `.setting-item-control`) — settings rows.
90
- - `.checkbox-container` (+ `.is-enabled`) — toggle.
91
- - `.clickable-icon` — icon button (escapes the global button background rule).
92
- - `.vertical-tab-header` / `.vertical-tab-nav-item` (+ `.is-active`) — tab nav.
93
- - `.modal-container` > `.modal` — modal/popover.
94
- - `.mod-cta` — primary call-to-action button.
@@ -1,92 +0,0 @@
1
- ---
2
- name: arrow-js-obsidian-porting
3
- description: Use when porting an Arrow component from the obsidian-arrow-sandbox into the real Obsidian plugin, or wiring a check that the plugin copy hasn't drifted from the sandbox source. Covers the content-addressed porting-parity tool (scripts/component-hash.mjs), the canonical-form rules, a port-parity manifest, and a husky/CI pre-commit hook. The sandbox is the source of truth; the plugin copy must match.
4
- ---
5
-
6
- # Porting parity (sandbox → Obsidian plugin)
7
-
8
- The sandbox is the **source of truth** for a component; the plugin gets a
9
- near-verbatim copy. The risk is **drift** — someone hand-edits the plugin copy,
10
- or a port silently diverges. This makes components **content-addressed**: a
11
- canonical hash of the component's portable body must match on both sides, the
12
- same idea as "never hand-edit generated artifacts."
13
-
14
- ## Write portable components
15
-
16
- So the body is byte-identical across sandbox and plugin and only the *mount site*
17
- differs:
18
-
19
- - Take data via **props / getters**, not hard-wired sources. The sandbox passes a
20
- stub (`loadStatus()`); the plugin passes the real source (rpc) — but that wiring
21
- lives at the **call site** (`main.ts` / `ItemView.onOpen()`), not in the
22
- component body.
23
- - Use Obsidian classes + `var(--…)` tokens (already the rule). Keep
24
- `obsidian`-API calls (`setIcon`, …) out of the component body where possible.
25
-
26
- ## The tool: `scripts/component-hash.mjs`
27
-
28
- Dependency-free Node; canonicalizer in `scripts/lib/canonical-source.mjs`.
29
-
30
- ```sh
31
- node scripts/component-hash.mjs <file> # print canonical hash
32
- node scripts/component-hash.mjs --verify <sandbox> <plugin> # PARITY OK | DRIFT (exit 0/1)
33
- node scripts/component-hash.mjs --check port-parity.json # check all; exit 1 on drift
34
- node scripts/component-hash.mjs --check port-parity.json --update # re-bless baselines
35
- ```
36
-
37
- **Canonical form** (define it so both sides agree by construction):
38
- - `import …;` statements are stripped (single- and multi-line) — imports are the
39
- main legit delta (stub vs real data, the `obsidian` API).
40
- - Line endings → `\n`, trailing whitespace trimmed, blank-line runs collapsed,
41
- leading/trailing blanks trimmed.
42
- - Comments are **kept** (they're part of the component; the sandbox owns them too).
43
- - SHA-256 of the result.
44
-
45
- So two copies that differ only in imports/formatting hash equal; any change to
46
- the body/template/comments shows as drift.
47
-
48
- ## Wiring the check in the plugin
49
-
50
- The hook lives in the **plugin** repo (that's where ported copies drift). Copy the
51
- two dependency-free files in (`scripts/lib/canonical-source.mjs`,
52
- `scripts/component-hash.mjs`) and add a manifest:
53
-
54
- ```jsonc
55
- // port-parity.json — paths resolve relative to THIS file
56
- {
57
- "entries": [
58
- {
59
- "plugin": "src/chat/arrow/SettingsPanel.ts",
60
- "sandbox": "../../arrow-ui/obsidian-arrow-sandbox/src/components/SettingsPanel.ts", // optional
61
- "hash": "<sha256>" // fallback when sandbox isn't checked out
62
- }
63
- ]
64
- }
65
- ```
66
-
67
- - If the `sandbox` path exists (sibling checkout), the check compares **plugin
68
- copy ↔ live sandbox source** — catches drift in both directions.
69
- - If it doesn't, it falls back to the recorded `hash` (catches hand-edits to the
70
- plugin copy). Record/refresh it with `--update` after an intentional re-port.
71
-
72
- husky `pre-commit` (and CI):
73
-
74
- ```sh
75
- node scripts/component-hash.mjs --check port-parity.json
76
- ```
77
-
78
- On drift the commit fails: *edit the sandbox component and re-port* (don't
79
- hand-edit the copy); use `--update` only to intentionally re-bless.
80
-
81
- ## What this does NOT do (by design)
82
-
83
- - **No runtime component-DOM hashing in Obsidian.** Source parity already
84
- guarantees same code → same Arrow DOM, and the sandbox vs real Obsidian DOM
85
- *intentionally* differ (icon stub vs `setIcon`, `MarkdownRenderer` nodes, live
86
- theme), so a runtime markup hash would be tautological *and* full of false
87
- mismatches. The real runtime risk is visual/CSS — verify that by **loading in
88
- Obsidian and looking**, not with a hash.
89
- - **Planned (not built): a styling-freshness check.** A small helper that hashes
90
- the *live Obsidian token set* and compares it to the sandbox's pulled `app.css`
91
- snapshot, to flag "re-run `pnpm pull-css`." That's the one runtime hash worth
92
- having — environment parity, not component parity. Add it when needed.
@@ -1,101 +0,0 @@
1
- ---
2
- name: arrow-js-obsidian-templates
3
- description: Use when writing @arrow-js/core (v1.0.6) html template literals — reactive vs static (wrap reactive reads in arrow functions), full-value attribute binding where returning false removes the attribute, .property and @event binding, keyed lists, and async component(fn, { fallback }) via boundary(); plus the footguns — no literal HTML comments and no partial attribute values (both throw Invalid HTML position at render), and @event handlers must type the param as Event not a narrowed subtype like MouseEvent (TS2345).
4
- ---
5
-
6
- # Arrow.js Templates (v1.0.6)
7
-
8
- Rules for writing `html\`\`` templates that render correctly in the browser and
9
- inside an Obsidian plugin. Several of these are hard runtime errors, not style.
10
-
11
- ## Reactive vs static — the core rule
12
-
13
- ```ts
14
- import { html, reactive } from "@arrow-js/core"
15
- const data = reactive({ count: 0 })
16
-
17
- html`<span>${data.count}</span>` // ❌ renders ONCE — never updates
18
- html`<span>${() => data.count}</span>` // ✅ tracked — updates only this slot
19
- ```
20
-
21
- `${value}` is read once at mount. `${() => value}` is tracked: Arrow records the
22
- reactive reads inside the function and re-runs **only that slot** when they
23
- change. Forgetting `() =>` is the #1 "why isn't it updating" bug.
24
-
25
- ## Attributes
26
-
27
- An attribute expression must be the **entire** attribute value:
28
-
29
- ```ts
30
- html`<div class="${() => (active() ? "tab is-active" : "tab")}">` // ✅
31
- html`<div class="tab ${() => active() ? "is-active" : ""}">` // ❌ THROWS
32
- ```
33
-
34
- Partial values (`"static ${…}"`) are not registered as placeholders and throw
35
- `Invalid HTML position`. Build the full string in one expression.
36
-
37
- Returning `false` from an attribute expression **removes** the attribute (vs `""`
38
- which keeps it present-but-empty) — the clean way to toggle `disabled`/`hidden`:
39
-
40
- ```ts
41
- html`<button disabled="${() => !canSubmit()}">Save</button>`
42
- ```
43
-
44
- ## Properties, events, lists
45
-
46
- ```ts
47
- html`<input .value="${() => data.text}" />` // .prop = IDL property
48
- html`<button @click="${() => data.count++}">+</button>` // @event
49
- html`${() => data.items.map((i) => html`<li>${i.text}</li>`.key(i.id))}` // keyed list
50
- ```
51
-
52
- Keyed lists preserve DOM across reorders; mutate item fields in place for
53
- fine-grained updates instead of replacing the whole array.
54
-
55
- ## Async sections
56
-
57
- ```ts
58
- import { boundary } from "@arrow-js/framework"
59
- const Card = component(
60
- async () => { const d = await load(); return html`<div>${d.label}</div>` },
61
- { fallback: html`<div>Loading…</div>` } // shown while pending
62
- )
63
- html`${boundary(Card())}`
64
- ```
65
-
66
- Works client-side with no SSR. `boundary()` only takes `{ idPrefix }`; the
67
- visible loading state comes from the async component's `fallback` option.
68
-
69
- ## Event handler typing
70
-
71
- An `@event` handler must be assignable to `(e: Event) => void`. A handler typed
72
- with a narrowed subtype fails (parameter contravariance) — `tsc` reports
73
- `TS2345 … not assignable to 'ArrowExpression'`. Type the param `Event` and
74
- narrow inside; no-arg handlers are always fine.
75
-
76
- ```ts
77
- // ❌ TS2345 — narrowed param
78
- html`<div @mousedown="${(e: MouseEvent) => resize(e)}">`
79
- // ✅ widen, narrow inside
80
- html`<div @mousedown="${(e: Event) => resize(e as MouseEvent)}">`
81
- ```
82
-
83
- (document.addEventListener handlers are unaffected — they're DOM-lib typed.)
84
-
85
- ## Hard footguns
86
-
87
- Render-time (pass `tsc`, fail only at render — always verify in a browser):
88
-
89
- 1. **No literal HTML comments inside templates.** Arrow uses HTML comments as
90
- expression-slot markers, so a literal `<!-- … -->` inflates the slot count and
91
- throws `Invalid HTML position`. Use JS `//` comments outside the template.
92
- 2. **No partial attribute values** — the expression must be the whole value
93
- (see Attributes above), else `Invalid HTML position`.
94
-
95
- Type-time (caught by `tsc`):
96
-
97
- 3. **No narrowed `Event` subtype in `@event` handlers** (see Event handler
98
- typing above).
99
-
100
- CI guards all three: `test/template-footguns.test.mjs` scans for `<!--` and for
101
- inline handlers typed with a narrowed Event subtype; `tsc` covers the rest.
@@ -1,64 +0,0 @@
1
- ---
2
- name: obsidian-arrow-sandbox
3
- description: Use when prototyping or building Arrow.js UI for an Obsidian plugin in the obsidian-arrow-sandbox project — covers running the sandbox, pulling Obsidian's real app.css from the local install, the dev/verify workflow, CSS scoping, and porting a finished component into a plugin's ItemView with near-zero refactoring.
4
- ---
5
-
6
- # Obsidian Arrow Sandbox
7
-
8
- A client-only Vite + TypeScript environment for building [Arrow.js](https://arrow-js.com/)
9
- UI that drops into an Obsidian plugin. Components render against Obsidian's real
10
- `app.css`, so what you see in the browser is what you get in a plugin view.
11
-
12
- ## Mental model
13
-
14
- - **Client-only, no SSR.** Components use `@arrow-js/core` (`reactive`, `html`,
15
- `component`, `watch`) plus `@arrow-js/framework` (`boundary` for async
16
- sections), mounted via `template(container)` — the exact call an Obsidian
17
- `ItemView.onOpen()` makes. Do **not** add `@arrow-js/ssr` or `@arrow-js/hydrate`;
18
- an Obsidian plugin has no server and nothing to hydrate.
19
- - **Styling is Obsidian's, not yours.** `index.html` puts Obsidian body classes
20
- (`theme-dark mod-macos …`) on `<body>` and loads the extracted `app.css`, which
21
- defines every `var(--…)` token and semantic class (`.setting-item`,
22
- `.clickable-icon`, `.vertical-tab-*`).
23
-
24
- ## Run it
25
-
26
- ```sh
27
- pnpm install
28
- pnpm pull-css # extract Obsidian's app.css (macOS auto-detect)
29
- pnpm dev # Vite + HMR; open the printed URL
30
- ```
31
-
32
- `pnpm pull-css` reads `app.css` out of `obsidian.asar`. macOS is auto-detected;
33
- elsewhere pass `--path <obsidian.asar|app.css>` or set `OBSIDIAN_ASAR=<path>`.
34
- `public/app.css` is **git-ignored** (Obsidian's proprietary CSS — not
35
- redistributed), so run `pnpm pull-css` once before `pnpm dev`.
36
-
37
- ## Build a component
38
-
39
- Add `src/components/MyThing.ts` exporting an Arrow `component()`, then mount it
40
- from `src/main.ts`. Use Obsidian classes + `var(--…)` tokens first; add custom
41
- CSS only when there's no Obsidian class, and scope it under a container class +
42
- element type (e.g. `.oas-frame button.oas-x`) so it beats Obsidian's global
43
- `button:not(.clickable-icon)` rule. Sandbox-only chrome lives in
44
- `src/sandbox/sandbox.css`.
45
-
46
- For the template-writing rules and Arrow's hard footguns, use the companion
47
- skill **arrow-js-obsidian-templates**.
48
-
49
- ## Verify before claiming done
50
-
51
- ```sh
52
- pnpm typecheck && pnpm test && pnpm lint # or: pnpm check
53
- ```
54
-
55
- Then open the `pnpm dev` URL and confirm the console is clean and the component
56
- looks like a real Obsidian pane. Arrow's footguns only surface at render, so
57
- typecheck passing is **not** proof a component works.
58
-
59
- ## Port into the plugin
60
-
61
- Copy the component file into the plugin's view directory and mount it from
62
- `ItemView.onOpen()` via `template(this.contentEl)`. If it uses `boundary()` /
63
- async components, add `@arrow-js/framework` to the plugin and the side-effect
64
- `import '@arrow-js/framework'`. Leave sandbox chrome (`src/sandbox/*`) behind.