kerfjs 5.0.0-beta.9 → 5.0.0-beta.90

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +2586 -107
  2. package/README.md +96 -37
  3. package/ai/cursorrules +110 -61
  4. package/ai/manifest.json +15 -7
  5. package/ai/skill.md +117 -72
  6. package/dist/actions.d.ts +1 -1
  7. package/dist/actions.js +10 -6
  8. package/dist/actions.js.map +1 -1
  9. package/dist/array-signal.js +1 -1
  10. package/dist/async.js +22 -19
  11. package/dist/async.js.map +1 -1
  12. package/dist/attach.js +1 -66
  13. package/dist/attach.js.map +1 -1
  14. package/dist/{chunk-HW7KSM2Y.js → chunk-3C6F6NM4.js} +1 -1
  15. package/dist/{chunk-HW7KSM2Y.js.map → chunk-3C6F6NM4.js.map} +1 -1
  16. package/dist/{chunk-GY4XV2UV.js → chunk-4TX6YJDM.js} +10 -3
  17. package/dist/chunk-4TX6YJDM.js.map +1 -0
  18. package/dist/{chunk-QIP723L4.js → chunk-5WRGJZV6.js} +3 -2
  19. package/dist/chunk-5WRGJZV6.js.map +1 -0
  20. package/dist/{chunk-KZJXHFIB.js → chunk-BI4HGICV.js} +3 -3
  21. package/dist/chunk-BI4HGICV.js.map +1 -0
  22. package/dist/chunk-FM6N2B4N.js +70 -0
  23. package/dist/chunk-FM6N2B4N.js.map +1 -0
  24. package/dist/{chunk-LVH3GC6B.js → chunk-H7WTRM6F.js} +3 -3
  25. package/dist/chunk-H7WTRM6F.js.map +1 -0
  26. package/dist/{chunk-U6FK33SG.js → chunk-IHMDBBUJ.js} +2 -2
  27. package/dist/chunk-IHMDBBUJ.js.map +1 -0
  28. package/dist/chunk-JN7EJM3B.js +86 -0
  29. package/dist/chunk-JN7EJM3B.js.map +1 -0
  30. package/dist/{chunk-UZJ6I4T6.js → chunk-MK42GLPV.js} +1 -1
  31. package/dist/chunk-MK42GLPV.js.map +1 -0
  32. package/dist/{chunk-SVATPF5R.js → chunk-T33U7SRR.js} +261 -165
  33. package/dist/chunk-T33U7SRR.js.map +1 -0
  34. package/dist/{chunk-ZDCJZCNO.js → chunk-UGNPDLNI.js} +78 -45
  35. package/dist/chunk-UGNPDLNI.js.map +1 -0
  36. package/dist/{chunk-V46JKE44.js → chunk-WKIPLNVO.js} +1 -1
  37. package/dist/chunk-WKIPLNVO.js.map +1 -0
  38. package/dist/{delegate-CL9VTZFb.d.ts → delegate--CrfgYtC.d.ts} +0 -34
  39. package/dist/dev.d.ts +31 -13
  40. package/dist/dev.js +101 -35
  41. package/dist/dev.js.map +1 -1
  42. package/dist/html.js +5 -4
  43. package/dist/html.js.map +1 -1
  44. package/dist/index.d.ts +14 -9
  45. package/dist/index.js +29 -20
  46. package/dist/index.js.map +1 -1
  47. package/dist/jsx-runtime.d.ts +13 -5
  48. package/dist/jsx-runtime.js +1 -1
  49. package/dist/list.d.ts +7 -3
  50. package/dist/list.js +153 -46
  51. package/dist/list.js.map +1 -1
  52. package/dist/overlay.d.ts +128 -129
  53. package/dist/overlay.js +675 -366
  54. package/dist/overlay.js.map +1 -1
  55. package/dist/remount.js +2 -2
  56. package/dist/router.d.ts +13 -4
  57. package/dist/router.js +37 -10
  58. package/dist/router.js.map +1 -1
  59. package/dist/scope.d.ts +35 -8
  60. package/dist/scope.js +61 -30
  61. package/dist/scope.js.map +1 -1
  62. package/dist/testing.js +1 -1
  63. package/dist/timing.d.ts +3 -2
  64. package/dist/timing.js +1 -1
  65. package/dist/timing.js.map +1 -1
  66. package/llms.txt +13 -6
  67. package/package.json +33 -8
  68. package/setup/cli.mjs +90 -0
  69. package/setup/index.d.mts +37 -0
  70. package/setup/index.mjs +1347 -0
  71. package/setup/jsonc.mjs +201 -0
  72. package/setup/state.schema.json +56 -0
  73. package/dist/chunk-GY4XV2UV.js.map +0 -1
  74. package/dist/chunk-KPXIOG2C.js +0 -54
  75. package/dist/chunk-KPXIOG2C.js.map +0 -1
  76. package/dist/chunk-KZJXHFIB.js.map +0 -1
  77. package/dist/chunk-LVH3GC6B.js.map +0 -1
  78. package/dist/chunk-QIP723L4.js.map +0 -1
  79. package/dist/chunk-SVATPF5R.js.map +0 -1
  80. package/dist/chunk-U6FK33SG.js.map +0 -1
  81. package/dist/chunk-UZJ6I4T6.js.map +0 -1
  82. package/dist/chunk-V46JKE44.js.map +0 -1
  83. package/dist/chunk-ZDCJZCNO.js.map +0 -1
package/README.md CHANGED
@@ -12,14 +12,14 @@
12
12
 
13
13
  <p align="center">
14
14
  <a href="https://www.npmjs.com/package/kerfjs"><img src="https://img.shields.io/npm/v/kerfjs.svg" alt="npm version" /></a>
