@wcstack/state 1.26.0 → 1.28.0

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.ja.md CHANGED
@@ -95,7 +95,7 @@
95
95
  - **宣言的データバインディング** — `data-wcs` 属性によるプロパティ / テキスト / イベント / 構造バインディング
96
96
  - **リアクティブ Proxy** — ES Proxy による依存追跡付き自動 DOM 更新
97
97
  - **構造ディレクティブ** — `<template>` 要素による `for`, `if` / `elseif` / `else`
98
- - **組み込みフィルタ** — フォーマット、比較、算術、日付など 40 種類
98
+ - **組み込みフィルタ** — フォーマット、比較、算術、日付など 46 種類
99
99
  - **双方向バインディング** — `<input>`, `<select>`, `<textarea>` で自動有効
100
100
  - **Web Component バインディング** — Shadow DOM コンポーネントとの双方向状態バインディング
101
101
  - **command token** — pub/sub チャネル(`command.<method>: tokenName`)で state から wc-bindable カスタム要素のメソッドを起動
@@ -220,7 +220,7 @@
220
220
 
221
221
  解決順序: `state` → `src` (.json / .js) → `json` → 内包 `<script>` → `setInitialState()` 待機。
222
222
 
223
- > **Content-Security-Policy 下では:** 5 番(内包 `<script type="module">`)は `blob:` URL 経由で評価されるため `script-src blob:` が必要です。ページの nonce では救えません。厳格な CSP を敷く場合は 4 番(`src="./state.js"`)を使ってください。追加ディレクティブは不要です。詳細は [docs/csp.md](../../docs/csp.md)。
223
+ > **Content-Security-Policy 下では:** 5 番(内包 `<script type="module">`)は `blob:` URL 経由で評価されるため `script-src blob:` が必要です。ページの nonce では救えません。厳格な CSP を敷く場合は 4 番(`src="./state.js"`)を使ってください。追加ディレクティブは不要です。詳細は [docs/csp.ja.md](../../docs/csp.ja.md)。
224
224
 
225
225
  ### 名前付き状態
226
226
 
@@ -394,7 +394,7 @@ authority はバインディング単位で `#init=` により上書きできま
394
394
 
395
395
  - `enableDirectionalInitialSync: false`(opt-out)のとき `#init=`/`#sync=` を書くと throw します。
396
396
  - **1.20 以前からの移行:** output-only メンバに対して state 側に都合のよい初期値(`value: []`、`query: ""` 等)をシードしないでください — 要素側の実初期値(多くは `null`/`undefined`)がシードを置き換えます。シードは要素の実初期値に合わせ、表示用の値は派生 getter で null ガードしてください。
397
- - **1.21.x まで**、`init=element` / `init=auto` / `init=none` はバインディングの生存期間全体で state→element 書き込みを抑止しており、真に双方向なメンバには使えませんでした。現在は authority は初期同期のみを支配します(`docs/architecture-hardening/09-remediation-design.md` §3.6)。
397
+ - **1.21.x まで**、`init=element` / `init=auto` / `init=none` はバインディングの生存期間全体で state→element 書き込みを抑止しており、真に双方向なメンバには使えませんでした。現在は authority は初期同期のみを支配します(`docs/architecture-hardening/09-remediation-design.ja.md` §3.6)。
398
398
 
399
399
  ### ラジオボタンバインディング
400
400
 
