kerfjs 5.0.0-beta.51 → 5.0.0-beta.53

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +353 -0
  2. package/README.md +4 -3
  3. package/ai/cursorrules +6 -5
  4. package/ai/manifest.json +9 -7
  5. package/ai/skill.md +6 -5
  6. package/dist/actions.js +3 -3
  7. package/dist/actions.js.map +1 -1
  8. package/dist/array-signal.js +1 -1
  9. package/dist/async.js +1 -1
  10. package/dist/attach.js +1 -68
  11. package/dist/attach.js.map +1 -1
  12. package/dist/{chunk-CEQMZYLR.js → chunk-3C6F6NM4.js} +1 -1
  13. package/dist/{chunk-CEQMZYLR.js.map → chunk-3C6F6NM4.js.map} +1 -1
  14. package/dist/{chunk-ZOIERTUW.js → chunk-BI4HGICV.js} +3 -3
  15. package/dist/{chunk-ZOIERTUW.js.map → chunk-BI4HGICV.js.map} +1 -1
  16. package/dist/{chunk-3GSWKMBZ.js → chunk-DU35YGKW.js} +3 -3
  17. package/dist/{chunk-3GSWKMBZ.js.map → chunk-DU35YGKW.js.map} +1 -1
  18. package/dist/chunk-FM6N2B4N.js +70 -0
  19. package/dist/chunk-FM6N2B4N.js.map +1 -0
  20. package/dist/{chunk-ELXVRKY2.js → chunk-H7WTRM6F.js} +2 -2
  21. package/dist/{chunk-ELXVRKY2.js.map → chunk-H7WTRM6F.js.map} +1 -1
  22. package/dist/{chunk-H3TTM7PQ.js → chunk-HTXQR2KL.js} +108 -106
  23. package/dist/chunk-HTXQR2KL.js.map +1 -0
  24. package/dist/{chunk-Y2FOYPBV.js → chunk-IHMDBBUJ.js} +2 -2
  25. package/dist/chunk-IHMDBBUJ.js.map +1 -0
  26. package/dist/{chunk-E5R5GNKE.js → chunk-X6OQ2HTR.js} +2 -2
  27. package/dist/{chunk-E5R5GNKE.js.map → chunk-X6OQ2HTR.js.map} +1 -1
  28. package/dist/dev.d.ts +31 -13
  29. package/dist/dev.js +34 -18
  30. package/dist/dev.js.map +1 -1
  31. package/dist/html.js +2 -2
  32. package/dist/index.d.ts +12 -7
  33. package/dist/index.js +11 -11
  34. package/dist/index.js.map +1 -1
  35. package/dist/jsx-runtime.js +1 -1
  36. package/dist/list.d.ts +3 -2
  37. package/dist/list.js +27 -13
  38. package/dist/list.js.map +1 -1
  39. package/dist/overlay.d.ts +0 -22
  40. package/dist/overlay.js +107 -30
  41. package/dist/overlay.js.map +1 -1
  42. package/dist/remount.js +2 -2
  43. package/dist/router.d.ts +13 -4
  44. package/dist/router.js +8 -4
  45. package/dist/router.js.map +1 -1
  46. package/dist/scope.d.ts +10 -2
  47. package/dist/scope.js +22 -28
  48. package/dist/scope.js.map +1 -1
  49. package/dist/testing.js +1 -1
  50. package/dist/timing.d.ts +3 -2
  51. package/dist/timing.js +1 -1
  52. package/dist/timing.js.map +1 -1
  53. package/llms.txt +1 -1
  54. package/package.json +1 -1
  55. package/dist/chunk-H3TTM7PQ.js.map +0 -1
  56. package/dist/chunk-Y2FOYPBV.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,359 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ - `@kerfjs/ui` elevation shadows follow the color scheme, with or without the
