kerfjs 0.6.0 → 0.8.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
@@ -4,6 +4,30 @@ 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
+ ## [0.8.0] - 2026-05-18
8
+
9
+
10
+ - Add opt-in dev warning `KERF_DEV_WARN_NARROW_SET=1` that fires when `set()` is called with a partial-state object (replace semantics would silently drop missing keys); names the missing keys and points at the `set({ ...get(), ...next })` merge fix
11
+ - Widen `KerfBaseAttrs.contentEditable` to accept `'plaintext-only'` and add the lowercase `contenteditable` alias
12
+ - Expand the `/kerf/migrating/` hub to 13 frameworks — adds Vue 3, Svelte 5, Solid, Preact, htmx, Angular, jQuery, Redux, and Astro pages alongside a refreshed 8-framework comparison matrix
13
+ - New runnable example apps: `cart-htmx` (htmx swap → kerf island mount pattern) and `counter-store` (sync + async + persisted store)
14
+ - Fix TodoMVC example: store actions now spread `get()` into `set()` so filter/edit interactions no longer wipe state
15
+ - 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
16
+
17
+ ## [0.7.0] - 2026-05-18
18
+
19
+
20
+ - Granular list updates now preserve DOM identity, focus, scroll, IME state, `<details open>`/`<dialog open>`, and `data-morph-skip` subtrees across in-place row updates
21
+ - Two new fast paths in the granular reconciler cut krausest select-row by 71% (27.8 → 8.2 ms) and partial-update by 28% (46.8 → 33.8 ms)
22
+ - `each()`'s third parameter renamed from `key` to `cacheKey` to clarify it's a passive cache-invalidation comparator, not a React-style reconciliation identity; positional callers unaffected
23
+ - JSX types now accept lowercase HTML attribute names (`class`, `for`, `tabindex`, `autofocus`, `autocomplete`, `spellcheck`) alongside the camelCase forms
24
+ - New public `morph(liveRoot, template)` export — kerf's general-purpose DOM reconciler, replacing the prior morphdom dependency
25
+ - `mount()` now throws if called on an element already inside (or containing) a mounted tree
26
+ - New `defineStore` dev-mode safety: `get()` snapshots are frozen so accidental mutations throw a `TypeError` instead of silently desyncing reactive consumers
27
+ - Clearer JSX runtime error for inline `onClick={handler}`-style attributes that points at `delegate()` as the fix
28
+ - 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`
29
+ - New `kerfjs/jsx-runtime` re-exports of `KerfBaseAttrs`, `KerfCustomElement`, `AttrLike`, `AttrValue`, `DataAriaAttrs` for declaration-merging custom-element types
30
+
7
31
  ## [0.6.0] - 2026-05-11
8
32
 
9
33
 
@@ -15,6 +39,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
15
39
  ## [Unreleased]
16
40
 
17
41
 
42
+ - **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.
43
+ - **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.
44
+ - **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.
45
+ - **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".
46
+ - **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.
47
+ - **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).
48
+ - **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).
49
+ - **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).
50
+ - **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).
51
+ - **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).
52
+ - **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).
53
+ - 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).
54
+ - 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).
55
+ - 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.
56
+ - 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).
57
+ - 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).
58
+ - 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).
59
+ - `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).
60
+ - 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).
61
+ - `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).
62
+ - 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).
18
63
  - 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).
19
64
  - 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).
20
65
  - **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).
package/README.md CHANGED
@@ -31,19 +31,18 @@ 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. **Built for the AI-assisted era.** Tiny public surface (~16 exports), no compiler magic, no hidden lifecycle. An LLM holds the framework in context and predicts behavior — your AI agent generates code that works the first time. Ships [`llms.txt`](./llms.txt) and a dedicated AI usage guide; the [Built by an AI · Pomodoro](https://brianwestphal.github.io/kerf/examples/complete/built-by-an-ai/) example is a working app one-shotted by Claude with `llms.txt` as its only kerf knowledge.
34
+ 1. **Small bundle.** 6.1 KB gzipped including signals (6.5 KB with `arraySignal`). One runtime dependency (`@preact/signals-core`). 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.
35
35
 
36
- 2. **Smallest cut.** 6.1 KB gzipped including signals (6.5 KB with `arraySignal`). Fine-grained reactivity re-runs only what changed; the diff touches only the DOM nodes that differ. On the [krausest js-framework-benchmark](./bench/results.md) kerf is competitive with Solid and Vue on swap-rows, remove-row, and clear — no compiler required.
36
+ 2. **No virtual DOM, no compiler.** JSX → HTML strings → native diff. DevTools shows the real DOM because it *is* the DOM.
37
37
 
38
- 3. **No virtual DOM, no compiler.** JSX → HTML strings → native diff. DevTools shows the real DOM because it *is* the DOM.
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. **Focus, selection, listeners survive re-renders.** We morph instead of rebuilding — your caret stays where you put it, your in-progress drag keeps moving, your delegated handlers keep firing.
40
+ 4. **Small public API.** ~16 exports total. 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
 
44
44
  ## When to use Kerf
45
45
 
46
- - **AI-generated apps** — your LLM/agent holds the framework in context; no hallucinated APIs.
47
46
  - **Hybrid desktop apps (Tauri / Electron)** — small bundle, predictable diff, debuggable runtime; ideal for the embedded webview.
48
47
  - **Embedded widgets** — chat bubbles, comment boxes, dashboards dropped into someone else's page.
49
48
  - **Server-rendered apps with islands** — Rails / Phoenix / Django / Hono. `mount` per island; `delegate` survives turbo-frame swaps.
@@ -161,8 +160,8 @@ npm install kerfjs
161
160
 
162
161
  - **Site:** [brianwestphal.github.io/kerf](https://brianwestphal.github.io/kerf/)
163
162
  - **Docs:** [`docs/`](./docs/) — overview · reactivity · stores · render · events · jsx · svg · [API reference](./docs/8-api-reference.md)
164
- - **Migrating:** [coming from React / Alpine / Lit / vanjs?](https://brianwestphal.github.io/kerf/migrating/) — side-by-side TodoMVC translations + per-framework gotchas
165
- - **AI guide:** [`docs/ai/usage-guide.md`](./docs/ai/usage-guide.md) — read once before writing kerf code with an LLM
163
+ - **Migrating:** [coming from another framework?](https://brianwestphal.github.io/kerf/migrating/) — side-by-side TodoMVC translations + per-framework gotchas
164
+ - **AI guide:** [`docs/ai/usage-guide.md`](./docs/ai/usage-guide.md) — reference for AI tools fetching kerf docs (linked from `llms.txt`)
166
165
  - **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)
167
166
  - **Repo:** [github.com/brianwestphal/kerf](https://github.com/brianwestphal/kerf)
168
167
 
@@ -1,4 +1,4 @@
1
- import { signal } from './chunk-FN2ID4QO.js';
1
+ import { signal } from './chunk-UU2YJEJY.js';
2
2
 
3
3
  // src/array-signal.ts
4
4
  var ARRAY_SIGNAL_BRAND = /* @__PURE__ */ Symbol.for("kerfjs.ArraySignal");
@@ -264,6 +264,15 @@ function renderAttr(key, value) {
264
264
  return "";
265
265
  }
266
266
  strValue = escapeAttr(value);
267
+ } else if (typeof value === "function" && /^on[A-Z]/.test(key)) {
268
+ throw new Error(
269
+ `JSX: inline event handlers like ${key}={fn} are not supported by kerf's JSX \u2192 HTML-string runtime. Use event delegation from the mount root instead:
270
+
271
+ delegate(rootEl, 'click', '[data-action="..."]', (evt, target) => { ... });
272
+ <button data-action="...">click</button>
273
+
274
+ See docs/5-event-delegation.md for the tier-1/tier-2/tier-3 model.`
275
+ );
267
276
  } else {
268
277
  throw new Error(
269
278
  `JSX: unsupported value for attribute "${key}" \u2014 got ${describeValue(value)}. Attribute values must be string, number, boolean, null, undefined, or SafeHtml. Did you mean to read .value off a Signal, or stringify the object first?`
@@ -284,5 +293,5 @@ function Fragment({ children }) {
284
293
  }
285
294
 
286
295
  export { Fragment, SafeHtml, collectLists, flatten, flattenWithoutListItems, granularListSafeHtml, isSafeHtml, jsx, listSafeHtml, raw };
287
- //# sourceMappingURL=chunk-LADH5GVQ.js.map
288
- //# sourceMappingURL=chunk-LADH5GVQ.js.map
296
+ //# sourceMappingURL=chunk-4VT4YZOO.js.map
297
+ //# sourceMappingURL=chunk-4VT4YZOO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/segment.ts","../src/utils/escapeHtml.ts","../src/utils/jsx-attr-aliases.ts","../src/jsx-runtime.ts"],"names":[],"mappings":"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","file":"chunk-4VT4YZOO.js","sourcesContent":["/**\n * `Segment` — kerf's structured render output.\n *\n * The JSX runtime emits a `SafeHtml` wrapping a `Segment`. Most renders\n * produce a single static segment (just an HTML string), which behaves\n * exactly like a string for backward compatibility. When the tree\n * contains a list (`each()`) or a parent whose children include a list,\n * the runtime emits a structured segment that `mount()` can dispatch\n * on — running its native keyed reconciler for the list parts and\n * leaving the static surrounds to the general-purpose diff.\n *\n * Why have a structured form at all: the perf bottleneck for huge\n * keyed lists isn't the per-row JSX work (which `each()` already\n * memoizes). It's that flattening every render's whole tree to one\n * big HTML string forces a full `innerHTML` parse and a tree walk\n * over rows we know are unchanged. The segment shape lets mount()\n * skip both for the list parts.\n */\n\nexport type Segment = StaticSegment | ListSegment | MixedSegment;\n\nexport interface StaticSegment {\n kind: 'static';\n html: string;\n}\n\nexport interface ListItem {\n /**\n * The row's object identity. Used by the reconciler to match new items\n * against live DOM nodes across renders. Unchanged ref → reuse the\n * existing live node; replaced ref → build a fresh node.\n */\n ref: object;\n /**\n * Optional cache-invalidation key that captures external state affecting\n * this row's render (e.g. selection class). Different cacheKey on the\n * same `ref` triggers a cache miss for that row. `undefined` when the\n * user didn't pass a `key` callback to `each()`.\n */\n cacheKey: unknown;\n html: string;\n}\n\nexport interface ListSegment {\n kind: 'list';\n id: string;\n items: ListItem[];\n /**\n * Optional granular patches (KF-92). When present, the list reconciler\n * applies these directly to the existing binding instead of doing a\n * full classify+reconcile pass. Emitted by `each()` when bound to an\n * `arraySignal`. Mutually exclusive with the `items` snapshot in the\n * sense that the snapshot is treated as informational/fall-back when\n * patches are present.\n */\n patches?: ArrayPatchInternal[];\n}\n\n/**\n * Internal patch shape used inside list segments. Mirrors `ArrayPatch<T>`\n * from `array-signal.ts` but typed against `object` so the segment layer\n * doesn't need to be generic. `update` / `insert` patches carry the row's\n * pre-rendered HTML — `each()` renders them at JSX-evaluation time inside a\n * try/catch so a throwing render falls back to the snapshot path (KF-99)\n * instead of leaving the signal and DOM divergent.\n */\nexport type ArrayPatchInternal =\n | { type: 'update'; index: number; item: object; html: string }\n | { type: 'insert'; index: number; item: object; html: string }\n | { type: 'remove'; index: number }\n | { type: 'move'; from: number; to: number }\n | { type: 'replace'; items: readonly object[] };\n\nexport interface MixedSegment {\n kind: 'mixed';\n parts: Segment[];\n}\n\n/**\n * Flatten a segment to a complete HTML string. Used for first render\n * (bulk innerHTML), for SSR-style consumption via `toString()`, and\n * for diagnostics.\n *\n * If `withMarkers` is set, list segments are wrapped in\n * `<!--kf-list:{id}-->` comments so the post-parse walk can find each\n * list's live parent. Plain (non-marker) flatten is what JSX consumers\n * see when they call `.toString()` on the SafeHtml.\n */\nexport function flatten(segment: Segment, withMarkers: boolean): string {\n if (segment.kind === 'static') return segment.html;\n if (segment.kind === 'list') {\n const items = segment.items.map((i) => i.html).join('');\n return withMarkers ? `<!--kf-list:${segment.id}-->${items}` : items;\n }\n return segment.parts.map((p) => flatten(p, withMarkers)).join('');\n}\n\n/**\n * Variant of `flatten` for the static-only diff path on subsequent\n * renders. Lists are reduced to a single marker comment with no items\n * inside — the actual list children stay in the live DOM and are\n * reconciled separately. Keeping list items out of this string is\n * what makes the morph cheap on huge lists where most rows are\n * unchanged.\n */\nexport function flattenWithoutListItems(segment: Segment): string {\n if (segment.kind === 'static') return segment.html;\n if (segment.kind === 'list') return `<!--kf-list:${segment.id}-->`;\n return segment.parts.map(flattenWithoutListItems).join('');\n}\n\n/** Collect every `ListSegment` in the tree, keyed by its id. */\nexport function collectLists(\n segment: Segment,\n out: Map<string, ListSegment> = new Map(),\n): Map<string, ListSegment> {\n if (segment.kind === 'list') out.set(segment.id, segment);\n else if (segment.kind === 'mixed') {\n for (const part of segment.parts) collectLists(part, out);\n }\n return out;\n}\n\n/**\n * Combine a list of child segments into the smallest equivalent\n * representation: collapses adjacent statics into one static, returns\n * a single static if everything is static, otherwise a mixed segment\n * with statics coalesced.\n */\nexport function mergeChildSegments(parts: Segment[]): Segment {\n if (parts.length === 0) return { kind: 'static', html: '' };\n if (parts.every((p) => p.kind === 'static')) {\n return {\n kind: 'static',\n html: parts.map((p) => (p as StaticSegment).html).join(''),\n };\n }\n const merged: Segment[] = [];\n let coalesced = '';\n for (const p of parts) {\n if (p.kind === 'static') {\n coalesced += p.html;\n } else {\n if (coalesced !== '') {\n merged.push({ kind: 'static', html: coalesced });\n coalesced = '';\n }\n merged.push(p);\n }\n }\n if (coalesced !== '') merged.push({ kind: 'static', html: coalesced });\n return { kind: 'mixed', parts: merged };\n}\n\n/**\n * Wrap a child segment with surrounding open/close tags from the\n * parent JSX element. Used by the JSX runtime when constructing\n * `_jsx(tag, ...)` output.\n */\nexport function wrapWithTags(child: Segment, openTag: string, closeTag: string): Segment {\n if (child.kind === 'static') {\n return { kind: 'static', html: openTag + child.html + closeTag };\n }\n if (child.kind === 'mixed') {\n return {\n kind: 'mixed',\n parts: [\n { kind: 'static', html: openTag },\n ...child.parts,\n { kind: 'static', html: closeTag },\n ],\n };\n }\n return {\n kind: 'mixed',\n parts: [\n { kind: 'static', html: openTag },\n child,\n { kind: 'static', html: closeTag },\n ],\n };\n}\n","/**\n * HTML / attribute escaping for the JSX runtime. Identical to the helpers\n * used in any reasonable HTML emitter — included here so kerf has no extra\n * runtime dependencies beyond `@preact/signals-core`.\n */\n\nexport function escapeHtml(str: string): string {\n return str\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;');\n}\n\nexport function escapeAttr(str: string): string {\n return str\n .replace(/&/g, '&amp;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;');\n}\n","/**\n * JSX → HTML / SVG attribute name aliases.\n *\n * The JSX runtime translates camelCase attributes (React convention) to\n * the kebab-case / colon-form names the browser actually wants. Anything\n * not in this map is passed through verbatim — `data-*`, `aria-*`, and\n * any custom attribute work without ceremony.\n *\n * Lives in its own module so `src/jsx-runtime.ts` can stay under the\n * 200-LOC project guideline; the bulk of `jsx-runtime.ts` was this table.\n */\n\nexport const ATTR_ALIASES: Record<string, string> = {\n // HTML attributes\n className: 'class',\n htmlFor: 'for',\n httpEquiv: 'http-equiv',\n acceptCharset: 'accept-charset',\n accessKey: 'accesskey',\n autoCapitalize: 'autocapitalize',\n autoComplete: 'autocomplete',\n autoFocus: 'autofocus',\n autoPlay: 'autoplay',\n colSpan: 'colspan',\n contentEditable: 'contenteditable',\n crossOrigin: 'crossorigin',\n dateTime: 'datetime',\n defaultChecked: 'checked',\n defaultValue: 'value',\n encType: 'enctype',\n formAction: 'formaction',\n formEncType: 'formenctype',\n formMethod: 'formmethod',\n formNoValidate: 'formnovalidate',\n formTarget: 'formtarget',\n hrefLang: 'hreflang',\n inputMode: 'inputmode',\n maxLength: 'maxlength',\n minLength: 'minlength',\n noModule: 'nomodule',\n noValidate: 'novalidate',\n readOnly: 'readonly',\n referrerPolicy: 'referrerpolicy',\n rowSpan: 'rowspan',\n spellCheck: 'spellcheck',\n srcDoc: 'srcdoc',\n srcLang: 'srclang',\n srcSet: 'srcset',\n tabIndex: 'tabindex',\n useMap: 'usemap',\n\n // SVG presentation attributes (camelCase → kebab-case)\n strokeWidth: 'stroke-width',\n strokeLinecap: 'stroke-linecap',\n strokeLinejoin: 'stroke-linejoin',\n strokeDasharray: 'stroke-dasharray',\n strokeDashoffset: 'stroke-dashoffset',\n strokeMiterlimit: 'stroke-miterlimit',\n strokeOpacity: 'stroke-opacity',\n fillOpacity: 'fill-opacity',\n fillRule: 'fill-rule',\n clipPath: 'clip-path',\n clipRule: 'clip-rule',\n colorInterpolation: 'color-interpolation',\n colorInterpolationFilters: 'color-interpolation-filters',\n floodColor: 'flood-color',\n floodOpacity: 'flood-opacity',\n lightingColor: 'lighting-color',\n stopColor: 'stop-color',\n stopOpacity: 'stop-opacity',\n shapeRendering: 'shape-rendering',\n imageRendering: 'image-rendering',\n textRendering: 'text-rendering',\n pointerEvents: 'pointer-events',\n vectorEffect: 'vector-effect',\n paintOrder: 'paint-order',\n\n // SVG text/font attributes\n fontFamily: 'font-family',\n fontSize: 'font-size',\n fontStyle: 'font-style',\n fontVariant: 'font-variant',\n fontWeight: 'font-weight',\n fontStretch: 'font-stretch',\n textAnchor: 'text-anchor',\n textDecoration: 'text-decoration',\n dominantBaseline: 'dominant-baseline',\n alignmentBaseline: 'alignment-baseline',\n baselineShift: 'baseline-shift',\n letterSpacing: 'letter-spacing',\n wordSpacing: 'word-spacing',\n writingMode: 'writing-mode',\n\n // SVG marker attributes\n markerStart: 'marker-start',\n markerMid: 'marker-mid',\n markerEnd: 'marker-end',\n\n // SVG xlink (legacy but still used)\n xlinkHref: 'xlink:href',\n xlinkShow: 'xlink:show',\n xlinkActuate: 'xlink:actuate',\n xlinkType: 'xlink:type',\n xlinkRole: 'xlink:role',\n xlinkTitle: 'xlink:title',\n xlinkArcrole: 'xlink:arcrole',\n xmlBase: 'xml:base',\n xmlLang: 'xml:lang',\n xmlSpace: 'xml:space',\n xmlnsXlink: 'xmlns:xlink',\n};\n","/**\n * kerf JSX runtime.\n *\n * JSX renders to `SafeHtml`, which wraps both:\n * - `__html`: the flattened HTML string (what `toString()` returns; what\n * legacy/SSR consumers care about)\n * - `__segment`: a structured representation that distinguishes \"static\n * html\", \"keyed list\", and \"mixed\" content.\n *\n * Most renders are pure-static and the segment is just `{kind:'static',html}`.\n * When the tree contains a list (via `each()`) or a parent whose children\n * include a non-static segment, the runtime threads that structure up so\n * `mount()` can dispatch on it — running its native keyed reconciler for\n * the list parts and leaving the static surrounds to the general-purpose\n * diff.\n *\n * Configure in your `tsconfig.json`:\n *\n * \"jsx\": \"react-jsx\",\n * \"jsxImportSource\": \"kerfjs\"\n *\n * Then write JSX as you normally would — kerf provides the `jsx` /\n * `jsxs` / `jsxDEV` / `Fragment` exports the JSX transform looks for.\n */\n\nimport type { KerfBuiltinIntrinsicElements } from './jsx-types.js';\nimport {\n flatten,\n type ListSegment,\n mergeChildSegments,\n type Segment,\n wrapWithTags,\n} from './segment.js';\nimport { escapeAttr, escapeHtml } from './utils/escapeHtml.js';\nimport { ATTR_ALIASES } from './utils/jsx-attr-aliases.js';\n\n// Cross-realm/cross-bundle brand. Using `Symbol.for` (the global registry)\n// means two `SafeHtml` classes from different module copies still recognize\n// each other. Same approach React uses for `$$typeof: Symbol.for('react.element')`.\n// Without this, `instanceof SafeHtml` fails when the consumer's bundler ends\n// up loading two copies of kerf (separate barrel + jsx-runtime entries,\n// monorepo dedup misses, ESM/CJS interop, etc.).\nconst SAFE_HTML_BRAND = Symbol.for('kerfjs.SafeHtml');\n\nexport class SafeHtml {\n readonly __html: string;\n readonly __segment: Segment;\n // Branded so `isSafeHtml()` recognizes instances from any copy of this module.\n readonly [SAFE_HTML_BRAND] = true as const;\n constructor(input: string | Segment) {\n if (typeof input === 'string') {\n this.__segment = { kind: 'static', html: input };\n this.__html = input;\n } else {\n this.__segment = input;\n this.__html = flatten(input, false);\n }\n }\n toString(): string {\n return this.__html;\n }\n}\n\n/**\n * Type guard for `SafeHtml`. Prefer this over `instanceof SafeHtml` — it works\n * across module copies (e.g. when the consumer's bundler loads kerf's barrel\n * and JSX-runtime entries as independent modules).\n */\nexport function isSafeHtml(value: unknown): value is SafeHtml {\n return typeof value === 'object'\n && value !== null\n && (value as Record<symbol, unknown>)[SAFE_HTML_BRAND] === true;\n}\n\n/** Inject a pre-escaped HTML string. Use sparingly — caller is responsible for escaping. */\nexport function raw(html: string): SafeHtml {\n return new SafeHtml(html);\n}\n\n/**\n * Internal: build a `SafeHtml` representing a list segment. Used by\n * `each()` so the JSX runtime is the sole owner of `SafeHtml` construction.\n */\nexport function listSafeHtml(id: string, items: ListSegment['items']): SafeHtml {\n return new SafeHtml({ kind: 'list', id, items });\n}\n\n/**\n * Internal: build a `SafeHtml` representing a granular list segment with\n * patches (KF-92). The reconciler applies the patches to the existing\n * binding directly, skipping the per-item iteration that the snapshot\n * `listSafeHtml` requires. `items` is included for fall-through paths\n * (toString during SSR, fall-back when the binding doesn't exist yet).\n *\n * Patch HTML is rendered upstream (in `each()`) inside a try/catch — see\n * KF-99 — so by the time we get here every `update` / `insert` patch\n * already carries a `html` string, and the reconciler does no further\n * row rendering.\n */\nexport function granularListSafeHtml(\n id: string,\n items: ListSegment['items'],\n patches: NonNullable<ListSegment['patches']>,\n): SafeHtml {\n return new SafeHtml({ kind: 'list', id, items, patches });\n}\n\ntype Child = SafeHtml | string | number | boolean | null | undefined;\ntype Children = Child | Children[];\n\ninterface Props {\n children?: Children;\n [key: string]: unknown;\n}\n\nconst VOID_TAGS = new Set([\n 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',\n 'link', 'meta', 'source', 'track', 'wbr',\n]);\n\n/**\n * Convert a single JSX child into a Segment. Handles SafeHtml passthrough,\n * primitive coercion + escaping, arrays (recursive), and the nullish/false\n * skip cases.\n */\nfunction toSegment(child: Children): Segment {\n if (child == null || typeof child === 'boolean') return { kind: 'static', html: '' };\n if (isSafeHtml(child)) {\n // Cross-bundle SafeHtml shims (KF-14 case) may have only `__html`.\n return child.__segment ?? { kind: 'static', html: child.__html };\n }\n if (typeof child === 'string') return { kind: 'static', html: escapeHtml(child) };\n if (typeof child === 'number') return { kind: 'static', html: String(child) };\n if (Array.isArray(child)) return mergeChildSegments(child.map(toSegment));\n // Catch the common mistake of passing a DOM element (e.g. the result of\n // toElement(...)) as a JSX child. The runtime renders to HTML strings, so\n // DOM nodes can't be composed — they'd silently serialize to \"\" and their\n // event listeners would be lost. Throw loudly so this can't sneak in.\n const maybeNode = child as unknown;\n if (typeof maybeNode === 'object' && maybeNode !== null\n && ('nodeType' in maybeNode || 'outerHTML' in maybeNode)) {\n throw new Error(\n 'JSX: DOM elements cannot be passed as children (the JSX runtime renders to HTML strings). '\n + 'Build the tree in one JSX expression and use querySelector after toElement() to get element refs.',\n );\n }\n throw new Error(\n `JSX: unsupported child of type ${describeValue(child)}. `\n + 'Children must be SafeHtml, string, number, boolean, null, undefined, or an array of those. '\n + 'Common mistakes: passing a Signal/Store object directly (use signal.value or store.state.value), '\n + 'passing a function (call it first), or passing a Promise (await it before render).',\n );\n}\n\nfunction describeValue(v: unknown): string {\n if (Array.isArray(v)) return 'array';\n if (typeof v === 'object' && v !== null) {\n const ctor = (v as { constructor?: { name?: string } }).constructor?.name;\n return ctor && ctor !== 'Object' ? `object (${ctor})` : 'object';\n }\n return typeof v;\n}\n\n// URL-bearing HTML/SVG attributes. Plain-string values written here are\n// screened against `DANGEROUS_URL_RE` so a stored-XSS payload like\n// `<a href={userInput}>` with `userInput === 'javascript:alert(1)'` produces\n// a dropped attribute (and a console.warn) rather than a clickable script\n// vector. `SafeHtml` values (i.e. `raw(...)`) bypass the screen — that's the\n// documented opt-out for legitimate cases (bookmarklet builders, sanitized\n// inputs from a separate trust layer).\nconst URL_ATTRS = new Set(['href', 'src', 'xlink:href', 'formaction', 'action']);\nconst DANGEROUS_URL_RE = /^\\s*(?:(?:java|vb)script:|data:text\\/html[;,])/i;\n\nfunction renderAttr(key: string, value: unknown): string {\n const name = ATTR_ALIASES[key] ?? key;\n if (value == null || value === false) return '';\n if (value === true) return ` ${name}`;\n let strValue: string;\n if (isSafeHtml(value)) {\n strValue = value.__html;\n } else if (typeof value === 'number') {\n strValue = String(value);\n } else if (typeof value === 'string') {\n if (URL_ATTRS.has(name) && DANGEROUS_URL_RE.test(value)) {\n console.warn(\n `JSX: dropped dangerous URL value for ${name}=${JSON.stringify(value.slice(0, 80))}. `\n + 'kerf blocks javascript:, vbscript:, and data:text/html URLs in href/src/formaction/action/xlink:href by default. '\n + 'Wrap in raw() if this is intentional (e.g. bookmarklets), or sanitize upstream.',\n );\n return '';\n }\n strValue = escapeAttr(value);\n } else if (typeof value === 'function' && /^on[A-Z]/.test(key)) {\n throw new Error(\n `JSX: inline event handlers like ${key}={fn} are not supported by kerf's JSX → HTML-string runtime. `\n + 'Use event delegation from the mount root instead:\\n\\n'\n + ' delegate(rootEl, \\'click\\', \\'[data-action=\"...\"]\\', (evt, target) => { ... });\\n'\n + ' <button data-action=\"...\">click</button>\\n\\n'\n + 'See docs/5-event-delegation.md for the tier-1/tier-2/tier-3 model.',\n );\n } else {\n throw new Error(\n `JSX: unsupported value for attribute \"${key}\" — got ${describeValue(value)}. `\n + 'Attribute values must be string, number, boolean, null, undefined, or SafeHtml. '\n + 'Did you mean to read .value off a Signal, or stringify the object first?',\n );\n }\n return ` ${name}=\"${strValue}\"`;\n}\n\nexport function jsx(tag: string | ((props: Props) => SafeHtml), props: Props): SafeHtml {\n if (typeof tag === 'function') return tag(props);\n\n const { children, ...attrs } = props;\n const attrStr = Object.entries(attrs)\n .map(([k, v]) => renderAttr(k, v))\n .join('');\n\n if (VOID_TAGS.has(tag)) return new SafeHtml(`<${tag}${attrStr}>`);\n\n const childSegment: Segment = children != null\n ? toSegment(children)\n : { kind: 'static', html: '' };\n return new SafeHtml(wrapWithTags(childSegment, `<${tag}${attrStr}>`, `</${tag}>`));\n}\n\nexport { jsx as jsxs };\n// vitest's dev-mode JSX transform emits `jsxDEV(tag, props, ...)`; the\n// alias lets tests import this module without the production build pipeline\n// caring.\nexport { jsx as jsxDEV };\n\nexport function Fragment({ children }: { children?: Children }): SafeHtml {\n return new SafeHtml(children != null ? toSegment(children) : { kind: 'static', html: '' });\n}\n\n// eslint-disable-next-line @typescript-eslint/no-namespace\nexport namespace JSX {\n export type Element = SafeHtml;\n export interface ElementChildrenAttribute {\n children: unknown;\n }\n // Per-tag attribute contracts live in `./jsx-types.ts` as\n // `KerfBuiltinIntrinsicElements`. Re-exposed as an **interface** (not a\n // type alias) so consumers can declaration-merge custom-element tags\n // (KF-100):\n //\n // declare module 'kerfjs/jsx-runtime' {\n // namespace JSX {\n // interface IntrinsicElements {\n // 'my-element': KerfCustomElement & { foo?: string };\n // }\n // }\n // }\n //\n // KF-123: the imported interface is named `KerfBuiltinIntrinsicElements`\n // upstream so tsup/tsc cannot strip an import alias and end up emitting\n // `interface IntrinsicElements extends IntrinsicElements {}` in the .d.ts\n // — that shadowed form self-resolves to empty and breaks every `<tag>` in\n // consumer .tsx with TS2339. Verified against `dist/jsx-runtime.d.ts` by\n // `tests/dist/jsx-typing/` on every `npm run build`.\n // eslint-disable-next-line @typescript-eslint/no-empty-object-type\n export interface IntrinsicElements extends KerfBuiltinIntrinsicElements {}\n}\n\n/**\n * Public re-exports of the JSX type primitives so consumers can compose\n * attribute interfaces for custom elements without reaching into\n * `kerfjs/jsx-types` (which is intentionally not in `package.json#exports`).\n */\nexport type {\n AttrLike,\n AttrValue,\n DataAriaAttrs,\n KerfBaseAttrs,\n KerfCustomElement,\n} from './jsx-types.js';\n"]}
@@ -0,0 +1,41 @@
1
+ import { signal as signal$1, Signal } from '@preact/signals-core';
2
+ export { batch, computed, effect } from '@preact/signals-core';
3
+
4
+ // src/reactive.ts
5
+ var WARNING_MESSAGE = "kerf: signal was written but has no subscribers. Did you read `.value` outside of a render fn / effect()? Hoisted reads do not subscribe, so subsequent writes will not re-render. Move the read inside mount()'s render fn or effect() callback. Set KERF_DEV_WARN_UNTRACKED_SIGNALS=0 (or unset it) to silence this warning.";
6
+ var DevSignal = class extends Signal {
7
+ __hasSubscriber = false;
8
+ __warned = false;
9
+ __constructed = false;
10
+ constructor(initial) {
11
+ super(initial, {
12
+ watched() {
13
+ this.__hasSubscriber = true;
14
+ }
15
+ });
16
+ this.__constructed = true;
17
+ }
18
+ get value() {
19
+ return super.value;
20
+ }
21
+ set value(v) {
22
+ super.value = v;
23
+ if (this.__constructed && !this.__hasSubscriber && !this.__warned) {
24
+ this.__warned = true;
25
+ console.warn(WARNING_MESSAGE);
26
+ }
27
+ }
28
+ };
29
+ function isDevWarnUntrackedEnabled() {
30
+ const proc = globalThis.process;
31
+ if (proc?.env?.NODE_ENV === "production") return false;
32
+ return proc?.env?.KERF_DEV_WARN_UNTRACKED_SIGNALS === "1";
33
+ }
34
+ function signal(value) {
35
+ if (isDevWarnUntrackedEnabled()) return new DevSignal(value);
36
+ return signal$1(value);
37
+ }
38
+
39
+ export { signal };
40
+ //# sourceMappingURL=chunk-UU2YJEJY.js.map
41
+ //# sourceMappingURL=chunk-UU2YJEJY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/dev-signal.ts","../src/reactive.ts"],"names":["coreSignal"],"mappings":";;;;AA0BA,IAAM,eAAA,GACF,gUAAA;AAMG,IAAM,SAAA,GAAN,cAA2B,MAAA,CAAU;AAAA,EAClC,eAAA,GAAkB,KAAA;AAAA,EAClB,QAAA,GAAW,KAAA;AAAA,EACX,aAAA,GAAgB,KAAA;AAAA,EAExB,YAAY,OAAA,EAAa;AACvB,IAAA,KAAA,CAAM,OAAA,EAAc;AAAA,MAClB,OAAA,GAAyB;AACvB,QAAC,KAAiD,eAAA,GAAkB,IAAA;AAAA,MACtE;AAAA,KACD,CAAA;AACD,IAAA,IAAA,CAAK,aAAA,GAAgB,IAAA;AAAA,EACvB;AAAA,EAEA,IAAa,KAAA,GAAW;AAAE,IAAA,OAAO,KAAA,CAAM,KAAA;AAAA,EAAO;AAAA,EAC9C,IAAa,MAAM,CAAA,EAAM;AACvB,IAAA,KAAA,CAAM,KAAA,GAAQ,CAAA;AACd,IAAA,IAAI,KAAK,aAAA,IAAiB,CAAC,KAAK,eAAA,IAAmB,CAAC,KAAK,QAAA,EAAU;AACjE,MAAA,IAAA,CAAK,QAAA,GAAW,IAAA;AAChB,MAAA,OAAA,CAAQ,KAAK,eAAe,CAAA;AAAA,IAC9B;AAAA,EACF;AACF,CAAA;AAEO,SAAS,yBAAA,GAAqC;AACnD,EAAA,MAAM,OAAQ,UAAA,CAA0E,OAAA;AACxF,EAAA,IAAI,IAAA,EAAM,GAAA,EAAK,QAAA,KAAa,YAAA,EAAc,OAAO,KAAA;AACjD,EAAA,OAAO,IAAA,EAAM,KAAK,+BAAA,KAAoC,GAAA;AACxD;ACrCO,SAAS,OAAU,KAAA,EAAsB;AAC9C,EAAA,IAAI,yBAAA,EAA0B,EAAG,OAAO,IAAI,UAAa,KAAU,CAAA;AACnE,EAAA,OAAOA,SAAW,KAAU,CAAA;AAC9B","file":"chunk-UU2YJEJY.js","sourcesContent":["/**\n * Dev-mode signal subclass with subscriber tracking (KF-176). When the\n * dev-warn opt-in is enabled, `signal()` returns a `DevSignal` that emits a\n * one-shot `console.warn` the first time `.value` is written to an instance\n * that has never had a subscriber attached. This surfaces the canonical\n * Rule 7 violation (read `.value` outside a render fn / effect — the read\n * doesn't subscribe, so subsequent writes silently fail to re-render) at\n * the moment the user makes the wrong write, instead of leaving them to\n * notice that their UI never updates.\n *\n * The gate is `process.env.NODE_ENV !== 'production'` AND\n * `KERF_DEV_WARN_UNTRACKED_SIGNALS === '1'`. Off by default because the\n * heuristic produces false positives for purely imperative signals (used as\n * mutable cells with no UI consumer); opt-in is the right shape until a\n * sharper heuristic is found. Production behavior is unchanged for zero\n * runtime cost.\n *\n * The subclass uses signals-core's `SignalOptions.watched` callback to set a\n * per-instance `__hasSubscriber` flag — fired by signals-core when the first\n * subscriber attaches. We never clear the flag on `unwatched`, so a signal\n * that *was* subscribed at some point won't warn even if its subscribers\n * later detach.\n */\n\nimport { Signal } from '@preact/signals-core';\n\nconst WARNING_MESSAGE\n = 'kerf: signal was written but has no subscribers. '\n + 'Did you read `.value` outside of a render fn / effect()? '\n + 'Hoisted reads do not subscribe, so subsequent writes will not re-render. '\n + 'Move the read inside mount()\\'s render fn or effect() callback. '\n + 'Set KERF_DEV_WARN_UNTRACKED_SIGNALS=0 (or unset it) to silence this warning.';\n\nexport class DevSignal<T> extends Signal<T> {\n private __hasSubscriber = false;\n private __warned = false;\n private __constructed = false;\n\n constructor(initial?: T) {\n super(initial as T, {\n watched(this: Signal<T>) {\n (this as unknown as { __hasSubscriber: boolean }).__hasSubscriber = true;\n },\n });\n this.__constructed = true;\n }\n\n override get value(): T { return super.value; }\n override set value(v: T) {\n super.value = v;\n if (this.__constructed && !this.__hasSubscriber && !this.__warned) {\n this.__warned = true;\n console.warn(WARNING_MESSAGE);\n }\n }\n}\n\nexport function isDevWarnUntrackedEnabled(): boolean {\n const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;\n if (proc?.env?.NODE_ENV === 'production') return false;\n return proc?.env?.KERF_DEV_WARN_UNTRACKED_SIGNALS === '1';\n}\n","/**\n * Re-exports of `@preact/signals-core`. Lets the rest of the codebase depend\n * on `'./reactive.js'` without naming the underlying lib, so swapping it out\n * later (or fronting it with a hand-rolled implementation) is a one-file\n * change.\n *\n * The `signal()` factory is dev-gated: when `NODE_ENV !== 'production'` and\n * `KERF_DEV_WARN_UNTRACKED_SIGNALS === '1'`, it returns a `DevSignal` that\n * warns on writes to signals with no subscribers (KF-176). Off by default;\n * production always returns the bare `@preact/signals-core` signal.\n */\n\nimport { type Signal,signal as coreSignal } from '@preact/signals-core';\n\nimport { DevSignal, isDevWarnUntrackedEnabled } from './dev-signal.js';\n\nexport {\n batch,\n computed,\n effect,\n type ReadonlySignal,\n type Signal,\n} from '@preact/signals-core';\n\nexport function signal<T>(value?: T): Signal<T> {\n if (isDevWarnUntrackedEnabled()) return new DevSignal<T>(value as T) as Signal<T>;\n return coreSignal(value as T);\n}\n"]}
@@ -0,0 +1,70 @@
1
+ import { signal } from './chunk-UU2YJEJY.js';
2
+
3
+ // src/dev-store-warn.ts
4
+ var WARNING_PREFIX = "kerf: defineStore.set() called with keys missing from the current state \u2014 ";
5
+ var WARNING_SUFFIX = ". set() REPLACES state; the missing keys will be undefined after this call. Use `set({ ...get(), ...next })` to merge instead, or update each call site to pass the full state. Set KERF_DEV_WARN_NARROW_SET=0 (or unset it) to silence this warning.";
6
+ function isOptedIn() {
7
+ const proc = globalThis.process;
8
+ if (proc?.env?.NODE_ENV === "production") return false;
9
+ return proc?.env?.KERF_DEV_WARN_NARROW_SET === "1";
10
+ }
11
+ function isPlainObjectState(v) {
12
+ if (v === null || typeof v !== "object") return false;
13
+ if (Array.isArray(v)) return false;
14
+ return true;
15
+ }
16
+ function maybeWarnNarrowSet(prev, next, ctx) {
17
+ if (ctx.warned) return;
18
+ if (!isOptedIn()) return;
19
+ if (!isPlainObjectState(prev) || !isPlainObjectState(next)) return;
20
+ const missing = [];
21
+ for (const k of Object.keys(prev)) {
22
+ if (!(k in next)) missing.push(k);
23
+ }
24
+ if (missing.length === 0) return;
25
+ ctx.warned = true;
26
+ const keysList = missing.map((k) => `\`${k}\``).join(", ");
27
+ console.warn(`${WARNING_PREFIX}${keysList}${WARNING_SUFFIX}`);
28
+ }
29
+
30
+ // src/store.ts
31
+ var REGISTRY = [];
32
+ var IS_DEV = (() => {
33
+ const proc = globalThis.process;
34
+ return proc?.env?.NODE_ENV !== "production";
35
+ })();
36
+ function defineStore(spec) {
37
+ const internal = signal(spec.initial());
38
+ const warnCtx = { warned: false };
39
+ const set = (next) => {
40
+ maybeWarnNarrowSet(internal.value, next, warnCtx);
41
+ internal.value = next;
42
+ };
43
+ const get = () => {
44
+ const v = internal.value;
45
+ if (IS_DEV && v !== null && typeof v === "object") {
46
+ Object.freeze(v);
47
+ }
48
+ return v;
49
+ };
50
+ const actions = spec.actions(set, get);
51
+ const store = {
52
+ state: internal,
53
+ actions,
54
+ reset() {
55
+ internal.value = spec.initial();
56
+ }
57
+ };
58
+ REGISTRY.push(store);
59
+ return store;
60
+ }
61
+ function resetAllStores() {
62
+ for (const s of REGISTRY) s.reset();
63
+ }
64
+ function clearStoreRegistry() {
65
+ REGISTRY.length = 0;
66
+ }
67
+
68
+ export { clearStoreRegistry, defineStore, resetAllStores };
69
+ //# sourceMappingURL=chunk-WUFUTNA7.js.map
70
+ //# sourceMappingURL=chunk-WUFUTNA7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/dev-store-warn.ts","../src/store.ts"],"names":[],"mappings":"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","file":"chunk-WUFUTNA7.js","sourcesContent":["/**\n * Dev-mode warning for partial-set violations of Hard Rule 8 (KF-212). When\n * the opt-in env var `KERF_DEV_WARN_NARROW_SET=1` is set in a non-production\n * build, `defineStore`'s `set()` calls `maybeWarnNarrowSet(prev, next, ctx)`\n * before assigning. If `next` is a plain object whose own-keys are a strict\n * subset of `prev`'s own-keys, a one-shot `console.warn` fires naming the\n * missing keys and pointing at the canonical `set({ ...get(), ...next })`\n * merge fix.\n *\n * Why opt-in: narrow-set IS legal — sometimes you want to replace state with\n * a smaller shape (a reset() that drops keys, a feature-flag-driven schema\n * change). The warn is the right shape for the canonical bug (\"I wrote\n * `set({filter})` against a replace-semantics store and wiped items+editingId\")\n * but produces false positives for intentional shape changes. Opt-in keeps\n * the warning available to dev/CI environments that want the diagnostic\n * without surprising existing projects.\n *\n * Trigger condition: ANY key in `prev` missing from `next` — strictly broader\n * than \"fewer keys total.\" A `set({a, c})` against `cur = {a, b}` (same count,\n * different keys) also wipes `b`, so it warns. The original partial-set bug\n * shape was always \"at least one key from current is missing in next\"; the\n * key-count check in the original ticket sketch was an early-exit\n * optimization, not the semantic gate.\n *\n * Skips: non-object cur/next (booleans, numbers, strings — no \"keys\" to\n * miss), null/undefined either side, and arrays either side (shrinking-array\n * replacement is normal, not a partial set).\n *\n * Per-store one-shot dedup: each store warns at most once across its\n * lifetime — matches the KF-174 / KF-176 pattern of \"tell the developer\n * about the rule violation once, then trust them to fix it.\" The dedup\n * scope is the store, not the module, so a second store can still warn\n * if it independently hits the same bug.\n *\n * Production behavior is unchanged for zero runtime cost (the env-var read\n * short-circuits before any per-set work runs).\n */\n\nexport interface NarrowSetWarnContext {\n /** Set once per store; the warner reads/writes this to enforce the per-store one-shot dedup. */\n warned: boolean;\n}\n\nconst WARNING_PREFIX\n = 'kerf: defineStore.set() called with keys missing from the current state — ';\nconst WARNING_SUFFIX\n = '. set() REPLACES state; the missing keys will be undefined after this call. '\n + 'Use `set({ ...get(), ...next })` to merge instead, or update each call site to pass the full state. '\n + 'Set KERF_DEV_WARN_NARROW_SET=0 (or unset it) to silence this warning.';\n\nexport function isOptedIn(): boolean {\n const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;\n if (proc?.env?.NODE_ENV === 'production') return false;\n return proc?.env?.KERF_DEV_WARN_NARROW_SET === '1';\n}\n\nfunction isPlainObjectState(v: unknown): v is Record<string, unknown> {\n if (v === null || typeof v !== 'object') return false;\n if (Array.isArray(v)) return false;\n return true;\n}\n\nexport function maybeWarnNarrowSet(\n prev: unknown,\n next: unknown,\n ctx: NarrowSetWarnContext,\n): void {\n if (ctx.warned) return;\n if (!isOptedIn()) return;\n if (!isPlainObjectState(prev) || !isPlainObjectState(next)) return;\n\n const missing: string[] = [];\n for (const k of Object.keys(prev)) {\n if (!(k in next)) missing.push(k);\n }\n if (missing.length === 0) return;\n\n ctx.warned = true;\n const keysList = missing.map((k) => `\\`${k}\\``).join(', ');\n console.warn(`${WARNING_PREFIX}${keysList}${WARNING_SUFFIX}`);\n}\n\n/**\n * Test helper — resets the per-store `warned` flag on a context so a\n * subsequent test in the same module can re-exercise the first-warning\n * path. Not exported from the public barrel; the unit-test file imports it\n * directly via the relative path.\n */\nexport function _resetWarnContext(ctx: NarrowSetWarnContext): void {\n ctx.warned = false;\n}\n","/**\n * `defineStore({ initial, actions })` — composable testable stores layered on\n * top of `reactive.ts`'s signals.\n *\n * Three rules:\n * 1. `state` is read-only. Consumers read via `state.value` or subscribe via\n * `effect()`. They cannot write directly.\n * 2. `actions` is the only mutation surface. All writes go through named\n * action functions. This is what makes stores testable — assert against\n * actions, not against arbitrary writes.\n * 3. `reset()` resets to `initial()`. Always defined; tests use it for\n * setup, lifecycle hooks (route change, sign-out, etc.) use it for\n * tear-down.\n *\n * A module-level registry tracks every store created via `defineStore()`;\n * `resetAllStores()` walks the registry and calls each `reset()`. Useful for\n * tests + project-switch / logout / route-reset scenarios where every piece\n * of client state should return to its initial shape.\n */\n\nimport { maybeWarnNarrowSet, type NarrowSetWarnContext } from './dev-store-warn.js';\nimport type { ReadonlySignal, Signal } from './reactive.js';\nimport { signal } from './reactive.js';\n\nexport interface Store<TState, TActions> {\n /** Read-only reactive view. Consumers read `state.value` or subscribe via `effect()`. */\n readonly state: ReadonlySignal<TState>;\n /** Named mutators — the only way to change state. */\n readonly actions: TActions;\n /** Reset state to `initial()`. Used by tests and lifecycle hooks. */\n reset(): void;\n}\n\ninterface DefineStoreSpec<TState, TActions> {\n initial: () => TState;\n actions: (set: (next: TState) => void, get: () => TState) => TActions;\n}\n\nconst REGISTRY: Array<{ reset: () => void }> = [];\n\nconst IS_DEV: boolean = (() => {\n const proc = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process;\n return proc?.env?.NODE_ENV !== 'production';\n})();\n\nexport function defineStore<TState, TActions>(\n spec: DefineStoreSpec<TState, TActions>,\n): Store<TState, TActions> {\n const internal: Signal<TState> = signal(spec.initial());\n // KF-212: per-store one-shot dedup for the opt-in narrow-set warning.\n // Default off; consumers opt in via `KERF_DEV_WARN_NARROW_SET=1` in dev.\n // Production behavior is unchanged — `maybeWarnNarrowSet` short-circuits\n // on the env-var read before any per-set work runs.\n const warnCtx: NarrowSetWarnContext = { warned: false };\n\n const set = (next: TState): void => {\n maybeWarnNarrowSet(internal.value, next, warnCtx);\n internal.value = next;\n };\n // In dev, freeze the snapshot returned to actions so that\n // `get().count = 42`-style mutations (a documented Rule 8 violation) throw\n // a native `TypeError: Cannot assign to read only property` instead of\n // silently landing on the underlying state without notifying subscribers.\n // Production keeps the bare reference for zero overhead. Read NODE_ENV via\n // globalThis so the source works untouched in browsers (no bare `process`).\n const get = (): TState => {\n const v = internal.value;\n if (IS_DEV && v !== null && typeof v === 'object') {\n Object.freeze(v);\n }\n return v;\n };\n\n const actions = spec.actions(set, get);\n\n const store: Store<TState, TActions> = {\n state: internal,\n actions,\n reset() {\n internal.value = spec.initial();\n },\n };\n\n REGISTRY.push(store);\n return store;\n}\n\n/**\n * Reset every store registered via `defineStore()` to its `initial()` value.\n * Used by tests and by application lifecycle hooks (project switch, logout,\n * route reset).\n */\nexport function resetAllStores(): void {\n for (const s of REGISTRY) s.reset();\n}\n\n/**\n * Test helper — clears the registry. Exposed via the `kerfjs/testing` subpath,\n * not the main `kerfjs` entry. Unit tests use it to isolate stores between cases.\n */\nexport function clearStoreRegistry(): void {\n REGISTRY.length = 0;\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { ArraySignal } from './array-signal.js';
2
2
  import { SafeHtml } from './jsx-runtime.js';
