| name | obsidian-arrow-stories |
| description | Use when creating, updating, testing, or viewing components and stories in obsidian-arrow-sandbox. Covers the full authoring workflow โ building Arrow components with Obsidian classes and oas-* utilities, creating *.stories.ts files in stories/views/ or stories/components/ (not src/), the complete defineStories API (variants, children, status, kind, readableWidth, decorator, per-variant notes), viewing the story viewer at /components, keeping code DRY through sub-component extraction, and knowing when to reach for utilities vs. Obsidian's own classes. |
Component & Story Authoring
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";
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.
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}" not class="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";
export default defineStories({
description: "One-line description shown in the viewer and on the Components index.",
status: "draft",
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({
title: string;
description?: string;
status?: "live" | "draft";
variants: Record<string, (() => ArrowExpression) | { render: () => ArrowExpression; notes?: string }>;
kind?: "view" | "component";
readableWidth?: boolean;
decorator?: (content: ArrowExpression) => ArrowExpression;
children?: string[];
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 in notes on the default variant.
- 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
notes to 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:
import { defineStories } from "../../tools/viewer/stories";
import { MyThing } from "../../src/components/MyThing";
export default defineStories({
variants: { default: () => MyThing() },
children: ["toggle-row", "status-badge"],
});
import { defineStories } from "../../tools/viewer/stories";
import { ToggleRow } from "../../src/components/MyThing";
export default defineStories({
componentPath: "src/components/MyThing.ts",
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
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
pnpm test
pnpm lint
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.
html`<div class="oas-flex oas-items-center oas-gap-2">
<span class="oas-text-muted">${label}</span>
<span class="badge">${count}</span>
</div>`
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 in reactive() โ a
reactive proxy wraps the DOM node and positioning silently no-ops. Resolve it in
the handler with e.target.closest("button").
interactive: () => {
const s = reactive({ open: false });
let anchor: HTMLElement | null = null;
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 own requestAnimationFrame.
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-view root with oas-shell-view-header,
oas-shell-view-body (scrollable), and oas-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) โ
shows oas-shell-panel as 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 a mock-data.ts
file as constants.
- No
as unknown as ArrowTemplate/ArrowExpression double-casts. Arrow.js 1.x
does not support raw Node insertion in template expressions โ it falls through
to createTextNode(String(value)). Use queueMicrotask to mount imperative
widgets after Arrow commits the DOM (see DiffViewer/DiffViewer.ts for the
pattern). The test suite checks all of src/ for this cast.