create-obsidian-arrow 0.1.0 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-obsidian-arrow",
3
- "version": "0.1.0",
3
+ "version": "0.1.5",
4
4
  "description": "Scaffold an Obsidian-styled Arrow.js UI sandbox (pnpm create obsidian-arrow <dir>).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,8 +4,19 @@ Operating guide for AI agents working in **obsidian-arrow-sandbox** — a
4
4
  client-only environment for prototyping [Arrow.js](https://arrow-js.com/) UI that
5
5
  ports into an Obsidian plugin with near-zero refactoring.
6
6
 
7
- Full design + rationale: [`docs/superpowers/specs`](docs/superpowers/specs/2026-06-29-obsidian-arrow-sandbox-design.md).
8
- Deeper how-to skills: [`skills/`](skills/) (install via `npx skills`).
7
+ ## Docs map (start here)
8
+
9
+ This file is the hub — everything else is linked from here:
10
+
11
+ - [`docs/workflow.md`](docs/workflow.md) — fresh-machine → running workflow.
12
+ - [`skills/`](skills/) — installable domain skills (`pnpm skills:install`):
13
+ obsidian-arrow-sandbox, arrow-js-obsidian-templates, arrow-js-obsidian-patterns,
14
+ arrow-js-obsidian-porting (sandbox→plugin parity check).
15
+ - [`docs/prompts/agent-setup.md`](docs/prompts/agent-setup.md) — prompt for
16
+ briefing a fresh agent (scaffold + orient).
17
+
18
+ Design rationale (why `core`+`framework`, no SSR, how `app.css` is sourced) is
19
+ summarized in "What this is (and isn't)" below and in the README.
9
20
 
10
21
  ## What this is (and isn't)
11
22
 
@@ -0,0 +1,4 @@
1
+ # CLAUDE.md
2
+
3
+ Read @AGENTS.md first — it's the operating guide and docs map (workflow, skills,
4
+ footguns, conventions, and the design record are all linked from there).
@@ -6,8 +6,8 @@ written with `@arrow-js/core` (+ `@arrow-js/framework` for async boundaries) and
6
6
  styled entirely by Obsidian's real `app.css`, so what you see here is what you
7
7
  get inside a plugin view.
8
8
 
9
- See the design + decision record in
10
- [`docs/superpowers/specs`](docs/superpowers/specs/2026-06-29-obsidian-arrow-sandbox-design.md).
9
+ New machine? See [`docs/workflow.md`](docs/workflow.md) for the full
10
+ fresh-checkout-to-running workflow.
11
11
 
12
12
  ## Scaffold a new project
13
13
 
@@ -75,17 +75,26 @@ under [`skills/`](skills/) — it doubles as a local skill marketplace:
75
75
  - `arrow-js-obsidian-templates` — Arrow v1.0.6 template rules + footguns.
76
76
  - `arrow-js-obsidian-patterns` — integration patterns: icons (Lucide / data-icon
77
77
  sweep), CSS scoping vs Obsidian globals, mount/unmount lifecycle, reactive state.
78
+ - `arrow-js-obsidian-porting` — content-addressed porting parity: the
79
+ `component-hash` tool + a husky/CI check that the plugin copy hasn't drifted
80
+ from the sandbox source.
78
81
 
79
- Install them into your agent via the interactive TUI:
82
+ Install them into your agent:
80
83
 
81
84
  ```sh
82
- pnpm skills:install # = npx skills add . (pick skills in the TUI)
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
83
89
  ```
84
90
 
85
- `postinstall` offers this automatically after `pnpm install`, but only in an
86
- interactive terminal — it skips in CI / non-TTY (set `SKIP_SKILLS_INSTALL=1` to
87
- opt out entirely). You can also add this repo from anywhere with
88
- `npx skills add <git-url-or-path>`.
91
+ `postinstall` offers the picker automatically after `pnpm install`, but only in
92
+ 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.
89
98
 
90
99
  ## Porting a component into the plugin
91
100
 
@@ -32,15 +32,16 @@ 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 # install the bundled agent skills (npx skills add .) — pick
36
- # them in the TUI; this is how you load the domain knowledge.
35
+ pnpm skills:install --yes # install ALL bundled agent skills non-interactively
36
+ # (runs `npx skills add . --all --yes`) this loads
37
+ # the domain knowledge. Drop --yes for an interactive picker.
37
38
 
38
39
  READ FIRST