3
3
  export { Fragment, isSafeHtml, raw } from './jsx-runtime.js';
4
- export { ReadonlySignal, Signal, batch, computed, effect, signal } from '@preact/signals-core';
4
+ import { Signal } from '@preact/signals-core';
5
+ export { ReadonlySignal, Signal, batch, computed, effect } from '@preact/signals-core';
5
6
  export { S as Store, d as defineStore, r as resetAllStores } from './testing-CdMgVVoI.js';
6
7
 
7
8
  /**
@@ -60,7 +61,7 @@ declare function delegate(rootEl: HTMLElement, type: string, selector: string, h
60
61
  declare function delegateCapture(rootEl: HTMLElement, type: string, selector: string, handler: Handler): () => void;
61
62
 
62
63
  /**
63
- * `each(items, render, key?)` — keyed list iteration with per-item memoization.
64
+ * `each(items, render, cacheKey?)` — keyed list iteration with per-item memoization.
64
65
  *
65
66
  * Drops in as the body of a list-rendering JSX expression inside a `mount()`
66
67
  * render function. Returns a `SafeHtml` carrying a structured list segment,
@@ -70,10 +71,10 @@ declare function delegateCapture(rootEl: HTMLElement, type: string, selector: st
70
71
  * Two layers of optimization:
71
72
  *
72
73
  * 1. Per-item memoization. `render(item)` is skipped for items whose object
73
- * identity (and optional `key`) are unchanged since the previous call.
74
- * Their HTML strings come from a `WeakMap` keyed by item reference. The
75
- * immutable-update style ("replace the row object" instead of "mutate it")
76
- * makes the cache work automatically.
74
+ * identity (and optional `cacheKey`) are unchanged since the previous
75
+ * call. Their HTML strings come from a `WeakMap` keyed by item reference.
76
+ * The immutable-update style ("replace the row object" instead of "mutate
77
+ * it") makes the cache work automatically.
77
78
  *
78
79
  * 2. Structural handoff. `mount()` recognizes the list segment and bypasses
79
80
  * the parse-the-whole-table round trip: only fresh items get parsed (one
@@ -81,18 +82,24 @@ declare function delegateCapture(rootEl: HTMLElement, type: string, selector: st
81
82
  * get patched in the live DOM. Unchanged rows are physically the same
82
83
  * nodes they were before — never visited.
83
84
  *
84
- * `key` covers the case where external state, not the item itself, drives
85
- * what the row should render (e.g. a "currently selected" id flips a CSS
86
- * class on one row). Same item identity but a different `key` value means
87
- * "re-render this item." If you don't pass `key`, only identity changes
88
- * invalidate.
85
+ * `cacheKey` is a passive comparator — it covers the case where external
86
+ * state, not the item itself, drives what the row should render (e.g. a
87
+ * "currently selected" id flips a CSS class on one row). Same item identity
88
+ * but a different `cacheKey` return value means "the cached HTML is stale —
89
+ * re-render this row." Not a reactive subscription: it's evaluated once per
90
+ * mount-effect run and compared against the previous run's return value. If
91
+ * you don't pass `cacheKey`, only object-identity changes invalidate the
92
+ * cache. (Renamed from `key` for clarity — it shared a name with React's
93
+ * `key` prop but has different semantics; the new name says what the
94
+ * parameter actually does. Positional callers — the canonical form — are
95
+ * unaffected.)
89
96
  *
90
97
  * Items must be objects (cache is a `WeakMap`); wrap primitives if you need
91
98
  * to iterate them. Each item's render output must produce exactly one
92
99
  * top-level element — the list reconciler binds one live DOM node per item.
93
100
  */
