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