SuperAPI AI Directory

obsidian-arrow-stories

Testing & quality

View repository

Updated 10-15 23:48

About this skill

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";

// 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}" 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";
// 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 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:

  1. Survey first. Read every hand-rolled component in scope — all of them, not just the obvious candidates.

  2. 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.

  3. 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.

  4. 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.

  5. 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 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;               // 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 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.