@@ -985,7 +985,7 @@ export default {
985
985
 
986
986
  ## フィルタ
987
987
 
988
- 40 種類の組み込みフィルタが入力(DOM → 状態)と出力(状態 → DOM)の両方向で利用できます。
988
+ 46 種類の組み込みフィルタが入力(DOM → 状態)と出力(状態 → DOM)の両方向で利用できます。
989
989
 
990
990
  ### 比較
991
991
 
@@ -1008,6 +1008,8 @@ export default {
1008
1008
  | `mul(n)` | 乗算 | `price\|mul(1.1)` |
1009
1009
  | `div(n)` | 除算 | `total\|div(100)` |
1010
1010
  | `mod(n)` | 剰余 | `index\|mod(2)` |
1011
+ | `abs` | 絶対値 | `delta\|abs` |
1012
+ | `clamp(min, max)` | 範囲内に丸める | `ratio\|clamp(0,100)` |
1011
1013
 
1012
1014
  ### 数値フォーマット
1013
1015
 
@@ -1019,6 +1021,7 @@ export default {
1019
1021
  | `ceil(n?)` | 切り上げ | `value\|ceil` |
1020
1022
  | `locale(loc?)` | ロケール数値フォーマット | `count\|locale` / `count\|locale(ja-JP)` |
1021
1023
  | `percent(n?)` | パーセンテージフォーマット | `ratio\|percent(1)` |
1024
+ | `unit(u)` | 単位(任意の接尾辞)を付加 | `width\|unit(px)` → `"40px"` |
1022
1025
 
1023
1026
  ### 文字列
1024
1027
 
@@ -1033,6 +1036,8 @@ export default {
1033
1036
  | `pad(n, char?)` | 先頭パディング | `id\|pad(5,0)` → `"00001"` |
1034
1037
  | `rep(n)` | 繰り返し | `text\|rep(3)` |
1035
1038
  | `rev` | 反転 | `text\|rev` |
1039
+ | `truncate(n, suffix?)` | 切り詰めて省略記号を付加 | `title\|truncate(20)` → `"…"` 付き |
1040
+ | `join(sep?)` | 配列を連結(既定 `", "`) | `tags\|join` / `tags\|join(/)` |
1036
1041
 
1037
1042
  ### 型変換
1038
1043
 
@@ -1053,6 +1058,7 @@ export default {
1053
1058
  | `time(loc?)` | 時刻フォーマット | `timestamp\|time` |
1054
1059
  | `datetime(loc?)` | 日付 + 時刻 | `timestamp\|datetime(en-US)` |
1055
1060
  | `ymd(sep?)` | YYYY-MM-DD | `timestamp\|ymd` / `timestamp\|ymd(/)` |
1061
+ | `hms(sep?)` | HH:MM:SS | `timestamp\|hms` / `timestamp\|hms(-)` |
1056
1062
 
1057
1063
  ### 真偽値 / デフォルト
1058
1064
 
@@ -1125,6 +1131,18 @@ customElements.define("my-light-component", MyLightComponent);
1125
1131
  - バインディングでは `@my-light` のように状態名を明示的に参照する必要があります
1126
1132
  - `<wcs-state>` はコンポーネント要素の直下に配置する必要があります
1127
1133
 
1134
+ - ホスト側からのバインド(`<my-light-component data-wcs="state.message: user.name">`)も
1135
+ Shadow DOM 形と同様に使えます。コンポーネントのサブツリーは**独立したバインディングスコープ**
1136
+ として扱われ、コンポーネント側の状態が名前登録を終えてから配線されます
1137
+
1138
+ > **注意**: Light DOM は名前空間を上位スコープと共有するため、**同じ `name` を持つインスタンスを
1139
+ > 同一スコープに複数置くことはできません**。リストの行ごとにコンポーネントを配置するような形は
1140
+ > Shadow DOM を使ってください。
1141
+ >
1142
+ > また `State.getBindingsReady(root)` はコンポーネントのスコープを含みません(Shadow DOM 形で
1143
+ > 子が別 rootNode にあるのと同じ扱いです)。コンポーネント内部の描画完了まで待ちたい場合は、
1144
+ > コンポーネント側の `<wcs-state>` の初期化を待ってください。
1145
+
1128
1146
  ### ホスト側の使用方法
1129
1147
 
1130
1148
  ```html
@@ -1218,8 +1236,20 @@ this.shadowRoot.innerHTML = `
1218
1236
 
1219
1237
  - ホストが `rows` を差し替えても、行フィールド(`rows.0.name`)だけを書いても、コンポーネント内の行に反映されます
1220
1238
  - コンポーネント側から `items.*.name` を書き戻すと、ホストの `rows` に届きます
1221
- - **制限**: コンポーネント自身がホスト側の `for` の中にあり、さらにコンポーネント内でも `for` を回す
1222
- 入れ子構成には対応していません(内外の行の対応付けが決まらないため)
1239
+
1240
+ #### 入れ子とスコープの重ね方
1241
+
1242
+ コンポーネント自身がホスト側の `for` の中にあり、**さらにコンポーネント内でも `for` を回す**入れ子構成にも対応しています。外側の行と内側の行の対応はフレームワークが保ちます:
1243
+
1244
+ ```html
1245
+ <template data-wcs="for: groups">
1246
+ <my-list data-wcs="state.items: groups.*.children"></my-list>
1247
+ </template>
1248
+ ```
1249
+
1250
+ コンポーネントの中にさらにコンポーネントを置いて、**スコープを重ねる**こともできます。途中のコンポーネントが配列を素通しするだけで自分では `for` を回さなくても、正本スコープ起点の行フィールド書き込みは最下層の行まで届きます。
1251
+
1252
+ コンポーネントの作者が自分の置かれる深さを意識する必要はありません。`$1` / イベントハンドラのインデックス / `$updatedCallback` / `$getAll` はいずれも**自分のスコープ内の位置**を報告します。
1223
1253
 
1224
1254
  ## Command Token(メソッドバインディング)
1225
1255
 
@@ -1520,7 +1550,7 @@ event token は command token と同じ `Token` pub/sub プリミティブを共
1520
1550
 
1521
1551
  command token / event token が運ぶのは離散的なやり取りです。**`$streams`** は残る形 —— 連続的なフローをカバーします。非同期 producer(async iterable / async generator / `ReadableStream`)を宣言すると、フレームワークがそれを **fold して単一の reactive プロパティに畳み込みます** —— 各チャンクは通常のパス代入を通るため、バインディング・パス getter・`$updatedCallback` は自分で値を代入した場合とまったく同じように反応します。`args` 関数が読んだ state パスが変化すると、実行中の producer は abort され、新しい引数で source が張り直されます(switchMap 型の依存駆動 restart)。stream は `$connectedCallback` 完了後に eager に起動し、要素の disconnect で abort されます。
1522
1552
 
1523
- `$updatedCallback` は引き続き binding 駆動です。stream 宣言だけでは headless な購読にならず、その value/status/error の live DOM binding が実際に適用されたときだけ callback の path に現れます。現行 API state-only な `$watch` / `$effects` 宣言はありません。観測契約は [stream リファレンス](docs/streams.md) を参照してください。
1553
+ `$updatedCallback` は引き続き binding 駆動です。stream 宣言だけでは headless な購読にならず、その value/status/error の live DOM binding が実際に適用されたときだけ callback の path に現れます。描画せずに stream の値へ反応したい場合は、そのパスに [`$watch`](#watch-watch) を宣言してください。観測契約は [stream リファレンス](docs/streams.md) を参照してください。
1524
1554
 
1525
1555
  ```html
1526
1556
  <wcs-state>
@@ -1597,6 +1627,68 @@ $streams: {
1597
1627
 
1598
1628
  完全な契約 —— ライフサイクルと所有権・restart セマンティクス・flush 粒度・スコープ外リスト —— は [docs/streams.ja.md](docs/streams.ja.md) を参照してください。
1599
1629
 
1630
+ ## Watch(`$watch`)
1631
+
1632
+ `$updatedCallback` は **binding 駆動** です。その更新で live DOM binding が実際に適用された path だけを報告するため、**描画していない値の変化は見えません**。**`$watch`** はその headless 版で、ページ上でそのパスがバインドされているかどうかに関わらず、state の変化で発火します(**ワイルドカードの行パスだけは例外**で、headless に成立させるには `$listKeys` が要ります。後述)。
1633
+
1634
+ ```html
1635
+ <wcs-state>
1636
+ <script type="module">
1637
+ export default {
1638
+ isLoading: false,
1639
+ items: [],
1640
+ startedAt: 0,
1641
+
1642
+ $watch: {
1643
+ // 立ち上がり検出は cur/prev を自分で比較する
1644
+ isLoading(cur, prev) {
1645
+ if (cur === true && prev === false) { this.startedAt = Date.now(); }
1646
+ },
1647
+
1648
+ // ワイルドカードパスは変化した行ごとに 1 回発火する
1649
+ //(リストを `for` で描画しているか、`$listKeys` の宣言が要る。後述)
1650
+ "items.*.price"(cur, prev, index) {
1651
+ this.lastPriceChange = `#${index}: ${prev} → ${cur}`;
1652
+ },
1653
+ },
1654
+ };
1655
+ </script>
1656
+ </wcs-state>
1657
+ ```
1658
+
1659
+ ハンドラの `this` は **writable** な state proxy なので書き戻せます。その書き込みは次の更新バッチに乗ります。戻り値は無視され、await もされません。
1660
+
1661
+ | 引数 | 契約 |
1662
+ |---|---|
1663
+ | `cur` | drain 時点の値(そのバッチの確定値) |
1664
+ | `prev` | **バッチ開始時点**の値(first-write-wins)。意味を持つのは**スカラのときだけ**(下記) |
1665
+ | `...indexes` | ワイルドカードパスのときのみ。そのスコープ自身のループ添字(`$1` / `$2` と同じ規約) |
1666
+
1667
+ **`prev` はスカラ限定です。** same-value guard が既に読んでいる旧値を再利用するため watch のための追加読みは発生せず、その帰結として参照型(in-place 変異では同じ参照になるため)・`$postUpdate` 経由・`config.sameValueGuard` オフのときは `undefined` になります。
1668
+
1669
+ **`$watch` は独自の発火条件を持ちません。** 更新バッチに載ったものをそのまま発火します。これはうまく噛み合っていて、同値の primitive 書き込みは enqueue 前に落ちている(=実質的に変化時のみ発火)一方、occurrence(`semantics: "event"` の property)は**意図的に**落とされないので `cur === prev` で発火します。エッジ検出が要るならハンドラ内で `cur` と `prev` を比較してください。
1670
+
1671
+ **getter を watch すると eager になります。** computed は本来 lazy で、依存は評価時にしか記録されません —— つまり描画していない getter は本来一度も発火しません。`$watch` に宣言すると接続時に 1 回評価され、以後は依存に触れたバッチの終端で毎回評価されます。`prev` は前回の評価値です。重い computed を watch すればその評価コストが毎バッチ乗り、getter 内の例外は watch 経由で表面化します。ワイルドカード getter(`items.*.tax`)は eager 化**しません**(初回評価がリスト全体を舐めることになるため)。この形は DOM にバインドされている場合にのみ発火し、`prev` は常に `undefined` です(行ごとの評価値を保持しないため)。
1672
+
1673
+ 発火順序は 3 層に分かれ、利用者が意思を持てるのは真ん中の層だけです。
1674
+
1675
+ | 層 | 順序 | 制御 |
1676
+ |---|---|---|
1677
+ | 機構間 | `$updatedCallback` → `$watch` → `$streams` restart | 固定 |
1678
+ | ハンドラ間 | `$watch` の宣言順 | **宣言を並べ替える** |
1679
+ | 同一パスの行間 | `indexes` 昇順 | 固定 |
1680
+
1681
+ 主なルール:
1682
+
1683
+ - **自 state のみ** —— パスに `@stateName` は書けません。他の state 要素の watch は宣言時に拒否されます。
1684
+ - **中間値は観測できません** —— 1 バッチ内の `a → b → c` は `cur = c` / `prev = a` で 1 回だけ発火します(binding 更新と同じ契約)。
1685
+ - **行単位の差分を見たいなら `$listKeys`** —— 未宣言のまま配列全体を代入すると、行 watch は**全行**について `prev === undefined` で発火します(どの行もパス書き込みを通っていないため)。`$listKeys` を宣言すればキー突合が per-field 書き込みに分解するので、変化した行だけが発火し `prev` もスカラで取れます。
1686
+ - **headless な行 watch には `$listKeys` が必要** —— `$watch` が単独では headless にならない唯一の箇所です。`items` から `items.*.price` への展開はリストの `for` バインディングが駆動しており、watch を宣言してもそのパスをリストとしては登録しません(意図的)。したがって `for` バインドも `$listKeys` も無い状態で配列を代入すると、行 watch は**一度も**発火しません。`$listKeys` を宣言する(キー突合がフィールドごとにパス書き込みするので展開を経由しない)か、リストを描画してください。スカラーパスは `user.name` のようなネストしたものも含め、この条件なしに headless で発火します。
1687
+ - **ハンドラの例外は隔離されます** —— throw はコンソールに報告され、残りの watch(と stream の restart)は続行します。loud fail する `$connectedCallback` / `$updatedCallback` とは異なる扱いです。
1688
+ - **書き込みの連鎖には上限があります** —— ハンドラの書き込みは新しいバッチを作るため、相互に書き合う watch は無限ループになり得ます。32 段で打ち切り、コンソールに報告します(値と DOM は巻き戻しません)。
1689
+ - **mapped な `bind-component` の子では使えません** —— 子の state は `$` 始まりのプロパティを遮る proxy に包まれるため、宣言が届きません(`$streams` も同様)。plain な(マップされていない)子では宣言できます。
1690
+ - **SSR では実行されません** —— ハンドラの副作用がサーバーとクライアントで二重に走るためです。
1691
+
1600
1692
  ## Inputs と属性ミラー
1601
1693
 
1602
1694
  `wcBindable.inputs` は一方向のプロパティ入力(state → 要素)を宣言します。エントリに `attribute` を設定すると、フレームワークはプロパティを書き込むたびにその値を当該 HTML 属性へも書き込むため、`attributeChangedCallback`・CSS の属性セレクタ・DevTools がすべてプロパティ値と同期し続けます。
@@ -1707,7 +1799,7 @@ JavaScript のクラス定義なしで、**HTML だけ**でカスタム要素を
1707
1799
 
1708
1800
  ### `$bindables` / `$commands` と wc-bindable プロトコル
1709
1801
 
1710
- `$bindables` は変更イベント付きのコンポーネント**プロパティ**として公開する状態プロパティを、`$commands` は起動可能な**コマンド**として公開する状態メソッドを宣言します。この 2 つで [wc-bindable プロトコル](https://github.com/nicenemo/nicenemo/blob/main/docs/wc-bindable-protocol.md)の宣言が組み立てられます:
1802
+ `$bindables` は変更イベント付きのコンポーネント**プロパティ**として公開する状態プロパティを、`$commands` は起動可能な**コマンド**として公開する状態メソッドを宣言します。この 2 つで [wc-bindable プロトコル](https://github.com/wc-bindable-protocol/wc-bindable-protocol/blob/main/README.md)の宣言が組み立てられます:
1711
1803
 
1712
1804
  ```javascript
1713
1805
  export default {
package/README.md CHANGED
@@ -95,7 +95,7 @@ That's it. No build, no bootstrap code, no framework.
95
95
  - **Declarative data binding** — `data-wcs` attribute for property / text / event / structural binding
96
96
  - **Reactive Proxy** — ES Proxy-based automatic DOM updates with dependency tracking
97
97
  - **Structural directives** — `for`, `if` / `elseif` / `else` via `<template>` elements
98
- - **Built-in filters** — 40 filters for formatting, comparison, arithmetic, date, and more
98
+ - **Built-in filters** — 46 filters for formatting, comparison, arithmetic, date, and more
99
99
  - **Two-way binding** — automatic for `<input>`, `<select>`, `<textarea>`
100
100
  - **Web Component binding** — bidirectional state binding with Shadow DOM components
101
101
  - **Command tokens** — invoke methods on wc-bindable custom elements from state via a pub/sub channel (`command.<method>: tokenName`)
@@ -443,7 +443,7 @@ Internally converted to comment-based bindings (`<!--@@:expression-->`).
443
443
 
444
444
  ### Spread Binding (`...`)
445
445
 
446
- For custom elements that declare the [`wc-bindable` protocol](#wcbindable-protocol), `...: target` wires all of the element's **properties + inputs** to a single state object in one line:
446
+ For custom elements that declare the [`wc-bindable` protocol](#bindables--commands-and-the-wc-bindable-protocol), `...: target` wires all of the element's **properties + inputs** to a single state object in one line:
447
447
 
448
448
  ```html
449
449
  <wcs-fetch data-wcs="...: usersFetch"></wcs-fetch>
@@ -986,7 +986,7 @@ export default {
986
986
 
987
987
  ## Filters
988
988
 
989
- 40 built-in filters are available for both input (DOM → state) and output (state → DOM) directions.
989
+ 46 built-in filters are available for both input (DOM → state) and output (state → DOM) directions.
990
990
 
991
991
  ### Comparison
992
992
 
@@ -1009,6 +1009,8 @@ export default {
1009
1009
  | `mul(n)` | Multiply | `price\|mul(1.1)` |
1010
1010
  | `div(n)` | Divide | `total\|div(100)` |
1011
1011
  | `mod(n)` | Modulo | `index\|mod(2)` |
1012
+ | `abs` | Absolute value | `delta\|abs` |
1013
+ | `clamp(min, max)` | Constrain to a range | `ratio\|clamp(0,100)` |
1012
1014
 
1013
1015
  ### Number Formatting
1014
1016
 
@@ -1020,6 +1022,7 @@ export default {
1020
1022
  | `ceil(n?)` | Ceiling | `value\|ceil` |
1021
1023
  | `locale(loc?)` | Locale number format | `count\|locale` / `count\|locale(ja-JP)` |
1022
1024
  | `percent(n?)` | Percentage format | `ratio\|percent(1)` |
1025
+ | `unit(u)` | Append a unit (any suffix) | `width\|unit(px)` → `"40px"` |
1023
1026
 
1024
1027
  ### String
1025
1028
 
@@ -1034,6 +1037,8 @@ export default {
1034
1037
  | `pad(n, char?)` | Pad start | `id\|pad(5,0)` → `"00001"` |
1035
1038
  | `rep(n)` | Repeat | `text\|rep(3)` |
1036
1039
  | `rev` | Reverse | `text\|rev` |
1040
+ | `truncate(n, suffix?)` | Shorten and append an ellipsis | `title\|truncate(20)` |
1041
+ | `join(sep?)` | Join an array (default `", "`) | `tags\|join` / `tags\|join(/)` |
1037
1042
 
1038
1043
  ### Type Conversion
1039
1044
 
@@ -1054,6 +1059,7 @@ export default {
1054
1059
  | `time(loc?)` | Time format | `timestamp\|time` |
1055
1060
  | `datetime(loc?)` | Date + Time | `timestamp\|datetime(en-US)` |
1056
1061
  | `ymd(sep?)` | YYYY-MM-DD | `timestamp\|ymd` / `timestamp\|ymd(/)` |
1062
+ | `hms(sep?)` | HH:MM:SS | `timestamp\|hms` / `timestamp\|hms(-)` |
1057
1063
 
1058
1064
  ### Boolean / Default
1059
1065
 
@@ -1126,6 +1132,18 @@ customElements.define("my-light-component", MyLightComponent);
1126
1132
  - Bindings must explicitly reference the state name with `@my-light`
1127
1133
  - `<wcs-state>` must be a direct child of the component element
1128
1134
 
1135
+ - Binding from the host (`<my-light-component data-wcs="state.message: user.name">`) works just as it
1136
+ does for Shadow DOM. The component's subtree is treated as an **independent binding scope** and is
1137
+ wired once the component's own state has registered its name
1138
+
1139
+ > **Note**: Light DOM shares its namespace with the parent scope, so **two instances carrying the same
1140
+ > `name` cannot live in one scope**. Use Shadow DOM for shapes that place a component on every row of
1141
+ > a list.
1142
+ >
1143
+ > Also, `State.getBindingsReady(root)` does not cover the component's scope — the same as the Shadow
1144
+ > DOM form, where the child lives in a different rootNode. Await the component's own `<wcs-state>`
1145
+ > initialization when you need to wait for its contents to render.
1146
+
1129
1147
  ### Host Usage
1130
1148
 
1131
1149
  ```html
@@ -1219,8 +1237,24 @@ this.shadowRoot.innerHTML = `
1219
1237
 
1220
1238
  - Replacing `rows` or writing a single row field (`rows.0.name`) both reach the rows inside the component
1221
1239
  - Writing `items.*.name` from inside the component reaches the host's `rows`
1222
- - **Limitation**: nesting is not supported — a component that sits inside a host `for:` *and* runs its own
1223
- `for:` over a mapped array has no way to relate the outer row to the inner one
1240
+
1241
+ #### Nesting and stacking scopes
1242
+
1243
+ A component that sits inside a host `for:` *and* runs its own `for:` over the array it was handed
1244
+ is supported. The framework keeps the outer row and the inner row related:
1245
+
1246
+ ```html
1247
+ <template data-wcs="for: groups">
1248
+ <my-list data-wcs="state.items: groups.*.children"></my-list>
1249
+ </template>
1250
+ ```
1251
+
1252
+ Components can also be placed inside components, **stacking scopes**. An intermediate component
1253
+ that only passes the array through — running no `for:` of its own — still lets a row-field write
1254
+ from the owning scope reach the rows at the bottom.
1255
+
1256
+ A component's author never has to know how deeply it is placed. `$1`, event-handler indexes,
1257
+ `$updatedCallback` and `$getAll` all report positions **within the component's own scope**.
1224
1258
 
1225
1259
  ## Command Token (Method Binding)
1226
1260
 
@@ -1521,7 +1555,7 @@ Event tokens share the same `Token` pub/sub primitive as command tokens — `nam
1521
1555
 
1522
1556
  Command tokens and event tokens carry discrete interactions. **`$streams`** covers the remaining shape: a continuous flow. Declare an async producer (async iterable / async generator / `ReadableStream`) and the framework **folds it into a single reactive property** — each chunk goes through normal path assignment, so bindings, path getters, and `$updatedCallback` react exactly as if you had assigned the value yourself. When a state path read by the `args` function changes, the running producer is aborted and the source is restarted with the new arguments (switchMap-style dependency-driven restart). Streams start eagerly after `$connectedCallback` completes and are aborted when the element disconnects.
1523
1557
 
1524
- `$updatedCallback` remains binding-driven: a stream declaration alone is not a headless subscription. Its path appears in the callback only when a live DOM binding for that value/status/error is actually applied. There is currently no state-only `$watch` / `$effects` declaration; see the [stream reference](docs/streams.md) for the observation contract.
1558
+ `$updatedCallback` remains binding-driven: a stream declaration alone is not a headless subscription. Its path appears in the callback only when a live DOM binding for that value/status/error is actually applied. To react to a stream's value without rendering it, declare [`$watch`](#watch-watch) on that path; see the [stream reference](docs/streams.md) for the observation contract.
1525
1559
 
1526
1560
  ```html
1527
1561
  <wcs-state>
@@ -1598,6 +1632,68 @@ Key rules:
1598
1632
 
1599
1633
  See [docs/streams.md](docs/streams.md) for the full contract — lifecycle and ownership, restart semantics, flush granularity, and the out-of-scope list.
1600
1634
 
1635
+ ## Watch (`$watch`)
1636
+
1637
+ `$updatedCallback` is **binding-driven**: it reports the paths whose live DOM bindings were actually applied in that update, so a value you never render is invisible to it. **`$watch`** is the headless counterpart — it fires on state changes whether or not anything on the page is bound to the path. (One exception, spelled out below: a *wildcard* row path needs `$listKeys` to work headlessly.)
1638
+
1639
+ ```html
1640
+ <wcs-state>
1641
+ <script type="module">
1642
+ export default {
1643
+ isLoading: false,
1644
+ items: [],
1645
+ startedAt: 0,
1646
+
1647
+ $watch: {
1648
+ // rising-edge detection: you compare cur/prev yourself
1649
+ isLoading(cur, prev) {
1650
+ if (cur === true && prev === false) { this.startedAt = Date.now(); }
1651
+ },
1652
+
1653
+ // wildcard paths fire once per changed row
1654
+ // (needs the list rendered with `for`, or `$listKeys` declared — see below)
1655
+ "items.*.price"(cur, prev, index) {
1656
+ this.lastPriceChange = `#${index}: ${prev} → ${cur}`;
1657
+ },
1658
+ },
1659
+ };
1660
+ </script>
1661
+ </wcs-state>
1662
+ ```
1663
+
1664
+ The handler runs with `this` bound to a **writable** state proxy, so it can write back; those writes land in the next update batch. The return value is ignored and never awaited.
1665
+
1666
+ | Argument | Contract |
1667
+ |---|---|
1668
+ | `cur` | The value at drain time (the settled value for the batch) |
1669
+ | `prev` | The value at the **start of the batch** (first-write-wins). Meaningful **for scalars only** — see below |
1670
+ | `...indexes` | Only for wildcard paths: this scope's own loop indexes, same convention as `$1`, `$2` |
1671
+
1672
+ **`prev` is scalar-only.** It reuses the old value the same-value guard already reads, so watch costs no extra read — and it is `undefined` for reference types (an in-place mutation would give you the same reference anyway), for `$postUpdate`, and when `config.sameValueGuard` is off.
1673
+
1674
+ **Watch adds no firing condition of its own.** It fires for whatever landed in the update batch. That falls out well: an equal primitive write is already dropped before it is enqueued (so you effectively get change-only firing), while an occurrence write — a `semantics: "event"` property — is deliberately *not* dropped, and still fires with `cur === prev`. If you need edge detection, compare `cur` and `prev` in the handler.
1675
+
1676
+ **Watching a getter makes it eager.** A computed getter is normally lazy, and its dependencies are only recorded when it is evaluated — so an unrendered getter would never fire at all. Declaring one in `$watch` evaluates it once at connect and again at the end of every batch that touches its dependencies. Its `prev` is the previous evaluation. Watch a heavy computed and you pay that evaluation on every batch; exceptions inside it surface through the watch instead of staying dormant. Wildcard getters (`items.*.tax`) are **not** made eager — priming one would sweep the whole list — so that form fires only when it is also bound to the DOM, and its `prev` is always `undefined` (no per-row evaluation is remembered).
1677
+
1678
+ Firing order is defined in three layers, and only the middle one is yours to steer:
1679
+
1680
+ | Layer | Order | Your control |
1681
+ |---|---|---|
1682
+ | Mechanisms | `$updatedCallback` → `$watch` → `$streams` restart | fixed |
1683
+ | Between handlers | declaration order in `$watch` | **reorder the declarations** |
1684
+ | Between rows of one path | ascending `indexes` | fixed |
1685
+
1686
+ Key rules:
1687
+
1688
+ - **Only its own state** — a path may not carry `@stateName`; watching another state element is rejected at declaration time.
1689
+ - **Intermediate values are not observable** — a batch that goes `a → b → c` fires once with `cur = c`, `prev = a`, the same contract as binding updates.
1690
+ - **Row-level diffs want `$listKeys`** — without it, assigning a whole array fires the row watch for *every* row with `prev === undefined`, because no row went through a path write. With `$listKeys` declared, the key match decomposes the assignment into per-field writes, so only changed rows fire and `prev` is a real scalar.
1691
+ - **A headless row watch requires `$listKeys`** — this is the one place `$watch` is *not* headless on its own. Expanding `items` into `items.*.price` is driven by the list's `for` binding, and declaring a watch deliberately does not register the path as a list. So with neither a `for` binding nor `$listKeys`, assigning the array fires the row watch **zero** times. Add `$listKeys` (the key match writes each field by path, bypassing the expansion) or render the list. Scalar paths — including nested ones like `user.name` — are headless with no such condition.
1692
+ - **Handler exceptions are isolated** — a throw is reported to the console and the remaining watches (and stream restarts) still run. This differs from `$connectedCallback` / `$updatedCallback`, which fail loudly.
1693
+ - **Write chains are bounded** — a handler's writes form a new batch, so mutually-writing watches would loop forever; the chain is cut off after 32 links with a console error. Values and DOM are not rolled back.
1694
+ - **Not available on a mapped `bind-component` child** — its state is wrapped in a proxy that blanks out every `$`-prefixed property, so the declaration never arrives. This applies to `$streams` too. A plain (unmapped) child can declare it.
1695
+ - **SSR does not run watches** — handler side effects would otherwise execute on both server and client.
1696
+
1601
1697
  ## Inputs and Attribute Mirror
1602
1698
 
1603
1699
  `wcBindable.inputs` declares one-way property inputs (state → element). When an entry sets `attribute`, the framework writes the value to that HTML attribute every time it writes the property, so `attributeChangedCallback`, CSS attribute selectors, and DevTools all stay in sync with the property value.
@@ -1708,7 +1804,7 @@ The definition element is hidden; each instance clones the template into its own
1708
1804
 
1709
1805
  ### `$bindables` / `$commands` and the wc-bindable Protocol
1710
1806
 
1711
- `$bindables` declares which state **properties** are exposed as component properties with change events. `$commands` declares which state **methods** are exposed as invocable commands. Together they build the [wc-bindable protocol](https://github.com/nicenemo/nicenemo/blob/main/docs/wc-bindable-protocol.md) declaration:
1807
+ `$bindables` declares which state **properties** are exposed as component properties with change events. `$commands` declares which state **methods** are exposed as invocable commands. Together they build the [wc-bindable protocol](https://github.com/wc-bindable-protocol/wc-bindable-protocol/blob/main/README.md) declaration:
1712
1808
 
1713
1809
  ```javascript
1714
1810
  export default {