@daz4126/helium 1.0.0-rc.2 → 1.0.0-rc.4

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/README.md CHANGED
@@ -18,7 +18,7 @@ It's really simple to use - just sprinkle the magic @attributes into your HTML a
18
18
 
19
19
  Helium is designed for developers who want:
20
20
 
21
- - **Lightweight** - About 3.9–10.9KB minified and gzipped, depending on the build
21
+ - **Lightweight** - About 6.2–14.2KB minified and gzipped, depending on the build
22
22
  - **Powerful** - Declarative JavaScript in your HTML
23
23
  - **Zero build step** - Works directly in the browser with no compiling
24
24
  - **Easy to learn** - If you know HTML and basic JavaScript, you're ready
@@ -31,7 +31,7 @@ Helium comes in three core versions, with SSE available as an optional add-on, s
31
31
 
32
32
  The full-featured version with everything included.
33
33
 
34
- - All directives (`@text`, `@html`, `@for`, `@bind`, `@data`, `@scope`, `@ref`, `@calculate`, `@effect`, `@init`, etc.)
34
+ - All directives (`@text`, `@html`, `@for`, `@if`, `@bind`, `@data`, `@ref`, `@calculate`, `@effect`, `@init`, etc.)
35
35
  - Event handlers with modifiers
36
36
  - HTTP requests (`@get`, `@post`, `@put`, `@patch`, `@delete`)
37
37
  - Module imports (`@import`)
@@ -51,7 +51,7 @@ A slimmed-down version with just the core reactivity features.
51
51
  - Core directives (`@text`, `@html`, `@bind`, `@data`, `@ref`, `@calculate`, `@effect`, `@init`)
52
52
  - Event handlers with modifiers
53
53
  - One global state namespace
54
- - No `@scope`, keyed `@for`, HTTP requests, imports, SSE, or Turbo integration
54
+ - No local `@data`, keyed `@for`, HTTP requests, imports, SSE, or Turbo integration
55
55
 
56
56
  **Best for:** Simple interactive pages where you only need reactivity and don't need server communication or module imports.
57
57
 
@@ -104,23 +104,25 @@ import helium from "@daz4126/helium/csp"
104
104
  | Feature | Standard | Lite | CSP |
105
105
  |---------|----------|------|-----|
106
106
  | Core directives | ✅ | ✅ | ✅ |
107
- | Element-local `@scope` | ✅ | ❌ | ✅ |
107
+ | Element-local `@data` | ✅ | ❌ | ✅ |
108
108
  | Keyed `@for` | ✅ | ❌ | ✅ |
109
+ | Conditional `@if` | ✅ | ❌ | ✅ |
109
110
  | Event handlers | ✅ | ✅ | ✅ |
110
111
  | HTTP requests | ✅ | ❌ | ✅ |
111
112
  | `@import` | ✅ | ❌ | ✅ |
112
113
  | SSE | Add-on | ❌ | Add-on |
113
114
  | Turbo integration | ✅ | ❌ | ✅ |
114
115
  | CSP safe | ❌ | ❌ | ✅ |
115
- | Approx. min+gzip size | 6.49KB | 3.93KB | 10.56KB |
116
+ | Approx. min+gzip size | 9.62KB | 6.18KB | 13.75KB |
116
117
 
117
118
  Sizes are enforced against the production Terser builds with gzip level 9. The
118
- CSP figure includes its expression parser. Adding SSE produces 6.86KB Standard
119
- and 10.94KB CSP bundles.
119
+ CSP figure includes its expression parser. Adding SSE produces 10.00KB Standard
120
+ and 14.12KB CSP bundles.
120
121
 
121
122
  Lite is generated from the same global-state reactive core as Standard. It has
122
- identical deep/shared reactivity, lifecycle, binding, and cleanup behavior, but
123
- intentionally excludes element-local scopes and Standard's other extensions.
123
+ identical deep/shared reactivity, lifecycle, binding, and cleanup behavior for
124
+ root state, but intentionally excludes element-local data and Standard's other
125
+ extensions.
124
126
 
125
127
  ## Installation
126
128
 
@@ -300,6 +302,32 @@ but keyed `@for` avoids HTML-string interpolation and lets each row use normal
300
302
 
