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 +1 -1
- package/template/AGENTS.md +13 -2
- package/template/CLAUDE.md +4 -0
- package/template/README.md +17 -8
- package/template/docs/prompts/agent-setup.md +5 -4
- package/template/docs/workflow.md +78 -0
- package/template/package.json +1 -0
- package/template/scripts/component-hash.mjs +123 -0
- package/template/scripts/install-skills.mjs +90 -28
- package/template/scripts/lib/canonical-source.mjs +63 -0
- package/template/skills/arrow-js-obsidian-porting/SKILL.md +92 -0
- package/template/skills/arrow-js-obsidian-templates/SKILL.md +1 -1
- package/template/test/component-hash.test.mjs +48 -0
- package/template/test/skills-frontmatter.test.mjs +57 -0
- package/template/docs/superpowers/specs/2026-06-29-obsidian-arrow-sandbox-design.md +0 -206
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
|
package/template/README.md
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
|
82
|
+
Install them into your agent:
|
|
80
83
|
|
|
81
84
|
```sh
|
|
82
|
-
pnpm skills:install
|
|
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
|
|
86
|
-
interactive terminal —
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
36
|
-
|
|
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
|
|
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`. |
|
package/template/package.json
CHANGED
|
@@ -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/*)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
* pnpm
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
75
|
+
if (optedOut && !update && !forced) {
|
|
76
|
+
console.log("[skills] SKIP_SKILLS_INSTALL=1 — skipping skill install.");
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
32
79
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
|
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
|
-
```
|