| name | obsidian-arrow-sandbox |
| 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. For the full component + story authoring workflow, see the obsidian-arrow-stories skill. |
Obsidian Arrow Sandbox
A client-only Vite + TypeScript environment for building Arrow.js
UI that drops into an Obsidian plugin. Components render against Obsidian's real
app.css, so what you see in the browser is what you get in a plugin view.
Mental model
- Client-only, no SSR. Components use
@arrow-js/core (reactive, html,
component, watch) plus @arrow-js/framework (boundary for async
sections), mounted via template(container) — the exact call an Obsidian
ItemView.onOpen() makes. Do not add @arrow-js/ssr or @arrow-js/hydrate;
an Obsidian plugin has no server and nothing to hydrate.
- Styling is Obsidian's, not yours.
index.html puts Obsidian body classes
(theme-dark mod-macos …) on <body> and loads the extracted app.css, which
defines every var(--…) token and semantic class (.setting-item,
.clickable-icon, .vertical-tab-*).
Run it
pnpm install
pnpm pull-css
pnpm dev
pnpm pull-css reads app.css out of obsidian.asar. macOS is auto-detected;
elsewhere pass --path <obsidian.asar|app.css> or set OBSIDIAN_ASAR=<path>.
public/app.css is git-ignored (Obsidian's proprietary CSS — not
redistributed), so run pnpm pull-css once before pnpm dev.
Install the skills
Pulled from the published repo (not vendored into scaffolds), so always current:
pnpm skills:install --yes
pnpm skills:install
pnpm skills:update
Scope flags: --agent <name>, --project-dir=<path> (install into another repo
root — use this when the project is nested inside a larger repo), --global.
To update an existing project's tooling later, see the obsidian-arrow-maintenance
skill (npx create-obsidian-arrow refresh).
CSS prefix conventions
| Prefix | Where it lives | Use in component code? |
|---|
oas-* | src/utilities.css | Yes — portable, ships with ports |
oas-shell-* | src/utilities.css | Yes — portable view/component shells |
oasbox-* | tools/sandbox/sandbox.css | No — sandbox chrome only |
Never use oasbox-* classes in component or view code. They are sandbox chrome
(frame, toolbar, sidebar) and do not exist in real plugin views. Note: oasbox-*
is the CSS prefix for sandbox chrome — it is distinct from the oasbox CLI tool.
Scaffolding new components and views
Use oasbox generate (the in-project CLI, installed as a devDependency) or the
create:view / create:component npm scripts (which delegate to oasbox generate):
oasbox generate component MyThing
oasbox generate component MyThing --css
oasbox generate component Parent/Child
oasbox generate view MyView
oasbox generate view NoteView --editor
oasbox (CLI) vs oasbox-* (CSS classes) — not the same thing.
oasbox is the in-project command-line tool (packages/oasbox). oasbox-*
classes (e.g. oasbox-frame, oasbox-toolbar) are sandbox chrome — they
live in tools/sandbox/sandbox.css and style the viewer frame. Never use
oasbox-* classes in component or view code; they don't exist in real plugin
views. The CLI and the CSS prefix are unrelated beyond sharing the project name.
Folder rule: a component gets a folder (src/components/MyThing/) when it has a co-located .css file. A component with no custom CSS stays as a flat .ts file (src/components/MyThing.ts).
Lint scripts
Three focused checks (all included in pnpm check):
pnpm check:css
pnpm check:scope
pnpm check:imports
What violations look like:
check:css → orphaned: src/components/Foo.css (no importers) — a CSS file that no TS file imports. Fix: add import "./Foo.css" to the component.
check:scope → missing ancestor: .my-shell used in Foo.css but not found in src/**/*.ts — CSS uses a class as an ancestor selector but no template applies it. Fix: add the class to the root element in the component template.
check:imports → tools/viewer/Foo.ts imports src/components/Bar — tool code must not depend on component code. Fix: move shared logic to a utility or reverse the dependency direction.
Two-pane viewer layout
The viewer renders differently based on story kind (auto-detected from path):
kind: "component" (stories/components/**) — left pane = metadata panel (title, variant tabs, copy buttons), right canvas = component rendered centered in the stage.
kind: "view" (stories/views/**) — full pane in the frame, no separate canvas. StoryPageDetails + StoryPageCanvas render together as a scrollable page.
To force a kind that differs from path convention: set kind: "view" or kind: "component" explicitly in defineStories().
Cold-load verification
Always cold-load after CSS changes. Vite's dev server keeps module state across in-tab SPA navigation — navigating between stories reuses the live module graph and masks orphaned CSS bugs. Close the tab entirely and reopen the URL.
What to assert after a cold load:
getComputedStyle(document.querySelector('.oas-shell-view')).flexDirection
getComputedStyle(document.querySelector('.oas-shell-panel')).display
Console clean + typecheck alone do not prove styles are applied.
/components and /views index pages
/components — shows all kind: "component" stories with live/draft badges and Open links
/views — shows all kind: "view" stories with live/draft badges and Open links
Both index pages are in the sidebar under "Sandbox". The sidebar has been simplified to top-level nav links — Components, Views, Tokens, Classes — rather than an individual story tree.
Build a component
Add src/components/MyThing.ts exporting an Arrow component(). Use Obsidian
classes + var(--…) tokens first; reach for oas-* utility classes
(src/utilities.css) for layout and spacing; add scoped custom CSS only when
neither covers it. Sandbox-only chrome lives in tools/sandbox/sandbox.css.
Create stories/components/MyThing.stories.ts (or stories/views/ for a view)
— stories live in the top-level stories/ directory, keeping src/ purely for
components. It appears at /components/<slug> automatically. Import depth
follows nesting: a story in stories/components/ uses ../../; a story nested
one deeper (e.g. stories/views/MyView/MyView.stories.ts) uses ../../../.
Minimal example:
import { defineStories } from "../../tools/viewer/stories";
import { MyThing } from "../../src/components/MyThing";
export default defineStories({
description: "What it demonstrates.",
status: "draft",
variants: { default: () => MyThing() },
});
Full defineStories options (variants, children, status, notes, componentPath)
and the complete authoring workflow are in the obsidian-arrow-stories skill.
Browse all var(--) tokens and curated Obsidian pattern classes at /reference.
For the template-writing rules and Arrow's hard footguns, use the companion
skill arrow-js-obsidian-templates.
Verify before claiming done
pnpm typecheck
pnpm test
pnpm lint
pnpm check
Then confirm the actual render:
-
Cold-load required. Close and reopen the browser tab — do not just navigate
within the SPA. Vite's dev server keeps CSS module state across in-tab navigation,
which masks orphaned CSS and missing ancestor classes until a genuine cold load.
-
Check a computed style. For each CSS change, assert at least one computed property
(display, flexDirection, padding) in the browser console — element count and
console-clean alone do not prove styles are applied:
getComputedStyle(document.querySelector('.my-class')).display
-
Console clean. No [viewer] warnings, no Arrow errors, no unhandled rejections.
Do not claim a component works on typecheck alone. Arrow's footguns and CSS adoption bugs
only surface at render time.
Port into the plugin
Copy the component file into the plugin's view directory and mount it from
ItemView.onOpen() via template(this.contentEl). If it uses boundary() /
async components, add @arrow-js/framework to the plugin and the side-effect
import '@arrow-js/framework'. Also copy src/utilities.css into the plugin
once — components may use oas--prefixed utility classes (flex, gap, padding,
typography, border helpers built on Obsidian's token scale). The prefix prevents
any conflict with Obsidian's selectors; all ported components share one copy.
Leave sandbox chrome (tools/sandbox/*) behind.