<!-- kerf-skill-version: 1.14.2 -->
# kerf.cursorrules — rules for building apps with kerf
#
# Drop this file into your project as `.cursorrules` (Cursor will pick it
# up automatically) when you're using kerf (https://github.com/brianwestphal/kerf).
# These rules condense `docs/ai/usage-guide.md` into the form Cursor parses.

You are writing a UI in kerf — a ~12 KB reactive framework (~13 KB with `arraySignal`): signals + DOM morphing + JSX → HTML strings. No virtual DOM, no compiler, no scheduler.

## Setup

- Install with `npm install kerfjs`.
- `tsconfig.json`: `"jsx": "react-jsx"`, `"jsxImportSource": "kerfjs"`.
- Vite / esbuild need no extra config.
- **Dev diagnostics are opt-in by import, and only an APP installs them.** kerf does not infer dev mode. In the app entry add `if (import.meta.env.DEV) await import('kerfjs/dev');` (Vite) or `if (process.env.NODE_ENV !== 'production') await import('kerfjs/dev');` (webpack/Node). That enables the read-only store `get()` snapshot, the throwing dangerous-URL screen, and makes the `KERF_DEV_WARN_*` family available; omitting it is production shape and sheds ~4.7 KB min+gzip because the condition folds away and the chunk is never emitted. Put it FIRST if relying on the untracked-signal warning — `signal()` picks its constructor at creation time.
  - **Switch individual warnings on with `enableWarnings()`**, which is the only switch that works in a browser (no `process` object there, and a bundler `define` cannot reach the read): `const dev = await import('kerfjs/dev'); dev.enableWarnings({ staleBinding: true, narrowSet: true, invariants: 'throw' });`. The `KERF_DEV_WARN_*` env vars do the same for Node/SSR/CI; an explicit call wins either way.
  - **A component package must NEVER import `kerfjs/dev`.** The hooks are process-global, so installing them is the consuming app's decision — a library that does it forces the diagnostics (and the chunk) on every consumer. Put the import in your demo page or test harness instead.
- Recommended: also install `eslint-plugin-kerfjs` (`npm install --save-dev eslint-plugin-kerfjs`) and add `kerfjs.configs.recommended` to the project's eslint config. It enforces five of the hard rules below (no inline JSX event handlers, require `data-key` in `each()`, capture `delegate()` disposers, no nested `mount()`, prefer module JSX augmentation) at edit time so violations surface as IDE squiggles before any code runs.

## Public API — one import path

```ts
import {
  signal, computed, effect, batch,    // reactivity
  defineStore, resetAllStores,        // stores
  mount, morph, each,                 // render (reactive + one-shot) + keyed list
  delegate, delegateCapture,          // events
  toElement,                          // direct JSX → DOM Element (or DocumentFragment for multi-root)
  SafeHtml, isSafeHtml, raw, Fragment,
} from 'kerfjs';

// Optional, only when you need granular collection updates:
import { arraySignal } from 'kerfjs/array-signal';

// Development diagnostics — gate with YOUR build's dev flag, in YOUR code.
if (import.meta.env.DEV) await import('kerfjs/dev');
```

