kerfjs 0.15.0-beta.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,7 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  ## Unreleased
8
8
 
9
9
 
10
- - KF-260 — **The snapshot list reconciler now updates a changed-but-stable row *in place* instead of replacing its DOM node.** When a re-render produces the same item refs in the same order (no insert/remove/move) but some rows' HTML changed, kerf morphs each changed row on its existing node — reusing the granular path's surgical attribute/text fast paths and `morph()` (with a `replaceChild` fallback only when the row's top-level tag changes) — rather than removing the old node and inserting a freshly-parsed one. Previously such a row was treated as "replaced": the node swap forced a full relayout, which in a large `<table>` is expensive even when only one attribute flipped. Two consequences: (1) **perf** — a selection model that keeps one external "selected id" and derives the row class via `each()`'s `cacheKey` (rather than a per-row flag) now updates select-row in the competitive range instead of several times slower, since it no longer node-swaps; (2) **behavior** — snapshot-path row updates (plain-array `each()` and `cacheKey`-driven re-renders) now preserve the row's DOM node, so focus, scroll position, IME composition, and in-progress CSS transitions on a changed row survive the update (matching the `arraySignal` granular path). Enter-animations keyed on element *creation* no longer re-fire for an in-place update, since the row is no longer recreated. Internal-only change (new `src/list-reconcile-inplace.ts`; the now-subsumed no-op snapshot fast path was removed); no public API change. A row that both moves *and* changes content in the same reconcile still takes the node-replacing path.
10
+ - KF-260 — **The snapshot list reconciler now updates a changed-but-stable row *in place* instead of replacing its DOM node.** When a re-render produces the same item refs in the same order (no insert/remove/move) but some rows' HTML changed, kerf morphs each changed row on its existing node — reusing the granular path's surgical attribute/text fast paths and `morph()` (with a `replaceChild` fallback only when the row's top-level tag changes) — rather than removing the old node and inserting a freshly-parsed one. Previously such a row was treated as "replaced": the node swap forced a full relayout, which in a large `<table>` is expensive even when only one attribute flipped. Two consequences: (1) **perf** — a selection model that keeps one external "selected id" and derives the row class via `each()`'s `cacheKey` (rather than a per-row flag) now updates select-row in the competitive range instead of several times slower, since it no longer node-swaps; (2) **behavior** — snapshot-path row updates (plain-array `each()` and `cacheKey`-driven re-renders) now preserve the row's DOM node, so focus, scroll position, IME composition, and in-progress CSS transitions on a changed row survive the update (matching the `arraySignal` granular path). Enter-animations keyed on element *creation* no longer re-fire for an in-place update, since the row is no longer recreated. **This is a behavior change at the 0.15.0 boundary — versions ≤ 0.14.x recreated the row node on a content change; 0.15.0 onward reuses it.** Internal-only change (new `src/list-reconcile-inplace.ts`; the now-subsumed no-op snapshot fast path was removed); no public API change. A row that both moves *and* changes content in the same reconcile still takes the node-replacing path.
11
11
  - KF-244 — **Each complete example app's docs page now opens with an animated SVG preview of the real app in action**, so a reader sees what the live demo does before clicking through. The five showcase apps (`todomvc`, `markdown-editor`, `kanban`, `chat`, `dashboard`) get a preview at the top of their page plus a gallery on the complete-examples index; the two migration-companion apps (`counter-store` on the Redux page, `cart-htmx` on the htmx page) get one inline next to their "Run live" link. Each preview is a self-contained, CSS-animated SVG (~60–100 KB) captured with [`domotion-svg`](https://github.com/brianwestphal/domotion) driving the real app through the same headline interaction its `tests/browser/example-apps.spec.ts` smoke spec exercises — they animate inside an `<img>` and scale crisply. New assets under `site/public/demos/`; the per-app capture configs + a `capture-demos.sh` regenerator live in `site/scripts/demo-captures/` (with a `site/scripts/build-demos-for-capture.mjs` helper that builds each app with a per-app base into a shared serve root). Docs-only change; no runtime or API impact.
12
12
  - KF-243 — **`mount()` now adopts an inert-document `rootEl` into the live `document` before its first render** — defense-in-depth complementing the KF-240 `toElement()` fix. `toElement()` already adopts its own output, but a consumer can hand `mount()` an element built another way (their own `DOMParser`, a detached `<template>.content` child, `document.implementation.createHTMLDocument()` output) whose `ownerDocument` has no browsing context; `mount()`'s first-render `rootEl.innerHTML = …` on such a node would hit the same WebKit inert-document fragment-parsing bug. `mount()` now adopts it up front. Only genuinely inert owners (`defaultView === null`) are adopted — a live element in another realm (e.g. an iframe, `defaultView !== null`) is left in place, since `mount()` works on it as-is and must never move a node out of its own window. Normal live-document roots (the overwhelmingly common case) are untouched. No API change. New unit tests in `tests/unit/mount.test.ts` (adopts an inert root + renders correctly; leaves a live root's `ownerDocument`/parent untouched).
13
13
  - KF-240 — **`toElement()` now adopts its result into the live `document` before returning it.** Both parse paths produced nodes owned by an *inert* document — the `<template>.content` path (HTML) yields nodes owned by the template-contents owner document, and the `DOMParser` path (SVG) yields nodes owned by the parser's document — neither being the document the caller renders into. Operating on such a node before it's inserted, most notably `mount()`'s first-render `rootEl.innerHTML = …`, runs against an inert-document element, which on **WebKit** trips a fragment-parsing bug: under rapid bursts (e.g. a feed mounting many `toElement(<div/>)` cards in one synchronous flush) the parser can hand back a *previous* parse's nodes, so a freshly-built element silently inherits unrelated DOM — the Safari-only "component renders pre-filled / in the wrong state on first paint" symptom diagnosed downstream. The render, the signals, and the produced HTML string were all correct; only the inert-document `innerHTML` parse diverged, and only on WebKit (Chromium never reproduced it). `toElement` now calls `document.adoptNode(...)` on the returned `Element` / `DocumentFragment` (identity- and namespace-preserving), so consumers can mount it / set `innerHTML` / otherwise mutate it before insertion without tripping engine-specific inert-document behavior. No API change — the returned shape is unchanged; the node is just guaranteed to belong to the live document. New deterministic regression guards in `tests/unit/toElement.test.ts` (`ownerDocument === document` for every return shape) + a real-browser spec `tests/browser/toelement-adopt.spec.ts` exercising the mount-before-insert burst across Chromium / Firefox / WebKit.
@@ -28,6 +28,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
28
28
  - `kerfjs` npm package now bundles the drop-in AI-assistant configs at `ai/skill.md`, `ai/cursorrules`, and `ai/manifest.json` — so `npm install kerfjs` lands them directly on disk instead of asking consumers to find them on GitHub. The repo-root `kerf.claude-skill.md` / `kerf.cursorrules` remain the source of truth; `ai/` is regenerated by `scripts/sync-ai-bundle.mjs` and kept honest by the new `npm run check:ai-bundle-in-sync` gate (wired into `npm run check`). Each bundled file carries a `kerf-skill-version` line + a `KERF-APP-CANONICAL-END` marker so the new ESLint rule can detect drift and auto-fix only the kerf-maintained section while preserving consumer customizations below the marker. See `docs/12-ai-assistant-configs.md`.
29
29
  - `eslint-plugin-kerfjs` v0.9.0 adds `kerfjs/ai-assistant-configs` (`warn` in recommended config) — once per lint pass, checks the consumer's `.claude/skills/kerf-app/SKILL.md` and `.cursorrules` against the bundled `kerfjs/ai/manifest.json`. Reports `missing` / `stale` / `forked` states; `eslint --fix` writes the bundled canonical above the `KERF-APP-CANONICAL-END` marker and preserves the consumer's append zone below it (the "versioned-section preservation" strategy). Granular disable via `['warn', { claude: false, cursor: true }]`.
30
30
 
31
+ ## [0.15.0] - 2026-06-30
32
+
33
+
34
+
35
+ - List updates now morph same-identity rows in place instead of recreating their DOM nodes, avoiding full-table relayout on large lists and preserving DOM identity, focus, and IME composition across re-renders.
36
+
37
+
38
+ - New guide on incremental migration: kerf can own a single DOM subtree and coexist with React (or any framework), letting you migrate one island at a time.
39
+ - New guide on building and publishing reusable kerf components as npm packages.
40
+ - Example app documentation pages now open with an animated SVG preview of the real app in action, plus a gallery on the complete-examples index.
41
+
31
42
  ## [0.14.0] - 2026-05-27
32
43
 
33
44
 
package/README.md CHANGED
@@ -31,7 +31,7 @@ That's it. Your JSX renders to HTML strings, kerf's native diff applies the mini
31
31
 
32
32
  ## Why Kerf
33
33
 
34
- 1. **Small bundle.** ~11 KB minified + gzipped including `@preact/signals-core` (~12 KB with `arraySignal`). One runtime dependency. No virtual DOM, no scheduler, no concurrent-mode machinery. On the [krausest js-framework-benchmark](./bench/results.md) kerf is in the same cluster as Vue, vanjs, and Lit on most operations; Solid wins the compiler-driven `select row` and `partial update` benchmarks.
34
+ 1. **Small bundle.** ~11 KB minified + gzipped including `@preact/signals-core` (~12 KB with `arraySignal`). One runtime dependency. No virtual DOM, no scheduler, no concurrent-mode machinery. On the [krausest js-framework-benchmark](./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.
35
35
 
36
36
  2. **No virtual DOM, no compiler.** JSX → HTML strings → native diff. DevTools shows the real DOM because it *is* the DOM.
37
37
 
package/ai/cursorrules CHANGED
@@ -1,4 +1,4 @@
1
- <!-- kerf-skill-version: 1.2.0 -->
1
+ <!-- kerf-skill-version: 1.2.1 -->
2
2
  # kerf.cursorrules — rules for building apps with kerf
3
3
  #
4
4
  # Drop this file into your project as `.cursorrules` (Cursor will pick it
@@ -135,6 +135,7 @@ morph(liveCard, '<article class="card">…</article>');
135
135
  - Library widget destroyed on every render → wrap host in `data-morph-skip`; mount the library imperatively after first render.
136
136
  - `each(): row render at index N produced K top-level elements` → wrap multiple roots in one parent.
137
137
  - 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.
138
+ - 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`).
138
139
 
139
140
  ## Server / SSR
140
141
 
package/ai/manifest.json CHANGED
@@ -1,21 +1,21 @@
1
1
  {
2
- "kerfjsVersion": "0.14.0",
2
+ "kerfjsVersion": "0.15.0",
3
3
  "files": [
4
4
  {
5
5
  "name": "skill",
6
6
  "source": "kerf.claude-skill.md",
7
7
  "bundle": "ai/skill.md",
8
8
  "dest": ".claude/skills/kerf-app/SKILL.md",
9
- "version": "1.2.0",
10
- "sha256": "76550ec18da8e063123bfabc575d9ad3a43583d41a498c6e4b4be03c17dbd188"
9
+ "version": "1.2.1",
10
+ "sha256": "290e57bfc9b4af0630c586770e61063742d5f5fac3d75406f5acbc2e5322440f"
11
11
  },
12
12
  {
13
13
  "name": "cursorrules",
14
14
  "source": "kerf.cursorrules",
15
15
  "bundle": "ai/cursorrules",
16
16
  "dest": ".cursorrules",
17
- "version": "1.2.0",
18
- "sha256": "dcf2fc03f5dfca3b9bfea8cfefc65ac8ce31e1c601c613e91af69b6195c5e267"
17
+ "version": "1.2.1",
18
+ "sha256": "2fcd5e9e0fd889febdab19bcc7e415c342b10ae81e937852b8280014b8d374e0"
19
19
  }
20
20
  ]
21
21
  }
package/ai/skill.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: kerf-app
3
3
  description: Build UIs in the kerf reactive framework (https://github.com/brianwestphal/kerf). Use this skill whenever the user is writing or modifying code that imports `kerfjs`, asks to add a feature to a kerf app, or asks "how do I do X in kerf?". Use it proactively the moment you spot a kerf import in the file you're editing.
4
- kerf-skill-version: 1.2.0
4
+ kerf-skill-version: 1.2.1
5
5
  ---
6
6
 
7
7
  # Building apps with kerf
@@ -147,6 +147,7 @@ morph(liveCard, '<article class="card">…</article>');
147
147
  | `<my-tag>` fails to typecheck | declaration merging targeted global JSX | Use `declare module 'kerfjs/jsx-runtime' { namespace JSX { … } }` instead |
148
148
  | `each(): row render at index N produced K top-level elements` | row returned multiple sibling elements or zero | Wrap them in one parent so the row renders exactly one element |
149
149
  | Drag/drop / state change has no visible effect; only elements *outside* `each()` update | Used `each(STATIC_ARRAY, …)` whose row render reads signals. Items never change identity → cache hits forever → row render never re-invoked → signal reads stop tracking | Replace outer with `STATIC_ARRAY.map(...)`; keep inner `each()` for the dynamic sub-list. See Hard Rule 14 |
150
+ | 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). Intentional flip side: 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`) so the reconciler replaces the node |
150
151
 
151
152
  ## Workflow guidance
152
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kerfjs",
3
- "version": "0.15.0-beta.1",
3
+ "version": "0.15.0",
4
4
  "description": "Tiny reactive UI framework — fine-grained signals + DOM morphing + JSX. Apply the smallest possible cut to update your DOM.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -108,7 +108,7 @@
108
108
  "@vitest/coverage-v8": "^3.0.0",
109
109
  "eslint": "^9.16.0",
110
110
  "eslint-plugin-simple-import-sort": "^12.1.1",
111
- "gitgist": "^1.0.0",
111
+ "gitgist": "^1.1.0",
112
112
  "happy-dom": "^20.9.0",
113
113
  "http-server": "^14.1.1",
114
114
  "husky": "^9.1.7",