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.
Files changed (38) hide show
  1. package/README.md +47 -0
  2. package/index.mjs +76 -0
  3. package/package.json +40 -0
  4. package/template/.github/workflows/ci.yml +36 -0
  5. package/template/.husky/pre-commit +2 -0
  6. package/template/AGENTS.md +90 -0
  7. package/template/LICENSE +21 -0
  8. package/template/README.md +116 -0
  9. package/template/_gitignore +8 -0
  10. package/template/biome.json +30 -0
  11. package/template/docs/prompts/agent-setup.md +94 -0
  12. package/template/docs/superpowers/specs/2026-06-29-obsidian-arrow-sandbox-design.md +206 -0
  13. package/template/index.html +19 -0
  14. package/template/package.json +43 -0
  15. package/template/pnpm-lock.yaml +1408 -0
  16. package/template/scripts/install-skills.mjs +47 -0
  17. package/template/scripts/lib/extract-app-css.mjs +60 -0
  18. package/template/scripts/pull-app-css.mjs +90 -0
  19. package/template/skills/arrow-js-obsidian-patterns/SKILL.md +94 -0
  20. package/template/skills/arrow-js-obsidian-templates/SKILL.md +101 -0
  21. package/template/skills/obsidian-arrow-sandbox/SKILL.md +64 -0
  22. package/template/src/components/SettingsPanel.ts +232 -0
  23. package/template/src/data/loadStatus.ts +17 -0
  24. package/template/src/examples/ExamplesIndex.ts +36 -0
  25. package/template/src/examples/registry.ts +26 -0
  26. package/template/src/main.ts +18 -0
  27. package/template/src/router/client.ts +85 -0
  28. package/template/src/router/routeToPage.ts +57 -0
  29. package/template/src/sandbox/frame.ts +35 -0
  30. package/template/src/sandbox/layout.ts +40 -0
  31. package/template/src/sandbox/sandbox.css +125 -0
  32. package/template/src/sandbox/shell.ts +15 -0
  33. package/template/src/sandbox/theme.ts +22 -0
  34. package/template/src/sandbox/toolbar.ts +32 -0
  35. package/template/test/extract-app-css.test.mjs +70 -0
  36. package/template/test/template-footguns.test.mjs +58 -0
  37. package/template/tsconfig.json +13 -0
  38. 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.