create-obsidian-arrow 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/index.mjs +76 -0
- package/package.json +40 -0
- package/template/.github/workflows/ci.yml +36 -0
- package/template/.husky/pre-commit +2 -0
- package/template/AGENTS.md +90 -0
- package/template/LICENSE +21 -0
- package/template/README.md +116 -0
- package/template/_gitignore +8 -0
- package/template/biome.json +30 -0
- package/template/docs/prompts/agent-setup.md +94 -0
- package/template/docs/superpowers/specs/2026-06-29-obsidian-arrow-sandbox-design.md +206 -0
- package/template/index.html +19 -0
- package/template/package.json +43 -0
- package/template/pnpm-lock.yaml +1408 -0
- package/template/scripts/install-skills.mjs +47 -0
- package/template/scripts/lib/extract-app-css.mjs +60 -0
- package/template/scripts/pull-app-css.mjs +90 -0
- package/template/skills/arrow-js-obsidian-patterns/SKILL.md +94 -0
- package/template/skills/arrow-js-obsidian-templates/SKILL.md +101 -0
- package/template/skills/obsidian-arrow-sandbox/SKILL.md +64 -0
- package/template/src/components/SettingsPanel.ts +232 -0
- package/template/src/data/loadStatus.ts +17 -0
- package/template/src/examples/ExamplesIndex.ts +36 -0
- package/template/src/examples/registry.ts +26 -0
- package/template/src/main.ts +18 -0
- package/template/src/router/client.ts +85 -0
- package/template/src/router/routeToPage.ts +57 -0
- package/template/src/sandbox/frame.ts +35 -0
- package/template/src/sandbox/layout.ts +40 -0
- package/template/src/sandbox/sandbox.css +125 -0
- package/template/src/sandbox/shell.ts +15 -0
- package/template/src/sandbox/theme.ts +22 -0
- package/template/src/sandbox/toolbar.ts +32 -0
- package/template/test/extract-app-css.test.mjs +70 -0
- package/template/test/template-footguns.test.mjs +58 -0
- package/template/tsconfig.json +13 -0
- package/template/vite.config.ts +15 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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.
|
|
7
|
+
*
|
|
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).
|
|
12
|
+
*
|
|
13
|
+
* SKIP_SKILLS_INSTALL=1 opt out entirely
|
|
14
|
+
* pnpm skills:install force the interactive install
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import process from "node:process";
|
|
18
|
+
|
|
19
|
+
const forced = process.argv.includes("--force");
|
|
20
|
+
const isCI = Boolean(process.env.CI);
|
|
21
|
+
const interactive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
22
|
+
const optedOut = process.env.SKIP_SKILLS_INSTALL === "1";
|
|
23
|
+
|
|
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);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log("[skills] Launching `npx skills add .` — pick the skills to install in the TUI…\n");
|
|
32
|
+
|
|
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
|
+
});
|
|
39
|
+
|
|
40
|
+
if (result.error) {
|
|
41
|
+
console.error(
|
|
42
|
+
`[skills] Could not run npx skills (${result.error.message}).\n Install manually: npx skills add .`
|
|
43
|
+
);
|
|
44
|
+
process.exit(0); // never fail an install over an optional convenience step
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
process.exit(result.status ?? 0);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure asar parsing for Obsidian's bundled app.css. No filesystem, no deps —
|
|
3
|
+
* takes a Buffer, returns the app.css string. Kept separate from the CLI so it
|
|
4
|
+
* can be unit-tested against a synthetic asar (CI has no Obsidian install).
|
|
5
|
+
*
|
|
6
|
+
* asar = Chromium Pickle header + concatenated data section:
|
|
7
|
+
* [0,4) UInt32LE = 4 (outer pickle length prefix)
|
|
8
|
+
* [4,8) UInt32LE = headerPickleSize (4-byte aligned — includes padding)
|
|
9
|
+
* [8,12) UInt32LE = json pickle payload size
|
|
10
|
+
* [12,16) UInt32LE = json string length
|
|
11
|
+
* [16, 16+jsonLen) = header JSON
|
|
12
|
+
* data section starts at 8 + headerPickleSize ← offset 4, NOT 8
|
|
13
|
+
*
|
|
14
|
+
* Using offset 8 (the inner payload size) instead of 4 yields a data start that
|
|
15
|
+
* is a few bytes short, bleeding bytes from the previous file into the slice —
|
|
16
|
+
* the alignment bug this module's test guards against.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Read the header JSON + data-section start from an asar buffer. */
|
|
20
|
+
export function readAsarHeader(buf) {
|
|
21
|
+
const jsonLen = buf.readUInt32LE(12);
|
|
22
|
+
const header = JSON.parse(buf.toString("utf8", 16, 16 + jsonLen));
|
|
23
|
+
const dataStart = 8 + buf.readUInt32LE(4);
|
|
24
|
+
return { header, dataStart };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Find a file entry by name anywhere in the asar header tree. */
|
|
28
|
+
export function findEntry(header, fileName) {
|
|
29
|
+
let found = null;
|
|
30
|
+
(function walk(node) {
|
|
31
|
+
if (!node?.files) return;
|
|
32
|
+
for (const [name, child] of Object.entries(node.files)) {
|
|
33
|
+
if (name === fileName && child.offset != null) found = child;
|
|
34
|
+
if (child.files) walk(child);
|
|
35
|
+
}
|
|
36
|
+
})(header);
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Extract `app.css` (or `fileName`) from an asar buffer as a string. */
|
|
41
|
+
export function extractAppCss(buf, fileName = "app.css") {
|
|
42
|
+
const { header, dataStart } = readAsarHeader(buf);
|
|
43
|
+
const entry = findEntry(header, fileName);
|
|
44
|
+
if (!entry) throw new Error(`${fileName} not found inside the asar header`);
|
|
45
|
+
const start = dataStart + Number.parseInt(entry.offset, 10);
|
|
46
|
+
return buf.toString("utf8", start, start + entry.size);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Sanity-check that an extracted string is really Obsidian's app.css. */
|
|
50
|
+
export function assertLooksLikeAppCss(css) {
|
|
51
|
+
const head = css.trimStart().slice(0, 64);
|
|
52
|
+
if (!head.startsWith("/*") && !head.startsWith(":root") && !head.startsWith("@")) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Extracted CSS does not start as expected (got: ${JSON.stringify(head)}). The asar data offset may be misaligned.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (!css.includes("--text-accent") || !css.includes("body.theme-dark")) {
|
|
58
|
+
throw new Error("Extracted CSS is missing expected Obsidian theme variables.");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Extract Obsidian's `app.css` from the local install and write it to
|
|
4
|
+
* `public/app.css`, so the sandbox renders against the *real* Obsidian default
|
|
5
|
+
* theme (every `var(--…)` token plus the semantic component rules like
|
|
6
|
+
* `.setting-item`, `.clickable-icon`, `.vertical-tab-nav-item`).
|
|
7
|
+
*
|
|
8
|
+
* On macOS `app.css` lives inside the asar archive
|
|
9
|
+
* `Obsidian.app/Contents/Resources/obsidian.asar`. The asar parsing lives in
|
|
10
|
+
* ./lib/extract-app-css.mjs (unit-tested separately).
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* node scripts/pull-app-css.mjs # auto-locate per platform
|
|
14
|
+
* node scripts/pull-app-css.mjs --path <asar> # explicit asar/app.css path
|
|
15
|
+
* OBSIDIAN_ASAR=<path> node scripts/pull-app-css.mjs
|
|
16
|
+
*
|
|
17
|
+
* Pure Node, no dependencies, no running Obsidian required.
|
|
18
|
+
*/
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { assertLooksLikeAppCss, extractAppCss } from "./lib/extract-app-css.mjs";
|
|
24
|
+
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const outFile = path.resolve(__dirname, "..", "public", "app.css");
|
|
27
|
+
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
const args = {};
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
if (argv[i] === "--path") args.path = argv[++i];
|
|
32
|
+
}
|
|
33
|
+
return args;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Candidate asar locations per platform, in priority order. */
|
|
37
|
+
function candidateAsarPaths() {
|
|
38
|
+
const home = os.homedir();
|
|
39
|
+
switch (process.platform) {
|
|
40
|
+
case "darwin":
|
|
41
|
+
return [
|
|
42
|
+
"/Applications/Obsidian.app/Contents/Resources/obsidian.asar",
|
|
43
|
+
path.join(home, "Applications/Obsidian.app/Contents/Resources/obsidian.asar"),
|
|
44
|
+
];
|
|
45
|
+
case "win32":
|
|
46
|
+
return [path.join(home, "AppData/Local/Obsidian/resources/obsidian.asar")];
|
|
47
|
+
default:
|
|
48
|
+
return [
|
|
49
|
+
"/opt/Obsidian/resources/obsidian.asar",
|
|
50
|
+
"/usr/lib/obsidian/resources/obsidian.asar",
|
|
51
|
+
path.join(home, ".local/share/obsidian/resources/obsidian.asar"),
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function locateSource(explicit) {
|
|
57
|
+
if (explicit) return explicit;
|
|
58
|
+
if (process.env.OBSIDIAN_ASAR) return process.env.OBSIDIAN_ASAR;
|
|
59
|
+
for (const p of candidateAsarPaths()) {
|
|
60
|
+
if (fs.existsSync(p)) return p;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function main() {
|
|
66
|
+
const { path: explicit } = parseArgs(process.argv.slice(2));
|
|
67
|
+
const source = locateSource(explicit);
|
|
68
|
+
|
|
69
|
+
if (!source) {
|
|
70
|
+
console.error(
|
|
71
|
+
`Could not locate Obsidian. Pass --path <obsidian.asar | app.css> or set OBSIDIAN_ASAR.\nLooked in:\n ${candidateAsarPaths().join("\n ")}`
|
|
72
|
+
);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const css = source.endsWith(".css")
|
|
77
|
+
? fs.readFileSync(source, "utf8")
|
|
78
|
+
: extractAppCss(fs.readFileSync(source));
|
|
79
|
+
|
|
80
|
+
assertLooksLikeAppCss(css);
|
|
81
|
+
|
|
82
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
83
|
+
fs.writeFileSync(outFile, css);
|
|
84
|
+
console.log(
|
|
85
|
+
`Wrote ${(css.length / 1024).toFixed(1)}KB to ${path.relative(process.cwd(), outFile)}\n` +
|
|
86
|
+
` source: ${source}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
main();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: arrow-js-obsidian-patterns
|
|
3
|
+
description: Use when building reactive Obsidian plugin UI with @arrow-js/core beyond basic templates — icons (Obsidian uses Lucide; the data-icon sweep since setIcon can't run inside templates), CSS scoping/specificity against Obsidian's global rules, component mount/unmount lifecycle in an ItemView, and organizing shared reactive state. Complements arrow-js-obsidian-templates (syntax) with integration patterns.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Arrow.js + Obsidian integration patterns
|
|
7
|
+
|
|
8
|
+
How to build real components (not just template syntax) for an Obsidian plugin
|
|
9
|
+
view with `@arrow-js/core`. Pairs with **arrow-js-obsidian-templates** (the
|
|
10
|
+
template rules + footguns).
|
|
11
|
+
|
|
12
|
+
## Mount / unmount lifecycle
|
|
13
|
+
|
|
14
|
+
A view mounts a component imperatively, the same call the sandbox makes:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { html } from "@arrow-js/core"
|
|
18
|
+
// in ItemView.onOpen():
|
|
19
|
+
this.contentEl.empty()
|
|
20
|
+
html`${MyComponent()}`(this.contentEl)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- `html\`\`(container)` does **not** reliably return an unmount function — don't
|
|
24
|
+
gate cleanup on its return value. Always `container.empty()` before remounting.
|
|
25
|
+
- Re-rendering on route/state change: clear the container, then mount fresh.
|
|
26
|
+
Reactive bindings (`${() => …}`) update in place — don't remount the whole tree
|
|
27
|
+
on every state change; let Arrow patch the slots that read changed state.
|
|
28
|
+
|
|
29
|
+
## Shared reactive state
|
|
30
|
+
|
|
31
|
+
One `reactive()` object is the single source of truth; components read it via
|
|
32
|
+
getters so they stay in sync without prop-drilling.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { reactive } from "@arrow-js/core"
|
|
36
|
+
export const state = reactive({ model: "", streaming: false, items: [] })
|
|
37
|
+
// pass getters into components: Toggle(() => state.streaming, () => { ... })
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Icons (Obsidian uses Lucide)
|
|
41
|
+
|
|
42
|
+
`setIcon(el, name)` needs a real DOM element, so it **cannot** be called inside
|
|
43
|
+
an Arrow template expression (the element doesn't exist yet). Two options:
|
|
44
|
+
|
|
45
|
+
1. **In-plugin — the data-icon sweep.** Emit a placeholder, then sweep after
|
|
46
|
+
mount:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
html`<span class="svg-icon" data-icon="copy"></span>`
|
|
50
|
+
// after template(container) — scope the query to the container, not document:
|
|
51
|
+
for (const el of Array.from(container.querySelectorAll<HTMLElement>("[data-icon]"))) {
|
|
52
|
+
const name = el.getAttribute("data-icon")
|
|
53
|
+
if (name) setIcon(el, name) // setIcon from "obsidian"
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
For sections that open after mount (dropdowns/popovers), run the sweep in a
|
|
57
|
+
`nextTick(...)` after they render, scoped to that section's element.
|
|
58
|
+
|
|
59
|
+
2. **In the sandbox (no `obsidian` module).** Use a Lucide import or inline SVG,
|
|
60
|
+
or plain text/emoji for chrome. The baseline uses text glyphs to stay
|
|
61
|
+
shim-free; add a Lucide-backed `setIcon` shim (aliased as `obsidian`) when you
|
|
62
|
+
port icon-using components in.
|
|
63
|
+
|
|
64
|
+
## CSS scoping & specificity (the big one)
|
|
65
|
+
|
|
66
|
+
Obsidian's `app.css` applies global rules like
|
|
67
|
+
`button:not(.clickable-icon) { background: var(--interactive-normal) }` at
|
|
68
|
+
specificity **(0,1,1)**. A plain `.my-btn` selector is **(0,1,0)** and loses
|
|
69
|
+
regardless of cascade order.
|
|
70
|
+
|
|
71
|
+
```css
|
|
72
|
+
/* (0,1,0) — LOSES to Obsidian's global button rule */
|
|
73
|
+
.my-action { background: var(--interactive-accent); }
|
|
74
|
+
|
|
75
|
+
/* (0,2,1) — WINS, and can't leak */
|
|
76
|
+
.my-panel button.my-action { background: var(--interactive-accent); }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Rules: prefer Obsidian's own classes (`.setting-item`, `.clickable-icon`,
|
|
80
|
+
`.workspace-leaf`, `.vertical-tab-*`, `.modal`) and `var(--…)` tokens first; only
|
|
81
|
+
add custom CSS where there's no Obsidian class; always scope custom rules under a
|
|
82
|
+
container class + element type.
|
|
83
|
+
|
|
84
|
+
## Common Obsidian layout classes
|
|
85
|
+
|
|
86
|
+
- `.workspace-leaf` > `.workspace-leaf-content` — a view pane.
|
|
87
|
+
- `.view-header` / `.view-content` — pane header + body.
|
|
88
|
+
- `.setting-item` (+ `.setting-item-info` / `.setting-item-name` /
|
|
89
|
+
`.setting-item-description` / `.setting-item-control`) — settings rows.
|
|
90
|
+
- `.checkbox-container` (+ `.is-enabled`) — toggle.
|
|
91
|
+
- `.clickable-icon` — icon button (escapes the global button background rule).
|
|
92
|
+
- `.vertical-tab-header` / `.vertical-tab-nav-item` (+ `.is-active`) — tab nav.
|
|
93
|
+
- `.modal-container` > `.modal` — modal/popover.
|
|
94
|
+
- `.mod-cta` — primary call-to-action button.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: arrow-js-obsidian-templates
|
|
3
|
+
description: Use when writing @arrow-js/core (v1.0.6) html`` templates — the 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).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Arrow.js Templates (v1.0.6)
|
|
7
|
+
|
|
8
|
+
Rules for writing `html\`\`` templates that render correctly in the browser and
|
|
9
|
+
inside an Obsidian plugin. Several of these are hard runtime errors, not style.
|
|
10
|
+
|
|
11
|
+
## Reactive vs static — the core rule
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { html, reactive } from "@arrow-js/core"
|
|
15
|
+
const data = reactive({ count: 0 })
|
|
16
|
+
|
|
17
|
+
html`<span>${data.count}</span>` // ❌ renders ONCE — never updates
|
|
18
|
+
html`<span>${() => data.count}</span>` // ✅ tracked — updates only this slot
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`${value}` is read once at mount. `${() => value}` is tracked: Arrow records the
|
|
22
|
+
reactive reads inside the function and re-runs **only that slot** when they
|
|
23
|
+
change. Forgetting `() =>` is the #1 "why isn't it updating" bug.
|
|
24
|
+
|
|
25
|
+
## Attributes
|
|
26
|
+
|
|
27
|
+
An attribute expression must be the **entire** attribute value:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
html`<div class="${() => (active() ? "tab is-active" : "tab")}">` // ✅
|
|
31
|
+
html`<div class="tab ${() => active() ? "is-active" : ""}">` // ❌ THROWS
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Partial values (`"static ${…}"`) are not registered as placeholders and throw
|
|
35
|
+
`Invalid HTML position`. Build the full string in one expression.
|
|
36
|
+
|
|
37
|
+
Returning `false` from an attribute expression **removes** the attribute (vs `""`
|
|
38
|
+
which keeps it present-but-empty) — the clean way to toggle `disabled`/`hidden`:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
html`<button disabled="${() => !canSubmit()}">Save</button>`
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Properties, events, lists
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
html`<input .value="${() => data.text}" />` // .prop = IDL property
|
|
48
|
+
html`<button @click="${() => data.count++}">+</button>` // @event
|
|
49
|
+
html`${() => data.items.map((i) => html`<li>${i.text}</li>`.key(i.id))}` // keyed list
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Keyed lists preserve DOM across reorders; mutate item fields in place for
|
|
53
|
+
fine-grained updates instead of replacing the whole array.
|
|
54
|
+
|
|
55
|
+
## Async sections
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { boundary } from "@arrow-js/framework"
|
|
59
|
+
const Card = component(
|
|
60
|
+
async () => { const d = await load(); return html`<div>${d.label}</div>` },
|
|
61
|
+
{ fallback: html`<div>Loading…</div>` } // shown while pending
|
|
62
|
+
)
|
|
63
|
+
html`${boundary(Card())}`
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Works client-side with no SSR. `boundary()` only takes `{ idPrefix }`; the
|
|
67
|
+
visible loading state comes from the async component's `fallback` option.
|
|
68
|
+
|
|
69
|
+
## Event handler typing
|
|
70
|
+
|
|
71
|
+
An `@event` handler must be assignable to `(e: Event) => void`. A handler typed
|
|
72
|
+
with a narrowed subtype fails (parameter contravariance) — `tsc` reports
|
|
73
|
+
`TS2345 … not assignable to 'ArrowExpression'`. Type the param `Event` and
|
|
74
|
+
narrow inside; no-arg handlers are always fine.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// ❌ TS2345 — narrowed param
|
|
78
|
+
html`<div @mousedown="${(e: MouseEvent) => resize(e)}">`
|
|
79
|
+
// ✅ widen, narrow inside
|
|
80
|
+
html`<div @mousedown="${(e: Event) => resize(e as MouseEvent)}">`
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
(document.addEventListener handlers are unaffected — they're DOM-lib typed.)
|
|
84
|
+
|
|
85
|
+
## Hard footguns
|
|
86
|
+
|
|
87
|
+
Render-time (pass `tsc`, fail only at render — always verify in a browser):
|
|
88
|
+
|
|
89
|
+
1. **No literal HTML comments inside templates.** Arrow uses HTML comments as
|
|
90
|
+
expression-slot markers, so a literal `<!-- … -->` inflates the slot count and
|
|
91
|
+
throws `Invalid HTML position`. Use JS `//` comments outside the template.
|
|
92
|
+
2. **No partial attribute values** — the expression must be the whole value
|
|
93
|
+
(see Attributes above), else `Invalid HTML position`.
|
|
94
|
+
|
|
95
|
+
Type-time (caught by `tsc`):
|
|
96
|
+
|
|
97
|
+
3. **No narrowed `Event` subtype in `@event` handlers** (see Event handler
|
|
98
|
+
typing above).
|
|
99
|
+
|
|
100
|
+
CI guards all three: `test/template-footguns.test.mjs` scans for `<!--` and for
|
|
101
|
+
inline handlers typed with a narrowed Event subtype; `tsc` covers the rest.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: obsidian-arrow-sandbox
|
|
3
|
+
description: Use when prototyping or building Arrow.js UI for an Obsidian plugin in the obsidian-arrow-sandbox project — covers running the sandbox, pulling Obsidian's real app.css from the local install, the dev/verify workflow, CSS scoping, and porting a finished component into a plugin's ItemView with near-zero refactoring.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Obsidian Arrow Sandbox
|
|
7
|
+
|
|
8
|
+
A client-only Vite + TypeScript environment for building [Arrow.js](https://arrow-js.com/)
|
|
9
|
+
UI that drops into an Obsidian plugin. Components render against Obsidian's real
|
|
10
|
+
`app.css`, so what you see in the browser is what you get in a plugin view.
|
|
11
|
+
|
|
12
|
+
## Mental model
|
|
13
|
+
|
|
14
|
+
- **Client-only, no SSR.** Components use `@arrow-js/core` (`reactive`, `html`,
|
|
15
|
+
`component`, `watch`) plus `@arrow-js/framework` (`boundary` for async
|
|
16
|
+
sections), mounted via `template(container)` — the exact call an Obsidian
|
|
17
|
+
`ItemView.onOpen()` makes. Do **not** add `@arrow-js/ssr` or `@arrow-js/hydrate`;
|
|
18
|
+
an Obsidian plugin has no server and nothing to hydrate.
|
|
19
|
+
- **Styling is Obsidian's, not yours.** `index.html` puts Obsidian body classes
|
|
20
|
+
(`theme-dark mod-macos …`) on `<body>` and loads the extracted `app.css`, which
|
|
21
|
+
defines every `var(--…)` token and semantic class (`.setting-item`,
|
|
22
|
+
`.clickable-icon`, `.vertical-tab-*`).
|
|
23
|
+
|
|
24
|
+
## Run it
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pnpm install
|
|
28
|
+
pnpm pull-css # extract Obsidian's app.css (macOS auto-detect)
|
|
29
|
+
pnpm dev # Vite + HMR; open the printed URL
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`pnpm pull-css` reads `app.css` out of `obsidian.asar`. macOS is auto-detected;
|
|
33
|
+
elsewhere pass `--path <obsidian.asar|app.css>` or set `OBSIDIAN_ASAR=<path>`.
|
|
34
|
+
`public/app.css` is **git-ignored** (Obsidian's proprietary CSS — not
|
|
35
|
+
redistributed), so run `pnpm pull-css` once before `pnpm dev`.
|
|
36
|
+
|
|
37
|
+
## Build a component
|
|
38
|
+
|
|
39
|
+
Add `src/components/MyThing.ts` exporting an Arrow `component()`, then mount it
|
|
40
|
+
from `src/main.ts`. Use Obsidian classes + `var(--…)` tokens first; add custom
|
|
41
|
+
CSS only when there's no Obsidian class, and scope it under a container class +
|
|
42
|
+
element type (e.g. `.oas-frame button.oas-x`) so it beats Obsidian's global
|
|
43
|
+
`button:not(.clickable-icon)` rule. Sandbox-only chrome lives in
|
|
44
|
+
`src/sandbox/sandbox.css`.
|
|
45
|
+
|
|
46
|
+
For the template-writing rules and Arrow's hard footguns, use the companion
|
|
47
|
+
skill **arrow-js-obsidian-templates**.
|
|
48
|
+
|
|
49
|
+
## Verify before claiming done
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
pnpm typecheck && pnpm test && pnpm lint # or: pnpm check
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Then open the `pnpm dev` URL and confirm the console is clean and the component
|
|
56
|
+
looks like a real Obsidian pane. Arrow's footguns only surface at render, so
|
|
57
|
+
typecheck passing is **not** proof a component works.
|
|
58
|
+
|
|
59
|
+
## Port into the plugin
|
|
60
|
+
|
|
61
|
+
Copy the component file into the plugin's view directory and mount it from
|
|
62
|
+
`ItemView.onOpen()` via `template(this.contentEl)`. If it uses `boundary()` /
|
|
63
|
+
async components, add `@arrow-js/framework` to the plugin and the side-effect
|
|
64
|
+
`import '@arrow-js/framework'`. Leave sandbox chrome (`src/sandbox/*`) behind.
|