39
- - AGENTS.md (root) — operating guide: run, footguns, CSS scoping, verify, port.
40
+ - AGENTS.md (root) — operating guide + docs map (links everything below).
41
+ - docs/workflow.md — fresh-machine → running workflow.
40
42
  - skills/*/SKILL.md — obsidian-arrow-sandbox (workflow), arrow-js-obsidian-
41
43
  templates (template syntax + footguns), arrow-js-obsidian-patterns (icons via
42
44
  Lucide/data-icon sweep, CSS scoping, mount/unmount lifecycle, reactive state).
43
- - docs/superpowers/specs/ — design + decision record (why core+framework, no SSR).
44
45
 
45
46
  ARROW v1.0.6 FOOTGUNS — do not relearn these the hard way:
46
47
  1. NO literal HTML comments inside html`` templates — Arrow treats HTML comments
@@ -0,0 +1,78 @@
1
+ # Workflow
2
+
3
+ How to go from a fresh machine to building Obsidian plugin UI in the sandbox.
4
+
5
+ ## Prerequisites (one-time, per machine)
6
+
7
+ - **Node ≥ 18** and **pnpm** — `corepack enable` (ships with Node) or `npm i -g pnpm`.
8
+ - **Obsidian desktop app installed** — required by `pull-css` to extract `app.css`.
9
+ macOS is auto-detected; Windows/WSL needs an explicit path (see below).
10
+
11
+ ## Spin up a sandbox
12
+
13
+ ```sh
14
+ # 1. Scaffold from the published initializer (any one of these)
15
+ pnpm create obsidian-arrow my-ui # or: npm create obsidian-arrow@latest my-ui
16
+ # or: npx create-obsidian-arrow my-ui
17
+ cd my-ui
18
+
19
+ # 2. Install dependencies
20
+ pnpm install
21
+
22
+ # 3. Pull Obsidian's styling locally — REQUIRED before dev (needs Obsidian installed)
23
+ pnpm pull-css # macOS: auto-detects /Applications/Obsidian.app
24
+ # else: pnpm pull-css --path <obsidian.asar|app.css>
25
+ # or OBSIDIAN_ASAR=<path> pnpm pull-css
26
+
27
+ # 4. Run it
28
+ pnpm dev # open the printed URL — / is the index, /example the demo
29
+ ```
30
+
31
+ `public/app.css` is **git-ignored and never shipped** (it's Obsidian's proprietary
32
+ CSS), so step 3 must run on every fresh checkout, or the sandbox renders unstyled.
33
+
34
+ ## Make your agent aware (optional)
35
+
36
+ From inside the scaffolded project:
37
+
38
+ ```sh
39
+ pnpm skills:install # interactive picker (TUI) on a terminal
40
+ pnpm skills:install --yes # non-interactive — installs ALL bundled skills (for agents/CI)
41
+ pnpm skills:update # update an already-installed setup to the latest
42
+ ```
43
+
44
+ Then point the agent at [`AGENTS.md`](../AGENTS.md), or brief a fresh agent with
45
+ [`docs/prompts/agent-setup.md`](prompts/agent-setup.md).
46
+
47
+ ## Build → verify → port loop
48
+
49
+ ```sh
50
+ # add a component in src/components/, register it in src/examples/registry.ts
51
+ pnpm dev # iterate with HMR
52
+ pnpm run ci # biome + typecheck + tests + build before trusting it
53
+ ```
54
+
55
+ Always confirm the actual render in the browser — Arrow's footguns surface only
56
+ at render, so a passing `tsc` is not proof a component works. See the footguns in
57
+ [`AGENTS.md`](../AGENTS.md) and the `arrow-js-obsidian-templates` skill.
58
+
59
+ **Port a component into a plugin:** copy the file into the plugin's view directory
60
+ and mount it from `ItemView.onOpen()` via `template(this.contentEl)`. If it uses
61
+ `boundary()`/async components, add `@arrow-js/framework` to the plugin and the
62
+ side-effect `import '@arrow-js/framework'`. Leave `src/sandbox/*` behind.
63
+
64
+ ## Scaffold vs. clone
65
+
66
+ - **Scaffold** (`pnpm create obsidian-arrow`) when you want to **build plugin UI** —
67
+ the normal use.
68
+ - **Clone** this repo (`kylebrodeur/obsidian-arrow-sandbox`) only to **change the
69
+ sandbox or the initializer itself**, then `pnpm create:sync` and publish the
70
+ `create-obsidian-arrow/` package (`cd create-obsidian-arrow && pnpm publish`).
71
+
72
+ ## Troubleshooting
73
+
74
+ | Symptom | Fix |
75
+ |---|---|
76
+ | `pnpm dev` renders unstyled / `var(--…)` not resolving | Run `pnpm pull-css` (step 3). |
77
+ | `pull-css` can't find Obsidian | Pass `--path <obsidian.asar\|app.css>` or set `OBSIDIAN_ASAR` (Windows/WSL not auto-detected). |
78
+ | `Invalid HTML position` at render | An Arrow footgun — no HTML comments in templates; attribute expressions must be the whole value. See `AGENTS.md`. |
@@ -18,6 +18,7 @@
18
18
  "check": "biome check --write . && pnpm typecheck && pnpm test",
19
19
  "ci": "biome ci . && pnpm typecheck && pnpm test && pnpm build",
20
20
  "skills:install": "node scripts/install-skills.mjs --force",
21
+ "skills:update": "node scripts/install-skills.mjs --update",
21
22
  "postinstall": "node scripts/install-skills.mjs",
22
23
  "prepare": "husky"
23
24
  },
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * component-hash — sandbox -> plugin porting parity check.
4
+ *
5
+ * The sandbox is the source of truth for a component; the plugin gets a
6
+ * near-verbatim copy. This hashes the canonical *portable body* (imports +
7
+ * whitespace normalized out; see lib/canonical-source.mjs) so the hook flags
8
+ * only meaningful drift, not benign import/mount deltas.
9
+ *
10
+ * Usage:
11
+ * node scripts/component-hash.mjs <file>
12
+ * print the canonical hash of one component.
13
+ *
14
+ * node scripts/component-hash.mjs --verify <sandbox-file> <plugin-file>
15
+ * compare two files; exit 0 (PARITY OK) or 1 (DRIFT).
16
+ *
17
+ * node scripts/component-hash.mjs --check <manifest.json> [--update]
18
+ * check every entry; exit 1 on any drift. --update re-records the baseline
19
+ * from the source of truth (sandbox if present, else the plugin copy).
20
+ *
21
+ * Manifest shape (paths resolved relative to the manifest file):
22
+ * { "entries": [
23
+ * { "plugin": "src/chat/arrow/Foo.ts",
24
+ * "sandbox": "../obsidian-arrow-sandbox/src/components/Foo.ts", // optional
25
+ * "hash": "<sha256>" } // optional fallback
26
+ * ] }
27
+ *
28
+ * Pure Node, no dependencies. Runs at the dev/commit boundary (husky/CI).
29
+ */
30
+ import fs from "node:fs";
31
+ import path from "node:path";
32
+ import { hashSource } from "./lib/canonical-source.mjs";
33
+
34
+ function fail(message) {
35
+ console.error(`component-hash: ${message}`);
36
+ process.exit(2);
37
+ }
38
+
39
+ function hashFile(file) {
40
+ try {
41
+ return hashSource(fs.readFileSync(file, "utf8"));
42
+ } catch (error) {
43
+ fail(`cannot read ${file} (${error.code ?? error.message})`);
44
+ return ""; // unreachable; fail() exits
45
+ }
46
+ }
47
+
48
+ const argv = process.argv.slice(2);
49
+
50
+ if (argv.length === 0) {
51
+ fail("usage: component-hash <file> | --verify <a> <b> | --check <manifest> [--update]");
52
+ }
53
+
54
+ if (argv[0] === "--verify") {
55
+ const [, a, b] = argv;
56
+ if (!a || !b) {
57
+ fail("--verify needs <sandbox-file> <plugin-file>");
58
+ }
59
+ const ha = hashFile(a);
60
+ const hb = hashFile(b);
61
+ if (ha === hb) {
62
+ console.log(`PARITY OK ${path.basename(b)} ${ha.slice(0, 12)}`);
63
+ process.exit(0);
64
+ }
65
+ console.error(`DRIFT ${b}\n sandbox ${ha.slice(0, 12)} != plugin ${hb.slice(0, 12)}`);
66
+ console.error(" -> edit the sandbox component and re-port (don't hand-edit the copy).");
67
+ process.exit(1);
68
+ }
69
+
70
+ if (argv[0] === "--check") {
71
+ const manifestPath = argv[1];
72
+ const update = argv.includes("--update");
73
+ if (!manifestPath) {
74
+ fail("--check needs <manifest.json>");
75
+ }
76
+ const manifestDir = path.dirname(path.resolve(manifestPath));
77
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
78
+ const entries = manifest.entries ?? [];
79
+ const resolve = (p) => path.resolve(manifestDir, p);
80
+
81
+ const drifts = [];
82
+ for (const entry of entries) {
83
+ const pluginHash = hashFile(resolve(entry.plugin));
84
+ const hasSandbox = entry.sandbox && fs.existsSync(resolve(entry.sandbox));
85
+ const sourceHash = hasSandbox ? hashFile(resolve(entry.sandbox)) : undefined;
86
+ const expected = sourceHash ?? entry.hash;
87
+
88
+ if (update) {
89
+ entry.hash = sourceHash ?? pluginHash;
90
+ continue;
91
+ }
92
+ if (expected === undefined) {
93
+ drifts.push(`${entry.plugin}: no reference (add a sandbox path or a recorded hash)`);
94
+ } else if (pluginHash !== expected) {
95
+ const ref = hasSandbox ? "sandbox" : "recorded";
96
+ drifts.push(
97
+ `${entry.plugin}: DRIFT (plugin ${pluginHash.slice(0, 12)} != ${ref} ${expected.slice(0, 12)})`
98
+ );
99
+ }
100
+ }
101
+
102
+ if (update) {
103
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, "\t")}\n`);
104
+ console.log(
105
+ `Re-recorded ${entries.length} baseline hash(es) in ${path.basename(manifestPath)}.`
106
+ );
107
+ process.exit(0);
108
+ }
109
+
110
+ if (drifts.length > 0) {
111
+ console.error(`Port parity: ${drifts.length} drift(s).`);
112
+ for (const d of drifts) {
113
+ console.error(` ${d}`);
114
+ }
115
+ console.error(" -> edit the sandbox component and re-port, or --update to re-bless.");
116
+ process.exit(1);
117
+ }
118
+ console.log(`Port parity OK (${entries.length} component(s)).`);
119
+ process.exit(0);
120
+ }
121
+
122
+ // Default: print the hash of one file.
123
+ console.log(hashFile(argv[0]));
@@ -1,47 +1,109 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Install the skills bundled in this repo (skills/*) into the developer's agent
4
- * via the `skills` CLI (https://github.com/vercel-labs/skills). This repo acts
5
- * as a local marketplace: `skills add .` discovers skills/<name>/SKILL.md and
6
- * lets the user pick which to install through the interactive TUI.
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.
7
6
  *
8
- * Wired as `postinstall`, so it runs after `pnpm install`. To keep that safe it
9
- * only launches the interactive TUI when attached to a real terminal — in CI or
10
- * any non-interactive context it prints a hint and exits 0 instead of hanging.
11
- * Run it on demand with `pnpm skills:install` (which passes --force).
7
+ * Modes (auto-detected):
8
+ * - Interactive terminal → opens the picker TUI (choose what to install).
9
+ * - Non-interactive / CI / -y → installs ALL bundled skills, no prompts.
10
+ * - --update → updates an already-installed setup in place.
12
11
  *
13
- * SKIP_SKILLS_INSTALL=1 opt out entirely
14
- * pnpm skills:install force the interactive install
12
+ * Wiring:
13
+ * - `postinstall` (auto on `pnpm install`): only acts in an interactive
14
+ * 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.
18
+ *
19
+ * Flags / env:
20
+ * --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
+ * --dry-run / SKILLS_DRY_RUN=1 print the command instead of running it
27
+ * SKIP_SKILLS_INSTALL=1 opt out of the postinstall auto-step
15
28
  */
16
29
  import { spawnSync } from "node:child_process";
17
30
  import process from "node:process";
18
31
 
19
- const forced = process.argv.includes("--force");
32
+ const BUNDLED =
33
+ "obsidian-arrow-sandbox, arrow-js-obsidian-templates, arrow-js-obsidian-patterns, arrow-js-obsidian-porting";
34
+
35
+ const has = (flag) => process.argv.includes(flag);
36
+ // Accepts both `--flag value` and `--flag=value`.
37
+ const flagValue = (flag) => {
38
+ const eq = process.argv.find((a) => a.startsWith(`${flag}=`));
39
+ if (eq) {
40
+ return eq.slice(flag.length + 1);
41
+ }
42
+ const i = process.argv.indexOf(flag);
43
+ return i >= 0 ? process.argv[i + 1] : undefined;
44
+ };
45
+
46
+ const forced = has("--force"); // set by `pnpm skills:install`
47
+ const update = has("--update") || has("-u");
48
+ const yes = has("--yes") || has("-y");
49
+ const agent = flagValue("--agent") || process.env.SKILLS_AGENT;
50
+ const dryRun = has("--dry-run") || process.env.SKILLS_DRY_RUN === "1";
20
51
  const isCI = Boolean(process.env.CI);
21
52
  const interactive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
22
53
  const optedOut = process.env.SKIP_SKILLS_INSTALL === "1";
23
54
 
24
- if (!forced && (isCI || optedOut || !interactive)) {
25
- console.log(
26
- "[skills] Skipping interactive skill install (non-interactive/CI/opt-out).\n Run `pnpm skills:install` to install the bundled skills via the npx skills TUI."
27
- );
28
- process.exit(0);
55
+ function run(args) {
56
+ const pretty = ["npx", "skills", ...args].join(" ");
57
+ if (dryRun) {
58
+ console.log(`[skills] (dry-run) would run: ${pretty}`);
59
+ process.exit(0);
60
+ }
61
+ console.log(`[skills] ${pretty}`);
62
+ const result = spawnSync("npx", ["--yes", "skills", ...args], {
63
+ stdio: "inherit",
64
+ shell: process.platform === "win32",
65
+ });
66
+ if (result.error) {
67
+ console.error(
68
+ `[skills] could not run npx skills (${result.error.message}). Install manually: npx skills add . --all --yes`
69
+ );
70
+ process.exit(0); // never fail an install over an optional convenience step
71
+ }
72
+ process.exit(result.status ?? 0);
29
73
  }
30
74
 
31
- console.log("[skills] Launching `npx skills add .` — pick the skills to install in the TUI…\n");
75
+ if (optedOut && !update && !forced) {
76
+ console.log("[skills] SKIP_SKILLS_INSTALL=1 — skipping skill install.");
77
+ process.exit(0);
78
+ }
32
79
 
33
- // `--yes` only auto-confirms npx fetching the `skills` package; the skills CLI's
34
- // own selection TUI still shows (we deliberately do not pass skills' `-y`).
35
- const result = spawnSync("npx", ["--yes", "skills", "add", "."], {
36
- stdio: "inherit",
37
- shell: process.platform === "win32",
38
- });
80
+ // Update an existing setup to the latest (works in any context).
81
+ if (update) {
82
+ console.log("[skills] Updating installed skills to the latest…");
83
+ run(["update", "-y"]);
84
+ }
39
85
 
40
- if (result.error) {
41
- console.error(
42
- `[skills] Could not run npx skills (${result.error.message}).\n Install manually: npx skills add .`
86
+ // Conservative postinstall: only auto-run when a human is at the terminal so
87
+ // `pnpm install` in CI never hangs or installs things unprompted.
88
+ if (!forced && (isCI || !interactive)) {
89
+ 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`
43
91
  );
