The sandbox has a built-in Storybook-style viewer. Each component gets a
*.stories.ts file in stories/views/ or stories/components/ — no registration,
no imports to update — and it appears at /components/<slug> automatically
via import.meta.glob.
Workflow in order
1. Check /reference/classes → does Obsidian already have a class for your pattern?
2. Write src/components/MyThing.ts (Arrow component)
3. Write stories/MyThing.stories.ts (story file — in stories/, not src/)
4. pnpm dev → /components → click the story, verify in browser
5. Add variants for meaningful state differences
6. Extract sub-components; link them via children
7. Set status: "live" when ready to port
8. pnpm run ci → clean
1. Before you write CSS — check the class reference
Open /reference/classes in the running sandbox. Obsidian has semantic classes
for most common patterns: settings rows (.setting-item), modals (.modal),
toggles (.checkbox-container), navigation (.vertical-tab-nav-item),
callouts, tags, suggestion lists, file tree rows, and more.
Use Obsidian's class first. Add oas-* utilities second. Write custom CSS last.
If a gap remains after Obsidian's classes and the oas-* utilities, add a
single scoped rule to tools/sandbox/sandbox.css (sandbox chrome) or a
co-located component CSS file (e.g. src/components/MyThing.css),
namespaced: .my-panel button.my-action { … }.
2. Writing the component
src/components/MyThing.ts — a plain Arrow export, no sandbox-specific wiring:
import { component, html, reactive } from "@arrow-js/core";
import type { ArrowTemplate } from "@arrow-js/core";
// Module-level state persists across navigations (fine for simple toggles).
// For state that must reset per-mount, put it inside the component() factory.
const state = reactive({ on: false });
export const MyThing = component((): ArrowTemplate => {
return html`
<div class="setting-item">
<div class="setting-item-info">
<div class="setting-item-name">My setting</div>
<div class="setting-item-description">
${() => (state.on ? "Enabled" : "Disabled")}
</div>
</div>
<div class="setting-item-control">
<div
class="${() => (state.on ? "checkbox-container is-enabled" : "checkbox-container")}"
@click="${() => { state.on = !state.on; }}"
>
<input type="checkbox" .checked="${() => state.on}" />
</div>
</div>
</div>
`;
});
Layout and spacing: reach for oas-* utilities before writing inline styles
or custom CSS. Utilities live in src/utilities.css and cover flex layout, gaps,
padding, margin, typography, overflow, and borders — all on Obsidian's token scale.
// Instead of: style="display: flex; align-items: center; gap: 8px;"
html`<div class="oas-flex oas-items-center oas-gap-2">…</div>`
Arrow footguns (always apply):
- No HTML comments inside
html\`templates (use//` outside) - Attribute expressions must be the entire value:
class="${expr}"notclass="static ${expr}" ${data.x}is static (renders once);${() => data.x}is reactive- Event handlers type as
(e: Event), not narrowed subtypes
3. The stories file
Stories live in the top-level stories/ directory — never in src/. src/ is
component code only; stories/ is user-owned and never touched by the update command.
- Views (full-pane):
stories/views/MyView.stories.ts - Components (primitives):
stories/components/MyThing.stories.ts
The kind field ("view" | "component") is auto-detected from the path —
stories/views/ → "view", stories/components/ → "component". Set it
explicitly when the auto-detection doesn't match the intent.
Readable line width — the readableWidth field
Views are full-bleed by default. Set readableWidth: true when the view
should render at readable line length — note/document/reader views where
long-form text should cap at --file-line-width (700px) and center.
readableWidth is not a kind or surface; it is an independent display hint
that only applies to kind: "view" stories. It tells the sandbox to open the
pane wider (~820px) so the centering is visible; the width itself comes from the
component's oas-readable-width wrapper, not from this flag.
You do not hand-write the width chrome. Scaffold a readable-width view with
oasbox generate view NoteView --editor (or pnpm create:view NoteView --editor —
the script delegates to oasbox) — it wraps the view body in the portable
oas-readable-width utility (from src/utilities.css) and sets
readableWidth: true in the story. Because the wrapper lives in the component and
utilities.css ports with it, the sandbox and the plugin render the same width —
no parity gap. To make an existing view readable-width, wrap its content in
<div class="oas-readable-width">…</div> and add readableWidth: true.
import { defineStories } from "../../tools/viewer/stories";
import { MyThing } from "../../src/components/MyThing";
// For a view: import { MyView } from "../../src/views/MyView/MyView";
export default defineStories({
description: "One-line description shown in the viewer and on the Components index.",
status: "draft", // "live" when ready to port; "draft" while iterating
variants: {
default: () => MyThing(),
},
});
Import depth follows the story's nesting: a story directly in
stories/components/ or stories/views/ uses ../../; a story nested one level
deeper (e.g. stories/views/MyView/MyView.stories.ts) uses ../../../.
Full defineStories interface
defineStories({
/** Display title — always set explicitly; do not rely on filename derivation.
* Format: "Group / ComponentName" where ComponentName is PascalCase.
* Established groups (use these; don't invent new ones without good reason):
* "Structural" — ALL primitives and composed components (Button, ListItem, Popover,
* SearchField, FilterInput, Tab, TabBar, …). Use this for anything
* that is a reusable building block, regardless of what it composes.
* "Popover" — Interactive Button+Popover usage PATTERNS (Picker). Not the Popover
* primitive itself — that lives in Structural. Use Popover/ for stories
* where the whole point is demonstrating the Button→open→Popover flow.
* "Controls" — interactive form controls (Toggle)
* "Settings" — settings-panel components (SettingsPanel)
* "Editor" — editor/diff components (DiffViewer)
* "Example" — reference/demo views (ExampleView)
* Views (stories/views/) use the same "Group / Name" pattern with kind: "view". */
title: string;
/** One-liner shown in the sidebar, Components index, and story header. */
description?: string;
/** "live" = production-ready (ready to port). "draft" = still iterating.
* Defaults to "draft". Shown as a badge in the story header and index. */
status?: "live" | "draft";
/** Named variants — each renders independently. Keys are human strings.
* Bare function shorthand: variantName: () => MyThing(props)
* With notes: variantName: { render: () => MyThing(props), notes: "…" } */
variants: Record<string, (() => ArrowExpression) | { render: () => ArrowExpression; notes?: string }>;
/** "view" | "component" — auto-detected from the stories/ subdirectory
* (stories/views/ → "view", stories/components/ → "component").
* Set explicitly to override auto-detection. */
kind?: "view" | "component";
/** Views only. `true` = readable line length (Obsidian's setting — long-form
* text capped at `--file-line-width` and centered). NOT a kind or surface.
* The sandbox widens the pane so the centering is visible; the width itself
* comes from the component's `oas-readable-width` wrapper, so it ports 1:1.
* Scaffold with `oasbox generate view <Name> --editor` (`--editor` sets this). */
readableWidth?: boolean;
/** Wraps each rendered variant in ancestor context — use when the component
* requires a parent class to render correctly (e.g. a shell class that
* activates its scoped CSS). Signature:
* (content: ArrowExpression) => ArrowExpression */
decorator?: (content: ArrowExpression) => ArrowExpression;
/** Slugs of sub-component stories to drill into. Creates linked navigation
* in the viewer and the Components index. */
children?: string[];
/** Repo-relative path override — use when the component is defined inside
* another file (e.g. a sub-component exported from its parent's file).
* Defaults to the stories file path with `.stories` removed. */
componentPath?: string;
})
Variants — when to add them
Add a variant for each meaningfully different state a consumer needs to see:
variants: {
"default": () => Toggle(() => false, () => {}),
"enabled": () => Toggle(() => true, () => {}),
"interactive": () => {
const s = reactive({ on: false });
return Toggle(() => s.on, () => { s.on = !s.on; });
},
},
Don't add variants for every prop permutation — only the ones that look different or expose a real edge case. Notes explain nuance:
"read-only": {
render: () => Toggle(() => true, () => {}),
notes: "Click does nothing — demonstrates static on state for display-only use.",
},
When NOT to use a variant — use notes instead
Some states cannot be reliably captured as a static story variant:
- Clipboard / external API dependent state —
navigator.clipboard.writeText()succeeds only in a secure, focused browser context. A"copied"variant will fail or hang in headless/automated contexts. Document the behavior innoteson thedefaultvariant. - Self-reverting state — if a state automatically reverts after N ms (a toast, a
"copied" checkmark, an animation), a static variant can never durably represent it.
Use
notesto document the timing behavior. - Permission-gated state — camera, microphone, geolocation require runtime grants that story rendering cannot reliably trigger.
For all of the above: the notes field on another variant is the right surface.
Before extracting a primitive — composition analysis
Before writing any primitive or deciding something belongs in src/components/, do a
survey of all existing implementations. Do not design from conceptual similarity.
Required workflow:
-
Survey first. Read every hand-rolled component in scope — all of them, not just the obvious candidates.
-
Map shared shapes at the template level. "These do similar things" is not enough. "These produce identical DOM structure and CSS class patterns" is the signal. List the actual repeated template fragments.
-
Let the primitive emerge from the survey. The shared code IS the interface. Design from what actually recurs in the code, not from a concept of what should recur. If you cannot point to two concrete identical fragments, there is no primitive yet.
-
Extract and migrate simultaneously. Extract from one consumer, migrate the second in the same session — before the session ends. A primitive without live consumers is dead code waiting to be deleted.
-
Verify the result is smaller. The primitive + consumers must be simpler than the hand-rolled originals combined. If the primitive has more parameters than the originals had lines of duplication, the abstraction is wrong — go back to the survey.
Children — linking sub-components
If MyThing internally renders ToggleRow and StatusBadge, register those
as separate stories and link them:
// stories/components/MyThing.stories.ts
import { defineStories } from "../../tools/viewer/stories";
import { MyThing } from "../../src/components/MyThing";
export default defineStories({
variants: { default: () => MyThing() },
children: ["toggle-row", "status-badge"], // slugs of the sub-component stories
});
// stories/components/ToggleRow.stories.ts
import { defineStories } from "../../tools/viewer/stories";
import { ToggleRow } from "../../src/components/MyThing";
export default defineStories({
componentPath: "src/components/MyThing.ts", // lives in the same file as MyThing
variants: { default: () => ToggleRow(…) },
});
The viewer shows child links below the variant tabs; the Components index shows "Sub-components:" inline.
4. Viewing and testing
pnpm dev
# open http://localhost:5173/components → Components index (all stories)
# open http://localhost:5173/components/my-thing → story for MyThing
# open http://localhost:5173/reference → live Obsidian token reference
# open http://localhost:5173/reference/classes → curated Obsidian class catalog
The sidebar lists all stories alphabetically. Click a story → use the variant tabs to switch states. The file path shown in the viewer is derived from the stories file location — no hand-maintenance.
Verify before marking done:
pnpm typecheck # Arrow footguns + type errors
pnpm test # 32 baseline tests; add your own if the component has logic
pnpm lint # Biome
Then look at the browser — typecheck passing is not proof a component works. Arrow's footguns only surface at render time. Check the console is clean.
5. Keeping code DRY
Extract sub-components early
Any template fragment that's used more than once or has its own state should be
its own exported function (not component() unless it needs per-instance
reactive state). Give it a story via children.
// ❌ repeated inline block
html`<div class="oas-flex oas-items-center oas-gap-2">
<span class="oas-text-muted">${label}</span>
<span class="badge">${count}</span>
</div>`
// ✓ extracted
export function CountRow(label: string, count: number): ArrowExpression {
return html`<div class="oas-flex oas-items-center oas-gap-2">
<span class="oas-text-muted">${label}</span>
<span class="badge">${count}</span>
</div>`;
}
Use utilities, not inline styles
src/utilities.css covers the common layout needs on Obsidian's token scale.
Prefer class composition over style="…" attributes in templates.
// ❌
html`<div style="display:flex;align-items:center;gap:var(--size-4-2);">`
// ✓
html`<div class="oas-flex oas-items-center oas-gap-2">`
Module-level vs. per-instance state
- Module-level
reactive()— persists across navigations; fine for settings panels and simple toggles where reset-on-navigate would be surprising. - Inside
component()factory — fresh per mount; required when two instances of the same component must be independent.
The TokensPage search query is intentionally module-level so the filter
survives route changes. A modal open/closed state should be per-instance.
Interactive stories with floating UI (popovers, menus)
Reuse the shared Popover primitive (src/components/Popover/Popover.ts) — one
shell with list / filterable / grouped / action variants and a built-in
positioning + dismiss controller. Trigger it from the Button primitive. Give a
story both static variants (open, position: { mode: "inline" }) and an
interactive one (a Button toggles isOpen, position: { mode: "anchor", … }).
Two rules make interactive floating stories actually work (see
docs/arrow-notes.md):
- Keep the trigger element in a plain closure
let, never inreactive()— a reactive proxy wraps the DOM node and positioning silently no-ops. Resolve it in the handler withe.target.closest("button").
interactive: () => {
const s = reactive({ open: false });
let anchor: HTMLElement | null = null; // NOT in reactive()
return html`
${Button({ label: () => s.model, trailingIcon: "chevron-down", variant: "ghost",
onClick: (e) => { anchor = (e.target as HTMLElement).closest("button"); s.open = !s.open; } })}
${Popover({ isOpen: () => s.open, onDismiss: () => { s.open = false; },
position: { mode: "anchor", anchor: () => anchor, placement: "bottom", align: "start" },
variant: { kind: "filterable", /* … */ } })}`;
},
- Popovers position via
nextTick(microtask), so they position correctly even when the sandbox tab is not focused — don't wire your ownrequestAnimationFrame.
6. Setting status
| Value | When | Effect |
|---|---|---|
"draft" |
Default — still iterating | Gray "draft" badge in viewer + index |
"live" |
Ready to port, behavior stable | Green "live" badge; plan screenshot |
Mark "live" when: the component passes pnpm run ci, looks correct in the
browser in both themes, and you're satisfied with the variant coverage.
Quick reference
import { defineStories } from "../../tools/viewer/stories";
import { MyThing, MySubThing } from "../../src/components/MyThing";
export default defineStories({
description: "Settings row with expandable detail.",
status: "live",
variants: {
collapsed: () => MyThing({ expanded: false }),
expanded: () => MyThing({ expanded: true }),
interactive: () => {
const s = reactive({ expanded: false });
return MyThing({ expanded: () => s.expanded, onToggle: () => { s.expanded = !s.expanded; } });
},
},
children: ["my-sub-thing"],
});
Shell stories — starting points for new views and components
Two live stories ship with the scaffold as starting points:
-
Shells / View Shell (
stories/components/ViewShell.stories.ts) — shows the standard view structure:oas-shell-viewroot withoas-shell-view-header,oas-shell-view-body(scrollable), andoas-shell-view-footer(pinned). You rarely need to copy this by hand —oasbox generate view <Name>scaffolds a view already wired with all three zones. Delete the header or footer zone if the view doesn't need it; this story stays as the canonical reference. -
Shells / Component Shell (
stories/components/ComponentShell.stories.ts) — showsoas-shell-panelas a card wrapper for standalone components.
Both classes are in src/utilities.css (portable — they go with the component into the plugin).
Story data hygiene
- Static mock data only. Never call
Date.now()inside a variant render function — the test suite checks for this. Put timestamps in amock-data.tsfile as constants. - No
as unknown as ArrowTemplate/ArrowExpressiondouble-casts. Arrow.js 1.x does not support raw Node insertion in template expressions — it falls through tocreateTextNode(String(value)). UsequeueMicrotaskto mount imperative widgets after Arrow commits the DOM (seeDiffViewer/DiffViewer.tsfor the pattern). The test suite checks all ofsrc/for this cast.