| Export | Use |
| --- | --- |
| `signal(initial)` | atomic reactive state; read/write via `.value` |
| `computed(fn)` | derived value (read-only) |
| `effect(fn)` | side effect that re-runs on signal change |
| `batch(fn)` | coalesce multiple writes into one re-run |
| `defineStore({initial, actions})` | named multi-consumer state |
| `resetAllStores()` | reset every store (test teardown) |
| `mount(el, render)` | bind reactive render to a DOM element; returns a disposer |
| `morph(liveRoot, template)` | one-shot reconcile against an already-populated element (SSR hydration, page-refresh diffs). Template can be `Element`, `SafeHtml`, or HTML string |
| `each(items, render, cacheKey?)` | keyed list iteration; per-row memoization on object identity (+ optional cacheKey — a passive comparator for external state). Distinct from `data-key` on the rendered element |
| `each(items, render, { cacheKey, key })` | same, options form. **`key` gives the list a stable identity** — required whenever a *conditional* list can render before this one, else kerf rebuilds this list and its rows lose focus/scroll/IME. A keyed list takes no positional slot, so keying the conditional list usually fixes its siblings too |
| `delegate(root, type, sel, h)` | one listener at the root, walks `closest(selector)` from target |
| `delegateCapture(root, type, sel, h, opts?)` | capture-phase escape hatch; `closest()` walk-up by default (same as `delegate`); pass `{ match: 'direct' }` for strict `target.matches()` |
| `attr(name, value)` | pre-computed `AttrSpec<N,V>` — `.selector` for `delegate()`, `.attrs` to spread into JSX (rename-safe) |
| `attr(name)` | dynamic factory — `attr<N,V=string>(name)` returns `(value: V) => { readonly [name]: V }`; both generics off → N inferred, V defaults to string; specify both to constrain values |
| `toElement(jsx)` | parse JSX into a DOM node (SVG-aware). Single-root → `Element`; multi-root (`<><svg/> label</>`, two icons side by side) → `DocumentFragment` that `appendChild`/`replaceChildren`/`append` inlines into the parent. |
| `raw(html)` | inject pre-escaped HTML |
| `arraySignal(initial?)` | granular keyed-list signal at `kerfjs/array-signal` subpath; `each()` reconciles in O(patches) |
| `` html`…` `` | tagged template at `kerfjs/html` subpath — JSX-identical runtime semantics with NO build step (CDN/importmap projects). Real HTML attribute names (`class`, not `className`); holes only in text positions or as a COMPLETE attribute value (`attr=${v}` / `attr="${v}"`) |

## Hard rules — get these right on the first try

1. **JSX renders to HTML strings, not DOM nodes.** Don't pass DOM nodes as JSX children — the runtime throws. Need a ref? Build JSX, then `querySelector` after `mount()` / `toElement()`.
2. **Diff keys are `id` then `data-key`.** Lists must set `data-key={item.id}` per item, otherwise the diff matches by position and you lose focus / cursor / identity on insert/delete.
3. **Escape hatches:** `data-morph-skip` (element + subtree preserved verbatim — for Monaco / xterm / D3); `data-morph-skip-children` (attrs morph, subtree preserved — for client-hydrated slots whose host classes change); `data-morph-preserve` (element survives the trailing-removal pass — for imperatively-injected nodes like autoplay videos).
4. **Never `addEventListener` inside a `mount()`-managed tree** unless under `data-morph-skip`. A morph re-render may discard the node. Use `delegate` / `delegateCapture` instead.
5. **Capture the `delegate()` / `delegateCapture()` disposer** whenever the registration's scope is shorter than the page. Both helpers return `() => void`; the listener closure pins `rootEl`, `handler`, and everything the handler closes over (stores, signals, app state). Discarding the disposer on a transient root (modal, route view, mount swap, dynamic widget) leaks the listener AND the app graph it captures; re-mount cycles stack listeners linearly. `mount()`'s own disposer does NOT remove delegates for you. Safe to discard only when the registration is truly page-lifetime (root is `document.body` or equivalent, attached once at startup, never torn down).
6. **One `mount()` per root.** Don't nest. Compose with plain functions that return JSX.
7. **No `<MyComponent />` semantics with hooks.** Components are plain functions returning JSX. State lives in module-scope signals or stores, never in component closures.
8. **Values bind, structure re-renders.** For a value hole, pass the signal/computed ITSELF (`<span>{count}</span>`, `class={sig}`) — kerf updates that one node directly, no render re-run. Read `.value` only when the JSX *structure* depends on the signal — and then the read must happen INSIDE the render function to be tracked: `const x = count.value; mount(el, () => <span>{x}</span>)` will NOT re-render. Bind a STABLE signal/computed instance per hole (a `computed` that switches internally), never `class={cond ? sigA : sigB}` — switching instances can go silently stale (`KERF_DEV_WARN_STALE_BINDING=1` detects it). Endpoint: a render reading NO `.value` runs exactly once — a fully bound mount never re-renders; `KERF_DEV_WARN_VALUE_ONLY_RERENDER=1` flags re-renders that could have been bindings.
9. **Store actions receive `(set, get)`, not `(state)`.** `set(next)` replaces state; mutating `get()` does nothing.
10. **Use `data-action` attributes, not inline `onClick`.** Inline handlers are NOT supported by the JSX → string runtime; delegate from the root instead.
11. **`arraySignal` is opt-in for long keyed lists** where most updates are pointwise (single-row edits, append-to-end). For short lists / filter+sort pipelines, plain `signal` + `each(items.value, ...)` is simpler and equally fast.
12. **Custom-element types: declaration-merge into `kerfjs/jsx-runtime`**, NOT into a global JSX namespace. Pattern: `declare module 'kerfjs/jsx-runtime' { namespace JSX { interface IntrinsicElements { 'my-tag': KerfCustomElement & { foo?: string } } } }`.
13. **Each `each()` row must produce exactly one top-level element.** The reconciler binds one live DOM node per item — multi-root or empty rows throw with a row-precise error. Wrap multiple roots in one parent.
14. **`each()` is for DYNAMIC lists. Use `.map()` for static structural arrays** (constant `COLUMNS` / `TABS` / settings sections) whose row render reads signals. `each()` memoizes per-item HTML by object identity; constant items never change identity, so the cache hits forever, the row render is never re-invoked, and signal reads inside it silently stop tracking. Outer `.map()` for the static frame + inner `each()` for the dynamic sub-list is the idiomatic shape.

