kerfjs 4.4.0 → 4.5.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +284 -104
- package/LICENSE +1 -1
- package/README.md +73 -54
- package/ai/cursorrules +130 -48
- package/ai/manifest.json +63 -5
- package/ai/skill.md +148 -65
- package/dist/actions.d.ts +1 -1
- package/dist/actions.js +4 -4
- package/dist/actions.js.map +1 -1
- package/dist/array-signal.js +5 -5
- package/dist/async.js +34 -24
- package/dist/async.js.map +1 -1
- package/dist/attach.d.ts +11 -8
- package/dist/attach.js +54 -4
- package/dist/attach.js.map +1 -1
- package/dist/{chunk-QIP723L4.js → chunk-5WRGJZV6.js} +4 -3
- package/dist/chunk-5WRGJZV6.js.map +1 -0
- package/dist/{chunk-GY4XV2UV.js → chunk-BDX3R4OM.js} +4 -3
- package/dist/chunk-BDX3R4OM.js.map +1 -0
- package/dist/{chunk-VVDJLWMP.js → chunk-CEQMZYLR.js} +2 -2
- package/dist/chunk-CEQMZYLR.js.map +1 -0
- package/dist/{chunk-KEZTD6H4.js → chunk-E5R5GNKE.js} +15 -5
- package/dist/chunk-E5R5GNKE.js.map +1 -0
- package/dist/{chunk-MRYM3O3V.js → chunk-ELXVRKY2.js} +12 -9
- package/dist/chunk-ELXVRKY2.js.map +1 -0
- package/dist/{chunk-U32TFTGZ.js → chunk-MK42GLPV.js} +3 -3
- package/dist/chunk-MK42GLPV.js.map +1 -0
- package/dist/{chunk-SUPUPSBE.js → chunk-QFUNWHKH.js} +42 -20
- package/dist/chunk-QFUNWHKH.js.map +1 -0
- package/dist/{chunk-SRWQKB33.js → chunk-V757BT6U.js} +206 -111
- package/dist/chunk-V757BT6U.js.map +1 -0
- package/dist/{chunk-YHH7OUFA.js → chunk-WKIPLNVO.js} +3 -3
- package/dist/chunk-WKIPLNVO.js.map +1 -0
- package/dist/{chunk-3APBEVHF.js → chunk-Y2FOYPBV.js} +3 -3
- package/dist/{chunk-3APBEVHF.js.map → chunk-Y2FOYPBV.js.map} +1 -1
- package/dist/{chunk-SAYPJ6XR.js → chunk-ZOIERTUW.js} +10 -6
- package/dist/chunk-ZOIERTUW.js.map +1 -0
- package/dist/dev.d.ts +9 -6
- package/dist/dev.js +66 -19
- package/dist/dev.js.map +1 -1
- package/dist/html.d.ts +1 -1
- package/dist/html.js +8 -7
- package/dist/html.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +28 -19
- package/dist/index.js.map +1 -1
- package/dist/jsx-runtime.js +5 -5
- package/dist/list.d.ts +1 -1
- package/dist/list.js +304 -213
- package/dist/list.js.map +1 -1
- package/dist/overlay.d.ts +190 -280
- package/dist/overlay.js +471 -364
- package/dist/overlay.js.map +1 -1
- package/dist/remount.d.ts +5 -3
- package/dist/remount.js +29 -10
- package/dist/remount.js.map +1 -1
- package/dist/router.d.ts +1 -1
- package/dist/router.js +73 -28
- package/dist/router.js.map +1 -1
- package/dist/scope.d.ts +4 -3
- package/dist/scope.js +11 -10
- package/dist/scope.js.map +1 -1
- package/dist/testing.js +4 -4
- package/dist/timing.d.ts +3 -2
- package/dist/timing.js +5 -5
- package/dist/timing.js.map +1 -1
- package/llms.txt +13 -6
- package/package.json +35 -13
- package/setup/cli.mjs +90 -0
- package/setup/index.d.mts +37 -0
- package/setup/index.mjs +1347 -0
- package/setup/jsonc.mjs +201 -0
- package/setup/state.schema.json +56 -0
- package/dist/chunk-GY4XV2UV.js.map +0 -1
- package/dist/chunk-KEZTD6H4.js.map +0 -1
- package/dist/chunk-MRYM3O3V.js.map +0 -1
- package/dist/chunk-QIP723L4.js.map +0 -1
- package/dist/chunk-SAYPJ6XR.js.map +0 -1
- package/dist/chunk-SRWQKB33.js.map +0 -1
- package/dist/chunk-SUPUPSBE.js.map +0 -1
- package/dist/chunk-U32TFTGZ.js.map +0 -1
- package/dist/chunk-VVDJLWMP.js.map +0 -1
- package/dist/chunk-YHH7OUFA.js.map +0 -1
- /package/dist/{attrSelector-Cmu2ZoGO.d.ts → attr-Cmu2ZoGO.d.ts} +0 -0
package/LICENSE
CHANGED
|
@@ -54,7 +54,7 @@ Steele-Idem, which is also distributed under the MIT License:
|
|
|
54
54
|
|
|
55
55
|
---
|
|
56
56
|
|
|
57
|
-
`src/
|
|
57
|
+
`src/attr.ts`'s `cssEscapeIdent` adapts the CSS identifier escaping of
|
|
58
58
|
the [CSS.escape polyfill](https://github.com/mathiasbynens/CSS.escape) by
|
|
59
59
|
Mathias Bynens, which is also distributed under the MIT License:
|
|
60
60
|
|
package/README.md
CHANGED
|
@@ -4,60 +4,86 @@
|
|
|
4
4
|
|
|
5
5
|
<h1 align="center">Kerf</h1>
|
|
6
6
|
|
|
7
|
-
<p align="center"><em>The smallest cut.</em></p>
|
|
7
|
+
<p align="center"><em>A tiny reactive UI framework. The smallest cut.</em></p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://brianwestphal.github.io/kerf/"><strong>brianwestphal.github.io/kerf</strong></a> — docs · examples · live demo
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
|
+
<p align="center">
|
|
14
|
+
<a href="https://www.npmjs.com/package/kerfjs"><img src="https://img.shields.io/npm/v/kerfjs.svg" alt="npm version" /></a>
|
|
15
|
+
<img src="https://img.shields.io/badge/min%2Bgzip-~12%20KB-brightgreen.svg" alt="~12 KB minified and gzipped" />
|
|
16
|
+
<img src="https://img.shields.io/npm/l/kerfjs.svg" alt="MIT license" />
|
|
17
|
+
<img src="https://img.shields.io/badge/types-included-3178c6.svg" alt="TypeScript types included" />
|
|
18
|
+
</p>
|
|
19
|
+
|
|
13
20
|
---
|
|
14
21
|
|
|
15
|
-
> Introducing Kerf.
|
|
16
|
-
> The smallest cut.
|
|
17
|
-
>
|
|
18
22
|
> ~12 KB. No virtual DOM. No compiler. No magic.
|
|
19
23
|
> Reactive UI that touches only the bytes that changed.
|
|
20
24
|
|
|
21
25
|
```ts
|
|
22
|
-
import { signal, mount } from 'kerfjs';
|
|
26
|
+
import { signal, mount, delegate } from 'kerfjs';
|
|
23
27
|
|
|
24
28
|
const count = signal(0);
|
|
29
|
+
const app = document.getElementById('app')!;
|
|
25
30
|
|
|
26
|
-
mount(
|
|
31
|
+
mount(app, () => (
|
|
27
32
|
<div>
|
|
28
33
|
<button data-action="inc">+</button>
|
|
29
34
|
<span>{count.value}</span>
|
|
30
35
|
</div>
|
|
31
36
|
));
|
|
37
|
+
|
|
38
|
+
delegate(app, 'click', '[data-action="inc"]', () => count.value++);
|
|
32
39
|
```
|
|
33
40
|
|
|
34
41
|
That's it. Your JSX renders to HTML strings, kerf's native diff applies the minimum DOM mutations to make the live tree match, and signals re-run the render only when something they read actually changed.
|
|
35
42
|
|
|
36
43
|
Here's the whole development loop — write a component, run the dev server, click around, edit, watch the browser pick it up:
|
|
37
44
|
|
|
38
|
-
[](https://brianwestphal.github.io/kerf/getting-started/)
|
|
46
|
+
**[Quick start](#quick-start) · [Why kerf](#why-kerf) · [Quick tour](#quick-tour) · [Docs & examples](https://brianwestphal.github.io/kerf/)**
|
|
39
47
|
|
|
40
|
-
##
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install kerfjs
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For an existing project, install the AI guidance, strict TypeScript/ESLint
|
|
55
|
+
feedback, and package scripts with a reviewed dry run:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npx kerfjs setup # inspect a value-level plan
|
|
59
|
+
npx kerfjs setup --write --yes # apply it
|
|
60
|
+
```
|
|
41
61
|
|
|
42
|
-
|
|
62
|
+
The command detects core versus `@kerfjs/ui`, supports deterministic npm,
|
|
63
|
+
pnpm, and Yarn workspace selection plus manager-correct offline installs, and
|
|
64
|
+
never adds UI to a core-only project. See
|
|
65
|
+
[`docs/24-ai-first-setup.md`](./docs/24-ai-first-setup.md).
|
|
43
66
|
|
|
44
|
-
|
|
67
|
+
```jsonc
|
|
68
|
+
// tsconfig.json — point JSX at kerf
|
|
69
|
+
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "kerfjs" } }
|
|
70
|
+
```
|
|
45
71
|
|
|
46
|
-
|
|
72
|
+
Write plain `.tsx` and build with your existing esbuild / Vite / tsup — no extra plugin. New here? Read the [5-minute orientation](https://github.com/brianwestphal/kerf/blob/main/docs/orientation.md), or open a [complete example](https://brianwestphal.github.io/kerf/examples/complete/).
|
|
47
73
|
|
|
48
|
-
|
|
74
|
+
## Why Kerf
|
|
49
75
|
|
|
50
|
-
|
|
76
|
+
1. **~12 KB, one dependency.** ~12 KB minified + gzipped including `@preact/signals-core` (~13 KB with `arraySignal`). No virtual DOM, no scheduler, no concurrent-mode machinery. On the official [krausest benchmark](https://krausest.github.io/js-framework-benchmark/current.html) kerf sits in the same cluster as Vue, Lit, and vanjs; Solid's compiler leads the update-path benchmarks, which kerf doesn't try to match by design — no compiler.
|
|
51
77
|
|
|
52
|
-
|
|
78
|
+
2. **No virtual DOM, no compiler.** JSX → HTML strings → native diff. DevTools shows the real DOM because it _is_ the DOM.
|
|
53
79
|
|
|
54
|
-
|
|
80
|
+
3. **Values bind, structure re-renders.** Hand a signal _itself_ into a JSX hole — `class={selectedId}` — and kerf binds that one node: on change, only that attribute updates, with no render re-run and no list reconcile. Moving selection between rows in a 10,000-row table touches at most the old and new row classes. ([more →](#fine-grained-updates-bind-a-signal-into-a-hole))
|
|
55
81
|
|
|
56
|
-
|
|
82
|
+
4. **Focus, selection, and listeners survive re-renders — even mid-list.** The reconciler morphs instead of rebuilding, so caret position, IME composition, scroll, and delegated listeners survive every update; keyed rows are patched in place rather than recreated.
|
|
57
83
|
|
|
58
|
-
|
|
84
|
+
5. **Safe by default.** Text and attributes are HTML-escaped automatically, URL attributes are scheme-screened (`javascript:` dropped), and inline `on*` handlers are rejected outright — so untrusted data stays inert. `raw()` is the explicit, auditable opt-out.
|
|
59
85
|
|
|
60
|
-
|
|
86
|
+
**Plus, nothing you don't ask for:** JSX typed against the HTML standard (not React's props) · a ~18-export API with no hooks, lifecycle, or per-instance state · **nine** tree-shakeable companion subpaths (`router`, `list`, `overlay`, `async`, …) that stay out of the core until imported · an [ESLint plugin](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) + opt-in dev warnings + `create-kerf-component` scaffold · plain TS/JSX/ESM that drops into esbuild / Vite / tsup — or **no** build at all via the `html` tagged template.
|
|
61
87
|
|
|
62
88
|
## When to use Kerf
|
|
63
89
|
|
|
@@ -73,7 +99,7 @@ Here's the whole development loop — write a component, run the dev server, cli
|
|
|
73
99
|
- Need a full ecosystem (router + forms + data + SSR streaming) → **Next.js / Remix / SolidStart**.
|
|
74
100
|
- Building a deeply componentised design-system app → **React / Solid / Svelte**.
|
|
75
101
|
- Need React Native / cross-platform mobile → **React** (Kerf + Tauri/Electron also covers many of these cases).
|
|
76
|
-
- Building a static site → **Astro** (we use it for
|
|
102
|
+
- Building a static site → **Astro** (we use it for _this_ project's site).
|
|
77
103
|
- Already invested in a framework where switching cost outweighs the bundle size gain.
|
|
78
104
|
|
|
79
105
|
## Quick tour
|
|
@@ -133,7 +159,7 @@ delegate(root, 'click', REMOVE.selector, (_e, btn) => { /* … */ }); // in dele
|
|
|
133
159
|
|
|
134
160
|
### Fine-grained updates: bind a signal into a hole
|
|
135
161
|
|
|
136
|
-
Inside a `mount()`, hand a signal
|
|
162
|
+
Inside a `mount()`, hand a signal _itself_ (not its `.value`) into an attribute or text position and kerf wires that hole straight to the signal — the render function never re-runs and the list reconciler never walks:
|
|
137
163
|
|
|
138
164
|
```ts
|
|
139
165
|
const status = signal('idle');
|
|
@@ -147,9 +173,9 @@ mount(root, () => (
|
|
|
147
173
|
status.value = 'saving'; // updates the class + the text node directly — no re-render
|
|
148
174
|
```
|
|
149
175
|
|
|
150
|
-
The headline use is external state driving
|
|
176
|
+
The headline use is external state driving a hot spot: a `selectedId` moving between rows inside a 10,000-row `each()` list updates at most the old and new row class attributes, with no reconcile. Works in static content and inside `each()` rows (a row's binding is torn down with the row); outside a `mount()` (SSR / `SafeHtml.toString()`) a bound signal just snapshots its current value.
|
|
151
177
|
|
|
152
|
-
This is kerf's guiding idiom —
|
|
178
|
+
This is kerf's guiding idiom — _values bind, structure re-renders_: pass the signal itself wherever a hole is just a value, and read `.value` in the render function only where the JSX structure depends on it. A render that reads no `.value` runs exactly once; from then on every update is a direct write to the node it concerns. See [`docs/2-reactivity.md`](./docs/2-reactivity.md) §2.9.
|
|
153
179
|
|
|
154
180
|
### Long keyed lists: `arraySignal`
|
|
155
181
|
|
|
@@ -176,11 +202,11 @@ The class lives in its own subpath so apps that don't need it shed ~1 KB. Reads
|
|
|
176
202
|
`mount()` wraps `effect()` so the render re-runs on signal changes. Sometimes you have a freshly-built template and an already-populated element and you just want to reconcile them once — no subscription, no re-render loop. That's `morph`:
|
|
177
203
|
|
|
178
204
|
```ts
|
|
179
|
-
import { morph, raw } from
|
|
205
|
+
import { morph, raw } from "kerfjs";
|
|
180
206
|
|
|
181
|
-
morph(liveCard, freshlyBuiltCardEl);
|
|
182
|
-
morph(liveCard, '<article class="card">…</article>');
|
|
183
|
-
morph(liveCard, raw(htmlFromServer));
|
|
207
|
+
morph(liveCard, freshlyBuiltCardEl); // Element template
|
|
208
|
+
morph(liveCard, '<article class="card">…</article>'); // raw HTML string
|
|
209
|
+
morph(liveCard, raw(htmlFromServer)); // SafeHtml
|
|
184
210
|
```
|
|
185
211
|
|
|
186
212
|
Same algorithm `mount()` uses internally — `data-morph-skip`, `data-morph-skip-children`, `data-morph-preserve`, focused-input value + selection preservation, the `<details>` / `<dialog>` user-agent-owned `open` rule all carry over. Use it for SSR-fragment hydration, page-refresh diffs, third-party widget remounts. See [`docs/4-render.md`](./docs/4-render.md) §4.4.3.
|
|
@@ -191,14 +217,19 @@ Same algorithm `mount()` uses internally — `data-morph-skip`, `data-morph-skip
|
|
|
191
217
|
|
|
192
218
|
```html
|
|
193
219
|
<script type="module">
|
|
194
|
-
import { signal, mount, each } from
|
|
195
|
-
import { html } from
|
|
196
|
-
|
|
197
|
-
const items = signal([{ id: 1, label:
|
|
198
|
-
|
|
199
|
-
mount(
|
|
200
|
-
|
|
201
|
-
|
|
220
|
+
import { signal, mount, each } from "https://esm.sh/kerfjs@4";
|
|
221
|
+
import { html } from "https://esm.sh/kerfjs@4/html";
|
|
222
|
+
|
|
223
|
+
const items = signal([{ id: 1, label: "no build step" }]);
|
|
224
|
+
|
|
225
|
+
mount(
|
|
226
|
+
document.getElementById("app"),
|
|
227
|
+
() => html`
|
|
228
|
+
<ul>
|
|
229
|
+
${each(items.value, (i) => html`<li id="${i.id}">${i.label}</li>`)}
|
|
230
|
+
</ul>
|
|
231
|
+
`,
|
|
232
|
+
);
|
|
202
233
|
</script>
|
|
203
234
|
```
|
|
204
235
|
|
|
@@ -227,7 +258,7 @@ const list = bindList(scrollEl, messages, {
|
|
|
227
258
|
observeRowHeights(list); // one ResizeObserver → kerf anchor-corrects scroll
|
|
228
259
|
```
|
|
229
260
|
|
|
230
|
-
And a whole client-side router in one call — `kerfjs/router`, the "postcard router." A route table, a keyed `outlet()`, and automatic `<a href>` interception; the
|
|
261
|
+
And a whole client-side router in one call — `kerfjs/router`, the "postcard router." A route table, a keyed `outlet()`, and automatic `<a href>` interception; the _core_ stays router-free (this is opt-in):
|
|
231
262
|
|
|
232
263
|
```ts
|
|
233
264
|
import { createRouter } from 'kerfjs/router';
|
|
@@ -247,21 +278,9 @@ mount(app, () => <div><nav>{/* <a href> links, auto-intercepted */}</nav>{router
|
|
|
247
278
|
|
|
248
279
|
Each subpath adds nothing to the main barrel until it's imported. See [`docs/8-api-reference.md`](./docs/8-api-reference.md) for the full list (`list`, `router`, `overlay`, `scope`, `async`, `timing`, `remount`, `attach`, `actions`).
|
|
249
280
|
|
|
250
|
-
##
|
|
281
|
+
## Optional tooling
|
|
251
282
|
|
|
252
|
-
|
|
253
|
-
npm install kerfjs
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
```jsonc
|
|
257
|
-
// tsconfig.json
|
|
258
|
-
{
|
|
259
|
-
"compilerOptions": {
|
|
260
|
-
"jsx": "react-jsx",
|
|
261
|
-
"jsxImportSource": "kerfjs"
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
```
|
|
283
|
+
Install and JSX setup are in [Quick start](#quick-start) above. These companion packages are opt-in.
|
|
265
284
|
|
|
266
285
|
### Optional: `eslint-plugin-kerfjs`
|
|
267
286
|
|
|
@@ -273,7 +292,7 @@ npm install --save-dev eslint-plugin-kerfjs
|
|
|
273
292
|
|
|
274
293
|
```js
|
|
275
294
|
// eslint.config.js (flat config, ESLint v9+)
|
|
276
|
-
import kerfjs from
|
|
295
|
+
import kerfjs from "eslint-plugin-kerfjs";
|
|
277
296
|
export default [kerfjs.configs.recommended];
|
|
278
297
|
```
|
|
279
298
|
|
|
@@ -281,7 +300,7 @@ Full docs at [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwes
|
|
|
281
300
|
|
|
282
301
|
### Optional: `create-kerf-component`
|
|
283
302
|
|
|
284
|
-
Building a reusable component package? Scaffold one that already follows kerf's hard packaging rules (kerfjs as a peer dependency and `external` in the build, ESM + `.d.ts`, `jsxImportSource: "kerfjs"`, subpath exports)
|
|
303
|
+
Building a reusable component package? Scaffold one that already follows kerf's hard packaging rules (kerfjs as a peer dependency and `external` in the build, ESM + `.d.ts`, `jsxImportSource: "kerfjs"`, subpath exports), includes an example component showing per-instance state via a factory and a `wire(root)` delegation disposer, and generates checked package-qualified AI component metadata from explicit author decisions:
|
|
285
304
|
|
|
286
305
|
```bash
|
|
287
306
|
npm create kerf-component@latest my-widgets
|
|
@@ -296,15 +315,15 @@ See [`docs/13-component-packages.md`](./docs/13-component-packages.md) for the f
|
|
|
296
315
|
- **Migrating:** [coming from another framework?](https://brianwestphal.github.io/kerf/migrating/) — side-by-side TodoMVC translations + per-framework gotchas
|
|
297
316
|
- **AI guide:** [`docs/ai/usage-guide.md`](./docs/ai/usage-guide.md) — reference for AI tools fetching kerf docs (linked from `llms.txt`)
|
|
298
317
|
- **ESLint plugin:** [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — `eslint-plugin-kerfjs`; eight rules (four hard-rule errors + four warns: `require-delegate-disposer`, `prefer-attr-selector`, `no-raw-with-dynamic-arg`, `ai-assistant-configs`) at edit time (source: [`eslint-plugin/`](./eslint-plugin/))
|
|
299
|
-
- **Component scaffold:** `npm create kerf-component@latest <dir>` — `create-kerf-component`; generates a publishable component package with
|
|
318
|
+
- **Component scaffold:** `npm create kerf-component@latest <dir>` — `create-kerf-component`; generates a publishable component package with packaging rules plus deterministic, drift-checked AI metadata pre-wired (source: [`create-kerf-component/`](./create-kerf-component/))
|
|
300
319
|
- **Demo:** [live demo](https://brianwestphal.github.io/kerf/demo/) — nine sections exercising every primitive (counter, store-backed cart, focus survival, keyed list, morph-skip, SVG render, Tier-2 capture, `arraySignal` patches, fine-grained signal bindings)
|
|
301
320
|
- **Repo:** [github.com/brianwestphal/kerf](https://github.com/brianwestphal/kerf)
|
|
302
321
|
|
|
303
322
|
## Why "kerf"?
|
|
304
323
|
|
|
305
|
-
A
|
|
324
|
+
A _kerf_ is the narrow strip of material a saw blade removes when cutting — the smallest possible cut. The framework's job is the same: apply the smallest possible mutation to update your DOM.
|
|
306
325
|
|
|
307
|
-
(And yes, ~~kerformance~~ →
|
|
326
|
+
(And yes, ~~kerformance~~ → _performance_ jokes were written. They were also rejected.)
|
|
308
327
|
|
|
309
328
|
## Status
|
|
310
329
|
|
package/ai/cursorrules
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
<!-- kerf-skill-version: 1.
|
|
1
|
+
<!-- kerf-skill-version: 1.20.0 -->
|
|
2
|
+
|
|
2
3
|
# kerf.cursorrules — rules for building apps with kerf
|
|
4
|
+
|
|
3
5
|
#
|
|
6
|
+
|
|
4
7
|
# Drop this file into your project as `.cursorrules` (Cursor will pick it
|
|
8
|
+
|
|
5
9
|
# up automatically) when you're using kerf (https://github.com/brianwestphal/kerf).
|
|
10
|
+
|
|
6
11
|
# These rules condense `docs/ai/usage-guide.md` into the form Cursor parses.
|
|
7
12
|
|
|
8
13
|
You are writing a UI in kerf — a ~12 KB reactive framework (~13 KB with `arraySignal`): signals + DOM morphing + JSX → HTML strings. No virtual DOM, no compiler, no scheduler.
|
|
@@ -17,45 +22,100 @@ You are writing a UI in kerf — a ~12 KB reactive framework (~13 KB with `array
|
|
|
17
22
|
- **A component package must NEVER import `kerfjs/dev`.** The hooks are process-global, so installing them is the consuming app's decision — a library that does it forces the diagnostics (and the chunk) on every consumer. Put the import in your demo page or test harness instead.
|
|
18
23
|
- Recommended: also install `eslint-plugin-kerfjs` (`npm install --save-dev eslint-plugin-kerfjs`) and add `kerfjs.configs.recommended` to the project's eslint config. It enforces five of the hard rules below (no inline JSX event handlers, require `data-key` in `each()`, capture `delegate()` disposers, no nested `mount()`, prefer module JSX augmentation) at edit time so violations surface as IDE squiggles before any code runs.
|
|
19
24
|
|
|
25
|
+
## Optional first-party UI
|
|
26
|
+
|
|
27
|
+
Install `@kerfjs/ui` for shared toolbars, menu rows, controlled tab bars, headers/value tables,
|
|
28
|
+
resizable regions, selects, banners, empty states, and loading indicators.
|
|
29
|
+
Import visual components from explicit subpaths so a CSS-aware browser bundler
|
|
30
|
+
includes only their reachable styles. The root barrel and
|
|
31
|
+
`@kerfjs/ui/unstyled` are CSS-free; pair the barrel with `styles.css` only when
|
|
32
|
+
the complete layer is intentional. Reuse these primitives before creating
|
|
33
|
+
parallel local ones; keep domain state, commands, routing, and tab/menu policy
|
|
34
|
+
in the app.
|
|
35
|
+
|
|
36
|
+
Use MenuItem/MenuActionRow/MenuHeader `rootAttributes` only for application
|
|
37
|
+
`data-*` metadata. MenuActionRow `trailingActionAttributes` and MenuHeader
|
|
38
|
+
`triggerAttributes` additionally support native popover target/action and
|
|
39
|
+
`aria-controls`/`aria-haspopup`. Do not use these slots to override action,
|
|
40
|
+
item identity, selection, disclosure, naming, disabled, icon, or role
|
|
41
|
+
semantics; one `role="menuitem"` does not make a complete menu widget.
|
|
42
|
+
`MenuItem.trailing` is dormant. Use `MenuActionRow` for sibling primary and
|
|
43
|
+
trailing native buttons, and keep its `label`, `icon`, and
|
|
44
|
+
`trailingActionIcon` SafeHtml slots free of controls.
|
|
45
|
+
|
|
46
|
+
Wire emitted `data-action` hooks at the mount root and retain disposers.
|
|
47
|
+
`wireResizableRegions()` and `wireTabBars()` are eventful helpers and return
|
|
48
|
+
disposers. Compose `AppTab` inside controlled `TabBar`; `wireTabBars()` supplies
|
|
49
|
+
horizontal edge autoscroll while dragging. Apply reorder reports
|
|
50
|
+
with `reorderTabs()`, and keep order, selection, close policy, routing, panels,
|
|
51
|
+
and persistence in the host. Icons/spinners are decorative unless
|
|
52
|
+
labeled, and assertive banners are only for urgent interruption.
|
|
53
|
+
|
|
54
|
+
The opinionated semantic ramps match Hot Sheet 2 and Web Awesome. Override
|
|
55
|
+
`--kui-color-*` globally or component properties such as
|
|
56
|
+
`--kui-state-banner-background` at a tone or instance boundary; do not replace
|
|
57
|
+
private descendant selectors.
|
|
58
|
+
|
|
59
|
+
For Web Awesome's broader free component set, import the CSS-only
|
|
60
|
+
`@kerfjs/ui/webawesome.css` theme once, then import only each Web Awesome
|
|
61
|
+
component module the app renders. The theme registers no component JavaScript
|
|
62
|
+
and remains overridable through later or scoped `--wa-*` values.
|
|
63
|
+
|
|
64
|
+
`Select` is pure markup until the app explicitly imports
|
|
65
|
+
`@kerfjs/ui/select/register`. Do that once in the application entry that uses
|
|
66
|
+
Select; never hide registration inside a component. Web Awesome is an optional
|
|
67
|
+
peer and stays out of bundles that use neither Select nor the Web Awesome theme. More detail ships at
|
|
68
|
+
`node_modules/@kerfjs/ui/ai/skill.md`.
|
|
69
|
+
|
|
20
70
|
## Public API — one import path
|
|
21
71
|
|
|
22
72
|
```ts
|
|
23
73
|
import {
|
|
24
|
-
signal,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
74
|
+
signal,
|
|
75
|
+
computed,
|
|
76
|
+
effect,
|
|
77
|
+
batch, // reactivity
|
|
78
|
+
defineStore,
|
|
79
|
+
resetAllStores, // stores
|
|
80
|
+
mount,
|
|
81
|
+
morph,
|
|
82
|
+
each, // render (reactive + one-shot) + keyed list
|
|
83
|
+
delegate,
|
|
84
|
+
delegateCapture, // events
|
|
85
|
+
toElement, // direct JSX → DOM Element (or DocumentFragment for multi-root)
|
|
86
|
+
SafeHtml,
|
|
87
|
+
isSafeHtml,
|
|
88
|
+
raw,
|
|
89
|
+
Fragment,
|
|
90
|
+
} from "kerfjs";
|
|
31
91
|
|
|
32
92
|
// Optional, only when you need granular collection updates:
|
|
33
|
-
import { arraySignal } from
|
|
93
|
+
import { arraySignal } from "kerfjs/array-signal";
|
|
34
94
|
|
|
35
95
|
// Development diagnostics — gate with YOUR build's dev flag, in YOUR code.
|
|
36
|
-
if (import.meta.env.DEV) await import(
|
|
96
|
+
if (import.meta.env.DEV) await import("kerfjs/dev");
|
|
37
97
|
```
|
|
38
98
|
|
|
39
|
-
| Export
|
|
40
|
-
|
|
|
41
|
-
| `signal(initial)`
|
|
42
|
-
| `computed(fn)`
|
|
43
|
-
| `effect(fn)`
|
|
44
|
-
| `batch(fn)`
|
|
45
|
-
| `defineStore({initial, actions})`
|
|
46
|
-
| `resetAllStores()`
|
|
47
|
-
| `mount(el, render)`
|
|
48
|
-
| `morph(liveRoot, template)`
|
|
49
|
-
| `each(items, render, cacheKey?)`
|
|
50
|
-
| `each(items, render, { cacheKey, key })`
|
|
51
|
-
| `delegate(root, type, sel, h)`
|
|
52
|
-
| `delegateCapture(root, type, sel, h, opts?)` | capture-phase escape hatch; `closest()` walk-up by default (same as `delegate`); pass `{ match: 'direct' }` for strict `target.matches()`
|
|
53
|
-
| `attr(name, value)`
|
|
54
|
-
| `attr(name)`
|
|
55
|
-
| `toElement(jsx)`
|
|
56
|
-
| `raw(html)`
|
|
57
|
-
| `arraySignal(initial?)`
|
|
58
|
-
| `` html`…` ``
|
|
99
|
+
| Export | Use |
|
|
100
|
+
| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
101
|
+
| `signal(initial)` | atomic reactive state; read/write via `.value` |
|
|
102
|
+
| `computed(fn)` | derived value (read-only) |
|
|
103
|
+
| `effect(fn)` | side effect that re-runs on signal change |
|
|
104
|
+
| `batch(fn)` | coalesce multiple writes into one re-run |
|
|
105
|
+
| `defineStore({initial, actions})` | named multi-consumer state |
|
|
106
|
+
| `resetAllStores()` | reset every store (test teardown) |
|
|
107
|
+
| `mount(el, render)` | bind reactive render to a DOM element; returns a disposer |
|
|
108
|
+
| `morph(liveRoot, template)` | one-shot reconcile against an already-populated element (SSR hydration, page-refresh diffs). Template can be `Element`, `SafeHtml`, or HTML string |
|
|
109
|
+
| `each(items, render, cacheKey?)` | keyed list iteration; per-row memoization on object identity (+ optional cacheKey — a passive comparator for external state). Distinct from `data-key` on the rendered element |
|
|
110
|
+
| `each(items, render, { cacheKey, key })` | same, options form. **`key` gives the list a stable identity** — required whenever a _conditional_ list can render before this one, else kerf rebuilds this list and its rows lose focus/scroll/IME. A keyed list takes no positional slot, so keying the conditional list usually fixes its siblings too |
|
|
111
|
+
| `delegate(root, type, sel, h)` | one listener at the root, walks `closest(selector)` from target |
|
|
112
|
+
| `delegateCapture(root, type, sel, h, opts?)` | capture-phase escape hatch; `closest()` walk-up by default (same as `delegate`); pass `{ match: 'direct' }` for strict `target.matches()` |
|
|
113
|
+
| `attr(name, value)` | pre-computed `AttrSpec<N,V>` — `.selector` for `delegate()`, `.attrs` to spread into JSX (rename-safe) |
|
|
114
|
+
| `attr(name)` | dynamic factory — `attr<N,V=string>(name)` returns `(value: V) => { readonly [name]: V }`; both generics off → N inferred, V defaults to string; specify both to constrain values |
|
|
115
|
+
| `toElement(jsx)` | parse JSX into a DOM node (SVG-aware). Single-root → `Element`; multi-root (`<><svg/> label</>`, two icons side by side) → `DocumentFragment` that `appendChild`/`replaceChildren`/`append` inlines into the parent. |
|
|
116
|
+
| `raw(html)` | inject pre-escaped HTML |
|
|
117
|
+
| `arraySignal(initial?)` | granular keyed-list signal at `kerfjs/array-signal` subpath; `each()` reconciles in O(patches) |
|
|
118
|
+
| `` html`…` `` | tagged template at `kerfjs/html` subpath — JSX-identical runtime semantics with NO build step (CDN/importmap projects). Real HTML attribute names (`class`, not `className`); holes only in text positions or as a COMPLETE attribute value (`attr=${v}` / `attr="${v}"`) |
|
|
59
119
|
|
|
60
120
|
## Hard rules — get these right on the first try
|
|
61
121
|
|
|
@@ -66,7 +126,7 @@ if (import.meta.env.DEV) await import('kerfjs/dev');
|
|
|
66
126
|
5. **Capture the `delegate()` / `delegateCapture()` disposer** whenever the registration's scope is shorter than the page. Both helpers return `() => void`; the listener closure pins `rootEl`, `handler`, and everything the handler closes over (stores, signals, app state). Discarding the disposer on a transient root (modal, route view, mount swap, dynamic widget) leaks the listener AND the app graph it captures; re-mount cycles stack listeners linearly. `mount()`'s own disposer does NOT remove delegates for you. Safe to discard only when the registration is truly page-lifetime (root is `document.body` or equivalent, attached once at startup, never torn down).
|
|
67
127
|
6. **One `mount()` per root.** Don't nest. Compose with plain functions that return JSX.
|
|
68
128
|
7. **No `<MyComponent />` semantics with hooks.** Components are plain functions returning JSX. State lives in module-scope signals or stores, never in component closures.
|
|
69
|
-
8. **Values bind, structure re-renders.** For a value hole, pass the signal/computed ITSELF (`<span>{count}</span>`, `class={sig}`) — kerf updates that one node directly, no render re-run. Read `.value` only when the JSX
|
|
129
|
+
8. **Values bind, structure re-renders.** For a value hole, pass the signal/computed ITSELF (`<span>{count}</span>`, `class={sig}`) — kerf updates that one node directly, no render re-run. Read `.value` only when the JSX _structure_ depends on the signal — and then the read must happen INSIDE the render function to be tracked: `const x = count.value; mount(el, () => <span>{x}</span>)` will NOT re-render. Bind a STABLE signal/computed instance per hole (a `computed` that switches internally), never `class={cond ? sigA : sigB}` — switching instances can go silently stale (`KERF_DEV_WARN_STALE_BINDING=1` detects it). Endpoint: a render reading NO `.value` runs exactly once — a fully bound mount never re-renders; `KERF_DEV_WARN_VALUE_ONLY_RERENDER=1` flags re-renders that could have been bindings.
|
|
70
130
|
9. **Store actions receive `(set, get)`, not `(state)`.** `set(next)` replaces state; mutating `get()` does nothing.
|
|
71
131
|
10. **Use `data-action` attributes, not inline `onClick`.** Inline handlers are NOT supported by the JSX → string runtime; delegate from the root instead.
|
|
72
132
|
11. **`arraySignal` is opt-in for long keyed lists** where most updates are pointwise (single-row edits, append-to-end). For short lists / filter+sort pipelines, plain `signal` + `each(items.value, ...)` is simpler and equally fast.
|
|
@@ -79,22 +139,26 @@ if (import.meta.env.DEV) await import('kerfjs/dev');
|
|
|
79
139
|
When deciding which primitive to reach for, work down the axes:
|
|
80
140
|
|
|
81
141
|
**Events.**
|
|
142
|
+
|
|
82
143
|
- Originates inside the mount tree → `delegate(rootEl, type, sel, handler)`. Originates outside (window-level keyboard, online/offline, beforeunload) → native `window.addEventListener` at module top-level.
|
|
83
144
|
- Gesture that needs to follow an element after press (drag, draw, resize) → at the start event, `el.setPointerCapture(e.pointerId)`. Subsequent `pointermove` / `pointerup` redirect to the captured element and `delegate(rootEl, 'pointermove', '[data-card]', …)` still picks them up. Don't reach for `window.addEventListener` for in-mount-tree gestures.
|
|
84
145
|
- Well-known non-bubbler (`focus`, `blur`, `scroll`, `load`, `error`, `mouseenter`, `mouseleave`) → still `delegate()`; it auto-promotes to capture. Custom non-bubblers or capture-phase interception → `delegateCapture()` (also `closest()`-matched by default). Need strict element-match? Add `{ match: 'direct' }` on either helper.
|
|
85
146
|
|
|
86
147
|
**Lists.**
|
|
148
|
+
|
|
87
149
|
- Items change across renders (todos, chat messages, table rows) → `each(items, render)`.
|
|
88
150
|
- Static structural enumeration whose row render reads signals → `STATIC.map(item => <jsx/>)`. Inner `each(item.children, …)` still gets keyed reconcile.
|
|
89
151
|
- Long list with point-wise mutations → `arraySignal` + `each(arraySig, render)` for O(patches) updates.
|
|
90
152
|
|
|
91
153
|
**Side effects / imperative DOM.**
|
|
154
|
+
|
|
92
155
|
- Library-owned subtree survives across renders → `data-morph-skip` on host.
|
|
93
156
|
- Host attributes morph but subtree preserved → `data-morph-skip-children`.
|
|
94
157
|
- Imperatively-injected element survives the trailing-removal pass → `data-morph-preserve`.
|
|
95
158
|
- Focused input / contenteditable caret survives re-renders → automatic; no opt-in.
|
|
96
159
|
|
|
97
160
|
**Raw HTML.**
|
|
161
|
+
|
|
98
162
|
- User-controlled HTML → sanitize first (DOMPurify) then `raw(sanitized)`.
|
|
99
163
|
- Author-controlled trusted HTML → `raw(html)` directly.
|
|
100
164
|
- Dangerous URLs (`javascript:`/`vbscript:`/script-executing `data:`) on `href`/`src`/`xlink:href`/`formaction`/`action`/`data` are dropped — kerf THROWS in dev, WARNS + drops in prod. Sanitize user URLs upstream; wrap an intentional trusted one in `raw(url)` to bypass the screen in both modes. The `javascript:` no-op placeholders (`javascript:void(0)`, `javascript:;`, …) are allowed — they're the placeholder-link idiom, matched whole so nothing can ride along.
|
|
@@ -107,20 +171,27 @@ When deciding which primitive to reach for, work down the axes:
|
|
|
107
171
|
// updates that one node directly with no render re-run; read `.value` only
|
|
108
172
|
// when the JSX STRUCTURE depends on the signal (conditionals, list shape).
|
|
109
173
|
const count = signal(0);
|
|
110
|
-
const ACTIONS = { inc: attr(
|
|
174
|
+
const ACTIONS = { inc: attr("data-action", "inc") } as const satisfies Record<
|
|
175
|
+
string,
|
|
176
|
+
AttrSpec<"data-action">
|
|
177
|
+
>;
|
|
111
178
|
|
|
112
|
-
mount(document.getElementById(
|
|
179
|
+
mount(document.getElementById("app")!, () => (
|
|
113
180
|
<div>
|
|
114
181
|
<button {...ACTIONS.inc.attrs}>+</button>
|
|
115
182
|
<span>{count}</span>
|
|
116
183
|
</div>
|
|
117
184
|
));
|
|
118
|
-
delegate(rootEl,
|
|
185
|
+
delegate(rootEl, "click", ACTIONS.inc.selector, () => {
|
|
186
|
+
count.value += 1;
|
|
187
|
+
});
|
|
119
188
|
|
|
120
189
|
// Keyed list with per-item memoization
|
|
121
190
|
mount(listEl, () => (
|
|
122
191
|
<ul>
|
|
123
|
-
{each(rows.value, (row) =>
|
|
192
|
+
{each(rows.value, (row) => (
|
|
193
|
+
<li data-key={row.id}>{row.label}</li>
|
|
194
|
+
))}
|
|
124
195
|
</ul>
|
|
125
196
|
));
|
|
126
197
|
|
|
@@ -128,8 +199,8 @@ mount(listEl, () => (
|
|
|
128
199
|
const cart = defineStore({
|
|
129
200
|
initial: () => ({ items: [] as string[] }),
|
|
130
201
|
actions: (set, get) => ({
|
|
131
|
-
add:
|
|
132
|
-
clear: ()
|
|
202
|
+
add: (id: string) => set({ items: [...get().items, id] }),
|
|
203
|
+
clear: () => set({ items: [] }),
|
|
133
204
|
}),
|
|
134
205
|
});
|
|
135
206
|
|
|
@@ -139,11 +210,16 @@ morph(liveCard, '<article class="card">…</article>');
|
|
|
139
210
|
// No build step (CDN / importmap): the html tagged template instead of JSX.
|
|
140
211
|
// Same runtime semantics as JSX; write real HTML attribute names; a hole must
|
|
141
212
|
// be a text position or a COMPLETE attribute value (partial values throw).
|
|
142
|
-
import { html } from
|
|
143
|
-
mount(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
213
|
+
import { html } from "kerfjs/html";
|
|
214
|
+
mount(
|
|
215
|
+
rootEl,
|
|
216
|
+
() => html`
|
|
217
|
+
<div class="${cls}">Count: ${count}</div>
|
|
218
|
+
<ul>
|
|
219
|
+
${each(rows.value, (row) => html`<li data-key="${row.id}">${row.label}</li>`)}
|
|
220
|
+
</ul>
|
|
221
|
+
`,
|
|
222
|
+
);
|
|
147
223
|
|
|
148
224
|
// Fine-grained binding (opt-in): pass the signal/computed ITSELF into a hole
|
|
149
225
|
// so a change updates ONLY that node (no render re-run, no reconcile). For a
|
|
@@ -152,9 +228,15 @@ mount(rootEl, () => html`
|
|
|
152
228
|
const selectedId = signal<number | null>(null);
|
|
153
229
|
mount(listEl, () => (
|
|
154
230
|
<ul>
|
|
155
|
-
{each(
|
|
156
|
-
|
|
157
|
-
|
|
231
|
+
{each(
|
|
232
|
+
rows.value,
|
|
233
|
+
(row) => (
|
|
234
|
+
<li class={computed(() => (row.id === selectedId.value ? "sel" : ""))}>
|
|
235
|
+
{row.label}
|
|
236
|
+
</li>
|
|
237
|
+
),
|
|
238
|
+
(row) => row.id,
|
|
239
|
+
)}
|
|
158
240
|
</ul>
|
|
159
241
|
));
|
|
160
242
|
```
|
|
@@ -170,10 +252,10 @@ mount(listEl, () => (
|
|
|
170
252
|
- SVG renders as broken / namespaceless markup → use `mount` (HTML path) or `toElement` (SVG-aware), not `innerHTML`.
|
|
171
253
|
- Library widget destroyed on every render → wrap host in `data-morph-skip`; mount the library imperatively after first render.
|
|
172
254
|
- `each(): row render at index N produced K top-level elements` → wrap multiple roots in one parent.
|
|
173
|
-
- Drag/drop / state change has no visible effect, only stuff
|
|
174
|
-
- Row-enter CSS animation no longer replays when only a row's
|
|
255
|
+
- Drag/drop / state change has no visible effect, only stuff _outside_ `each()` updates → you used `each(STATIC_ARRAY, …)` whose row render reads signals. Replace the outer with `STATIC_ARRAY.map(...)`; keep inner `each()` for the dynamic sub-list. See Hard Rule 14.
|
|
256
|
+
- Row-enter CSS animation no longer replays when only a row's _content_ changed (kerf ≥ 0.15.0) → 0.15.0+ morphs a same-identity, same-position row _in place_ instead of recreating its node, so a mount-keyed `@keyframes` never re-triggers on a content-only update (≤ 0.14.x recreated the node, so it fired; the intentional flip side is that focus, scroll, IME, and in-progress transitions now survive). Key the animation on a state-class toggle, not element creation; to force a remount, churn the row's identity (new object ref / `data-key`).
|
|
175
257
|
- Want a hot spot to update without re-running the whole render → fine-grained binding: pass the signal/`computed` ITSELF into the attr/text hole (`class={computed(() => …)}`), not `.value`. Use `computed()` not a bare `() => …` (memoization keeps a shared-signal flip to ~O(changed nodes)). Opt-in per hole. Limit: a bound hole depending on the row's OWN mutated data goes stale on a granular in-place update — use plain interpolation there.
|
|
176
|
-
-
|
|
258
|
+
- ` html` ``: partial attribute values are not supported → in `kerfjs/html` templates a hole must be the COMPLETE attribute value. Replace `class="a ${b}"` with a pre-built string (`` class="${`a ${b}`}" ``) or, for a bound attribute, `class="${computed(() => `a ${b.value}`)}"`.
|
|
177
259
|
- An `each()` list's rows lose focus / scroll / typing state when an unrelated conditional list above them appears or disappears (kerf warns in dev) → lists without a key are identified by position among the render's `each()` calls, so adding/removing one above shifts this list's identity and kerf rebuilds it. Give the lists stable keys: `each(items, render, { key: 'results' })`; keying just the conditional list is usually enough.
|
|
178
260
|
- Keyed `each()` list suddenly renders zero rows — only its `<!--kf-list:N-->` marker — with no errors, and it never recovers (kerfjs ≤ 2.0.1) → a conditionally-rendered sibling BEFORE the list (possibly higher in the tree, e.g. an error banner) was removed that render; older kerfjs rebuilt the shifted list container from the template, permanently detaching the list's internal binding. Upgrade kerfjs (fixed after 2.0.1 — the morph now moves the shifted container up in place, keeping node identity). On older versions, keep the structure before the list stable: wrap the conditional in an always-present container (`<div class="banners">{cond ? <div/> : ''}</div>`).
|
|
179
261
|
- A numbered / zebra-striped / "N of M" `each()` list shows the wrong number on rows that MOVED (reorder, or non-tail insert/remove), while unmoved rows look right → the render fn's `index` argument is NOT part of the memo key (only item identity + `cacheKey` + content version are), so a row that keeps identity but changes position keeps HTML rendered at its old index. Fold the index into the memo key: `each(items, (it, i) => …, { cacheKey: (_, i) => i })` (add `key` if used). Opt-in dev warn: `KERF_DEV_WARN_STALE_INDEX=1`.
|