301
303
  **Alias:** `data-he-for` (the key remains `:key`)
302
304
 
305
+ ### @if
306
+
307
+ Standard and CSP can conditionally render a `<template>`'s content. Unlike
308
+ `@hidden`/`@visible`, which only toggle visibility, `@if` adds the content to
309
+ the DOM when the expression is truthy and fully removes it — bindings,
310
+ listeners, and `@init` cleanup included — when it becomes falsy:
311
+
312
+ ```html
313
+ <template @if="loggedIn">
314
+ <p>Welcome back, <span @text="user.name"></span>!</p>
315
+ <button @click="logout($data)">Log out</button>
316
+ </template>
317
+ ```
318
+
319
+ Content that is switched off costs nothing: its expressions never evaluate and
320
+ its listeners don't exist. Use `@if` for anything conditional-but-expensive
321
+ (modals, tab panels, admin-only markup) and `@hidden`/`@visible` for cheap
322
+ show/hide toggles where you want to preserve element state such as form input.
323
+
324
+ `@if` requires a `<template>` element (like `@for`, and like Alpine's `x-if`)
325
+ because template content is inert until inserted — that's what makes the "off"
326
+ state free. It cannot be combined with `@for` on the same template; nest one
327
+ template inside the other instead. Lite excludes this feature.
328
+
329
+ **Alias:** `data-he-if`
330
+
303
331
  ### @bind
304
332
 
305
333
  Creates a 2-way binding between an input element's value and a variable. Whatever is entered in the following input field will be stored as a variable called name:
@@ -389,31 +417,23 @@ Makes the element hidden or visible depending on the result of a JavaScript expr
389
417
 
390
418
  ### @data
391
419
 
392
- Initializes variables that can be used in JavaScript expressions. This is useful for setting up initial state.
420
+ Initializes variables that can be used in JavaScript expressions. In Standard
421
+ and CSP, `@data` behaves like Alpine's `x-data`: root-level `@data` initializes
422
+ the mounted root state, while nested `@data` creates local state for that
423
+ element and its descendants. Lite always uses one global state namespace, so
424
+ every `@data` merges into root state.
393
425
 
394
426
  ```html
395
- <div @data="{ count: 0, open: false, name: 'Helium' }"></div>
396
- ```
397
-
398
- You can then use these variables in other Helium attributes:
399
-
400
- ```html
401
- <div @data="{ count: 0 }">
402
- <button @click="count++">Increment</button>
403
- <p @text="count"></p>
404
- </div>
427
+ <main @helium @data="{ count: 100, name: 'Helium' }">
428
+ <p @text="name + ': ' + count"></p>
429
+ </main>
405
430
  ```
406
431
 
407
- **Alias:** `data-he-data`
408
-
409
- ### @scope (Standard/CSP)
410
-
411
- Creates reactive state owned by an element and its descendants. Scoped names
412
- shadow root state without changing the object returned by `helium()`:
432
+ Nested `@data` is local in Standard/CSP:
413
433
 
414
434
  ```html
415
- <main @data="{ count: 100 }">
416
- <section @scope="{ count: 0, open: false }">
435
+ <main @helium @data="{ count: 100 }">
436
+ <section @data="{ count: 0, open: false }">
417
437
  <button @click="count++">Increment local count</button>
418
438
  <button @click="open = !open">Toggle</button>
419
439
  <p @text="count"></p>
@@ -424,41 +444,49 @@ shadow root state without changing the object returned by `helium()`:
424
444
  </main>
425
445
  ```
426
446
 
427
- The scope expression runs once in its parent context, so defaults can use outer
428
- values. Nested scopes inherit outer names, and assignment updates the nearest
429
- scope that declares that name:
447
+ The object is evaluated once in its parent context, so local defaults can use
448
+ outer values. Nested data objects inherit outer names, and assignment updates
449
+ the nearest data object that declares that name:
430
450
 
431
451
  ```html
432
- <div @scope="{ count: startingCount, label: 'outer' }">
433
- <div @scope="{ label: 'inner' }">
452
+ <div @data="{ count: startingCount, label: 'outer' }">
453
+ <div @data="{ label: 'inner' }">
434
454
  <button @click="count++">Updates the outer local count</button>
435
455
  <span @text="label"></span> <!-- inner -->
436
456
  </div>
437
457
  </div>
438
458
  ```
439
459
 
440
- Declare every name that should remain local. Assigning an undeclared name keeps
441
- the existing Helium behavior and writes to root state. The same applies to
442
- `@bind` and `@calculate` targets: declare their target names in `@scope` when
443
- they should be local. Inside the subtree, `$data` is the scoped state view.
444
- Local values are not included in root `@local-storage` persistence.
445
-
446
- `@data` continues to merge into root state; it has not changed semantics.
460
+ Declare every name that should remain local. Assigning an undeclared name writes
461
+ to root state. The same applies to `@bind` and `@calculate` targets: declare
462
+ their target names in local `@data` when they should be local. Inside the
463
+ subtree, `$data` is the scoped state view. Local values are not returned from
464
+ `helium()` and are not included in root `@local-storage` persistence.
447
465
 
448
- **Alias:** `data-he-scope`
449
-
450
- `@scope` is intentionally excluded from Lite, which always uses one global
451
- state namespace per mounted root.
466
+ **Alias:** `data-he-data` (the legacy bare `data-he` alias is also accepted)
452
467
 
453
468
  ### @ref
454
469
 
455
- Creates a reference to the element that can be used in JavaScript expressions. References are prefixed with `$` when accessed.
470
+ Creates a scoped reference to an element. The first matching element is
471
+ available through `$name`, and every matching element is available as the
472
+ always-array `$refs.name` in current DOM order:
456
473
 
457
474
  ```html
458
- <ul @ref="list"></ul>
475
+ <section @data="{ items: [] }">
476
+ <ul @ref="list"></ul>
477
+ <div @ref="row"></div>
478
+ <div @ref="row"></div>
479
+ <button @click="console.log($list, $refs.row)">Inspect refs</button>
480
+ </section>
459
481
  ```
460
482
 
461
- This element can then be accessed in other JavaScript expressions as `$list`:
483
+ Ref lookup starts in the nearest explicit `@data` scope and falls back through
484
+ parent scopes. A ref with the same name in a nested scope shadows the outer
485
+ name. Lite has one root scope, so all of its refs belong to the mounted root.
486
+ When refs are removed or reordered, singular and plural lookup follows the
487
+ current DOM.
488
+
489
+ This element can be accessed in other JavaScript expressions as `$list`:
462
490
 
463
491
  ```html
464
492
  <button @click="$list.appendChild($html('<li>New item</li>'))">Add Task</button>
@@ -648,11 +676,22 @@ You can add modifiers by appending them with a dot (`.`) after the event name:
648
676
  - **once** - Only runs the event handler once, then removes the listener
649
677
  - **outside** - Only fires when the event happens outside the element
650
678
  - **document** - Attaches the listener to the document instead of the element
679
+ - **window** - Attaches the listener to `window` instead of the element
680
+ - **self** - Only fires when the element itself is the event target
681
+ - **capture** - Handles the event during the capture phase
682
+ - **passive** - Registers a native passive listener
651
683
  - **debounce** - Debounces the event handler (default 300ms)
652
684
  - **debounce:500** - Debounces with custom delay in milliseconds
685
+ - **throttle** - Runs immediately, then at most once per interval (default 300ms)
686
+ - **throttle:500** - Throttles with a custom interval in milliseconds
653
687
  - **shift, ctrl, alt, meta** - Only fires if the modifier key is pressed
654
688
  - **Key names** - For keyboard events, specify which key (e.g., `enter`, `esc`, `space`)
655
689
 
690
+ Throttle uses leading and trailing calls: the first matching event runs
691
+ immediately, and events received during the interval are coalesced into one
692
+ final call using the latest event. `.passive.prevent` is invalid because a
693
+ passive listener cannot cancel an event; Helium warns and does not register it.
694
+
656
695
  **Examples:**
657
696
 
658
697
  ```html
@@ -682,6 +721,12 @@ You can add modifiers by appending them with a dot (`.`) after the event name:
682
721
 
683
722
  <!-- Listen on document level -->
684
723
  <div @keydown.document.esc="closeModal()">Press ESC anywhere</div>
724
+
725
+ <!-- Window listener with throttled updates -->
726
+ <div @resize.window.throttle:100="measureLayout()"></div>
727
+
728
+ <!-- Ignore clicks that originated in descendants -->
729
+ <button @click.self="select()"><span>Select</span></button>
685
730
  ```
686
731
 
687
732
  **Alias:** Prepend the event name with `data-he-`, for example `data-he-click="count++"`
@@ -1000,13 +1045,26 @@ You can also use a string:
1000
1045
  These special variables are available in all JavaScript expressions:
1001
1046
 
1002
1047
  ### $
1003
- Alias for `document.querySelector` - quickly select elements:
1048
+ The `$` query helper preserves `document.querySelector` as its default and adds
1049
+ array-returning plural and scoped forms:
1004
1050
 
1005
1051
  ```html
1006
1052
  <div @click="$('#header').classList.add('active')">Activate Header!</div>
1007
1053
  <button @click="$('.sidebar').style.display = 'none'">Hide Sidebar</button>
1008
1054
  ```
1009
1055
 
1056
+ ```js
1057
+ $(selector) // first match in document
1058
+ $.all(selector) // all matches in document
1059
+ $.root(selector) // first descendant match in $root
1060
+ $.root.all(selector) // all descendant matches in $root
1061
+ $.scope(selector) // first descendant match in $scope
1062
+ $.scope.all(selector) // all descendant matches in $scope
1063
+ ```
1064
+
1065
+ The plural forms return normal arrays. Like `querySelector`, the root and scope
1066
+ forms search descendants and do not include `$root` or `$scope` themselves.
1067
+
1010
1068
  ### $el
1011
1069
  Reference to the current element:
1012
1070
 
@@ -1016,15 +1074,41 @@ Reference to the current element:
1016
1074
  <input @input="console.log($el.value)">
1017
1075
  ```
1018
1076
 
1077
+ ### $root
1078
+
1079
+ The actual root element owned by the current Helium instance. This is the
1080
+ element selected by `@helium`/`data-helium`, the element passed to
1081
+ `helium.mount()`, or `document.body` for the default fallback root.
1082
+
1083
+ ### $scope
1084
+
1085
+ The nearest element that declares local `@data` state, falling back to `$root`.
1086
+ Lite has no local data scopes, so `$scope` and `$root` are always the same.
1087
+
1019
1088
  ### $event
1020
1089
  The event object (available in event handlers):
1021
1090
 
1022
1091
  ```html
1023
1092
  <div @click="console.log($event.timeStamp)">Log the timestamp</div>
1024
1093
  <input @keydown="$event.key === 'Enter' && submit()">
1025
- <form @submit="$event.preventDefault(); handleSubmit()">
1094
+ <form @submit.prevent="handleSubmit()">
1026
1095
  ```
1027
1096
 
1097
+ ### $dispatch
1098
+
1099
+ Dispatch a bubbling, cancelable `CustomEvent` from the current element. The
1100
+ event is returned, so callers can inspect `defaultPrevented`:
1101
+
1102
+ ```html
1103
+ <section @cart:add="total += $event.detail.price">
1104
+ <button @click="$dispatch('cart:add', { price: 12 })">Add</button>
1105
+ </section>
1106
+ ```
1107
+
1108
+ The signature is `$dispatch(name, detail = {}, options = {})`. Options can set
1109
+ `target` (default `$el`), `bubbles` (default `true`), and `cancelable` (default
1110
+ `true`).
1111
+
1028
1112
  ### $data
1029
1113
  The reactive data object containing all Helium variables:
1030
1114
 
@@ -1060,14 +1144,49 @@ The arguments are `url`,`params` (not for `$get`) and `options`. `options` is an
1060
1144
 
1061
1145
  ### Named refs
1062
1146
 
1063
- Each element marked with `@ref="name"` is available as `$name`. There is no
1064
- separate `$refs` object:
1147
+ The first element marked with `@ref="name"` in the nearest defining scope is
1148
+ available as `$name`. `$refs.name` is an array of every matching ref in that
1149
+ scope, in current DOM order:
1065
1150
 
1066
1151
  ```html
1067
1152
  <input @ref="username">
1068
1153
  <button @click="console.log($username.value)">Log Username</button>
1154
+
1155
+ <div @ref="item"></div>
1156
+ <div @ref="item"></div>
1157
+ <button @click="console.log($refs.item.length)">Log item count</button>
1158
+ ```
1159
+
1160
+ ## Expression Contract
1161
+
1162
+ Directive values are **single expressions**, in both Standard and CSP. Common
1163
+ expressions include property reads, assignments, function calls, object and
1164
+ array literals, ternaries, logical operators, and arrow functions:
1165
+
1166
+ ```html
1167
+ <button @click="open = !open">Toggle</button>
1168
+ <button @click="save($data, $el)">Save</button>
1169
+ <p @text="loading ? 'Loading…' : message"></p>
1069
1170
  ```
1070
1171
 
1172
+ Top-level statement lists, declarations, and control-flow statements are not
1173
+ directive syntax. Prefer event modifiers and move multi-step behavior into a
1174
+ named function:
1175
+
1176
+ ```html
1177
+ <!-- Not supported: two top-level statements -->
1178
+ <form @submit="$event.preventDefault(); save()"></form>
1179
+
1180
+ <!-- Preferred -->
1181
+ <form @submit.prevent="save($data)"></form>
1182
+ ```
1183
+
1184
+ The Standard build uses the JavaScript expression grammar. The CSP build uses
1185
+ its restricted expression parser and additionally excludes `new` and some
1186
+ JavaScript expression forms; see the CSP build notes above. Functions declared inside
1187
+ an `@data` object or imported from a module are ordinary JavaScript functions
1188
+ and may contain statements internally.
1189
+
1071
1190
  ## Functions
1072
1191
 
1073
1192
  Functions can be imported using `@import` or defined using the `@data` attribute.
@@ -1164,6 +1283,29 @@ This is the recommended approach when you need to update variables:
1164
1283
 
1165
1284
  ## Advanced Features
1166
1285
 
1286
+ ### Batched Updates
1287
+
1288
+ State changes are batched: a burst of synchronous mutations triggers a single
1289
+ DOM update on the next microtask, and each affected binding runs once no matter
1290
+ how many mutations fed it. This keeps loops over state cheap — updating 100
1291
+ items in a keyed list re-renders the list once, not 100 times:
1292
+
1293
+ ```javascript
1294
+ // One DOM update, not 100
1295
+ for (let i = 0; i < 100; i++) state.items[i].done = true;
1296
+ ```
1297
+
1298
+ Two practical consequences:
1299
+
1300
+ - **Reading the DOM right after a mutation shows the old value.** Await a
1301
+ microtask first: `state.count = 5; await Promise.resolve();` (in tests, a
1302
+ `setTimeout(0)` tick also covers content inserted by `@if`/`@for`, which is
1303
+ processed asynchronously by the MutationObserver).
1304
+ - **Assigning a value that is already current is a no-op** — it doesn't
1305
+ re-trigger bindings, so effects that write unchanged state settle instead of
1306
+ looping. A binding cascade that *never* stops writing new values (e.g.
1307
+ `@effect:*="count++"`) is detected and reported after 100 passes.
1308
+
1167
1309
  ### DOM Morphing with Idiomorph
1168
1310
 
1169
1311
  By default, when you update innerHTML with `@html`, Helium replaces the entire content. This can cause issues like losing focus, resetting scroll positions, or interrupting animations.
@@ -1206,7 +1348,28 @@ Without keys, the entire list is re-rendered. With keys, only changed items are
1206
1348
 
1207
1349
  ### MutationObserver
1208
1350
 
1209
- Helium automatically observes the DOM and processes new elements as they're added. This means Helium works seamlessly with:
1351
+ Helium automatically observes the DOM, processes new elements as they're added,
1352
+ and reconciles Helium directives when attributes on retained elements change.
1353
+ Adding, replacing, or removing an event, binding, ref, effect, initializer, or
1354
+ structural directive installs the new behavior and tears down the old behavior
1355
+ without rebuilding unrelated parts of the subtree.
1356
+
1357
+ For programmatic changes, use the `data-he-*` aliases: DOM `setAttribute()`
1358
+ rejects names such as `@click` in some browsers and DOM implementations even
1359
+ though the HTML parser accepts them.
1360
+
1361
+ ```js
1362
+ button.setAttribute("data-he-click", "save()")
1363
+ button.setAttribute("data-he-click", "archive()") // replaces save
1364
+ button.removeAttribute("data-he-click") // removes the listener
1365
+ ```
1366
+
1367
+ Changing local `data-he-data` recreates that local scope and re-tracks its
1368
+ descendant bindings. HTTP option attributes such as `data-he-target` are read
1369
+ when the request runs, so their current values are used automatically. Root
1370
+ selection and `@local-storage` remain mount-time configuration.
1371
+
1372
+ This means Helium works seamlessly with:
1210
1373
 
1211
1374
  - Dynamically inserted content
1212
1375
  - Content loaded via AJAX
@@ -1306,6 +1469,18 @@ import helium from "@daz4126/helium/csp"
1306
1469
  <div @effect:page="analytics.track('page_view', { page })"></div>
1307
1470
  ```
1308
1471
 
1472
+ **Benchmarking:**
1473
+
1474
+ The repo includes a head-to-head runtime benchmark against Alpine.js, React,
1475
+ and Datastar (keyed list create/update/swap/clear plus rapid counter updates,
1476
+ run in real Chromium; Datastar only runs the counter test since it renders
1477
+ lists server-side). Run it with:
1478
+
1479
+ ```bash
1480
+ npm run bench # defaults: 1000 rows, 7 runs
1481
+ node scripts/benchmark.mjs --rows 5000 --runs 5
1482
+ ```
1483
+
1309
1484
  ### Structuring Larger Apps
1310
1485
 
1311
1486
  **Organize state at the root:**
@@ -1411,7 +1586,11 @@ helium({
1411
1586
 
1412
1587
  ### JavaScript Expression Errors
1413
1588
 
1414
- If a binding expression throws at runtime, Helium logs the error and continues without updating that binding. Standard and lite retain malformed expressions as literal text for compatibility; quote intentional string constants, for example `@text="'Ready'"`.
1589
+ If a binding expression throws at runtime, Helium logs the error and continues
1590
+ without updating that binding. Failed compilation keeps Helium's literal-string
1591
+ ergonomics: plain text such as `@text="Loading..."` stays quiet, while
1592
+ expression-looking failures such as `@text="user.name."` or
1593
+ `@click="count +"` emit a `console.warn` with the attribute and expression.
1415
1594
 
1416
1595
  ```html
1417
1596
  <!-- If items is undefined, this won't crash the page -->
@@ -1434,8 +1613,10 @@ promises, so you can handle failures explicitly:
1434
1613
 
1435
1614
  ### Invalid Attribute Syntax
1436
1615
 
1437
- Standard and Lite retain expressions that cannot be compiled as literal values.
1438
- The CSP build logs the parser error and leaves the binding undefined.
1616
+ Helium keeps plain malformed value expressions as literal values. If a failed
1617
+ expression looks like broken JavaScript, Helium emits a warning with directive
1618
+ context. Event handlers, HTTP options, `@data`, `@for`, `@if`, `@calculate`,
1619
+ `@effect`, and `@init` are treated strictly and warn on compile failure.
1439
1620
 
1440
1621
 
1441
1622
  ## Roadmap & Known Limitations
@@ -1445,43 +1626,106 @@ intentionally absent (or still on the roadmap). Current gaps:
1445
1626
 
1446
1627
  ### Not yet supported
1447
1628
 
1448
- - **No DOM-removing `@if`** — only `@hidden`/`@visible`, which toggle visibility
1449
- but keep elements in the DOM.
1450
1629
  - **No `$watch`/`$nextTick`, and no cross-root shared store** — each mounted
1451
1630
  instance has its own isolated state namespace.
1452
-
1453
- ### Roadmap
1454
-
1455
- Ordered by recommended implementation sequence:
1456
-
1457
- 1. **Improve expression-compilation diagnostics** — emit a useful `console.warn`
1458
- containing the expression and build/engine context instead of silently
1459
- treating every compile failure as a literal value.
1460
- 2. **Add real-browser CI** covering CSP, Turbo, SSE, focus preservation, late
1461
- imports, generated Lite output, and package exports before adding more DOM
1462
- lifecycle behavior.
1463
- 3. **Document function and `this` context limitations** alongside the existing
1464
- magic-variable guidance, with correct patterns for passing `$data`, `$el`,
1465
- and other context explicitly.
1466
- 4. **Add opt-in required-CSRF validation for POST/PUT/PATCH** — fail before the
1467
- request when an application declares that a token is mandatory, without
1468
- breaking tokenless APIs and cross-origin requests.
1469
- 5. **Add request cancellation and explicit success/error hooks** using
1470
- `AbortController`; define the request lifecycle before expanding loading and
1471
- error-state APIs.
1472
- 6. **Document a form-validation pattern first**, using native constraint
1473
- validation and Helium state; add helpers only if examples reveal recurring
1474
- boilerplate.
1475
- 7. **Create a complete error-state example** covering validation, HTTP failure,
1476
- retry, accessible messaging, and clearing stale errors.
1477
- 8. **Complete HTTP loading-state lifecycle behavior** — `@loading` already
1478
- exists, so define consistent success, error, cancellation, and restoration
1479
- behavior rather than adding a second loading mechanism.
1480
- 9. **Add debounced two-way binding updates if needed** after reproducing a real
1481
- feedback/caret loop; event-handler debouncing already covers ordinary input
1482
- throttling.
1483
- 10. **Add a minimal watch/middleware system** together with a clear size budget;
1484
- avoid adopting Alpine-scale surface area without demonstrated use cases.
1631
+ - **No transition helper** — use CSS transitions/classes directly with
1632
+ `@hidden`, `@visible`, or `@if`.
1633
+
1634
+ ### Stimulus-parity roadmap
1635
+
1636
+ The goal is not to copy Stimulus's controller syntax. Simple behavior should
1637
+ remain local (`@click="open = !open"`, `@bind="name"`); the roadmap supplies the
1638
+ lifecycle and JavaScript escape hatches needed when behavior becomes complex.
1639
+
1640
+ #### P0 — 1.0 release foundations
1641
+
1642
+ - [x] **Define the expression and function-context contract.** Directive values
1643
+ are expressions; multi-step work belongs in named functions with `$data`,
1644
+ `$el`, refs, and other required context passed explicitly.
1645
+ - [x] **Reconcile directive attributes on retained elements.** Attribute add,
1646
+ replace, and removal now install and tear down bindings, events, refs,
1647
+ initializers, local data, and structural directives independently.
1648
+ - [x] **Run the production browser suite in Chromium, Firefox, and WebKit.** Keep
1649
+ the same CSP, Turbo, SSE, keyed identity, dynamic-directive, late-import,
1650
+ Lite, and entry-point checks in every engine.
1651
+
1652
+ #### P1 — complex-behavior escape hatches
1653
+
1654
+ 1. **Formalize lifecycle modules over `@init`.** Document the existing
1655
+ `@import` + `@init` setup-function convention: imported setup receives its
1656
+ required element, state, refs, and dispatch context explicitly and returns
1657
+ cleanup. Keep ordinary behavior inline and move only complex imperative work
1658
+ into a lifecycle module.
1659
+
1660
+ ```js
1661
+ export function connectFeature(element, data, refs) {
1662
+ // setup
1663
+ return () => {
1664
+ // cleanup
1665
+ }
1666
+ }
1667
+ ```
1668
+
1669
+ 2. **Formally support and test namespaced initializers.** Allow independent
1670
+ behaviors such as `@init:editor` and `@init:tooltip` on one element, with
1671
+ isolated replacement and cleanup when either attribute changes.
1672
+
1673
+ ```html
1674
+ <div
1675
+ @init:editor="connectEditor($el, $data, $refs)"
1676
+ @init:tooltip="connectTooltip($el)"
1677
+ ></div>
1678
+ ```
1679
+
1680
+ 3. **Add unified lifecycle error handling.** Route initializer setup, async
1681
+ rejection, cleanup, binding, event, import, and request failures through an
1682
+ instance-level `handleError(error, context)` hook and optional debug mode.
1683
+ 4. **Add an initializer-scoped `AbortSignal`.** Give each initializer a signal
1684
+ that aborts when its attribute is replaced, removed, its element disconnects,
1685
+ or its Helium root unmounts, so listeners and pending external work can use
1686
+ native cancellation.
1687
+ 5. **Support asynchronous setup and cleanup safely.** Recognize setup promises,
1688
+ avoid registering stale results after disconnection or replacement, run a
1689
+ cleanup returned asynchronously, and report rejected setup/cleanup through
1690
+ the lifecycle error hook.
1691
+ 6. **Add programmatic `watch()`.** Let imported setup functions observe reactive
1692
+ state without exporting update functions or storing external instances in a
1693
+ module-level `WeakMap`; include current/previous values and lifecycle-bound
1694
+ cleanup. Keep middleware separate and lower priority.
1695
+ 7. **Publish TypeScript declarations** for mounting, factories, expression
1696
+ engines, CSP globals, SSE, lifecycle errors, initializer context, signals,
1697
+ and watching.
1698
+
1699
+ #### P2 — lifecycle depth and application maturity
1700
+
1701
+ 8. **Observe ref collections** so complex behaviors can react when matching
1702
+ refs connect, disconnect, or reorder; this covers Stimulus target callbacks
1703
+ without changing simple `$ref`/`$refs` usage.
1704
+ 9. **Complete the HTTP lifecycle:** request cancellation with `AbortController`,
1705
+ explicit success/error hooks, consistent `@loading` restoration, and opt-in
1706
+ required-CSRF validation for state-changing same-origin requests.
1707
+ 10. **Evaluate behavior lookup only after lifecycle modules are established.**
1708
+ Prefer `$dispatch` for loose coordination; add outlet-like instance lookup
1709
+ only if real applications require direct behavior-to-behavior calls.
1710
+
1711
+ #### P3 — evidence-driven conveniences
1712
+
1713
+ 11. **Document complete validation and error-state patterns** covering native
1714
+ constraint validation, HTTP failure, retry, accessible messages, and stale
1715
+ error clearing before adding validation helpers.
1716
+ 12. **Consider extensible event modifiers and structured action parameters.**
1717
+ Expressions already cover most cases, so add APIs only for repeated needs.
1718
+ 13. **Revisit `@use`, debounced binding, middleware, transitions, and a shared
1719
+ store** only with concrete use cases and explicit size budgets. Event
1720
+ debouncing, CSS transitions, custom events, and independent roots cover the
1721
+ common forms today. Add `@use` only if lifecycle modules reveal a need for
1722
+ behavior registration, private instances, or package interoperability
1723
+ beyond namespaced `@init`.
1724
+
1725
+ This ordering moves the former HTTP/form-heavy roadmap behind lifecycle,
1726
+ diagnostics, watching, and types. The HTTP, CSRF, loading, validation, and error
1727
+ example work remains planned, but it does not by itself close the most important
1728
+ Stimulus-parity gaps.
1485
1729
 
1486
1730
  ## Contributing
1487
1731
 
@@ -1491,9 +1735,12 @@ Helium is open source! Contributions, issues, and feature requests are welcome.
1491
1735
  - Report issues: Create an issue on GitHub
1492
1736
  - Suggest features: Open a discussion on GitHub
1493
1737
 
1494
- Before submitting a change, run `npm run check`. It executes the full test
1495
- suite, creates the production bundles, and fails if any gzip size budget is
1496
- exceeded. `npm run size` also reports raw, gzip, and Brotli sizes.
1738
+ Before submitting a change, run `npm run check`. It executes the full jsdom test
1739
+ suite, creates the production bundles, smoke-tests them, and fails if any gzip
1740
+ size budget is exceeded. `npm run test:browser` runs the cross-browser smoke suite
1741
+ used by CI for CSP, Turbo, SSE, keyed focus preservation, late imports, Lite
1742
+ generation, and browser entry imports. `npm run check:ci` runs both paths.
1743
+ `npm run size` also reports raw, gzip, and Brotli sizes.
1497
1744
 
1498
1745
  `helium.js` contains the shared runtime plus sections marked Standard-only.
1499
1746
  `npm run generate` derives `helium-lite.js`; do not edit the generated Lite file