15
- <img src="https://img.shields.io/badge/min%2Bgzip-~12%20KB-brightgreen.svg" alt="~12 KB minified and gzipped" />
15
+ <img src="https://img.shields.io/badge/min%2Bgzip-~13%20KB-brightgreen.svg" alt="~13 KB minified and gzipped" />
16
16
  <img src="https://img.shields.io/npm/l/kerfjs.svg" alt="MIT license" />
17
17
  <img src="https://img.shields.io/badge/types-included-3178c6.svg" alt="TypeScript types included" />
18
18
  </p>
19
19
 
20
20
  ---
21
21
 
22
- > ~12 KB. No virtual DOM. No compiler. No magic.
22
+ > ~13 KB. No virtual DOM. No compiler. No magic.
23
23
  > Reactive UI that touches only the bytes that changed.
24
24
 
25
25
  ```ts
@@ -31,14 +31,17 @@ const app = document.getElementById('app')!;
31
31
  mount(app, () => (
32
32
  <div>
33
33
  <button data-action="inc">+</button>
34
- <span>{count.value}</span>
34
+ <span>{count}</span>
35
35
  </div>
36
36
  ));
37
37
 
38
38
  delegate(app, 'click', '[data-action="inc"]', () => count.value++);
39
39
  ```
40
40
 
41
- That's it. Your JSX renders to HTML strings, kerf's native diff applies the minimum DOM mutations to make the live tree match, and signals re-run the render only when something they read actually changed.
41
+ That's it. Your JSX renders to HTML strings, and handing the signal itself to
42
+ the text hole binds that one node directly. Structural reads still re-run the
43
+ render, where kerf's native diff applies the minimum DOM mutations needed to
44
+ make the live tree match.
42
45
 
43
46
  Here's the whole development loop — write a component, run the dev server, click around, edit, watch the browser pick it up:
44
47
 
@@ -51,25 +54,42 @@ Here's the whole development loop — write a component, run the dev server, cli
51
54
  npm install kerfjs
52
55
  ```
53
56
 
57
+ For an existing project, install the AI guidance, strict TypeScript/ESLint
58
+ feedback, and package scripts with a reviewed dry run:
59
+
60
+ ```bash
61
+ npx kerfjs setup # inspect a value-level plan
62
+ npx kerfjs setup --write --yes # apply it
63
+ ```
64
+
65
+ The command detects core versus `@kerfjs/ui`, supports deterministic npm,
66
+ pnpm, and Yarn workspace selection plus manager-correct offline installs, and
67
+ never adds UI to a core-only project. See
68
+ [`docs/24-ai-first-setup.md`](./docs/24-ai-first-setup.md).
69
+
54
70
  ```jsonc
55
71
  // tsconfig.json — point JSX at kerf
56
72
  { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "kerfjs" } }
57
73
  ```
58
74
 
59
75
  Write plain `.tsx` and build with your existing esbuild / Vite / tsup — no extra plugin. New here? Read the [5-minute orientation](https://github.com/brianwestphal/kerf/blob/main/docs/orientation.md), or open a [complete example](https://brianwestphal.github.io/kerf/examples/complete/).
76
+
60
77
  ## Why Kerf
61
78
 
62
- 1. **~12 KB, one dependency.** ~12 KB minified + gzipped including `@preact/signals-core` (~13 KB with `arraySignal`). No virtual DOM, no scheduler, no concurrent-mode machinery. On the official [krausest benchmark](https://krausest.github.io/js-framework-benchmark/current.html) kerf sits in the same cluster as Vue, Lit, and vanjs; Solid's compiler leads the update-path benchmarks, which kerf doesn't try to match by design — no compiler.
79
+ 1. **~13 KB in the browser.** ~13 KB minified + gzipped including `@preact/signals-core` (~13 KB with `arraySignal`). The optional setup CLI has its own npm dependencies; they stay out of the browser bundle. No virtual DOM, no scheduler, no concurrent-mode machinery. On the official [krausest benchmark](https://krausest.github.io/js-framework-benchmark/current.html) kerf sits in the same cluster as Vue, Lit, and vanjs; Solid's compiler leads the update-path benchmarks, which kerf doesn't try to match by design — no compiler.
63
80
 
64
- 2. **No virtual DOM, no compiler.** JSX → HTML strings → native diff. DevTools shows the real DOM because it *is* the DOM.
81
+ 2. **No virtual DOM, no compiler.** JSX → HTML strings → native diff. DevTools shows the real DOM because it _is_ the DOM.
65
82
 
66
- 3. **Values bind, structure re-renders.** Hand a signal *itself* into a JSX hole — `class={selectedId}` — and kerf binds that one node: on change, only that attribute updates, with no render re-run and no list reconcile. Moving selection between rows in a 10,000-row table touches at most the old and new row classes. ([more →](#fine-grained-updates-bind-a-signal-into-a-hole))
83
+ 3. **Values bind, structure re-renders.** Hand a signal _itself_ into a JSX hole — `class={selectedId}` — and kerf binds that one node: on change, only that attribute updates, with no render re-run and no list reconcile. Moving selection between rows in a 10,000-row table touches at most the old and new row classes. ([more →](#fine-grained-updates-bind-a-signal-into-a-hole))
67
84
 
68
85
  4. **Focus, selection, and listeners survive re-renders — even mid-list.** The reconciler morphs instead of rebuilding, so caret position, IME composition, scroll, and delegated listeners survive every update; keyed rows are patched in place rather than recreated.
69
86
 
70
- 5. **Safe by default.** Text and attributes are HTML-escaped automatically, URL attributes are scheme-screened (`javascript:` dropped), and inline `on*` handlers are rejected outright — so untrusted data stays inert. `raw()` is the explicit, auditable opt-out.
87
+ 5. **A first-party UI layer when you want one.** `@kerfjs/ui` adds accessible components, responsive application layouts, typed CSS values, and explicit wiring helpers without changing kerf's stateless component model. It is separately installable and tree-shakeable, so the core stays the core. ([tour →](#first-party-ui-separately-installable))
88
+
89
+ 6. **Safe by default.** Text and attributes are HTML-escaped automatically, URL attributes are scheme-screened (`javascript:` dropped), and inline `on*` handlers are rejected outright — so untrusted data stays inert. `raw()` is the explicit, auditable opt-out.
90
+
91
+ **Plus, nothing you don't ask for:** JSX typed against the HTML standard (not React's props) · a small public API with no hooks, lifecycle, or per-instance state · tree-shakeable companion subpaths (`router`, `list`, `overlay`, `async`, …) that stay out of the core until imported · an [ESLint plugin](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) + opt-in dev warnings + `create-kerf-component` scaffold · plain TS/JSX/ESM that drops into esbuild / Vite / tsup — or **no** build at all via the `html` tagged template.
71
92
 
72
- **Plus, nothing you don't ask for:** JSX typed against the HTML standard (not React's props) · a ~18-export API with no hooks, lifecycle, or per-instance state · **nine** tree-shakeable companion subpaths (`router`, `list`, `overlay`, `async`, …) that stay out of the core until imported · an [ESLint plugin](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) + opt-in dev warnings + `create-kerf-component` scaffold · plain TS/JSX/ESM that drops into esbuild / Vite / tsup — or **no** build at all via the `html` tagged template.
73
93
  ## When to use Kerf
74
94
 
75
95
  - **Hybrid desktop apps (Tauri / Electron)** — small bundle, predictable diff, debuggable runtime; ideal for the embedded webview.
@@ -82,9 +102,9 @@ Write plain `.tsx` and build with your existing esbuild / Vite / tsup — no ext
82
102
  ### When to reach for something else
83
103
 
84
104
  - Need a full ecosystem (router + forms + data + SSR streaming) → **Next.js / Remix / SolidStart**.
85
- - Building a deeply componentised design-system app → **React / Solid / Svelte**.
105
+ - Building a deeply componentized design-system app → **React / Solid / Svelte**.
86
106
  - Need React Native / cross-platform mobile → **React** (Kerf + Tauri/Electron also covers many of these cases).
87
- - Building a static site → **Astro** (we use it for *this* project's site).
107
+ - Building a static site → **Astro** (we use it for _this_ project's site).
88
108
  - Already invested in a framework where switching cost outweighs the bundle size gain.
89
109
 
90
110
  ## Quick tour
@@ -144,7 +164,7 @@ delegate(root, 'click', REMOVE.selector, (_e, btn) => { /* … */ }); // in dele
144
164
 
145
165
  ### Fine-grained updates: bind a signal into a hole
146
166
 
147
- Inside a `mount()`, hand a signal *itself* (not its `.value`) into an attribute or text position and kerf wires that hole straight to the signal — the render function never re-runs and the list reconciler never walks:
167
+ Inside a `mount()`, hand a signal _itself_ (not its `.value`) into an attribute or text position and kerf wires that hole straight to the signal — the render function never re-runs and the list reconciler never walks:
148
168
 
149
169
  ```ts
