SuperAPI AI Directory

arrow-js-obsidian-patterns

Frontend & websites

View repository

Updated 10-15 23:48

About this skill

Arrow.js + Obsidian integration patterns

How to build real components (not just template syntax) for an Obsidian plugin view with @arrow-js/core. Pairs with arrow-js-obsidian-templates (the template rules + footguns).

Mount / unmount lifecycle

A view mounts a component imperatively, the same call the sandbox makes:

import { html } from "@arrow-js/core"
// in ItemView.onOpen():
this.contentEl.empty()
html`${MyComponent()}`(this.contentEl)
  • html\`(container)does **not** reliably return an unmount function — don't gate cleanup on its return value. Alwayscontainer.empty()` before remounting.
  • Re-rendering on route/state change: clear the container, then mount fresh. Reactive bindings (${() => …}) update in place — don't remount the whole tree on every state change; let Arrow patch the slots that read changed state.

Shared reactive state

One reactive() object is the single source of truth; components read it via getters so they stay in sync without prop-drilling.

import { reactive } from "@arrow-js/core"
export const state = reactive({ model: "", streaming: false, items: [] })
// pass getters into components: Toggle(() => state.streaming, () => { ... })

Reactivity & imperative DOM (floating UI) gotchas

Runtime traps that typecheck clean — hit them building popovers/menus. Full write-up with code: docs/arrow-notes.md.

  • Never put a DOM node (or class instance / function) in reactive(). It gets deep-proxied, so you get a Proxy of the element back — geometry reads misbehave and positioning silently no-ops. Keep DOM refs (e.g. a popover's anchor/trigger) in a plain closure variable; only put serialisable data in reactive(). Resolve a trigger in the handler via e.target.closest("…"); type handlers (e: Event).
  • requestAnimationFrame is paused in background/unfocused tabs — a popover that positions or wires its dismiss inside a rAF silently never runs (intermittently). Use a microtask (nextTick / queueMicrotask / setTimeout) for any correctness-critical DOM work; reserve rAF for skippable visual animation.
  • Always-mount floating elements; toggle an is-hidden class rather than conditionally creating them. A conditionally-rendered nested html\`may not be committed whennextTick fires (getElementById→null`); an always-mounted box is committed once, so lookups are reliable and there's no race. (Mirrors the plugin's session popover.)
  • Drive open/close side-effects from an attribute (class) getter, not watch or a side-effect-only slot. Both watch(() => state.x, cb) and a trailing ${() => { fx(); return "" }} slot were observed not to re-run. A class/style getter re-runs reliably (Arrow must recompute the attribute) — co-locate the effect there, guarded to be idempotent.
  • Floating controller: one shared controller, position: fixed, positioned off the trigger's getBoundingClientRect(), clamped to bounds, flipped on overflow; reposition on scroll/resize; wire dismiss (click-outside/Escape) synchronously from the positioning nextTick, and ignore clicks on the trigger so a toggle closes cleanly.

Icons (Obsidian uses Lucide)

setIcon(el, name) needs a real DOM element, so it cannot be called inside an Arrow template expression (the element doesn't exist yet). Two options:

  1. In-plugin — the data-icon sweep. Emit a placeholder, then sweep after mount:

    html`<span class="svg-icon" data-icon="copy"></span>`
    // after template(container) — scope the query to the container, not document:
    for (const el of Array.from(container.querySelectorAll<HTMLElement>("[data-icon]"))) {
      const name = el.getAttribute("data-icon")
      if (name) setIcon(el, name)  // setIcon from "obsidian"
    }
    

    For sections that open after mount (dropdowns/popovers), run the sweep in a nextTick(...) after they render, scoped to that section's element.

  2. In the sandbox (no obsidian module). Use icon(name) from src/components/icons.ts — it returns a glyph string for common Lucide names and is shim-free. Always import from icons.ts; never define a local ICON_MAP in component code. When porting to the plugin, add a Lucide-backed setIcon shim (aliased as obsidian) and swap the icon() calls to setIcon.

    import { icon } from "./icons";       // sandbox: glyph string
    // html`<span>${icon("search")}</span>`  → "⌕"
    

    Available names: check, x, chevron-right, chevron-down, loader, search, file, folder, info, warning, error.

CSS scoping & specificity (the big one)

Obsidian's app.css applies global rules like button:not(.clickable-icon) { background: var(--interactive-normal) } at specificity (0,1,1). A plain .my-btn selector is (0,1,0) and loses regardless of cascade order.

/* (0,1,0) — LOSES to Obsidian's global button rule */
.my-action { background: var(--interactive-accent); }

/* (0,2,1) — WINS, and can't leak */
.my-panel button.my-action { background: var(--interactive-accent); }

Rules: prefer Obsidian's own classes (.setting-item, .clickable-icon, .workspace-leaf, .vertical-tab-*, .modal) and var(--…) tokens first; only add custom CSS where there's no Obsidian class; always scope custom rules under a container class + element type.

Component composition analysis

Before extracting any primitive, survey all existing implementations. The CaretPopover failure pattern — designing from conceptual similarity without studying shared template structure — produces primitives that don't fit real consumers and must be deleted.

The correct sequence:

  1. Read every hand-rolled implementation in the area. All of them.
  2. Find repeated DOM structures and CSS class patterns — not conceptual similarities.
  3. Design the primitive from those actual shared fragments. If you can't point to two identical code fragments, there is no primitive yet.
  4. Extract from one consumer, migrate the second immediately in the same session.
  5. The primitive + consumers must be shorter than the originals. If not, the abstraction is wrong — return to the survey.

What this replaces: "only extract when 2+ consumers are ready." That's a symptom fix. The real fix is doing the survey before designing anything.

Common Obsidian layout classes

  • .workspace-leaf > .workspace-leaf-content — the pane/tab container that holds a View (WorkspaceLeaf).
  • .view-header / .view-content — pane header + body.
  • .setting-item (+ .setting-item-info / .setting-item-name / .setting-item-description / .setting-item-control) — settings rows.
  • .checkbox-container (+ .is-enabled) — toggle.
  • .clickable-icon — icon button (escapes the global button background rule).
  • .vertical-tab-header / .vertical-tab-nav-item (+ .is-active) — tab nav.
  • .modal-container > .modal — modal/popover.
  • .mod-cta — primary call-to-action button.