kerfjs 4.4.0 → 4.5.0-beta.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +284 -104
  2. package/LICENSE +1 -1
  3. package/README.md +73 -54
  4. package/ai/cursorrules +130 -48
  5. package/ai/manifest.json +63 -5
  6. package/ai/skill.md +148 -65
  7. package/dist/actions.d.ts +1 -1
  8. package/dist/actions.js +4 -4
  9. package/dist/actions.js.map +1 -1
  10. package/dist/array-signal.js +5 -5
  11. package/dist/async.js +34 -24
  12. package/dist/async.js.map +1 -1
  13. package/dist/attach.d.ts +11 -8
  14. package/dist/attach.js +54 -4
  15. package/dist/attach.js.map +1 -1
  16. package/dist/{chunk-QIP723L4.js → chunk-5WRGJZV6.js} +4 -3
  17. package/dist/chunk-5WRGJZV6.js.map +1 -0
  18. package/dist/{chunk-GY4XV2UV.js → chunk-BDX3R4OM.js} +4 -3
  19. package/dist/chunk-BDX3R4OM.js.map +1 -0
  20. package/dist/{chunk-VVDJLWMP.js → chunk-CEQMZYLR.js} +2 -2
  21. package/dist/chunk-CEQMZYLR.js.map +1 -0
  22. package/dist/{chunk-KEZTD6H4.js → chunk-E5R5GNKE.js} +15 -5
  23. package/dist/chunk-E5R5GNKE.js.map +1 -0
  24. package/dist/{chunk-MRYM3O3V.js → chunk-ELXVRKY2.js} +12 -9
  25. package/dist/chunk-ELXVRKY2.js.map +1 -0
  26. package/dist/{chunk-U32TFTGZ.js → chunk-MK42GLPV.js} +3 -3
  27. package/dist/chunk-MK42GLPV.js.map +1 -0
  28. package/dist/{chunk-SUPUPSBE.js → chunk-QFUNWHKH.js} +42 -20
  29. package/dist/chunk-QFUNWHKH.js.map +1 -0
  30. package/dist/{chunk-SRWQKB33.js → chunk-V757BT6U.js} +206 -111
  31. package/dist/chunk-V757BT6U.js.map +1 -0
  32. package/dist/{chunk-YHH7OUFA.js → chunk-WKIPLNVO.js} +3 -3
  33. package/dist/chunk-WKIPLNVO.js.map +1 -0
  34. package/dist/{chunk-3APBEVHF.js → chunk-Y2FOYPBV.js} +3 -3
  35. package/dist/{chunk-3APBEVHF.js.map → chunk-Y2FOYPBV.js.map} +1 -1
  36. package/dist/{chunk-SAYPJ6XR.js → chunk-ZOIERTUW.js} +10 -6
  37. package/dist/chunk-ZOIERTUW.js.map +1 -0
  38. package/dist/dev.d.ts +9 -6
  39. package/dist/dev.js +66 -19
  40. package/dist/dev.js.map +1 -1
  41. package/dist/html.d.ts +1 -1
  42. package/dist/html.js +8 -7
  43. package/dist/html.js.map +1 -1
  44. package/dist/index.d.ts +1 -1
  45. package/dist/index.js +28 -19
  46. package/dist/index.js.map +1 -1
  47. package/dist/jsx-runtime.js +5 -5
  48. package/dist/list.d.ts +1 -1
  49. package/dist/list.js +304 -213
  50. package/dist/list.js.map +1 -1
  51. package/dist/overlay.d.ts +190 -280
  52. package/dist/overlay.js +471 -364
  53. package/dist/overlay.js.map +1 -1
  54. package/dist/remount.d.ts +5 -3
  55. package/dist/remount.js +29 -10
  56. package/dist/remount.js.map +1 -1
  57. package/dist/router.d.ts +1 -1
  58. package/dist/router.js +73 -28
  59. package/dist/router.js.map +1 -1
  60. package/dist/scope.d.ts +4 -3
  61. package/dist/scope.js +11 -10
  62. package/dist/scope.js.map +1 -1
  63. package/dist/testing.js +4 -4
  64. package/dist/timing.d.ts +3 -2
  65. package/dist/timing.js +5 -5
  66. package/dist/timing.js.map +1 -1
  67. package/llms.txt +13 -6
  68. package/package.json +35 -13
  69. package/setup/cli.mjs +90 -0
  70. package/setup/index.d.mts +37 -0
  71. package/setup/index.mjs +1347 -0
  72. package/setup/jsonc.mjs +201 -0
  73. package/setup/state.schema.json +56 -0
  74. package/dist/chunk-GY4XV2UV.js.map +0 -1
  75. package/dist/chunk-KEZTD6H4.js.map +0 -1
  76. package/dist/chunk-MRYM3O3V.js.map +0 -1
  77. package/dist/chunk-QIP723L4.js.map +0 -1
  78. package/dist/chunk-SAYPJ6XR.js.map +0 -1
  79. package/dist/chunk-SRWQKB33.js.map +0 -1
  80. package/dist/chunk-SUPUPSBE.js.map +0 -1
  81. package/dist/chunk-U32TFTGZ.js.map +0 -1
  82. package/dist/chunk-VVDJLWMP.js.map +0 -1
  83. package/dist/chunk-YHH7OUFA.js.map +0 -1
  84. /package/dist/{attrSelector-Cmu2ZoGO.d.ts → attr-Cmu2ZoGO.d.ts} +0 -0