## Decision-making axes

When deciding which primitive to reach for, work down the axes:

**Events.**
- Originates inside the mount tree → `delegate(rootEl, type, sel, handler)`. Originates outside (window-level keyboard, online/offline, beforeunload) → native `window.addEventListener` at module top-level.
- Gesture that needs to follow an element after press (drag, draw, resize) → at the start event, `el.setPointerCapture(e.pointerId)`. Subsequent `pointermove` / `pointerup` redirect to the captured element and `delegate(rootEl, 'pointermove', '[data-card]', …)` still picks them up. Don't reach for `window.addEventListener` for in-mount-tree gestures.
- Well-known non-bubbler (`focus`, `blur`, `scroll`, `load`, `error`, `mouseenter`, `mouseleave`) → still `delegate()`; it auto-promotes to capture. Custom non-bubblers or capture-phase interception → `delegateCapture()` (also `closest()`-matched by default). Need strict element-match? Add `{ match: 'direct' }` on either helper.

**Lists.**
- Items change across renders (todos, chat messages, table rows) → `each(items, render)`.
- Static structural enumeration whose row render reads signals → `STATIC.map(item => <jsx/>)`. Inner `each(item.children, …)` still gets keyed reconcile.
- Long list with point-wise mutations → `arraySignal` + `each(arraySig, render)` for O(patches) updates.

**Side effects / imperative DOM.**
- Library-owned subtree survives across renders → `data-morph-skip` on host.
- Host attributes morph but subtree preserved → `data-morph-skip-children`.
- Imperatively-injected element survives the trailing-removal pass → `data-morph-preserve`.
- Focused input / contenteditable caret survives re-renders → automatic; no opt-in.

**Raw HTML.**
- User-controlled HTML → sanitize first (DOMPurify) then `raw(sanitized)`.
- Author-controlled trusted HTML → `raw(html)` directly.
- Dangerous URLs (`javascript:`/`vbscript:`/script-executing `data:`) on `href`/`src`/`xlink:href`/`formaction`/`action`/`data` are dropped — kerf THROWS in dev, WARNS + drops in prod. Sanitize user URLs upstream; wrap an intentional trusted one in `raw(url)` to bypass the screen in both modes. The `javascript:` no-op placeholders (`javascript:void(0)`, `javascript:;`, …) are allowed — they're the placeholder-link idiom, matched whole so nothing can ride along.

