kerfjs 0.7.0 → 0.8.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,6 +4,21 @@ 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.1] - 2026-05-19
8
+
9
+
10
+ - New `eslint-plugin-kerfjs` with four AST rules enforcing kerf Hard Rules
11
+
12
+ ## [0.8.0] - 2026-05-18
13
+
14
+
15
+ - 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
16
+ - Widen `KerfBaseAttrs.contentEditable` to accept `'plaintext-only'` and add the lowercase `contenteditable` alias
17
+ - 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
18
+ - New runnable example apps: `cart-htmx` (htmx swap → kerf island mount pattern) and `counter-store` (sync + async + persisted store)
19
+ - Fix TodoMVC example: store actions now spread `get()` into `set()` so filter/edit interactions no longer wipe state
20
+ - 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
21
+
7
22
  ## [0.7.0] - 2026-05-18
8
23
 
9
24
 
@@ -29,6 +44,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
29
44
  ## [Unreleased]
30
45
 
31
46
 
47
+ - **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.
48
+ - **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.
49
+ - **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.
50
+ - **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".
51
+ - **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.
52
+ - **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).
53
+ - **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).
32
54
  - **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).
33
55
  - **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).
34
56
  - **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).
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.
@@ -157,13 +156,29 @@ npm install kerfjs
157
156
  }