44
- process.exit(0); // never fail an install over an optional convenience step
92
+ process.exit(0);
93
+ }
94
+
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"]);
103
+ }
104
+ run(["add", ".", "--all"]);
45
105
  }
46
106
 
47
- process.exit(result.status ?? 0);
107
+ // Interactive terminal: let the user pick in the TUI.
108
+ console.log(`[skills] Opening the skills picker. Bundled: ${BUNDLED}`);
109
+ run(["add", "."]);
@@ -0,0 +1,63 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * Canonical source hashing for sandbox -> plugin porting parity.
5
+ *
6
+ * The sandbox is the source of truth for a component; the plugin gets a
7
+ * near-verbatim copy. This hashes the component's *portable body* so the only
8
+ * thing that can change the hash is meaningful drift in the component — not the
9
+ * bits that legitimately differ on the way into a plugin.
10
+ *
11
+ * Canonical form (define it so both sides agree by construction):
12
+ * 1. Drop `import …;` statements (single- or multi-line). Imports are the main
13
+ * legit delta: the sandbox imports stub data / no obsidian; the plugin
14
+ * imports the obsidian API / real data wiring. The component body is what
15
+ * must match.
16
+ * 2. Normalize line endings to \n, strip trailing whitespace per line, collapse
17
+ * runs of blank lines to one, trim leading/trailing blank lines.
18
+ * 3. SHA-256 the result.
19
+ *
20
+ * Note: comments are intentionally kept (they're part of the component; the
21
+ * sandbox is the source of truth for them too). To keep parity clean, write
22
+ * portable components that take data via props/getters so the body is identical
23
+ * across sandbox and plugin and only the mount site differs.
24
+ */
25
+
26
+ // Matches a whole `import …;` statement. `[^;]*` crosses newlines (the negated
27
+ // class matches \n) so multi-line imports are captured up to the first semicolon.
28
+ const IMPORT_STATEMENT = /^[ \t]*import\b[^;]*;[ \t]*$/gm;
29
+
30
+ export function canonicalizeSource(text) {
31
+ const withoutImports = text.replace(IMPORT_STATEMENT, "");
32
+ const lines = withoutImports
33
+ .replace(/\r\n/g, "\n")
34
+ .split("\n")
35
+ .map((line) => line.replace(/[ \t]+$/, ""));
36
+
37
+ // Collapse runs of blank lines to a single blank line.
38
+ const collapsed = [];
39
+ let lastBlank = false;
40
+ for (const line of lines) {
41
+ const blank = line === "";
42
+ if (blank && lastBlank) {
43
+ continue;
44
+ }
45
+ collapsed.push(line);
46
+ lastBlank = blank;
47
+ }
48
+
49
+ // Trim leading/trailing blank lines.
50
+ while (collapsed.length > 0 && collapsed[0] === "") {
51
+ collapsed.shift();
52
+ }
53
+ while (collapsed.length > 0 && collapsed[collapsed.length - 1] === "") {
54
+ collapsed.pop();
55
+ }
56
+
57
+ return `${collapsed.join("\n")}\n`;
58
+ }
59
+
60
+ /** SHA-256 hex of the canonical source. */
61
+ export function hashSource(text) {
62
+ return createHash("sha256").update(canonicalizeSource(text), "utf8").digest("hex");
63
+ }
@@ -0,0 +1,92 @@
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,6 +1,6 @@
1
1
  ---