10
+ optional `@kerfjs/ui/webawesome.css` theme. Kerf's theme layer set
11
+ `--wa-shadow-s` / `-m` / `-l` to fixed light-mode shadows (14%, 11%, and 9%
12
+ black), which all but vanished against the dark page, and the
13
+ `--kui-shadow-s` / `--kui-shadow-l` foundation fallbacks copied those fixed
14
+ values. The theme now pairs each light alpha with a stronger dark one (50%,
15
+ 40%, 32%) through `light-dark()`, and the foundation fallbacks mirror them, so
16
+ selected segments and app tabs, compact overlay panels, and Web Awesome
17
+ surfaces that read `--wa-shadow-*` keep their elevation in dark mode. Light mode is unchanged. The browser parity test
18
+ now compares every `--kui-shadow-*` token with and without the theme in both
19
+ schemes.
20
+ - `@kerfjs/ui` text on loud tone fills clears WCAG AA in both color schemes,
21
+ with or without the optional `@kerfjs/ui/webawesome.css` theme. White
22
+ `on-loud` text reached about 2.2:1 on the success fill, 3.5:1 on the light
23
+ brand fill (`#0088ff`), and 3.6:1 on the danger fill (`#ff383c`) in both
24
+ schemes. Kerf's theme layer and the foundation fallbacks now pair each
25
+ fill with a foreground chosen to keep the tone's look: success keeps its
26
+ bright green with dark text (`#1d1d1f`, 7.6:1), as warning already did; light
27
+ brand and danger keep white text on a slightly deeper fill of the same hue
28
+ (`#0075db`, 4.6:1; `#eb0005`, 4.6:1); dark danger keeps its fill with dark
29
+ text (`#111113`, 5.3:1), as dark brand already did. Web Awesome accent
30
+ buttons, badges, and checked controls pick this up, and accent `wa-badge`s now
31
+ use their variant's `on-loud` foreground by default instead of a fixed dark
32
+ color (`--kui-wa-badge-accent-foreground` still overrides it). The browser
33
+ contrast test asserts every tone's `on-loud` over `fill-loud` pair, including
34
+ pop and neutral, in both schemes with and without the theme.
35
+ - `@kerfjs/ui` brand, success, and warning text clears WCAG AA on the lowered
36
+ page background in light mode, with or without the optional
37
+ `@kerfjs/ui/webawesome.css` theme. Their light `on-quiet` values (`#1e6ef4`,
38
+ `#008932`, `#a16a00`) passed on the white surface but reached only about
39
+ 4.1:1 on `#f2f2f7`. Kerf's theme layer and the foundation fallbacks now use
40
+ the same hues a step darker: `#0c62f3` (4.64:1), `#007f2e` (4.62:1), and
41
+ `#966200` (4.65:1) on the page, about 5.2:1 on white. Links, selected tabs,
42
+ segmented and toolbar selections, and token-search accents that read
43
+ `brand-on-quiet` darken with it. Dark mode is unchanged. The browser contrast
44
+ test no longer skips light text over the page background.
45
+ - `@kerfjs/ui` surfaces, links, and focus rings match the optional
46
+ `@kerfjs/ui/webawesome.css` theme without it. Three foundation fallbacks
47
+ pointed at a different token than the theme: `--kui-color-surface-raised`
48
+ fell back to the surface color (the theme's dark raised surface is
49
+ `#2c2c2e`), `--kui-color-text-link` fell back to the brand fill (`#0088ff`,
50
+ about 3.5:1 on white) instead of the brand on-quiet text color, and the focus
51
+ ring used the brand fill instead of the theme's focus color (`#007aff`). Each
52
+ now falls back to the theme's value. The browser parity test now covers every
53
+ `--kui-color-*` token the foundation declares, plus the focus ring, instead of
54
+ only the tone tokens.
55
+ - The `@kerfjs/ui` UX catalog's `StateBanner` route adds a placeholder with a
56
+ badge for every tone, next to the existing badge-less placeholder, so visual
57
+ review and captures show the solid badge's loading skeleton on each tone's
58
+ fill. A browser test checks that each tone's badged placeholder is present.
59
+ - `@kerfjs/ui` toned text stays readable in dark mode without the optional
60
+ `@kerfjs/ui/webawesome.css` theme. The foundation's fallbacks for the
61
+ success, warning, and danger `on-quiet` and `on-normal` tokens were single
62
+ light-mode colors, so dark danger `Text` rendered dark red on a dark surface
63
+ (under 3:1). Every tone token now falls back to the same light and dark
64
+ pair the theme assigns, including `brand-on-loud` (dark text on the pale dark
65
+ brand fill instead of white) and `neutral-border-quiet`. Light-mode colors
66
+ are unchanged. A browser test bundles the package with and without the theme
67
+ and asserts that every tone token resolves identically in both color schemes
68
+ and that toned text clears WCAG AA in dark mode.
69
+ - `@kerfjs/ui` danger text and badges are readable in dark mode, and every
70
+ tone's `StateBanner` detail and quiet or outline `Badge` text now clears
71
+ WCAG AA. The danger tone was the only one without an on-fill foreground: its
72
+ solid badge fill and `StateBanner` text reused `danger-on-quiet`, a mid red
73
+ in dark mode (banner detail 4.0:1, badge placeholder skeleton 2.25:1). A new
74
+ `--kui-color-danger-on-fill` token (also accepted by `uiColor()`) matches the
75
+ other tones' pale dark values, lifting the dark danger banner text to 8.2:1.
76
+ The banner detail no longer dims to 78% opacity, which left the light info,
77
+ success, warning, and danger details at 3.4 to 4.3:1; it reads as secondary
78
+ through its smaller type, and the neutral detail uses the quiet text color.
79
+ Quiet and outline badges use their tone's on-fill text, lifting light brand,
80
+ success, and warning from about 4.1:1 to at least 5:1.
81
+
82
+ - A `@kerfjs/ui` placeholder skeleton inside a solid `Badge`, such as the
83
+ badge of a `StateBanner` placeholder, is now clearly visible. The generic
84
+ skeleton tint, 12% of the text color, almost disappeared on the badge's
85
+ saturated fill in every tone. A solid badge now tints its skeleton from its
86
+ own foreground color, which contrasts with the fill in every tone in light
87
+ and dark mode. Quiet and outline badges keep the generic tint, which already
88
+ reads on their pale fills. Geometry is unchanged.
89
+ - The `@kerfjs/ui` SVG design templates render badges as filled pills: the
90
+ `StateBanner` badge and the `ListHeader` and `List` count pills used to show
91
+ as plain text, because the template build loaded only a hand-written list of
92
+ stylesheets that omitted `badge.css`. The build now loads each component's
93
+ full stylesheet set from its generated browser entry, the same set a
94
+ consumer's browser import loads, so a nested component's CSS can no longer be
95
+ missed. The `ListHeader` and `List` templates also pick up the label inset
96
+ from `text.css`, so a header label now lines up with the list items below it,
97
+ as it does in the live component.
98
+ - A `@kerfjs/ui` `StateBanner` placeholder's badge sits at the live badge's
99
+ height on every platform. With a skeleton instead of text, the badge took a
100
+ baseline from the skeleton's bottom edge and sat about a pixel higher, by an
101
+ amount that depended on the platform's font metrics.
102
+ - A `@kerfjs/ui` `StateBanner` with a long, wrapping title keeps its badge
103
+ one item gap after the title's last word, and its detail uses the full copy
104
+ width. The wide flex layout shrank the title and detail together, which left
105
+ a large gap before the badge and truncated the detail early. The title,
106
+ badge, and detail now flow as inline text: the detail stays beside the title
107
+ while it fits whole, and otherwise moves to its own line, truncating only at
108
+ the full width. A wrapped title's lines stay evenly spaced.
109
+ - A `@kerfjs/ui` `ListHeader` disclosure toggle disabled with `actionDisabled`
110
+ is dimmed by the component itself, at the same tone as a disabled action
111
+ button, and shows the `not-allowed` cursor. It used to rely on Web Awesome's
112
+ native `button:disabled` dimming, so without `webawesome.css` it looked
113
+ enabled. Disabled toggles and action buttons also no longer show hover
114
+ feedback without `webawesome.css`.
115
+ - A `@kerfjs/ui` `ListActionRow` placeholder keeps its `busy` spinner. The
116
+ whole status line used to collapse into one skeleton, dropping the spinner
117
+ the component draws from a known prop. Now only the `status` text becomes a
118
+ skeleton, matching `ListItem`.
119
+ - **A tooltip or popover already showing stays visible when a modal opens.**
120
+ Lifting a surface above an open modal `<dialog>` was decided only when the
121
+ surface opened, so a tooltip or popover already on screen when a modal
122
+ dialog opened was left inert beneath it. Whenever `kerfjs/overlay` opens a
123
+ modal `<dialog>` (a `native: true` modal, or one it lifted), it now moves
124
+ every open tooltip and popover into the top layer above the new dialog. One
125
+ that is already a `[popover]` is re-shown so it stacks on top. The existing
126
+ `kerfjs/dev` warnings still report an engine without the Popover API, and a
127
+ surface with controls the modal keeps inert. A modal `<dialog>` the app
128
+ opens itself, outside kerf, is not observed.
129
+ - Moved `mount()`'s list-binding lifecycle (turning list markers into
130
+ bindings, first-render row-contract validation, orphan pruning) into its own
131
+ internal module, so `mount.ts` holds only the render orchestration. No
132
+ behavior or public API change.
133
+ - A narrow (576px and below) `@kerfjs/ui` `StateBanner` keeps its badge
134
+ sized to its content, one item gap after the title and on the title's
135
+ baseline, as in the wide layout. The narrow grid stretched the badge across
136
+ the free column into a wide pill that sat less than 1px from the title.
137
+ - Hovering a `@kerfjs/ui` `ListActionRow` placeholder with
138
+ `trailingActionVisibility="interaction"` no longer reveals its disabled
139
+ trailing button. A placeholder is inert, so it keeps the live row's resting
140
+ state, with the trailing action hidden until an interaction it cannot take.
141
+ - A `@kerfjs/ui` `ListItem` placeholder keeps its `busy` spinner. The whole
142
+ trailing slot used to collapse into one skeleton, dropping the spinner the
143
+ component draws from a known prop and shifting the label's width. Now only
144
+ the values in that slot become skeletons: `status` text in its status line
145
+ and author-supplied `trailing` content (always a value, since the component
146
+ cannot tell a chevron from a count) each get their own.
147
+ - `@kerfjs/ui` placeholders keep the live disabled tone of a control the
148
+ author disabled. A disabled `ListItem` or `ListActionRow`, a
149
+ `trailingActionDisabled` button, a disabled `SegmentedControl` choice, or a
150
+ `ListHeader` with `actionDisabled` rendered at full opacity as a
151
+ placeholder, because the placeholder reset could not tell its own
152
+ `disabled` attribute from the author's. Those controls now carry a
153
+ component-owned `data-kui-disabled="true"` marker whenever their disabled
154
+ prop is set; the disabled tone keys on it and the placeholder reset skips
155
+ it. The marker is reserved: the extension-attribute props cannot set it.
156
+ - `@kerfjs/ui` adds a browser test that renders every placeholder-capable
157
+ component live and as a placeholder from identical props and diffs each
158
+ element's geometry and computed chrome, so any future drift outside a value
159
+ slot fails. It caught one more: a `ListActionRow` placeholder's `status`
160
+ line was 3.5px shorter than the live line; the status line now always
161
+ keeps a full line height.
162
+ - `@kerfjs/ui`'s `Select` placeholder hint now matches the live hint: 14px
163
+ text at a 1.6 line height, 7px below the box. It was 12px / 1.4 with a 4px
164
+ gap, so a placeholder with a `hint` was 8.6px shorter than the loaded
165
+ control and the content below it jumped when loading finished.
166
+ - `@kerfjs/ui`'s `StateBanner` placeholder adds a detail skeleton only when
167
+ `detail` is set, matching the live banner. Previously every placeholder
168
+ showed a detail line, so a banner without one changed shape when loading
169
+ finished. Pass the banner's real `title` and `detail` with `placeholder`;
170
+ an empty `detail` now means no detail line.
171
+ - `@kerfjs/ui`'s `AppTab` placeholder pill is no longer dimmed to 50% with a
172
+ `not-allowed` cursor when `webawesome.css` is loaded; it keeps the live
173
+ tab's tone, and only its name is a skeleton.
174
+ - `@kerfjs/ui`'s `ListHeader` placeholder no longer dims its action button
175
+ (48%) or, in `toggle` mode, the whole title and label (Web Awesome's native
176
+ 50%), and its inert action and toggle give no hover feedback. A live
177
+ `actionDisabled` header still dims its action.
178
+ - `@kerfjs/ui`'s `SegmentedControl` placeholder now keeps its choices. The
179
+ choices are known; only the selection is unknown, so a placeholder renders
180
+ every choice's live label or icon at its live size, selects none, and stays
181
+ inert. Previously every segment became a 4em text skeleton (widening icon
182
+ segments from 40px to 60px) and was dimmed to 45%.
183
+ - `@kerfjs/ui`'s `ListItem` and `ListActionRow` placeholders now match the
184
+ live row. They are no longer dimmed (the rows' own `:disabled` tone and
185
+ hover highlight skip placeholders), and a row with a `description` (or a
186
+ `ListActionRow` `status`) keeps that line as a second, smaller skeleton, so
187
+ the row keeps its live height instead of collapsing to one line. The
188
+ `ListActionRow` icon placeholder is an 18px block centered in its icon slot,
189
+ like `ListItem`'s.
190
+ - `@kerfjs/ui` placeholders no longer look disabled. A component's
191
+ `placeholder` keeps its controls `disabled` (inert and out of the keyboard
192
+ order), but `skeleton.css` now neutralizes generic disabled chrome under
193
+ `[data-placeholder="true"]`: full opacity and the default cursor instead of
194
+ Web Awesome's native `button:disabled` dimming to 50% with a `not-allowed`
195
+ cursor. A component's own opacity (a control hidden until interaction) still
196
+ applies.
197
+ - `toElement('<image href="…" />')` now returns an SVG `<image>`. The HTML
198
+ parser rewrites an orphan `<image>` to an HTML `<img>` before kerf sees the
199
+ tag, so it previously came back as an XHTML `img` that no `<svg>` would
200
+ render. kerf now recognizes a leading `<image` tag in the raw input
201
+ (case-insensitive, after leading whitespace) and parses it through the SVG
202
+ wrapper. A genuine `<img>` is unchanged.
203
+ - `delegateActions()` (`kerfjs/actions`) dispatches only the table's own
204
+ entries. It used to look the action up with a plain property read, so a
205
+ `data-action` naming an inherited `Object.prototype` member resolved:
206
+ `"__proto__"` or `"hasOwnProperty"` threw a `TypeError` from the listener,
207
+ and `"toString"` or `"constructor"` silently ran the inherited function.
208
+ Those values are now ignored like any other action absent from the table.
209
+ An own entry with such a name (`{ toString: … }`) still dispatches.
210
+ - `toElement()` now puts orphan camelCase SVG fragments in the SVG namespace.
211
+ `toElement('<linearGradient>…</linearGradient>')`, and likewise `clipPath`,
212
+ `radialGradient`, and `foreignObject`, previously returned an XHTML
213
+ `LINEARGRADIENT` element that no `<svg>` would render, because the lookup
214
+ compared the HTML parser's lowercased tag against camelCase names. The
215
+ fragment is now wrapped in `<svg>` and XML-parsed like `<path>` or `<g>`,
216
+ and the returned element keeps its camelCase `localName`.
217
+ - **A tooltip or popover no longer outlives its anchor.** When the anchor left
218
+ the document, most commonly because the modal containing it closed, the
219
+ `tooltip()` or `popover()` stayed on screen: a removed element fires no
220
+ `pointerleave` or `blur`. Both now watch their anchor with the same removal
221
+ tracking as `kerfjs/attach`. The tooltip hides, and the popover closes
222
+ without calling `onDismiss`, as soon as the anchor leaves the document.
223
+ Moving the anchor within the document keeps them open. A tooltip show still
224
+ pending when its anchor is removed never appears.
225
+ - **Overlays opened over a modal `<dialog>` are visible and usable.** A browser
226
+ makes everything outside an open modal dialog inert and paints a plain
227
+ element beneath it, so a `tooltip()`, `popover()`, or `confirm()` opened
228
+ from inside a `native: true` dialog without `native` itself was invisible and
229
+ could not be used. While a modal `<dialog>` is open, `kerfjs/overlay` now
230
+ hosts such a surface in the top layer anyway: a tooltip or popover becomes a
231
+ `[popover]`, and a nested modal becomes its own `<dialog>`. The browser's
232
+ default `[popover]` / `<dialog>` styles then apply, as they do with `native:
233
+ true`. Browsers keep a popover outside the modal dialog inert even in the top
234
+ layer, so a lifted popover with buttons is visible but not clickable. A new
235
+ always-on `kerfjs/dev` warning reports that case, and an engine without the
236
+ needed API.
237
+ - **A tooltip shown inside a modal no longer disables the modal.** `kerfjs/overlay`
238
+ used to treat the most recently opened overlay as the only one that could
239
+ react to input, so a `tooltip()` (which handles no input) inside an open
240
+ modal left Escape doing nothing and let Tab leave the dialog. Every overlay,
241
+ native or fallback, now joins one open-order stack, and each Escape, backdrop
242
+ click, outside click, or trapped Tab goes to the topmost surface that handles
243
+ it. A modal still blocks everything beneath it, and a surface that handles
244
+ nothing never intercepts. A popover that dismisses on Escape gets it before
245
+ the modal beneath. One click that is both outside a popover and on a modal's
246
+ backdrop now closes only the popover. A native `<dialog>` is arbitrated with
247
+ the fallback surfaces: one Escape closes one surface, and a fallback modal
248
+ beneath an open dialog no longer takes the dialog's Tab. Closing a lower
249
+ overlay first leaves focus in the overlay that holds it. An overlay opened
250
+ from inside it then restores focus to the closed overlay's opener, not a
251
+ detached node.
252
+ - `kerfjs` now exports the `EachOptions<T>` type (`{ cacheKey?, key? }`), the
253
+ options-object form of `each()`'s third argument, so a helper that builds or
254
+ forwards those options can import the shape instead of restating it:
255
+ `import { type EachOptions } from "kerfjs"`. Type-only; no runtime or bundle
256
+ change.
257
+ - Documentation: the companion-utility reference now matches the code. The
258
+ router's `match()` / `activeClass()` compare a literal path prefix, so
259
+ `match('/users/:id')` is never true. Link interception still applies to
260
+ `target="_self"`, the no-match `outlet()` is `null` with `params` `{}`,
261
+ components receive `(params, route)`, and hash mode keeps `route.hash` at
262
+ `''`. Invalid `bindList` configuration throws only `RangeError`. `popover()`
263
+ defaults `className` to `'kerf-popover'`, and the toast region is a reused
264
+ `.kerf-toasts` element when one exists. A `resource()` `cacheKey` applies
265
+ only to `run(input, fetcher)`, and throttle's `flush()` leaves the cooldown
266
+ running. `delegate()` / `delegateCapture()` throw on an invalid selector at
267
+ registration. The reference also now lists the `kerfjs/jsx-dev-runtime`
268
+ alias, the `DEV_HOOKS` export of `kerfjs/dev`, and `Signal#peek()`.
269
+ - Documentation: `morph(liveRoot, template)` is now described as it behaves.
270
+ It reconciles only the root's children and never touches the root's own
271
+ attributes, so `template` describes the content inside the root (an
272
+ `Element` template contributes its child nodes). The examples no longer
273
+ nest a duplicate `<article>` inside the live one. An `each()` list parent is
274
+ not a children-only short-circuit: the diff still walks it and skips only
275
+ the owned rows. The user-agent-owned `open` exception covers custom elements
276
+ as well as `<details>` / `<dialog>`, and a content-only row change inside an
277
+ `each()` list keeps its DOM node (and focus) unless the row's top-level tag
278
+ changes or the same render also inserts, removes, or moves rows.
279
+ - Every opt-in `kerfjs/dev` warning now ends with a silence hint that leads
280
+ with the browser-reachable switch, for example "Silence it with
281
+ enableWarnings({ narrowSet: false }) from kerfjs/dev, or set
282
+ KERF_DEV_WARN_NARROW_SET=0 (or unset it) under Node." Previously the
283
+ messages named only the environment variable, which a browser cannot reach.
284
+ The docs now teach `enableWarnings()` wherever they showed an env-only
285
+ switch, and name all three creation-time boundaries: `signal()`
286
+ (`untrackedSignals`), `effect()` (`delegateInEffect`), and `mount()`
287
+ (`rebuiltListeners`) decide once when created, so anything created before
288
+ the install or the opt-in stays uncovered.
289
+ - The `kerfjs/dev` rebuilt-listeners warning (`rebuiltListeners` /
290
+ `KERF_DEV_WARN_REBUILT_LISTENERS`) now dedups once per `mount()`, as
291
+ documented. Previously the first warning silenced every other mount for
292
+ the life of the process, so a second mount with the same bug stayed quiet.
293
+ - `kerfjs/router`: a second router on the same document no longer diverges
294
+ from the URL after a link click. The first router's interceptor navigates
295
+ with `pushState`, which fires no `popstate`, so the second router ignored
296
+ the already-handled click and kept the old route. An already-handled click
297
+ now re-syncs the route from the URL; when nothing navigated, it is a no-op.
298
+ One router per document remains the supported shape: a second router does
299
+ not follow another router's programmatic `navigate()` (no `popstate` fires
300
+ and routers do not broadcast to each other) until the next `popstate` or
301
+ link click. This is now documented and pinned by a test.
302
+ - `kerfjs/scope`: a disposed `Scope` handle no longer leaks registrations.
303
+ Previously `add` / `mount` / `effect` / `delegate` on a handle after its
304
+ `dispose()` (or from a disposer while `dispose()` ran) pushed into a list
305
+ nothing would ever run, leaving the effect subscribed or the listener
306
+ attached. Such a registration now tears down at once: `add(fn)` calls `fn`
307
+ immediately, and the wrappers create and immediately dispose.
308
+ - `bindList` measured-height virtualization no longer applies a stale
309
+ scroll-anchor correction. A `setHeight()` report for a row above the viewport
310
+ is now resolved when the animation frame runs: if the source removed the key,
311
+ moved it below the fold, or the user scrolled the row into view first,
312
+ `scrollTop` is left alone, and several reports for one key in a frame net
313
+ out. Fixed-height windows also clamp to the source length, so a list that
314
+ shrinks while scrolled deep no longer pads far past its content.
315
+ - `create-kerf-component` accepts an explicit `boundaries.rootClass: null` as
316
+ an author decision, as its schema always allowed. A wrapper whose root
317
+ element belongs to another catalog (a `rendersAs` wrapper) no longer has to
318
+ invent a class to satisfy the generator. An absent `rootClass` still fails
319
+ as undecided.
320
+ - `@kerfjs/ui`'s `Select` placeholder now matches the live control. The label
321
+ uses the field-label style (uppercase, extra-small, quiet, inset to line up
322
+ with the value), and the chevron is the same glyph at the same size.
323
+ Previously the placeholder drew a larger, regular-weight label at a
324
+ different inset and a different, smaller chevron. Only the value is a
325
+ skeleton; the chrome no longer changes when loading finishes.
326
+ - **Wrapper components can declare the cataloged root they render.** A
327
+ `kerf.components.json` component may set `composition.rendersAs`, for
328
+ example `["@kerfjs/ui:toolbar-control-group"]`: a list of the roots it
329
+ renders, any one of them, or nothing. `kerf-component-catalog` copies it
330
+ into the generated v2 entry and fails unless every key resolves to a
331
+ cataloged entry. `eslint-plugin-kerfjs`'s `ui-composition` rule, and so
332
+ `kerf-ui-doctor`, now loads the application and third-party catalogs a
333
+ `.kerf-ui-profile.json` declares. It resolves their components by package
334
+ subpath or source file and checks a wrapper as each declared root: a zone
335
+ must accept every root, each root's parent contract applies where the
336
+ wrapper is placed, and a wrapper counts as zero or one child. Undeclared
337
+ wrappers keep reporting `KUI-L202`.
338
+ - `@kerfjs/ui`'s `AppTab` adds `pending` for a known tab that is still
339
+ opening. It keeps the name visible in the quiet text color and uses it as
340
+ the tab's accessible name, so a trailing spinner's label is not folded in.
341
+ Like `placeholder`, it disables select and close, turns off dragging, and
342
+ sets `aria-busy`, and it shows the consumer's `trailing` spinner. It keeps
343
+ the live tab's pill geometry, so it swaps in place without shifting the bar.
344
+ Dormant tabs (placeholder or pending) no longer reveal their disabled close
345
+ button on hover.
346
+ - `@kerfjs/ui`'s `wireSidebar` moves focus into an opening panel without
347
+ scrolling it. A plain `focus()` scrolled the panel's clipped content to the
348
+ target while it was still sliding in, then the slide unwound that scroll:
349
+ a bottom drawer's content jumped up past its top and drifted back. The
350
+ collapsible-sidebar recipe also floats the drawer's expand toggle in a
351
+ `FloatingToolbar` at the bottom edge the drawer opens from, and drops the
352
+ divider under the drawer's toolbar.
353
+ - `@kerfjs/ui`'s `Skeleton` takes `block` (default `false`). It lays the
354
+ placeholder out as `display: block` instead of `inline-block`, for a
355
+ skeleton that stands in for a box rather than text: no text line and no
356
+ baseline gap of its own. `ListItem`'s placeholder icon uses it.
357
+ - `@kerfjs/ui/css-values` adds `remify(px)`, the runtime twin of source
358
+ CSS's `remify(<px>)`. It turns a pixel count into a rem `CssLength` against
359
+ the fixed 16px baseline, so `remify(18)` is `1.125rem`. `ListItem`'s
360
+ placeholder icon now uses it at 18px, and `Skeleton` no longer forces a
361
+ 24px minimum width, so small placeholders keep the size they ask for.
9
362
  - `@kerfjs/ui/catalog`'s application-sized (`height: "app"`) example frame