150
170
  const status = signal('idle');
@@ -160,7 +180,7 @@ status.value = 'saving'; // updates the class + the text node directly — n
160
180
 
161
181
  The headline use is external state driving a hot spot: a `selectedId` moving between rows inside a 10,000-row `each()` list updates at most the old and new row class attributes, with no reconcile. Works in static content and inside `each()` rows (a row's binding is torn down with the row); outside a `mount()` (SSR / `SafeHtml.toString()`) a bound signal just snapshots its current value.
162
182
 
163
- This is kerf's guiding idiom — *values bind, structure re-renders*: pass the signal itself wherever a hole is just a value, and read `.value` in the render function only where the JSX structure depends on it. A render that reads no `.value` runs exactly once; from then on every update is a direct write to the node it concerns. See [`docs/2-reactivity.md`](./docs/2-reactivity.md) §2.9.
183
+ This is kerf's guiding idiom — _values bind, structure re-renders_: pass the signal itself wherever a hole is just a value, and read `.value` in the render function only where the JSX structure depends on it. A render that reads no `.value` runs exactly once; from then on every update is a direct write to the node it concerns. See [`docs/2-reactivity.md`](./docs/2-reactivity.md) §2.9.
164
184
 
165
185
  ### Long keyed lists: `arraySignal`
166
186
 
@@ -187,14 +207,15 @@ The class lives in its own subpath so apps that don't need it shed ~1 KB. Reads
187
207
  `mount()` wraps `effect()` so the render re-runs on signal changes. Sometimes you have a freshly-built template and an already-populated element and you just want to reconcile them once — no subscription, no re-render loop. That's `morph`:
188
208
 
189
209
  ```ts
190
- import { morph, raw } from 'kerfjs';
210
+ import { morph, raw } from "kerfjs";
191
211
 
192
- morph(liveCard, freshlyBuiltCardEl); // Element template
193
- morph(liveCard, '<article class="card">…</article>'); // raw HTML string
194
- morph(liveCard, raw(htmlFromServer)); // SafeHtml
212
+ // `template` describes liveCard's CHILDREN; liveCard's own attributes are untouched
213
+ morph(liveCard, freshlyBuiltCardEl); // Element template: its child nodes are used
214
+ morph(liveCard, '<h2 class="card-title">…</h2><p>…</p>'); // raw HTML string
215
+ morph(liveCard, raw(htmlFromServer)); // SafeHtml
195
216
  ```
196
217
 
197
- Same algorithm `mount()` uses internally — `data-morph-skip`, `data-morph-skip-children`, `data-morph-preserve`, focused-input value + selection preservation, the `<details>` / `<dialog>` user-agent-owned `open` rule all carry over. Use it for SSR-fragment hydration, page-refresh diffs, third-party widget remounts. See [`docs/4-render.md`](./docs/4-render.md) §4.4.3.
218
+ Same algorithm `mount()` uses internally — `data-morph-skip`, `data-morph-skip-children`, `data-morph-preserve`, focused-input value + selection preservation, the user-agent-owned `open` rule (`<details>`, `<dialog>`, custom elements) all carry over. Only the live root's children are reconciled — the root's own attributes are never touched. Use it for SSR-fragment hydration, page-refresh diffs, third-party widget remounts. See [`docs/4-render.md`](./docs/4-render.md) §4.4.3.
198
219
 
199
220
  ### No build step at all: the `html` tagged template
200
221
 
@@ -202,22 +223,27 @@ Same algorithm `mount()` uses internally — `data-morph-skip`, `data-morph-skip
202
223
 
203
224
  ```html
204
225
  <script type="module">
205
- import { signal, mount, each } from 'https://esm.sh/kerfjs@4';
206
- import { html } from 'https://esm.sh/kerfjs@4/html';
207
-
208
- const items = signal([{ id: 1, label: 'no build step' }]);
209
-
210
- mount(document.getElementById('app'), () => html`
211
- <ul>${each(items.value, (i) => html`<li id="${i.id}">${i.label}</li>`)}</ul>
212
- `);
226
+ import { signal, mount, each } from "https://esm.sh/kerfjs@5";
227
+ import { html } from "https://esm.sh/kerfjs@5/html";
228
+
229
+ const items = signal([{ id: 1, label: "no build step" }]);
230
+
231
+ mount(
232
+ document.getElementById("app"),
233
+ () => html`
234
+ <ul>
235
+ ${each(items.value, (i) => html`<li id="${i.id}">${i.label}</li>`)}
236
+ </ul>
237
+ `,
238
+ );
213
239
  </script>
214
240
  ```
215
241
 
216
- Nothing is self-hosted — `kerfjs` is on npm, so every ESM CDN (esm.sh, jsDelivr, unpkg) mirrors it automatically. esm.sh works with a direct import as shown; jsDelivr / unpkg want an importmap so the internal `@preact/signals-core` import resolves. Pin to a major (`@4`, as shown — the latest `4.x`) rather than floating on `latest`, or an exact version (`@4.1.0`) for full reproducibility. Attribute names are written verbatim (`class`, not `className`), and holes are only legal in text positions or as a complete attribute value — anything ambiguous throws with an actionable message. See [`docs/6-jsx-runtime.md`](./docs/6-jsx-runtime.md) §6.11 (§6.11.1 for the full CDN / importmap recipes) — or the [live-poll example](https://brianwestphal.github.io/kerf/examples/complete/live-poll/), a complete app served exactly as authored: no bundler ever touches it.
242
+ Nothing is self-hosted — `kerfjs` is on npm, so every ESM CDN (esm.sh, jsDelivr, unpkg) mirrors it automatically. esm.sh works with a direct import as shown; jsDelivr / unpkg want an importmap so the internal `@preact/signals-core` import resolves. Pin to a major (`@5`, as shown) rather than floating on `latest`, or an exact version (`@5.0.0`) for full reproducibility. Attribute names are written verbatim (`class`, not `className`), and holes are only legal in text positions or as a complete attribute value — anything ambiguous throws with an actionable message. See [`docs/6-jsx-runtime.md`](./docs/6-jsx-runtime.md) §6.11 (§6.11.1 for the full CDN / importmap recipes) — or the [live-poll example](https://brianwestphal.github.io/kerf/examples/complete/live-poll/), a complete app served exactly as authored: no bundler ever touches it.
217
243
 
218
244
  ### Batteries when you need them: the companion subpaths
219
245
 
220
- The core stays tiny because the patterns every real app rebuilds live in optional, tree-shakeable subpaths — a modal you'd otherwise hand-roll (`kerfjs/overlay`), an async-state container with the stale-response race already solved (`kerfjs/async`), a debounce that composes inside the reactive graph (`kerfjs/timing`), teardown tied to a DOM node's lifetime (`kerfjs/scope`). The largest is `kerfjs/list` — a keyed list that mounts each row individually (so a signal one row reads updates just that row) and virtualizes a long viewport, with fixed, app-declared, or measured row heights:
246
+ The core stays tiny because the patterns every real app rebuilds live in optional, tree-shakeable subpaths — a modal you'd otherwise hand-roll (`kerfjs/overlay`), an async-state container with the stale-response race already solved (`kerfjs/async`), a debounce that composes inside the reactive graph (`kerfjs/timing`), and disposer groups with or without a DOM owner (`kerfjs/scope`). The largest is `kerfjs/list` — a keyed list that mounts each row individually (so a signal one row reads updates just that row) and virtualizes a long viewport, with fixed, app-declared, or measured row heights:
221
247
 
222
248
  ```ts
223
249
  import { bindList, observeRowHeights } from 'kerfjs/list';
@@ -238,7 +264,7 @@ const list = bindList(scrollEl, messages, {
238
264
  observeRowHeights(list); // one ResizeObserver → kerf anchor-corrects scroll
239
265
  ```
240
266
 
241
- And a whole client-side router in one call — `kerfjs/router`, the "postcard router." A route table, a keyed `outlet()`, and automatic `<a href>` interception; the *core* stays router-free (this is opt-in):
267
+ And a whole client-side router in one call — `kerfjs/router`, the "postcard router." A route table, a keyed `outlet()`, and automatic `<a href>` interception; the _core_ stays router-free (this is opt-in):
242
268
 
243
269
  ```ts
244
270
  import { createRouter } from 'kerfjs/router';
@@ -258,13 +284,45 @@ mount(app, () => <div><nav>{/* <a href> links, auto-intercepted */}</nav>{router
258
284
 
259
285
  Each subpath adds nothing to the main barrel until it's imported. See [`docs/8-api-reference.md`](./docs/8-api-reference.md) for the full list (`list`, `router`, `overlay`, `scope`, `async`, `timing`, `remount`, `attach`, `actions`).
260
286
 
287
+ ### First-party UI, separately installable
288
+
289
+ `@kerfjs/ui` is the optional component layer for applications that want a
290
+ coherent interface vocabulary without switching frameworks. Components return
291
+ Kerf `SafeHtml`; the application still owns state and wires behavior explicitly.
292
+ Component subpaths pull in only their reachable CSS, while responsive app
293
+ layouts, typed CSS-value builders, Web Awesome adapters, and the checked
294
+ component catalog stay opt-in.
295
+
296
+ ```bash
297
+ npm install kerfjs @kerfjs/ui
298
+ ```
299
+
300
+ ```tsx
301
+ import { mount } from "kerfjs";
302
+ import { ListItem } from "@kerfjs/ui/list-item";
303
+ import { Toolbar } from "@kerfjs/ui/toolbar";
304
+ import { ToolbarText } from "@kerfjs/ui/toolbar-text";
305
+
306
+ const root = document.getElementById("app")!;
307
+
308
+ mount(root, () => (
309
+ <>
310
+ <Toolbar label="Files" leading={<ToolbarText text="Workspace" />} />
311
+ <ListItem action="open-file" label="src/main.ts" selected />
312
+ </>
313
+ ));
314
+ ```
315
+
316
+ Start with the [component selection matrix](./ui/docs/component-selection.md),
317
+ or read the complete [`@kerfjs/ui` package guide](./ui/README.md).
318
+
261
319
  ## Optional tooling
262
320
 
263
321
  Install and JSX setup are in [Quick start](#quick-start) above. These companion packages are opt-in.
264
322
 
265
323
  ### Optional: `eslint-plugin-kerfjs`
266
324
 
267
- A companion ESLint plugin enforces kerf's hard rules at edit time. Eight rules total: four `error`-level AST rules catch hard-rule violations — inline JSX event handlers, missing `data-key` in `each()`, nested `mount()`, and global `JSX.IntrinsicElements` augmentation — and four `warn`-level rules cover delegate-disposer capture, `attr()` selector rename-safety, `raw()` XSS audit trails, and AI-assistant config hygiene. The plugin is AST-only (no parser-services dependency), so it works with any TypeScript-ESLint setup.
325
+ A companion ESLint plugin enforces kerf's hard rules at edit time. Its eight core rules cover inline JSX event handlers, missing `data-key` in `each()`, nested `mount()`, delegate-disposer capture, `attr()` selector rename-safety, `raw()` XSS audit trails, JSX augmentation, and AI-assistant config hygiene. Six additional UI rules check component ownership, composition, CSS values, application preferences, wiring, and public boundaries when you opt into the UI preset. The plugin requires no TypeScript parser service.
268
326
 
269
327
  ```bash
270
328
  npm install --save-dev eslint-plugin-kerfjs
@@ -272,15 +330,15 @@ npm install --save-dev eslint-plugin-kerfjs
272
330
 
273
331
  ```js
274
332
  // eslint.config.js (flat config, ESLint v9+)
275
- import kerfjs from 'eslint-plugin-kerfjs';
333
+ import kerfjs from "eslint-plugin-kerfjs";
276
334
  export default [kerfjs.configs.recommended];
277
335
  ```
278
336
 
279
- Full docs at [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — legacy `.eslintrc` config, per-rule examples, and the rationale for which violations get lint rules vs. dev-warns vs. strict TS.
337
+ Full docs at [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — flat config, per-rule examples, and the rationale for which violations get lint rules vs. dev-warns vs. strict TS.
280
338
 
281
339
  ### Optional: `create-kerf-component`
282
340
 
283
- Building a reusable component package? Scaffold one that already follows kerf's hard packaging rules (kerfjs as a peer dependency and `external` in the build, ESM + `.d.ts`, `jsxImportSource: "kerfjs"`, subpath exports) plus an example component showing per-instance state via a factory and a `wire(root)` delegation disposer:
341
+ Building a reusable component package? Scaffold one that already follows kerf's hard packaging rules (kerfjs as a peer dependency and `external` in the build, ESM + `.d.ts`, `jsxImportSource: "kerfjs"`, subpath exports), includes an example component showing per-instance state via a factory and a `wire(root)` delegation disposer, and generates checked package-qualified AI component metadata from explicit author decisions:
284
342
 
285
343
  ```bash
286
344
  npm create kerf-component@latest my-widgets
@@ -294,16 +352,17 @@ See [`docs/13-component-packages.md`](./docs/13-component-packages.md) for the f
294
352
  - **Docs:** [`docs/`](./docs/) — overview · reactivity · stores · render · events · jsx · svg · [API reference](./docs/8-api-reference.md)
295
353
  - **Migrating:** [coming from another framework?](https://brianwestphal.github.io/kerf/migrating/) — side-by-side TodoMVC translations + per-framework gotchas
296
354
  - **AI guide:** [`docs/ai/usage-guide.md`](./docs/ai/usage-guide.md) — reference for AI tools fetching kerf docs (linked from `llms.txt`)
297
- - **ESLint plugin:** [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — `eslint-plugin-kerfjs`; eight rules (four hard-rule errors + four warns: `require-delegate-disposer`, `prefer-attr-selector`, `no-raw-with-dynamic-arg`, `ai-assistant-configs`) at edit time (source: [`eslint-plugin/`](./eslint-plugin/))
298
- - **Component scaffold:** `npm create kerf-component@latest <dir>` — `create-kerf-component`; generates a publishable component package with the hard packaging rules pre-wired (source: [`create-kerf-component/`](./create-kerf-component/))
355
+ - **UI package:** [`@kerfjs/ui`](./ui/README.md) — accessible components, responsive app layouts, typed CSS values, Web Awesome adapters, and checked AI-facing component metadata
356
+ - **ESLint plugin:** [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — `eslint-plugin-kerfjs`; eight core rules plus six opt-in UI rules at edit time (source: [`eslint-plugin/`](./eslint-plugin/))
357
+ - **Component scaffold:** `npm create kerf-component@latest <dir>` — `create-kerf-component`; generates a publishable component package with packaging rules plus deterministic, drift-checked AI metadata pre-wired (source: [`create-kerf-component/`](./create-kerf-component/))
299
358
  - **Demo:** [live demo](https://brianwestphal.github.io/kerf/demo/) — nine sections exercising every primitive (counter, store-backed cart, focus survival, keyed list, morph-skip, SVG render, Tier-2 capture, `arraySignal` patches, fine-grained signal bindings)
300
359
  - **Repo:** [github.com/brianwestphal/kerf](https://github.com/brianwestphal/kerf)
301
360
 
302
361
  ## Why "kerf"?
303
362
 
304
- A *kerf* is the narrow strip of material a saw blade removes when cutting — the smallest possible cut. The framework's job is the same: apply the smallest possible mutation to update your DOM.
363
+ A _kerf_ is the narrow strip of material a saw blade removes when cutting — the smallest possible cut. The framework's job is the same: apply the smallest possible mutation to update your DOM.
305
364
 
306
- (And yes, ~~kerformance~~ → *performance* jokes were written. They were also rejected.)
365
+ (And yes, ~~kerformance~~ → _performance_ jokes were written. They were also rejected.)
307
366
 
308
367
  ## Status
309
368
 
package/ai/cursorrules CHANGED
@@ -1,18 +1,23 @@
1
- <!-- kerf-skill-version: 1.20.0 -->
1
+ <!-- kerf-skill-version: 1.23.0 -->
2
+
2
3
  # kerf.cursorrules — rules for building apps with kerf
4
+
3
5
  #
6
+
4
7
  # Drop this file into your project as `.cursorrules` (Cursor will pick it
8
+
5
9
  # up automatically) when you're using kerf (https://github.com/brianwestphal/kerf).
10
+
6
11
  # These rules condense `docs/ai/usage-guide.md` into the form Cursor parses.
7
12
 
8
- 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.
13
+ You are writing a UI in kerf — a ~13 KB reactive framework (still ~13 KB with `arraySignal`): signals + DOM morphing + JSX → HTML strings. No virtual DOM, no compiler, no scheduler.
9
14
 
10
15
  ## Setup
11
16
 
12
17
  - Install with `npm install kerfjs`.
13
18
  - `tsconfig.json`: `"jsx": "react-jsx"`, `"jsxImportSource": "kerfjs"`.
14
19
  - Vite / esbuild need no extra config.
15
- - **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.
20
+ - **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.
16
21
  - **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.
17
22
  - **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.
18
23
  - 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.
@@ -28,13 +33,13 @@ the complete layer is intentional. Reuse these primitives before creating
28
33
  parallel local ones; keep domain state, commands, routing, and tab/menu policy
29
34
  in the app.
30
35
 
31
- Use MenuItem/MenuActionRow/MenuHeader `rootAttributes` only for application
32
- `data-*` metadata. MenuActionRow `trailingActionAttributes` and MenuHeader
36
+ Use ListItem/ListActionRow/ListHeader `rootAttributes` only for application
37
+ `data-*` metadata. ListActionRow `trailingActionAttributes` and ListHeader
33
38
  `triggerAttributes` additionally support native popover target/action and
34
39
  `aria-controls`/`aria-haspopup`. Do not use these slots to override action,
35
40
  item identity, selection, disclosure, naming, disabled, icon, or role
36
41
  semantics; one `role="menuitem"` does not make a complete menu widget.
37
- `MenuItem.trailing` is dormant. Use `MenuActionRow` for sibling primary and
42
+ `ListItem.trailing` is dormant. Use `ListActionRow` for sibling primary and
38
43
  trailing native buttons, and keep its `label`, `icon`, and
39
44
  `trailingActionIcon` SafeHtml slots free of controls.
40
45
 
@@ -43,13 +48,22 @@ Wire emitted `data-action` hooks at the mount root and retain disposers.
43
48
  disposers. Compose `AppTab` inside controlled `TabBar`; `wireTabBars()` supplies
44
49
  horizontal edge autoscroll while dragging. Apply reorder reports
45
50
  with `reorderTabs()`, and keep order, selection, close policy, routing, panels,
46
- and persistence in the host. Icons/spinners are decorative unless
51
+ and persistence in the host. Toolbars draw no divider by default; call
52
+ `wireScrollDividers()` once at the app root so panes and tab bars show scroll
53
+ dividers only while content is scrolled beneath or beyond them. Icons/spinners are decorative unless
47
54
  labeled, and assertive banners are only for urgent interruption.
48
55
 
49
- The opinionated semantic ramps match Hot Sheet 2 and Web Awesome. Override
50
- `--kui-color-*` globally or component properties such as
51
- `--kui-state-banner-background` at a tone or instance boundary; do not replace
52
- private descendant selectors.
56
+ Components own their styles; configure, never override. Set how a component
57
+ looks and sits through its typed props (size, density, inset, presentation,
58
+ `dividerSides`, `ToolbarConfig`, `PaneConfig`, …); never write application
59
+ CSS that selects a `.kui-*` class, a `[data-component]` root, a `wa-*` element
60
+ a Kerf component renders, or its `::part()`s, and never add padding, margin,
61
+ width, or height around a component to size or space it (it owns its 8px
62
+ inset — adding more double-insets). A missing prop is a component gap to
63
+ report upstream, not a reason to override. The opinionated semantic ramps
64
+ match Hot Sheet 2 and Web Awesome: override `--kui-color-*` globally or
65
+ component properties such as `--kui-state-banner-background` at a tone or
66
+ instance boundary, for theming only.
53
67
 
54
68
  For Web Awesome's broader free component set, import the CSS-only
55
69
  `@kerfjs/ui/webawesome.css` theme once, then import only each Web Awesome
@@ -66,41 +80,53 @@ peer and stays out of bundles that use neither Select nor the Web Awesome theme.
66
80
 
67
81
  ```ts
68
82
  import {
69
- signal, computed, effect, batch, // reactivity
70
- defineStore, resetAllStores, // stores
71
- mount, morph, each, // render (reactive + one-shot) + keyed list
72
- delegate, delegateCapture, // events
73
- toElement, // direct JSX → DOM Element (or DocumentFragment for multi-root)
74
- SafeHtml, isSafeHtml, raw, Fragment,
75
- } from 'kerfjs';
83
+ signal,
84
+ computed,
85
+ effect,
86
+ batch, // reactivity
87
+ defineStore,
88
+ resetAllStores, // stores
89
+ mount,
90
+ morph,
91
+ each, // render (reactive + one-shot) + keyed list
92
+ delegate,
93
+ delegateCapture, // events
94
+ toElement, // direct JSX → DOM Element (or DocumentFragment for multi-root)
95
+ SafeHtml,
96
+ isSafeHtml,
97
+ raw,
98
+ Fragment,
99
+ type JSXChildren,
100
+ } from "kerfjs";
76
101
 
77
102
  // Optional, only when you need granular collection updates:
78
- import { arraySignal } from 'kerfjs/array-signal';
103
+ import { arraySignal } from "kerfjs/array-signal";
79
104
 
80
105
  // Development diagnostics — gate with YOUR build's dev flag, in YOUR code.
81
- if (import.meta.env.DEV) await import('kerfjs/dev');
106
+ if (import.meta.env.DEV) await import("kerfjs/dev");
82
107
  ```
83
108
 
84
- | Export | Use |
85
- | --- | --- |
86
- | `signal(initial)` | atomic reactive state; read/write via `.value` |
87
- | `computed(fn)` | derived value (read-only) |
88
- | `effect(fn)` | side effect that re-runs on signal change |
89
- | `batch(fn)` | coalesce multiple writes into one re-run |
90
- | `defineStore({initial, actions})` | named multi-consumer state |
91
- | `resetAllStores()` | reset every store (test teardown) |
92
- | `mount(el, render)` | bind reactive render to a DOM element; returns a disposer |
93
- | `morph(liveRoot, template)` | one-shot reconcile against an already-populated element (SSR hydration, page-refresh diffs). Template can be `Element`, `SafeHtml`, or HTML string |
94
- | `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 |
95
- | `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 |
96
- | `delegate(root, type, sel, h)` | one listener at the root, walks `closest(selector)` from target |
97
- | `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()` |
98
- | `attr(name, value)` | pre-computed `AttrSpec<N,V>` — `.selector` for `delegate()`, `.attrs` to spread into JSX (rename-safe) |
99
- | `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 |
100
- | `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. |
101
- | `raw(html)` | inject pre-escaped HTML |
102
- | `arraySignal(initial?)` | granular keyed-list signal at `kerfjs/array-signal` subpath; `each()` reconciles in O(patches) |
103
- | `` 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}"`) |
109
+ | Export | Use |
110
+ | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
111
+ | `signal(initial)` | atomic reactive state; read/write via `.value` |
112
+ | `computed(fn)` | derived value (read-only) |
113
+ | `effect(fn)` | side effect that re-runs on signal change |
114
+ | `batch(fn)` | coalesce multiple writes into one re-run |
115
+ | `defineStore({initial, actions})` | named multi-consumer state |
116
+ | `resetAllStores()` | reset every store (test teardown) |
117
+ | `mount(el, render)` | bind reactive render to a DOM element; returns a disposer |
118
+ | `morph(liveRoot, template)` | one-shot reconcile against an already-populated element (SSR hydration, page-refresh diffs). Template can be `Element`, `SafeHtml`, or HTML string |
119
+ | `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 |
120
+ | `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 |
121
+ | `delegate(root, type, sel, h)` | one listener at the root, walks `closest(selector)` from target |
122
+ | `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()` |
123
+ | `attr(name, value)` | pre-computed `AttrSpec<N,V>` — `.selector` for `delegate()`, `.attrs` to spread into JSX (rename-safe) |
124
+ | `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 |
125
+ | `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. |
126
+ | `raw(html)` | inject pre-escaped HTML |
127
+ | `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 |
128
+ | `arraySignal(initial?)` | granular keyed-list signal at `kerfjs/array-signal` subpath; `each()` reconciles in O(patches) |
129
+ | `` 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}"`) |
104
130
 
105
131
  ## Hard rules — get these right on the first try
106
132
 
@@ -109,9 +135,9 @@ if (import.meta.env.DEV) await import('kerfjs/dev');
109
135
  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).
110
136
  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.
111
137
  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).
112
- 6. **One `mount()` per root.** Don't nest. Compose with plain functions that return JSX.
138
+ 6. **One `mount()` per root.** Don't nest. Compose with plain functions that return JSX. One exception: a modal `<dialog>` of your own that anchors kerf `popover()` / `tooltip()` surfaces must contain `<div data-kerf-overlay-host data-morph-skip></div>` — kerf renders those surfaces into it (inside the modal, so they are not inert) and that slot is the one allowed nested-mount boundary. Dialogs `kerfjs/overlay` opens get a slot automatically.
113
139
  7. **No `<MyComponent />` semantics with hooks.** Components are plain functions returning JSX. State lives in module-scope signals or stores, never in component closures.
114
- 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.
140
+ 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.
115
141
  9. **Store actions receive `(set, get)`, not `(state)`.** `set(next)` replaces state; mutating `get()` does nothing.
116
142
  10. **Use `data-action` attributes, not inline `onClick`.** Inline handlers are NOT supported by the JSX → string runtime; delegate from the root instead.
117
143
  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.
@@ -124,22 +150,26 @@ if (import.meta.env.DEV) await import('kerfjs/dev');
124
150
  When deciding which primitive to reach for, work down the axes:
125
151
 
126
152
  **Events.**
153
+
127
154
  - 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.
128
155
  - 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.
129
156
  - 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.
130
157
 
131
158
  **Lists.**
159
+
132
160
  - Items change across renders (todos, chat messages, table rows) → `each(items, render)`.
133
161
  - Static structural enumeration whose row render reads signals → `STATIC.map(item => <jsx/>)`. Inner `each(item.children, …)` still gets keyed reconcile.
134
162
  - Long list with point-wise mutations → `arraySignal` + `each(arraySig, render)` for O(patches) updates.
135
163
 
136
164
  **Side effects / imperative DOM.**
165
+
137
166
  - Library-owned subtree survives across renders → `data-morph-skip` on host.
138
167
  - Host attributes morph but subtree preserved → `data-morph-skip-children`.
139
168
  - Imperatively-injected element survives the trailing-removal pass → `data-morph-preserve`.
140
169
  - Focused input / contenteditable caret survives re-renders → automatic; no opt-in.
141
170
 
142
171
  **Raw HTML.**
172
+
143
173
  - User-controlled HTML → sanitize first (DOMPurify) then `raw(sanitized)`.
144
174
  - Author-controlled trusted HTML → `raw(html)` directly.
145
175
  - 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.
@@ -152,20 +182,27 @@ When deciding which primitive to reach for, work down the axes:
152
182
  // updates that one node directly with no render re-run; read `.value` only
153
183
  // when the JSX STRUCTURE depends on the signal (conditionals, list shape).
154
184
  const count = signal(0);
155
- const ACTIONS = { inc: attr('data-action', 'inc') } as const satisfies Record<string, AttrSpec<'data-action'>>;
185
+ const ACTIONS = { inc: attr("data-action", "inc") } as const satisfies Record<
186
+ string,
187
+ AttrSpec<"data-action">
188
+ >;
156
189
 
157
- mount(document.getElementById('app')!, () => (
190
+ mount(document.getElementById("app")!, () => (
158
191
  <div>
159
192
  <button {...ACTIONS.inc.attrs}>+</button>
160
193
  <span>{count}</span>
161
194
  </div>
162
195
  ));
163
- delegate(rootEl, 'click', ACTIONS.inc.selector, () => { count.value += 1; });
196
+ delegate(rootEl, "click", ACTIONS.inc.selector, () => {
197
+ count.value += 1;
198
+ });
164
199
 
165
200
  // Keyed list with per-item memoization
166
201
  mount(listEl, () => (
167
202
  <ul>
168
- {each(rows.value, (row) => <li data-key={row.id}>{row.label}</li>)}
203
+ {each(rows.value, (row) => (
204
+ <li data-key={row.id}>{row.label}</li>
205
+ ))}
169
206
  </ul>
170
207
  ));
171
208
 
@@ -173,22 +210,28 @@ mount(listEl, () => (
173
210
  const cart = defineStore({
174
211
  initial: () => ({ items: [] as string[] }),
175
212
  actions: (set, get) => ({
176
- add: (id: string) => set({ items: [...get().items, id] }),
177
- clear: () => set({ items: [] }),
213
+ add: (id: string) => set({ items: [...get().items, id] }),
214
+ clear: () => set({ items: [] }),
178
215
  }),
179
216
  });
180
217
 
181
218
  // One-shot reconcile against existing DOM (no signals)
182
- morph(liveCard, '<article class="card">…</article>');
219
+ // the template describes liveCard's CHILDREN; liveCard's own attributes are untouched
220
+ morph(liveCard, '<h2 class="card-title">…</h2><p>…</p>');
183
221
 
184
222
  // No build step (CDN / importmap): the html tagged template instead of JSX.
185
223
  // Same runtime semantics as JSX; write real HTML attribute names; a hole must
186
224
  // be a text position or a COMPLETE attribute value (partial values throw).
187
- import { html } from 'kerfjs/html';
188
- mount(rootEl, () => html`
189
- <div class="${cls}">Count: ${count}</div>
190
- <ul>${each(rows.value, (row) => html`<li data-key="${row.id}">${row.label}</li>`)}</ul>
191
- `);
225
+ import { html } from "kerfjs/html";
226
+ mount(
227
+ rootEl,
228
+ () => html`
229
+ <div class="${cls}">Count: ${count}</div>
230
+ <ul>
231
+ ${each(rows.value, (row) => html`<li data-key="${row.id}">${row.label}</li>`)}
232
+ </ul>
233
+ `,
234
+ );
192
235
 
193
236
  // Fine-grained binding (opt-in): pass the signal/computed ITSELF into a hole
194
237
  // so a change updates ONLY that node (no render re-run, no reconcile). For a
@@ -197,9 +240,15 @@ mount(rootEl, () => html`
197
240
  const selectedId = signal<number | null>(null);
198
241
  mount(listEl, () => (
199
242
  <ul>
200
- {each(rows.value, (row) => (
201
- <li class={computed(() => (row.id === selectedId.value ? 'sel' : ''))}>{row.label}</li>
202
- ), (row) => row.id)}
243
+ {each(
244
+ rows.value,
245
+ (row) => (
246
+ <li class={computed(() => (row.id === selectedId.value ? "sel" : ""))}>
247
+ {row.label}
248
+ </li>
249
+ ),
250
+ (row) => row.id,
251
+ )}
203
252
  </ul>
204
253
  ));
205
254
  ```
@@ -215,13 +264,13 @@ mount(listEl, () => (
215
264
  - SVG renders as broken / namespaceless markup → use `mount` (HTML path) or `toElement` (SVG-aware), not `innerHTML`.
216
265
  - Library widget destroyed on every render → wrap host in `data-morph-skip`; mount the library imperatively after first render.
217
266
  - `each(): row render at index N produced K top-level elements` → wrap multiple roots in one parent.
218
- - 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.
219
- - 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`).
267
+ - 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.
268
+ - 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`).
220
269
  - 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.
221
- - `` 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}`)}"`.
270
+ - ` 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}`)}"`.
222
271
  - 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.
223
272
  - 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>`).
224
- - 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`.
273
+ - 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).
225
274
 
226
275
  ## Server / SSR
227
276