2
2
  name: arrow-js-obsidian-templates
3
- description: Use when writing @arrow-js/core (v1.0.6) html`` templatesthe reactive-vs-static rule, full-value attribute binding (returning false removes the attribute), .property and @event binding, keyed lists, async component(fn, { fallback }) wrapped in boundary(), and the footguns: no literal HTML comments inside templates and no partial attribute values (both throw "Invalid HTML position" at render), plus @event handlers must type the param as Event not a narrowed subtype like MouseEvent (TS2345).
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
4
  ---
5
5
 
6
6
  # Arrow.js Templates (v1.0.6)
@@ -0,0 +1,48 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { canonicalizeSource, hashSource } from "../scripts/lib/canonical-source.mjs";
4
+
5
+ /**
6
+ * Porting-parity canonicalization. The hash must ignore the bits that
7
+ * legitimately differ when a component is ported sandbox -> plugin (imports,
8
+ * whitespace) and flag everything else (the component body/template).
9
+ */
10
+
11
+ const SANDBOX = `import { component, html } from "@arrow-js/core";
12
+ import { loadStatus } from "../data/loadStatus";
13
+
14
+ export const Card = component(() => html\`<div class="setting-item">\${() => loadStatus()}</div>\`);
15
+ `;
16
+
17
+ // Same body, plugin-flavored imports (obsidian + real data wiring).
18
+ const PLUGIN = `import { component, html } from "@arrow-js/core";
19
+ import { loadStatus } from "./rpc";
20
+ import { setIcon } from "obsidian";
21
+
22
+ export const Card = component(() => html\`<div class="setting-item">\${() => loadStatus()}</div>\`);
23
+ `;
24
+
25
+ test("imports are stripped — same body hashes equal across sandbox and plugin", () => {
26
+ assert.equal(hashSource(SANDBOX), hashSource(PLUGIN));
27
+ });
28
+
29
+ test("a body change is detected as drift", () => {
30
+ const drifted = PLUGIN.replace("setting-item", "setting-item is-hacked");
31
+ assert.notEqual(hashSource(SANDBOX), hashSource(drifted));
32
+ });
33
+
34
+ test("whitespace and blank-line differences are normalized away", () => {
35
+ const messy = `${SANDBOX.replace(/\n/g, "\n\n")} \n`;
36
+ assert.equal(hashSource(SANDBOX), hashSource(messy));
37
+ });
38
+
39
+ test("multi-line imports are stripped", () => {
40
+ const multiline = `import {\n\tcomponent,\n\thtml,\n} from "@arrow-js/core";\n\nexport const X = 1;\n`;
41
+ const singleline = `import { component, html } from "@arrow-js/core";\n\nexport const X = 1;\n`;
42
+ assert.equal(hashSource(multiline), hashSource(singleline));
43
+ assert.match(canonicalizeSource(multiline), /^export const X = 1;\n$/);
44
+ });
45
+
46
+ test("hash is stable sha256 hex", () => {
47
+ assert.match(hashSource(SANDBOX), /^[0-9a-f]{64}$/);
48
+ });
@@ -0,0 +1,57 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { test } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * The `skills` CLI parses each SKILL.md's YAML frontmatter; if it can't, the
9
+ * skill is silently skipped (not installed). An unquoted plain-scalar
10
+ * `description:` that contains `: ` (read as a nested key) or an unescaped
11
+ * double-quote breaks the parse — that's a real footgun (it skipped
12
+ * arrow-js-obsidian-templates until caught). These guards keep descriptions
13
+ * installable.
14
+ */
15
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
16
+ const skillsDir = path.join(root, "skills");
17
+
18
+ function skillFiles() {
19
+ if (!fs.existsSync(skillsDir)) {
20
+ return [];
21
+ }
22
+ return fs
23
+ .readdirSync(skillsDir, { withFileTypes: true })
24
+ .filter((entry) => entry.isDirectory())
25
+ .map((entry) => path.join(skillsDir, entry.name, "SKILL.md"))
26
+ .filter((file) => fs.existsSync(file));
27
+ }
28
+
29
+ function frontmatter(text) {
30
+ const match = text.match(/^---\n([\s\S]*?)\n---/);
31
+ return match ? match[1] : "";
32
+ }
33
+
34
+ test("every skill has name + description frontmatter", () => {
35
+ for (const file of skillFiles()) {
36
+ const fm = frontmatter(fs.readFileSync(file, "utf8"));
37
+ assert.match(fm, /^name:\s+\S/m, `${path.relative(root, file)}: missing name`);
38
+ assert.match(fm, /^description:\s+\S/m, `${path.relative(root, file)}: missing description`);
39
+ }
40
+ });
41
+
42
+ test("skill descriptions are YAML-safe so the skills CLI installs them", () => {
43
+ const offenders = [];
44
+ for (const file of skillFiles()) {
45
+ const fm = frontmatter(fs.readFileSync(file, "utf8"));
46
+ const desc = (fm.match(/^description:\s+(.*)$/m) ?? [])[1] ?? "";
47
+ const quoted = /^["']/.test(desc);
48
+ if (!quoted && (/: /.test(desc) || desc.includes('"'))) {
49
+ offenders.push(path.relative(root, file));
50
+ }
51
+ }
52
+ assert.deepEqual(
53
+ offenders,
54
+ [],
55
+ "unquoted description must not contain ': ' or a double-quote (breaks YAML; the skills CLI skips it)"
56
+ );
57
+ });
@@ -1,206 +0,0 @@
1
- # Obsidian-Arrow Sandbox — Design
2
-
3
- **Date:** 2026-06-29
4
- **Location:** `/Users/kylebrodeur/workspace/arrow-ui/obsidian-arrow-sandbox`
5
- **Status:** Draft for review
6
-
7
- ## Purpose
8
-
9
- A minimal, Obsidian-styled prototyping environment for building plugin UI with
10
- [Arrow.js](https://arrow-js.com/), so we can iterate on UI/UX fast in the
11
- browser without building/loading into Obsidian on every change. Components are
12
- written so they copy-paste into an Obsidian `ItemView`/`Modal`/settings tab with
13
- near-zero refactoring, and a separate agent later reconciles them into the real
14
- plugin (`pi-vault-mind/packages/obsidian`).
15
-
16
- Success = `pnpm dev` opens a browser page that looks like a real Obsidian pane,
17
- rendering **one** working, reactive, Obsidian-styled Arrow component, where the
18
- component source is plugin-ready as-is.
19
-
20
- ## Scope
21
-
22
- **In scope (baseline):**
23
- - Client-only Vite + TypeScript dev environment (`pnpm dev`).
24
- - `@arrow-js/core` **+ `@arrow-js/framework`** (client-side only). Core supplies
25
- `reactive`, `html`, `component`, `watch`, `nextTick`; framework supplies
26
- `boundary()` for async fallback sections. Mounted via `template(container)` —
27
- the same call an Obsidian `ItemView.onOpen()` makes. **No `ssr`/`hydrate`.**
28
- - A deliberate **templates showcase** exercising Arrow's template features:
29
- reactive `${() => …}` vs static `${…}`, attribute sync with `false`-removal
30
- (`disabled="${() => …}"`), `.property` binding (`.checked`), `@event`
31
- (`@click`), and keyed lists (`.key(id)`).
32
- - An async section wrapped in `boundary()` (fallback → resolved) so the baseline
33
- itself **is** the framework evaluation — we can judge `boundary()` ergonomics
34
- against plain reactive flags on real code.
35
- - `index.html` wrapped in Obsidian's body classes, loading the extracted
36
- `app.css` for full fidelity (tokens **and** semantic component classes).
37
- - A puller script that extracts `app.css` from the local Obsidian install.
38
- - One baseline component: a settings panel (vertical tabs + `.setting-item` +
39
- `.checkbox-container` toggle + token-colored status line) proving the pipeline.
40
- - A light/dark theme toggle for eyeballing both themes.
41
-
42
- **Out of scope (deferred, additive later):**
43
- - `@arrow-js/ssr` + `@arrow-js/hydrate` — **cut, not deferred.** See the Arrow
44
- Layering decision record below for the reasoning.
45
- - Porting the full chat composer (`input.ts`, `message-feed`, `model-select`,
46
- …). The composer is the *next* component after the baseline proves out.
47
- - An `obsidian` API shim (`setIcon`, `Notice`, …). Not needed until we port a
48
- component that calls Obsidian APIs; the baseline uses none.
49
- - CDP-based token capture (see "Token sourcing", option B).
50
-
51
- ## Decision record — Arrow layering
52
-
53
- Evidence from the installed `@arrow-js` packages (v1.0.6) + how an Obsidian
54
- plugin runs (Electron renderer, client-only; view DOM built imperatively in
55
- `ItemView.onOpen()`, no server, no server-rendered HTML to adopt):
56
-
57
- | Package | Key exports | Server needed? | Verdict |
58
- |---|---|---|---|
59
- | `core` (13.5KB min, 0 deps) | `reactive`, `html`, `component`, `watch`, `nextTick` | no | **Use** |
60
- | `framework` (client entry, 1.4KB, no jsdom) | `boundary()`, `render`, `toTemplate` | no | **Use** (client only) |
61
- | `framework/ssr` | server render runtime | yes (pulls **jsdom**, Node-only) | unused |
62
- | `ssr` | `renderToString`, `serializePayload` | yes | **Cut** |
63
- | `hydrate` (12.7KB) | `hydrate`, `readPayload` | yes (needs SSR output) | **Cut** |
64
-
65
- - **Cut `ssr` + `hydrate`:** they are a server→client pair. `renderToString`
66
- needs a server to run on; `hydrate` needs server HTML + payload to adopt.
67
- Obsidian has neither — `onOpen()` already builds the DOM client-side, so
68
- `hydrate` would only add indirection over `template(container)`. The SSR path
69
- also depends on `jsdom` (Node) and cannot sanely run in the renderer bundle.
70
- - **Use `framework` client-side:** its only client-relevant feature is
71
- `boundary()` (async fallback). Client entry is tiny and does **not** import
72
- jsdom, so it bundles cleanly into a plugin. Adopting it now makes the baseline
73
- double as the "should the plugin upgrade?" evaluation, scoped to `boundary()`.
74
- - **Note:** `component()` lives in core, so reusable components don't require
75
- framework. Porting a framework-using component to the plugin requires adding
76
- `@arrow-js/framework` as a plugin dependency + the side-effect runtime import.
77
-
78
- ## CSS scoping convention
79
-
80
- 1. Components use **Obsidian's own semantic classes** (`.setting-item`,
81
- `.clickable-icon`, `.workspace-leaf`, `.vertical-tab-nav-item`) and `var(--…)`
82
- tokens first; add custom CSS only where Obsidian offers no class.
83
- 2. Any custom CSS is **scoped under a container class + element type** (e.g.
84
- `.oas-frame button.oas-theme-toggle`, specificity ≥ (0,2,1)) so it beats
85
- Obsidian's global `button:not(.clickable-icon)` rule and never leaks — per the
86
- `arrow-js-obsidian` skill's specificity lesson. Sandbox-only chrome lives in a
87
- scoped `src/sandbox/sandbox.css`; component styling stays on Obsidian classes.
88
-
89
- ## Architecture
90
-
91
- Plain client-side single-page sandbox. No server, no SSR. Vite serves
92
- `index.html`, which loads `public/app.css` (extracted locally via `pull-css`,
93
- git-ignored) and a TS entry that mounts an Arrow component into `#app`.
94
-
95
- ```
96
- obsidian-arrow-sandbox/
97
- ├── index.html # Obsidian body-class wrapper + app.css link + #app
98
- ├── public/
99
- │ └── app.css # extracted from Obsidian via pull-css (git-ignored, not redistributed)
100
- ├── src/
101
- │ ├── main.ts # mounts the baseline component into #app
102
- │ ├── sandbox/
103
- │ │ ├── frame.ts # workspace-leaf frame + theme toggle chrome
104
- │ │ ├── theme.ts # flip body theme-dark/theme-light
105
- │ │ └── sandbox.css # scoped sandbox-only chrome styles
106
- │ ├── data/
107
- │ │ └── loadStatus.ts # async data for the boundary() demo
108
- │ └── components/
109
- │ └── SettingsPanel.ts # the baseline Arrow component (+ boundary section)
110
- ├── scripts/
111
- │ └── pull-app-css.mjs # extract app.css from local Obsidian install
112
- ├── docs/superpowers/specs/ # this spec
113
- ├── package.json
114
- ├── tsconfig.json
115
- └── vite.config.ts
116
- ```
117
-
118
- ### `index.html`
119
-
120
- ```html
121
- <body class="theme-dark mod-macos is-frameless is-hidden-frameless obsidian-app
122
- show-view-header show-inline-title">
123
- <div id="app"></div>
124
- <script type="module" src="/src/main.ts"></script>
125
- </body>
126
- ```
127
-
128
- The body classes activate Obsidian's variable scope; without them `var(--…)`
129
- lookups in `app.css` don't resolve. `app.css` is loaded via `<link>` in `<head>`.
130
-
131
- ### Component model
132
-
133
- ```ts
134
- import '@arrow-js/framework' // side-effect: install framework runtime
135
- import { html, reactive, component } from '@arrow-js/core'
136
- import { boundary } from '@arrow-js/framework' // async fallback boundary
137
-
138
- const state = reactive({ activeTab: 'general', developerMode: true })
139
- export const SettingsPanel = component(() => html`…`) // returns an Arrow template
140
-
141
- // main.ts
142
- import { SettingsPanel } from './components/SettingsPanel'
143
- SettingsPanel()(document.getElementById('app')!) // == ItemView.onOpen mount
144
- ```
145
-
146
- Reactive expressions use the `() => …` form per Arrow rules
147
- (`${() => state.activeTab === 'general' ? 'is-active' : ''}`), events via
148
- `@click`, properties via `.checked`, matching the production
149
- `arrow-js-obsidian` skill conventions.
150
-
151
- ## Token sourcing — extract `app.css`
152
-
153
- The sandbox loads the **full** authored `app.css` (not a slimmed token file) so
154
- both `var(--…)` tokens and semantic component rules render faithfully.
155
-
156
- **Option A — asar extraction (default, validated):**
157
- On macOS `app.css` is bundled inside
158
- `/Applications/Obsidian.app/Contents/Resources/obsidian.asar`. `pull-app-css.mjs`
159
- parses the asar header (a Chromium Pickle: JSON length at byte 12, JSON header at
160
- byte 16, file data section after the 4-byte-aligned header pickle), locates
161
- `/app.css`, slices its bytes, and writes `public/app.css`. Pure Node, no deps, no
162
- running Obsidian.
163
-
164
- *Validated 2026-06-29:* extracted `app.css` = 586KB, ~1948 var declarations,
165
- contains `body.theme-dark`, `.theme-light`, `--text-accent`, `--size-4-4`,
166
- `--interactive-accent`, `--radius-m`. (Implementation note: handle 4-byte
167
- alignment of the data offset so the slice isn't a few bytes off.)
168
-
169
- CLI: `pnpm pull-css` (with `--path <asar>` override and an env-var fallback for
170
- non-default install locations). Output is **git-ignored** — Obsidian's CSS is
171
- proprietary, so each developer extracts it from their own licensed install rather
172
- than us redistributing it. Run `pull-css` once before `pnpm dev`.
173
-
174
- **Option B — CDP capture (deferred, optional `--source cdp`):**
175
- Launch/attach to a running Obsidian via Chrome DevTools Protocol
176
- (`--remote-debugging-port`), `Runtime.evaluate` in the renderer to dump the live
177
- stylesheet text or `getComputedStyle` variable set. Captures the user's *active*
178
- community theme / snippets / resolved values rather than stock defaults. Useful
179
- later for testing against a real themed environment; not part of the baseline.
180
-
181
- ## Dev workflow
182
-
183
- - `pnpm pull-css` — refresh `public/app.css` from the local Obsidian install.
184
- - `pnpm dev` — Vite dev server with HMR; open the printed URL.
185
- - Edit `src/components/*.ts`; HMR re-renders instantly.
186
- - `pnpm typecheck` — `tsc --noEmit`.
187
-
188
- ## Porting / reconcile story
189
-
190
- Components are framework-free `@arrow-js/core` + Obsidian CSS classes, so moving
191
- one into the plugin is: copy the file into `src/chat/arrow/` (or appropriate
192
- view dir) and mount it from `ItemView.onOpen()` via `template(this.contentEl)`.
193
- Strip any U-of-D references per project notes. No build-system translation needed
194
- (plugin bundles `@arrow-js/core` via esbuild; sandbox imports the same package).
195
-
196
- ## Risks / open notes
197
-
198
- - `app.css` is ~586KB; fine for a local sandbox loaded once. Not slimming it
199
- keeps full component-class fidelity.
200
- - asar data-offset alignment must be correct (off-by-a-few-bytes corrupts the
201
- slice). Validated approach; nail alignment in implementation.
202
- - Exact set of Obsidian body classes may need tweaking to match a real pane;
203
- start from a known-good set and adjust visually.
204
- - Community-theme fidelity is not captured by asar extraction — that's what the
205
- optional CDP mode is for, later.
206
- ```