## Canonical patterns

```tsx
// Signal + mount. THE core idiom — values bind, structure re-renders:
// pass the signal ITSELF into a value hole ({count}, class={sig}) so kerf
// updates that one node directly with no render re-run; read `.value` only
// when the JSX STRUCTURE depends on the signal (conditionals, list shape).
const count = signal(0);
const ACTIONS = { inc: attr('data-action', 'inc') } as const satisfies Record<string, AttrSpec<'data-action'>>;

mount(document.getElementById('app')!, () => (
  <div>
    <button {...ACTIONS.inc.attrs}>+</button>
    <span>{count}</span>
  </div>
));
delegate(rootEl, 'click', ACTIONS.inc.selector, () => { count.value += 1; });

// Keyed list with per-item memoization
mount(listEl, () => (
  <ul>
    {each(rows.value, (row) => <li data-key={row.id}>{row.label}</li>)}
  </ul>
));

// Store
const cart = defineStore({
  initial: () => ({ items: [] as string[] }),
  actions: (set, get) => ({
    add:   (id: string) => set({ items: [...get().items, id] }),
    clear: ()           => set({ items: [] }),
  }),
});

// One-shot reconcile against existing DOM (no signals)
morph(liveCard, '<article class="card">…</article>');

// No build step (CDN / importmap): the html tagged template instead of JSX.
// Same runtime semantics as JSX; write real HTML attribute names; a hole must
// be a text position or a COMPLETE attribute value (partial values throw).
import { html } from 'kerfjs/html';
mount(rootEl, () => html`
  <div class="${cls}">Count: ${count}</div>
  <ul>${each(rows.value, (row) => html`<li data-key="${row.id}">${row.label}</li>`)}</ul>
`);

// Fine-grained binding (opt-in): pass the signal/computed ITSELF into a hole
// so a change updates ONLY that node (no render re-run, no reconcile). For a
// hot spot driven by an external signal (selection class) — not everywhere.
// Use computed(), never a bare () => ….
const selectedId = signal<number | null>(null);
mount(listEl, () => (
  <ul>
    {each(rows.value, (row) => (
      <li class={computed(() => (row.id === selectedId.value ? 'sel' : ''))}>{row.label}</li>
    ), (row) => row.id)}
  </ul>
));
```

## Common errors → fixes