10
363
  no longer loses taps in Safari/WebKit. The frame became the containing block
11
364
  for screen-fixed chrome through `contain: layout`. Combined with its clip,
package/README.md CHANGED
@@ -209,12 +209,13 @@ The class lives in its own subpath so apps that don't need it shed ~1 KB. Reads
209
209
  ```ts
210
210
  import { morph, raw } from "kerfjs";
211
211
 
212
- morph(liveCard, freshlyBuiltCardEl); // Element template
213
- morph(liveCard, '<article class="card">…</article>'); // raw HTML string
212
+ // `template` describes liveCard's CHILDREN; liveCard's own attributes are untouched
213
+ morph(liveCard, freshlyBuiltCardEl); // Element template: its child nodes are used
214
+ morph(liveCard, '<h2 class="card-title">…</h2><p>…</p>'); // raw HTML string
214
215
  morph(liveCard, raw(htmlFromServer)); // SafeHtml
215
216
  ```
216
217
 
217
- 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.
218
+ Same algorithm `mount()` uses internally — `data-morph-skip`, `data-morph-skip-children`, `data-morph-preserve`, focused-input value + selection preservation, the user-agent-owned `open` rule (`<details>`, `<dialog>`, custom elements) all carry over. Only the live root's children are reconciled — the root's own attributes are never touched. 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.
218
219
 
