npx agentspec-cli install 919df33f-99e7-4dcc-bac0-cc4e174b6124 --file-type cursorrules→ .cursor/rules/
<!-- kerf-skill-version: 1.21.1 -->
# 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 (and any `enableWarnings()` call) FIRST if relying on the untracked-signal, delegate-in-effect, or rebuilt-listeners warnings — `signal()`, `effect()`, and `mount()` decide those at creation time, so anything created earlier stays uncovered.
- **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.
## Optional first-party UI
Install `@kerfjs/ui` for shared toolbars, menu rows, controlled tab bars, headers/value tables,
resizable regions, selects, banners, empty states, and loading indicators.
Import visual components from explicit subpaths so a CSS-aware browser bundler
includes only their reachable styles. The root barrel and
`@kerfjs/ui/unstyled` are CSS-free; pair the barrel with `styles.css` only when
the complete layer is intentional. Reuse these primitives before creating
parallel local ones; keep domain state, commands, routing, and tab/menu policy
in the app.
Use MenuItem/MenuActionRow/MenuHeader `rootAttributes` only for application
`data-*` metadata. MenuActionRow `trailingActionAttributes` and MenuHeader
`triggerAttributes` additionally support native popover target/action and
`aria-controls`/`aria-haspopup`. Do not use these slots to override action,
item identity, selection, disclosure, naming, disabled, icon, or role
semantics; one `role="menuitem"` does not make a complete menu widget.
`MenuItem.trailing` is dormant. Use `MenuActionRow` for sibling primary and
trailing native buttons, and keep its `label`, `icon`, and
`trailingActionIcon` SafeHtml slots free of controls.
Wire emitted `data-action` hooks at the mount root and retain disposers.
`wireResizableRegions()` and `wireTabBars()` are eventful helpers and return
disposers. Compose `AppTab` inside controlled `TabBar`; `wireTabBars()` supplies
horizontal edge autoscroll while dragging. Apply reorder reports
with `reorderTabs()`, and keep order, selection, close policy, routing, panels,
and persistence in the host. Icons/spinners are decorative unless
labeled, and assertive banners are only for urgent interruption.
The opinionated semantic ramps match Hot Sheet 2 and Web Awesome. Override
`--kui-color-*` globally or component properties such as
`--kui-state-banner-background` at a tone or instance boundary; do not replace
private descendant selectors.
For Web Awesome's broader free component set, import the CSS-only
`@kerfjs/ui/webawesome.css` theme once, then import only each Web Awesome
component module the app renders. The theme registers no component JavaScript
and remains overridable through later or scoped `--wa-*` values.
`Select` is pure markup until the app explicitly imports
`@kerfjs/ui/select/register`. Do that once in the application entry that uses
Select; never hide registration inside a component. Web Awesome is an optional
peer and stays out of bundles that use neither Select nor the Web Awesome theme. More detail ships at
`node_modules/@kerfjs/ui/ai/skill.md`.
## 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,
type JSXChildren,
} 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 |
| `JSXChildren` | recursive type for general-purpose component `children`: SafeHtml, primitives, read-only signals, nullish/boolean empty values, and readonly arrays at any depth. Use a narrower union for semantically restricted slots |
| `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 (`enableWarnings({ staleBinding: true })` (or `KERF_DEV_WARN_STALE_BINDING=1` under Node) detects it). Endpoint: a render reading NO `.value` runs exactly once — a fully bound mount never re-renders; `enableWarnings({ valueOnlyRerender: true })` (or `KERF_DEV_WARN_VALUE_ONLY_RERENDER=1` under Node) 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)
// the template describes liveCard's CHILDREN; liveCard's own attributes are untouched
morph(liveCard, '<h2 class="card-title">…</h2><p>…</p>');
// 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: `enableWarnings({ staleIndex: true })` (or `KERF_DEV_WARN_STALE_INDEX=1` under Node).
## 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 -->
React Mobx Cursorrules Prompt File Rules
Cursor IDE rules and conventions for AI-assisted development.
by AgentHub Crawler
.cursorrules
rule: "Expert in front-end engineering and node.js ecosystem"
by AgentHub Crawler
mxstbr.com Cursor Rules
Cursor IDE rules and conventions for AI-assisted development.
by AgentHub Crawler
MyModelArena .cursorrules
Cursor IDE rules and conventions for AI-assisted development.
Sign in to leave a comment.
by AgentHub Crawler