- `JSX: DOM elements cannot be passed as children` → you passed a `toElement()` result inside JSX. Build the whole tree in JSX; get refs via `querySelector` after rendering.
- `draggable={true}` / `spellCheck={false}` / `contentEditable={false}` / `writingsuggestions={false}` / `translate={false}` / `autocorrect={false}` won't typecheck → these are HTML **enumerated** attributes, not boolean ones: they take keyword strings (`"true"` / `"false"`; `"yes"` / `"no"` for `translate`; `"on"` / `"off"` for `autocorrect`), and omitting one selects a third state, so the boolean form rendered the opposite of what was meant. Write the keyword (`draggable="true"`, `spellCheck="false"`, `writingsuggestions="false"`, `translate="no"`, `autocorrect="off"`) and omit the attribute for the default state. Real boolean attributes (`hidden`, `checked`, `disabled`, `autofocus`, `required`, `inert`) are unaffected, and so is `popover` (its bare form is the spec's `auto` state); for a signal, put the string in it (`signal('true')`).
- `<select value={x}>` / `<textarea value={x}>` won't typecheck → neither element has a `value` content attribute, so that markup was inert. Use `<option value="b" selected>` and `<textarea>{draft}</textarea>`.
- Focus / cursor lost on every keystroke → list items lack `data-key`. Add it.
- Click handler stops firing after re-render → `el.addEventListener` was used. Replace with `delegate(rootEl, 'click', ACTIONS.foo.selector, ...)` (or a string literal `'[data-action="foo"]'` for ad-hoc cases).
- Render fn never re-runs → signal was read outside the render fn. Move the `signal.value` read inside.
- SVG renders as broken / namespaceless markup → use `mount` (HTML path) or `toElement` (SVG-aware), not `innerHTML`.
- Library widget destroyed on every render → wrap host in `data-morph-skip`; mount the library imperatively after first render.
- `each(): row render at index N produced K top-level elements` → wrap multiple roots in one parent.
- Drag/drop / state change has no visible effect, only stuff *outside* `each()` updates → you used `each(STATIC_ARRAY, …)` whose row render reads signals. Replace the outer with `STATIC_ARRAY.map(...)`; keep inner `each()` for the dynamic sub-list. See Hard Rule 14.
- Row-enter CSS animation no longer replays when only a row's *content* changed (kerf ≥ 0.15.0) → 0.15.0+ morphs a same-identity, same-position row *in place* instead of recreating its node, so a mount-keyed `@keyframes` never re-triggers on a content-only update (≤ 0.14.x recreated the node, so it fired; the intentional flip side is that focus, scroll, IME, and in-progress transitions now survive). Key the animation on a state-class toggle, not element creation; to force a remount, churn the row's identity (new object ref / `data-key`).
- Want a hot spot to update without re-running the whole render → fine-grained binding: pass the signal/`computed` ITSELF into the attr/text hole (`class={computed(() => …)}`), not `.value`. Use `computed()` not a bare `() => …` (memoization keeps a shared-signal flip to ~O(changed nodes)). Opt-in per hole. Limit: a bound hole depending on the row's OWN mutated data goes stale on a granular in-place update — use plain interpolation there.
- `` html`` ``: partial attribute values are not supported → in `kerfjs/html` templates a hole must be the COMPLETE attribute value. Replace `class="a ${b}"` with a pre-built string (`` class="${`a ${b}`}" ``) or, for a bound attribute, `class="${computed(() => `a ${b.value}`)}"`.
- An `each()` list's rows lose focus / scroll / typing state when an unrelated conditional list above them appears or disappears (kerf warns in dev) → lists without a key are identified by position among the render's `each()` calls, so adding/removing one above shifts this list's identity and kerf rebuilds it. Give the lists stable keys: `each(items, render, { key: 'results' })`; keying just the conditional list is usually enough.
- Keyed `each()` list suddenly renders zero rows — only its `<!--kf-list:N-->` marker — with no errors, and it never recovers (kerfjs ≤ 2.0.1) → a conditionally-rendered sibling BEFORE the list (possibly higher in the tree, e.g. an error banner) was removed that render; older kerfjs rebuilt the shifted list container from the template, permanently detaching the list's internal binding. Upgrade kerfjs (fixed after 2.0.1 — the morph now moves the shifted container up in place, keeping node identity). On older versions, keep the structure before the list stable: wrap the conditional in an always-present container (`<div class="banners">{cond ? <div/> : ''}</div>`).
- A numbered / zebra-striped / "N of M" `each()` list shows the wrong number on rows that MOVED (reorder, or non-tail insert/remove), while unmoved rows look right → the render fn's `index` argument is NOT part of the memo key (only item identity + `cacheKey` + content version are), so a row that keeps identity but changes position keeps HTML rendered at its old index. Fold the index into the memo key: `each(items, (it, i) => …, { cacheKey: (_, i) => i })` (add `key` if used). Opt-in dev warn: `KERF_DEV_WARN_STALE_INDEX=1`.

## Server / SSR

`SafeHtml.toString()` returns the HTML string. JSX works in Node with no DOM. `mount`, `delegate`, `toElement`, `morph` all require a DOM and run client-side.

## Where to look next

- API reference: `node_modules/kerfjs/docs/8-api-reference.md` (or https://brianwestphal.github.io/kerf/api/)
- Full AI guide: https://github.com/brianwestphal/kerf/blob/main/docs/ai/usage-guide.md
- llms.txt index: https://github.com/brianwestphal/kerf/blob/main/llms.txt

<!-- KERF-APP-CANONICAL-END · your customizations below -->