219
220
  ### No build step at all: the `html` tagged template
220
221
 
package/ai/cursorrules CHANGED
@@ -1,4 +1,4 @@
1
- <!-- kerf-skill-version: 1.21.0 -->
1
+ <!-- kerf-skill-version: 1.21.1 -->
2
2
 
3
3
  # kerf.cursorrules — rules for building apps with kerf
4
4
 
@@ -17,7 +17,7 @@ You are writing a UI in kerf — a ~12 KB reactive framework (~13 KB with `array
17
17
  - Install with `npm install kerfjs`.
18
18
  - `tsconfig.json`: `"jsx": "react-jsx"`, `"jsxImportSource": "kerfjs"`.
19
19
  - Vite / esbuild need no extra config.
20
- - **Dev diagnostics are opt-in by import, and only an APP installs them.** kerf does not infer dev mode. In the app entry add `if (import.meta.env.DEV) await import('kerfjs/dev');` (Vite) or `if (process.env.NODE_ENV !== 'production') await import('kerfjs/dev');` (webpack/Node). That enables the read-only store `get()` snapshot, the throwing dangerous-URL screen, and makes the `KERF_DEV_WARN_*` family available; omitting it is production shape and sheds ~4.7 KB min+gzip because the condition folds away and the chunk is never emitted. Put it FIRST if relying on the untracked-signal warning — `signal()` picks its constructor at creation time.
20
+ - **Dev diagnostics are opt-in by import, and only an APP installs them.** kerf does not infer dev mode. In the app entry add `if (import.meta.env.DEV) await import('kerfjs/dev');` (Vite) or `if (process.env.NODE_ENV !== 'production') await import('kerfjs/dev');` (webpack/Node). That enables the read-only store `get()` snapshot, the throwing dangerous-URL screen, and makes the `KERF_DEV_WARN_*` family available; omitting it is production shape and sheds ~4.7 KB min+gzip because the condition folds away and the chunk is never emitted. Put it (and any `enableWarnings()` call) FIRST if relying on the untracked-signal, delegate-in-effect, or rebuilt-listeners warnings — `signal()`, `effect()`, and `mount()` decide those at creation time, so anything created earlier stays uncovered.
21
21
  - **Switch individual warnings on with `enableWarnings()`**, which is the only switch that works in a browser (no `process` object there, and a bundler `define` cannot reach the read): `const dev = await import('kerfjs/dev'); dev.enableWarnings({ staleBinding: true, narrowSet: true, invariants: 'throw' });`. The `KERF_DEV_WARN_*` env vars do the same for Node/SSR/CI; an explicit call wins either way.
22
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.
23
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.
@@ -128,7 +128,7 @@ if (import.meta.env.DEV) await import("kerfjs/dev");
128
128
  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).
129
129
  6. **One `mount()` per root.** Don't nest. Compose with plain functions that return JSX.
130
130
  7. **No `<MyComponent />` semantics with hooks.** Components are plain functions returning JSX. State lives in module-scope signals or stores, never in component closures.
131
- 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.
131
+ 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 (`enableWarnings({ staleBinding: true })` (or `KERF_DEV_WARN_STALE_BINDING=1` under Node) detects it). Endpoint: a render reading NO `.value` runs exactly once — a fully bound mount never re-renders; `enableWarnings({ valueOnlyRerender: true })` (or `KERF_DEV_WARN_VALUE_ONLY_RERENDER=1` under Node) flags re-renders that could have been bindings.
132
132
  9. **Store actions receive `(set, get)`, not `(state)`.** `set(next)` replaces state; mutating `get()` does nothing.
133
133
  10. **Use `data-action` attributes, not inline `onClick`.** Inline handlers are NOT supported by the JSX → string runtime; delegate from the root instead.
134
134
  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.
@@ -207,7 +207,8 @@ const cart = defineStore({
207
207
  });
208
208
 
209
209
  // One-shot reconcile against existing DOM (no signals)
210
- morph(liveCard, '<article class="card">…</article>');
210
+ // the template describes liveCard's CHILDREN; liveCard's own attributes are untouched
211
+ morph(liveCard, '<h2 class="card-title">…</h2><p>…</p>');
211
212
 
212
213
  // No build step (CDN / importmap): the html tagged template instead of JSX.
213
214
  // Same runtime semantics as JSX; write real HTML attribute names; a hole must
@@ -260,7 +261,7 @@ mount(listEl, () => (
260
261
  - ` 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}`)}"`.
