kerfjs 5.0.0-beta.56 → 5.0.0-beta.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,338 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [5.0.0] - 2026-09-29
10
+
11
+ - **`ui-public-boundaries` inspects `kerfjs/html` tagged templates.** The
12
+ no-build `html` template writes the same markup as JSX, so its static
13
+ `class` attributes now carry `KUI-L101` (private `kui-*` anatomy) and
14
+ `KUI-L103` (a component's anatomy on your own element):
15
+ ``html`<aside class="kui-pane">` `` is reported with `Pane` to render, and a
16
+ plain ``html`<div class="kui-content-item">` `` with `ContentItem`, while a
17
+ `<ul>` carrier keeps the placeable class. The template's static parts are
18
+ read by the runtime's own rules; a class a hole supplies (`class=${cls}`)
19
+ is unknown and skipped.
20
+
21
+ - **`KUI-L103`'s "render X instead" answer has one implementation.**
22
+ `@kerfjs/ui` ships `ai/component-class-owners.cjs`, the rule that decides
23
+ which cataloged classes are a component's anatomy and which export renders
24
+ each. `eslint-plugin-kerfjs`'s `ui-public-boundaries` loads it beside
25
+ `application-ui-profile-sync.cjs`, and the generated `ai/components/*.md`
26
+ pages use it too, so the lint message and the component reference can no
27
+ longer name different components (a test pins their agreement over the whole
28
+ shipped catalog). A vendored `profileContractPath` directory must now also
29
+ hold `component-class-owners.cjs`; an `@kerfjs/ui` without it reports
30
+ `KUI-L090`.
31
+
32
+ - **A delegated click no longer re-fires on a target the re-render recycled.**
33
+ A delegated handler that writes a signal makes `mount()` re-render
34
+ synchronously, inside the same event dispatch; when the morph recycled the
35
+ clicked element in place into a control with a different `data-action`,
36
+ delegates registered later for the same event matched the recycled element
37
+ and fired too (a `request` click immediately ran `cancel` and undid itself).
38
+ `delegate()`, `delegateCapture()`, and `kerfjs/actions`' `delegateActions()`
39
+ now resolve every delegate's match against the DOM as it stood when the
40
+ event reached the first kerf delegate listener, like per-element listeners:
41
+ a delegate that matched at dispatch start still fires (unless an earlier
42
+ handler removed its element), one that matches only after a re-render does
43
+ not, and `delegateActions()` dispatches by the action value the element
44
+ carried at dispatch start. `mount()` stays synchronous. The
45
+ wrap-it-in-a-distinct-element workaround is no longer needed. Cost: about
46
+ 0.11 KB min+gzip, which moves the advertised core size from ~12 KB to
47
+ ~13 KB.
48
+
49
+ - **Component packages declare `placeableClasses`, and `KUI-L103` covers
50
+ their anatomy classes.** `create-kerf-component`'s `kerf.components.json`
51
+ accepts optional `boundaries.placeableClasses` (a subset of
52
+ `publicClasses`) and `boundaries.rootElement`, validates them, and copies
53
+ them into the generated `component-composition.json`.
54
+ `eslint-plugin-kerfjs`'s `ui-public-boundaries` now checks every class the
55
+ application or third-party catalogs in `.kerf-ui-profile.json` declare, not
56
+ only `kui-*` names: `<div class="acme-meter">` is reported with the
57
+ package's component to render (`Meter`), while its placeable classes stay
58
+ allowed. The scaffold template's README explains the field, and
59
+ `@kerfjs/ui/ai/skill.md` (1.50.0) applies the rule to package entries.
60
+
61
+ - **`eslint-plugin-kerfjs` `ui-public-boundaries` reports a plain
62
+ `<div class="kui-content-item">` as `ContentItem` recreated (`KUI-L103`).**
63
+ The `kui-content-item` classes stay placeable on another carrier (a `<ul>`, a
64
+ `Text`, a control-cluster `<footer>`), but a plain `<div>` carrying them is
65
+ exactly what `ContentItem` renders; the diagnostic now says to render
66
+ `ContentItem`. The `@kerfjs/ui` catalogs gain an additive `rootElement`
67
+ field (selection entry and composition `boundaries.rootElement`) naming the
68
+ element a component renders around its own placeable classes, and
69
+ `check:guidance` derives its repository check from the same fields, so the
70
+ package check and the downstream diagnostic cannot drift. The rule also
71
+ inspects the static class names of a template-literal `class` /
72
+ `className` value.
73
+
74
+ - **`eslint-plugin-kerfjs` `ui-public-boundaries` reports a component's
75
+ anatomy class written onto an application-owned element (`KUI-L103`).**
76
+ `<div class="kui-toolbar">`, `<aside class="kui-pane">`, or
77
+ `<wa-dropdown class="kui-popup-menu">` recreates a component by class and
78
+ inherits (and invites restyling of) its CSS; the diagnostic names the
79
+ component to render instead (`Toolbar`, `Pane`, `PopupMenu`). The
80
+ `@kerfjs/ui` catalogs gain `placeableClasses` (selection entry field and
81
+ composition `boundaries.placeableClasses`) naming the public classes an
82
+ application may place itself — the layout utilities, `kui-app-root`, the
83
+ `kui-content-item` geometry on a non-div carrier, and Web Awesome
84
+ `hide-actions` — and `check:catalog` requires every class-only entry to list
85
+ its classes there. A component's own element (`<Toolbar className="kui-toolbar">`)
86
+ is not reported. The README pane example and `@kerfjs/ui/ai/skill.md`
87
+ (1.47.0) now render `Pane` instead of writing its classes, and each generated
88
+ `ai/components/*.md` page states which classes are placeable and which
89
+ component renders the rest.
90
+
91
+ - **`kerf-ui-analyze` retires `KUI-L005` ("forced component dimension").**
92
+ `KUI-L019` already reports any application rule whose subject is a cataloged
93
+ component, dimensions included, so `.kui-pane { width: 300px }` was reported
94
+ twice (an error and a review finding). L005's only other shape, a public
95
+ class as an ancestor (`.kui-pane .app-sidebar { width: 240px }`), sizes the
96
+ application's own element and is allowed. The analyzer no longer emits it;
97
+ the id stays in the diagnostic registry (now listing it under `retired`) so
98
+ existing profile exceptions that name it still load.
99
+
100
+ - **`KUI-L301` now reports every `Discouraged` Web Awesome element rendered
101
+ directly.** The `@kerfjs/ui` package-default application profile avoids all
102
+ fifteen Discouraged entries (Dropdown/Dropdown Item → `PopupMenu`,
103
+ Select/Option → `Select`, Tab Group/Tab/Tab Panel → `TabBar`, Icon →
104
+ `LucideIcon`, Split Panel → `ResizableRegion`, Button Group →
105
+ `SegmentedControl`, Tree/Tree Item → `List`, and Animated Image, Comparison,
106
+ and Zoomable Frame → `ContentItem`), each with a rationale, and
107
+ `eslint-plugin-kerfjs` `ui-preferences` (and `kerf-ui-doctor`, which runs it)
108
+ now resolves a custom-element JSX tag such as `<wa-dropdown>` to its catalog
109
+ entry, not only an imported component. A declared component's own source
110
+ stays exempt because it is the wrapper that owns the element. A test keeps
111
+ the profile's avoid set equal to the catalog's Discouraged set in both
112
+ directions and lints the UX demo, recipes, and catalog shell clean;
113
+ `@kerfjs/ui/ai/skill.md` (1.46.0) names the diagnostic.
114
+
115
+ - **New (`@kerfjs/ui`): `NavStack` and `TabScaffold` take `chromeDividers`
116
+ (`'scroll' | 'always' | 'none'`), like `Pane`.** `scroll` (the default) keeps
117
+ today's behavior: the stack's top-chrome and bottom-toolbar lines and the
118
+ scaffold's bar line follow the scroll once `wireScrollDividers` is installed.
119
+ `always` draws them permanently without the wiring; `none` never draws them,
120
+ even when wired. The top `Toolbar`'s own `toolbarConfig.dividerSides` is
121
+ unaffected. `SplitView` forwards it to its compact stack through
122
+ `compactStack.chromeDividers`.
123
+
124
+ - **Fix (`@kerfjs/ui`): a `Pane` that is the only content of a `NavStack` view
125
+ or a `TabScaffold` scene fills it.** It used to size to its content, so the
126
+ view scrolled the whole Pane and its header scrolled away. Now the Pane's own
127
+ content scrolls under its pinned header and footer, the Pane's slots take the
128
+ safe-area edges the view or scene still reaches, and scroll dividers key on
129
+ the Pane's content. The rule lives in `pane.css`, as the Workbench-region fill
130
+ does.
131
+
132
+ - **The `@kerfjs/ui` suite-v3 AI-regression protocol test runs in every gate
133
+ again.** `ui/tests/integration/ai-regressions-v3.test.ts` was selected by no
134
+ package script, so neither `npm --prefix ui run check` nor CI executed it. It
135
+ now runs as `test:ai-regressions` inside the package's `npm test`, and a new
136
+ unit guard fails when any file under `ui/tests` is selected by no script
137
+ reachable from `check` or `test:e2e`.
138
+
139
+ - **Importing `@kerfjs/ui/catalog` or `@kerfjs/ui/workbench` now ships every
140
+ stylesheet those composites render.** `catalog`, `workbench`, `nav-stack`,
141
+ `split-view`, `tab-scaffold`, and `collapsible-panel` gain a CSS-aware
142
+ `browser` export condition whose generated wrapper imports the foundation, the
143
+ CSS of each internally rendered component (Pane, Toolbar, ToolbarText, List,
144
+ ListItem, ListInsetText, Row, PopupMenu, …) in dependency order, and the
145
+ composite's own CSS. Apps no longer hand-import `pane.css`, and a Catalog
146
+ sidebar's styling no longer depends on whether the app happens to import
147
+ `ListInsetText` elsewhere. The list is derived from the source import graph
148
+ (now following parent-relative imports), the `import` condition stays
149
+ CSS-free, and the companion `.css` subpaths remain for pipelines without the
150
+ `browser` condition. `@kerfjs/ui/ai/skill.md` is 1.46.0.
151
+
152
+ - **The `@kerfjs/ui` AI guidance now states every maintainer design rule
153
+ explicitly.** An audit of the package's tickets folded the missing rules into
154
+ `@kerfjs/ui/ai/skill.md` (1.45.0), `llms.txt`, and the design docs: prefer a
155
+ Kerf UI component over a Web Awesome component over a raw tag and never render
156
+ a `Discouraged` Web Awesome element directly (only through the Kerf component
157
+ that wraps it); nothing wraps inside a `ToolbarControlGroup` and a
158
+ `ToolbarText` is one ellipsized line unless `wrap`/`maxLines` is set; a
159
+ `Select` or `PopupMenu` trigger drops its focus ring while its popup is open
160
+ and the ring follows the control's shape (never add ring CSS); dividers earn
161
+ their place (`dividerSides=""` over self-spacing content or between a pane's
162
+ header and its list, never padded around); a control sits near where its
163
+ effect appears; and text aligns to text and icons to icons — a border is
164
+ never the alignment anchor. The ToolbarControlGroup catalog entry no longer
165
+ points at a hand-written `wa-dropdown` trigger, and the bundled kerf app
166
+ configs (`ai/skill.md` 1.25.0, `ai/cursorrules` 1.23.0) name the List family
167
+ and carry the "components own their styles; configure, never override" rule.
168
+
169
+ - **Applications and component packages are held to the component-ownership
170
+ rules `@kerfjs/ui` enforces internally: components own their styles and are
171
+ configured, never overridden.** `kerf-ui-analyze` and `kerf-ui-doctor` add
172
+ four error diagnostics (review findings under `--adoption`):
173
+ - `KUI-L019` — a CSS rule whose subject is another package's cataloged
174
+ component: a public class (`.kui-toolbar`, `.acme-meter`), its
175
+ `[data-component]` root, or a Web Awesome tag the package themes
176
+ (`wa-button`). Your own element in a component's context
177
+ (`.kui-toolbar > .my-widget`), keying on its state inside `:has()`, and
178
+ setting its public tokens stay allowed.
179
+ - `KUI-L020` — reading or writing another component's private `--_*`
180
+ variable (`--_kui-list-gap`).
181
+ - `KUI-L021` — overriding a token a typed prop sets (`--kui-list-gap` is
182
+ `<List gap>`); the message quotes the prop's example.
183
+ - `KUI-L022` — a class placed on a component's root (`<Toolbar
184
+ className="header" />`) that the application then styles.
185
+
186
+ Each message names the component's configuration (typed props, public
187
+ tokens, variants) and ends by routing a need with no prop to a component-gap
188
+ report instead of an override. Ownership is per package, from the nearest
189
+ `package.json`, so a component package styling its own components — including
190
+ in a Kerf UI parent's context — passes, and third-party catalogs declared in
191
+ `.kerf-ui-profile.json` get the same protection. `eslint-plugin-kerfjs` adds
192
+ `ui-component-ownership` (in `recommended-ui` and `strict-ui`, `error`),
193
+ reporting `KUI-L020` / `KUI-L021` in style strings, style objects, and
194
+ `style.setProperty()`. The `create-kerf-component` scaffold now ships a
195
+ `counter.css` that styles only Counter (configured by a typed `size` prop and
196
+ a `--kerf-counter-gap` token, with context named after Counter and a
197
+ Toolbar-context rule in its own stylesheet), plus `npm run check:styles`
198
+ (`kerf-ui-analyze`, from a new `@kerfjs/ui` dev dependency) in
199
+ `prepublishOnly`.
200
+
201
+ - **Behavior change (`@kerfjs/ui`): `NavStack` and `TabScaffold` chrome
202
+ dividers follow the scroll.** The `NavStack` bottom toolbar's and the
203
+ `TabScaffold` bar's top borders are no longer permanent. With `wireScrollDividers`
204
+ installed, a `NavStack`'s top chrome draws a line along its bottom edge only
205
+ while the active view's content is scrolled beneath it, its bottom toolbar a
206
+ line along its top edge only while more content lies below, and a
207
+ `TabScaffold` bar a line along its top edge only while the active scene's
208
+ content continues below it — none when the content fits. The line follows
209
+ the active view across a push or pop and the active scene across a tab
210
+ switch, and keys on whichever element actually scrolls: the view or scene
211
+ itself, or, through a sole child with no chrome of its own on that edge, a
212
+ `Pane`'s content or a nested `NavStack`'s active view (a sole `Pane`'s own
213
+ header or footer draws that boundary instead). Each layout draws the line
214
+ from its own stylesheet (an overlaid pseudo-element on the stack's chrome; the
215
+ bottom toolbar and bar keep their 1px top border, transparent until shown),
216
+ so nothing moves; the wiring
217
+ writes only its `data-scroll-overflow` / `data-scroll-divider` state. Without
218
+ the wiring those edges draw no line.
219
+
220
+ - **Behavior change (`@kerfjs/ui`): toolbars no longer draw a divider by
221
+ default; dividers between pinned chrome and scrolling content follow the
222
+ scroll.** `Toolbar`'s `dividerSides` now defaults to `''` (was `'b'`), and
223
+ the `Workbench` (`mainToolbar`, `mainBottomToolbar`, panel toolbars, and the
224
+ `mainHeader` / `mainFooter` lists), `CollapsiblePanel`, and `Catalog`
225
+ toolbars no longer place dividers of their own. Instead a `Pane` draws one
226
+ line under its header and one over its footer, wherever that chrome ends:
227
+ the header's only while content is scrolled beneath it, the footer's only
228
+ while more content lies below, and neither when the content fits. The new
229
+ `@kerfjs/ui/wire-scroll-dividers` subpath's `wireScrollDividers(root, {
230
+ targets? })` reports that state — call it once at the app root and keep its
231
+ disposer (`wireCatalog` installs it for its own root). It also marks each
232
+ side of a `TabBar` strip with tabs scrolled out of view, and lets a
233
+ `Toolbar` or `List` named as chrome in `targets` draw the divider facing an
234
+ app-owned scroller. The wiring only writes the `data-scroll-overflow` /
235
+ `data-scroll-divider` state attributes; each component draws its own line,
236
+ and no state moves the chrome, the content, or a tab. `Pane` gains
237
+ `chromeDividers` (`scroll` default, `always`, `none`; also in `PaneConfig`,
238
+ so `mainPane` and a panel's `pane` forward it) and TabBar the
239
+ `--kui-tab-bar-divider-color` token. To keep a permanent line, set
240
+ `chromeDividers: "always"` on the pane or an explicit `dividerSides`.
241
+
242
+ - **`@kerfjs/ui`: `Pane`, `NavStack`, `SplitView`, and `TabScaffold` fill a
243
+ Workbench region from their own stylesheets.** As a Workbench main area's
244
+ or panel's only child, each sets its own `height: 100%` in that context,
245
+ which the Workbench marks on its regions (`data-workbench-main`, and the new
246
+ `data-workbench-panel-content` on each panel's content); `workbench.css` no
247
+ longer sizes another component's root (it still fills a nested Workbench).
248
+ Rendering is unchanged.
249
+
250
+ - **`@kerfjs/ui`: layouts name the covered-floating-controls context after
251
+ themselves, and no longer write `FloatingToolbar`'s inset token.** Workbench,
252
+ CollapsiblePanel, and ResizableRegion each provide their own private
253
+ `--_kui-<layout>-covered` context in place of the shared
254
+ `--_kui-floating-covered` flag; `FloatingToolbar` and every restore corner
255
+ read all three. A restore corner no longer sets
256
+ `--kui-floating-toolbar-inset: 0px`; `FloatingToolbar` zeroes its own inset
257
+ inside a restore corner (a toolbar's own `inset` still wins). Rendering is
258
+ unchanged. One edge case changes: a floating control inside an open overlay
259
+ of one layout type that is itself covered by another layout type's open
260
+ overlay (for example, a ResizableRegion overlay in a Workbench work area
261
+ under an open rail overlay) now stays hidden.
262
+
263
+ - **`@kerfjs/ui`: `ListHeader`, `NavStack`, `Select`, and `StateBanner` no
264
+ longer restyle a composed child through a class on its root.** Each child
265
+ now styles itself: `Text` sets a `ListHeader` label from `text.css`, `Badge`
266
+ spaces a `Select` count and a `StateBanner` badge from `badge.css`, and a
267
+ `NavStack` title uses `ToolbarText`'s new `fill` option. Rendering is
268
+ unchanged. `ToolbarText` gains `fill` (grow into the free space of its
269
+ toolbar zone and truncate there). The `kui-list-header__label`,
270
+ `kui-nav-stack__title`, `kui-select__count`, and `kui-state-banner__badge`
271
+ classes are removed; select a ListHeader label with
272
+ `.kui-list-header__title > .kui-text` and a NavStack title with
273
+ `.kui-toolbar-text[data-fill="true"]` if a test needs to find them.
274
+
275
+ - **`@kerfjs/ui` ships a markdown component reference for AI assistants.**
276
+ `@kerfjs/ui/ai/components/README.md` indexes every catalog entry in one line,
277
+ and `@kerfjs/ui/ai/components/<id>.md` gives each entry a short page: when to
278
+ use and avoid it, imports and CSS/registration side effects, a link to its
279
+ exact props, legal parents and zones (JSX prop and child count), app- and
280
+ wiring-owned state, geometry, accessibility obligations, its styling boundary
281
+ (configure through props; do not override internals), and its diagnostics.
282
+ `llms.txt` and the shipped skill now send assistants there instead of to the
283
+ 263 KB `component-catalog.json` and 221 KB `component-composition.json`,
284
+ which remain the canonical sources for tools. The pages are generated from
285
+ those two files and a package check fails when they drift.
286
+
287
+ - **Breaking (`@kerfjs/ui` and `create-kerf-component`): the composition
288
+ catalog files are renamed so they no longer read as a "v2" of the component
289
+ catalog.** The composition catalog is a generated composition layer beside
290
+ `component-catalog.json`, not a newer version of it. Update any import,
291
+ `$schema`, `$ref`, or `.kerf-ui-profile.json` catalog location that names an
292
+ old path:
293
+
294
+ | Old path | New path |
295
+ | -------------------------------------------------------------- | --------------------------------------------------------------- |
296
+ | `@kerfjs/ui/ai/component-catalog-v2.json` | `@kerfjs/ui/ai/component-composition.json` |
297
+ | `@kerfjs/ui/ai/component-catalog-v2.schema.json` | `@kerfjs/ui/ai/component-composition.schema.json` |
298
+ | `@kerfjs/ui/ai/component-catalog-v2.d.ts` | `@kerfjs/ui/ai/component-composition.d.ts` |
299
+ | `@kerfjs/ui/ai/component-catalog-v2-overrides.json` | `@kerfjs/ui/ai/component-composition-rules.json` |
300
+ | `@kerfjs/ui/ai/component-catalog-extension-v2.schema.json` | `@kerfjs/ui/ai/component-composition-extension.schema.json` |
301
+ | `@kerfjs/ui/ai/component-catalog-extension-v2.d.ts` | `@kerfjs/ui/ai/component-composition-extension.d.ts` |
302
+ | `@kerfjs/ui/docs/examples/component-catalog-extension-v2.json` | `@kerfjs/ui/docs/examples/component-composition-extension.json` |
303
+ | scaffolded package `component-catalog-v2.json` | scaffolded package `component-composition.json` |
304
+
305
+ The exported types follow: `ComponentCatalogV2` → `ComponentComposition`,
306
+ `CatalogCompositionEntryV2` → `CatalogCompositionEntry`,
307
+ `ConsumerComponentCatalogV2` → `ConsumerComponentComposition`, and
308
+ `ConsumerCatalogEntryV2` → `ConsumerCompositionEntry`. In the repository,
309
+ `npm run catalog:v2:sync` is now `npm run composition:sync`
310
+ (`catalog:sync` still runs both generators). The file contents and their
311
+ `schemaVersion` are unchanged.
312
+
313
+ - **Breaking (`@kerfjs/ui`, `create-kerf-component`, and `kerfjs setup`): the
314
+ composition catalog's own fields no longer read as a "v2" of the component
315
+ catalog.** In `component-composition.json` (and any consumer composition
316
+ catalog), `"schemaVersion": 2` is now `"schemaVersion": 1` — the composition
317
+ format's own first version, independent of `component-catalog.json`'s — and
318
+ `compatibility.v1Catalog` is now `compatibility.componentCatalog`. A
319
+ scaffolded package's `kerf.components.json` renames its `v1Catalog` field to
320
+ `componentCatalog` the same way. Regenerate scaffolded catalogs
321
+ (`npm run catalog:generate`), and change any `.kerf-ui-profile.json` `composition`
322
+ location from `"schemaVersion": 2` to `"schemaVersion": 1`; a catalog still
323
+ carrying the old fields fails validation with a message naming the rename.
324
+ The `ComponentComposition` type follows (`schemaVersion: 1`,
325
+ `compatibility.componentCatalog`).
326
+
327
+ - **`@kerfjs/ui` `NavStack` bars use the standard `Toolbar` padding.** The
328
+ navigation bar dropped its toolbar's block padding to stay 44px tall, so its
329
+ title and controls touched the bar's edges. It now keeps the 8px above and
330
+ below that every other toolbar has (60px plus any top safe-area inset).
331
+
332
+ - **`@kerfjs/ui` multiline list rows break long camelCase names at word
333
+ boundaries.** A string label in a `multiline` `ListItem` or `ListActionRow`
334
+ (such as a Catalog sidebar entry) gets `<wbr>` break opportunities at its
335
+ camelCase and acronym boundaries, so `QuickTicketComposer` wraps as
336
+ `QuickTicket` / `Composer` instead of `QuickTicketComp` / `oser`. Breaking
337
+ mid-word remains the fallback for a single word that is still too wide.
338
+ - **`wbr` is a typed JSX intrinsic element.** The runtime already rendered it
339
+ as a void element; `JSX.IntrinsicElements` now declares it.
340
+
9
341
  - **`@kerfjs/ui` `Badge` has a text-free dot, and `TabScaffold` tabs can show
10
342
  it.** `Badge` takes `size="dot"`: an 8px solid circle in the badge's tone,
11
343
  with no children, appearance, or shape. Because a dot has no text, its props
@@ -2135,6 +2467,8 @@ font="monospace"` and Web Awesome code text match.
2135
2467
  - Added the lockstep `@kerfjs/ui` package: accessible toolbar, menu, tab, header, resize, select, and feedback primitives; semantic CSS tokens and browser component subpaths that automatically include only reachable component styles; CSS-free root/SSR entries and an optional complete stylesheet; opt-in Web Awesome registration; AI-oriented docs; a production-backed UX catalog; bundle/unit/three-engine browser gates; and a least-privilege release workflow.
2136
2468
  - The virtual-list demo animation opens on the full app again (header, the "N in the DOM" badge, and the list) before the scroll; the list now scrolls inside its own box while the header stays put (previously the whole frame was a full-bleed list strip), holds at row 0 before gliding instead of starting a few rows down, and the loop stays in phase on every pass. Captured with domotion-svg 0.28.2.
2137
2469
 
2470
+ - @kerfjs/ui panel toolbars now place groups in leading, center, and trailing zones; relocateOnCollapse keeps chosen groups available when a panel closes.
2471
+
2138
2472
  ## [4.4.1] - 2026-08-26
2139
2473
 
2140
2474
  - Overhauled the README landing page: new one-line tagline, npm/size/license/TypeScript-types badges, a hoisted "Quick start" (install + `tsconfig`) right under the hook, and a jump nav (Quick start · Why kerf · Quick tour · Docs). The counter example now shows `delegate()` wiring the click handler.
package/README.md CHANGED
@@ -12,14 +12,14 @@
12
12
 
13
13
  <p align="center">
14
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" />
15
+ <img src="https://img.shields.io/badge/min%2Bgzip-~13%20KB-brightgreen.svg" alt="~13 KB minified and gzipped" />
16
16
  <img src="https://img.shields.io/npm/l/kerfjs.svg" alt="MIT license" />
17
17
  <img src="https://img.shields.io/badge/types-included-3178c6.svg" alt="TypeScript types included" />
18
18
  </p>
19
19
 
20
20
  ---
21
21
 
22
- > ~12 KB. No virtual DOM. No compiler. No magic.
22
+ > ~13 KB. No virtual DOM. No compiler. No magic.
23
23
  > Reactive UI that touches only the bytes that changed.
24
24
 
25
25
  ```ts
@@ -76,7 +76,7 @@ Write plain `.tsx` and build with your existing esbuild / Vite / tsup — no ext
76
76
 
77
77
  ## Why Kerf
78
78
 
79
- 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.
79
+ 1. **~13 KB in the browser.** ~13 KB minified + gzipped including `@preact/signals-core` (~13 KB with `arraySignal`). The optional setup CLI has its own npm dependencies; they stay out of the browser bundle. 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.
80
80
 
81
81
  2. **No virtual DOM, no compiler.** JSX → HTML strings → native diff. DevTools shows the real DOM because it _is_ the DOM.
82
82
 
@@ -88,7 +88,7 @@ Write plain `.tsx` and build with your existing esbuild / Vite / tsup — no ext
88
88
 
89
89
  6. **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.
90
90
 
91
- **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.
91
+ **Plus, nothing you don't ask for:** JSX typed against the HTML standard (not React's props) · a small public API with no hooks, lifecycle, or per-instance state · 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.
92
92
 
93
93
  ## When to use Kerf
94
94
 
@@ -223,8 +223,8 @@ Same algorithm `mount()` uses internally — `data-morph-skip`, `data-morph-skip
223
223
 
224
224
  ```html
225
225
  <script type="module">
226
- import { signal, mount, each } from "https://esm.sh/kerfjs@4";
227
- import { html } from "https://esm.sh/kerfjs@4/html";
226
+ import { signal, mount, each } from "https://esm.sh/kerfjs@5";
227
+ import { html } from "https://esm.sh/kerfjs@5/html";
228
228
 
229
229
  const items = signal([{ id: 1, label: "no build step" }]);
230
230
 
@@ -239,7 +239,7 @@ Same algorithm `mount()` uses internally — `data-morph-skip`, `data-morph-skip
239
239
  </script>
240
240
  ```
241
241
 
242
- Nothing is self-hosted — `kerfjs` is on npm, so every ESM CDN (esm.sh, jsDelivr, unpkg) mirrors it automatically. esm.sh works with a direct import as shown; jsDelivr / unpkg want an importmap so the internal `@preact/signals-core` import resolves. Pin to a major (`@4`, as shown — the latest `4.x`) rather than floating on `latest`, or an exact version (`@4.1.0`) for full reproducibility. Attribute names are written verbatim (`class`, not `className`), and holes are only legal in text positions or as a complete attribute value — anything ambiguous throws with an actionable message. See [`docs/6-jsx-runtime.md`](./docs/6-jsx-runtime.md) §6.11 (§6.11.1 for the full CDN / importmap recipes) — or the [live-poll example](https://brianwestphal.github.io/kerf/examples/complete/live-poll/), a complete app served exactly as authored: no bundler ever touches it.
242
+ Nothing is self-hosted — `kerfjs` is on npm, so every ESM CDN (esm.sh, jsDelivr, unpkg) mirrors it automatically. esm.sh works with a direct import as shown; jsDelivr / unpkg want an importmap so the internal `@preact/signals-core` import resolves. Pin to a major (`@5`, as shown) rather than floating on `latest`, or an exact version (`@5.0.0`) for full reproducibility. Attribute names are written verbatim (`class`, not `className`), and holes are only legal in text positions or as a complete attribute value — anything ambiguous throws with an actionable message. See [`docs/6-jsx-runtime.md`](./docs/6-jsx-runtime.md) §6.11 (§6.11.1 for the full CDN / importmap recipes) — or the [live-poll example](https://brianwestphal.github.io/kerf/examples/complete/live-poll/), a complete app served exactly as authored: no bundler ever touches it.
243
243
 
244
244
  ### Batteries when you need them: the companion subpaths
245
245
 
@@ -322,7 +322,7 @@ Install and JSX setup are in [Quick start](#quick-start) above. These companion
322
322
 
323
323
  ### Optional: `eslint-plugin-kerfjs`
324
324
 
325
- A companion ESLint plugin enforces kerf's hard rules at edit time. Eight rules total: four `error`-level AST rules catch hard-rule violations — inline JSX event handlers, missing `data-key` in `each()`, nested `mount()`, and global `JSX.IntrinsicElements` augmentation — and four `warn`-level rules cover delegate-disposer capture, `attr()` selector rename-safety, `raw()` XSS audit trails, and AI-assistant config hygiene. The plugin is AST-only (no parser-services dependency), so it works with any TypeScript-ESLint setup.
325
+ A companion ESLint plugin enforces kerf's hard rules at edit time. Its eight core rules cover inline JSX event handlers, missing `data-key` in `each()`, nested `mount()`, delegate-disposer capture, `attr()` selector rename-safety, `raw()` XSS audit trails, JSX augmentation, and AI-assistant config hygiene. Six additional UI rules check component ownership, composition, CSS values, application preferences, wiring, and public boundaries when you opt into the UI preset. The plugin requires no TypeScript parser service.
326
326
 
327
327
  ```bash
328
328
  npm install --save-dev eslint-plugin-kerfjs
@@ -334,7 +334,7 @@ import kerfjs from "eslint-plugin-kerfjs";
334
334
  export default [kerfjs.configs.recommended];
335
335
  ```
336
336
 
337
- Full docs at [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — legacy `.eslintrc` config, per-rule examples, and the rationale for which violations get lint rules vs. dev-warns vs. strict TS.
337
+ Full docs at [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — flat config, per-rule examples, and the rationale for which violations get lint rules vs. dev-warns vs. strict TS.
338
338
 
339
339
  ### Optional: `create-kerf-component`
340
340
 
@@ -353,7 +353,7 @@ See [`docs/13-component-packages.md`](./docs/13-component-packages.md) for the f
353
353
  - **Migrating:** [coming from another framework?](https://brianwestphal.github.io/kerf/migrating/) — side-by-side TodoMVC translations + per-framework gotchas
354
354
  - **AI guide:** [`docs/ai/usage-guide.md`](./docs/ai/usage-guide.md) — reference for AI tools fetching kerf docs (linked from `llms.txt`)
355
355
  - **UI package:** [`@kerfjs/ui`](./ui/README.md) — accessible components, responsive app layouts, typed CSS values, Web Awesome adapters, and checked AI-facing component metadata
356
- - **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/))
356
+ - **ESLint plugin:** [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — `eslint-plugin-kerfjs`; eight core rules plus six opt-in UI rules at edit time (source: [`eslint-plugin/`](./eslint-plugin/))
357
357
  - **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/))
358
358
  - **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)
359
359
  - **Repo:** [github.com/brianwestphal/kerf](https://github.com/brianwestphal/kerf)
package/ai/cursorrules CHANGED
@@ -1,4 +1,4 @@
1
- <!-- kerf-skill-version: 1.22.0 -->
1
+ <!-- kerf-skill-version: 1.23.0 -->
2
2
 
3
3
  # kerf.cursorrules — rules for building apps with kerf
4
4
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  # These rules condense `docs/ai/usage-guide.md` into the form Cursor parses.
12
12
 
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.
13
+ You are writing a UI in kerf — a ~13 KB reactive framework (still ~13 KB with `arraySignal`): signals + DOM morphing + JSX → HTML strings. No virtual DOM, no compiler, no scheduler.
14
14
 
15
15
  ## Setup
16
16
 
@@ -33,13 +33,13 @@ the complete layer is intentional. Reuse these primitives before creating
33
33
  parallel local ones; keep domain state, commands, routing, and tab/menu policy
34
34
  in the app.
35
35
 
36
- Use MenuItem/MenuActionRow/MenuHeader `rootAttributes` only for application
37
- `data-*` metadata. MenuActionRow `trailingActionAttributes` and MenuHeader
36
+ Use ListItem/ListActionRow/ListHeader `rootAttributes` only for application
37
+ `data-*` metadata. ListActionRow `trailingActionAttributes` and ListHeader
38
38
  `triggerAttributes` additionally support native popover target/action and
39
39
  `aria-controls`/`aria-haspopup`. Do not use these slots to override action,
40
40
  item identity, selection, disclosure, naming, disabled, icon, or role
41
41
  semantics; one `role="menuitem"` does not make a complete menu widget.
42
- `MenuItem.trailing` is dormant. Use `MenuActionRow` for sibling primary and
42
+ `ListItem.trailing` is dormant. Use `ListActionRow` for sibling primary and
43
43
  trailing native buttons, and keep its `label`, `icon`, and
44
44
  `trailingActionIcon` SafeHtml slots free of controls.
45
45
 
@@ -48,13 +48,22 @@ Wire emitted `data-action` hooks at the mount root and retain disposers.
48
48
  disposers. Compose `AppTab` inside controlled `TabBar`; `wireTabBars()` supplies
49
49
  horizontal edge autoscroll while dragging. Apply reorder reports
50
50
  with `reorderTabs()`, and keep order, selection, close policy, routing, panels,
51
- and persistence in the host. Icons/spinners are decorative unless
51
+ and persistence in the host. Toolbars draw no divider by default; call
52
+ `wireScrollDividers()` once at the app root so panes and tab bars show scroll
53
+ dividers only while content is scrolled beneath or beyond them. Icons/spinners are decorative unless
52
54
  labeled, and assertive banners are only for urgent interruption.
53
55
 
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.
56
+ Components own their styles; configure, never override. Set how a component
57
+ looks and sits through its typed props (size, density, inset, presentation,
58
+ `dividerSides`, `ToolbarConfig`, `PaneConfig`, …); never write application
59
+ CSS that selects a `.kui-*` class, a `[data-component]` root, a `wa-*` element
60
+ a Kerf component renders, or its `::part()`s, and never add padding, margin,
61
+ width, or height around a component to size or space it (it owns its 8px
62
+ inset — adding more double-insets). A missing prop is a component gap to
63
+ report upstream, not a reason to override. The opinionated semantic ramps
64
+ match Hot Sheet 2 and Web Awesome: override `--kui-color-*` globally or
65
+ component properties such as `--kui-state-banner-background` at a tone or
66
+ instance boundary, for theming only.
58
67
 
59
68
  For Web Awesome's broader free component set, import the CSS-only
60
69
  `@kerfjs/ui/webawesome.css` theme once, then import only each Web Awesome
package/ai/manifest.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
- "kerfjsVersion": "5.0.0-beta.56",
2
+ "kerfjsVersion": "5.0.0-beta.58",
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.24.0",
10
- "sha256": "f74616ec20120fa97ec2ff7e92971f5721ee8dcb0b5b7da2dd2604bb87002b69",
9
+ "version": "1.25.0",
10
+ "sha256": "4bc2b292bd707639c13b8cafe3fb24f779bfea04ab38fd6a5d40c443eed89c9d",
11
11
  "history": {
12
12
  "1.0.0": "bef982cb743fd4c73e666ee5cf774112e57c93ebb13b24bbacbf08577db0930a",
13
13
  "1.1.0": "f2aadb21e33dd389028951ae1ecbd9f8ba68648b3c5beba43fb4dfa2ab29f195",
@@ -39,7 +39,8 @@
39
39
  "1.22.0": "dc99b0ca5e911be11cbfa2ae515c0e161a414a5185895069535c41ba56664e70",
40
40
  "1.23.0": "28b82b4d31b98814c8fbbe1cb0bcf716192deb494c020b9af41a30830e6a0007",
41
41
  "1.23.1": "e21d7db1097ad95c11af9248ad17d514716bebc5646becf57d19a7598d6e382c",
42
- "1.24.0": "f74616ec20120fa97ec2ff7e92971f5721ee8dcb0b5b7da2dd2604bb87002b69"
42
+ "1.24.0": "f74616ec20120fa97ec2ff7e92971f5721ee8dcb0b5b7da2dd2604bb87002b69",
43
+ "1.25.0": "4bc2b292bd707639c13b8cafe3fb24f779bfea04ab38fd6a5d40c443eed89c9d"
43
44
  }
44
45
  },
45
46
  {
@@ -47,8 +48,8 @@
47
48
  "source": "kerf.cursorrules",
48
49
  "bundle": "ai/cursorrules",
49
50
  "dest": ".cursorrules",
50
- "version": "1.22.0",
51
- "sha256": "3515c663e908b2d6dc9a2a9cf42f2a5bf9a5b3a10c79d43e30cc8427e2ce209d",
51
+ "version": "1.23.0",
52
+ "sha256": "908e6622f597108ca696c8ff6f1081b3d83996dc46771a3515b184884dab4b25",
52
53
  "history": {
53
54
  "1.0.0": "98f7faff2b7585ef9cc511add42ea5d9bf0cbae8307b066af090e9281136d2ab",
54
55
  "1.1.0": "a2ec8c221dc694c98b81009cd2d99f9159a45fda94585de2f54471b227099eda",
@@ -78,7 +79,8 @@
78
79
  "1.20.0": "5e7b8d8ddc41f60220961417c9ae01c193fae045378a10a3e9c96a0e3cf10a03",
79
80
  "1.21.0": "a1b663b44650b1a04f89c60b26b002322fc6789650291477e14e13329c3ce25f",
80
81
  "1.21.1": "9b3eb825a7bec05bb6f0138f77b45fadf896b83020178c34c5a2f531c38f4dee",
81
- "1.22.0": "3515c663e908b2d6dc9a2a9cf42f2a5bf9a5b3a10c79d43e30cc8427e2ce209d"
82
+ "1.22.0": "3515c663e908b2d6dc9a2a9cf42f2a5bf9a5b3a10c79d43e30cc8427e2ce209d",
83
+ "1.23.0": "908e6622f597108ca696c8ff6f1081b3d83996dc46771a3515b184884dab4b25"
82
84
  }
83
85
  }
84
86
  ]
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.24.0
4
+ kerf-skill-version: 1.25.0
5
5
  ---
6
6
 
7
7
  # Building apps with kerf
@@ -10,7 +10,7 @@ kerf-skill-version: 1.24.0
10
10
  > project's `.claude/skills/kerf-app/SKILL.md`) so Claude Code activates
11
11
  > it whenever you work on a kerf app.
12
12
 
13
- kerf is a ~12 KB reactive UI framework (~13 KB with `arraySignal`): signals + DOM morphing + JSX → HTML strings. No virtual DOM, no compiler, no scheduler. The whole public surface fits in 17 exports.
13
+ kerf is a ~13 KB reactive UI framework (still ~13 KB with `arraySignal`): signals + DOM morphing + JSX → HTML strings. No virtual DOM, no compiler, no scheduler. The whole public surface fits in 17 exports.
14
14
 
15
15
  ## Setup
16
16
 
@@ -53,13 +53,23 @@ explicit behavioral helpers and return disposers. Compose `AppTab` inside a
53
53
  controlled `TabBar`; `wireTabBars()` supplies horizontal edge autoscroll during
54
54
  dragging. Apply reorder reports with `reorderTabs()`, and keep order,
55
55
  selection, close policy, routing, panels, and persistence in the host.
56
+ Toolbars draw no divider by default: call `wireScrollDividers()` once at the
57
+ app root so each `Pane` draws its header/footer divider, and each `TabBar` its
58
+ overflow divider, only while content is scrolled beneath or beyond it.
56
59
  Icons and spinners are decorative unless labeled; use assertive banners only
57
60
  for urgent interruption.
58
61
 
59
- The opinionated semantic ramps match Hot Sheet 2 and Web Awesome. Override
60
- `--kui-color-*` globally or component properties such as
61
- `--kui-state-banner-background` at a tone or instance boundary; do not replace
62
- private descendant selectors.
62
+ Components own their styles; configure, never override. Set how a component
63
+ looks and sits through its typed props (size, density, inset, presentation,
64
+ `dividerSides`, `ToolbarConfig`, `PaneConfig`, …); never write application
65
+ CSS that selects a `.kui-*` class, a `[data-component]` root, a `wa-*` element
66
+ a Kerf component renders, or its `::part()`s, and never add padding, margin,
67
+ width, or height around a component to size or space it (it owns its 8px
68
+ inset — adding more double-insets). A missing prop is a component gap to
69
+ report upstream, not a reason to override. The opinionated semantic ramps
70
+ match Hot Sheet 2 and Web Awesome: override `--kui-color-*` globally or
71
+ component properties such as `--kui-state-banner-background` at a tone or
72
+ instance boundary, for theming only.
63
73
 
64
74
  For Web Awesome's broader free component set, import the CSS-only
65
75
  `@kerfjs/ui/webawesome.css` theme once, then import only each Web Awesome
package/dist/actions.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { A as AttrSpec } from './attr-Cmu2ZoGO.js';
2
- import { D as DelegateOptions } from './delegate-CL9VTZFb.js';
2
+ import { D as DelegateOptions } from './delegate--CrfgYtC.js';
3
3
 
4
4
  /**
5
5
  * `kerfjs/actions` — the delegated action-table helper.
package/dist/actions.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { attr } from './chunk-MK42GLPV.js';
2
- import { delegate } from './chunk-X6OQ2HTR.js';
2
+ import { _delegate, NON_BUBBLING } from './chunk-JN7EJM3B.js';
3
3
 
4
4
 
5
5
  // src/actions.ts
@@ -9,15 +9,19 @@ function action(value) {
9
9
  }
10
10
  function delegateActions(root, eventType, table, options) {
11
11
  const attrName = options?.attr ?? DEFAULT_ACTION_ATTR;
12
- return delegate(
12
+ return _delegate(
13
13
  root,
14
14
  eventType,
15
15
  `[${attrName}]`,
16
16
  (event, el) => {
17
17
  const value = el.getAttribute(attrName);
18
- if (Object.hasOwn(table, value)) table[value](event, el);
18
+ return () => {
19
+ if (Object.hasOwn(table, value)) table[value](event, el);
20
+ };
19
21
  },
20
- options
22
+ options,
23
+ NON_BUBBLING.has(eventType),
24
+ "delegate"
21
25
  );
22
26
  }
23
27
 
@@ -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,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
+ {"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;AAKlC,EAAA,OAAO,SAAA;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,OAAO,MAAM;AACX,QAAA,IAAI,MAAA,CAAO,OAAO,KAAA,EAAO,KAAK,GAAG,KAAA,CAAM,KAAK,CAAA,CAAE,KAAA,EAAO,EAAO,CAAA;AAAA,MAC9D,CAAA;AAAA,IACF,CAAA;AAAA,IACA,OAAA;AAAA,IACA,YAAA,CAAa,IAAI,SAAS,CAAA;AAAA,IAC1B;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, NON_BUBBLING } 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 // The action value is read when the event STARTS dispatching (with the\n // match itself — see `_delegate`), not from the live element when the\n // handler runs: an earlier handler's synchronous re-render may have\n // recycled the matched element into a different action by then.\n return _delegate(\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 return () => {\n if (Object.hasOwn(table, value)) table[value](event, el as E);\n };\n },\n options,\n NON_BUBBLING.has(eventType),\n 'delegate',\n );\n}\n"]}
@@ -0,0 +1,86 @@
1
+ import { devHooks } from './chunk-3C6F6NM4.js';
2
+
3
+ // src/delegate.ts
4
+ var NON_BUBBLING = /* @__PURE__ */ new Set([
5
+ "focus",
6
+ "blur",
7
+ "scroll",
8
+ "load",
9
+ "error",
10
+ "mouseenter",
11
+ "mouseleave"
12
+ ]);
13
+ function assertValidSelector(selector, fn) {
14
+ try {
15
+ document.createElement("div").matches(selector);
16
+ } catch {
17
+ throw new Error(
18
+ `${fn}: invalid selector "${selector}". Pass a valid CSS selector (e.g. '[data-action="add"]', '.btn', 'input').`
19
+ );
20
+ }
21
+ }
22
+ var KEY = /* @__PURE__ */ Symbol();
23
+ function _delegate(rootEl, type, selector, bind, options, capture, fn) {
24
+ assertValidSelector(selector, fn);
25
+ devHooks.delegateInEffect?.(fn);
26
+ const direct = options?.match === "direct";
27
+ const resolve = (event) => {
28
+ const target = event.target;
29
+ if (event.type === type && target instanceof Element) {
30
+ const matched = direct ? target.matches(selector) ? target : null : target.closest(selector);
31
+ if (matched !== null) {
32
+ const run = bind(event, matched);
33
+ return () => {
34
+ if (rootEl.contains(matched)) run();
35
+ };
36
+ }
37
+ }
38
+ return void 0;
39
+ };
40
+ const listener = (event) => {
41
+ let snap = event[KEY];
42
+ if (!snap?.has(resolve)) {
43
+ snap = event[KEY] = /* @__PURE__ */ new Map();
44
+ for (const node of event.composedPath()) {
45
+ for (const other of node[KEY] ?? []) {
46
+ snap.set(other, other(event));
47
+ }
48
+ }
49
+ }
50
+ const run = snap.get(resolve);
51
+ snap.delete(resolve);
52
+ run?.();
53
+ };
54
+ const registry = (rootEl[KEY] ??= /* @__PURE__ */ new Set()).add(resolve);
55
+ rootEl.addEventListener(type, listener, capture);
56
+ return () => {
57
+ registry.delete(resolve);
58
+ rootEl.removeEventListener(type, listener, capture);
59
+ };
60
+ }
61
+ function delegate(rootEl, type, selector, handler, options) {
62
+ return _delegate(
63
+ rootEl,
64
+ type,
65
+ selector,
66
+ (event, matched) => () => handler(event, matched),
67
+ options,
68
+ NON_BUBBLING.has(type),
69
+ "delegate"
70
+ );
71
+ }
72
+ function delegateCapture(rootEl, type, selector, handler, options) {
73
+ return _delegate(
74
+ rootEl,
75
+ type,
76
+ selector,
77
+ (event, matched) => () => handler(event, matched),
78
+ options,
79
+ true,
80
+ "delegateCapture"
81
+ );
82
+ }
83
+
84
+ export { NON_BUBBLING, _delegate, delegate, delegateCapture };
85
+
86
+ //# sourceMappingURL=chunk-JN7EJM3B.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/delegate.ts"],"names":[],"mappings":";;;AA+CO,IAAM,YAAA,uBAAmB,GAAA,CAAY;AAAA,EAC1C,OAAA;AAAA,EACA,MAAA;AAAA,EACA,QAAA;AAAA,EACA,MAAA;AAAA,EACA,OAAA;AAAA,EACA,YAAA;AAAA,EACA;AACF,CAAC;AAqBD,SAAS,mBAAA,CAAoB,UAAkB,EAAA,EAAkB;AAC/D,EAAA,IAAI;AACF,IAAA,QAAA,CAAS,aAAA,CAAc,KAAK,CAAA,CAAE,OAAA,CAAQ,QAAQ,CAAA;AAAA,EAChD,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,CAAA,EAAG,EAAE,CAAA,oBAAA,EAAuB,QAAQ,CAAA,2EAAA;AAAA,KAEtC;AAAA,EACF;AACF;AASA,IAAM,sBAAM,MAAA,EAAO;AAiDZ,SAAS,UACd,MAAA,EACA,IAAA,EACA,UACA,IAAA,EACA,OAAA,EACA,SACA,EAAA,EACY;AACZ,EAAA,mBAAA,CAAoB,UAAU,EAAE,CAAA;AAChC,EAAA,QAAA,CAAS,mBAAmB,EAAE,CAAA;AAC9B,EAAA,MAAM,MAAA,GAAS,SAAS,KAAA,KAAU,QAAA;AAClC,EAAA,MAAM,OAAA,GAAmB,CAAC,KAAA,KAAU;AAClC,IAAA,MAAM,SAAS,KAAA,CAAM,MAAA;AACrB,IAAA,IAAI,KAAA,CAAM,IAAA,KAAS,IAAA,IAAQ,MAAA,YAAkB,OAAA,EAAS;AACpD,MAAA,MAAM,OAAA,GAAU,MAAA,GACZ,MAAA,CAAO,OAAA,CAAQ,QAAQ,IACrB,MAAA,GACA,IAAA,GACF,MAAA,CAAO,OAAA,CAAQ,QAAQ,CAAA;AAC3B,MAAA,IAAI,YAAY,IAAA,EAAM;AACpB,QAAA,MAAM,GAAA,GAAM,IAAA,CAAK,KAAA,EAAO,OAAO,CAAA;AAI/B,QAAA,OAAO,MAAM;AACX,UAAA,IAAI,MAAA,CAAO,QAAA,CAAS,OAAO,CAAA,EAAG,GAAA,EAAI;AAAA,QACpC,CAAA;AAAA,MACF;AAAA,IACF;AACA,IAAA,OAAO,MAAA;AAAA,EACT,CAAA;AACA,EAAA,MAAM,QAAA,GAAW,CAAC,KAAA,KAAuB;AACvC,IAAA,IAAI,IAAA,GAAQ,MAAsB,GAAG,CAAA;AACrC,IAAA,IAAI,CAAC,IAAA,EAAM,GAAA,CAAI,OAAO,CAAA,EAAG;AACvB,MAAA,IAAA,GAAQ,KAAA,CAAsB,GAAG,CAAA,mBAAI,IAAI,GAAA,EAAI;AAC7C,MAAA,KAAA,MAAW,IAAA,IAAQ,KAAA,CAAM,YAAA,EAAa,EAAG;AACvC,QAAA,KAAA,MAAW,KAAA,IAAU,IAAA,CAAoB,GAAG,CAAA,IAAK,EAAC,EAAG;AACnD,UAAA,IAAA,CAAK,GAAA,CAAI,KAAA,EAAO,KAAA,CAAM,KAAK,CAAC,CAAA;AAAA,QAC9B;AAAA,MACF;AAAA,IACF;AACA,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,GAAA,CAAI,OAAO,CAAA;AAC5B,IAAA,IAAA,CAAK,OAAO,OAAO,CAAA;AACnB,IAAA,GAAA,IAAM;AAAA,EACR,CAAA;AACA,EAAA,MAAM,QAAA,GAAA,CAAa,OAAsB,GAAG,CAAA,yBAAU,GAAA,EAAI,EAAG,IAAI,OAAO,CAAA;AACxE,EAAA,MAAA,CAAO,gBAAA,CAAiB,IAAA,EAAM,QAAA,EAAU,OAAO,CAAA;AAC/C,EAAA,OAAO,MAAM;AACX,IAAA,QAAA,CAAS,OAAO,OAAO,CAAA;AACvB,IAAA,MAAA,CAAO,mBAAA,CAAoB,IAAA,EAAM,QAAA,EAAU,OAAO,CAAA;AAAA,EACpD,CAAA;AACF;AAuBO,SAAS,QAAA,CACd,MAAA,EACA,IAAA,EACA,QAAA,EACA,SACA,OAAA,EACY;AACZ,EAAA,OAAO,SAAA;AAAA,IACL,MAAA;AAAA,IACA,IAAA;AAAA,IACA,QAAA;AAAA,IACA,CAAC,KAAA,EAAO,OAAA,KAAY,MAAM,OAAA,CAAQ,OAAO,OAAY,CAAA;AAAA,IACrD,OAAA;AAAA,IACA,YAAA,CAAa,IAAI,IAAI,CAAA;AAAA,IACrB;AAAA,GACF;AACF;AAuBO,SAAS,eAAA,CACd,MAAA,EACA,IAAA,EACA,QAAA,EACA,SACA,OAAA,EACY;AACZ,EAAA,OAAO,SAAA;AAAA,IACL,MAAA;AAAA,IACA,IAAA;AAAA,IACA,QAAA;AAAA,IACA,CAAC,KAAA,EAAO,OAAA,KAAY,MAAM,OAAA,CAAQ,OAAO,OAAY,CAAA;AAAA,IACrD,OAAA;AAAA,IACA,IAAA;AAAA,IACA;AAAA,GACF;AACF","file":"chunk-JN7EJM3B.js","sourcesContent":["/**\n * Tiny event-delegation helpers. Replace per-element `addEventListener` calls\n * (which don't survive morph re-renders for nodes the diff creates) with one\n * listener at the morph-root that dispatches via `closest()`.\n *\n * Three-tier listener model:\n *\n * - Tier 1 (bubbling events) — use `delegate()`.\n * click, input, change, submit, mousedown/up, keydown/up, pointerdown/up/move,\n * drag*, drop, contextmenu, wheel, copy/paste/cut, focusin/focusout.\n *\n * `delegate()` also auto-promotes the well-known non-bubbling event\n * types (`focus`, `blur`, `scroll`, `load`, `error`, `mouseenter`,\n * `mouseleave`) to the capture phase under the hood, so the call site\n * looks identical for \"interactive thing happens on a descendant\"\n * regardless of whether that event bubbles. Selector matching stays\n * `closest()`-style — the same as for bubbling events — so a wrapper\n * selector like `'.field-row'` still matches when the event fires on\n * a descendant `<input>`.\n *\n * - Tier 2 (explicit capture) — use `delegateCapture()`.\n * The escape hatch for cases the auto-promotion list doesn't cover\n * (custom non-bubbling events) or when you want capture-phase\n * interception. Selector matching is `closest()`-style by default —\n * the same walk-up as `delegate()`, and it passes the matched ancestor\n * (not the raw target) to the handler — so a click on any descendant of\n * the selected element climbs to it. Pass `{ match: 'direct' }` to opt\n * into strict `matches()`-style matching (fire only when the event lands\n * on the exact element the selector identifies).\n *\n * - Tier 3 (per-element instances / library-owned subtrees) — mark the\n * host element with `data-morph-skip` and manage the library's\n * lifecycle directly. No delegation helper applies.\n */\n\nimport { devHooks } from './dev-hooks.js';\n\n/**\n * Event types that don't bubble and so wouldn't reach a root-level\n * bubble-phase listener. `delegate()` flips to capture for these; the\n * caller doesn't need to know or care.\n *\n * Membership is conservative — it covers the cases that \"should obviously\n * work\" with delegate() but otherwise don't. For exotic non-bubbling events\n * (custom events, less-common DOM events) the explicit `delegateCapture()`\n * remains the escape hatch.\n */\nexport const NON_BUBBLING = new Set<string>([\n 'focus',\n 'blur',\n 'scroll',\n 'load',\n 'error',\n 'mouseenter',\n 'mouseleave',\n]);\n\n/**\n * How the selector is matched against the event's target:\n *\n * - `'closest'` (the default for both helpers) — walk UP from `event.target`\n * via `closest(selector)`, firing for the nearest matching ancestor inside\n * `rootEl`. This is the delegation behavior you almost always want: a click\n * on an icon inside a button fires the button's handler.\n * - `'direct'` — strict `matches()` match: fire only when `event.target`\n * itself matches the selector, with no walk-up.\n */\nexport interface DelegateOptions {\n match?: 'closest' | 'direct';\n}\n\n/**\n * Validate a CSS selector at registration time, so a typo throws immediately\n * with the bad selector quoted instead of producing a cryptic DOMException\n * the first time a matching event fires.\n */\nfunction assertValidSelector(selector: string, fn: string): void {\n try {\n document.createElement('div').matches(selector);\n } catch {\n throw new Error(\n `${fn}: invalid selector \"${selector}\". ` +\n \"Pass a valid CSS selector (e.g. '[data-action=\\\"add\\\"]', '.btn', 'input').\",\n );\n }\n}\n\n/**\n * Per-root registry of the delegates installed on that root, and the per-event\n * dispatch snapshot. Both live ON the DOM objects, under one symbol key (a\n * root and an event are never the same object), rather than in module-level\n * maps — so they are collected with the root / event and add no module-level\n * mutable state (Design rule 5).\n */\nconst KEY = Symbol();\n\n/**\n * Resolves one delegate's selector against an event's target: the bound\n * handler call for a match, `undefined` otherwise.\n */\ntype Resolve = (event: Event) => (() => void) | undefined;\n\ninterface Registered {\n [KEY]?: Set<Resolve>;\n}\n\ninterface Snapshotted {\n [KEY]?: Map<Resolve, (() => void) | undefined>;\n}\n\n/**\n * Install one root-level delegate — the shared core of `delegate()`,\n * `delegateCapture()`, and `kerfjs/actions`' `delegateActions()`.\n *\n * The listener resolves the event's target to a matched element (walk-up\n * `closest()` or strict `matches()`, per `options.match`) that must lie inside\n * `rootEl`, then runs the call `bind(event, matched)` prepared. `bind` runs at\n * RESOLUTION time, so a caller can read matched-element state there (such as\n * `delegateActions`' action attribute) before any handler has mutated it.\n *\n * **Dispatch snapshot.** A handler that writes a signal makes `mount()` morph\n * synchronously, inside the same dispatch, and the morph can recycle the\n * clicked element in place into a control with a different selector identity\n * (same tag, same position, new `data-action`). Resolving later delegates\n * against that live DOM would fire one the user never clicked — typically one\n * that immediately undoes the first handler's state change. So the FIRST kerf\n * delegate listener to see an event resolves every kerf delegate on the\n * event's propagation path (fixed at dispatch start, per the DOM spec, and\n * listed by `composedPath()`) before any delegated handler runs, and stores\n * the results on the event; each listener then consumes its own entry. The\n * set of delegates an event reaches is fixed when it starts dispatching, like\n * per-element listeners. The matched element must also still be inside\n * `rootEl` when the listener runs: a handler that removed the target still\n * suppresses the later delegates, as before.\n *\n * A missing entry (a re-dispatch of the same event object, a delegate added\n * mid-dispatch, or a root hidden from the first listener by a closed shadow\n * boundary) takes a fresh snapshot. Accepted residuals: a same-path\n * re-dispatch after a stopped dispatch can reuse an unconsumed entry if all\n * earlier kerf listeners were disposed, and a closed shadow boundary can\n * hide delegates from the first listener's path view. See docs/5 §5.2.1.\n * Internal — exported for `kerfjs/actions`.\n */\nexport function _delegate(\n rootEl: HTMLElement,\n type: string,\n selector: string,\n bind: (event: Event, matched: Element) => () => void,\n options: DelegateOptions | undefined,\n capture: boolean,\n fn: 'delegate' | 'delegateCapture',\n): () => void {\n assertValidSelector(selector, fn);\n devHooks.delegateInEffect?.(fn);\n const direct = options?.match === 'direct';\n const resolve: Resolve = (event) => {\n const target = event.target;\n if (event.type === type && target instanceof Element) {\n const matched = direct\n ? target.matches(selector)\n ? target\n : null\n : target.closest(selector);\n if (matched !== null) {\n const run = bind(event, matched);\n // Checked when the handler would run, not at resolution: a match\n // outside `rootEl` never fires, and neither does one an earlier\n // handler removed from `rootEl`.\n return () => {\n if (rootEl.contains(matched)) run();\n };\n }\n }\n return undefined;\n };\n const listener = (event: Event): void => {\n let snap = (event as Snapshotted)[KEY];\n if (!snap?.has(resolve)) {\n snap = (event as Snapshotted)[KEY] = new Map();\n for (const node of event.composedPath()) {\n for (const other of (node as Registered)[KEY] ?? []) {\n snap.set(other, other(event));\n }\n }\n }\n const run = snap.get(resolve);\n snap.delete(resolve);\n run?.();\n };\n const registry = ((rootEl as Registered)[KEY] ??= new Set()).add(resolve);\n rootEl.addEventListener(type, listener, capture);\n return () => {\n registry.delete(resolve);\n rootEl.removeEventListener(type, listener, capture);\n };\n}\n\n/**\n * Delegation that \"just works\" for both bubbling and the common non-bubbling\n * events. Installs ONE listener on `rootEl`; for known non-bubblers (see\n * `NON_BUBBLING` above) the listener is registered on the capture phase so\n * it actually reaches the target, otherwise on the bubble phase. Either way,\n * matching walks up from `event.target` via `closest(selector)` and fires\n * `handler(event, matched)` if the match is inside `rootEl`.\n *\n * Pass `{ match: 'direct' }` to fire only when `event.target` itself matches\n * the selector (no walk-up); the default is `'closest'`.\n *\n * The generic `T` narrows the second handler argument to the expected element\n * type — `delegate<HTMLButtonElement>(root, 'click', 'button', (e, btn) => btn.value)`\n * — so consumers can avoid casts. Defaults to `Element` for untyped calls.\n *\n * Returns a disposer that removes the listener.\n *\n * Usage (pseudo-code — see examples for live ones):\n * delegate(rootEl, 'click', '[data-action=\"add\"]', handlerFn);\n * delegate(rootEl, 'focus', 'input', handlerFn); // auto-capture\n */\nexport function delegate<T extends Element = Element>(\n rootEl: HTMLElement,\n type: string,\n selector: string,\n handler: (event: Event, target: T) => void,\n options?: DelegateOptions,\n): () => void {\n return _delegate(\n rootEl,\n type,\n selector,\n (event, matched) => () => handler(event, matched as T),\n options,\n NON_BUBBLING.has(type),\n 'delegate',\n );\n}\n\n/**\n * Capture-phase delegation — the escape hatch for custom non-bubbling events\n * (ones `delegate()`'s auto-promotion list doesn't know about) and for\n * capture-phase interception (run before any descendant's bubble-phase\n * handler). Reaches descendants of `rootEl` that match `selector` regardless\n * of how many times the diff has rebuilt them.\n *\n * Selector matching is `closest()`-style by default — the same walk-up as\n * `delegate()`, and it passes the matched ancestor (not the raw target) to\n * the handler — so a click on any descendant of the selected element climbs\n * to it. Pass `{ match: 'direct' }` to opt into strict `matches()`-style\n * matching (fire only when the event lands on the exact element the selector\n * identifies, with no walk-up).\n *\n * The generic `T` narrows the second handler argument to the expected element\n * type, mirroring `delegate<T>()`. Defaults to `Element` for untyped calls.\n *\n * Usage (pseudo-code — see examples for live ones):\n * delegateCapture(rootEl, 'focus', 'input, textarea', handlerFn);\n * delegateCapture(rootEl, 'click', '.exact', handlerFn, { match: 'direct' });\n */\nexport function delegateCapture<T extends Element = Element>(\n rootEl: HTMLElement,\n type: string,\n selector: string,\n handler: (event: Event, target: T) => void,\n options?: DelegateOptions,\n): () => void {\n return _delegate(\n rootEl,\n type,\n selector,\n (event, matched) => () => handler(event, matched as T),\n options,\n true,\n 'delegateCapture',\n );\n}\n"]}
@@ -1,37 +1,3 @@
1
- /**
2
- * Tiny event-delegation helpers. Replace per-element `addEventListener` calls
3
- * (which don't survive morph re-renders for nodes the diff creates) with one
4
- * listener at the morph-root that dispatches via `closest()`.
5
- *
6
- * Three-tier listener model:
7
- *
8
- * - Tier 1 (bubbling events) — use `delegate()`.
9
- * click, input, change, submit, mousedown/up, keydown/up, pointerdown/up/move,
10
- * drag*, drop, contextmenu, wheel, copy/paste/cut, focusin/focusout.
11
- *
12
- * `delegate()` also auto-promotes the well-known non-bubbling event
13
- * types (`focus`, `blur`, `scroll`, `load`, `error`, `mouseenter`,
14
- * `mouseleave`) to the capture phase under the hood, so the call site
15
- * looks identical for "interactive thing happens on a descendant"
16
- * regardless of whether that event bubbles. Selector matching stays
17
- * `closest()`-style — the same as for bubbling events — so a wrapper
18
- * selector like `'.field-row'` still matches when the event fires on
19
- * a descendant `<input>`.
20
- *
21
- * - Tier 2 (explicit capture) — use `delegateCapture()`.
22
- * The escape hatch for cases the auto-promotion list doesn't cover
23
- * (custom non-bubbling events) or when you want capture-phase
24
- * interception. Selector matching is `closest()`-style by default —
25
- * the same walk-up as `delegate()`, and it passes the matched ancestor
26
- * (not the raw target) to the handler — so a click on any descendant of
27
- * the selected element climbs to it. Pass `{ match: 'direct' }` to opt
28
- * into strict `matches()`-style matching (fire only when the event lands
29
- * on the exact element the selector identifies).
30
- *
31
- * - Tier 3 (per-element instances / library-owned subtrees) — mark the
32
- * host element with `data-morph-skip` and manage the library's
33
- * lifecycle directly. No delegation helper applies.
34
- */
35
1
  /**
36
2
  * How the selector is matched against the event's target:
37
3
  *
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { A as AttrSpec, a as attr } from './attr-Cmu2ZoGO.js';
2
- export { D as DelegateOptions, d as delegate, a as delegateCapture } from './delegate-CL9VTZFb.js';
2
+ export { D as DelegateOptions, d as delegate, a as delegateCapture } from './delegate--CrfgYtC.js';
3
3
  import { ArraySignal } from './array-signal.js';
4
4
  import { SafeHtml } from './jsx-runtime.js';
5
5
  export { Fragment, JSXChildren, isSafeHtml, raw } from './jsx-runtime.js';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { each, morph, mount } from './chunk-TLL3KXT6.js';
2
2
  export { defineStore, resetAllStores } from './chunk-BI4HGICV.js';
3
3
  export { attr } from './chunk-MK42GLPV.js';
4
- export { delegate, delegateCapture } from './chunk-X6OQ2HTR.js';
4
+ export { delegate, delegateCapture } from './chunk-JN7EJM3B.js';
5
5
 
6
6
 
7
7
  export { Fragment, SafeHtml, isSafeHtml, raw } from './chunk-UGNPDLNI.js';
@@ -865,6 +865,7 @@ interface KerfBuiltinIntrinsicElements {
865
865
  p: KerfBaseAttrs;
866
866
  hr: KerfBaseAttrs;
867
867
  br: KerfBaseAttrs;
868
+ wbr: KerfBaseAttrs;
868
869
  pre: KerfBaseAttrs;
869
870
  blockquote: HTMLBlockquoteAttrs;
870
871
  q: HTMLQAttrs;
package/dist/overlay.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { attach } from './chunk-FM6N2B4N.js';
2
2
  import { mount } from './chunk-TLL3KXT6.js';
3
- import { delegate } from './chunk-X6OQ2HTR.js';
3
+ import { delegate } from './chunk-JN7EJM3B.js';
4
4
 
5
5
 
6
6
  import { jsx, escapeHtml } from './chunk-UGNPDLNI.js';
package/dist/router.js CHANGED
@@ -1,4 +1,4 @@
1
- import { delegate } from './chunk-X6OQ2HTR.js';
1
+ import { delegate } from './chunk-JN7EJM3B.js';
2
2
  import { jsx } from './chunk-UGNPDLNI.js';
3
3
  import { signal, computed } from './chunk-IHMDBBUJ.js';
4
4
 
package/dist/scope.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { D as DelegateOptions } from './delegate-CL9VTZFb.js';
1
+ import { D as DelegateOptions } from './delegate--CrfgYtC.js';
2
2
  import { M as MountResult } from './mount-Bo2qOx25.js';
3
3
  import './jsx-runtime.js';
4
4
  import '@preact/signals-core';
package/dist/scope.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { mount } from './chunk-TLL3KXT6.js';
2
- import { delegate } from './chunk-X6OQ2HTR.js';
2
+ import { delegate } from './chunk-JN7EJM3B.js';
3
3
 
4
4
 
5
5
 
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # kerf
2
2
 
3
- > A tiny (~12 KB minified + gzipped including its one runtime dependency `@preact/signals-core`; ~13 KB with `arraySignal`) reactive UI framework — fine-grained signals + DOM morphing + JSX. No virtual DOM, no compiler. Apply the smallest possible cut to update your DOM.
3
+ > A tiny (~13 KB minified + gzipped including its one runtime dependency `@preact/signals-core`; still ~13 KB with `arraySignal`) reactive UI framework — fine-grained signals + DOM morphing + JSX. No virtual DOM, no compiler. Apply the smallest possible cut to update your DOM.
4
4
 
5
5
  kerf renders JSX to a structured `SafeHtml` (string for static content; tagged "list"/"mixed" segments where `each(...)` was used) and reconciles it against the live tree with a custom segment-aware morph. Static surrounds go through a general-purpose tree-morph; list contents go through a keyed reconciler that operates directly on live children. Snapshot reconciliation scans O(rows) but limits rendering and DOM mutations to cache misses and structural changes; eligible `arraySignal` patch delivery runs in O(patches). Reactivity is provided by [@preact/signals-core](https://github.com/preactjs/signals). It pairs well with server-rendered HTML, embedded widgets, and any UI where preserving focus / selection across re-renders matters. Public API is one import: `signal`, `computed`, `effect`, `batch`, `defineStore`, `resetAllStores`, `mount`, `morph`, `each`, `attr`, `delegate`, `delegateCapture`, `toElement`, `renderDocument`, `SafeHtml`, `isSafeHtml`, `raw`, `Fragment`. (Two more subpaths: `kerfjs/testing` exposes `clearStoreRegistry` for unit-test isolation; `kerfjs/jsx-runtime` exposes the typed JSX building blocks for declaration-merging custom-element types.) An optional subpath at `kerfjs/array-signal` adds `arraySignal()` — a granular keyed-list signal whose patch events let `each()` reconcile in O(patches) instead of O(N). An optional subpath at `kerfjs/dev` installs the development diagnostics — kerf does NOT infer dev mode, so you import it behind your own build's dev flag (`if (import.meta.env.DEV) await import('kerfjs/dev');`); omitting it is production and sheds ~4.7 KB min+gzip. Another optional subpath at `kerfjs/html` adds the `html` tagged template — JSX-identical runtime semantics with no JSX transform, so CDN/importmap projects can author kerf UIs with literally no build step. A family of optional, tree-shakeable **companion-utility** subpaths cover patterns real apps hand-roll: `kerfjs/list` (`bindList` — a keyed list with per-row fine-grained mounts and fixed / declared / measured-height viewport virtualization, plus `observeRowHeights`), `kerfjs/router` (`createRouter` — the opt-in "postcard router": route matching + `navigate` + `<a>` link interception + a keyed outlet, core stays router-free), `kerfjs/overlay` (`overlay` / `confirm` / `prompt` / `form` / `choice` / `popover` / `tooltip` / `toast` + `positionAnchored` / `autoReposition`; `native: true` top-layer backing via `<dialog>` / the Popover API applies to the seven overlay/dialog/popover/tooltip surfaces), `kerfjs/scope` (`disposeScope` / `disposeSubtree` / `observeRemovals`), `kerfjs/async` (`resource` async-state with a stale-response guard + SWR cache), `kerfjs/timing` (`debounce` / `throttle` / `debouncedSignal`), `kerfjs/remount` (`remountOn` — key-driven wholesale subtree replacement), `kerfjs/attach` (`attach` — bind a non-kerf widget's lifecycle to one node), and `kerfjs/actions` (`action` / `delegateActions` — the delegated `data-action` table idiom).
6
6
 
@@ -19,8 +19,8 @@ kerf renders JSX to a structured `SafeHtml` (string for static content; tagged "
19
19
  - [Requirements summary](https://github.com/brianwestphal/kerf/blob/main/docs/ai/requirements-summary.md): synthesized view of the numbered design docs.
20
20
  - [`kerf.cursorrules`](https://github.com/brianwestphal/kerf/blob/main/kerf.cursorrules): drop-in [Cursor](https://cursor.sh) rules for projects that import `kerfjs`. Copy into your project as `.cursorrules` — or use the bundled mirror at `node_modules/kerfjs/ai/cursorrules` once you've `npm install`ed kerfjs.
21
21
  - [`kerf.claude-skill.md`](https://github.com/brianwestphal/kerf/blob/main/kerf.claude-skill.md): drop-in [Claude Code](https://claude.com/claude-code) skill. Copy into `~/.claude/skills/kerf-app/SKILL.md` (or your project's `.claude/skills/kerf-app/SKILL.md`) — or use the bundled mirror at `node_modules/kerfjs/ai/skill.md` once you've `npm install`ed kerfjs.
22
- - [`eslint-plugin-kerfjs`](https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/README.md): companion ESLint plugin enforcing the hard rules at edit time — eight AST-only core rules in `kerfjs.configs.recommended`: `no-inline-jsx-event-handlers`, `require-data-key-in-each`, `no-nested-mount`, `prefer-module-jsx-augmentation` (error) plus `require-delegate-disposer`, `prefer-attr-selector`, `no-raw-with-dynamic-arg`, `ai-assistant-configs` (warn), with no `parserServices` dependency; plus five `@kerfjs/ui` catalog-contract rules (`ui-composition`, `ui-css-values`, `ui-preferences`, `ui-public-boundaries`, `ui-wiring`) in the `recommended-ui` / `strict-ui` configs. Install with `npm install --save-dev eslint-plugin-kerfjs` and add `kerfjs.configs.recommended` to your eslint config. Recommended when authoring kerf code with an AI assistant — eslint feedback surfaces in the IDE before `tsc` or runtime warns ever run.
23
- - [`create-kerf-component`](https://github.com/brianwestphal/kerf/blob/main/create-kerf-component/README.md): companion initializer that scaffolds a publishable kerf component package with the hard packaging rules, an example component, and explicit `kerf.components.json` source metadata plus deterministic `component-catalog-v2.json` generation/checking. Run `npm create kerf-component@latest <dir>`.
22
+ - [`eslint-plugin-kerfjs`](https://github.com/brianwestphal/kerf/blob/main/eslint-plugin/README.md): companion ESLint plugin enforcing the hard rules at edit time — eight AST-only core rules in `kerfjs.configs.recommended`: `no-inline-jsx-event-handlers`, `require-data-key-in-each`, `no-nested-mount`, `prefer-module-jsx-augmentation` (error) plus `require-delegate-disposer`, `prefer-attr-selector`, `no-raw-with-dynamic-arg`, `ai-assistant-configs` (warn), with no `parserServices` dependency; plus six `@kerfjs/ui` catalog-contract rules (`ui-component-ownership`, `ui-composition`, `ui-css-values`, `ui-preferences`, `ui-public-boundaries`, `ui-wiring`) in the `recommended-ui` / `strict-ui` configs. Install with `npm install --save-dev eslint-plugin-kerfjs` and add `kerfjs.configs.recommended` to your eslint config. Recommended when authoring kerf code with an AI assistant — eslint feedback surfaces in the IDE before `tsc` or runtime warns ever run.
23
+ - [`create-kerf-component`](https://github.com/brianwestphal/kerf/blob/main/create-kerf-component/README.md): companion initializer that scaffolds a publishable kerf component package with the hard packaging rules, an example component, and explicit `kerf.components.json` source metadata plus deterministic `component-composition.json` generation/checking. Run `npm create kerf-component@latest <dir>`.
24
24
  - [`@kerfjs/ui` AI guide](https://github.com/brianwestphal/kerf/blob/main/ui/ai/skill.md): component-selection, styling, accessibility, and event-wiring contract for the optional first-party UI package.
25
25
 
26
26
  ## Reference docs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kerfjs",
3
- "version": "5.0.0-beta.56",
3
+ "version": "5.0.0-beta.58",
4
4
  "description": "Tiny reactive UI framework — fine-grained signals + DOM morphing + JSX. Apply the smallest possible cut to update your DOM.",
5
5
  "type": "module",
6
6
  "sideEffects": [
package/setup/index.mjs CHANGED
@@ -491,7 +491,7 @@ function profileTemplate(packageName, catalogPath) {
491
491
  catalogs: [
492
492
  {
493
493
  package: packageName,
494
- composition: { path: catalogPath, schemaVersion: 2 },
494
+ composition: { path: catalogPath, schemaVersion: 1 },
495
495
  },
496
496
  ],
497
497
  };
@@ -502,7 +502,7 @@ function metadataTemplate() {
502
502
  $schema:
503
503
  './node_modules/create-kerf-component/component-metadata.schema.json',
504
504
  schemaVersion: 1,
505
- v1Catalog: 'not-applicable',
505
+ componentCatalog: 'not-applicable',
506
506
  components: [],
507
507
  };
508
508
  }
@@ -820,7 +820,7 @@ export async function planKerfSetup({
820
820
  key: 'kerfComponentCatalog',
821
821
  value: {
822
822
  source: './kerf.components.json',
823
- output: './component-catalog-v2.json',
823
+ output: './component-composition.json',
824
824
  },
825
825
  id: 'package.json#kerfComponentCatalog',
826
826
  ...context,
@@ -1011,7 +1011,7 @@ export async function planKerfSetup({
1011
1011
  : JSON.parse(currentProfileSource);
1012
1012
  const catalogRelative = portable(
1013
1013
  root,
1014
- resolve(selected.directory, 'component-catalog-v2.json'),
1014
+ resolve(selected.directory, 'component-composition.json'),
1015
1015
  );
1016
1016
  const profile = mergeProfile(
1017
1017
  currentProfile,
@@ -1,64 +0,0 @@
1
- import { devHooks } from './chunk-3C6F6NM4.js';
2
-
3
- // src/delegate.ts
4
- var NON_BUBBLING = /* @__PURE__ */ new Set([
5
- "focus",
6
- "blur",
7
- "scroll",
8
- "load",
9
- "error",
10
- "mouseenter",
11
- "mouseleave"
12
- ]);
13
- function assertValidSelector(selector, fn) {
14
- try {
15
- document.createElement("div").matches(selector);
16
- } catch {
17
- throw new Error(
18
- `${fn}: invalid selector "${selector}". Pass a valid CSS selector (e.g. '[data-action="add"]', '.btn', 'input').`
19
- );
20
- }
21
- }
22
- function makeListener(rootEl, selector, handler, match) {
23
- return (event) => {
24
- const target = event.target;
25
- if (!(target instanceof Element)) return;
26
- const matched = match === "direct" ? target.matches(selector) ? target : null : target.closest(selector);
27
- if (matched !== null && rootEl.contains(matched)) {
28
- handler(event, matched);
29
- }
30
- };
31
- }
32
- function delegate(rootEl, type, selector, handler, options) {
33
- assertValidSelector(selector, "delegate");
34
- devHooks.delegateInEffect?.("delegate");
35
- const listener = makeListener(
36
- rootEl,
37
- selector,
38
- handler,
39
- options?.match ?? "closest"
40
- );
41
- const capture = NON_BUBBLING.has(type);
42
- rootEl.addEventListener(type, listener, capture);
43
- return () => {
44
- rootEl.removeEventListener(type, listener, capture);
45
- };
46
- }
47
- function delegateCapture(rootEl, type, selector, handler, options) {
48
- assertValidSelector(selector, "delegateCapture");
49
- devHooks.delegateInEffect?.("delegateCapture");
50
- const listener = makeListener(
51
- rootEl,
52
- selector,
53
- handler,
54
- options?.match ?? "closest"
55
- );
56
- rootEl.addEventListener(type, listener, true);
57
- return () => {
58
- rootEl.removeEventListener(type, listener, true);
59
- };
60
- }
61
-
62
- export { delegate, delegateCapture };
63
-
64
- //# sourceMappingURL=chunk-X6OQ2HTR.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/delegate.ts"],"names":[],"mappings":";;;AA+CA,IAAM,YAAA,uBAAmB,GAAA,CAAY;AAAA,EACnC,OAAA;AAAA,EACA,MAAA;AAAA,EACA,QAAA;AAAA,EACA,MAAA;AAAA,EACA,OAAA;AAAA,EACA,YAAA;AAAA,EACA;AACF,CAAC,CAAA;AAqBD,SAAS,mBAAA,CAAoB,UAAkB,EAAA,EAAkB;AAC/D,EAAA,IAAI;AACF,IAAA,QAAA,CAAS,aAAA,CAAc,KAAK,CAAA,CAAE,OAAA,CAAQ,QAAQ,CAAA;AAAA,EAChD,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,CAAA,EAAG,EAAE,CAAA,oBAAA,EAAuB,QAAQ,CAAA,2EAAA;AAAA,KAEtC;AAAA,EACF;AACF;AAQA,SAAS,YAAA,CACP,MAAA,EACA,QAAA,EACA,OAAA,EACA,KAAA,EACwB;AACxB,EAAA,OAAO,CAAC,KAAA,KAAuB;AAC7B,IAAA,MAAM,SAAS,KAAA,CAAM,MAAA;AACrB,IAAA,IAAI,EAAE,kBAAkB,OAAA,CAAA,EAAU;AAClC,IAAA,MAAM,OAAA,GACJ,KAAA,KAAU,QAAA,GACN,MAAA,CAAO,OAAA,CAAQ,QAAQ,CAAA,GACrB,MAAA,GACA,IAAA,GACF,MAAA,CAAO,OAAA,CAAQ,QAAQ,CAAA;AAC7B,IAAA,IAAI,OAAA,KAAY,IAAA,IAAQ,MAAA,CAAO,QAAA,CAAS,OAAO,CAAA,EAAG;AAChD,MAAA,OAAA,CAAQ,OAAO,OAAY,CAAA;AAAA,IAC7B;AAAA,EACF,CAAA;AACF;AAuBO,SAAS,QAAA,CACd,MAAA,EACA,IAAA,EACA,QAAA,EACA,SACA,OAAA,EACY;AACZ,EAAA,mBAAA,CAAoB,UAAU,UAAU,CAAA;AACxC,EAAA,QAAA,CAAS,mBAAmB,UAAU,CAAA;AACtC,EAAA,MAAM,QAAA,GAAW,YAAA;AAAA,IACf,MAAA;AAAA,IACA,QAAA;AAAA,IACA,OAAA;AAAA,IACA,SAAS,KAAA,IAAS;AAAA,GACpB;AACA,EAAA,MAAM,OAAA,GAAU,YAAA,CAAa,GAAA,CAAI,IAAI,CAAA;AACrC,EAAA,MAAA,CAAO,gBAAA,CAAiB,IAAA,EAAM,QAAA,EAAU,OAAO,CAAA;AAC/C,EAAA,OAAO,MAAM;AACX,IAAA,MAAA,CAAO,mBAAA,CAAoB,IAAA,EAAM,QAAA,EAAU,OAAO,CAAA;AAAA,EACpD,CAAA;AACF;AAuBO,SAAS,eAAA,CACd,MAAA,EACA,IAAA,EACA,QAAA,EACA,SACA,OAAA,EACY;AACZ,EAAA,mBAAA,CAAoB,UAAU,iBAAiB,CAAA;AAC/C,EAAA,QAAA,CAAS,mBAAmB,iBAAiB,CAAA;AAC7C,EAAA,MAAM,QAAA,GAAW,YAAA;AAAA,IACf,MAAA;AAAA,IACA,QAAA;AAAA,IACA,OAAA;AAAA,IACA,SAAS,KAAA,IAAS;AAAA,GACpB;AACA,EAAA,MAAA,CAAO,gBAAA,CAAiB,IAAA,EAAM,QAAA,EAAU,IAAI,CAAA;AAC5C,EAAA,OAAO,MAAM;AACX,IAAA,MAAA,CAAO,mBAAA,CAAoB,IAAA,EAAM,QAAA,EAAU,IAAI,CAAA;AAAA,EACjD,CAAA;AACF","file":"chunk-X6OQ2HTR.js","sourcesContent":["/**\n * Tiny event-delegation helpers. Replace per-element `addEventListener` calls\n * (which don't survive morph re-renders for nodes the diff creates) with one\n * listener at the morph-root that dispatches via `closest()`.\n *\n * Three-tier listener model:\n *\n * - Tier 1 (bubbling events) — use `delegate()`.\n * click, input, change, submit, mousedown/up, keydown/up, pointerdown/up/move,\n * drag*, drop, contextmenu, wheel, copy/paste/cut, focusin/focusout.\n *\n * `delegate()` also auto-promotes the well-known non-bubbling event\n * types (`focus`, `blur`, `scroll`, `load`, `error`, `mouseenter`,\n * `mouseleave`) to the capture phase under the hood, so the call site\n * looks identical for \"interactive thing happens on a descendant\"\n * regardless of whether that event bubbles. Selector matching stays\n * `closest()`-style — the same as for bubbling events — so a wrapper\n * selector like `'.field-row'` still matches when the event fires on\n * a descendant `<input>`.\n *\n * - Tier 2 (explicit capture) — use `delegateCapture()`.\n * The escape hatch for cases the auto-promotion list doesn't cover\n * (custom non-bubbling events) or when you want capture-phase\n * interception. Selector matching is `closest()`-style by default —\n * the same walk-up as `delegate()`, and it passes the matched ancestor\n * (not the raw target) to the handler — so a click on any descendant of\n * the selected element climbs to it. Pass `{ match: 'direct' }` to opt\n * into strict `matches()`-style matching (fire only when the event lands\n * on the exact element the selector identifies).\n *\n * - Tier 3 (per-element instances / library-owned subtrees) — mark the\n * host element with `data-morph-skip` and manage the library's\n * lifecycle directly. No delegation helper applies.\n */\n\nimport { devHooks } from './dev-hooks.js';\n\n/**\n * Event types that don't bubble and so wouldn't reach a root-level\n * bubble-phase listener. `delegate()` flips to capture for these; the\n * caller doesn't need to know or care.\n *\n * Membership is conservative — it covers the cases that \"should obviously\n * work\" with delegate() but otherwise don't. For exotic non-bubbling events\n * (custom events, less-common DOM events) the explicit `delegateCapture()`\n * remains the escape hatch.\n */\nconst NON_BUBBLING = new Set<string>([\n 'focus',\n 'blur',\n 'scroll',\n 'load',\n 'error',\n 'mouseenter',\n 'mouseleave',\n]);\n\n/**\n * How the selector is matched against the event's target:\n *\n * - `'closest'` (the default for both helpers) — walk UP from `event.target`\n * via `closest(selector)`, firing for the nearest matching ancestor inside\n * `rootEl`. This is the delegation behavior you almost always want: a click\n * on an icon inside a button fires the button's handler.\n * - `'direct'` — strict `matches()` match: fire only when `event.target`\n * itself matches the selector, with no walk-up.\n */\nexport interface DelegateOptions {\n match?: 'closest' | 'direct';\n}\n\n/**\n * Validate a CSS selector at registration time, so a typo throws immediately\n * with the bad selector quoted instead of producing a cryptic DOMException\n * the first time a matching event fires.\n */\nfunction assertValidSelector(selector: string, fn: string): void {\n try {\n document.createElement('div').matches(selector);\n } catch {\n throw new Error(\n `${fn}: invalid selector \"${selector}\". ` +\n \"Pass a valid CSS selector (e.g. '[data-action=\\\"add\\\"]', '.btn', 'input').\",\n );\n }\n}\n\n/**\n * Build the shared root-level listener used by both helpers. Resolves the\n * event's target to a matched element (walk-up `closest()` or strict\n * `matches()`, per `match`), requires the match to be inside `rootEl`, then\n * fires `handler(event, matched)`.\n */\nfunction makeListener<T extends Element>(\n rootEl: HTMLElement,\n selector: string,\n handler: (event: Event, target: T) => void,\n match: 'closest' | 'direct',\n): (event: Event) => void {\n return (event: Event): void => {\n const target = event.target;\n if (!(target instanceof Element)) return;\n const matched =\n match === 'direct'\n ? target.matches(selector)\n ? target\n : null\n : target.closest(selector);\n if (matched !== null && rootEl.contains(matched)) {\n handler(event, matched as T);\n }\n };\n}\n\n/**\n * Delegation that \"just works\" for both bubbling and the common non-bubbling\n * events. Installs ONE listener on `rootEl`; for known non-bubblers (see\n * `NON_BUBBLING` above) the listener is registered on the capture phase so\n * it actually reaches the target, otherwise on the bubble phase. Either way,\n * matching walks up from `event.target` via `closest(selector)` and fires\n * `handler(event, matched)` if the match is inside `rootEl`.\n *\n * Pass `{ match: 'direct' }` to fire only when `event.target` itself matches\n * the selector (no walk-up); the default is `'closest'`.\n *\n * The generic `T` narrows the second handler argument to the expected element\n * type — `delegate<HTMLButtonElement>(root, 'click', 'button', (e, btn) => btn.value)`\n * — so consumers can avoid casts. Defaults to `Element` for untyped calls.\n *\n * Returns a disposer that removes the listener.\n *\n * Usage (pseudo-code — see examples for live ones):\n * delegate(rootEl, 'click', '[data-action=\"add\"]', handlerFn);\n * delegate(rootEl, 'focus', 'input', handlerFn); // auto-capture\n */\nexport function delegate<T extends Element = Element>(\n rootEl: HTMLElement,\n type: string,\n selector: string,\n handler: (event: Event, target: T) => void,\n options?: DelegateOptions,\n): () => void {\n assertValidSelector(selector, 'delegate');\n devHooks.delegateInEffect?.('delegate');\n const listener = makeListener(\n rootEl,\n selector,\n handler,\n options?.match ?? 'closest',\n );\n const capture = NON_BUBBLING.has(type);\n rootEl.addEventListener(type, listener, capture);\n return () => {\n rootEl.removeEventListener(type, listener, capture);\n };\n}\n\n/**\n * Capture-phase delegation — the escape hatch for custom non-bubbling events\n * (ones `delegate()`'s auto-promotion list doesn't know about) and for\n * capture-phase interception (run before any descendant's bubble-phase\n * handler). Reaches descendants of `rootEl` that match `selector` regardless\n * of how many times the diff has rebuilt them.\n *\n * Selector matching is `closest()`-style by default — the same walk-up as\n * `delegate()`, and it passes the matched ancestor (not the raw target) to\n * the handler — so a click on any descendant of the selected element climbs\n * to it. Pass `{ match: 'direct' }` to opt into strict `matches()`-style\n * matching (fire only when the event lands on the exact element the selector\n * identifies, with no walk-up).\n *\n * The generic `T` narrows the second handler argument to the expected element\n * type, mirroring `delegate<T>()`. Defaults to `Element` for untyped calls.\n *\n * Usage (pseudo-code — see examples for live ones):\n * delegateCapture(rootEl, 'focus', 'input, textarea', handlerFn);\n * delegateCapture(rootEl, 'click', '.exact', handlerFn, { match: 'direct' });\n */\nexport function delegateCapture<T extends Element = Element>(\n rootEl: HTMLElement,\n type: string,\n selector: string,\n handler: (event: Event, target: T) => void,\n options?: DelegateOptions,\n): () => void {\n assertValidSelector(selector, 'delegateCapture');\n devHooks.delegateInEffect?.('delegateCapture');\n const listener = makeListener(\n rootEl,\n selector,\n handler,\n options?.match ?? 'closest',\n );\n rootEl.addEventListener(type, listener, true);\n return () => {\n rootEl.removeEventListener(type, listener, true);\n };\n}\n"]}