158
157
  ```
159
158
 
159
+ ### Optional: `eslint-plugin-kerfjs`
160
+
161
+ A companion ESLint plugin enforces four of kerf's hard rules at edit time — inline JSX event handlers, missing `data-key` in `each()`, nested `mount()`, and global `JSX.IntrinsicElements` augmentation. The plugin is AST-only (no parser-services dependency), so it works with any TypeScript-ESLint setup.
162
+
163
+ ```bash
164
+ npm install --save-dev eslint-plugin-kerfjs
165
+ ```
166
+
167
+ ```js
168
+ // eslint.config.js (flat config, ESLint v9+)
169
+ import kerfjs from 'eslint-plugin-kerfjs';
170
+ export default [kerfjs.configs.recommended];
171
+ ```
172
+
173
+ See [`eslint-plugin/README.md`](./eslint-plugin/README.md) for legacy `.eslintrc` config and per-rule docs.
174
+
160
175
  ## Links
161
176
 
162
177
  - **Site:** [brianwestphal.github.io/kerf](https://brianwestphal.github.io/kerf/)
163
178
  - **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
166
- - **AI evidence:** [the four layers of evidence we publish](https://brianwestphal.github.io/kerf/ai-evidence/) — structural (intrinsic measurements), diagnostic (runtime-error audit), operational (one-shot transcripts), empirical (cross-framework benchmark) — so the AI-friendliness claim is checkable
179
+ - **Migrating:** [coming from another framework?](https://brianwestphal.github.io/kerf/migrating/) — side-by-side TodoMVC translations + per-framework gotchas
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:** [`eslint-plugin/`](./eslint-plugin/) — `eslint-plugin-kerfjs`; four AST-only rules enforcing kerf hard rules at edit time
167
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)
168
183
  - **Repo:** [github.com/brianwestphal/kerf](https://github.com/brianwestphal/kerf)
169
184
 
@@ -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.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { isSafeHtml, listSafeHtml, flattenWithoutListItems, collectLists, flatten, granularListSafeHtml } from './chunk-4VT4YZOO.js';
2
2
  export { Fragment, SafeHtml, isSafeHtml, raw } from './chunk-4VT4YZOO.js';
3
- export { defineStore, resetAllStores } from './chunk-Z7ZHXD2R.js';
3
+ export { defineStore, resetAllStores } from './chunk-WUFUTNA7.js';
4
4
  import { effect } from './chunk-UU2YJEJY.js';
5
5
  export { batch, computed, effect, signal } from './chunk-UU2YJEJY.js';
6
6
 
@@ -62,7 +62,14 @@ interface KerfBaseAttrs extends DataAriaAttrs {
62
62
  dir?: AttrLike<'ltr' | 'rtl' | 'auto'>;
63
63
  hidden?: AttrLike<boolean>;
64
64
  draggable?: AttrLike<boolean>;
65
- contentEditable?: AttrLike<boolean | 'true' | 'false' | 'inherit'>;
65
+ contentEditable?: AttrLike<boolean | 'true' | 'false' | 'inherit' | 'plaintext-only'>;
66
+ /**
67
+ * Lowercase HTML form accepted alongside `contentEditable` (same shape as
68
+ * `class` / `tabindex` / `autofocus` / `spellcheck`). The HTML spec defines
69
+ * `contenteditable` as a string-valued enumerated attribute; an HTML-savvy
70
+ * developer typing the lowercase form will reach for `contenteditable="false"`.
71
+ */
72
+ contenteditable?: AttrLike<boolean | 'true' | 'false' | 'inherit' | 'plaintext-only'>;
66
73
  inputMode?: AttrLike<'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'>;
67
74
  spellCheck?: AttrLike<boolean>;
68
75
  /**
package/dist/testing.js CHANGED
@@ -1,4 +1,4 @@
1
- export { clearStoreRegistry } from './chunk-Z7ZHXD2R.js';
1
+ export { clearStoreRegistry } from './chunk-WUFUTNA7.js';
2
2
  import './chunk-UU2YJEJY.js';
3
3
  //# sourceMappingURL=testing.js.map
4
4
  //# sourceMappingURL=testing.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kerfjs",
3
- "version": "0.7.0",
3
+ "version": "0.8.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,
@@ -66,11 +66,13 @@
66
66
  "test:dist": "npm run build && vitest run --config vitest.config.dist.ts",
67
67
  "test:dist:full": "npm run build && vitest run --config vitest.config.dist-full.ts",
68
68
  "test:dist:jsx-typing": "npm run build && tsc -p tests/dist/jsx-typing/tsconfig.json",
69
+ "test:dist:examples": "npm run build && tsc -p site/src/examples/complete/tsconfig.json",
69
70
  "test:browser": "npm run build && playwright test",
70
71
  "bench:micro": "vitest bench --run --config vitest.config.bench.ts",
71
72
  "lint": "eslint src tests",
72
73
  "typecheck": "tsc --noEmit",
73
- "check": "npm run lint && npm run typecheck && node scripts/check-doc-test-inventory.mjs && node scripts/check-doc-api-coverage.mjs && npm test && npm run build && vitest run --config vitest.config.dist.ts && vitest run --config vitest.config.dist-full.ts && tsc -p tests/dist/jsx-typing/tsconfig.json",
74
+ "check": "npm run lint && npm run typecheck && node scripts/check-doc-test-inventory.mjs && node scripts/check-doc-api-coverage.mjs && npm test && npm run build && vitest run --config vitest.config.dist.ts && vitest run --config vitest.config.dist-full.ts && tsc -p tests/dist/jsx-typing/tsconfig.json && tsc -p site/src/examples/complete/tsconfig.json && node scripts/check-docs-examples.mjs",
75
+ "check:docs:examples": "node scripts/check-docs-examples.mjs",
74
76
  "check:full": "npm run check && playwright test",
75
77
  "check:docs:test-inventory": "node scripts/check-doc-test-inventory.mjs",
76
78
  "check:docs:api-coverage": "node scripts/check-doc-api-coverage.mjs",
@@ -1,41 +0,0 @@
1
- import { signal } from './chunk-UU2YJEJY.js';
2
-
3
- // src/store.ts
4
- var REGISTRY = [];
5
- var IS_DEV = (() => {
6
- const proc = globalThis.process;
7
- return proc?.env?.NODE_ENV !== "production";
8
- })();
9
- function defineStore(spec) {
10
- const internal = signal(spec.initial());
11
- const set = (next) => {
12
- internal.value = next;
13
- };
14
- const get = () => {
15
- const v = internal.value;
16
- if (IS_DEV && v !== null && typeof v === "object") {
17
- Object.freeze(v);
18
- }
19
- return v;
20
- };
21
- const actions = spec.actions(set, get);
22
- const store = {
23
- state: internal,
24
- actions,
25
- reset() {
26
- internal.value = spec.initial();
27
- }
28
- };
29
- REGISTRY.push(store);
30
- return store;
31
- }
32
- function resetAllStores() {
33
- for (const s of REGISTRY) s.reset();
34
- }
35
- function clearStoreRegistry() {
36
- REGISTRY.length = 0;
37
- }
38
-
39
- export { clearStoreRegistry, defineStore, resetAllStores };
40
- //# sourceMappingURL=chunk-Z7ZHXD2R.js.map
41
- //# sourceMappingURL=chunk-Z7ZHXD2R.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/store.ts"],"names":[],"mappings":";;;AAqCA,IAAM,WAAyC,EAAC;AAEhD,IAAM,UAAmB,MAAM;AAC7B,EAAA,MAAM,OAAQ,UAAA,CAA6D,OAAA;AAC3E,EAAA,OAAO,IAAA,EAAM,KAAK,QAAA,KAAa,YAAA;AACjC,CAAA,GAAG;AAEI,SAAS,YACd,IAAA,EACyB;AACzB,EAAA,MAAM,QAAA,GAA2B,MAAA,CAAO,IAAA,CAAK,OAAA,EAAS,CAAA;AAEtD,EAAA,MAAM,GAAA,GAAM,CAAC,IAAA,KAAuB;AAClC,IAAA,QAAA,CAAS,KAAA,GAAQ,IAAA;AAAA,EACnB,CAAA;AAOA,EAAA,MAAM,MAAM,MAAc;AACxB,IAAA,MAAM,IAAI,QAAA,CAAS,KAAA;AACnB,IAAA,IAAI,MAAA,IAAU,CAAA,KAAM,IAAA,IAAQ,OAAO,MAAM,QAAA,EAAU;AACjD,MAAA,MAAA,CAAO,OAAO,CAAC,CAAA;AAAA,IACjB;AACA,IAAA,OAAO,CAAA;AAAA,EACT,CAAA;AAEA,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,OAAA,CAAQ,GAAA,EAAK,GAAG,CAAA;AAErC,EAAA,MAAM,KAAA,GAAiC;AAAA,IACrC,KAAA,EAAO,QAAA;AAAA,IACP,OAAA;AAAA,IACA,KAAA,GAAQ;AACN,MAAA,QAAA,CAAS,KAAA,GAAQ,KAAK,OAAA,EAAQ;AAAA,IAChC;AAAA,GACF;AAEA,EAAA,QAAA,CAAS,KAAK,KAAK,CAAA;AACnB,EAAA,OAAO,KAAA;AACT;AAOO,SAAS,cAAA,GAAuB;AACrC,EAAA,KAAA,MAAW,CAAA,IAAK,QAAA,EAAU,CAAA,CAAE,KAAA,EAAM;AACpC;AAMO,SAAS,kBAAA,GAA2B;AACzC,EAAA,QAAA,CAAS,MAAA,GAAS,CAAA;AACpB","file":"chunk-Z7ZHXD2R.js","sourcesContent":["/**\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 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\n const set = (next: TState): void => {\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"]}