261
262
  - 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.
262
263
  - 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>`).
263
- - 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`.
264
+ - 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: `enableWarnings({ staleIndex: true })` (or `KERF_DEV_WARN_STALE_INDEX=1` under Node).
264
265
 
265
266
  ## Server / SSR
266
267
 
package/ai/manifest.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
- "kerfjsVersion": "5.0.0-beta.51",
2
+ "kerfjsVersion": "5.0.0-beta.53",
3
3
  "files": [
4
4
  {
5
5
  "name": "skill",
6
6
  "source": "kerf.claude-skill.md",
7
7
  "bundle": "ai/skill.md",
8
8
  "dest": ".claude/skills/kerf-app/SKILL.md",
9
- "version": "1.23.0",
10
- "sha256": "28b82b4d31b98814c8fbbe1cb0bcf716192deb494c020b9af41a30830e6a0007",
9
+ "version": "1.23.1",
10
+ "sha256": "e21d7db1097ad95c11af9248ad17d514716bebc5646becf57d19a7598d6e382c",
11
11
  "history": {
12
12
  "1.0.0": "bef982cb743fd4c73e666ee5cf774112e57c93ebb13b24bbacbf08577db0930a",
13
13
  "1.1.0": "f2aadb21e33dd389028951ae1ecbd9f8ba68648b3c5beba43fb4dfa2ab29f195",
@@ -37,7 +37,8 @@
37
37
  "1.20.0": "aa88ccdbf969624bbc6cefadba76ada1f090fae436597a7bab564e24c467287f",
38
38
  "1.21.0": "265b747eeb9c3d445c60d66a1109eec42705075b7cd154d107bd79d543c1e7e5",
39
39
  "1.22.0": "dc99b0ca5e911be11cbfa2ae515c0e161a414a5185895069535c41ba56664e70",
40
- "1.23.0": "28b82b4d31b98814c8fbbe1cb0bcf716192deb494c020b9af41a30830e6a0007"
40
+ "1.23.0": "28b82b4d31b98814c8fbbe1cb0bcf716192deb494c020b9af41a30830e6a0007",
41
+ "1.23.1": "e21d7db1097ad95c11af9248ad17d514716bebc5646becf57d19a7598d6e382c"
41
42
  }
42
43
  },
43
44
  {
@@ -45,8 +46,8 @@
45
46
  "source": "kerf.cursorrules",
46
47
  "bundle": "ai/cursorrules",
47
48
  "dest": ".cursorrules",
48
- "version": "1.21.0",
49
- "sha256": "a1b663b44650b1a04f89c60b26b002322fc6789650291477e14e13329c3ce25f",
49
+ "version": "1.21.1",
50
+ "sha256": "9b3eb825a7bec05bb6f0138f77b45fadf896b83020178c34c5a2f531c38f4dee",
50
51
  "history": {
51
52
  "1.0.0": "98f7faff2b7585ef9cc511add42ea5d9bf0cbae8307b066af090e9281136d2ab",
52
53
  "1.1.0": "a2ec8c221dc694c98b81009cd2d99f9159a45fda94585de2f54471b227099eda",
@@ -74,7 +75,8 @@
74
75
  "1.18.0": "de3eea1bc181da629694d19a0817d252a868e4704437fbdd9e923856b8a0890a",
75
76
  "1.19.0": "c5b91e449f6feccaa36a04cb7f8550d3e414eb2795ea6c5dccdf6071206b3261",
76
77
  "1.20.0": "5e7b8d8ddc41f60220961417c9ae01c193fae045378a10a3e9c96a0e3cf10a03",
77
- "1.21.0": "a1b663b44650b1a04f89c60b26b002322fc6789650291477e14e13329c3ce25f"
78
+ "1.21.0": "a1b663b44650b1a04f89c60b26b002322fc6789650291477e14e13329c3ce25f",
79
+ "1.21.1": "9b3eb825a7bec05bb6f0138f77b45fadf896b83020178c34c5a2f531c38f4dee"
78
80
  }
79
81
  }
80
82
  ]