94
101
 
95
- declare function each<T extends object>(items: readonly T[] | ArraySignal<T>, render: (item: T, index: number) => SafeHtml | string, key?: (item: T, index: number) => unknown): SafeHtml;
102
+ declare function each<T extends object>(items: readonly T[] | ArraySignal<T>, render: (item: T, index: number) => SafeHtml | string, cacheKey?: (item: T, index: number) => unknown): SafeHtml;
96
103
 
97
104
  /**
98
105
  * `morph(liveRoot, template)` — minimum-mutation DOM reconciliation.
@@ -206,6 +213,20 @@ declare function morph(liveRoot: Element, template: Element | SafeHtml | string,
206
213
  type MountResult = SafeHtml | string | number | boolean | null | undefined;
207
214
  declare function mount(rootEl: HTMLElement, render: () => MountResult): () => void;
208
215
 
216
+ /**
217
+ * Re-exports of `@preact/signals-core`. Lets the rest of the codebase depend
218
+ * on `'./reactive.js'` without naming the underlying lib, so swapping it out
219
+ * later (or fronting it with a hand-rolled implementation) is a one-file
220
+ * change.
221
+ *
222
+ * The `signal()` factory is dev-gated: when `NODE_ENV !== 'production'` and
223
+ * `KERF_DEV_WARN_UNTRACKED_SIGNALS === '1'`, it returns a `DevSignal` that
224
+ * warns on writes to signals with no subscribers (KF-176). Off by default;
225
+ * production always returns the bare `@preact/signals-core` signal.
226
+ */
227
+
228
+ declare function signal<T>(value?: T): Signal<T>;
229
+
209
230
  /**
210
231
  * `toElement(jsx)` — JSX → DOM, with SVG-aware namespace handling.
211
232
  *
@@ -222,4 +243,4 @@ declare function mount(rootEl: HTMLElement, render: () => MountResult): () => vo
222
243
 
223
244
  declare function toElement(jsx: SafeHtml | string): Element;
224
245
 
225
- export { type MountResult, SafeHtml, delegate, delegateCapture, each, morph, mount, toElement };
246
+ export { type MountResult, SafeHtml, delegate, delegateCapture, each, morph, mount, signal, toElement };