package/LICENSE CHANGED
@@ -54,7 +54,7 @@ Steele-Idem, which is also distributed under the MIT License:
54
54
 
55
55
  ---
56
56
 
57
- `src/attrSelector.ts`'s `cssEscapeIdent` adapts the CSS identifier escaping of
57
+ `src/attr.ts`'s `cssEscapeIdent` adapts the CSS identifier escaping of
58
58
  the [CSS.escape polyfill](https://github.com/mathiasbynens/CSS.escape) by
59
59
  Mathias Bynens, which is also distributed under the MIT License:
60
60
 
package/README.md CHANGED
@@ -4,60 +4,86 @@
4
4
 
5
5
  <h1 align="center">Kerf</h1>
6
6
 
7
- <p align="center"><em>The smallest cut.</em></p>
7
+ <p align="center"><em>A tiny reactive UI framework. The smallest cut.</em></p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://brianwestphal.github.io/kerf/"><strong>brianwestphal.github.io/kerf</strong></a> — docs · examples · live demo
11
11
  </p>
12
12
 
13
+ <p align="center">
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" />
16
+ <img src="https://img.shields.io/npm/l/kerfjs.svg" alt="MIT license" />
17
+ <img src="https://img.shields.io/badge/types-included-3178c6.svg" alt="TypeScript types included" />
18
+ </p>
19
+
13
20
  ---
14
21
 
15
- > Introducing Kerf.
16
- > The smallest cut.
17
- >
18
22
  > ~12 KB. No virtual DOM. No compiler. No magic.
19
23
  > Reactive UI that touches only the bytes that changed.
20
24
 
21
25
  ```ts
22
- import { signal, mount } from 'kerfjs';
26
+ import { signal, mount, delegate } from 'kerfjs';
23
27
 
24
28
  const count = signal(0);
29
+ const app = document.getElementById('app')!;
25
30
 
26
- mount(document.getElementById('app')!, () => (
31
+ mount(app, () => (
27
32
  <div>
28
33
  <button data-action="inc">+</button>
29
34
  <span>{count.value}</span>
30
35
  </div>
31
36
  ));
37
+
38
+ delegate(app, 'click', '[data-action="inc"]', () => count.value++);
32
39
  ```
33
40
 
34
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.
35
42
 
36
43
  Here's the whole development loop — write a component, run the dev server, click around, edit, watch the browser pick it up:
37
44
 
38
- [![Animated coding session: a counter component is typed line by line into an editor, npm run dev starts in a terminal and the localhost link is clicked, the running app is clicked in a browser, then back in the editor a computed class is added — selecting "btn" and typing a bound {cls} hole — and the browser shows the button change color at the fifth click](https://brianwestphal.github.io/kerf/demos/getting-started.svg)](https://brianwestphal.github.io/kerf/getting-started/)
45
+ [![Animated demo: coding a kerf counter in an editor, running the dev server, then hot-reloading a class change in the browser](https://brianwestphal.github.io/kerf/demos/getting-started.svg)](https://brianwestphal.github.io/kerf/getting-started/)
46
+ **[Quick start](#quick-start) · [Why kerf](#why-kerf) · [Quick tour](#quick-tour) · [Docs & examples](https://brianwestphal.github.io/kerf/)**
39
47
 
40
- ## Why Kerf
48
+ ## Quick start
49
+
50
+ ```bash
51
+ npm install kerfjs
52
+ ```
53
+
54
+ For an existing project, install the AI guidance, strict TypeScript/ESLint
55
+ feedback, and package scripts with a reviewed dry run:
56
+
57
+ ```bash
58
+ npx kerfjs setup # inspect a value-level plan
59
+ npx kerfjs setup --write --yes # apply it
60
+ ```
41
61
 
42
- 1. **Small bundle.** ~12 KB minified + gzipped including `@preact/signals-core` (~13 KB with `arraySignal`). One runtime dependency. No virtual DOM, no scheduler, no concurrent-mode machinery. On the official [krausest js-framework-benchmark](https://krausest.github.io/js-framework-benchmark/current.html) — where kerf is a listed entry, measured on the same reference machine as every competitor ([local mirror](./bench/results.md)) — kerf is in the same cluster as Vue, vanjs, and Lit on most operations; Solid's compiler leads the update-path benchmarks (notably `partial update`), which kerf doesn't try to match by design — no compiler.
62
+ The command detects core versus `@kerfjs/ui`, supports deterministic npm,
63
+ pnpm, and Yarn workspace selection plus manager-correct offline installs, and
64
+ never adds UI to a core-only project. See
65
+ [`docs/24-ai-first-setup.md`](./docs/24-ai-first-setup.md).
43
66
 
44
- 2. **No virtual DOM, no compiler.** JSX → HTML strings → native diff. DevTools shows the real DOM because it *is* the DOM.
67
+ ```jsonc
68
+ // tsconfig.json — point JSX at kerf
69
+ { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "kerfjs" } }
70
+ ```
45
71
 
46
- 3. **Values bind, structure re-renders.** Hand a signal *itself* into a JSX hole — `class={selectedId}` or `{status}` — and kerf binds that one node directly: when the signal changes, only that attribute or text node updates, with no render re-run and no list reconcile. A selection flip on a 10,000-row table touches exactly one class. Taken to its logical end: a mount whose render reads no `.value` at all runs **exactly once, forever** — every subsequent update flows through the per-hole bindings. Read `.value` in the render only when the *structure* depends on it (conditionals, list shape).
72
+ 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/).
47
73
 
48
- 4. **Focus, selection, listeners survive re-renders — even mid-list.** The reconciler morphs instead of rebuilding, so caret position, selection range, IME composition, and delegated listeners survive every re-render. Keyed lists get the same treatment: same-identity rows are updated *in place* rather than recreated, so a row reorder or a single-cell edit no longer blows away focus, scroll, or an in-flight animation the way node replacement does.
74
+ ## Why Kerf
49
75
 
50
- 5. **Safe by default.** Text and attribute values are HTML-escaped automatically, URL attributes are scheme-screened (`javascript:` / script-carrying `data:` dropped), inline `on*` handlers are rejected outright, and the same screening covers the fine-grained bound path so untrusted data stays inert even when kerf is dropped into someone else's page. The URL screen fails loudly at your desk (throws in development) and degrades safely in the field (warns and drops in production). `raw()` is the explicit, auditable opt-out.
76
+ 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.
51
77
 
52
- 6. **JSX typed against HTML, not against React.** Tags and attributes are checked at compile time — `<diiv>` and `<input typo />` don't build. The attribute types are derived from the HTML standard rather than another framework's property table, and that distinction has teeth: `draggable` and `spellcheck` are *enumerated* attributes that take the strings `"true"` / `"false"`, so kerf rejects `draggable={true}` rather than quietly emitting markup that means the opposite. Custom elements and web components slot in with one declaration merge.
78
+ 2. **No virtual DOM, no compiler.** JSX HTML strings native diff. DevTools shows the real DOM because it _is_ the DOM.
53
79
 
54
- 7. **Small public API.** ~18 exports from the main barrel (plus `arraySignal`, the `html` tagged template, and the companion-utility subpaths below each opt-in, none in the core). No hooks, no lifecycle, no per-instance state. Components are plain functions that return JSX.
80
+ 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))
55
81
 
56
- 8. **Batteries on their own subpaths.** Nine optional, tree-shakeable subpaths cover the patterns every real app otherwise hand-rolls**`kerfjs/list`** (a keyed list with per-row fine-grained mounts and fixed / app-declared / measured-height viewport **virtualization**, plus a `content-visibility` mode that keeps every row find-in-page-able), **`kerfjs/router`** (a "postcard **router**": route matching, `navigate`, auto `<a>` link interception, and a keyed outlet the *core* stays router-free, this is opt-in), **`kerfjs/overlay`** (modals, `confirm` / `prompt` / `form` / `choice`, anchored popovers + tooltips, toasts — with opt-in native **top-layer** backing that stacks above any `z-index`), **`kerfjs/async`** (`resource` async-state with a built-in stale-response guard + SWR cache), **`kerfjs/scope`** (dispose-scopes that tie teardown to a DOM node's lifetime), plus `timing`, `remount`, `attach`, and `actions`. None of them grows the ~12 KB core until you import it.
82
+ 4. **Focus, selection, and listeners survive re-renderseven 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.
57
83
 
58
- 9. **Plain TS, plain JSX, plain ESM.** Drops into anything using esbuild / Vite / tsup. No plugin chain. And with the `html` tagged template (`import { html } from 'kerfjs/html'` identical runtime semantics to JSX), a CDN / importmap project needs no build step at all.
84
+ 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.
59
85
 
60
- 10. **Grown-up tooling around a tiny core.** An [ESLint plugin](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) that enforces the hard rules at edit time, an opt-in family of `KERF_DEV_WARN_*` runtime warnings that catch the classic mistakes in development (with zero production cost), a `create-kerf-component` scaffold for publishable component packages, drop-in AI-assistant configs, and side-by-side migration guides for a dozen-plus frameworks none of which grows the core runtime past ~12 KB.
86
+ **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.
61
87
 
62
88
  ## When to use Kerf
63
89
 
@@ -73,7 +99,7 @@ Here's the whole development loop — write a component, run the dev server, cli
73
99
  - Need a full ecosystem (router + forms + data + SSR streaming) → **Next.js / Remix / SolidStart**.
74
100
  - Building a deeply componentised design-system app → **React / Solid / Svelte**.
75
101
  - Need React Native / cross-platform mobile → **React** (Kerf + Tauri/Electron also covers many of these cases).
76
- - Building a static site → **Astro** (we use it for *this* project's site).
102
+ - Building a static site → **Astro** (we use it for _this_ project's site).
77
103
  - Already invested in a framework where switching cost outweighs the bundle size gain.
78
104
 
79
105
  ## Quick tour
@@ -133,7 +159,7 @@ delegate(root, 'click', REMOVE.selector, (_e, btn) => { /* … */ }); // in dele
133
159
 
134
160
  ### Fine-grained updates: bind a signal into a hole
135
161
 
136
- 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:
162
+ 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:
137
163
 
138
164
  ```ts
139
165
  const status = signal('idle');
@@ -147,9 +173,9 @@ mount(root, () => (
147
173
  status.value = 'saving'; // updates the class + the text node directly — no re-render
148
174
  ```
149
175
 
150
- The headline use is external state driving one spot: a `selectedId` flipping a single row's class inside a 10,000-row `each()` list touches exactly that one node, 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.
176
+ 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.
151
177
 
152
- 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.
178
+ 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.
153
179
 
154
180
  ### Long keyed lists: `arraySignal`
155
181
 
@@ -176,11 +202,11 @@ The class lives in its own subpath so apps that don't need it shed ~1 KB. Reads
176
202
  `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`:
177
203
 
178
204
  ```ts
179
- import { morph, raw } from 'kerfjs';
205
+ import { morph, raw } from "kerfjs";
180
206
 
181
- morph(liveCard, freshlyBuiltCardEl); // Element template
182
- morph(liveCard, '<article class="card">…</article>'); // raw HTML string
183
- morph(liveCard, raw(htmlFromServer)); // SafeHtml
207
+ morph(liveCard, freshlyBuiltCardEl); // Element template
208
+ morph(liveCard, '<article class="card">…</article>'); // raw HTML string
209
+ morph(liveCard, raw(htmlFromServer)); // SafeHtml
184
210
  ```
185
211
 
186
212
  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.
@@ -191,14 +217,19 @@ Same algorithm `mount()` uses internally — `data-morph-skip`, `data-morph-skip
191
217
 
192
218
  ```html
193
219
  <script type="module">
194
- import { signal, mount, each } from 'https://esm.sh/kerfjs@4';
195
- import { html } from 'https://esm.sh/kerfjs@4/html';
196
-
197
- const items = signal([{ id: 1, label: 'no build step' }]);
198
-
199
- mount(document.getElementById('app'), () => html`
200
- <ul>${each(items.value, (i) => html`<li id="${i.id}">${i.label}</li>`)}</ul>
201
- `);
220
+ import { signal, mount, each } from "https://esm.sh/kerfjs@4";
221
+ import { html } from "https://esm.sh/kerfjs@4/html";
222
+
223
+ const items = signal([{ id: 1, label: "no build step" }]);
224
+
225
+ mount(
226
+ document.getElementById("app"),
227
+ () => html`
228
+ <ul>
229
+ ${each(items.value, (i) => html`<li id="${i.id}">${i.label}</li>`)}
230
+ </ul>
231
+ `,
232
+ );
202
233
  </script>
203
234
  ```
204
235
 
@@ -227,7 +258,7 @@ const list = bindList(scrollEl, messages, {
227
258
  observeRowHeights(list); // one ResizeObserver → kerf anchor-corrects scroll
228
259
  ```
229
260
 
230
- 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):
261
+ 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):
231
262
 
232
263
  ```ts
233
264
  import { createRouter } from 'kerfjs/router';
@@ -247,21 +278,9 @@ mount(app, () => <div><nav>{/* <a href> links, auto-intercepted */}</nav>{router
247
278
 
248
279
  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`).
249
280
 
250
- ## Install
281
+ ## Optional tooling
251
282
 
252
- ```bash
253
- npm install kerfjs
254
- ```
255
-
256
- ```jsonc
257
- // tsconfig.json
258
- {
259
- "compilerOptions": {
260
- "jsx": "react-jsx",
261
- "jsxImportSource": "kerfjs"
262
- }
263
- }
264
- ```
283
+ Install and JSX setup are in [Quick start](#quick-start) above. These companion packages are opt-in.
265
284
 
266
285
  ### Optional: `eslint-plugin-kerfjs`
267
286
 
@@ -273,7 +292,7 @@ npm install --save-dev eslint-plugin-kerfjs
273
292
 
274
293
  ```js
275
294
  // eslint.config.js (flat config, ESLint v9+)
276
- import kerfjs from 'eslint-plugin-kerfjs';
295
+ import kerfjs from "eslint-plugin-kerfjs";
277
296
  export default [kerfjs.configs.recommended];
278
297
  ```
279
298
 
@@ -281,7 +300,7 @@ Full docs at [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwes
281
300
 
282
301
  ### Optional: `create-kerf-component`
283
302
 
284
- 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:
303
+ 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:
285
304
 
286
305
  ```bash
287
306
  npm create kerf-component@latest my-widgets
@@ -296,15 +315,15 @@ See [`docs/13-component-packages.md`](./docs/13-component-packages.md) for the f
296
315
  - **Migrating:** [coming from another framework?](https://brianwestphal.github.io/kerf/migrating/) — side-by-side TodoMVC translations + per-framework gotchas
297
316
  - **AI guide:** [`docs/ai/usage-guide.md`](./docs/ai/usage-guide.md) — reference for AI tools fetching kerf docs (linked from `llms.txt`)
298
317
  - **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/))
299
- - **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/))
318
+ - **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/))
300
319
  - **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)
301
320
  - **Repo:** [github.com/brianwestphal/kerf](https://github.com/brianwestphal/kerf)
302
321
 
303
322
  ## Why "kerf"?
304
323
 
305
- 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.
324
+ 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.
306
325
 
307
- (And yes, ~~kerformance~~ → *performance* jokes were written. They were also rejected.)
326
+ (And yes, ~~kerformance~~ → _performance_ jokes were written. They were also rejected.)
308
327
 
309
328
  ## Status
310
329
 
package/ai/cursorrules CHANGED
@@ -1,8 +1,13 @@
1
- <!-- kerf-skill-version: 1.14.2 -->
1
+ <!-- kerf-skill-version: 1.20.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
13
  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.
@@ -17,45 +22,100 @@ You are writing a UI in kerf — a ~12 KB reactive framework (~13 KB with `array
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.
19
24
 
25
+ ## Optional first-party UI
26
+
27
+ Install `@kerfjs/ui` for shared toolbars, menu rows, controlled tab bars, headers/value tables,
28
+ resizable regions, selects, banners, empty states, and loading indicators.
29
+ Import visual components from explicit subpaths so a CSS-aware browser bundler
30
+ includes only their reachable styles. The root barrel and
31
+ `@kerfjs/ui/unstyled` are CSS-free; pair the barrel with `styles.css` only when
32
+ the complete layer is intentional. Reuse these primitives before creating
33
+ parallel local ones; keep domain state, commands, routing, and tab/menu policy
34
+ in the app.
35
+
36
+ Use MenuItem/MenuActionRow/MenuHeader `rootAttributes` only for application
37
+ `data-*` metadata. MenuActionRow `trailingActionAttributes` and MenuHeader
38
+ `triggerAttributes` additionally support native popover target/action and
39
+ `aria-controls`/`aria-haspopup`. Do not use these slots to override action,
40
+ item identity, selection, disclosure, naming, disabled, icon, or role
41
+ semantics; one `role="menuitem"` does not make a complete menu widget.
42
+ `MenuItem.trailing` is dormant. Use `MenuActionRow` for sibling primary and
43
+ trailing native buttons, and keep its `label`, `icon`, and
44
+ `trailingActionIcon` SafeHtml slots free of controls.
45
+
46
+ Wire emitted `data-action` hooks at the mount root and retain disposers.
47
+ `wireResizableRegions()` and `wireTabBars()` are eventful helpers and return
48
+ disposers. Compose `AppTab` inside controlled `TabBar`; `wireTabBars()` supplies
49
+ horizontal edge autoscroll while dragging. Apply reorder reports
50
+ with `reorderTabs()`, and keep order, selection, close policy, routing, panels,
51
+ and persistence in the host. Icons/spinners are decorative unless
52
+ labeled, and assertive banners are only for urgent interruption.
53
+
54
+ The opinionated semantic ramps match Hot Sheet 2 and Web Awesome. Override
55
+ `--kui-color-*` globally or component properties such as
56
+ `--kui-state-banner-background` at a tone or instance boundary; do not replace
57
+ private descendant selectors.
58
+
59
+ For Web Awesome's broader free component set, import the CSS-only
60
+ `@kerfjs/ui/webawesome.css` theme once, then import only each Web Awesome
61
+ component module the app renders. The theme registers no component JavaScript
62
+ and remains overridable through later or scoped `--wa-*` values.
63
+
64
+ `Select` is pure markup until the app explicitly imports
65
+ `@kerfjs/ui/select/register`. Do that once in the application entry that uses
66
+ Select; never hide registration inside a component. Web Awesome is an optional
67
+ peer and stays out of bundles that use neither Select nor the Web Awesome theme. More detail ships at
68
+ `node_modules/@kerfjs/ui/ai/skill.md`.
69
+
20
70
  ## Public API — one import path
21
71
 
22
72
  ```ts
23
73
  import {
24
- signal, computed, effect, batch, // reactivity
25
- defineStore, resetAllStores, // stores
26
- mount, morph, each, // render (reactive + one-shot) + keyed list
27
- delegate, delegateCapture, // events
28
- toElement, // direct JSX → DOM Element (or DocumentFragment for multi-root)
29
- SafeHtml, isSafeHtml, raw, Fragment,
30
- } from 'kerfjs';
74
+ signal,
75
+ computed,
76
+ effect,
77
+ batch, // reactivity
78
+ defineStore,
79
+ resetAllStores, // stores
80
+ mount,
81
+ morph,
82
+ each, // render (reactive + one-shot) + keyed list
83
+ delegate,
84
+ delegateCapture, // events
85
+ toElement, // direct JSX → DOM Element (or DocumentFragment for multi-root)
86
+ SafeHtml,
87
+ isSafeHtml,
88
+ raw,
89
+ Fragment,
90
+ } from "kerfjs";
31
91
 
32
92
  // Optional, only when you need granular collection updates:
33
- import { arraySignal } from 'kerfjs/array-signal';
93
+ import { arraySignal } from "kerfjs/array-signal";
34
94
 
35
95
  // Development diagnostics — gate with YOUR build's dev flag, in YOUR code.
36
- if (import.meta.env.DEV) await import('kerfjs/dev');
96
+ if (import.meta.env.DEV) await import("kerfjs/dev");
37
97
  ```
38
98
 
39
- | Export | Use |
40
- | --- | --- |
41
- | `signal(initial)` | atomic reactive state; read/write via `.value` |
42
- | `computed(fn)` | derived value (read-only) |
43
- | `effect(fn)` | side effect that re-runs on signal change |
44
- | `batch(fn)` | coalesce multiple writes into one re-run |
45
- | `defineStore({initial, actions})` | named multi-consumer state |
46
- | `resetAllStores()` | reset every store (test teardown) |
47
- | `mount(el, render)` | bind reactive render to a DOM element; returns a disposer |
48
- | `morph(liveRoot, template)` | one-shot reconcile against an already-populated element (SSR hydration, page-refresh diffs). Template can be `Element`, `SafeHtml`, or HTML string |
49
- | `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 |
50
- | `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 |
51
- | `delegate(root, type, sel, h)` | one listener at the root, walks `closest(selector)` from target |
52
- | `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()` |
53
- | `attr(name, value)` | pre-computed `AttrSpec<N,V>` — `.selector` for `delegate()`, `.attrs` to spread into JSX (rename-safe) |
54
- | `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 |
55
- | `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. |
56
- | `raw(html)` | inject pre-escaped HTML |
57
- | `arraySignal(initial?)` | granular keyed-list signal at `kerfjs/array-signal` subpath; `each()` reconciles in O(patches) |
58
- | `` 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}"`) |
99
+ | Export | Use |
100
+ | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
101
+ | `signal(initial)` | atomic reactive state; read/write via `.value` |
102
+ | `computed(fn)` | derived value (read-only) |
103
+ | `effect(fn)` | side effect that re-runs on signal change |
104
+ | `batch(fn)` | coalesce multiple writes into one re-run |
105
+ | `defineStore({initial, actions})` | named multi-consumer state |
106
+ | `resetAllStores()` | reset every store (test teardown) |
107
+ | `mount(el, render)` | bind reactive render to a DOM element; returns a disposer |
108
+ | `morph(liveRoot, template)` | one-shot reconcile against an already-populated element (SSR hydration, page-refresh diffs). Template can be `Element`, `SafeHtml`, or HTML string |
109
+ | `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 |
110
+ | `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 |
111
+ | `delegate(root, type, sel, h)` | one listener at the root, walks `closest(selector)` from target |
112
+ | `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()` |
113
+ | `attr(name, value)` | pre-computed `AttrSpec<N,V>` — `.selector` for `delegate()`, `.attrs` to spread into JSX (rename-safe) |
114
+ | `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 |
115
+ | `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. |
116
+ | `raw(html)` | inject pre-escaped HTML |
117
+ | `arraySignal(initial?)` | granular keyed-list signal at `kerfjs/array-signal` subpath; `each()` reconciles in O(patches) |
118
+ | `` 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}"`) |
59
119
 
60
120
  ## Hard rules — get these right on the first try
61
121
 
@@ -66,7 +126,7 @@ if (import.meta.env.DEV) await import('kerfjs/dev');
66
126
  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).
67
127
  6. **One `mount()` per root.** Don't nest. Compose with plain functions that return JSX.
68
128
  7. **No `<MyComponent />` semantics with hooks.** Components are plain functions returning JSX. State lives in module-scope signals or stores, never in component closures.
69
- 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.
129
+ 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.
70
130
  9. **Store actions receive `(set, get)`, not `(state)`.** `set(next)` replaces state; mutating `get()` does nothing.
71
131
  10. **Use `data-action` attributes, not inline `onClick`.** Inline handlers are NOT supported by the JSX → string runtime; delegate from the root instead.
72
132
  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.
@@ -79,22 +139,26 @@ if (import.meta.env.DEV) await import('kerfjs/dev');
79
139
  When deciding which primitive to reach for, work down the axes:
80
140
 
81
141
  **Events.**
142
+
82
143
  - 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.
83
144
  - 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.
84
145
  - 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.
85
146
 
86
147
  **Lists.**
148
+
87
149
  - Items change across renders (todos, chat messages, table rows) → `each(items, render)`.
88
150
  - Static structural enumeration whose row render reads signals → `STATIC.map(item => <jsx/>)`. Inner `each(item.children, …)` still gets keyed reconcile.
89
151
  - Long list with point-wise mutations → `arraySignal` + `each(arraySig, render)` for O(patches) updates.
90
152
 
91
153
  **Side effects / imperative DOM.**
154
+
92
155
  - Library-owned subtree survives across renders → `data-morph-skip` on host.
93
156
  - Host attributes morph but subtree preserved → `data-morph-skip-children`.
94
157
  - Imperatively-injected element survives the trailing-removal pass → `data-morph-preserve`.
95
158
  - Focused input / contenteditable caret survives re-renders → automatic; no opt-in.
96
159
 
97
160
  **Raw HTML.**
161
+
98
162
  - User-controlled HTML → sanitize first (DOMPurify) then `raw(sanitized)`.
99
163
  - Author-controlled trusted HTML → `raw(html)` directly.
100
164
  - 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.
@@ -107,20 +171,27 @@ When deciding which primitive to reach for, work down the axes:
107
171
  // updates that one node directly with no render re-run; read `.value` only
108
172
  // when the JSX STRUCTURE depends on the signal (conditionals, list shape).
109
173
  const count = signal(0);
110
- const ACTIONS = { inc: attr('data-action', 'inc') } as const satisfies Record<string, AttrSpec<'data-action'>>;
174
+ const ACTIONS = { inc: attr("data-action", "inc") } as const satisfies Record<
175
+ string,
176
+ AttrSpec<"data-action">
177
+ >;
111
178
 
112
- mount(document.getElementById('app')!, () => (
179
+ mount(document.getElementById("app")!, () => (
113
180
  <div>
114
181
  <button {...ACTIONS.inc.attrs}>+</button>
115
182
  <span>{count}</span>
116
183
  </div>
117
184
  ));
118
- delegate(rootEl, 'click', ACTIONS.inc.selector, () => { count.value += 1; });
185
+ delegate(rootEl, "click", ACTIONS.inc.selector, () => {
186
+ count.value += 1;
187
+ });
119
188
 
120
189
  // Keyed list with per-item memoization
121
190
  mount(listEl, () => (
122
191
  <ul>
123
- {each(rows.value, (row) => <li data-key={row.id}>{row.label}</li>)}
192
+ {each(rows.value, (row) => (
193
+ <li data-key={row.id}>{row.label}</li>
194
+ ))}
124
195
  </ul>
125
196
  ));
126
197
 
@@ -128,8 +199,8 @@ mount(listEl, () => (
128
199
  const cart = defineStore({
129
200
  initial: () => ({ items: [] as string[] }),
130
201
  actions: (set, get) => ({
131
- add: (id: string) => set({ items: [...get().items, id] }),
132
- clear: () => set({ items: [] }),
202
+ add: (id: string) => set({ items: [...get().items, id] }),
203
+ clear: () => set({ items: [] }),
133
204
  }),
134
205
  });
135
206
 
@@ -139,11 +210,16 @@ morph(liveCard, '<article class="card">…</article>');
139
210
  // No build step (CDN / importmap): the html tagged template instead of JSX.
140
211
  // Same runtime semantics as JSX; write real HTML attribute names; a hole must
141
212
  // be a text position or a COMPLETE attribute value (partial values throw).
142
- import { html } from 'kerfjs/html';
143
- mount(rootEl, () => html`
144
- <div class="${cls}">Count: ${count}</div>
145
- <ul>${each(rows.value, (row) => html`<li data-key="${row.id}">${row.label}</li>`)}</ul>
146
- `);
213
+ import { html } from "kerfjs/html";
214
+ mount(
215
+ rootEl,
216
+ () => html`
217
+ <div class="${cls}">Count: ${count}</div>
218
+ <ul>
219
+ ${each(rows.value, (row) => html`<li data-key="${row.id}">${row.label}</li>`)}
220
+ </ul>
221
+ `,
222
+ );
147
223
 
148
224
  // Fine-grained binding (opt-in): pass the signal/computed ITSELF into a hole
149
225
  // so a change updates ONLY that node (no render re-run, no reconcile). For a
@@ -152,9 +228,15 @@ mount(rootEl, () => html`
152
228
  const selectedId = signal<number | null>(null);
153
229
  mount(listEl, () => (
154
230
  <ul>
155
- {each(rows.value, (row) => (
156
- <li class={computed(() => (row.id === selectedId.value ? 'sel' : ''))}>{row.label}</li>
157
- ), (row) => row.id)}
231
+ {each(
232
+ rows.value,
233
+ (row) => (
234
+ <li class={computed(() => (row.id === selectedId.value ? "sel" : ""))}>
235
+ {row.label}
236
+ </li>
237
+ ),
238
+ (row) => row.id,
239
+ )}
158
240
  </ul>
159
241
  ));
160
242
  ```
@@ -170,10 +252,10 @@ mount(listEl, () => (
170
252
  - SVG renders as broken / namespaceless markup → use `mount` (HTML path) or `toElement` (SVG-aware), not `innerHTML`.
171
253
  - Library widget destroyed on every render → wrap host in `data-morph-skip`; mount the library imperatively after first render.
172
254
  - `each(): row render at index N produced K top-level elements` → wrap multiple roots in one parent.
173
- - 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.
174
- - 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`).
255
+ - 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.
256
+ - 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`).
175
257
  - 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.
176
- - `` 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}`)}"`.
258
+ - ` 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}`)}"`.
177
259
  - 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.
178
260
  - 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>`).
179
261
  - 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`.