package/ai/skill.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: kerf-app
3
3
  description: Build UIs in the kerf reactive framework and its @kerfjs/ui component package (https://github.com/brianwestphal/kerf). Use this skill whenever the user is writing or modifying code that imports `kerfjs` or `@kerfjs/ui`, asks to add a feature to a kerf app, or asks "how do I do X in kerf?". Use it proactively the moment you spot a kerf import in the file you're editing.
4
- kerf-skill-version: 1.23.0
4
+ kerf-skill-version: 1.23.1
5
5
  ---
6
6
 
7
7
  # Building apps with kerf
@@ -17,7 +17,7 @@ kerf is a ~12 KB reactive UI framework (~13 KB with `arraySignal`): signals + DO
17
17
  - Install: `npm install kerfjs`
18
18
  - `tsconfig.json`: `"jsx": "react-jsx"`, `"jsxImportSource": "kerfjs"`
19
19
  - Vite / esbuild need no extra config.
20
- - **Dev diagnostics are opt-in by import, and only an APP installs them.** kerf does not infer dev mode. In the app entry add `if (import.meta.env.DEV) await import('kerfjs/dev');` (Vite) or `if (process.env.NODE_ENV !== 'production') await import('kerfjs/dev');` (webpack/Node). That enables the read-only store `get()` snapshot, the throwing dangerous-URL screen, and makes the `KERF_DEV_WARN_*` family available; omitting it is production shape and sheds ~4.7 KB min+gzip because the condition folds away and the chunk is never emitted. Put it FIRST if relying on the untracked-signal warning — `signal()` picks its constructor at creation time.
20
+ - **Dev diagnostics are opt-in by import, and only an APP installs them.** kerf does not infer dev mode. In the app entry add `if (import.meta.env.DEV) await import('kerfjs/dev');` (Vite) or `if (process.env.NODE_ENV !== 'production') await import('kerfjs/dev');` (webpack/Node). That enables the read-only store `get()` snapshot, the throwing dangerous-URL screen, and makes the `KERF_DEV_WARN_*` family available; omitting it is production shape and sheds ~4.7 KB min+gzip because the condition folds away and the chunk is never emitted. Put it (and any `enableWarnings()` call) FIRST if relying on the untracked-signal, delegate-in-effect, or rebuilt-listeners warnings — `signal()`, `effect()`, and `mount()` decide those at creation time, so anything created earlier stays uncovered.
21
21
  - **Switch individual warnings on with `enableWarnings()`**, which is the only switch that works in a browser (no `process` object there, and a bundler `define` cannot reach the read): `const dev = await import('kerfjs/dev'); dev.enableWarnings({ staleBinding: true, narrowSet: true, invariants: 'throw' });`. The `KERF_DEV_WARN_*` env vars do the same for Node/SSR/CI; an explicit call wins either way.
22
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.
23
23
  - Recommended companion: `npm install --save-dev eslint-plugin-kerfjs` and add `kerfjs.configs.recommended` to the project's eslint config. 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 — useful as a self-correction signal when authoring kerf code.
@@ -137,7 +137,7 @@ if (import.meta.env.DEV) await import("kerfjs/dev");
137
137
  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).
138
138
  6. **One `mount()` per root.** Don't nest `mount()` calls. Compose with plain functions returning JSX.
139
139
  7. **Components are plain functions.** `<MyComponent props />` works — the JSX runtime calls `MyComponent(props)` and uses the returned JSX — but there's no hook system, no lifecycle, and no per-instance state. State lives in module-scope signals or stores, never in component closures.
140
- 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>)` does NOT re-render. One caveat on bound holes: bind a STABLE signal/computed instance per hole (`class={computed(() => …)}` that switches internally), never `class={cond ? sigA : sigB}` — switching instances can go silently stale (detectable via `KERF_DEV_WARN_STALE_BINDING=1`). The idiom's endpoint: a render that reads NO `.value` runs exactly once — a fully bound mount never re-renders. To find `.value` holes worth migrating, `KERF_DEV_WARN_VALUE_ONLY_RERENDER=1` flags re-renders whose only differences were text/attribute values.
140
+ 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>)` does NOT re-render. One caveat on bound holes: bind a STABLE signal/computed instance per hole (`class={computed(() => …)}` that switches internally), never `class={cond ? sigA : sigB}` — switching instances can go silently stale (detectable via `enableWarnings({ staleBinding: true })` (or `KERF_DEV_WARN_STALE_BINDING=1` under Node)). The idiom's endpoint: a render that reads NO `.value` runs exactly once — a fully bound mount never re-renders. To find `.value` holes worth migrating, `enableWarnings({ valueOnlyRerender: true })` (or `KERF_DEV_WARN_VALUE_ONLY_RERENDER=1` under Node) flags re-renders whose only differences were text/attribute values.
141
141
  9. **Store actions take `(set, get)`, not `(state)`.** `set(next)` replaces state; mutating `get()` does nothing.
