kerfjs 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/ai/cursorrules +144 -0
- package/ai/manifest.json +21 -0
- package/ai/skill.md +169 -0
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to **kerf** are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## Unreleased
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
- `kerfjs` npm package now bundles the drop-in AI-assistant configs at `ai/skill.md`, `ai/cursorrules`, and `ai/manifest.json` — so `npm install kerfjs` lands them directly on disk instead of asking consumers to find them on GitHub. The repo-root `kerf.claude-skill.md` / `kerf.cursorrules` remain the source of truth; `ai/` is regenerated by `scripts/sync-ai-bundle.mjs` and kept honest by the new `npm run check:ai-bundle-in-sync` gate (wired into `npm run check`). Each bundled file carries a `kerf-skill-version` line + a `KERF-APP-CANONICAL-END` marker so the new ESLint rule can detect drift and auto-fix only the kerf-maintained section while preserving consumer customisations below the marker. See `docs/12-ai-assistant-configs.md`.
|
|
11
|
+
- `eslint-plugin-kerfjs` v0.9.0 adds `kerfjs/ai-assistant-configs` (`warn` in recommended config) — once per lint pass, checks the consumer's `.claude/skills/kerf-app/SKILL.md` and `.cursorrules` against the bundled `kerfjs/ai/manifest.json`. Reports `missing` / `stale` / `forked` states; `eslint --fix` writes the bundled canonical above the `KERF-APP-CANONICAL-END` marker and preserves the consumer's append zone below it (the "versioned-section preservation" strategy). Granular disable via `['warn', { claude: false, cursor: true }]`.
|
|
12
|
+
|
|
13
|
+
## [0.9.1] - 2026-05-20
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
- Bundle the kerf-app Claude Code skill and Cursor rules inside the npm package at `ai/skill.md`, `ai/cursorrules`, and `ai/manifest.json`
|
|
17
|
+
- Add `kerfjs/ai-assistant-configs` rule to `eslint-plugin-kerfjs` (warn in recommended) to flag drift in installed AI assistant configs
|
|
18
|
+
- `eslint --fix` now replaces only the canonical section above the `KERF-APP-CANONICAL-END` marker, preserving consumer customizations below it
|
|
19
|
+
|
|
20
|
+
## [0.9.0] - 2026-05-20
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
- Bundle the kerf-app Claude Code skill and Cursor rules inside the npm package at `ai/skill.md`, `ai/cursorrules`, and `ai/manifest.json`
|
|
24
|
+
- Add `kerfjs/ai-assistant-configs` rule (warn in recommended) to `eslint-plugin-kerfjs` v0.9.0 to surface AI-config drift on every lint pass
|
|
25
|
+
- Canonical-file contract (`kerf-skill-version` + `KERF-APP-CANONICAL-END` marker) lets `eslint --fix` refresh the canonical section while preserving consumer customizations below the marker
|
|
26
|
+
|
|
7
27
|
## [0.8.2] - 2026-05-19
|
|
8
28
|
|
|
9
29
|
|
package/ai/cursorrules
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<!-- kerf-skill-version: 1.0.0 -->
|
|
2
|
+
# kerf.cursorrules — rules for building apps with kerf
|
|
3
|
+
#
|
|
4
|
+
# Drop this file into your project as `.cursorrules` (Cursor will pick it
|
|
5
|
+
# up automatically) when you're using kerf (https://github.com/brianwestphal/kerf).
|
|
6
|
+
# These rules condense `docs/ai/usage-guide.md` into the form Cursor parses.
|
|
7
|
+
|
|
8
|
+
You are writing a UI in kerf — a ~6.1 KB reactive framework (6.5 KB with `arraySignal`): signals + DOM morphing + JSX → HTML strings. No virtual DOM, no compiler, no scheduler.
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
- Install with `npm install kerfjs`.
|
|
13
|
+
- `tsconfig.json`: `"jsx": "react-jsx"`, `"jsxImportSource": "kerfjs"`.
|
|
14
|
+
- Vite / esbuild need no extra config.
|
|
15
|
+
- 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 four of the hard rules below (no inline JSX event handlers, require `data-key` in `each()`, no nested `mount()`, prefer module JSX augmentation) at edit time so violations surface as IDE squiggles before any code runs.
|
|
16
|
+
|
|
17
|
+
## Public API — one import path
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import {
|
|
21
|
+
signal, computed, effect, batch, // reactivity
|
|
22
|
+
defineStore, resetAllStores, // stores
|
|
23
|
+
mount, morph, each, // render (reactive + one-shot) + keyed list
|
|
24
|
+
delegate, delegateCapture, // events
|
|
25
|
+
toElement, // direct JSX → DOM Element
|
|
26
|
+
SafeHtml, isSafeHtml, raw, Fragment,
|
|
27
|
+
} from 'kerfjs';
|
|
28
|
+
|
|
29
|
+
// Optional, only when you need granular collection updates:
|
|
30
|
+
import { arraySignal } from 'kerfjs/array-signal';
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
| Export | Use |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `signal(initial)` | atomic reactive state; read/write via `.value` |
|
|
36
|
+
| `computed(fn)` | derived value (read-only) |
|
|
37
|
+
| `effect(fn)` | side effect that re-runs on signal change |
|
|
38
|
+
| `batch(fn)` | coalesce multiple writes into one re-run |
|
|
39
|
+
| `defineStore({initial, actions})` | named multi-consumer state |
|
|
40
|
+
| `resetAllStores()` | reset every store (test teardown) |
|
|
41
|
+
| `mount(el, render)` | bind reactive render to a DOM element; returns a disposer |
|
|
42
|
+
| `morph(liveRoot, template)` | one-shot reconcile against an already-populated element (SSR hydration, page-refresh diffs). Template can be `Element`, `SafeHtml`, or HTML string |
|
|
43
|
+
| `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 |
|
|
44
|
+
| `delegate(root, type, sel, h)` | one listener at the root, walks `closest(selector)` from target |
|
|
45
|
+
| `delegateCapture(root, type, sel, h)` | capture-phase escape hatch, strict `target.matches()` |
|
|
46
|
+
| `toElement(jsx)` | parse JSX into one DOM node (SVG-aware) |
|
|
47
|
+
| `raw(html)` | inject pre-escaped HTML |
|
|
48
|
+
| `arraySignal(initial?)` | granular keyed-list signal at `kerfjs/array-signal` subpath; `each()` reconciles in O(patches) |
|
|
49
|
+
|
|
50
|
+
## Hard rules — get these right on the first try
|
|
51
|
+
|
|
52
|
+
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()`.
|
|
53
|
+
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.
|
|
54
|
+
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).
|
|
55
|
+
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.
|
|
56
|
+
5. **One `mount()` per root.** Don't nest. Compose with plain functions that return JSX.
|
|
57
|
+
6. **No `<MyComponent />` semantics with hooks.** Components are plain functions returning JSX. State lives in module-scope signals or stores, never in component closures.
|
|
58
|
+
7. **Signal reads must happen INSIDE the render function** to be tracked. `const x = count.value; mount(el, () => <span>{x}</span>)` will NOT re-render. Move the read inside.
|
|
59
|
+
8. **Store actions receive `(set, get)`, not `(state)`.** `set(next)` replaces state; mutating `get()` does nothing.
|
|
60
|
+
9. **Use `data-action` attributes, not inline `onClick`.** Inline handlers are NOT supported by the JSX → string runtime; delegate from the root instead.
|
|
61
|
+
10. **`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.
|
|
62
|
+
11. **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 } } } }`.
|
|
63
|
+
12. **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.
|
|
64
|
+
13. **`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.
|
|
65
|
+
|
|
66
|
+
## Decision-making axes
|
|
67
|
+
|
|
68
|
+
When deciding which primitive to reach for, work down the axes:
|
|
69
|
+
|
|
70
|
+
**Events.**
|
|
71
|
+
- 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.
|
|
72
|
+
- 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.
|
|
73
|
+
- Well-known non-bubbler (`focus`, `blur`, `scroll`, `load`, `error`, `mouseenter`, `mouseleave`) → still `delegate()`; it auto-promotes to capture. Custom non-bubblers or strict element-match → `delegateCapture()`.
|
|
74
|
+
|
|
75
|
+
**Lists.**
|
|
76
|
+
- Items change across renders (todos, chat messages, table rows) → `each(items, render)`.
|
|
77
|
+
- Static structural enumeration whose row render reads signals → `STATIC.map(item => <jsx/>)`. Inner `each(item.children, …)` still gets keyed reconcile.
|
|
78
|
+
- Long list with point-wise mutations → `arraySignal` + `each(arraySig, render)` for O(patches) updates.
|
|
79
|
+
|
|
80
|
+
**Side effects / imperative DOM.**
|
|
81
|
+
- Library-owned subtree survives across renders → `data-morph-skip` on host.
|
|
82
|
+
- Host attributes morph but subtree preserved → `data-morph-skip-children`.
|
|
83
|
+
- Imperatively-injected element survives the trailing-removal pass → `data-morph-preserve`.
|
|
84
|
+
- Focused input / contenteditable caret survives re-renders → automatic; no opt-in.
|
|
85
|
+
|
|
86
|
+
**Raw HTML.**
|
|
87
|
+
- User-controlled HTML → sanitize first (DOMPurify) then `raw(sanitized)`.
|
|
88
|
+
- Author-controlled trusted HTML → `raw(html)` directly.
|
|
89
|
+
|
|
90
|
+
## Canonical patterns
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
// Signal + mount
|
|
94
|
+
const count = signal(0);
|
|
95
|
+
mount(document.getElementById('app')!, () => (
|
|
96
|
+
<div>
|
|
97
|
+
<button data-action="inc">+</button>
|
|
98
|
+
<span>{count.value}</span>
|
|
99
|
+
</div>
|
|
100
|
+
));
|
|
101
|
+
delegate(rootEl, 'click', '[data-action="inc"]', () => { count.value += 1; });
|
|
102
|
+
|
|
103
|
+
// Keyed list with per-item memoization
|
|
104
|
+
mount(listEl, () => (
|
|
105
|
+
<ul>
|
|
106
|
+
{each(rows.value, (row) => <li data-key={row.id}>{row.label}</li>)}
|
|
107
|
+
</ul>
|
|
108
|
+
));
|
|
109
|
+
|
|
110
|
+
// Store
|
|
111
|
+
const cart = defineStore({
|
|
112
|
+
initial: () => ({ items: [] as string[] }),
|
|
113
|
+
actions: (set, get) => ({
|
|
114
|
+
add: (id: string) => set({ items: [...get().items, id] }),
|
|
115
|
+
clear: () => set({ items: [] }),
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// One-shot reconcile against existing DOM (no signals)
|
|
120
|
+
morph(liveCard, '<article class="card">…</article>');
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Common errors → fixes
|
|
124
|
+
|
|
125
|
+
- `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.
|
|
126
|
+
- Focus / cursor lost on every keystroke → list items lack `data-key`. Add it.
|
|
127
|
+
- Click handler stops firing after re-render → `el.addEventListener` was used. Replace with `delegate(rootEl, 'click', '[data-action="..."]', ...)`.
|
|
128
|
+
- Render fn never re-runs → signal was read outside the render fn. Move the `signal.value` read inside.
|
|
129
|
+
- SVG renders as broken / namespaceless markup → use `mount` (HTML path) or `toElement` (SVG-aware), not `innerHTML`.
|
|
130
|
+
- Library widget destroyed on every render → wrap host in `data-morph-skip`; mount the library imperatively after first render.
|
|
131
|
+
- `each(): row render at index N produced K top-level elements` → wrap multiple roots in one parent.
|
|
132
|
+
- 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 13.
|
|
133
|
+
|
|
134
|
+
## Server / SSR
|
|
135
|
+
|
|
136
|
+
`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.
|
|
137
|
+
|
|
138
|
+
## Where to look next
|
|
139
|
+
|
|
140
|
+
- API reference: `node_modules/kerfjs/docs/8-api-reference.md` (or https://brianwestphal.github.io/kerf/api/)
|
|
141
|
+
- Full AI guide: https://github.com/brianwestphal/kerf/blob/main/docs/ai/usage-guide.md
|
|
142
|
+
- llms.txt index: https://github.com/brianwestphal/kerf/blob/main/llms.txt
|
|
143
|
+
|
|
144
|
+
<!-- KERF-APP-CANONICAL-END · your customizations below -->
|
package/ai/manifest.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kerfjsVersion": "0.9.1",
|
|
3
|
+
"files": [
|
|
4
|
+
{
|
|
5
|
+
"name": "skill",
|
|
6
|
+
"source": "kerf.claude-skill.md",
|
|
7
|
+
"bundle": "ai/skill.md",
|
|
8
|
+
"dest": ".claude/skills/kerf-app/SKILL.md",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"sha256": "bef982cb743fd4c73e666ee5cf774112e57c93ebb13b24bbacbf08577db0930a"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"name": "cursorrules",
|
|
14
|
+
"source": "kerf.cursorrules",
|
|
15
|
+
"bundle": "ai/cursorrules",
|
|
16
|
+
"dest": ".cursorrules",
|
|
17
|
+
"version": "1.0.0",
|
|
18
|
+
"sha256": "98f7faff2b7585ef9cc511add42ea5d9bf0cbae8307b066af090e9281136d2ab"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
package/ai/skill.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kerf-app
|
|
3
|
+
description: Build UIs in the kerf reactive framework (https://github.com/brianwestphal/kerf). Use this skill whenever the user is writing or modifying code that imports `kerfjs`, asks to add a feature to a kerf app, or asks "how do I do X in kerf?". Use it proactively the moment you spot a kerf import in the file you're editing.
|
|
4
|
+
kerf-skill-version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Building apps with kerf
|
|
8
|
+
|
|
9
|
+
> Drop this file into your `~/.claude/skills/kerf-app/SKILL.md` (or your
|
|
10
|
+
> project's `.claude/skills/kerf-app/SKILL.md`) so Claude Code activates
|
|
11
|
+
> it whenever you work on a kerf app.
|
|
12
|
+
|
|
13
|
+
kerf is a ~6.1 KB reactive UI framework (6.5 KB with `arraySignal`): signals + DOM morphing + JSX → HTML strings. No virtual DOM, no compiler, no scheduler. The whole public surface fits in 15 exports.
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
- Install: `npm install kerfjs`
|
|
18
|
+
- `tsconfig.json`: `"jsx": "react-jsx"`, `"jsxImportSource": "kerfjs"`
|
|
19
|
+
- Vite / esbuild need no extra config.
|
|
20
|
+
- Recommended companion: `npm install --save-dev eslint-plugin-kerfjs` and add `kerfjs.configs.recommended` to the project's eslint config. Enforces four of the hard rules below (no inline JSX event handlers, require `data-key` in `each()`, no nested `mount()`, prefer module JSX augmentation) at edit time — useful as a self-correction signal when authoring kerf code.
|
|
21
|
+
|
|
22
|
+
## Public API — one import path
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import {
|
|
26
|
+
signal, computed, effect, batch, // reactivity
|
|
27
|
+
defineStore, resetAllStores, // stores
|
|
28
|
+
mount, morph, each, // render (reactive + one-shot) + keyed list
|
|
29
|
+
delegate, delegateCapture, // events
|
|
30
|
+
toElement, // direct JSX → DOM Element
|
|
31
|
+
SafeHtml, isSafeHtml, raw, Fragment,
|
|
32
|
+
} from 'kerfjs';
|
|
33
|
+
|
|
34
|
+
// Optional, only when you need granular collection updates:
|
|
35
|
+
import { arraySignal } from 'kerfjs/array-signal';
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| Export | Use |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `signal(initial)` | atomic reactive state; `.value` get/set |
|
|
41
|
+
| `computed(fn)` | derived value, read-only |
|
|
42
|
+
| `effect(fn)` | side effect on signal change; returns disposer |
|
|
43
|
+
| `batch(fn)` | coalesce multiple writes into one re-run |
|
|
44
|
+
| `defineStore({initial, actions})` | named multi-consumer state |
|
|
45
|
+
| `resetAllStores()` | reset every store (test teardown) |
|
|
46
|
+
| `mount(el, render)` | bind reactive render to a DOM element; returns disposer |
|
|
47
|
+
| `morph(liveRoot, template)` | one-shot reconcile against a populated element (SSR hydration, page-refresh diffs). Template = `Element`, `SafeHtml`, or HTML string |
|
|
48
|
+
| `each(items, render, cacheKey?)` | keyed list iteration; per-row memoization on identity (+ optional cacheKey — a passive comparator for external state). Distinct from `data-key` on the rendered element |
|
|
49
|
+
| `delegate(root, type, sel, h)` | one listener at the root; `closest(selector)` walk from target |
|
|
50
|
+
| `delegateCapture(root, type, sel, h)` | capture-phase escape hatch; `target.matches()` strict match |
|
|
51
|
+
| `toElement(jsx)` | parse JSX into one DOM node (SVG-aware) |
|
|
52
|
+
| `raw(html)` | inject pre-escaped HTML |
|
|
53
|
+
| `arraySignal(initial?)` | granular keyed-list signal (subpath `kerfjs/array-signal`); `each()` reconciles in O(patches) |
|
|
54
|
+
|
|
55
|
+
## Hard rules — every AI assistant gets these wrong at least once
|
|
56
|
+
|
|
57
|
+
1. **JSX renders to HTML strings, not DOM nodes.** Don't pass DOM nodes as JSX children — the runtime throws. Need a ref? Build the JSX, then `querySelector` after `mount()` / `toElement()`.
|
|
58
|
+
2. **Diff keys: `id` first, then `data-key`.** Lists MUST set `data-key={item.id}` per item — otherwise the diff matches by position and you lose focus, cursor, and identity on insert/delete.
|
|
59
|
+
3. **Three escape hatches for the morph:**
|
|
60
|
+
- `data-morph-skip` — element AND subtree preserved verbatim. For library-owned hosts (Monaco, xterm, D3).
|
|
61
|
+
- `data-morph-skip-children` — attrs on the host morph, subtree preserved. For client-hydrated slots whose loading/state classes need to flow through.
|
|
62
|
+
- `data-morph-preserve` — element survives the trailing-removal pass even when the new template doesn't emit it. For imperatively-injected children (autoplay video, tooltip overlay, analytics pixel). Does NOT block a keyed-match move.
|
|
63
|
+
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.
|
|
64
|
+
5. **One `mount()` per root.** Don't nest `mount()` calls. Compose with plain functions returning JSX.
|
|
65
|
+
6. **Components are plain functions.** `<MyComponent props />` works — the JSX runtime calls `MyComponent(props)` and uses the returned JSX — but there's no hook system, no lifecycle, and no per-instance state. State lives in module-scope signals or stores, never in component closures.
|
|
66
|
+
7. **Signal reads must happen INSIDE the render function** to be tracked. `const x = count.value; mount(el, () => <span>{x}</span>)` does NOT re-render. Move the read inside.
|
|
67
|
+
8. **Store actions take `(set, get)`, not `(state)`.** `set(next)` replaces state; mutating `get()` does nothing.
|
|
68
|
+
9. **Use `data-action` attributes, not inline `onClick`.** Inline handlers are NOT supported by the JSX → string runtime; delegate from the root.
|
|
69
|
+
10. **`arraySignal` is opt-in for long keyed lists** where most updates are pointwise. For short lists / filter+sort pipelines, plain `signal` + `each(items.value, ...)` is simpler and equally fast.
|
|
70
|
+
11. **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 } } } }`.
|
|
71
|
+
12. **Each `each()` row must produce exactly one top-level element.** Multi-root or empty rows throw a row-precise error. Wrap multiple roots in one parent.
|
|
72
|
+
13. **`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.
|
|
73
|
+
|
|
74
|
+
## Decision-making axes
|
|
75
|
+
|
|
76
|
+
When deciding which primitive to reach for, work down the axes:
|
|
77
|
+
|
|
78
|
+
**Events.**
|
|
79
|
+
- 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.
|
|
80
|
+
- 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.
|
|
81
|
+
- Well-known non-bubbler (`focus`, `blur`, `scroll`, `load`, `error`, `mouseenter`, `mouseleave`) → still `delegate()`; it auto-promotes to capture. Custom non-bubblers or strict element-match → `delegateCapture()`.
|
|
82
|
+
|
|
83
|
+
**Lists.**
|
|
84
|
+
- Items change across renders (todos, chat messages, table rows) → `each(items, render)`.
|
|
85
|
+
- Static structural enumeration whose row render reads signals → `STATIC.map(item => <jsx/>)`. Inner `each(item.children, …)` still gets keyed reconcile.
|
|
86
|
+
- Long list with point-wise mutations → `arraySignal` + `each(arraySig, render)` for O(patches) updates.
|
|
87
|
+
|
|
88
|
+
**Side effects / imperative DOM.**
|
|
89
|
+
- Library-owned subtree survives across renders → `data-morph-skip` on host.
|
|
90
|
+
- Host attributes morph but subtree preserved → `data-morph-skip-children`.
|
|
91
|
+
- Imperatively-injected element survives the trailing-removal pass → `data-morph-preserve`.
|
|
92
|
+
- Focused input / contenteditable caret survives re-renders → automatic; no opt-in.
|
|
93
|
+
|
|
94
|
+
**Raw HTML.**
|
|
95
|
+
- User-controlled HTML → sanitize first (DOMPurify) then `raw(sanitized)`.
|
|
96
|
+
- Author-controlled trusted HTML → `raw(html)` directly.
|
|
97
|
+
|
|
98
|
+
## Canonical patterns
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
// Pattern 1: signal + mount + delegate
|
|
102
|
+
const count = signal(0);
|
|
103
|
+
mount(document.getElementById('app')!, () => (
|
|
104
|
+
<div>
|
|
105
|
+
<button data-action="inc">+</button>
|
|
106
|
+
<span>{count.value}</span>
|
|
107
|
+
</div>
|
|
108
|
+
));
|
|
109
|
+
delegate(rootEl, 'click', '[data-action="inc"]', () => { count.value += 1; });
|
|
110
|
+
|
|
111
|
+
// Pattern 2: keyed list with per-row memoization
|
|
112
|
+
mount(listEl, () => (
|
|
113
|
+
<ul>
|
|
114
|
+
{each(rows.value, (row) => <li data-key={row.id}>{row.label}</li>)}
|
|
115
|
+
</ul>
|
|
116
|
+
));
|
|
117
|
+
|
|
118
|
+
// Pattern 3: store with reset
|
|
119
|
+
const cart = defineStore({
|
|
120
|
+
initial: () => ({ items: [] as string[] }),
|
|
121
|
+
actions: (set, get) => ({
|
|
122
|
+
add: (id: string) => set({ items: [...get().items, id] }),
|
|
123
|
+
clear: () => set({ items: [] }),
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
// access: cart.state.value.items, cart.actions.add('x'), cart.reset()
|
|
127
|
+
|
|
128
|
+
// Pattern 4: one-shot reconcile (no signals, no effect)
|
|
129
|
+
morph(liveCard, '<article class="card">…</article>');
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Diagnosing common errors
|
|
133
|
+
|
|
134
|
+
| Error / symptom | Root cause | Fix |
|
|
135
|
+
| --- | --- | --- |
|
|
136
|
+
| `JSX: DOM elements cannot be passed as children` | passed a `toElement()` result inside JSX | Build the whole tree in JSX; refs via `querySelector` after rendering |
|
|
137
|
+
| Focus / cursor lost on every keystroke | list items lack `data-key` | Add `data-key` (or `id`) to each list item |
|
|
138
|
+
| Click handler stops firing after re-render | `el.addEventListener` was used | Replace with `delegate(rootEl, 'click', '[data-action="..."]', ...)` |
|
|
139
|
+
| Render fn never re-runs | signal was read outside the render fn | Move `signal.value` read inside the render fn |
|
|
140
|
+
| SVG renders as broken / namespaceless markup | `innerHTML` used directly | Use `mount` or `toElement` (SVG-aware) |
|
|
141
|
+
| Library widget destroyed on every render | host reachable by the morph | Wrap host in `data-morph-skip`; mount the library imperatively after first render |
|
|
142
|
+
| `<my-tag>` fails to typecheck | declaration merging targeted global JSX | Use `declare module 'kerfjs/jsx-runtime' { namespace JSX { … } }` instead |
|
|
143
|
+
| `each(): row render at index N produced K top-level elements` | row returned multiple sibling elements or zero | Wrap them in one parent so the row renders exactly one element |
|
|
144
|
+
| Drag/drop / state change has no visible effect; only elements *outside* `each()` update | Used `each(STATIC_ARRAY, …)` whose row render reads signals. Items never change identity → cache hits forever → row render never re-invoked → signal reads stop tracking | Replace outer with `STATIC_ARRAY.map(...)`; keep inner `each()` for the dynamic sub-list. See Hard Rule 13 |
|
|
145
|
+
|
|
146
|
+
## Workflow guidance
|
|
147
|
+
|
|
148
|
+
When the user asks you to add a feature to a kerf app:
|
|
149
|
+
|
|
150
|
+
1. **Check what state already exists.** Is there a signal / store you should reuse? Don't create a new one for derived data — use `computed`.
|
|
151
|
+
2. **Decide where state lives.** Module-scope signal for ephemeral UI state; `defineStore` for state shared across mounts or that needs `reset()` for tests.
|
|
152
|
+
3. **Decide who fires the action.** A handler on a DOM event → `delegate` with a `data-action` attribute. A signal change → `effect()`.
|
|
153
|
+
4. **Render output is JSX returning `SafeHtml`.** No JSX-as-DOM-node, no inline handlers. Lists get `data-key`.
|
|
154
|
+
5. **Test with `kerfjs/testing`'s `clearStoreRegistry()`** between unit tests if you used `defineStore`.
|
|
155
|
+
|
|
156
|
+
When you spot user code that violates any of the hard rules above, fix it inline AND explain the rule briefly so the user learns the pattern.
|
|
157
|
+
|
|
158
|
+
## Server / SSR
|
|
159
|
+
|
|
160
|
+
`SafeHtml.toString()` returns the HTML string. JSX works in Node with no DOM. `mount`, `morph`, `delegate`, `toElement` all require a DOM and run client-side.
|
|
161
|
+
|
|
162
|
+
## Where to look next
|
|
163
|
+
|
|
164
|
+
- API reference: <https://brianwestphal.github.io/kerf/api/>
|
|
165
|
+
- Full AI guide: <https://github.com/brianwestphal/kerf/blob/main/docs/ai/usage-guide.md>
|
|
166
|
+
- llms.txt index: <https://github.com/brianwestphal/kerf/blob/main/llms.txt>
|
|
167
|
+
- Example apps: <https://brianwestphal.github.io/kerf/examples/>
|
|
168
|
+
|
|
169
|
+
<!-- KERF-APP-CANONICAL-END · your customizations below -->
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kerfjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Tiny reactive UI framework — fine-grained signals + DOM morphing + JSX. Apply the smallest possible cut to update your DOM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"files": [
|
|
54
54
|
"dist",
|
|
55
|
+
"ai",
|
|
55
56
|
"README.md",
|
|
56
57
|
"CHANGELOG.md",
|
|
57
58
|
"LICENSE"
|
|
@@ -71,11 +72,13 @@
|
|
|
71
72
|
"bench:micro": "vitest bench --run --config vitest.config.bench.ts",
|
|
72
73
|
"lint": "eslint src tests",
|
|
73
74
|
"typecheck": "tsc --noEmit",
|
|
74
|
-
"check": "npm run lint && npm run typecheck && node scripts/check-doc-test-inventory.mjs && node scripts/check-doc-api-coverage.mjs && npm test && npm run build && vitest run --config vitest.config.dist.ts && vitest run --config vitest.config.dist-full.ts && tsc -p tests/dist/jsx-typing/tsconfig.json && tsc -p site/src/examples/complete/tsconfig.json && node scripts/check-docs-examples.mjs",
|
|
75
|
+
"check": "npm run lint && npm run typecheck && node scripts/check-doc-test-inventory.mjs && node scripts/check-doc-api-coverage.mjs && node scripts/check-ai-bundle.mjs && npm test && npm run build && vitest run --config vitest.config.dist.ts && vitest run --config vitest.config.dist-full.ts && tsc -p tests/dist/jsx-typing/tsconfig.json && tsc -p site/src/examples/complete/tsconfig.json && node scripts/check-docs-examples.mjs",
|
|
75
76
|
"check:docs:examples": "node scripts/check-docs-examples.mjs",
|
|
76
77
|
"check:full": "npm run check && playwright test",
|
|
77
78
|
"check:docs:test-inventory": "node scripts/check-doc-test-inventory.mjs",
|
|
78
79
|
"check:docs:api-coverage": "node scripts/check-doc-api-coverage.mjs",
|
|
80
|
+
"check:ai-bundle-in-sync": "node scripts/check-ai-bundle.mjs",
|
|
81
|
+
"ai-bundle:sync": "node scripts/sync-ai-bundle.mjs",
|
|
79
82
|
"clean": "rm -rf dist coverage node_modules/.cache",
|
|
80
83
|
"prepare": "husky",
|
|
81
84
|
"release": "bash scripts/release.sh",
|