kerfjs 0.15.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,29 +4,11 @@ All notable changes to **kerf** are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## Unreleased
8
-
9
-
10
- - 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
- - 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
- - 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
- - 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.
14
- - KF-238 — New opt-in dev-warn `KERF_DEV_WARN_DELEGATE_IN_EFFECT=1` fires once per process when `delegate()` or `delegateCapture()` is called inside an `effect()` body. Every effect re-run executes its body fresh, so a `delegate()` call inside the body installs a NEW root listener on each re-run; the effect's disposer cleans up the reactive subscription but not the side-effects the body produced, so listener count grows linearly with signal churn and each listener pins its handler closure. Implementation: `reactive.ts`'s `effect()` factory now wraps the user body in `enterEffect()` / `exitEffect()` calls (in a `try`/`finally` so a throwing body still decrements the counter) when the env var is set; `delegate.ts` calls `warnIfInsideEffect()` at the top of both helpers. Production (`NODE_ENV=production`) short-circuits before the wrap, so the bare `coreEffect` re-export stays the default path. New module `src/dev-delegate-warn.ts`; new test `tests/unit/dev-delegate-warn.internal.test.ts` (9 cases). Pairs with KF-237's docs gotchas section (§5.3 "When capturing the disposer still isn't enough" — scenario 2).
15
- - KF-237 — `docs/5-event-delegation.md` §5.3 gains a "When capturing the disposer still isn't enough" subsection covering five scenarios where capturing the disposer is necessary but not sufficient: `delegate()` rooted on a node inside a morph-managed tree (root at the outer `mount()` instead), `delegate()` called inside `effect()` (per-rerun listener stack — see KF-238 dev warn), `delegate()` on `toElement()` output that's later `replaceChildren()`-ed, disposer variables overwritten by reassignment, and nested-root confusion where the stable parent fools an AI into thinking a transient child is page-lifetime. Each scenario has wrong / right code pairs. Cross-linked from `docs/8-api-reference.md`'s `delegate()` entry, Hard Rule 5 in `docs/ai/usage-guide.md`, and two new rows in the common-errors table.
16
- - KF-236 — The `chat`, `todomvc`, `kanban`, and `markdown-editor` example apps now prefix their bare `delegate()` / `delegateCapture()` calls with the `void` opt-out sigil and carry an inline comment explaining the page-lifetime intent (matching the `counter-store` pattern shipped in KF-234). Downstream consumers who copy these examples and enable `kerfjs.configs.recommended` no longer see warnings from `kerfjs/require-delegate-disposer` on the canonical source.
17
- - KF-235 — `eslint-plugin-kerfjs` adds `kerfjs/require-delegate-disposer` (`warn` in recommended; plugin bumped to v0.13.0) — flags `delegate(...)` / `delegateCapture(...)` calls whose `() => void` return value is discarded (parent is an `ExpressionStatement`). The listener closure pins `rootEl`, `handler`, and everything the handler closes over, so an undisposed delegate on a transient root (modal, route view, mount swap, dynamic widget) leaks both the listener and the app graph it references; re-mount cycles stack listeners linearly. Accepts assignments, returns, array/object literals, argument positions, and `void` as an explicit-discard sigil; standard `eslint-disable-next-line` works for one-off page-lifetime exceptions. Severity `warn` to give downstream code an audit window — will promote to `error` after one or two releases. Pairs with KF-234's docs rewrite.
18
- - KF-234 — Delegate-disposer guidance rewritten (`docs/5-event-delegation.md` §5.3, mirrored in `docs/8-api-reference.md`). The prior wording said discarding the disposer is "usually fine" — that's only true for genuinely page-lifetime registrations (root is `document.body`, attached once at startup, never torn down), and was dangerously presumptuous everywhere else. New rule: **capture the disposer when the delegate's scope is shorter than the page** (modals, route views, mount swaps, dynamic widgets). The listener closure pins `rootEl`, `handler`, and everything the handler closes over, so an undisposed delegate on a transient root leaks the listener AND the app graph it references; re-mount cycles stack listeners linearly. `mount()`'s own disposer does NOT remove delegates for you. The `cart-htmx` example gained an inline annotation flagging its transient-root pattern; the `counter-store` example gained an inline annotation flagging its page-lifetime exception. The AI configs (`docs/ai/usage-guide.md`, `kerf.cursorrules`, `kerf.claude-skill.md`) pick up a new Hard Rule 5; `kerf-skill-version` bumped 1.1.1 → 1.2.0 (`ai/` bundle regenerated). See KF-235 for the upcoming `require-delegate-disposer` eslint rule that will mechanically enforce this.
19
- - KF-232 — `toElement()` no longer throws on multi-root inputs (`<><svg/> label</>`, two icons side by side, `text<svg/>`) and no longer silently drops sibling content. The return type widens to `Element | DocumentFragment`: single-root inputs still return an `Element` (XML-validated for `<svg>` roots, namespace-fixed for orphan SVG fragments); multi-root inputs return a `DocumentFragment` containing every top-level node — text and elements alike. Callers using the result with `appendChild` / `replaceChildren` / `append` keep working without changes (those APIs splat a `DocumentFragment`'s children into the parent and empty the fragment), so `parent.replaceChildren(toElement(<>{ICON} label</>))` now does the obvious thing — parent gets the SVG and the text. Callers that downcast the result (e.g. `as HTMLDivElement`) keep working since `Element | DocumentFragment` is still assignable via `as`, but the more precise types they intend to assert are now an `instanceof Element` guard or `if ('tagName' in result)` away.
20
- - KF-230 — `eslint-plugin-kerfjs` adds `kerfjs/prefer-attr-selector` (`warn` in recommended; plugin bumped to v0.11.0) — flags `delegate(_, _, '[name="value"]', _)` literal selectors and recommends `attr('name', 'value').selector`. Routing JSX `{...spec.attrs}` and the delegate target through one typed source means a rename can't desync the two. Conservative AST match: compound selectors (`[a="x"][b="y"]`), tag-qualified (`button[data-action="x"]`), bare-presence (`[data-new]`), and class / id selectors are left alone — those aren't 1:1 swaps for `attr()`. Severity `warn` because the literal form still works at runtime.
21
- - KF-230 — `scripts/check-docs-examples.mjs` gains Check 3 (example doc/source import mirroring). For every paired `site/src/content/docs/examples/complete/<name>.md` ↔ `site/src/examples/complete/<name>/main.tsx`, every named kerfjs import the source pulls in must also appear in at least one kerf-import statement in the doc page. Catches "release adds a new public API (`attr()`, …) and updates the example source, but the rendered doc excerpt still uses the pre-release pattern" — the v0.11.0 release shipped exactly this drift across TodoMVC + chat doc pages, 12 migration pages, 7 reactivity-demo sections, and `docs/ai/usage-guide.md`, all corrected in the same ticket.
22
- - `attr()` now ships two overloads and a richer return type. **Static** `attr(name, value)` returns `AttrSpec<N, V>` — a frozen descriptor with `.name`, `.value`, `.selector`, and `.attrs` (a `{ readonly [name]: value }` object ready to spread into JSX, keeping the attribute name out of every call site so renames propagate automatically). **Dynamic** `attr<N, V extends string = string>(name)` pre-validates the attribute name and returns a per-render factory `(value: V) => { readonly [name]: V }` for per-row data attributes like `data-id`. `V` defaults to `string`; specify both generics explicitly (e.g. `attr<'data-sort', 'asc'|'desc'>('data-sort')`) to constrain the value set the factory accepts. `AttrSpec` gains two type parameters (`N extends string`, `V extends string`) for tighter `satisfies` constraints. `satisfies Record<string, AttrSpec<'data-action'>>` now catches a mismatched attribute name at compile time.
23
- - `delegate<T extends Element = Element>()` and `delegateCapture<T>()` now accept an optional element-type generic that narrows the matched element in the handler — `delegate<HTMLButtonElement>(...)` avoids the `as HTMLButtonElement` cast, zero runtime change.
24
- - `get()` inside `defineStore` actions is now typed `() => Readonly<TState>` — the compile-time counterpart to the existing dev-mode `Object.freeze` on the snapshot. Actions that try to mutate `get().prop = ...` now fail `tsc --noEmit` without a cast, before even reaching the runtime `TypeError`.
25
- - Two new opt-in dev warnings in `src/dev-each-warn.ts`: `KERF_DEV_WARN_DUPLICATE_EACH_KEYS=1` (fires when two items in the same `each()` list produce the same `cacheKey` value — indicates a non-unique cache-key function) and `KERF_DEV_WARN_EACH_IN_MORPH_SKIP=1` (fires when an `each()` list is nested inside a `data-morph-skip` subtree — flags the asymmetric-freeze pattern where static JSX in the same ancestor is frozen while the list still updates). Both are one-shot per `each()` callsite.
26
- - `eslint-plugin-kerfjs` adds `kerfjs/no-raw-with-dynamic-arg` (`warn` in recommended) — flags any `raw()` call whose argument is not a static string literal or an expression-free template literal. Creates a searchable audit trail for every dynamic HTML injection point; suppress with `eslint-disable-next-line` to acknowledge the sanitizer.
27
- - `mount()` same-element double-mount error now names the element in the message (`<div#app> is already mounted`) instead of a generic description, so the duplicate mount site is immediately locatable in DevTools or a test failure log.
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
- - `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 }]`.
7
+ ## [0.15.1] - 2026-06-30
8
+
9
+
10
+
11
+ - README refresh + eslint rule-count fixes + CHANGELOG hygiene (`cfbaef3`)
30
12
 
31
13
  ## [0.15.0] - 2026-06-30
32
14
 
@@ -113,6 +95,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
113
95
  - New runnable example apps: `cart-htmx` (htmx swap → kerf island mount pattern) and `counter-store` (sync + async + persisted store)
114
96
  - Fix TodoMVC example: store actions now spread `get()` into `set()` so filter/edit interactions no longer wipe state
115
97
  - Drop AI-evidence pages, the AI marketing page, the blog, and the built-by-an-AI example; remaining docs re-toned to verifiable claims only
98
+ - New `scripts/check-docs-examples.mjs` doc/example consistency gate (wired into `npm run check`): verifies every example linked from a migration page is built + tested, and typechecks self-contained doc code blocks against `dist/`
116
99
 
117
100
  ## [0.7.0] - 2026-05-18
118
101
 
@@ -127,6 +110,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
127
110
  - Clearer JSX runtime error for inline `onClick={handler}`-style attributes that points at `delegate()` as the fix
128
111
  - Two opt-in dev warnings via env vars: `KERF_DEV_WARN_REBUILT_LISTENERS=1` flags rebuilt listener-bearing nodes; `KERF_DEV_WARN_UNTRACKED_SIGNALS=1` flags signal writes with no subscribers; `each()` now warns once per binding when the first row has no `id` or `data-key`
129
112
  - New `kerfjs/jsx-runtime` re-exports of `KerfBaseAttrs`, `KerfCustomElement`, `AttrLike`, `AttrValue`, `DataAriaAttrs` for declaration-merging custom-element types
113
+ - New `bench/micro/` Vitest bench-mode harness (`npm run bench:micro`) for primitive-level perf questions that don't need the full krausest run
114
+ - Docs: the AI usage-guide gains a decision-making-axes section and an explicit antipattern callout for `each(STATIC_ARRAY, …)` rows that read dynamic signals
130
115
 
131
116
  ## [0.6.0] - 2026-05-11
132
117
 
@@ -135,36 +120,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
135
120
  - New `data-morph-preserve` attribute to opt elements out of morphing
136
121
  - New `data-morph-skip-children` attribute — morph host attrs but leave subtree intact
137
122
  - Drop-in AI-tool config files (`kerf.cursorrules`, `kerf.claude-skill.md`) for Cursor and Claude Code
138
-
139
- ## [Unreleased]
140
-
141
-
142
- - **Opt-in dev warn for partial-set violations of Hard Rule 8 (KF-212).** New env var `KERF_DEV_WARN_NARROW_SET=1` (off by default; gated by `NODE_ENV !== 'production'`). When set, every `defineStore.set(next)` call checks whether any key from the current state is missing in `next` — exactly the bug shape that shipped in the TodoMVC partial-set bug on 2026-05-18. First violation per store emits a one-shot `console.warn` naming the missing keys and pointing at `set({ ...get(), ...next })` as the canonical merge fix. Trigger semantics are "any missing key" (not just "fewer total keys"), so `set({a, c})` against `{a, b}` (same count, different keys) also warns since `b` would silently disappear. Skips arrays (shrinking-array replacement is normal), null state, and primitives. Per-store one-shot dedup means a buggy store warns once across its lifetime; a second buggy store warns independently. Production behavior unchanged for zero runtime cost — the env-var read short-circuits before any per-set work. New `src/dev-store-warn.ts` module follows the `src/dev-listener-warn.ts` (KF-174) / `src/dev-signal.ts` (KF-176) pattern; new `tests/unit/dev-store-warn.internal.test.ts` covers opt-out / opt-in / dedup / array-skip / null-skip / primitive-skip paths; 100% lines/functions/statements coverage on the new module. Pairs the static-check side that landed in KF-211 + KF-189 follow-up (site/src/examples/complete/tsconfig.json + npm run check wiring) — together they cover both the build-time and runtime safety nets for partial-set bugs. New cross-cutting requirements doc `docs/11-dev-warnings.md` documents the dev-warn family (all three env vars) and the rules that keep them coherent (production short-circuit, per-warning env vars, one-shot dedup, warning message shape, no public-API surface, zero production cost) plus a recipe for adding new dev-warnings in the same shape. `docs/3-stores.md` gained §3.6 partial-set anti-pattern and §3.7 frozen-snapshot doc (KF-141, previously undocumented in the numbered doc); `docs/ai/usage-guide.md` Hard Rule 8 expanded with the env-var name and the cross-link to docs/11.
143
- - **Bugfix (TodoMVC example):** `site/src/examples/complete/todomvc/main.tsx` every store action called `set()` with a partial-state object (e.g., `set({ filter })` against a 3-key state), violating Hard Rule 8 (`set(next)` replaces state). The fix spreads `get()` in every `set()` call. The bug shipped because example apps weren't in any `tsc --noEmit` gate; the headline interaction (add / toggle / clear-completed) happened to dodge the failure mode (the render fn handles undefined `filter` / `editingId` gracefully), so the existing Playwright smoke test never tripped — clicking a filter is the first interaction whose *next* action then reads `items`, which by then is `undefined`. New `site/src/examples/complete/tsconfig.json` brings all complete examples under strict tsc; new `test:dist:examples` npm script wires that into `npm run check`. The Playwright smoke test for TodoMVC now also exercises the filter triplet, clear-completed from inside a filtered view, the edit flow, and asserts `pageerror` is empty across the run — any future partial-set regression of this shape trips the gate.
144
- - **JSX types:** `contentEditable` widened from `boolean | 'true' | 'false' | 'inherit'` to also accept `'plaintext-only'` (canonical HTML enumerated-attribute value, used by `site/src/examples/complete/markdown-editor/main.tsx`). New lowercase form `contenteditable` declared alongside, matching the KF-183 / KF-191 pattern that already covered `class` / `tabindex` / `autofocus` / `spellcheck` / `autocomplete`. No runtime change — `src/utils/jsx-attr-aliases.ts` already normalized the camelCase form to lowercase on output.
145
- - **New examples + browser specs.** Two new complete apps under `site/src/examples/complete/` to back the htmx and Redux migration pages with runnable + tested code, since both have unique kerf code that isn't a TodoMVC excerpt: (a) `cart-htmx/` — simulates the htmx-as-navigation + kerf-as-island composition (`htmx:afterSwap` → `mount()`), with a button-triggered swap so the demo works against a static server with no backend; (b) `counter-store/` — sync counter actions + async fetch action (load/error states) + localStorage persistence, the three patterns the Redux migration page demonstrates. Both are registered in `site/scripts/build-examples.mjs` COMPLETE_APPS and `tests/dist/example-apps/build.mjs` COMPLETE_APPS, both have `test.describe()` blocks in `tests/browser/example-apps.spec.ts` (chromium + firefox + webkit). The Redux + htmx + Astro migration pages link to them as "▶ Run live".
146
- - **Doc/example consistency gate.** New `scripts/check-docs-examples.mjs` (wired into `npm run check` + `npm run check:docs:examples`). Two checks: (1) every `/kerf/run/<name>/` link in a migration page must point at an example app that's both in COMPLETE_APPS and has a `test.describe('<name>')` Playwright block — closes the loop on "every example linked from the docs is built and tested"; (2) every self-contained kerf code block (imports from `kerfjs`, no `// ...` / `/* ... */` placeholders) is written to a scratch dir and run through `tsc --noEmit` against the built `dist/` types — catches doc snippets that violate runtime contracts at the same level the example-app tsc gate does.
147
- - **Docs:** expanded the `/kerf/migrating/` comparison hub from 4 pages to 13. New per-framework pages: Vue 3, Svelte 5, Solid, Preact, htmx, Angular, jQuery, Redux, and Astro. The Solid page is explicitly honest that kerf does not target Solid's compiler-driven update-path performance (per CLAUDE.md's "no compiler" architectural ceiling rule). The htmx / Redux / Astro pages use conceptual mapping instead of literal TodoMVC side-by-sides because the source isn't directly comparable. Updated `migrating/index.mdx` comparison matrix to cover the new framework set; updated `docs/10-migrating.md` to reflect the full page set; updated the sidebar nav in `site/astro.config.mjs` (KF-189).
148
- - **Docs:** removed the `/kerf/ai-evidence/` tree (index, structural, diagnostics, one-shots), the `/kerf/ai/` marketing page, the `Built by an AI · Pomodoro` example + page, and the launch-essay blog post (`predictable-performance.md`) entirely. Re-toned the homepage, `why-kerf.md`, `use-cases.md`, and the README to drop AI-pillar / hyperbolic-claim framing in favor of verifiable claims only (bundle size, krausest cluster position, public-API surface count). `docs/ai/usage-guide.md` remains as the canonical reference for AI tools that fetch it via `llms.txt`, but is no longer mirrored as a published site page (`site/scripts/sync-docs.mjs:syncAiPage()` deleted). `site/scripts/build-examples.mjs` no longer builds the pomodoro-ai app; `site/astro.config.mjs` sidebar drops the AI / AI-evidence / Blog entries. The migration hub still exists at `/kerf/migrating/` and is the public face for cross-framework comparisons (KF-211).
149
- - **Perf:** two surgical fast paths in the granular update reconciler (`each(arraySignal, …)` update patches), targeting the kerf gap-vs-non-Solid-cluster on the krausest `select-row` and `partial-update` benchmarks. Both apply the change directly to the live row before the parse + morph path can run; both bail conservatively on anything they can't prove safe and fall through to `_morphElement`. (a) **Attribute-only (KF-198)**: when old and new row HTML differ only in attribute values on the top-level element (rest of HTML byte-equal from the first `>` onward), parse the opening tags, diff the attribute maps, and apply `setAttribute` / `removeAttribute` directly. Targets select-row, whose 2 updates flip `class=""` ↔ `class="danger"` on a `<tr>` with otherwise identical 4-child subtree. (b) **Text-content-only (KF-206)**: prefix-equal + suffix-equal scan finds the diff window; if it lies entirely inside one text node's content (no `<`, `>`, `"`, `'`, `&`, `=` chars in the window), walk the live row to that text node and patch its `nodeValue`. Targets partial-update, whose 100 updates each rewrite one label text node deep inside the row. Both honor `data-morph-skip` (bail if it appears in either HTML string), `<details open>` / `<dialog open>` user-agent state, namespaced attributes (bail), and HTML entity escapes (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&#39;` decoded on the way back to `setAttribute`). The sanity check at the end of the text-content path (live text node's `nodeValue` must equal the text extracted from `oldHtml`) is the final safety net for any drift between HTML-string-state and live-DOM-state. New internal module `src/list-reconcile-fast-paths.ts` exports `tryAttributeOnlyFastPath` and `tryTextContentFastPath`; `list-reconcile-granular.ts`'s `applySingleUpdate` / `applyBulkUpdate` consult them before the existing parse + morph path. The bulk-update path now consolidates only the rows the fast paths couldn't handle into one `template.innerHTML` parse (preserving KF-94's bulk-parse savings on the residual). Detection cost measured in `bench/micro/attribute-diff-detection.bench.ts`: ~1.5μs per call when firing, ~0.06–1μs when bailing — both well under `parseRowTemplate`'s ~34μs ceiling. **Krausest impact** measured via a `--count=10` rerun (2026-05-16) against the KF-196 baseline: **select-row 27.8 → 8.2 ms (-71%)** — kerf is now dead-center in the non-Solid cluster (preact 8.1, react 8.6, lit 9.5, vanjs 11.3); **partial-update 46.8 → 33.8 ms (-28%)** — clears the project-perf-bar of "within 2x of the tightest non-Solid cluster" (2× preact 20.6 = 41.2 ms ceiling), though still off the cluster's 20.6–24.7 ms band. Other scenarios drift ~3–7% within typical bench noise (create 1k 43.1→44.7, replace 1k 46.2→49.1, append 1k 47.0→50.4, clear 1k 18.9→20.5). New tests in `tests/unit/list-reconcile-fast-paths.test.ts` cover the public-API behavior (firing/bailing through `arraySignal.update()` inside `mount()`, identity preservation, parse-count assertions); `tests/unit/list-reconcile-fast-paths.internal.test.ts` covers the direct-function bail branches (`.internal.test.ts` so dist-full excludes it). 100% lines/functions/statements, 99% branches maintained (KF-198 + KF-206).
150
- - **Perf-infra:** new `bench/micro/` directory with a Vitest bench-mode suite (runs in ~10 seconds) for primitive-level perf questions that don't need the full krausest run. Five files: `morph-vs-replace.bench.ts` (the retrospective check that proves the KF-201 swap is a wash on a kerf-typical row — would have predicted the nothingburger in 2 seconds), `parse-row.bench.ts` (single-row + bulk-100 parse cost — sets the ceiling for KF-198's parse-skipping fast path), `each-snapshot-classify.bench.ts` (cache-hit and cache-miss loops in `eachSnapshotById` — tests KF-199's "alloc reduction" premise), `jsx-string-build.bench.ts` (JSX runtime vs raw concat — shows the JSX abstraction overhead), `attribute-diff-detection.bench.ts` (placeholder for KF-198). Uses Vitest's built-in bench mode rather than wiring up `tinybench` separately, since Vitest already uses tinybench internally and reusing the existing happy-dom env keeps the dep surface tight. New `vitest.config.bench.ts` is dedicated to the bench suite (no coverage thresholds — microbench numbers are noisy and host-dependent so gating commits on them would be over-eager). New `bench/README.md` § Micro-benchmarks documents purpose, usage, when-to-use, when-NOT-to-use. Intentionally not part of `npm run check`. Run with `npm run bench:micro` (KF-202).
151
- - **Behavior fix (perf-neutral):** the granular reconcile path (`each(arraySignal, …)` update patches) now morphs each row in place via `morph()` instead of `replaceChild`-ing the whole row. Same-tag updates apply surgical attribute / text-node changes to the existing live node, preserving DOM identity, focus, scroll, IME state, `data-morph-skip` subtree contents, and `<details>` / `<dialog>`'s user-agent-owned `open` state across the update. Tag-mismatch updates fall back to explicit `replaceChild` so a node reference is still captured. Behavior previously documented as "trade-off of the granular path" (focus loss inside an updated row, `data-morph-skip` ignored on rows, `<details open>` wiped) is fixed — granular updates are no longer destructive. Five existing tests that pinned the destructive behavior were rewritten to assert the new preserving behavior. **Perf disclosure**: this was originally framed as a partial-update / select-row perf optimization; the krausest `--count=10` rerun (KF-196 results in `bench/results.md`) shows no measurable change on either benchmark — partial-update 44.6 → 46.8ms, select-row 27.6 → 27.8ms, both within noise. Chrome's layout cost for `replaceChild`-ing a small kerf row is apparently comparable to morph's walk cost, so the in-place update doesn't save the wall-clock time we hoped. The behavior wins are real and tested; the perf framing was wrong. Exported a new internal `_morphElement(fromEl, toEl)` from `src/morph.ts` (underscore-prefixed; not part of the public API surface) for the granular reconciler to invoke (KF-201).
152
- - **Renamed** `each(items, render, key?)`'s third parameter to `cacheKey` for clarity. The previous name collided with React's `key` prop, which has fundamentally different semantics (React: reconciliation identity; kerf: passive cache-invalidation comparator). The new name says what the parameter actually does — it's a key into the per-item HTML cache, evaluated synchronously inside the mount effect run and compared against the previous run's return value to decide whether the cached HTML is stale. No reactivity contract is implied (the parameter is not a dependency declaration; the mount effect itself owns the subscriptions). Distinct from `data-key` on the rendered element, which the morph uses for DOM-node reconciliation. The change is purely a parameter rename — positional callers (the canonical form, `each(items, render, fn)`) are unaffected at runtime. Named-property syntax doesn't exist for TypeScript positional args, so source-level callers don't break either. The visible change is in `dist/jsx-runtime.d.ts` / IDE tooltips / generated docs. JSDoc, the `docs/8-api-reference.md` entry, the `docs/4-render.md` §each() prose + memo-cache callout (synced to `site/src/content/docs/docs/render.md`), `docs/ai/usage-guide.md`, `kerf.cursorrules`, and `kerf.claude-skill.md` are all refreshed to use the new name. Surfaced by the KF-184 v3 kanban one-shot transcript, where the model didn't pass a `key` because it pattern-matched the name to React's `key` (reconciliation identity) and reasonably concluded that `data-key={col.id}` was already doing that job — missing the actual purpose (cache invalidation for external state). The rename makes the intent unambiguous from the name alone (KF-194).
153
- - Docs: `docs/ai/usage-guide.md` now teaches decision-making *axes* in a new §Decision-making axes section (after §Hard rules), so AI tools and developers reading the doc derive idiomatic patterns from principles rather than recipe-matching. Four clusters: Events (where the event originates; whether it needs to follow an element across a gesture via `setPointerCapture`; well-known non-bubblers), Lists (item-by-item dynamic vs static structural vs granular `arraySignal`), Side effects / imperative DOM (`data-morph-skip*` variants, focus survival), and Raw HTML (user-controlled vs author-trusted sanitisation rules). Recipes stay linked rather than inlined — `site/src/examples/complete/<name>/main.tsx` for runnable examples, `docs/4-render.md` / `docs/5-event-delegation.md` for worked deep-dives. The shape is "axes first, recipes on demand": the model derives the pattern from the axes, only fetching the recipe page if uncertain. Mirrored into the two drop-in AI configs (`kerf.cursorrules`, `kerf.claude-skill.md`). Surfaced by the KF-184 v3 kanban one-shot transcript, where the de-leaked prompt (no Hard Rules section, no kerf-primitive hints) produced `window.addEventListener` for the entire drag pipeline instead of the kerf-idiomatic `delegate()` + `setPointerCapture()` — the docs listed both primitives but didn't teach how to compose them (KF-195).
154
- - Docs: explicit antipattern callout for `each(STATIC_ARRAY, …)` whose row render reads dynamic signals. Added as Hard Rule 13 in `docs/ai/usage-guide.md`, mirrored in `kerf.cursorrules` and `kerf.claude-skill.md`, with a corresponding row in §Common errors → fixes. New worked example in `docs/4-render.md` (and the synced `site/src/content/docs/docs/render.md`) showing the wrong shape (`each(COLUMNS, …)` whose row reads `board.value`, columns freeze on first render forever) and the right shape (`COLUMNS.map(...)` for the static frame, inner `each()` for the dynamic sub-list). Surfaced by the KF-184 v3 kanban transcript where this antipattern caused drag/drop to have no visible effect — the drop logic fired but the columns never re-rendered because the per-item HTML cache hit on every render after the first (KF-192).
155
- - JSX `IntrinsicElements` typing now accepts the lowercase HTML forms `class`, `for`, `tabindex`, and `autofocus` alongside the existing React-style camelCase `className` / `htmlFor` / `tabIndex` / `autoFocus`. The migration doc at `/kerf/migrating/react/` explicitly tells incoming developers (and any LLM that reads it) to write the canonical HTML attribute names, so the type system now matches that guidance: `class` is declared on `KerfBaseAttrs` and `SVGCommonAttrs` (applies to every element), `for` is declared on `HTMLLabelAttrs` and the `output` element inline type (the two places `htmlFor` is valid in HTML), `tabindex` is declared on `KerfBaseAttrs` and `SVGCommonAttrs` and widened to `AttrLike<number | string>` because the HTML spec defines tabindex as a string-valued integer attribute (`tabindex="0"` is the canonical HTML form), and `autofocus` is declared on `KerfBaseAttrs` and widened to `AttrLike<boolean | 'true' | 'false'>` (same KF-183 rationale as `spellcheck`). No runtime change — `src/utils/jsx-attr-aliases.ts` already normalized the camelCase forms to lowercase on output, so both spellings produce the same HTML. Surfaced by the KF-184 kanban one-shot transcript, where Claude Code (Opus 4.7) followed the migration doc's guidance and wrote `class={…}` everywhere — runtime rendered correctly but `tsc --noEmit` failed; the captured transcript would have logged this as a model defect when it was actually a kerf docs/types inconsistency.
156
- - JSX `IntrinsicElements` typing now accepts the lowercase HTML forms `autocomplete` and `spellcheck` alongside the existing React-style camelCase `autoComplete` / `spellCheck`. `autocomplete` is declared on the four attribute interfaces that already had `autoComplete` (`HTMLInputAttrs`, `HTMLFormAttrs`, `HTMLSelectAttrs`, `HTMLTextareaAttrs`). `spellcheck` is declared on `KerfBaseAttrs` so it applies to every element, and widened to `AttrLike<boolean | 'true' | 'false'>` because HTML defines spellcheck as a string-valued enumerated attribute — an HTML-savvy developer typing the lowercase form will naturally reach for `spellcheck="false"`. No runtime change — `src/utils/jsx-attr-aliases.ts` already normalized the camelCase form to lowercase on output, so both spellings produce the same HTML. Surfaced by the KF-181 innerHTML audit where the reactivity-demo example tripped `tsc --noEmit` with three unrelated typecheck errors in `focusSurvivalSection.tsx`, `keyedListSection.tsx`, and `tier2CaptureSection.tsx` (KF-183).
157
- - Opt-in dev-mode warning for Rule 4 violations: when `process.env.NODE_ENV !== 'production'` AND `process.env.KERF_DEV_WARN_REBUILT_LISTENERS === '1'`, `mount()` installs a `MutationObserver` on the root that watches for `childList`/`subtree` removals plus a one-time monkey-patch on the realm's `EventTarget.prototype.addEventListener` (resolved via a probe Element's prototype chain so the patch lands on the live realm's EventTarget, not `globalThis.EventTarget`) that marks each Element receiver with a `Symbol.for("kerfjs.devListener")` flag. When the observer reports a removed Element (or any descendant in the removed subtree) carrying the marker, a one-shot `console.warn` fires pointing at `delegate()` and `data-morph-skip` as the canonical fixes. Off by default because the monkey-patch is realm-wide (every `addEventListener` call gets marked, including third-party code paths) and false-positives are possible for legitimate library-owned subtrees the user forgot to wrap in `data-morph-skip`. Production behavior is unchanged for zero runtime cost. Promotes Rule 4 in the diagnostic audit from score 0 (default) to score 2 (opt-in) (KF-174).
158
- - Dev-mode `console.warn` from `each()` when a row has no `id` or `data-key` attribute on its top-level element. Fires once per `ListBinding` (suppressed for the rest of that binding's lifetime so re-renders don't spam) the first time a row is bound. The message names the row index, points at the canonical fix (`data-key={item.id}` on the row's top-level element), and quotes the offending row HTML so the author can locate the call site. Without a key, the reconciler silently falls back to positional matching — focused inputs jump on insert/remove, mid-edit textareas swap content with their neighbor, per-row state follows the wrong item. Production behavior is unchanged for zero runtime cost (gated on `NODE_ENV !== 'production'`). Promotes Rule 2 in the diagnostic audit from score 0 to score 2 (KF-173).
159
- - `mount()` now enforces "one mount per tree" — it walks the requested root's ancestors, descendants, and the root itself for a `Symbol.for("kerfjs.mounted")` marker placed by a prior `mount()` call, and throws `mount: rootEl is already inside (or contains) a mounted tree. kerf supports one mount per tree — compose with plain functions that return JSX instead of nesting mounts.` if any is found. The marker is cleared in the disposer returned from `mount()`, so `mount(sameEl, …)` after dispose still works. Previously this was the worst silent-misbehavior alongside the store-mutation case (Rule 5 in the diagnostic audit, score 0): both effects would run, the outer's morph would reconcile the inner mount's output back to the outer template, and the inner mount's work would silently vanish. Promoted to score 3 (KF-175).
160
- - Opt-in dev-mode warning for Rule 7 violations: when `process.env.NODE_ENV !== 'production'` AND `process.env.KERF_DEV_WARN_UNTRACKED_SIGNALS === '1'`, kerf's `signal()` factory returns a `DevSignal` subclass that emits a one-shot `console.warn` the first time `.value` is written to a signal that has never had a subscriber attached. This surfaces the canonical "I read `.value` outside the render fn so the read never subscribed, and now my writes don't trigger re-renders" failure at the moment of the bad write rather than leaving the user to wonder why their UI doesn't update. The subclass wires up signals-core's `SignalOptions.watched` callback to set a per-instance flag; the `.value` setter checks the flag and warns once. Production behavior is unchanged for zero runtime cost. Off by default because the heuristic produces false positives for purely imperative signals (mutable cells with no UI consumer); opt-in by env var is the right shape until a sharper heuristic emerges (KF-176).
161
- - `defineStore` now freezes the snapshot returned to actions via `get()` when `process.env.NODE_ENV !== 'production'`, so a Rule 8 violation (`get().count = 42` instead of `set(next)`) throws a native `TypeError: Cannot assign to read only property 'count' of object '#<Object>'` instead of silently landing on the underlying state without notifying subscribers. Previously the audit graded this score 0 — the worst silent-misbehavior of all the rules, because the mutation was visible to direct `.value` reads but never re-fired effects, so the bug looked like it worked from one read site and looked broken from another. The dev freeze converts it to a score-3 capture; production behavior is unchanged for zero overhead (KF-177).
162
- - Dedicated error for function-valued JSX attributes whose names match `/^on[A-Z]/` (e.g. `onClick={fn}`, `onInput={fn}`). The thrown message names the attribute, explains that kerf's JSX → HTML-string runtime can't serialize functions, and embeds the canonical `delegate(rootEl, 'click', '[data-action="..."]', handler)` snippet as the fix. Previously this hit the generic "unsupported value for attribute" branch whose advice ("read .value off a Signal, or stringify the object first") pointed in the wrong direction. Surfaced as a score-2 capture by the diagnostic-error audit at `/kerf/ai-evidence/diagnostics/`; the dedicated path promotes the rule to score 3 (KF-178).
163
- - Add `data-morph-skip-children` attribute — morphs attributes on the host but leaves its subtree alone. For client-hydrated slots whose loading / state classes still need to flow through. Companion to existing `data-morph-skip`; decision matrix in `docs/4-render.md` §4.3 (KF-152).
164
- - Add `data-morph-preserve` attribute — an unmatched live element with this attribute is skipped by the morph's trailing-removal pass instead of removed. Lets imperatively-injected nodes (autoplay video, tooltip overlays, analytics pixels) survive across renders without `data-morph-skip` on the parent. Scope is strictly end-of-list-discard: keyed-match moves and attribute/child morphing still apply when the element IS in the new template (KF-151).
165
- - **New public export `morph(liveRoot, template)`** — one-shot in-place DOM reconciliation. Same algorithm `mount()` uses internally, exported for consumers that have an already-populated element and need to reconcile it against a freshly-built template (SSR-fragment hydration, page-refresh diffs, third-party widget remounts). Accepts an `Element`, `SafeHtml`, or raw HTML string for the template. Honors every short-circuit `mount()`'s pipeline uses (`data-morph-skip`, `data-morph-skip-children`, `data-morph-preserve`, focused-input value/selection preservation, focused-`[contenteditable]` subtree preservation, `<details>` `open`). Renamed the internal module `src/diff.ts` → `src/morph.ts` and the function `diff()` → `morph()` in the same change so the public name matches the file name and the internal-vs-public split is gone (KF-150).
166
- - Convention: use American-English spelling everywhere — prose, comments, identifiers, test names. CLAUDE.md notes the rule; the existing codebase was swept in the same change (KF-153).
167
- - Docs: add a `/kerf/migrating/` comparison hub with one page per source framework (React, Alpine, Lit, vanjs). KF-132 ships the index (comparison matrix + perf snapshot), the sidebar `Migrating` section, and a `Coming from React?` hero CTA on the homepage. KF-156/157/158/159 fill in the per-framework pages — bundle delta, mental-model translations, side-by-side TodoMVC, gotchas, and perf numbers. New requirements doc at `docs/10-migrating.md` (KF-132 + KF-156/157/158/159).
123
+ - Adopt American-English spelling everywhere — prose, comments, identifiers, and test names; the existing codebase was swept in the same change
168
124
 
169
125
  ## [0.5.1] - 2026-05-11
170
126
 
@@ -198,11 +154,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
198
154
  ## [0.4.0] - 2026-05-09
199
155
 
200
156
 
201
- - Auto-promote known non-bubbling events (focus, blur, scroll, etc.) to capture phase in `delegate()`
202
-
203
- ## [0.4.0] - 2026-05-09
204
-
205
-
206
157
  - `delegate()` now auto-promotes the seven well-known non-bubbling events (`focus`, `blur`, `scroll`, `load`, `error`, `mouseenter`, `mouseleave`) to capture phase, with `closest()`-style selector matching preserved
207
158
  - Fixed focus and caret position loss when reordering keyed `each()` rows on engines that drop focus on `insertBefore` (older Safari, happy-dom)
208
159
  - `mount()` now throws a descriptive error when the root element is null/undefined instead of a generic "Cannot set properties of null"
@@ -221,6 +172,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
221
172
 
222
173
  - Rebuilt render pipeline with structured segments and a native keyed-list diff, replacing the morphdom dependency
223
174
  - Added `each()` for keyed list iteration with per-item HTML memoization by object identity
175
+ - Renamed the npm package from `kerf` to `kerfjs` (the `kerf` name tripped npm's typo-squatting heuristic); the brand, GitHub repo, and Pages URL are unchanged
176
+ - Add `isSafeHtml(value)` type guard for checking JSX values across module copies (works where `instanceof SafeHtml` can't)
177
+ - New `npm run test:dist:full` runs the full unit + integration suite against the built `dist/` bundle in CI
178
+ - Add behavioral-guarantee tests pinning documented contracts (non-deep-reactivity, `batch()` coalescing, the `mount()` disposer, Tier-3 listener survival)
179
+ - Publish the live reactivity demo to GitHub Pages via `.github/workflows/pages.yml`
224
180
 
225
181
  ## [0.2.1] - 2026-05-07
226
182
 
@@ -244,36 +200,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
244
200
 
245
201
  - This is just a publication script test
246
202
 
247
- ## [Unreleased]
248
-
249
- ### Fixed
250
-
251
- - **Focus was sometimes lost on `each()` reorders (KF-65).** When the keyed list reconciler moved a row whose descendant held focus, `insertBefore` blurred the element on engines that don't preserve focus across DOM moves (older Safari, happy-dom). The element survived in the live tree, but `document.activeElement` reverted to `<body>` and the user's typing was interrupted. The reconciler now snapshots the active element + its selection range (when applicable) before the move pass and re-applies them after, so focus and caret position survive a reorder uniformly across engines. Engines that already preserve focus across moves see a no-op — the snapshot only takes effect when the active element changed. `docs/4-render.md` §4.4 and `docs/8-api-reference.md` updated. New regression tests in `tests/unit/mount.test.ts` cover reorder, top-insert, focused-row removal, non-text focused elements, selection-API rejection (e.g. `type=number`), and the "active element is outside the list" path.
252
- - **`Fragment` was missing from the `kerfjs` barrel (KF-24).** `Fragment` was implemented in `src/jsx-runtime.ts`, exported from `kerfjs/jsx-runtime`, and present in the shared chunk — but the barrel `src/index.ts` didn't re-export it. Importing `Fragment` from `'kerfjs'` resolved to `undefined`, so a manual `<Fragment>...</Fragment>` rendered as `<undefined>...</undefined>`. The `<>...</>` shorthand was unaffected because the JSX transform pulls `Fragment` from `kerfjs/jsx-runtime` directly. Added `Fragment` to the barrel re-export, and pinned the entire public-API contract with a new `tests/dist/barrel-completeness.test.ts` so any future omission fails CI loudly. Docs updated to list `Fragment` in the public API surface (`CLAUDE.md`, `llms.txt`, `docs/ai/usage-guide.md`, `docs/ai/code-summary.md`, `docs/6-jsx-runtime.md`, `docs/8-api-reference.md`).
253
- - **Focused contenteditable was being morphed, clobbering in-progress edits (KF-19).** The docs claimed contenteditable elements got focus + selection preservation alongside `<input>` and `<textarea>`, but the implementation only handled the latter two — a focused contenteditable's typed content was overwritten by morphdom on the next re-render. `mount()` now short-circuits the morph entirely when the active element is a contenteditable (same mechanism as `data-morph-skip`), so the user's edit, caret position, and any multi-range selection survive verbatim. Attribute updates are deferred until the next render after blur — that's the explicit trade-off, and matches what you want for in-progress rich-text editing. `docs/4-render.md` §4.4 and `docs/8-api-reference.md` §8.7 updated to describe the per-element-kind behavior. The check uses the `contenteditable` attribute directly (the spec's source of truth) rather than the derived `isContentEditable` property, so test environments that don't populate the latter still get correct behavior.
254
- - **`clearStoreRegistry` was a no-op in the published bundle (KF-15).** `dist/testing.js` shipped an empty function body. Root cause: `tsup` bundled each entry independently with `splitting: false`, so the testing entry tree-shook the module-level `REGISTRY` array out as unreferenced — leaving `REGISTRY.length = 0` as dead code. Same root cause as KF-14. Fixed by enabling `splitting: true` in `tsup.config.ts`: shared modules now live in chunk files that all entries import, so `defineStore`'s registry and `clearStoreRegistry`'s reference are the same array. Side benefit: the duplicate `SafeHtml` class definition is gone too — there's now exactly one copy across the whole dist. Build output now includes `dist/chunk-*.js` files (covered by the existing `"files": ["dist"]` in `package.json`). New regression test in `tests/dist/store-registry-shared.test.ts` exercises the cross-entry registry from the built bundles.
255
- - **`SafeHtml` cross-bundle identity (KF-14).** When a consumer's bundler ended up loading two copies of kerf — for example, the barrel (`kerfjs`) and the JSX-runtime entry (`kerfjs/jsx-runtime`) resolving as separate modules — `instanceof SafeHtml` failed inside the JSX runtime because the two `SafeHtml` classes were structurally identical but referentially distinct. The renderer would then throw `JSX: unsupported child of type object (SafeHtml)` on perfectly valid JSX. `SafeHtml` instances now carry a `Symbol.for('kerfjs.SafeHtml')` brand and the runtime checks for the brand instead of using `instanceof`. New unit tests simulate the duplicate-class scenario, and a new `npm run test:dist` job exercises the actual built bundles in CI.
256
-
257
- ### Changed
258
-
259
- - **`delegate()` now auto-promotes the well-known non-bubbling event types to capture phase (KF-56).** `focus`, `blur`, `scroll`, `load`, `error`, `mouseenter`, `mouseleave` previously needed `delegateCapture()`; with auto-promotion the call site is identical to bubbling events. Selector matching stays `closest()`-style for every type, including the auto-promoted ones — so `delegate(root, 'focus', '.field-row', ...)` fires when a descendant `<input>` is focused, with the row as the matched element (not the input). `delegateCapture()` remains as the explicit-capture escape hatch with its `target.matches()`-style direct matching. No bundle-size change worth measuring. `docs/5-event-delegation.md` and `docs/8-api-reference.md` §8.4 updated. New unit tests in `tests/unit/delegate.test.ts` cover the `closest()` walk-up on auto-promoted events, the disposer path, and a phase-check for bubbling events to confirm the promotion is type-gated.
260
-
261
- ### Added
262
-
263
- - **`each(items, render, key?)` list primitive** exported from `kerfjs`. Keyed list iteration with per-item memoization: skips re-running `render` for items whose object identity (and optional `key`) are unchanged since the previous call. Targets the partial-update / select-row / swap-rows perf path, where today's `mount()` re-runs the render for the full list on any signal change. On the js-framework-benchmark suite this drops kerfjs's partial-update from 87 → 64 ms (-27%), select-row from 69 → 42 ms (-38%), swap-rows from 86 → 58 ms (-33%), and remove-row from 49 → 35 ms (-29%); creates and bundle size are unaffected (+0.2 KB gz for the WeakMap memoiser). See `docs/8-api-reference.md` §8.3 and `bench/` for the benchmark harness.
264
- - **`isSafeHtml(value)` type guard** exported from `kerfjs`. Use this rather than `instanceof SafeHtml` when inspecting JSX values from your own code — it works across module copies.
265
- - **End-to-end test coverage of the published bundle (KF-16).** New `npm run test:dist:full` re-runs the entire unit + integration suite against `dist/` instead of `src/` via a tiny vitest plugin that rewrites `../../src/<name>.js` imports to the equivalent dist entry point. Wired into the CI `build` job. Combined with the existing `test:dist` (focused dist regression suite), CI now proves the exact bytes we publish pass every test we have, not just the source they were built from.
266
- - **Four behavioral-guarantee tests (KF-17)** pinning documented contracts that previously had no test: signals are not deep-reactive (§2.6), `batch()` inside an action coalesces notifications (§3.5), `mount()` disposer leaves the rendered DOM in place (§4), and direct event listeners inside `data-morph-skip` subtrees survive parent re-renders (Tier 3, §5).
267
-
268
- ### Changed
269
-
270
- - **Render pipeline rebuilt around structured segments + a native diff.** `SafeHtml` no longer wraps a flat string; it wraps a `Segment` tree that distinguishes `static` HTML, `list` segments (from `each(...)`), and `mixed` parents containing lists. `mount()` dispatches on the segment kind: static surrounds go through a new general-purpose tree-diff (`src/diff.ts`, derived from morphdom — MIT — with attribution in `LICENSE`), and lists are reconciled directly against live children by a keyed reconciler. The reconciler bulk-parses every fresh row's HTML in one `innerHTML` call, then uses an LIS pass over old positions so the number of `insertBefore` calls is the minimum possible. `morphdom` is no longer a runtime dependency — kerf now depends only on `@preact/signals-core`. Net perf vs the prior `each` + morphdom Stage-1: partial-update 64 → 51 ms (-19%), select-row 42 → 39 ms (-9%), swap-rows 58 → 33 ms (-43%), remove-row 35 → 21 ms (-39%), append-1k 67 → 54 ms (-19%), clear 35 → 23 ms (-33%); creates roughly unchanged. Bundle gz: 6.9 → 6.6 KB. Public API and JSX usage are unchanged — the change is entirely internal.
271
- - **Package renamed from `kerf` to `kerfjs`** on the npm registry. The `kerf` name was rejected by npm's typo-squatting heuristic ("too similar to `keyv`"). The brand is still *kerf* — only the npm identifier changed. Update imports to `from 'kerfjs'`, `tsconfig.json` to `"jsxImportSource": "kerfjs"`, and the install command to `npm install kerfjs`. The GitHub repo and Pages URL (`brianwestphal.github.io/kerf/`) are unchanged.
272
-
273
- ### Added
274
-
275
- - Live demo published to GitHub Pages at <https://brianwestphal.github.io/kerf/>. Builds `examples/reactivity-demo/` on every push to `main` via `.github/workflows/pages.yml`. New `docs/9-live-demo.md` covers the deploy, and `examples/reactivity-demo/vite.config.ts` now sets `base: '/kerf/'` for the subpath. New `npm run example:reactivity-demo:build` script.
276
-
277
203
  ## [0.1.0] - 2026-05-07
278
204
 
279
205
  ### Added
package/README.md CHANGED
@@ -37,7 +37,7 @@ That's it. Your JSX renders to HTML strings, kerf's native diff applies the mini
37
37
 
38
38
  3. **Focus, selection, listeners survive re-renders.** The reconciler morphs instead of rebuilding — caret position, selection range, and delegated listeners survive every re-render.
39
39
 
40
- 4. **Small public API.** ~16 exports total. No hooks, no lifecycle, no per-instance state. Components are plain functions that return JSX.
40
+ 4. **Small public API.** ~17 exports from the main barrel (plus `arraySignal` on its own subpath). No hooks, no lifecycle, no per-instance state. Components are plain functions that return JSX.
41
41
 
42
42
  5. **Plain TS, plain JSX, plain ESM.** Drops into anything using esbuild / Vite / tsup. No plugin chain.
43
43
 
@@ -158,7 +158,7 @@ npm install kerfjs
158
158
 
159
159
  ### Optional: `eslint-plugin-kerfjs`
160
160
 
161
- A companion ESLint plugin enforces kerf's hard rules at edit time. Four AST rules catch hard-rule violations — inline JSX event handlers, missing `data-key` in `each()`, nested `mount()`, and global `JSX.IntrinsicElements` augmentation. Two additional rules cover `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.
161
+ 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.
162
162
 
163
163
  ```bash
164
164
  npm install --save-dev eslint-plugin-kerfjs
@@ -178,7 +178,7 @@ Full docs at [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwes
178
178
  - **Docs:** [`docs/`](./docs/) — overview · reactivity · stores · render · events · jsx · svg · [API reference](./docs/8-api-reference.md)
179
179
  - **Migrating:** [coming from another framework?](https://brianwestphal.github.io/kerf/migrating/) — side-by-side TodoMVC translations + per-framework gotchas
180
180
  - **AI guide:** [`docs/ai/usage-guide.md`](./docs/ai/usage-guide.md) — reference for AI tools fetching kerf docs (linked from `llms.txt`)
181
- - **ESLint plugin:** [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — `eslint-plugin-kerfjs`; six rules (four hard-rule errors + `no-raw-with-dynamic-arg` warn + `ai-assistant-configs` warn) at edit time (source: [`eslint-plugin/`](./eslint-plugin/))
181
+ - **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/))
182
182
  - **Demo:** [live demo](https://brianwestphal.github.io/kerf/demo/) — eight sections exercising every primitive (counter, store-backed cart, focus survival, keyed list, morph-skip, SVG render, Tier-2 capture, `arraySignal` patches)
183
183
  - **Repo:** [github.com/brianwestphal/kerf](https://github.com/brianwestphal/kerf)
184
184
 
package/ai/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "kerfjsVersion": "0.15.0",
2
+ "kerfjsVersion": "0.15.1",
3
3
  "files": [
4
4
  {
5
5
  "name": "skill",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kerfjs",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Tiny reactive UI framework — fine-grained signals + DOM morphing + JSX. Apply the smallest possible cut to update your DOM.",
5
5
  "type": "module",
6
6
  "sideEffects": false,