142
142
  10. **Use `data-action` attributes, not inline `onClick`.** Inline handlers are NOT supported by the JSX → string runtime; delegate from the root.
143
143
  11. **`arraySignal` is opt-in for long keyed lists** where most updates are pointwise. For short lists / filter+sort pipelines, plain `signal` + `each(items.value, ...)` is simpler and equally fast.
@@ -219,7 +219,8 @@ const cart = defineStore({
219
219
  // access: cart.state.value.items, cart.actions.add('x'), cart.reset()
220
220
 
221
221
  // Pattern 4: one-shot reconcile (no signals, no effect)
222
- morph(liveCard, '<article class="card">…</article>');
222
+ // the template describes liveCard's CHILDREN; liveCard's own attributes are untouched
223
+ morph(liveCard, '<h2 class="card-title">…</h2><p>…</p>');
223
224
 
224
225
  // Pattern 5: fine-grained binding (opt-in) — pass the signal/computed ITSELF
225
226
  // into a hole so a change updates ONLY that node (no render re-run, no
@@ -276,7 +277,7 @@ mount(
276
277
  | ` html` ``: partial attribute values are not supported | In `kerfjs/html` templates a hole must be the COMPLETE attribute value | Build the full string first (``class="${`a ${b}`}"``), or bind `class="${computed(() => `a ${b.value}`)}"` for a reactive one |
277
278
  | An `each()` list's rows lose focus / scroll / typing state when an unrelated conditional list above them appears or disappears (kerf warns about this in dev) | Lists without a key are identified by their 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 |
278
279
  | 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>`) |
279
- | 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 the HTML it rendered at its old index | Fold the index into the memo key so displaced rows re-render: `each(items, (it, i) => …, { cacheKey: (_, i) => i })` (add `key` if used). Opt-in dev warn: `KERF_DEV_WARN_STALE_INDEX=1` |
280
+ | 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 the HTML it rendered at its old index | Fold the index into the memo key so displaced rows re-render: `each(items, (it, i) => …, { cacheKey: (_, i) => i })` (add `key` if used). Opt-in dev warn: `enableWarnings({ staleIndex: true })` (or `KERF_DEV_WARN_STALE_INDEX=1` under Node) |
280
281
 
281
282
  ## Workflow guidance
282
283
 
package/dist/actions.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { attr } from './chunk-MK42GLPV.js';
2
- import { delegate } from './chunk-E5R5GNKE.js';
2
+ import { delegate } from './chunk-X6OQ2HTR.js';
3
3
 
4
4
 
5
5
  // src/actions.ts
@@ -14,8 +14,8 @@ function delegateActions(root, eventType, table, options) {
14
14
  eventType,
15
15
  `[${attrName}]`,
16
16
  (event, el) => {
17
- const handler = table[el.getAttribute(attrName)];
18
- if (handler !== void 0) handler(event, el);
17
+ const value = el.getAttribute(attrName);
18
+ if (Object.hasOwn(table, value)) table[value](event, el);
19
19
  },
20
20
  options
21
21
  );
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/actions.ts"],"names":[],"mappings":";;;;;AAmCA,IAAM,mBAAA,GAAsB,aAAA;AAkBrB,SAAS,OAAyB,KAAA,EAAsC;AAC7E,EAAA,OAAO,IAAA,CAAK,qBAAqB,KAAK,CAAA;AACxC;AAwBO,SAAS,eAAA,CACd,IAAA,EACA,SAAA,EACA,KAAA,EACA,OAAA,EACY;AACZ,EAAA,MAAM,QAAA,GAAW,SAAS,IAAA,IAAQ,mBAAA;AAClC,EAAA,OAAO,QAAA;AAAA,IACL,IAAA;AAAA,IACA,SAAA;AAAA,IACA,IAAI,QAAQ,CAAA,CAAA,CAAA;AAAA,IACZ,CAAC,OAAO,EAAA,KAAO;AAEb,MAAA,MAAM,OAAA,GAAU,KAAA,CAAM,EAAA,CAAG,YAAA,CAAa,QAAQ,CAAW,CAAA;AACzD,MAAA,IAAI,OAAA,KAAY,MAAA,EAAW,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAA;AAAA,IAC9C,CAAA;AAAA,IACA;AAAA,GACF;AACF","file":"actions.js","sourcesContent":["/**\n * `kerfjs/actions` — the delegated action-table helper.\n *\n * The most-reinvented idiom across real kerf apps: one table of `data-action`\n * attribute specs used as the single source of truth for BOTH the JSX attribute\n * and the delegate selector, plus a hand-rolled `switch (dataset.action)`\n * dispatcher. This subpath blesses it as two thin helpers over the existing\n * `attr()` + `delegate()` — it does NOT replace them.\n *\n * import { action, delegateActions } from 'kerfjs/actions';\n *\n * const A = {\n * select: action('select-file'),\n * remove: action('remove-file'),\n * };\n *\n * // JSX — spread the attr (rename-safe; no hardcoded attribute name):\n * // <button {...A.select.attrs} data-id={id}>…</button>\n *\n * // Wire the whole table with ONE delegated listener; returns a disposer:\n * const dispose = delegateActions(root, 'click', {\n * [A.select.value]: (_e, el) => selectFile(el.getAttribute('data-id')),\n * [A.remove.value]: (_e, el) => removeFile(el.getAttribute('data-id')),\n * });\n *\n * Contract: `delegateActions` returns a `() => void` disposer and holds no\n * per-instance state — the same shape as `delegate()`, which it builds on (so\n * it inherits the single-listener dispatch and the capture auto-promotion for\n * well-known non-bubbling event types). One event type per call, mirroring\n * `delegate()`; collect the disposers for a root that needs several.\n */\nimport { attr, type AttrSpec } from './attr.js';\nimport { delegate, type DelegateOptions } from './delegate.js';\n\n/** The attribute an action table keys on by default. */\nconst DEFAULT_ACTION_ATTR = 'data-action';\n\n/**\n * A handler in a {@link delegateActions} table. Receives the DOM event and the\n * matched element (walk-up `closest()` match by default) — the same shape as a\n * `delegate()` handler.\n */\nexport type ActionHandler<E extends Element = Element> = (\n event: Event,\n el: E,\n) => void;\n\n/**\n * `action(value)` — an {@link AttrSpec} on `data-action`. A thin specialization\n * of `attr('data-action', value)`: spread its `.attrs` in JSX and use its\n * `.value` as the handler-table key, so the action name lives in exactly one\n * place and can't drift between the markup and the dispatcher.\n */\nexport function action<V extends string>(value: V): AttrSpec<'data-action', V> {\n return attr(DEFAULT_ACTION_ATTR, value);\n}\n\n/** Options for {@link delegateActions}. Extends {@link DelegateOptions}. */\nexport interface DelegateActionsOptions extends DelegateOptions {\n /**\n * The attribute the table keys on. Default `'data-action'`. Override it only\n * if you also author the specs with `attr(yourName, …)` instead of `action()`.\n */\n attr?: string;\n}\n\n/**\n * Wire a whole table of action handlers with ONE delegated listener.\n *\n * On `eventType`, the nearest element carrying the action attribute (walk-up\n * `closest()` by default; pass `{ match: 'direct' }` for an exact-element match)\n * is looked up in `table` by its attribute value, and the matching handler\n * runs. An element whose action is absent from the table is ignored — the same\n * behavior as a `switch (dataset.action)` with no matching `case`.\n *\n * Returns a `() => void` disposer. One event type per call (the smallest\n * surface, mirroring `delegate()`); collect the disposers when a root needs\n * several event types.\n */\nexport function delegateActions<E extends Element = Element>(\n root: HTMLElement,\n eventType: string,\n table: Readonly<Record<string, ActionHandler<E>>>,\n options?: DelegateActionsOptions,\n): () => void {\n const attrName = options?.attr ?? DEFAULT_ACTION_ATTR;\n return delegate<E>(\n root,\n eventType,\n `[${attrName}]`,\n (event, el) => {\n // `el` matched `[${attrName}]`, so the attribute is always present.\n const handler = table[el.getAttribute(attrName) as string];\n if (handler !== undefined) handler(event, el);\n },\n options,\n );\n}\n"]}
1
+ {"version":3,"sources":["../src/actions.ts"],"names":[],"mappings":";;;;;AAmCA,IAAM,mBAAA,GAAsB,aAAA;AAkBrB,SAAS,OAAyB,KAAA,EAAsC;AAC7E,EAAA,OAAO,IAAA,CAAK,qBAAqB,KAAK,CAAA;AACxC;AAwBO,SAAS,eAAA,CACd,IAAA,EACA,SAAA,EACA,KAAA,EACA,OAAA,EACY;AACZ,EAAA,MAAM,QAAA,GAAW,SAAS,IAAA,IAAQ,mBAAA;AAClC,EAAA,OAAO,QAAA;AAAA,IACL,IAAA;AAAA,IACA,SAAA;AAAA,IACA,IAAI,QAAQ,CAAA,CAAA,CAAA;AAAA,IACZ,CAAC,OAAO,EAAA,KAAO;AAEb,MAAA,MAAM,KAAA,GAAQ,EAAA,CAAG,YAAA,CAAa,QAAQ,CAAA;AAKtC,MAAA,IAAI,MAAA,CAAO,OAAO,KAAA,EAAO,KAAK,GAAG,KAAA,CAAM,KAAK,CAAA,CAAE,KAAA,EAAO,EAAE,CAAA;AAAA,IACzD,CAAA;AAAA,IACA;AAAA,GACF;AACF","file":"actions.js","sourcesContent":["/**\n * `kerfjs/actions` — the delegated action-table helper.\n *\n * The most-reinvented idiom across real kerf apps: one table of `data-action`\n * attribute specs used as the single source of truth for BOTH the JSX attribute\n * and the delegate selector, plus a hand-rolled `switch (dataset.action)`\n * dispatcher. This subpath blesses it as two thin helpers over the existing\n * `attr()` + `delegate()` — it does NOT replace them.\n *\n * import { action, delegateActions } from 'kerfjs/actions';\n *\n * const A = {\n * select: action('select-file'),\n * remove: action('remove-file'),\n * };\n *\n * // JSX — spread the attr (rename-safe; no hardcoded attribute name):\n * // <button {...A.select.attrs} data-id={id}>…</button>\n *\n * // Wire the whole table with ONE delegated listener; returns a disposer:\n * const dispose = delegateActions(root, 'click', {\n * [A.select.value]: (_e, el) => selectFile(el.getAttribute('data-id')),\n * [A.remove.value]: (_e, el) => removeFile(el.getAttribute('data-id')),\n * });\n *\n * Contract: `delegateActions` returns a `() => void` disposer and holds no\n * per-instance state — the same shape as `delegate()`, which it builds on (so\n * it inherits the single-listener dispatch and the capture auto-promotion for\n * well-known non-bubbling event types). One event type per call, mirroring\n * `delegate()`; collect the disposers for a root that needs several.\n */\nimport { attr, type AttrSpec } from './attr.js';\nimport { delegate, type DelegateOptions } from './delegate.js';\n\n/** The attribute an action table keys on by default. */\nconst DEFAULT_ACTION_ATTR = 'data-action';\n\n/**\n * A handler in a {@link delegateActions} table. Receives the DOM event and the\n * matched element (walk-up `closest()` match by default) — the same shape as a\n * `delegate()` handler.\n */\nexport type ActionHandler<E extends Element = Element> = (\n event: Event,\n el: E,\n) => void;\n\n/**\n * `action(value)` — an {@link AttrSpec} on `data-action`. A thin specialization\n * of `attr('data-action', value)`: spread its `.attrs` in JSX and use its\n * `.value` as the handler-table key, so the action name lives in exactly one\n * place and can't drift between the markup and the dispatcher.\n */\nexport function action<V extends string>(value: V): AttrSpec<'data-action', V> {\n return attr(DEFAULT_ACTION_ATTR, value);\n}\n\n/** Options for {@link delegateActions}. Extends {@link DelegateOptions}. */\nexport interface DelegateActionsOptions extends DelegateOptions {\n /**\n * The attribute the table keys on. Default `'data-action'`. Override it only\n * if you also author the specs with `attr(yourName, …)` instead of `action()`.\n */\n attr?: string;\n}\n\n/**\n * Wire a whole table of action handlers with ONE delegated listener.\n *\n * On `eventType`, the nearest element carrying the action attribute (walk-up\n * `closest()` by default; pass `{ match: 'direct' }` for an exact-element match)\n * is looked up in `table` by its attribute value, and the matching handler\n * runs. An element whose action is absent from the table is ignored — the same\n * behavior as a `switch (dataset.action)` with no matching `case`.\n *\n * Returns a `() => void` disposer. One event type per call (the smallest\n * surface, mirroring `delegate()`); collect the disposers when a root needs\n * several event types.\n */\nexport function delegateActions<E extends Element = Element>(\n root: HTMLElement,\n eventType: string,\n table: Readonly<Record<string, ActionHandler<E>>>,\n options?: DelegateActionsOptions,\n): () => void {\n const attrName = options?.attr ?? DEFAULT_ACTION_ATTR;\n return delegate<E>(\n root,\n eventType,\n `[${attrName}]`,\n (event, el) => {\n // `el` matched `[${attrName}]`, so the attribute is always present.\n const value = el.getAttribute(attrName) as string;\n // Own entries only: the table is an ordinary object, so a bare\n // `table[value]` would resolve Object.prototype names (`__proto__`,\n // `constructor`, `toString`, `hasOwnProperty`, …) and dispatch or throw\n // on markup that names no action in the table.\n if (Object.hasOwn(table, value)) table[value](event, el);\n },\n options,\n );\n}\n"]}
@@ -1,4 +1,4 @@
1
- export { ARRAY_SIGNAL_BRAND, ArraySignal, arraySignal } from './chunk-ELXVRKY2.js';
1
+ export { ARRAY_SIGNAL_BRAND, ArraySignal, arraySignal } from './chunk-H7WTRM6F.js';
2
2
 
3
3
 
4
4
 
package/dist/async.js CHANGED
@@ -1,4 +1,4 @@
1
- import { signal } from './chunk-Y2FOYPBV.js';
1
+ import { signal } from './chunk-IHMDBBUJ.js';
2
2
 
3
3
 
4
4
  // src/async.ts