Repository navigation
Expand file tree
/
Copy pathllms-full.txt
More file actions
2369 lines (1908 loc) · 298 KB
/
Copy pathllms-full.txt
File metadata and controls
2369 lines (1908 loc) · 298 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# wcstack — complete authoring guide for LLMs
> Everything needed to generate correct wcstack (v4.0.0) apps: workflow, exact data-wcs
> syntax, the full wcs-* I/O node catalog with source-verified timing notes, and the
> silent-failure matrix. Generated from the wcstack-app skill (github.com/wcstack/wcstack-skill),
> whose content is verified against the wcstack monorepo at v4.0.0.
Note: the guide below refers to three reference files (references/state-binding.md,
router-and-scaffold.md, io-node-catalog.md). All three are included IN THIS FILE after
the main guide — no external fetches are needed.
---
# Building apps with wcstack
## Overview
wcstack is a family of "standards-first, zero-config, buildless" Web Components packages. An app is correctly a **single HTML file + one-line CDN loads** — do not introduce bundlers, build steps, or npm install unless the user explicitly asks for them.
Content verified against **wcstack v4.0.0** (READMEs, examples, and source as of 2026-10). **The rules here are for wcstack 4.0**, which replaced the `@wcstack/state` engine behind the same markup (`<wcs-state>`, `data-wcs`, `{{ }}`, `for` / `if`, path getters, the `$` APIs, `bind-component`, `mount=`, the package entries). What a 4.0 author writes differently:
- **Configuration is split.** `bootstrapState()` takes only how the page spells the markup (`bindAttributeName`, `tagNames`, the anchor-comment prefixes) plus `locale` and `enableContractAnalyzer`; behaviour switches live in each root state as `$behavior: { enableMustache, sameValueGuard, enableDirectionalInitialSync }`. **Every package's `bootstrapXxx(config)` throws on an option it does not have** (`/auto` passes none).
- **Bubbling `on*:` events are delegated to the root** — `event.currentTarget` is the root, not the element. `on*#direct:` keeps a listener on the element.
- **`for:` takes no filters** — return the filtered list from a getter. **Writing a list element replaces the value at that position**; reorder by assigning a new array.
- **Volumes (`mount=`) hold data, getters and lifecycle callbacks only** — `$watch` / `$listKeys` / `$renderedCallback` go on the root state, and a volume takes no injections. Mounted components run `$listKeys`, tokens, `$on` and `$errorCallback` for their own bindings.
- **Removed:** `$scan`, the names 3.2 renamed (`uc`, `inc`, `$trackDependency`, `$streams`, …), the `substr` filter, and the `bootstrapState()` options `debug` / `commentTextPrefix` / `enablePropagationContext`.
- **Comment bindings (`<!--@@: expr-->`) are the FOUC-free text form**, and bind even with `$behavior.enableMustache: false`.
- **A failed `<wcs-state>` reports once (`#49` / `#50`) and rejects `connectedCallbackPromise`**; SSR server and client move together.
Reading or upgrading 3.x code: `references/state-binding.md` §16 and `docs/migration-v4.md` in the wcstack repo. If the installed/CDN version is much newer, spot-check syntax against the package READMEs.
Generated-code accuracy lives in the exact syntax. **This file holds only the workflow, a cheat sheet, and the failure-mode matrix**; full syntax is split into references/ next to this file. Read the matching reference before entering each phase:
| File | Read before |
|---|---|
| `references/state-binding.md` | writing `<wcs-state>` / `data-wcs` / comment bindings / filters / events + `#direct` / command- & event-tokens / `$behavior` / `$listKeys` / `$watch` / `$eq` family (keyed selection) / `$recursion` + `**` (trees) / accumulation over time (§15) / DCC vs `bind-component` / mounts (`state: path`) / CSP + SRI loading / split entries (`/core`, `dist/split/auto.js`, `$features`) / headless page tests / 3.x code (§16) |
| `references/router-and-scaffold.md` | writing SPA routing / autoloader / index.html scaffold / server |
| `references/io-node-catalog.md` | wiring I/O nodes (52 wcs-* tags) or writing a signals app |
## Workflow
### 1. Pick the stack (state vs signals)
- **`@wcstack/state` (default)** — UI and state connected through `data-wcs` path strings in HTML; no reactive primitives appear in JS. Forms, lists, CRUD, and general apps go here.
- **`@wcstack/signals`** — write `signal()` / `computed()` / `effect()` / `h()` directly in JS. Choose it when the user wants no DSL, logic-heavy code, or typed signals. It has no deep path tracking (that is state's job). When the app owns its I/O nodes, reach for `mountNode(tag)` or bind a `XxxCore` directly instead of `whenDefined` + `bindNode` — see the decision table in `references/io-node-catalog.md` §3.
- The two **coexist** (not rivals), but pick one per app as a rule.
### 2. HTML scaffold (CDN loading rules)
```html
<!-- state family: one /auto line per package. @wcstack/state/auto goes LAST —
module scripts execute in document order, so every wcs-* element is defined
before state wires anything to it. -->
<script type="module" src="https://esm.run/@wcstack/fetch/auto"></script>
<script type="module" src="https://esm.run/@wcstack/router/auto"></script>
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
```
- **State loads last — this is a real rule, not tidiness.** A property/spread binding on a not-yet-defined element is deferred via `customElements.whenDefined` and applied with the latest value, so nothing is lost there (inside `for:` / `if:` rows too). **A command-token emit is never replayed**: its subscription is deferred the same way, so if state is wired first and a slow (or failed) CDN fetch leaves an I/O element undefined, a click that fires `$command.x.emit()` inside that window reaches zero subscribers and is silently dropped. Loading state last closes the window. `@wcstack/devtools/auto`, if used, goes before state too — that one lint enforces as `wcs/script-order`; the I/O-node case is not lint-checked, so it is on you.
- **When you cannot control the order** — a snippet pasted into a larger page, or tags arriving via `@wcstack/autoloader` — gate the emitting control with `<wcs-defined>` (load `@wcstack/defined/auto` too):
```html
<wcs-defined tags="wcs-tilt,wcs-accelerometer" timeout="5000"
data-wcs="defined: sensorsReady; pending: sensorsPending; missing: sensorsMissing"></wcs-defined>
<button data-wcs="onclick: startGame; disabled: sensorsResolving">Start</button>
<!-- get sensorsResolving() { return this.sensorsPending.length > 0; } -->
```
Gate on **`pending`**, not `defined`: a timed-out package lands in `missing`, which should usually release the control into a degraded mode rather than lock the user out (a late arrival is still promoted out of `missing`). Do **not** `await customElements.whenDefined(tag)` inside the handler instead — it never rejects for a tag that is never registered, so a failed import parks the handler forever, and the handler stops being synchronous (which breaks iOS gesture-gated permission calls).
- signals apps import **only from the single `@wcstack/signals/dom` entry** (mixing `.` and `.dom` on a CDN page duplicates the reactive core and breaks reactivity at the seam; lint: `wcs/signals-dual-entry`).
- An SPA **must have `<base href="/">` in `<head>`** (otherwise deep links break: basename gets misderived from the URL).
- **Always set `<html lang>`** — it is the default locale for the locale-dependent filters (`locale` / `date` / `time` / `datetime`), read when `@wcstack/state` is evaluated (an explicit `bootstrapState({ locale })` still wins). Changing the locale later re-renders nothing, so the language must be in the markup (or set by a synchronous `<head>` script) before state loads.
- The bare-name `wcstack` npm package is the **SPA-core bundle**: `wcstack/auto` = state + router + fetch + storage + autoloader pre-linked into one self-contained file (one request, one SRI hash). Use it when the app uses the SPA core anyway; per-package `/auto` tags stay the default (they fetch in parallel — no waterfall) and pay only for what the page uses. Loading both is safe (first evaluated copy wins; the other is inert). **Never merge files yourself via jsDelivr `/combine/`** — concatenated minified ESM does not even parse (top-level identifier collisions), and jsDelivr itself forbids SRI on it.
- **`esm.run/@wcstack/<pkg>/auto` follows the npm `latest` release.** The rules in this skill are 4.0's; a page that must stay on 3.x pins the major on every wcstack line (`https://esm.run/@wcstack/state@3/auto`, likewise `wcstack@3/auto`) and follows the 3.x documentation instead (§16 of `references/state-binding.md` lists what differs).
- **`@wcstack/state/core` + `features/*`, or the split auto entry `dist/split/auto.js`, is an opt-in for byte-conscious pages, not the default** — `/auto` carries every feature. Use it only when the user asks, from version-pinned **plain jsDelivr file paths**, **never `esm.run`** (`+esm` re-bundles each entry with its own copy of the engine, so features install into a copy the core never sees). The split auto entry needs no import map: one `<script type="module" src="…/@wcstack/state@<version>/dist/split/auto.js">`, the root `<wcs-state features="scopes diagnostics">` for what must exist before any state starts (and dev aids), and `$features: ["temporal", "formats"]` in each state for what that state needs. Without `features/diagnostics`, messages print as a code and a number (`[wcs/filter-unknown] #501 "uc"`) — keep it while developing. Recipe and entry table: `references/state-binding.md` §1.
- **Production / CSP** — only when the user asks for hardening; `esm.run` stays the default for demos and prototypes. Pin the version and use the direct jsDelivr path so `integrity` works: `<script type="module" src="https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0/dist/auto.min.js" integrity="sha384-…">`. `dist/auto.min.js` has zero static imports, so one hash covers the whole runtime; digests ship in the GitHub Release (`sri.json`), never taken from the CDN's own API. `esm.run` cannot be hashed (it redirects to a re-bundling `+esm` endpoint) and costs two CSP hosts. **The one CSP fact that silently breaks a working page: the inline `<script type="module">` inside `<wcs-state>` is imported through a `blob:` URL, which a strict policy refuses unless the `<script>` that loads state carries the page nonce (`<script type="module" nonce="{RANDOM}" src="…/dist/auto.min.js">` — the `blob:` import inherits it) or the policy opens `script-src blob:`. Where no nonce can be issued (static hosting), move the state to `src="./state.js"` — the preferred form under a strict CSP anyway.** The browser also evaluates that inner module itself, so its top-level code can run twice: keep side effects out of it (`references/state-binding.md` §2). Route guards go through `blob:` too and have no `src=` form: the nonce on the router's `<script>` admits them; without one they need `script-src blob:` (router also retries through a `data:` URL, so `script-src data:` would pass too — do not open it; it is the riskier source). Under Trusted Types enforcement, `html:` / `innerHTML:` bindings and `<wcs-fetch target>` need a sanitizing policy you install; `<wcs-layout>` / `<wcs-worker>` need `trusted-types wcstack`. Details in `references/state-binding.md` §1–2.
### 3. State design (state family)
State is a plain object `export default`. Computed values are getters keyed by dot-path strings (`get "cart.total"()`, wildcard `get "users.*.fullName"()`). Decide up front:
- **Getters read only through `this`.** The cache is invalidated solely via the dependency graph, and the graph records only `this` reads — a getter reading `Date.now()`, the DOM, or a module variable keeps its **first value forever** with no warning. Put the untracked input into state and assign to it (`this.now = Date.now()` on a timer), or use `$dependOn` / `$postUpdate`. Details: `references/state-binding.md` §6.
- **Declare every key you write.** Writing a top-level key the state does not have creates it, so a typo in a write (`this.cuont = 1`) is silent; reading a missing key throws `[wcs/binding-path-missing]`. `defineState` / `wcs-tsc` typing catches the typo.
- One state slot per I/O node (`listFetch: { value: null, loading: false, error: null, status: 0 }`)
- `for:` requires an array — wrap nullable sources in a derived getter: `get rows() { return this["listFetch.value"] ?? []; }`. **A filtered or sorted view is a getter too** — `for:` takes no filters (`for: items|take(2)` fails initialization with `[wcs/binding-syntax]` #121): `get firstTwo() { return this.items.slice(0, 2); }` + `for: firstTwo`.
- Never seed convenient initial values into output-only element properties (the element is the authority; seed real initial values like `null` / `false`)
- **Behaviour switches belong to the state, not to `bootstrapState()`**: `$behavior: { enableMustache: false }` (also `sameValueGuard`, `enableDirectionalInitialSync`; booleans, each `true` by default). Each root state that needs one declares it — the page's root, a root `<wcs-state>` in a shadow root, a mounted component, a DCC; nothing is inherited, a volume cannot declare it, and a re-set may not change it (`#45`). An unknown key or a non-boolean throws `#44`. Works on `/auto` pages and in JSON states.
- **Declare `$listKeys` for any list that is refetched *and* whose rows hold DOM state the bindings do not own** — focus, IME composition, `<details>` open/closed, in-row scroll, `<canvas>`, `<video>.currentTime`. A `fetch().json()` refresh produces all-new objects, so identity-based row matching fails and that state is shuffled between rows, not merely lost: `$listKeys: { items: "id" }` keeps the row objects and writes only the changed fields. Skip it for plain text lists.
- **React to a state change nothing renders with `$watch`** — `$renderedCallback` only reports paths whose live DOM bindings were applied, so "when this value changes, do X" without a binding belongs in `$watch: { path(cur, prev) {...} }`. It also watches computed getters, wildcard ones included (they turn eager). Row watches (`"items.*.price"`) are headless — no `for` or `$listKeys` needed; a whole-array assignment fires only the rows that entered the list (after a `fetch().json()` refresh, every row — `$listKeys` narrows that to the changed fields). A `$`-path (`$streamStatus.<n>`) cannot be a key — mirror it through a non-`$` getter. Declare it on the **root** state (a volume declaring it is not grafted). Full contract: `references/state-binding.md` §10.
- **An accumulation that must outlive a stream restart, or span events, is a key you own, written from a `$watch` or `$on` handler** — a `$stream` `fold` resets to `initial` on every restart, and there is no `$scan`. Seed it (`feed: { items: [], pages: [] }`), fold in `$watch: { pageResult(chunk) { … } }` (a state path) or `$on: { message: (state, e) => { … } }` (an event token), and write only a changed value. Keep an idempotency key — the handler runs **once per landing, not once per page**, and a Retry after `done` or a re-attached page lands the page again; **never derive the stream's `args` from the feed** (it reloads every page with no sentinel — keep the cursor a plain property advanced from an event); keep it bounded. Contract: `references/state-binding.md` §15.
- **Getter reads track exactly the path you spell**: `this.form.name` registers `form` only, so the getter never re-runs when a bound input edits `form.name` — read `this["form.name"]` (lint / VS Code: `wcs/getter-untracked-read`). Reads inside a setter are untracked, and the same-value guard skips primitives only. Table: `references/state-binding.md` §6.
- **A tree whose depth is decided by the data needs `$recursion`** — a path's depth is fixed in its string, so `nodes.*.children.*.total` never stretches to three levels. Declare `$recursion: { "nodes.*": "children.*" }` (anchor → repeating sub-path, exactly one per state, root-only) and one `get "nodes.**.total"()` covers every depth. Inside it an **index-omitted** `$getAll("nodes.**.children.*.total")` is bound to the depth being evaluated (the correct fold); an explicit `$getAll(path, [])` unions **every** depth, and unioning something that already aggregates its subtree silently double-counts. `$setAll(path, [], value)` broadcasts; recursive getters are read-only at every spelling. `**` never appears in markup — a tree is drawn by a self-referential component. Full contract, diagnostics and the two component traps: `references/state-binding.md` §14.
- **Surface binding failures in the page with `$errorCallback(error, { path, bindingType, node })` on the root state** — a throwing getter / filter / structural directive is isolated (the rest of the batch applies, nothing is rolled back) and otherwise only reaches `console.error`. Canonical shape: write the message into state and render it with an ordinary `textContent:` bind. A mounted component's own `$errorCallback` receives the failures of its own bindings; a volume's is not run. It does not cover `$watch` or `$connectedCallback` failures. `info.node` is `null` for a list no `for:` renders.
- **`$connectedCallback` runs after the page's bindings are built** — a defined element already has its bound inputs and its command tokens wired, so it may emit or read at once. An element whose package may load later gets its property / command / event-token bindings when it is defined.
- **Replacing the whole state at runtime** — `stateEl.setInitialState(next)` on an initialized element re-applies every established binding to the new state before it returns (scalars, getters, row getters, `for` and `if` alike), and a binding the new state cannot serve is reported as a failed apply. It is **not** a write: no `$watch`, no `$stream` restart, no `$renderedCallback`. Lists are matched by **array identity**, so put a *new* array in the state you hand over whenever a list changed. Repeat the `$behavior` declaration in it (a changed one throws `#45`). It throws on a tree that already has grafted volumes or mounted components, on a `mount=` volume element (write those paths on the root instead), and on an element that failed to initialize. Contract: `references/state-binding.md` §11.
- **A "selected row" flag belongs in `$eqPath` / `$eqIndex`** — `get "items.*.selected"() { return this["items.*.id"] === this.selectedId; }` makes every row depend on `selectedId`, so one click re-evaluates the whole list. `return this.$eqPath("selectedId", "items.*.id");` subscribes each row under its own id, and a write re-evaluates only the old and the new row; `$eqIndex("selectedIndex")` keys on the row's position instead, `$eq(path, key)` on any key you pass. Point `path` at the written state (`selectedId`) — a getter path falls back to a tracked read (correct, but every row re-evaluates). Keys compare without coercion: an `<input>` writes strings — `value|number: selectedId`. Contract: `references/state-binding.md` §6.
### 4. Bindings → I/O wiring → routing
Read the references, then write. Four wiring forms between state and I/O nodes:
1. Property binding: `data-wcs="value: users; loading: busy"`
2. Spread: `data-wcs="...: listFetch"` (all wcBindable properties + inputs; commands/events excluded)
3. Command-token (state → element): `data-wcs="command.fetch: $command.refresh"` + `$commandTokens`
4. Event-token (element → state): `data-wcs="eventToken.value: responded"` + `$eventTokens` + `$on`
Positive rules with no failure-mode row below: use `<wcs-link to="...">` instead of raw `<a>` (basename handling + `active` class + `aria-current="page"`); wire live handles (MediaStream etc.) element-to-element via `eventToken` → `$command.attachStream`, never through state; `trigger` is a momentary input property, not a command — any truthy write fires (no edge detection, and it bypasses `manual`), so seed its slot with `false`. Exception: on `wcs-debounce`/`wcs-throttle`, `trigger` IS a command. For animations: **enter** is plain CSS (`@starting-style` on the inserted element — no package needed); only **leave and move** need `<wcs-view-transition>` (a policy node, one per document — `for="router"` keeps state's drain timing untouched, `naming="auto"` names list rows for `::view-transition-*` CSS; see `references/io-node-catalog.md`). For routing: a route that must not appear with empty content loads in its **guard** — return an object and it lands in `<wcs-router>.data` (`data: routeData`) together with the params, return a string to redirect dynamically (`/login?next=…`), any falsy return still cancels to `guard=`; the third argument `{ params, typedParams, searchParams, routeName }` is the match being entered (`references/router-and-scaffold.md` §4). **Wrap text `{{ }}` directly under `<wcs-route>` or a layout template in an element** — the router hands state only elements, so bare text binds on the landing route only; wrapping the whole route body in one element (`<wcs-route path="/v"><article>…</article></wcs-route>`) works in every combination (`references/router-and-scaffold.md` §1).
**Text: `{{ }}` inside templates, comment bindings or `textContent:` at page level.** `{{ expr }}` in page-level text shows the raw braces until state has loaded (lint: `wcs/template-syntax` info). `<p>Hello, <!--@@: user.name--></p>` (or `<!--@@wcs-text: user.name|upper-->`) renders nothing before binding, adds no element, takes filters, and binds even with `$behavior.enableMustache: false`. Not inside `<textarea>` / `<title>` (the browser parses that content as text — bind `textContent:` / `value:` there).
**Grammar the runtime refuses** — each fails when the bindings are set up, with the same `[wcs/<code>]` the lint reports; at page level the whole `<wcs-state>` then fails to initialize (4.0 preview behaviour, still under review — `references/state-binding.md` §3 / §11). One `#` with comma-separated modifiers (`onclick#prevent,stop:`, never `onclick#prevent#stop:`); structural keywords bare (`else:` with nothing after it; no modifier or filter on `for` / `if` / `elseif` / `else` / `...` themselves — right-side filters such as `if: count|gt(0)` stay valid, but **`for:` takes none**); a structural binding alone in its `data-wcs`; no surplus filter arguments, no empty filter (`x|`), closed quotes, `|` between filters (`n|toFixed(2)|upper`); no empty path segment on the right (`a.`, `a..b`; a *leading* dot is the loop shorthand); a left side that names a property; no `__proto__` / `prototype` segment; `outerHTML:` / `outerText:` only outside `for:` / `if:` templates; a `*` in a row only for the list its `for:` renders (`b.*.y` inside `for: a` is `[wcs/wildcard-rank]`). Filter arguments are typed: unquoted `true` / `false` / `null` / numbers are values, quoted ones strings (`eq('admin')`). `""` or `null` for "no text" (display surfaces empty on both); `undefined` into an element input is skipped. `$resolve(path, indexes)` reads, `$resolve(path, indexes, value)` writes.
**Events are delegated in 4.0.** `on*:` bindings of `click`, `dblclick`, `input`, `change`, `submit`, `keydown`, `keyup`, `mousedown`, `mouseup`, `pointerdown`, `pointerup` share one listener per type on the root the state binds (the document, a shadow root, or a Light DOM component's host), innermost handler first. So **never read `event.currentTarget` in a plain `on*:` handler** — it is the root: use `event.target.closest("li")` or the loop index the handler receives (`remove(event, index)`). Write **`on*#direct:`** only where the listener must sit on the element: pointer capture (`e.currentTarget.setPointerCapture(…)`), the element's own rect, a `#stop` that must stop your own `addEventListener` on an ancestor (a clickable card), an ancestor whose code calls `stopPropagation()` (a modal keeping clicks from its overlay), an element moved under another root (a dialog moved out of a shadow root to `document.body`). An outer `#direct` handler runs before the delegated handlers inside it, so an inner `#stop` cannot stop it unless the inner binding is `#direct` too (`onclick#direct,stop:`). Two-way bindings and other event types stay on the element (`references/state-binding.md` §8; lint `wcs/delegated-current-target`).
**Never let user content become markup the page scan reads.** Every `{{ … }}`, `data-wcs` and `<!--@@: … -->` in the page when state binds it (and in route content handed to state) is a binding — including inside text your server template inserted. HTML-escaping does not neutralize `{{ }}`, turning mustache off does not stop comment bindings, and 4.0 has no opt-out attribute. Put user content in state and render it with `textContent:` / `{{ }}` (values are text), or `innerHTML:` / `html:` only after sanitizing. State does not sanitize values: validate URLs you bind to `href:` / `src:` / `attr.href:` (`javascript:`), never bind handler strings to `attr.on*:`, and keep `'unsafe-inline'` out of `script-src` (`references/state-binding.md` §2).
**Filtered and reordered lists.** Reorder or filter by assigning a **new array** (`toSorted`, `filter`, a swapped copy) — rows are matched to its elements by identity and move with them. Writing an element (`this["items.0"] = b`, `$resolve("items.*", [0], b)`) replaces the value at that position; the row stays where it is. A `for:` over a getter that returns the list itself passes row writes (`checked: .done`) to the source list; over a getter that returns a **filtered copy**, a write below one row does not reach the source list's row bindings (known 4.0 limitation, #365) — toggle from a handler that reassigns the source: `toggle(e, i) { const id = this.shown[i].id; this.todos = this.todos.map((t) => t.id === id ? { ...t, done: !t.done } : t); }` (`references/state-binding.md` §4).
Two shapes that sit outside those four forms:
- **Giving a custom element its own state** — pick exactly one mechanism, they are exclusive and combining them errors: **DCC** (`data-wc-definition`, HTML only, declares `$bindables` + `$commands: ["bumpBy"]` so the parent can `command.bumpBy: $command.bump`) when there is no JS class, **`bind-component`** (path mapping, no `wcBindable`, so no spread and no command tokens *into* it) when you are already writing one. For bind-component, prefer the **whole-object mount**: `data-wcs="state: user"` mounts the subtree as the component's root (`name` inside IS `user.name`), and `state: .` mounts the current row inside a `for` — instead of per-property `state.x: y` lines. The host **must** wire a Light DOM component this way, a component's own data keys stay private (rule R1), an explicit mapping wins over a component default of the same name, and `state#ro: …` makes the entry read-only for the component. Its **getters are exported at the mount point** — the host reads `user.display` from the component's `get display()`, per row under a `for`, and a self-recursive component (`<tree-node>` inside `<tree-node>`) closes its derivation one level at a time with `$getAll("children.*.total")`; the first host read may be `undefined` until the component registers, so write `(x ?? 0)`. Inside, the component runs `$listKeys`, `$commandTokens` / `$eventTokens` / `$on` and `$errorCallback` on its own lists and bindings; `$watch` / `$stream` / `$renderedCallback` are not run there (a warning), and `$recursion` throws — declare those on the root state. Decision table and mount rules: `references/state-binding.md` §12.
- **`@wcstack/audio`** — markup nesting *is* the signal graph. Only numeric params are bindable; `id` / `out` / `param` / `note` / `poly` are structural, and changing one **rebuilds the graph and audibly cuts every sounding voice**. Read `references/io-node-catalog.md` §1.1 before writing a patch.
### 5. Server and verification
- **The lint loop is a completion gate, not a suggestion (MUST).** Do not present the app as finished until `npx @wcstack/lint --errors-only <every html file> <sidecars>` exits `0`. The loop is generate → lint → fix → re-lint; wcstack's whole failure model is "wrong wiring fails silently" (see the matrix below), and in 4.0 a page-level markup error stops the whole `<wcs-state>`, so a page you have only eyeballed is unverified by definition. If lint cannot run (offline, no npx), say so explicitly instead of skipping it silently. Use the 4.0 lint (`@wcstack/lint` 4.x, VS Code extension 2.x) for 4.0 pages — `@wcstack/lint@3.5` and the 1.21.x extension stay with 3.x projects, because the 4.0 rules report forms 3.x accepts.
- **When the state is a TypeScript file (`<wcs-state src="./state.ts">` or a build that emits `state.js` from it), generate the sidecar `stateSchema` so lint checks paths against the real types**: `npx wcs-schema emit src/state.ts` (from `@wcstack/typescript`; `typescript` is a peer) writes `wcstack.manifest.json`, which `wcs-validate` and the VS Code extension discover automatically (nearest one walking up from the HTML — do not pass it as an argument unless you want it to replace discovery). With a `stateSchema` declared, a path the type lacks is `wcs/path-nonexistent` (**error**, exit 1) and `for:` on a non-array is `wcs/path-type-mismatch`; paths under a `Date` / `Map` / `Record<string, T>` (a bare `{}` in the schema) stay silent, and inline-script methods / getters still count. The regex analyzer cannot read `users: [] as { name: string }[]` — without the schema that is a false `wcs/binding-path-missing` warning and a real typo is only a warning too. The manifest is derived: gate CI with `npx wcs-schema check src/state.ts && npx wcs-validate --strict index.html`, and re-run `emit --merge` after changing the type. Keep hand-written `stateSchema`s only for states you do not generate (`--merge` replaces the named state wholesale).
- **To type-check the inline `<script type="module">` inside `<wcs-state>` in CI (npm + tsc projects only)**: `npx wcs-tsc --noEmit` (also `@wcstack/typescript`; optional peers `@volar/typescript@~2.4.0` + `@volar/language-core@~2.4.0`). It is vue-tsc's mechanism over `.html`: the same language plugin the VS Code extension uses gives `this` its type, wraps a bare `export default {}` in `defineState`, and strips `@wcstack/state` imports (bare or CDN URL), so `this.coutn++` fails as `index.html(9,14): error TS2551 … Did you mean 'count'?` — the check that catches the write typo 4.0 would otherwise turn into a new key. The tsconfig must reach HTML (`include` covering `**/*.html`, `noImplicitThis`, `allowJs`, `checkJs`, and a `target`/`lib` of ES2015+ — the default ES5 lib silently degrades the `this` typing); `wcs-tsc` warns about what is missing, and `--wcs-defaults` runs with a temporary config that adds it. Other CDN imports (`https://esm.run/lodash-es`) are `any` by default (`--url-imports=error` to forbid). It complements lint, it does not replace it: `wcs-validate` checks the HTML side (paths, filters, tag members), `wcs-tsc` checks the script side.
- **Lint every generated HTML file before browser testing**. During authoring, keep warnings visible:
```bash
npx @wcstack/lint index.html
```
Pass every generated HTML file and `wcstack.manifest.json` sidecar as separate arguments when the app has more than one file. **External state files referenced by `<wcs-state src="...">` (`.json` / `.js` / `.ts`) are resolved automatically** — relative to the HTML, never URLs or absolute paths — so do not pass them as arguments. The lint cannot read a **volume** loaded with `src=`, so a root `$watch` key or a binding under that mount path (`cart.total`) gets `wcs/watch-path-missing` / `wcs/binding-path-missing` warnings although it works — expect them there (`--strict` turns them into CI failures). Read the stable `wcs/*` diagnostic codes and `source:line:col` ranges, fix all errors and actionable warnings, then rerun. What the CLI checks:
- **Syntax and paths** — `data-wcs` syntax (`wcs/binding-syntax`, `wcs/template-syntax` — including filters on `for:`, `outerHTML:` in a template, `#direct` on a non-event binding, a structural binding sharing its `data-wcs`), filters (`wcs/filter-unknown` names the replacement for a removed 3.x name, `wcs/filter-arity`, `wcs/filter-arg-type`), state paths (`wcs/binding-path-missing`, with a `stateSchema` `wcs/path-nonexistent`), `wcs/wildcard-rank`, `wcs/index-arity`, `wcs/index-param-range`, `wcs/getter-cycle`, `wcs/getter-untracked-read`.
- **The script** — the non-reactive assignment family `wcs/nested-assign` / `wcs/array-mutation` / `wcs/array-index-assign` (**errors**: they always drop the update); `wcs/delegated-current-target` (warning: a handler of a delegated event reads `currentTarget`); removed names (`wcs/name-alias`, `wcs/declaration-alias`, `wcs/scan-declaration-invalid`, errors); `$watch` (`wcs/watch-declaration-invalid` error, `wcs/watch-path-missing` warning — a `$watch` typo does not fail visibly, it just never fires); `wcs/updated-callback-unbound` (`$renderedCallback` testing a path nothing binds); `$recursion` / `**` (`wcs/recursion-*`, errors).
- **4.0 configuration and scopes** — `wcs/behavior-invalid`, `wcs/feature-unknown`, `wcs/features-invalid`, `wcs/volume-declaration` (`$watch` / `$listKeys` / `$renderedCallback` / `$stream` in a volume), `wcs/second-root`, `wcs/bind-component-source`, `wcs/mount-path-invalid`, `wcs/named-state-deprecated`.
- **Tags and page** — wcs-* tag members (`wcs/tag-member-unknown`, `command.` / `eventToken.` keys included, with typo suggestions), `wcs/on-prefixed-member` (write `.once:`), `wcs/spread-no-bindable`, `wcs/token-*`, `wcs/trigger-seeded-truthy`, `wcs/storage-seed-clobber`, `wcs/base-href-missing`, `wcs/script-order`, `wcs/signals-dual-entry`, `wcs/aria-attr-unknown`.
The analyzer follows `$listKeys` for **list paths only** (so `for: items.*.children` validates even when `items` starts `[]`). The row shape of a list that starts `[]` comes from the row literals in the assignments that add or replace rows (`this.items = this.items.concat({ id, kind: "general" })`, `.toSpliced(i, n, { … })`, `.with(i, { … })`, `[...this.items, { … }]`), anywhere in the script; a row passed as a variable (`concat(row)`) is invisible, so inline the literal or declare a `stateSchema` — otherwise `.kind` inside the `for` is `wcs/binding-path-missing`. The lint does **not** see `bootstrapXxx()` options, `#stop` / `stopPropagation()` around delegated handlers, moved elements, element writes used to reorder rows, `[data-wcs]` selectors, or SSR post-processing.
- **Runtime errors echo the lint loop**: messages carry the same `[wcs/<code>]` as the lint, many with a number (`#501`); with the `diagnostics` feature (in `@wcstack/state` and `/auto`) they add the sentence, a did-you-mean, the replacement for a removed 3.x name, and `Validate statically: npx @wcstack/lint <file>.`; on `/core` without it they print `[wcs/<code>] #<number> <values>` (look the number up in the wcstack repo's `docs/state-errors.md`). A bracketed code means lint reproduces the finding with a `source:line:col` range — run it and fix every instance, not just the one that threw. A bound path that provably does not resolve gets one `console.warn` (`wcs/binding-path-missing`, with did-you-mean) from the diagnostics feature; the check under-approximates on purpose (`null` parents, rows of an empty list, intermediate getter returns, mapped `bind-component` children and `$` namespaces stay silent), so **no warning proves nothing** — only lint checks exhaustively. Every package's `bootstrapXxx(config)` throws on an option it does not have (`[@wcstack/fetch] bootstrapFetch: "tagName" is not one of its options, or not of the option's type.`); the lint cannot see those calls.
- **A `<wcs-state>` that fails to initialize reports once and rejects `connectedCallbackPromise`**: `console.error` with the element and its source (`<wcs-state src="./state.js"> failed to initialize.`, `#49`), then the error. Causes: a throwing or removed `$` declaration, a source that cannot load (`src="*.json"` HTTP error `#13`, `state="<id>"` with no `<script type="application/json">` of that id `#16`), a second root `<wcs-state>` (`#47`, refused alone), and — in 4.0, still under review — a page-level markup error (syntax, unknown filter, wildcard rank: the bindings after it are not attached). `initializePromise` still resolves; `getBindingsReady(root)` rejects only when every `<wcs-state>` on that root failed before building its bindings; `setInitialState()` on a failed element throws (`#14` — replace the element). A `$connectedCallback` that throws or rejects **after** the bindings are built is `… $connectedCallback failed.` (`#50`): `connectedCallbackPromise` rejects, but `getBindingsReady()` resolves (the page is bound). A volume that is refused still resolves its promise; `renderToString()` and `@wcstack/testing`'s `mount()` reject with the cause.
- **Two console messages point at the page, not at wcstack**: `render chain depth limit exceeded (100 drains that rendering itself started); bindings for this batch were not applied.` is a render loop — a row element's output, a `$renderedCallback` or a `$watch` keeps writing a new value into something the same render reads; break the cycle, or write only when the value really changes (`updates did not settle after 32 passes`, `#11`, is the same within one update). `The host row of <x-row> was removed.` is a component's method or a kept `this` (a timer, a continuation after `await`) outliving its `for:` row — check the row is still there, and clear timers in `$disconnectedCallback`.
- **Mount private keys are invisible to `keys()` / `read()`** — the devtools State pane's **Overlays** section is the one place they surface, per mount record, with the component's exported getters under `exports`. A runtime without the protocol member simply hides the section.
- **When wiring is silent and lint is clean, use the devtools coverage tab**: load `@wcstack/devtools/auto` (before state; on a split auto page add `devtools` to `features=`) and open the wiring pane's coverage view. It joins declared against measured — `$watch` paths (fired ×N / never), command/event tokens (emitted ×N / never), and bindings (attached / never-attached) — which is exactly the "declared but silently dead" class the matrix below describes. Its *prerequisite-missing* note on a row watch whose list has no `for` and no `$listKeys` is a 3.x rule; under 4.0 such a watch fires (read it as "not fired yet"). <!-- 4.0: @wcstack/devtools still applies the 3.x prerequisite check (DevtoolsCore.ts); drop this sentence if devtools is updated for 4.0 row watches -->
- **Use the quiet form only as the final CI gate**:
```bash
npx @wcstack/lint --errors-only index.html
```
Append other HTML files and `wcstack.manifest.json` only when they exist. `--strict` exits `1` on warning-severity findings too (severities unchanged, only the threshold moves) — the right gate once every `<wcs-state src>` resolves and a `stateSchema` exists, since it fails CI on a path typo that is otherwise only a warning (and on `wcs/filter-unknown` / `wcs/wildcard-rank`, warnings the runtime throws on); it combines with `--errors-only` (output stays error-only, exit code reflects warnings). `--errors-only` (alias `--quiet`) hides warning/info lines but does not change their counts or the exit code. Exit `0` means no error-severity diagnostics, `1` one or more errors, `2` invalid usage or a file-read failure. Messages follow the environment locale; `--lang=ja` / `--lang=en` force it. In VS Code, the WcStack IntelliSense extension shows the same diagnostics inline, plus hover, go-to-definition (F12 from a path into the state script; `$command.<n>` jumps to `$commandTokens`), find-references and inlay hints, and resolves `<wcs-state src>`. It also ships `wcs.html-data.json` (HTML custom data from the wcBindable catalog) — other editors get `wcs-*` tag/attribute completion by listing it under `html.customData`.
- **Headless page tests (npm projects)**: a wcstack page is plain DOM, so it tests under vitest + happy-dom with no browser. `@wcstack/testing` packages the recipe as one import — `mount(html)` (registers elements, waits for router + every binding), `app.state().write(s => {...})`, `fire(el, "click")`, `settle()`. Details and the bare no-package recipe: `references/state-binding.md` §13.
- A static single page needs no server (recommend a tiny server if it fetches).
- An SPA needs the fallback "every extensionless non-API GET returns index.html" (implementation in router-and-scaffold.md §7).
- **SSR (`enable-ssr` + `@wcstack/server`'s `renderToString()`)**: deploy `@wcstack/server` and the client at the same major.minor — a 4.0 client discards a 3.x server's snapshot and renders the page on the client (a 4.0 server with a 3.x client is unsupported) — and keep the HTML comments in the output (text bindings and row / branch markers are comments; a minifier's `removeComments` breaks hydration and lets `{{ }}` in values be read as bindings). Details: `references/state-binding.md` §11, `references/router-and-scaffold.md` §5.
- After finishing, do a minimal run in a browser or via a tiny server. For working references, see `examples/` (multi-package demos) and `packages/*/examples/` (single-package demos) in the wcstack repo.
- **When the user wants automated tests, the page is testable headlessly with vitest + happy-dom — no test-only API, no build.** Setup file: `bootstrapState(); URL.createObjectURL = undefined as any;` (the second line routes inline `<script type="module">` state through the `data:` loader; without it the state never finishes loading under Node). Test: `document.body.innerHTML = html` → `await stateEl.connectedCallbackPromise` → `await getBindingsReady(document)` → assert → `await stateEl.createStateAsync("writable", async (s) => { s.count = 42; })` (or `button.click()` for a handler) → `await new Promise(r => setTimeout(r, 0))` → assert. Use `json=` or the inline script for state; write arrays reactively (`s.items = [...s.items, x]`, never `push`). `renderToString()` from `@wcstack/server` gives a snapshot test. Bare Node: `installGlobals()` from `@wcstack/server`, then dynamic-import `@wcstack/state` **after** it. **As one import**: `@wcstack/testing` (peers `@wcstack/state`, `@wcstack/server`) — `const app = await mount(html)` registers the elements and waits for every element and binding (a `<wcs-router>`'s first route too; add `bootstrap: [async () => (await import("@wcstack/router")).bootstrapRouter()]`), then `app.state().write(s => …)` / `app.state().read(s => …)`, `fire(el, "click")`, `await settle()`, `app.unmount()`. It also shims two happy-dom edges the bare recipe hits: inline-script state loading, and `textContent = 0` rendering as `""` instead of `"0"`. Example suite: `examples/state-testing-todo/`.
### 6. Embedding wcs-* nodes in a React / Vue / Svelte / Solid app
This is a supported path (`docs/framework-adapter-integration.md`): every I/O node implements wc-bindable, so a thin adapter (`@wc-bindable/react`, `/vue`, `/svelte`, `/solid`, …) maps an element's outputs into framework state with no per-element glue. Three rules carry it — nothing here uses `data-wcs` or `@wcstack/state`.
1. **Use `@wc-bindable/*` 0.9 or later, and import the definition before the app renders anyway.** From 0.9 the adapters wait for a late definition (`syncOn: "define"`, their default) and bind when it lands; up to 0.8 they checked `isWcBindable(el)` once on mount and never retried, so an element that upgraded later stayed silently unbound — no error, no log. A static import at the entry (`import "@wcstack/websocket/auto";` in `main.tsx`) works with every version. With a 0.8 adapter and a definition that genuinely arrives late (autoloader, CDN tag, code-split), gate the mount on `customElements.whenDefined("wcs-ws")`. A `syncOn` of your own replaces the default — keep `"define"` in it. `connectedCallbackPromise` is not a substitute — it covers connection, not definition.
2. **Pass object-valued inputs as properties, not attributes.** Attributes hold only strings, and several frameworks fall back to attributes when the property is not on the element yet, which stringifies the payload. Use `.prop` (Vue), `prop:` (Solid), `.prop=` (Lit), or assign through a ref.
3. **Unwrap reactive proxies before handing values in.** Vue `reactive`, Svelte `$state`, Solid stores, MobX and Qwik `useStore` wrap plain objects in proxies, and a proxy cannot cross a structured-clone boundary — `<wcs-worker>` / `<wcs-broadcast>` report a `DataCloneError` into `error` rather than sending. Pass `toRaw()` / `$state.snapshot()` / `unwrap()` results. Cores deliberately do not unwrap for you (that would mean a framework dependency).
Live handles such as `<wcs-camera>`'s `MediaStream` are deliberately not snapshot state — take them off the element event via a ref. Angular templates and JSX cannot bind event names containing a colon, so `addEventListener` / `Renderer2.listen` is the portable path for `wcs-*:*` events. Working demos: `examples/websocket-chat` (React 19 and Vue 3 against the same server as the vanilla / state / signals variants).
## Cheat sheet (most-used bindings)
```html
<div data-wcs="textContent: user.name"></div> <!-- text -->
<p>Hello, <!--@@: user.name--></p> <!-- page-level text, no FOUC -->
<input data-wcs="value: form.email"> <!-- two-way -->
<input type="checkbox" data-wcs="checked: done">
<div data-wcs="class.active: isActive; style.color: color; attr.href: url"></div>
<!-- class.* wants a boolean (|truthy to coerce); undefined/null remove it -->
<button data-wcs="onclick: save; disabled: saving">Save</button> <!-- delegated: currentTarget is the root -->
<form data-wcs="onsubmit#prevent: submit">
<div data-wcs="onpointerdown#direct: dragStart"></div> <!-- needs event.currentTarget: listener on the element -->
<template data-wcs="if: items.length|gt(0)">...</template>
<template data-wcs="else:">...</template> <!-- trailing colon required -->
<template data-wcs="for: items"><li>{{ $1|add(1) }}. <span data-wcs="textContent: .name"></span></li></template>
<!-- never children inside a textContent:/innerHTML:/html: element — they are not bound -->
<template data-wcs="for: openItems">...</template> <!-- for: takes no filters — loop over a getter -->
<user-card data-wcs="state: user"></user-card> <!-- mount subtree as component root -->
<wcs-fetch data-wcs="...: usersFetch"></wcs-fetch> <!-- spread -->
<wcs-fetch data-wcs="command.fetch: $command.reload"></wcs-fetch> <!-- command-token -->
<my-el data-wcs="eventToken.value: responded"></my-el> <!-- event-token -->
```
```javascript
export default {
// $behavior: { enableMustache: false }, // behaviour switches (each defaults to true) live here, never in bootstrapState()
items: [],
usersFetch: { value: null, loading: false, error: null, status: 0 },
loadError: "",
selectedId: null,
log: [], // an accumulation is a key you own
get rows() { return this["usersFetch.value"] ?? []; }, // for: needs an array
get openItems() { return this.items.filter((i) => !i.done); }, // a filtered view is a getter
get "items.*.label"() { return this["items.*.name"]; }, // wildcard computed
get "items.*.selected"() { return this.$eqPath("selectedId", "items.*.id"); }, // keyed selection (selectedId: a primitive)
add() { this.items = this.items.concat({ id: Date.now(), name: "new" }); }, // new array + path assignment
remove(e, i) { this.items = this.items.toSpliced(i, 1); }, // (event, ...loopIndexes) — not e.currentTarget
toggleAll(e) { this.$setAll("items.*.done", [], e.target.checked); }, // bulk write, keeps the array
$commandTokens: ["reload"],
$eventTokens: ["responded"],
$on: { responded(state, ev) { /* check ev.detail.status first */ state.log = [...state.log.slice(-19), ev.detail]; } }, // fold events here, bounded
$listKeys: { items: "id" }, // keep row DOM state across a refetch
$watch: { "items.*.name"(cur, prev, i) { /* headless — fires with no DOM binding */ } },
$errorCallback(error, { path }) { this.loadError = `${path}: ${error.message}`; }, // in-page error boundary
$recursion: { "nodes.*": "children.*" }, // a tree whose depth is data — then get "nodes.**.total"()
};
```
Full syntax (modifiers, 47 built-in filters, nested loops, `$getAll` / `$resolve`, spread rules): `references/state-binding.md`.
## Failure-mode matrix (most of these break without an error)
Run `npx @wcstack/lint` without `--errors-only` first (§5), fix its actionable warnings, then self-review against this table:
| Mistake / combination | Symptom | Correct form |
|---|---|---|
| `this.user.name = v` (nested property mutation) | DOM stays stale; lint reports `wcs/nested-assign` (**error** — fails CI) | Path assignment: `this["user.name"] = v` |
| `push` / `splice` / `sort` or `this.items[0] = v` on arrays | DOM stays stale; lint reports `wcs/array-mutation` or `wcs/array-index-assign` (**error**) | Reassign a new array (`toSpliced` / `concat` / `filter` / `toSorted`), or use `this["items.0"] = v` / `this.items = this.items.with(0, v)` |
| A write to a top-level key the state does not declare (`this.cuont = 1`) | 4.0 creates the key; nothing renders it, no error | Declare every key in the state; `defineState` / `wcs-tsc` typing catches the typo |
| `event.currentTarget` in a plain `on*:` handler of a delegated event (`click`, `input`, `change`, `submit`, key / mouse / pointer down and up) | It is the root (document, shadow root, Light DOM host) — `setPointerCapture`, `getBoundingClientRect`, `new FormData(e.currentTarget)` act on the wrong node; lint `wcs/delegated-current-target` (warning) | `event.target.closest(…)` or the loop index; `on*#direct:` where the element itself is needed |
| `onclick#stop:` meant to stop your own `addEventListener` on an ancestor; page code calling `stopPropagation()` on an ancestor; a bound element moved under another root (a dialog moved out of a shadow root to `document.body`) | The ancestor listener still runs; the inner handler never runs; the moved element's handler stops running — nothing reported, the lint cannot see it | `on*#direct:` on those bindings (`onclick#direct,stop: save`) |
| An outer `on*#direct:` around an inner delegated `onclick#stop:` | The outer handler runs first, so the `#stop` cannot stop it | Make the inner binding `#direct` too (`onclick#direct,stop:`) |
| Reordering rows by writing list elements (`$resolve("items.*", [0], b)`, `this["items.0"] = c`) | The value at that position is replaced and the row stays put — focus, typed text and `<details>` state stay with the position, not the value | Assign a new array (a swapped copy, `toSorted`) — rows move with their objects |
| A `for:` over a getter returning a **filtered copy**, with a two-way binding or leaf write inside its rows (TodoMVC `checked: .done` in `for: shown`) | The object changes, but the source list's row bindings and row getters do not follow (known 4.0 limitation, #365) | Toggle from a handler that reassigns the source list with a new row object (`this.todos = this.todos.map(…)`); a getter returning the list itself is fine |
| One object at two positions (`items: [o, o]`), or reachable from two lists | A write below one row does not reach the other row's bindings (#365) | Give each position its own object |
| CSS or test selectors on row / branch elements by `[data-wcs…]` | Never match — 4.0 removes `data-wcs` from the elements it clones for rows and branches | A class or a `data-*` attribute of your own |
| `{{ }}` / `data-wcs` / templates inside an element whose content a binding sets (`textContent:`, `text:`, `innerText:`, `innerHTML:`, `html:`), or inside `<noscript>` / `<iframe>` | Left literal — those children are never bound | Put markup that needs bindings in a separate element |
| User content in the page markup (a server template inserting a comment body, a profile name) containing `{{ … }}` or `<!--@@: … -->` | Read as a binding by the page scan (client-side template injection) — HTML-escaping does not neutralize `{{ }}`, `enableMustache: false` does not stop comment bindings, 4.0 has no opt-out attribute | Deliver user content through state and render it with `textContent:` / `{{ }}`; sanitize before `innerHTML:` / `html:` |
| `{{ }}` at page level | Raw braces flash until state loads (FOUC; lint `wcs/template-syntax` info) | `<!--@@: path-->` or `textContent:` at page level; `{{ }}` inside `<template>`s |
| Getter reading `Date.now()`, the DOM, or a module variable | Keeps its **first value forever** — the cache is invalidated only through the dependency graph, which records reads through `this` alone | Put the input into state and assign to it, or `$dependOn(path)` / `$postUpdate(path)` |
| Getter reading `this.form.name` (nested property access) | Tracks `form` only — editing `form.name` through a binding never re-runs it; lint / VS Code report `wcs/getter-untracked-read` | Read `this["form.name"]` |
| `for:` bound to a null / non-array path | List breaks | Derived getter with `?? []` |
| `for: items\|take(2)` (any filter on `for:`) | **Throws** `[wcs/binding-syntax]` #121 — at page level the `<wcs-state>` fails to initialize | A getter returning the filtered list, `for: firstTwo` |
| Arguments on `onclick:` | Cannot pass arguments | Zero-arg wrapper method per variant, or read the loop index argument |
| Bare name on command binding (`command.fetch: reload`) | Throws `[wcs/token-misconfigured]` #1201 | `$command.reload` (the `$command.` prefix is mandatory) |
| Raw DOM event name as `eventToken.` key | Throws `[wcs/token-misconfigured]` #1204 (no such property) once the element is defined | Use the **wcBindable property name** |
| Expecting spread (`...:`) to wire commands/events | They stay unwired | Wire `command.` / `eventToken.` explicitly |
| Right-hand filter on spread (`...: slot\|f`) | Throws `[wcs/binding-syntax]` #105 | Filters go on individual property bindings |
| `{{ }}` text directly under `<wcs-route>` or a layout template (not inside an element) | Bound on the landing route only; entered by navigation it stays literal | Wrap it in an element (`references/router-and-scaffold.md` §1) |
| A `for:` / `if:` template at the top of a `<wcs-head>` | Not rendered, `[wcs/template-syntax]` #204 on the console | Wrap it in an element |
| `data-wcs="attr.aria-label: ..."` on `<wcs-link>` | Never reaches the generated `<a>` — `aria-*` is copied once at anchor creation, and dynamic changes are not tracked | Write `aria-*` on `<wcs-link>` as **static attributes** (they are forwarded, along with `title` / `rel` / `target` / `download` / `hreflang` / `lang` / `dir`) |
| `@wcstack/state/auto` loaded **before** an I/O package | A `$command.x.emit()` fired in the load window reaches zero subscribers and is dropped (tokens are never replayed; property bindings are fine) | Put every I/O-node `/auto` before `state/auto` |
| `await customElements.whenDefined(tag)` as a readiness gate | Hangs forever if the package never loads (`whenDefined` never rejects) — takes the fallback path down with it | `<wcs-defined timeout>` bound to `disabled:` via its **`pending`** output |
| SPA without `<base href="/">` | Deep links break (basename misderived) | Add `<base href="/">` to `<head>` |
| Assuming `wcs-fetch:response` means success | Fires on HTTP/network errors too | Check `event.detail.status` in `$on` |
| Expecting a repeated identical value on a **`state`** output to fire again | The same-value guard skips the set, the dependency walk, the DOM apply and `$renderedCallback` | Take it from the occurrence surface instead — `eventToken.` + `$on`. (Properties declared `semantics: "event"` — `message` / `result` / `fired` / … — are exempt from the guard; the catalog §0 lists all 20) |
| Awaiting an async `$on` handler, or relying on it to sequence work | Dispatch never awaits handlers; a rejection is caught and reported via `console.error` but never propagates | Keep `$on` synchronous, or have the async work write its own state slot when it settles |
| Storage slot seeded with `""` / `null` | Initial write-back clobbers the saved value | Seed the bound slot with `undefined`, or bind `value#init=element: slot` (the element's loaded value wins the initial sync, then normal two-way resumes) |
| Seeding truthy initials into output-only properties | Element authority overwrites them | Seed real initial values (`null` / `false`) |
| Seeding `true` into a `trigger`-bound slot | Fires/starts at bind, even with `manual` (no edge detection) | Seed `false`; write `true` to fire |
| `send` / `post` before connected / opened / started | Dropped silently into `error` — not queued, no throw | Gate on `connected` / `running`, and bind `error` |
| Changing an attribute after connect to reconfigure | Most inputs are frozen (connect-time or next-command read) — change silently ignored | Check the catalog's live/frozen notes; re-invoke the command or replace the element |
| Writing `undefined` to an element input | Write is skipped silently (write-skip) | Assign `null` to clear |
| Binding an element property whose name starts with `on` (`once:` on `<wcs-timer>`, `online:` on your own element) | It becomes an event binding (listens for `"ce"` / `"line"`) and the value never arrives — silently; lint `wcs/on-prefixed-member` | The explicit property form: `.once:` / `.online:` |
| `class.NAME:` bound to a non-boolean (`class.on: user.label`, a count) | Throws on every apply (`[wcs/binding-type-expectation]` #401) — that binding fails | `class.on: count\|gt(0)`, or `class.on: label\|truthy`; `undefined` / `null` remove the class |
| Missing trailing colon on `else` | Parse fails | `data-wcs="else:"` |
| `for:` / `if:` / `elseif:` / `else:` sharing one `data-wcs` with any other binding (`<template data-wcs="for: items; class.on: x">`) | Throws `[wcs/template-syntax]` #201 — the `<wcs-state>` fails to initialize | A structural binding must be **alone** in its `data-wcs`; put other bindings on elements inside the template |
| `bootstrapState({ enableMustache: false })` (or `sameValueGuard`, `enableDirectionalInitialSync`, `debug`, any unknown key, a value of another type) | **Throws** `#44` before anything is applied — the page never binds | `$behavior: { enableMustache: false }` in each root state that needs it; `bootstrapState()` takes notation only |
| An unknown option to any `bootstrapXxx(config)` (`bootstrapFetch({ tagName })`, the autoloader's `scanImportmap`) | **Throws** — the package never registers | Pass only the documented options; `/auto` passes none |
| `$watch` / `$listKeys` / `$renderedCallback` / `$behavior` / `$features` in a volume (`<wcs-state mount>`), or an injection on the volume element (`data-wcs="state.taxRate: …"`) | The volume is **not grafted** (one `console.error`) — everything under its mount path reads `undefined`; its `connectedCallbackPromise` still resolves | Declare them on the root state with full paths (`"cart.total"`); read cross-module values in a root getter |
| Mixing `@wcstack/signals` and `@wcstack/signals/dom` on a CDN page | Two reactive cores, broken seams | Import everything from the single `/dom` entry |
| Custom filter registration | No public API | Compose the 47 built-in filters, or compute in a getter |
| Strict CSP + the inline `<script type="module">` state form, loaded by a `<script>` without the page nonce | State never initializes; next to the browser's CSP report, state logs `failed to initialize.` with `The inline <script> of <wcs-state> was blocked by Content-Security-Policy …` (`#42`) or `Failed to evaluate the inline <script> of <wcs-state>: …` (`#43`, when no violation was seen — usually a syntax error) | Put the page nonce on the `<script>` that loads state (the `blob:` import inherits it); where no nonce can be issued, move the state to `src="./state.js"`, or open `script-src blob:` deliberately |
| Side effects (`fetch`, logging, assignments to globals) at the top level of the inline `<script type="module">` inside `<wcs-state>` | They run **twice** — the browser evaluates that module itself as well as state's own `blob:` import | Keep the top level to `export default { … }`; do the work in methods or `$connectedCallback` |
| Refetching a list (`fetch().json()`) whose rows hold focus / IME / `<details>` / `<canvas>` / `<video>` state | Rows are rebuilt, and that state is **shuffled onto other rows** rather than just lost | Declare `$listKeys: { items: "id" }` so rows are matched by key and only changed fields are written |
| Using `$renderedCallback` as a headless watcher | Never fires for a path with no live DOM binding; lint `wcs/updated-callback-unbound` | It is binding-driven — declare `$watch: { path(cur, prev) {...} }` instead, which fires with no binding at all |
| A row watch (`$watch: { "items.*.price": … }`) over a list refreshed with `fetch().json()` | Every row fires on every refresh, with `prev` `undefined` — all the row objects are new, so every row "entered" the list | Declare `$listKeys: { items: "id" }`: the refresh becomes per-field writes, and only changed rows fire, with a real `prev` |
| Expecting a row watch to fire after mutating rows in place and re-assigning the same array (or a copy, or `$postUpdate("items")`) | Nothing fires — kept rows did not change by identity (getters do read the new values) | Write the row paths (`this["items.0.price"] = 5`), or replace the changed rows with new objects |
| Watching `$streamStatus.<name>` / any `$`-path in `$watch` | Rejected at declaration (`$` is reserved); lint reports `wcs/watch-declaration-invalid` | Mirror through a non-`$` getter — `get streamStatus() { return this["$streamStatus.x"]; }` — and watch that; a watched getter is evaluated eagerly even when nothing renders it |
| `$watch` / `$stream` / `$renderedCallback` declared in a **mounted** `bind-component` scope | Not run, with a one-time `[wcs/mount-dollar-declaration]` warning (`$recursion` there throws) | Declare them on the root state |
| DCC and `bind-component` on the same component (e.g. `<wcs-state bind-component>` inside a `data-wc-definition` host) | Error (they are exclusive) | One mechanism per component — no JS class → DCC, class already written → `bind-component` |
| A page with only `<wcs-state mount=...>` volumes and no root | Loud error — the root `<wcs-state>` is required (it may be empty) | Add `<wcs-state></wcs-state>` (or the root `src`) alongside the volumes |
| A plain, unwired Light DOM `<wcs-state bind-component>` | Fails loudly with the fix — an independent tree cannot share the parent's root | Add `attachShadow`, or mount it from the host (`state: user` / `state.message: user.name`) |
| `setInitialState()` re-set on a tree that already has volumes or mounts, or with a different `$behavior` | Throws (`#45` for `$behavior`) | Write the individual paths you want to change; repeat the `$behavior` declaration in a re-set state |
| Re-setting with an array you mutated in place (`s.items.push(x); el.setInitialState(s)`) | Lists are matched by **array identity**, so the added or removed rows never reach the page — nothing is reported | Put a new array in the state you hand over (`{ ...s, items: [...s.items, x] }`), or write the path instead of re-setting |
| `setInitialState()` on a `<wcs-state mount="…">` element to refresh a volume | Throws — a volume's data is copied into the root tree once, when it grafts | Write the paths under the mount path on the **root** state |
| Duplicate name in `$bindables` / `$commands` | Definition-time error | Deduplicate; `$commands` lists methods only, `$bindables` value properties only |
| `page++` on each `<wcs-intersect>` edge with a `$stream` fetch | A second edge aborts page N and jumps to N+1 — a page is skipped | Derive it from the rows already in the feed: `page = floor(feed.items.length / pageSize) + 1`, written from the sentinel's `$on`; repeated edges then rewrite the same value and no-op |
| Expecting an auto-fetch after the url passes through empty/`undefined` and back | Skipped — the guard holds the last url *actually fetched*, which an empty url does not update | Explicit `command.fetch` / `trigger`, which bypass the guard |
| `data-bind` params read in the `connectedCallback` of a custom element that is **not yet defined** when the route renders | `this.props.userId` is `undefined` there — params land after `whenDefined()` resolves, which is after the upgrade | Read them in the `props` / `states` / attribute setter or `attributeChangedCallback`, define the element before `@wcstack/router` renders, or bind `typedParams` on `<wcs-router>` into state |
| A getter / filter / structural directive that throws while a binding applies | That binding is isolated and the report goes to `console.error` only — the page shows a stale node and no message | Declare `$errorCallback(error, { path, bindingType, node })` on the **root** state (a mounted component's covers its own bindings) and render the message from state |
| Host binding `user.display` where `display` is a getter of the component mounted at `user` | Exported — but the first read may be `undefined` (the parent evaluates before the child registers); a tree key of the same name wins and warns `wcs/mount-export-shadowed` | Write derived expressions defensively (`?? 0`) and do not declare the key on the tree too; a wildcard-local accessor (`get "children.*.label"()`) is never exported — mount a component per row |
| A component in a `for:` row whose method continues after an `await`, or that keeps `this` in a timer | Once the row is gone it throws `The host row of <x-row> was removed.` | Check the row is still there; clear timers in `$disconnectedCallback`, which can still write the component's own keys |
| `matches:` bound on `<wcs-media-query>` | Never updates — the output is named `matched`, because `Element.prototype.matches()` exists on every element | `data-wcs="matched: isDark"` |
| `$getAll("nodes.**.total", [])` to total a tree | Too big, with no diagnostic — every node's total already folds its own subtree, and the union adds each one again | Union raw values (`nodes.**.value`) or sum the roots (`nodes.*.total`). Inside the recursive getter the same slip surfaces as `wcs/getter-cycle` |
| `**` in `data-wcs` / mustache, a `$watch` or `$listKeys` key, `$resolve` / `$postUpdate` / `$dependOn`, or an assignment | Refused (`wcs/recursion-unsupported`) — `**` is authoring notation for the state definition only, and means nothing without a `$recursion` declaration | Bind concrete paths; draw the tree with a self-referential component (`references/state-binding.md` §14) |
| Reading `this["nodes.**.value"]` from the top level, or from a plain getter a recursive getter calls | `wcs/recursion-context` — a bound `**` takes its depth from the innermost evaluation frame only | Read it inside the recursive (or row) getter and pass the value on, or pass `[]` to union every depth |
| Two rows sharing one `children` array instance | Under `$recursion` refused as `wcs/recursion-shared-list`; elsewhere both rows show the same child rows | Give every node its own array (sharing an empty one is harmless) |
| A self-referential component that declares its own key for the data it is mounted over, or builds its shadow in the constructor | The child renders its own default and never descends (`wcs/mount-own-key-shadow`); a constructor `innerHTML` recurses forever where `<template>` content is not inert | Leave the mounted keys out of `state`, and fill the shadow in `connectedCallback` |
| Numeric keys under a plain object (`sales.2024.total`, `usersById.42.name`) read or written from the script | Markup renders them, but `this["sales.2024.total"]` reads `undefined` and a write throws `no row for …` (known 4.0 limitation) | Read `this.sales[2024].total`; write a new object to the top-level key (`this.sales = { ...this.sales, 2024: { … } }`), or use non-numeric keys |
| A write past the end of a list (`this["items.5.v"] = 1`) | Throws `no row for "items.*.v"` (`#3`); a read returns `undefined` | Grow the list by assigning a new array first |
| Accumulating a feed in a `$stream` `fold` | Resets to `initial` on every restart — the list empties the moment `page` changes | Own the feed as a plain key and fold each landing in a `$watch` on the stream's value (`$watch: { pageResult(chunk) { … this.feed = { … }; } }`): it outlives restarts and reconnects |
| A feed fold that appends without a key | The same page appears twice after a Retry once it is `done`, or after the state element is re-attached — the handler runs once per landing, not once per page | Keep an idempotency key in the feed (`if (this.feed.pages.includes(chunk.page)) return;`) |
| A stream's `args` reading a getter derived from its own feed (`get page() { return Math.floor(this.feed.items.length / 20) + 1; }`) | The stream restarts on its own result and loads every page with no sentinel | Keep the cursor a plain property and advance it from an event (`$on`) |
| A render that writes something new every time — a row element's output bound to the key the list's getter reads, a `$renderedCallback` that bumps a bound value on every render, a `$watch` that feeds a list's getter from its rows' output | Cut off after 100 links with one `render chain depth limit exceeded …` — the values stay in state, the DOM stops at the cut | Break the cycle: write only when the value really changes, and never feed a row element's output into the key its own list is derived from |
| Stripping comments from SSR output (html-minifier `removeComments`), or a 3.x `@wcstack/server` in front of a 4.0 client | Hydration breaks and `{{ }}` inside values may be read as bindings; with a 3.x server the client discards the snapshot and re-renders everything (`<wcs-ssr version="3.x.y"> does not match 4.0.0 …`) | Keep comments in the output; deploy `@wcstack/server` and the client at the same version, and purge HTML cached from 3.x |
| SSR state holding a `Date` / `Set` / `Map` / class instance | The JSON snapshot turns it into a string or a plain object that overwrites the client value | Keep JSON values in SSR state and derive the rest in getters (`get created() { return new Date(this.createdIso); }`) |
| `html:` / `innerHTML:` binding or `<wcs-fetch target>` under `require-trusted-types-for 'script'` with no policy installed | Blocked; reported once, then the binding fails like any failed apply | Install a sanitizing policy via `globalThis[Symbol.for("wcstack.trustedTypes.policy")]` (or `setTrustedTypesPolicy()`); `<wcs-layout>` / `<wcs-worker>` additionally need `trusted-types wcstack` in the CSP |
| Binding a structural audio attribute (`out` / `param` / `note` / `poly`) from state | Every write rebuilds the graph and cuts sounding voices | Bind only numeric params; declare structure in markup |
## Minimal template (starting point)
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
</head>
<body>
<wcs-state>
<script type="module">
export default {
count: 0,
countUp() { this.count++; }
};
</script>
</wcs-state>
<p>Count: <!--@@: count--></p>
<button data-wcs="onclick: countUp">+1</button>
</body>
</html>
```
For a full SPA with fetch wiring and layouts, start from the router-spa skeleton in `references/router-and-scaffold.md` §7.
---
# Reference: data-wcs binding syntax (state family)
<!-- source: wcstack-skill/skills/wcstack-app/references/state-binding.md -->
# @wcstack/state Reference
Sources: the 4.0 engine — wcstack `packages/state/src` and its README, `docs/migration-v4.md`, `docs/state-errors.md` (message numbers) and the 4.0.0 CHANGELOG entry — plus what 4.0 keeps from the 3.x documentation: `packages/state/README.ja.md`, `packages/state/examples/*`, `packages/fetch/examples/users-crud`, `docs/csp.md` / `docs/sri.md` / `docs/state-list-key-design.md` / `docs/state-watch-hook-design.md` / `docs/timing-and-firing-contract.md`. All verified against real code at v4.0.0, and the 4.0 rules against the 4.0 engine source. **The rules are 4.0's.** A form from 3.x code and what replaced it: §16.
## 1. CDN Loading
```html
<!-- Auto-initialization (the one-liner used in all real examples) -->
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
```
```html
<!-- Manual initialization -->
<script type="module">
import { bootstrapState } from 'https://esm.run/@wcstack/state';
bootstrapState(); // options: notation only (§11 Configuration) — an unknown key throws #44
</script>
```
### Production loading — pin the version and add `integrity`
```html
<script type="module"
src="https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0/dist/auto.min.js"
integrity="sha384-…"></script>
```
`dist/auto.min.js` is a **self-contained bundle with zero static imports**, so the usual ESM caveat — `integrity` covers the entry but not what it imports — does not apply: one hash covers every line of wcstack that runs. Rules that make it work:
- **Use the version-pinned direct path on `cdn.jsdelivr.net`.** `esm.run` redirects to the `+esm` endpoint, which re-bundles server-side, so a fixed digest can never match there. jsDelivr's plain path does not resolve `package.json` `exports`, so name the real file (`/npm/@wcstack/state/auto` is a 404; `@<version>/dist/auto.min.js` is a 200).
- **`crossorigin` is not needed** — `type="module"` is always fetched in CORS mode.
- **Get digests from the GitHub Release** (a table in the body, plus a machine-readable `sri.json` asset), computed from the published tree. Never from jsDelivr's data API: the point of SRI is not trusting the CDN, so letting it self-report is circular.
- **Not covered, by design**: your state definition (inline `<script>` or `src="./state.js"`), route guard scripts, and autoloader-resolved components — all page-supplied code that is dynamically imported at runtime. Named imports from `dist/index.esm.js` need import-map `integrity` (Chrome 127 / Safari 18; not Firefox).
### Loading only the features a page uses — split entries
`/auto` (or `@wcstack/state` + `bootstrapState()`) ships every feature and **stays the default**: a page that uses everything is smaller as one file than as core plus features, and the one-hash SRI story above holds only for `auto.min.js`. Use a split form only when the user asks for fewer bytes and the page deliberately leaves features out. Two forms:
**The split auto entry** — one tag, no import map:
```html
<script type="module" src="https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0/dist/split/auto.js"></script>
<wcs-state features="scopes diagnostics"> <!-- the document's root <wcs-state> -->
<script type="module">
export default {
$features: ["temporal", "formats"], // what THIS state needs
price: 1200,
$watch: { price(cur) { /* … */ } },
};
</script>
</wcs-state>
<p>{{ price|locale }}</p>
```
- **`features="…"`** on the document's root `<wcs-state>` (the first one without `mount` and `bind-component`) is read once, before `<wcs-state>` is defined: the place for `scopes` (`bind-component`, `mount=`, DCC — it must exist before any `<wcs-state>` starts) and for development aids (`diagnostics`, `devtools`). Only the split auto entry reads it; on another `<wcs-state>` lint warns `wcs/features-invalid`.
- **`$features: ["temporal", "formats"]`** names what a state needs; the split auto entry loads the missing ones before it builds that state, the other entries only check they are installed (`[wcs/feature-not-installed]`). A value that is not an array throws `#46`; a volume may not declare it.
- Names: `formats`, `diagnostics`, `temporal`, `list-keys`, `scopes`, `recursion`, `ssr`, `devtools`. Any other name fails with `[wcs/feature-unknown]` (lint: the same code). Features load from `./features/<name>.js` beside `auto.js`, so only those eight files can ever be imported.
- Load it from a version-pinned plain `/npm/` path, **never `esm.run`**. It is not in `exports`; with a bundler use the import-map form's imports.
**Core plus features through an import map** (or a bundler):
```html
<script type="importmap">
{
"imports": {
"@wcstack/state/core": "https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0/dist/split/core.js",
"@wcstack/state/features/temporal": "https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0/dist/split/features/temporal.js",
"@wcstack/state/features/formats": "https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0/dist/split/features/formats.js"
}
}
</script>
<script type="module">
import { bootstrapState, installFeatures } from '@wcstack/state/core';
import temporal from '@wcstack/state/features/temporal'; // $watch / $stream
import formats from '@wcstack/state/features/formats'; // the formatting filters
installFeatures([temporal, formats]); // before bootstrapState()
bootstrapState();
</script>
```
| Entry | Adds |
|---|---|
| `@wcstack/state/core` | `data-wcs`, `{{ }}` and comment bindings, `for` / `if`, path getters, events (`#direct` included), `$command` / `$on`, `$eq*`, `$errorCallback`, the core filters (`eq ne not lt le gt ge`, `add sub mul div mod abs clamp`, `int float boolean number string nullIfEmpty`, `truthy falsy defaults coalesce`), `bootstrapState`, `installFeatures`, `getBindingsReady` |
| `…/features/formats` | the formatting filters: `toFixed locale upper lower capitalize trim slice padStart padEnd repeat reverse truncate join round floor ceil percent unit date time datetime ymd hms` (§7) |
| `…/features/temporal` | `$watch`, `$stream` |
| `…/features/list-keys` | `$listKeys` (outside the core in 4.0) |
| `…/features/scopes` | `bind-component`, `mount=` volumes, a mounted component's exported getters, DCC (`data-wc-definition`) |
| `…/features/recursion` | `$recursion` and `**` |
| `…/features/ssr` | `enable-ssr` (server rendering and hydration) |
| `…/features/devtools` | the DevTools hook source |
| `…/features/diagnostics` | the sentences of the numbered messages (did-you-mean, the replacement for a removed 3.x name), the bound / `$watch` path warnings (`wcs/binding-path-missing`), the CSP / Trusted Types explanations |
| `@wcstack/state/define` | `defineState` and the types — no runtime |
- **Never load the split form through `esm.run`.** Its `+esm` endpoint re-bundles each entry and inlines the shared core chunk into every one, so each entry carries its own engine and a feature installs into a copy the core never sees — the page throws `[wcs/feature-not-installed]` although `installFeatures` ran. Use the version-pinned plain jsDelivr path (it does not read `exports`: name the file under `dist/split/`) or a bundler; then every relative import resolves to the same chunk URL and the engine is evaluated once. Chunk file names under `dist/split/chunks/` carry a content hash — take them from the version you pin when you list them (preload, import-map `integrity`, Chrome 127 / Safari 18; not Firefox — `docs/sri.md` §5).
- **Under a CSP the split files need only the delivery host** in `script-src`, like `auto.min.js` (no `eval`; the inline-state `blob:` rule of §2 still applies). The split auto entry carries no inline script; the import-map recipe carries **two** — the import map and the bootstrap module — and each needs the page nonce or a hash. `docs/csp.md` §2.1.
- **A missing feature is loud**: a declaration that needs one throws `[wcs/feature-not-installed] <key> needs the add-on @wcstack/state/features/<name>` when the state loads (`$watch` / `$stream` → temporal, `$listKeys` → list-keys, `$recursion` → recursion, `bind-component` / `mount=` → scopes, `enable-ssr` → ssr), and a formatting filter without `formats` throws `[wcs/filter-unknown] … "upper" is in the formats add-on — install it …` when the bindings are planned. **The one silent omission is `features/diagnostics`**: without it messages print as `[wcs/<code>] #<number> <values>` (look the number up in the wcstack repo's `docs/state-errors.md`) and a mistyped bound path gets no console warning at all. Keep it (or `/auto`) while developing, and rely on lint either way.
- `installFeatures` is idempotent (a feature already installed is skipped); call it before `bootstrapState()` — a state that declares a feature's key before that feature is installed fails with `[wcs/feature-not-installed]`.
## 2. `<wcs-state>` State Definition (6 methods)
Resolution order: `state` attribute → `src` (.json/.js) → `json` attribute → inner `<script>` → wait for `setInitialState()`.
```html
<!-- 1. Reference a <script type="application/json"> by id -->
<script type="application/json" id="state">{ "count": 0 }</script>
<wcs-state state="state"></wcs-state>
<!-- 2. Inline JSON attribute -->
<wcs-state json='{ "count": 0 }'></wcs-state>
<!-- 3. External JSON -->
<wcs-state src="./data.json"></wcs-state>
<!-- 4. External JS module (export default {...}) -->
<wcs-state src="./state.js"></wcs-state>
<!-- 5. Inline script (most common. export default with type="module") -->
<wcs-state>
<script type="module">
export default { count: 0 };
</script>
</wcs-state>
<!-- 6. Programmatic API -->
<script>
const el = document.createElement('wcs-state');
el.setInitialState({ count: 0 });
document.body.appendChild(el);
</script>
```
`<wcs-state>` attributes: `mount` (graft this state onto the root tree at that path — see below) / `state` / `src` / `json` / `bind-component` (Web Component binding) / `enable-ssr` / `features` (root only, split auto entry — §1). `src` resolves against the **document base URL** — so with `<base href="/ja/">` (the i18n basename pattern), a relative `src="./state.js"` fetches `/ja/state.js` and 404s. **Under a `<base>`, write the URL root-absolute**: `src="/state.js"`.
A source that cannot load fails the element (§11): `state="<id>"` reads only a `<script type="application/json">` with that id and rejects when there is none (`#16`); a `src="*.json"` that cannot be fetched (`#13`, with the HTTP status) or parsed rejects too. `name=` and `path@name` (v1) are gone: `@` in a path is `#32` with the replacement, lint `wcs/named-state-deprecated` (error).
### Under a Content-Security-Policy the load path decides the directives
| Method | What it does | CSP needed |
|---|---|---|
| `state="<id>"` / `json='{...}'` / `setInitialState()` | `JSON.parse` | **nothing extra** (data blocks are not executed) |
| `src="./state.js"` | normal `import(url)` | `script-src <origin>` |
| `src="./data.json"` | `fetch(url)` | `connect-src <origin>` |
| inner `<script type="module">` (method 5 — the default in every example) | text is extracted and imported through a **`blob:` URL** | **the page nonce on the `<script>` that loads state**, or **`script-src blob:`** |
**The page's nonce rescues method 5 — on the `<script>` that loads state, not on the inner one.** A module's `import()` inherits the nonce of the `<script>` that loaded that module, so `<script type="module" nonce="{RANDOM}" src="https://cdn.jsdelivr.net/npm/@wcstack/state@4.0.0/dist/auto.min.js">` admits the `blob:` import under a policy without `blob:` (the browser's rule, not a wcstack feature — checked on Chromium, Firefox and WebKit). A hash cannot do this: a `blob:` module is fetched as an external script, so no inline hash ever matches it. Where no nonce can be issued (static hosting), it is `src="./state.js"` or `script-src blob:`, and opening `script-src blob:` means "allow all dynamically generated scripts", which gives away most of the reason for having a CSP. **Under a strict CSP, `src="./state.js"` stays the preferred form** — it needs neither `blob:` nor the nonce hand-off, and the double run below does not happen. Other consequences worth knowing before you write the policy:
- **The browser evaluates the inner `<script type="module">` too.** Being a child of `<wcs-state>` does not stop it (only a `<template>` does). Its export goes nowhere, so state is unaffected, but with no CSP — or with the nonce on that inner `<script>` as well — **its top-level code runs twice**: once by the browser, once through state's `blob:` import. Under a CSP without a nonce on it, the browser's run is refused and the console shows one violation; state still loads. Either way, **keep side effects out of the top level** (no `fetch`, logging or assignments to globals next to `export default { … }`); do that work in methods or `$connectedCallback`.
- **`<wcs-guard-handler>` goes through `blob:` too, and has no `src=` form.** The nonce on the `<script>` that loads router admits it the same way. Without a nonce, using guards forces `script-src blob:` (router also retries through a `data:` URL, so `script-src data:` would pass too — do not open it; it is the riskier source) — control access on the route-content side instead if the policy must stay strict.
- **`esm.run` needs two hosts** (`https://esm.run` *and* `https://cdn.jsdelivr.net`) because CSP re-checks the redirect target. The version-pinned direct path needs one.
- **Inline import maps need a nonce** (`@wcstack/autoloader` depends on one) and cannot take `integrity`.
- **`class.` / `style.` bindings do NOT need `style-src`** — they are CSSOM property assignments, not attribute parsing or `<style>` injection. And `data-wcs` is never evaluated: no `eval`, no `new Function` anywhere.
- **Trusted Types (`require-trusted-types-for 'script'`).** **Author-written strings are signed** by a shared identity policy named `wcstack` — `<wcs-layout>` templates and `new Worker(src)` — so add `trusted-types wcstack` to the policy **only if the page uses `<wcs-layout>` or `<wcs-worker>`**. **Remote and state data are never signed** — the `html:` / `innerHTML:` / `outerHTML:` / `srcdoc:` bindings (an `<iframe>`'s `attr.srcdoc:` too) and `<wcs-fetch target>` responses need a sanitizing policy *you* install, before the first fetch / layout / worker: `globalThis[Symbol.for("wcstack.trustedTypes.policy")] = trustedTypes.createPolicy("my-app", { createHTML: (s) => DOMPurify.sanitize(s, { RETURN_TRUSTED_TYPE: true }), createScriptURL: (s) => s })` from a nonced head script (bundles: `setTrustedTypesPolicy()` from `@wcstack/state` / `router` / `fetch` / `worker` — same slot). Without one those sinks stay blocked: the diagnostics feature explains it once, and the write fails like any binding (`$errorCallback`, or `binding "…" failed to apply.`). DCC definition has no sink (it clones nodes). Chromium-only — elsewhere every path is a pass-through. Full sink table: `docs/csp.md` §7.
- **Diagnosing it**: a CSP-blocked dynamic `import()` only rejects with `Failed to fetch dynamically imported module` (Chromium; Firefox: `error loading dynamically imported module`). State subscribes to `securitypolicyviolation` and says `The inline <script> of <wcs-state> was blocked by Content-Security-Policy … give the page's nonce to the <script> that loads @wcstack/state, or allow blob: in script-src …` (`#42`) only when a violation was actually observed; `Failed to evaluate the inline <script> of <wcs-state>: <message>` (`#43`) means none was seen — usually a syntax error in your state module. Router says `loadGuardHandler: failed to import guard script …` with the original error in `cause`.
### Security — values, URLs and user content
- **State does not sanitize values.** Text bindings (`textContent:`, `{{ }}`, comment bindings) write text, so a value never becomes markup there. `innerHTML:` / `html:` write HTML: sanitize user content first (and under Trusted Types, through your policy).
- **Validate URLs you bind** to `href:` / `src:` / `attr.href:` — a `javascript:` URL runs on click. Never bind handler strings to `attr.on*:` (state does not refuse it); keep `'unsafe-inline'` out of `script-src` so such a string cannot run.
- **User content must not become page markup.** When state binds the page it reads every `{{ … }}`, `data-wcs` and `<!--@@: … -->` / `<!--@@wcs-text: … -->` under the root (and in route content the router hands it) as a binding — client-side template injection when a non-SSR server template or your own code put user text there. HTML-escaping does not neutralize `{{ }}` (braces are not HTML-special); `$behavior.enableMustache: false` does not stop comment bindings; 4.0 has **no opt-out attribute** (a `data-wcs-ignore` is planned for a later 4.x). Deliver user content through state — `json=` / `src=` / `setInitialState()` / a fetch — and render it with a text binding.
- **SSR output**: keep its comments (§11) — stripping them also lets `{{ }}` inside rendered values be read as bindings on the client.
### Mounting additional state (`mount=`) — volumes
There is **one state tree per rootNode** (document or shadowRoot). To split state across modules, mount a **volume**: its data is grafted onto the root tree at the mount path, and bindings read it by prefix.
```html
<wcs-state src="./app.js"></wcs-state> <!-- the root: exactly one per rootNode, required -->
<wcs-state mount="cart" src="./cart.js"></wcs-state> <!-- a volume grafted at `cart` -->
<div data-wcs="textContent: cart.total"></div>
<button data-wcs="onclick: cart.add">Add</button> <!-- a volume's methods are reachable by path -->
```
| In a volume | 4.0 |
|---|---|
| Data, getters (relative to the mount path), methods (`onclick: cart.add`, `this["cart.add"]`), `$connectedCallback` / `$disconnectedCallback` | supported |
| `$watch`, `$listKeys`, `$renderedCallback` | **refused**: the volume is not grafted, `console.error` (`$watch is not run in a volume — declare it on the root state.`) |
| `$stream`, `$recursion` / `**` getters, `$behavior`, `$features` | refused (not grafted) |
| `$commandTokens`, `$eventTokens`, `$on`, `$errorCallback` | not run, `console.warn` — declare them on the root |
| An injection on the volume element (`data-wcs="state.taxRate: settings.taxRate"`) | **refused** (not grafted): read both paths in a root getter — `get cartTotalWithTax() { return this["cart.subtotal"] * (1 + this["settings.taxRate"]); }` |
- **Declare `$watch` / `$listKeys` / `$renderedCallback` on the root state with full paths** (`$watch: { "cart.total"(cur) { … } }`). Inside the root's handlers `this` is the root state. The root's `$renderedCallback(paths, indexes)` receives the paths of the whole tree (`cart.items.*.name`) — filter by prefix: `paths.filter((p) => p.startsWith("cart."))`.
- A refused volume still resolves its `connectedCallbackPromise`; everything under its mount path reads `undefined`. Lint: `wcs/volume-declaration`, `wcs/behavior-invalid`, `wcs/features-invalid`. The lint cannot read a volume loaded with `src=`, so a root `$watch` key or a binding under that mount path gets `wcs/watch-path-missing` / `wcs/binding-path-missing` warnings although it works.
- The **root `<wcs-state>` is required** and may be empty (`<wcs-state></wcs-state>`). Volumes with no root are a loud error. A second root on one root node is refused alone (`#47`; lint `wcs/second-root`).
- **Load order does not matter.** A volume connected before the root is grafted when the root registers; reads under a not-yet-loaded volume return `undefined` and are *not* reported as missing paths. If the root fails to initialize, volumes waiting for it settle with a report of their own — final: fix the root and reload. A volume that settles without grafting releases its mount slot, so a replacement element on the same path grafts.
- **`setInitialState()` on a loaded volume element throws** — the graft copies the volume's data into the root tree once. Write the paths under the mount path on the **root** state instead.
- The mount path is **static and dotted** (`settings.theme` is fine). `*`, `$`, `#`, `@` and empty segments are rejected — at runtime (not grafted) and as `wcs/mount-path-invalid` (error) in lint. Changing `mount` after initialization does nothing. A path the root state already has, or one another volume holds, is refused (`will not graft: the root state already has "…"`); replacing a mount point's parent wholesale from the root (`this.settings = {...}` under `mount="settings.theme"`) throws.
- A volume written as a `class` grafts its prototype accessors and methods; an own data key still shadows a prototype getter. Any `data-wcs` on the volume element counts as an injection (refused).
- A `<wcs-state mount>` inside the shadow root of a component that is wired to its host is refused (`will not graft: its component is wired to its host.`) — a wired component reads the host's tree, so put that data in the host's state.
- Cross-module reads are ordinary paths: a root getter reading `this["cart.total"]` tracks the dependency like any other.
## 3. `data-wcs` Binding Syntax
```
property[#modifier[,modifier...]][|input filter...]: path[|output filter...]
```
- Multiple bindings are **separated by `;`**: `data-wcs="textContent: count; class.over: count|gt(10)"`. `;`, `|`, `:` and the filter parentheses split only **outside quotes**, so a quoted argument may contain them (`tags|join('; ')`, `value|defaults('00:00'): .startTime`, `join(')')`).
- There is **no `@state` selector** — `@` in a path is a parse error (`#32`). Read another module's state through its mount prefix (`cart.total`, §2).
- Filters on the left side (property side) apply in the **DOM→state input direction**: `<select data-wcs="value|number: selectedProductId">`
- Right-side filters apply in the state→DOM output direction.
- Multiple modifiers are comma-separated after a single `#`: `value#ro,init=none: path`
- **Refused when the bindings are set up** — `[wcs/binding-syntax]` (`#1xx`) / `[wcs/template-syntax]` (`#2xx`) / `[wcs/filter-arity]`, the same codes the lint reports as errors. At page level the `<wcs-state>` then fails to initialize and the bindings after the error are not attached (4.0 preview behaviour, still under review); inside a `for:` / `if:` row an error found while attaching goes to `$errorCallback` as that binding's failure:
| Refused | Write |
|---|---|
| A second `#` (`value#ro#wo`) | `value#ro,wo` |
| A value after `else:`; modifiers or left-side filters on `for` / `if` / `elseif` / `else` / `...` (`if#ro:`, `for\|f:`) | The bare keyword (right-side filters such as `if: count\|gt(0)` stay valid) |
| **Any filter on `for:`** (`for: items\|take(2)`, #121) | A getter that returns the filtered list (§4) |
| `for` / `if` / `elseif` / `else` sharing one `data-wcs` with another binding (#201); `elseif:` / `else:` not after an `if:` (#202) | A structural binding alone on its `<template>` |
| `outerHTML:` / `outerText:` inside a `for:` / `if:` template (#203) | `innerHTML:` on a wrapper element |
| An unterminated quote, an empty filter (`x\|`, `x\|\|y`), text after a filter's `)` (`n\|toFixed(2)upper`) | Closed quotes; `n\|toFixed(2)\|upper` |
| More filter arguments than the filter takes, or fewer than it needs | The filter's own arity (§7) |
| An empty path segment on the right (`textContent: a.`, `a..b`) | The real path — a **leading** dot is the loop shorthand (`.name`, `state: .`) |
| A left side that names no property (`": x"`, `"#ro: x"`); a dotted namespace word (`.class:`, `.attr:`, `.style:`, `.command:`, `.eventToken:`, `.state:`) | Name the property; the namespaces are undotted |
| A `__proto__` / `prototype` segment anywhere in a path (#120 — in writes, `$resolve`, `$setAll` and reads too) | — |
| A path over 512 segments | Real paths are under ten |
| A `*` in a row that ranges over another list than the enclosing `for:` (`{{ b.*.y }}` inside `for: a`, `[wcs/wildcard-rank]` #1403); `state: .` or a `.`-path outside any `for:` (#1402) | Read the other list's row in a getter with `$resolve(path, indexes)` |
- **A modifier never changes the kind of binding:** `radio#ro:` / `checkbox#ro:` stay radio / checkbox bindings.
- **Empty values:** display surfaces — `textContent` / `innerText` / `innerHTML`, `{{ }}` and comment bindings, `attr.*`, `style.*`, `class.*` — treat `undefined` and `null` alike: the text is emptied, the attribute or style removed, the class taken off. Element inputs (every other property, spread) skip `undefined` (the element keeps its own default) and clear on `null`. The formatting filters `upper` / `lower` / `capitalize` / `trim` / `slice` / `padStart` / `padEnd` / `repeat` / `reverse` / `truncate` / `unit` pass `undefined` / `null` through. The filters for which an absent value *is* the input (`defaults` / `coalesce` / `nullIfEmpty` / `boolean` / `truthy` / `falsy` / `not` / `eq` / `ne`) read it as such; the conversions and the number / date / array families do not pass it through — a filter that requires a number, a date or an array throws (`#28`–`#30`), confined to that binding and routed to `$errorCallback`.
### What is bound, and in which order
- **The children of an element whose content a binding sets are never bound** — `textContent:`, `text:`, `innerText:`, `innerHTML:`, `html:` (and `outerHTML:` / `outerText:`, which replace the element): `{{ }}`, `data-wcs` and templates in them stay literal, also when `#init=element` / `#init=none` leaves the children you wrote in place. Put markup that needs bindings in a separate element.
- **The children of `<noscript>` and `<iframe>` are not bound** (nor of `<script>` / `<style>`); the element's own `data-wcs` (`srcdoc:`, `attr.src:`) still binds.
- **At page level an element's children are bound before the element's own bindings** — a custom element's first property writes, and the order in which `$errorCallback` receives failures, run children first. Inside templates the order is document order.
- What a binding puts into the page is a value, not markup: rendered text, `innerHTML:` content and a custom element's own rendering are never scanned for bindings.
### Property types
| Property | Description |
|---|---|
| `value` | Element value (two-way for input/select/textarea) |
| `checked` | checkbox/radio checked state (two-way) |
| `textContent` / `text` | Text (`text` is an alias) |
| `html` | innerHTML |
| `class.NAME` | CSS class on/off. **The value must be a `boolean`** — a truthy string or number throws `[wcs/binding-type-expectation]` #401 on every apply. Write `class.on: count\|gt(0)`, or `class.on: label\|truthy` when you really mean "coerce". `undefined` / `null` remove the class |
| `style.PROP` | CSS style property — the DOM name (`style.backgroundColor`) or the CSS name (`style.background-color`, a custom property `style.--gap`); `undefined` / `null` remove it |
| `attr.NAME` | Attribute setting (SVG namespace supported) |
| `radio` | Radio group → single value (two-way) |
| `checkbox` | Checkbox group → array (two-way) |
| `onclick`, `on*` | Event handlers (§8) |
In addition, any DOM property name can be used (e.g. `disabled: createFetch.loading`).
**A name starting with `on` is always an event binding.** `online: x` listens for a `"line"` event, and `once: flag` on `<wcs-timer>` / `<wcs-raf>` / `<wcs-resize>` / `<wcs-intersect>` listens for `"ce"`. In both cases the value never arrives, silently. **Put a dot in front to bind the property** — `.online: isOnline`, `.once: flag`. The dotted form is the same binding as the undotted one, except that it is never an event: `.value:` is two-way, and modifiers and input filters apply. Lint reports an undotted `on*` member of a built-in tag as `wcs/on-prefixed-member` (warning).
### Modifiers
| Modifier | Description |
|---|---|
| `#ro` | Read-only (disables two-way binding) |
| `#prevent` | `event.preventDefault()` |
| `#stop` | `event.stopPropagation()` |
| `#direct` | Put the `on*:` listener on the element instead of delegating it to the root — §8. On a binding that is not an event binding, lint warns `wcs/template-syntax` |
| `#onchange` | Two-way binding on the `change` event instead of `input` |
| `#init=state\|element\|auto\|none` | Binding authority for wcBindable elements: decides ONLY who wins the initial sync — two-way members flow both ways afterwards. `#init=element` is the declarative load-before-bind form: `<wcs-storage data-wcs="value#init=element: todos">` keeps the persisted value at bind, then writes back normally. Needs `enableDirectionalInitialSync` (on by default; `#31` when a state turned it off) |
| `#sync=call\|connect` | Snapshot read timing under element authority (`connect` also holds state→element writes until the initial conflict resolves) |
### Two-way binding (auto-enabled)
`<input>` (value/checked/valueAsNumber/valueAsDate), `<select>` (value, change event), `<textarea>` (value). `<input type="button">` is excluded.
### Text: `{{ }}` and comment bindings
```html
<p>Hello, {{ user.name }}!</p> <!-- mustache: shows the braces until state binds (FOUC) -->
<p>Hello, <!--@@: user.name-->!</p> <!-- comment binding: renders nothing until bound -->
<p>Total: <!--@@wcs-text: total|locale--></p> <!-- the same, with the wcs-text keyword -->
```
- Both are the same text binding — filters included, at page level and inside templates; the comment is replaced by a text node at its position. The expression may span lines.
- **At page level prefer the comment binding (or `textContent:`)**: `{{ }}` outside a `<template>` is visible until state has loaded (lint: `wcs/template-syntax`, info). Inside `<template>`s (`for:` / `if:` rows) `{{ }}` is fine — template content is inert until rendered.
- `{{ }}` follows `$behavior.enableMustache` (on by default); **comment bindings bind even with `enableMustache: false`**.
- The keyword is empty or `wcs-text` — nothing else (no renaming option exists). A comment inside `<textarea>` or `<title>` is not bound (the browser parses that content as text): bind `value:` / `textContent:` there.
## 4. List Rendering (`for`)
```html
<template data-wcs="for: users">
<div>
<span data-wcs="textContent: users.*.name"></span> <!-- full path -->
<span data-wcs="textContent: .name"></span> <!-- dot shorthand -->
</div>
</template>
```
- No key attribute needed (identity-based diffing). Arrays must **always be reassigned as new arrays** (`concat`/`toSpliced`/`filter`/`toSorted`/`toReversed`/`with`). The runtime does not observe `push`/`splice`/`sort` or direct index writes such as `this.items[0] = value`; lint reports these as `wcs/array-mutation` / `wcs/array-index-assign` (errors).
- Dot shorthand: `.name` → `users.*.name`, `.` → `users.*` (element value for primitive arrays); `.name|upper` also works. `{{ .name }}` also works inside rows.
- **Row identity is the element value** — for objects, the reference. When you assign a new array, rows are matched to its elements by identity and move with them; every non-destructive array method preserves references, so sorting and filtering are structurally keyed.
- **`for:` takes no filters** (`for: items|take(2)` → `[wcs/binding-syntax]` #121). A row of `for: items` is `items.<index>`, so the rows of a filtered array would name other elements. Declare a getter that returns the filtered or sorted list and loop over it: `get firstTwo() { return this.items.slice(0, 2); }` + `for: firstTwo`.
- **Writing a list element replaces the value at that position.** `this["items.0"] = c` and `$resolve("items.*", [0], c)` keep the row where it is, with its `$1` and the DOM state bindings do not own (focus, text typed into an unbound input, `<details>` open state); everything derived below the row is computed again, and `$watch("items.*")` fires for that position. **To move rows with their values, assign a new array** (a swapped copy, `toSorted`):
```js
const items = this.items.slice();
[items[0], items[1]] = [items[1], items[0]];
this.items = items;
```
- **Replacing an element updates every read below it**: `this["items.0"] = { ...this["items.0"], name: "z" }` — the form `wcs/array-index-assign` tells you to write — makes `this["items.0.name"]`, `$getAll("items.*.name", [])` and row getters read the new object, with or without a `for:`.
- **An index past the end**: a write (`this["items.5.v"] = 1`, `$resolve("items.*.v", [5], 1)`) throws `no row for "items.*.v"` (`#3`) and changes nothing; a read returns `undefined`. Grow the list by assigning a new array first.
- **`data-wcs` is removed from the elements cloned for rows (and `if:` branches)** — CSS or test selectors such as `[data-wcs*="items"]` do not match them. Use a class or a `data-*` attribute of your own.
- **A `for:` over a getter.** When the getter returns the list itself (`get shown() { return this.todos; }`), writes through its rows — a two-way `checked: .done`, `this["shown.0.done"] = true` — reach the source list, its readers and `$getAll`, and other `for:`s over it. When it returns a **filtered copy**, the row objects are reachable from two lists, and a write below one row does not reach the other list's row bindings and row getters (known 4.0 limitation, #365). For the TodoMVC shape, toggle from a handler that reassigns the source list with a new row object — `toggle(e, i) { const id = this.shown[i].id; this.todos = this.todos.map((t) => t.id === id ? { ...t, done: !t.done } : t); }` (`i` is the position in `shown`) — so the filter getter, the count and every `for:` recompute from the new array.
- **One object at two positions** (`items: [o, o]`) has the same limitation: a write through `items.0.name` does not reach `items.1`'s bindings; plain reads, root getters and `$getAll` see the new value. Give each position its own object.
### `$listKeys` — identity across a refetch
The exception to identity is **data that arrives as freshly created objects**: `(await fetch(...)).json()`, `JSON.parse` out of storage, a full-snapshot WebSocket/SSE push, a worker `postMessage`. No row matches by reference, so every row is torn down and rebuilt — and DOM state the bindings do not own (focus, an in-progress IME composition, `<details>` open/closed, in-row scroll position, `<canvas>` pixels, `<video>.currentTime`) is not merely lost but **shuffled between rows**. Declare a key and rows survive the refresh:
```javascript
{
items: [],
$listKeys: {
"items": "id", // field name
"items.*.children": (row) => row.uid, // function, for composite keys
},
}
// rows are all-new objects, but they are matched by id: DOM, focus and
// <details> state are kept and only the fields that actually changed are written
this.items = await (await fetch("/api/items")).json();
```
- **Opt-in and per-path.** Undeclared lists behave exactly as before at zero extra cost. Nesting is opt-in too — only declared paths are key-matched.
- **An unchanged refresh is free**: no field writes, no DOM work. A keyed refresh fires `$watch` only for the fields that changed, with a real `prev` (§10).
- **Rows must be plain objects**, and keys must exist and be unique. A duplicate, missing, or class-instance key is an immediate error. The declaration is validated when the state is installed: empty paths, empty segments, a trailing `*` (declare `items`, not `items.*`), non-flat key field names (no `.`/`*`), and `Object.prototype` names are rejected.
- **A field that disappears from a row is cleared to `null`** — `null` is this package's explicit "clear" vocabulary, while `undefined` means "no value" and skips the write.
- **The stored array is rebuilt from the matched row objects**, so after assignment `this.items !== theArrayYouAssigned`.
- Declare it on the **root** state or in a mounted component (its own lists); a volume declaring it is not grafted. On `/core` install `features/list-keys`.
- `@wcstack/lint` and the VS Code extension follow the declaration, so `for: items.*.children` completes and validates even when `items` starts as `[]`.
### Nested loops
```html
<template data-wcs="for: regions">
<template data-wcs="for: .states"> <!-- .states → regions.*.states -->
<span data-wcs="textContent: .name"></span> <!-- → regions.*.states.*.name -->
</template>
</template>
```
An inner array held by several outer rows (`const inner = […]; groups: [{ items: inner }, { items: inner }]`) is drawn and written consistently — writes land on the element written, and every list that renders it follows — except where one object is reached through two arrays (the limitation above). Copy per row (`groups.map(g => ({ ...g, items: [...g.items] }))`) when rows must diverge.
### Loop index
- Inside getters/handlers: `this.$1` (outer), `this.$2` (inner), ... — `$1`…`$128`; `$0` / `$129` / `$01` throw `[wcs/index-param-range]`.
- Inside templates: `{{ $1|add(1) }}` (1-based row number); an outer index inside a nested template follows its row after a reorder.
- `.length` paths also work: `data-wcs="if: cart.items.length|gt(0)"`
## 5. Conditional Rendering (`if` / `elseif` / `else`)
```html
<template data-wcs="if: count|gt(0)"><p>Positive</p></template>
<template data-wcs="elseif: count|lt(0)"><p>Negative</p></template>
<template data-wcs="else:"><p>Zero</p></template>
```
- `else:` **requires the trailing colon** (no right side). Nested `if` is allowed. The condition is coerced with `Boolean()`, so a falsy non-boolean (`0`, `""`, `undefined`, `null`) shows the `else:` branch, and `|not` accepts any value.
- **A structural binding must be the only binding in its `data-wcs`** (#201); put other bindings on elements inside the template.
## 6. computed (path getters) and the Proxy API
**Getters on a plain object**, not class syntax. Dot-path string keys + `*` wildcard:
```javascript
export default {
users: [{ id: 1, firstName: "Alice", lastName: "Smith" }],
get total() { return this.price * (1 + this.tax); }, // top level
get "cart.totalPrice"() { /* nested computed */ },
get "users.*.fullName"() { // wildcard
return this["users.*.firstName"] + " " + this["users.*.lastName"];
},
set "users.*.fullName"(value) { /* path setter, two-way capable */ },
get "categories.*.items.*.label"() { /* multiple wildcards */ },
};
```
- Inside a getter, `this["users.*.firstName"]` auto-resolves to the current loop element. Automatic dependency tracking, per-address caching.
- **Numeric indexes are ordinary path segments**: `this["users.0.name"]`, `` this[`cart.items.${i}.quantity`] += 1 ``, `{{ users.1.name }}`, `groups.0.items.1.v`, `for: groups.0.items` — they read, write and follow writes on any list, rendered or not. A `$watch` key with a numeric index (`"items.0.v"`) fires only when the value at that index changes.
- **Numeric keys under a plain object** (`sales.2024.total`, `usersById.42.name`) are the exception (known 4.0 limitation): markup renders them, but a script read (`this["sales.2024.total"]`, also inside a getter) gives `undefined`, `$eq` on such a path is always false, a write throws `no row for "sales.*.total"`, and a two-way write-back fails. Read `this.sales[2024].total`, and write by assigning a new object to the top-level key (`this.sales = { ...this.sales, 2024: { ...this.sales[2024], total: 10 } }`) — or use keys that are not numbers.
- **Writing a top-level key the state does not have creates it** (reading one throws `[wcs/binding-path-missing]`). Declare every key; a write typo is otherwise silent.
- Chaining into a getter's returned object works: `this["cart.items.*.product.price"]`.
### Getters must be pure with respect to state
The cache is invalidated **only** through the dependency graph, and the graph records only what the getter read **through `this`**. Everything else is invisible to invalidation, so the first value computed is the value you keep — forever, with no warning:
```javascript
get stamp() { return `${this.label} @ ${Date.now()}`; } // ❌ Date.now() untracked — never recomputes
get theme() { return document.body.dataset.theme; } // ❌ the DOM is untracked
get total() { return this.price * exchangeRate; } // ❌ a module variable is untracked
```
The rule: **read only through `this`; never write state or touch the DOM from a getter.** When an untracked input genuinely must participate, put it into state and assign to it (`now: Date.now()` seeded, then `this.now = Date.now()` on a timer in `$connectedCallback`), or use the escape hatches — `$dependOn(path)` (register an extra dependency), `$postUpdate(path)` (announce an untracked change from outside), `$untracked(fn)` (read without registering). Getters that throw are not swallowed: the exception surfaces where the getter was evaluated (a binding apply, a `$watch` evaluation, or your own read). Mutually-recursive getters are a reported cycle (`[wcs/getter-cycle]` #701).
### Dependency tracking boundaries
Three rules decide what the graph sees. None matters until you cross one, and the symptom is always *a value that stops updating with no error*:
| Rule | What it looks like when crossed |
|---|---|
| **Only path reads through `this` are tracked.** `this.form` tracks `form`; `this["form.name"]` tracks `form.name`; **`this.form.name` tracks `form` only** — the `.name` is a plain property access on the object that came back | A getter reading `this.form.name` does not re-run when a bound `<input data-wcs="value: form.name">` changes — read `this["form.name"]`. `wcs-validate` and the VS Code extension report **`wcs/getter-untracked-read`** (warning) when a getter reads `this.form.name` and the document writes `form.name` somewhere; a root that is only ever replaced wholesale is left alone, and so are array roots (`this.items[0].name` — `items` suffices) |
| **Reads inside a setter are not tracked.** A setter is an imperative assignment, not a derivation | A setter that reads `this.a` to decide what to write does not run again when `a` changes — only a getter re-runs. `$untracked(fn)` applies this rule to a getter on purpose |
| **The same-value guard is primitive-only.** An `Object.is`-equal primitive write is dropped before anything is enqueued; an object or array write always passes, even the same reference | Assigning the same string again fires nothing; assigning the same object again re-fires its bindings and `$watch` (`semantics: "event"` properties are exempt either way). `$behavior: { sameValueGuard: false }` turns the guard off for that tree |
The storage "persist a form as one object" recipe (`io-node-catalog.md` §2) is where the first rule bites most often: the accessor pair's getter must read `this["form.name"]`, not `this.form.name`.
### Demand roots — what makes a getter run
Path getters are **lazy**; "does this getter run?" depends on where demand comes from, and there are exactly **three roots**: a **live DOM binding** (demand disappears with the element!), a **`$watch` declaration** (headless), and a **`$stream` `args` function** (evaluated on start and every restart). **`$renderedCallback` is not a root** — it reports what the bindings did. Logic that must not depend on what is rendered belongs on `$watch` or `args`; a display-only element that is secretly the only demand root is the accident `wcs/updated-callback-unbound` catches statically.
### Proxy API (via `this`)
| API | Description |
|---|---|
| `this.$getAll(path, indexes?)` | Get all values of a wildcard path as an array (for aggregation). `indexes` is a **prefix** over the path's wildcards — missing levels expand fully, `[]` always means "every match"; **more than the `*` count throws `wcs/index-arity`**. **Omitting `indexes` defaults to the enclosing loop context** — `this.$getAll("regions.*.prefectures.*.population")` inside a `regions.*` getter narrows to the current region. If the path shares **no** wildcard level with a loop context that holds indexes, it throws (`#6`) rather than silently reading everything — pass `[]` explicitly there. A non-array `indexes` throws |
| `this.$setAll(path, indexes, value, options?)` | Write to **every** address a wildcard path matches, in place — see below |
| `this.$resolve(path, indexes, value?)` | Read/write at specific indexes. The index count must match the path's `*` count **exactly** (`wcs/index-arity`). **The argument count decides** — two arguments read, three write (`undefined` included). On a readonly proxy (`createState("readonly", …)`) a write throws `This state is readonly.` (`#8`), and so does `$setAll` |
| `this.$postUpdate(path)` | Manually emit an update notification (reaches keyed-selection rows too) |
| `this.$dependOn(path)` / `this.$untracked(fn)` | Manually register / suppress dependencies |
| `this.$eq(path, key)` / `this.$eqPath(path, keyPath)` / `this.$eqIndex(path, level?)` | Keyed subscription — "is `path` equal to this row's key?" without a dependency on `path` from every row. See below |
| `this.$stateElement` | IStateElement access |
| `this.$1`, `this.$2`, ... | Loop indexes, `$1`…`$128` (`$129` / `$0` / `$01` throw `wcs/index-param-range`) |
### `$setAll` — bulk writes that keep the array
The write-side counterpart of `$getAll`. The point is not brevity but **list identity**: `this.users = this.users.map(...)` throws away row identity, per-row getter caches, and the render diff; `$setAll` decomposes into in-place per-row writes, so the array survives.
```javascript
this.$setAll("users.*.selected", [], e.target.checked); // broadcast (same value everywhere)
this.$setAll("users.*.selected", [], cur => !cur); // mapper: (current, ...indexes)
this.$setAll("users.*.score", [], (cur, i) => i < 3 ? cur * 2 : undefined); // undefined = skip this row
this.$setAll("matrix.*.*", [0], 0); // indexes prefix: row 0 only
this.$setAll("users.*", [], rows, { spread: true }); // one entry per address, in match order
```
- Three forms: a **function** is a mapper; **anything else broadcasts** (arrays included — the target may itself be array-valued); an array **plus `{ spread: true }`** hands one entry per matched address, and a length mismatch throws (`#10`) rather than misaligning.
- `undefined` is never written ("skip this address" in all three forms — a mapper that forgets to `return` wipes nothing); use `null` to clear. Returns the number of addresses written.
- `indexes` is a prefix exactly as in `$getAll` but **required** (`#9`) — writes get no implicit loop context, so inside a `for` template `$setAll("users.*.selected", [], true)` still means *every* user, never the current row.
- Not a shortcut for the dependency walk: each write is enqueued individually (cost matches the hand-written loop); rendering still coalesces into one batch.
**A path's depth is fixed in its string.** `nodes.*.children.*.total` is depth 2 and nothing stretches it to 3. For a tree whose depth is decided by the data, declare `$recursion` and write `**` — §14.
### Keyed selection — `$eq` / `$eqPath` / `$eqIndex`
A row getter that answers "is this row the selected one?" is where dependency tracking scales worst: `get "items.*.selected"() { return this.$1 === this.selectedIndex; }` (or `this["items.*.id"] === this.selectedId`) makes **every** row depend on the selection path, so one click re-evaluates the whole list. The keyed forms read the selection path **without** a dependency and subscribe each row under **its own key**, so a write to the path re-evaluates only the row that was selected and the row that becomes selected:
| API | Key | Selection follows | Notes |
|---|---|---|---|
| `this.$eq(path, key)` | any value you pass | the key | Pass a tracked read (`this["items.*.id"]`) when the key itself can change, `this.$untracked(() => …)` when it cannot |
| `this.$eqPath(path, keyPath)` | the value at `keyPath` (its wildcards resolve to this row) | the id | Reads the key untracked too, so reordering or replacing the list never re-evaluates the rows. **Survives sorting and removal** — the default choice |
| `this.$eqIndex(path, level = 1)` | this row's index (`$1`; `level` picks the wildcard in a nested list) | the position | Unlike reading `$1`, the getter is not recorded as index-dependent — removing a row re-evaluates at most two rows |
```javascript
export default {
items: [],
selectedId: null,
get "items.*.selected"() { return this.$eqPath("selectedId", "items.*.id"); }, // by id
select(e, i) { this.selectedId = this[`items.${i}.id`]; }, // (event, ...listIndexes)
};
```
```html
<template data-wcs="for: items">
<li data-wcs="class.selected: .selected; onclick: select; textContent: .name"></li>
</template>
```
Rules:
- **What reaches the rows** — a write to `path` of any value, objects included (`this.selected = row` with `$eq("selected", this["items.*"])`), a write that replaces an object above it (`this.selection = { id }` under `$eqPath("selection.id", …)`), and `$postUpdate(path)`: the row that was selected and the row that becomes selected.
- **A getter as `path`**, a path under one, or a path with a numeric index falls back to an ordinary tracked read: the selection is correct, but a change re-evaluates every row — point `path` at the written state (`selectedId`) to keep the two-row cost.
- **Keys compare like `Map` keys** (`Object.is`, except `+0`/`-0` and `NaN`/`NaN` count as equal), so no type coercion: a selection written as the string `"2"` by an `<input>` / `<select>` never matches numeric ids — convert on the way in (`value|number: selectedId`).
- `$eqPath` reads the key without a dependency, so a row whose key changes **in place** is not re-evaluated by that change — use it for identities that do not change (ids), and `$eq` with a tracked key read when the key itself is live.
- They subscribe only when evaluated inside a getter; called from a method or a handler, `$eq` / `$eqPath` just return the comparison. `$eqIndex` needs a list row: in a getter outside a row it throws `$eqIndex("…") needs a list row scope.` (`#7`). A row's subscription is dropped when the list diff removes the row.
- Inside a `mount=` volume or a `bind-component` scope they resolve against that scope, like `$getAll` / `$setAll` / `$resolve` / `$postUpdate`.
- **DevTools:** the State pane counts the subscriptions per path under **Keyed selection** and marks a path that fell back to a tracked read with a `tracked` badge.
### Iron rule of state updates
```javascript
this["user.name"] = "Bob"; // ✅ path assignment → DOM update
this.user.name = "Bob"; // ❌ runtime ignores it; lint wcs/nested-assign (error)
```
## 7. Filters (47 built-ins; no public registration API)
- Comparison: `eq` `ne` `not` `lt` `le` `gt` `ge`
- Arithmetic: `add` `sub` `mul` `div` `mod` `abs` `clamp`
- Number formatting: `toFixed` `round` `floor` `ceil` `locale` `percent` `unit`
- String: `upper` `lower` `capitalize` `trim` `slice` `padStart` `padEnd` `repeat` `reverse` `truncate` `join`
- Type conversion: `int` `float` `boolean` `number` `string` `nullIfEmpty`
- Date: `date` `time` `datetime` `ymd` `hms`
- Truthy/default: `truthy` `falsy` `defaults` `coalesce`
The comparison, arithmetic (except the number formatting), conversion and truthy/default families are the core set; the formatting filters come from `features/formats` (`/auto` and the full entry install both — §1). There is no `substr` (write `slice(start, start + length)`; `slice` takes the end index) and none of the 3.x short names (`uc`, `inc`, `fix`, `pad`, `null`, … — §16): an unknown name throws `[wcs/filter-unknown]` #501, and for a removed 3.x name the diagnostics feature names the replacement instead of a did-you-mean.
With arguments: `gt(10)`, `slice(0,10)`, `padStart(5)` or `padStart(5,'0')` — quote the pad character, since an unquoted `0` is a typed *number* and lint reports `wcs/filter-arg-type` — `locale(ja-JP)`, `date(ja-JP)`, `ymd(/)`, `eq('admin')` (quotes allowed, bare allowed, comma-separated). Chaining: `price|mul(1.1)|round(2)|locale(ja-JP)`. Do transformations the built-ins cannot express in a getter.
**Filter arguments are typed.** Unquoted `true` / `false` / `null` / numbers are typed literals, quoted arguments are strings: `done|eq(true)` matches `true`, `eq('true')` does not, `eq(null)` matches `null`; a non-string literal compares as itself (a number never equals `null` or `true`), and a form value `"1"` still matches `eq(1)`. `defaults(v)` returns the typed value (`defaults(0)` → `0`; `defaults('0')` for the text). `truthy` / `falsy` / `defaults` use JavaScript truthiness (`0n` is falsy). Still prefer binding a boolean directly (`class.done: done`, `hidden: done|not`). `coalesce(v)` replaces only `null` / `undefined` while `defaults(v)` replaces every falsy value (`0`, `false`, `""` included) — pick `coalesce` for a count that may legitimately be `0`. `add` / `sub` require their argument.
**Arity is checked** (`[wcs/filter-arity]`, the bounds lint uses): `join(a,b)` throws "accepts at most 1 argument(s)"; `date` / `time` / `datetime` / `locale` / `toFixed` / `round` / `floor` / `ceil` / `percent` / `join` / `ymd` / `hms` take 0–1, `slice` / `padStart` / `padEnd` / `truncate` 1–2, `clamp` 2. `Object.prototype` names (`|toString`, `|valueOf`) are `wcs/filter-unknown`.
Contracts worth knowing:
- `abs` — `Math.abs`; number input required.
- `clamp(min, max)` — both arguments required; saturates into `[min, max]`. Same family as `round`/`floor`: a wire conversion, so it belongs on the binding, not in state.
- `unit(u)` — appends any suffix: `width|unit(px)` → `"40px"`. **Accepts strings as well as numbers on purpose** — the useful chains run through `toFixed`/`percent`, which return strings. `null`/`undefined` pass through untouched (never `"undefinedpx"`). The canonical style-binding chain that keeps presentation out of state: `style.height: samples.*.cpu|clamp(0,100)|toFixed(0)|unit(%)`.
- `join(sep?)` — array → string; default separator is `", "`.
- `truncate(n, suffix?)` — `n` counts **kept characters** (matching the `slice(0, n)` reading), suffix defaults to one `…` (U+2026); a string at or below the limit is returned untouched.
- `hms(sep?)` — the counterpart of `ymd`: fixed zero-padded `HH:MM:SS` from a Date, locale-independent, separator defaults to `:`.
- `padStart`'s default pad character is `0` while `padEnd`'s is a space (deliberately not symmetric — `padStart` exists for zero-padding).
- Whitespace inside quotes is literal: `padStart(5, ' ')` pads with a space and `join(' / ')` works.
**The default locale is `<html lang>`, read when `@wcstack/state` is evaluated**, falling back to `'en'`. The four locale-dependent filters — `locale`, `date`, `time`, `datetime` — use it. Always set `<html lang>` in the markup; an explicit `bootstrapState({ locale })` still wins; a tag `Intl` does not take is reported (`#48`) and `'en'` is used. **Changing the locale later re-renders nothing** (it is a page setting, not state). Per-call overrides (`price|locale(fr-FR)`) are fixed at bind time. For pages that switch language without reloading, translations belong on a path, not in a filter (`docs/i18n-design.md`).
## 8. Event Handling
```html
<button data-wcs="onclick: handleClick">Click</button>
<form data-wcs="onsubmit#prevent: handleSubmit">...</form>
```
```javascript
export default {
items: ["A", "B", "C"],
handleClick(event) { /* this = state proxy */ },
removeItem(event, index) { // (event, ...listIndexes) when inside a loop
this.items = this.items.toSpliced(index, 1);
}
};
```
- Signature: `(event, ...listIndexes)`. Inside loops, the enclosing loop indexes are appended after the event.
- **`onclick:` binds a method name only and cannot pass arguments** — for argument variants, define zero-argument wrapper methods (e.g. `filterAll() { this.filterBy(""); }`), or read the loop index.
- Writing `$command.<name>` on the right side emits directly: `<button data-wcs="onclick: $command.refreshList">`.
- Two `on*:` bindings of one type on one element (`onclick: a; onclick: b`) both run.
### Delegation and `#direct`
`on*:` bindings for `click`, `dblclick`, `input`, `change`, `submit`, `keydown`, `keyup`, `mousedown`, `mouseup`, `pointerdown` and `pointerup` are **delegated**: one listener per event type sits on the root the state binds — the document, a shadow root, or, inside a Light DOM mounted component, the host element — and it runs the handlers of the elements the event passed, innermost first. Other event types, and an event a custom element dispatches with `bubbles: false`, are heard on the element. Two-way bindings (`value:`, `checked:`, radio, checkbox) keep their listener on the element.
| | Delegated `on*:` | `on*#direct:` |
|---|---|---|
| `event.currentTarget` in the handler | **the root** (document, shadow root, Light DOM host) | the element |
| `#stop` on an inner binding | stops the outer delegated handlers | stops them, **and** your own `addEventListener` listeners on ancestors |
| Your code calls `stopPropagation()` on an ancestor (a modal's content keeping clicks from the overlay) | the inner handler **never runs** | it runs |
| The element is moved under another root (a dialog moved from a shadow root to `document.body`) | its handler **does not run** | it runs |
- **Do not read `event.currentTarget` in a plain `on*:` handler.** Prefer `event.target.closest("li")` or the loop index the handler receives. Lint reports a delegated-event handler that reads `currentTarget` from its event parameter as `wcs/delegated-current-target` (warning); it cannot see `stopPropagation()` in your own code or elements moved between roots.
- **Write `on*#direct:` only where the listener must sit on the element** — pointer capture (`e.currentTarget.setPointerCapture(…)`), the element's own rect, `new FormData(e.currentTarget)`, a `#stop` meant to stop your own listener on an ancestor, an ancestor that calls `stopPropagation()`, an element you move to another root. It combines with `#prevent` and `#stop` (`onclick#direct,stop:`). Inside `for:` / `if:` it is attached per row and removed with the row.
- **Order:** handlers run where the DOM puts them. An outer `#direct` handler runs on its element, before the delegated handlers inside it run at the root, so an inner delegated `#stop` cannot stop it — `<li data-wcs="onclick#direct: select">` around `<button data-wcs="onclick#stop: remove">` runs `select` and then `remove`. When an outer binding is `#direct`, make the inner `#stop` binding `#direct` too (`onclick#direct,stop: remove`).
## 9. command-token / event-token
### command token (state → element method invocation)
```html
<wcs-state>
<script type="module">
export default {
$commandTokens: ["refreshList"],
onClick() { this.$command.refreshList.emit("/api/users", { method: "GET" }); }
};
</script>
</wcs-state>
<!-- Subscriber side. The right side must be $command.<name> (bare names throw #1201) -->
<wcs-fetch data-wcs="command.fetch: $command.refreshList"></wcs-fetch>
```
- Declare with `$commandTokens: string[]` → `this.$command.<name>.emit(...args)`. Arguments are forwarded verbatim to the subscribing element's method (not awaited; wait on Promises with `Promise.all(token.emit(...))`).
- One token fans out to multiple elements; subscribe order is preserved. An undeclared `$command.<name>` in markup throws `[wcs/token-undeclared]` #1302; a method the element does not declare throws `[wcs/token-misconfigured]` #1203. A subscriber that throws is reported (`#17`) and the others still run.
### event token (element → state)
```html
<wcs-state>
<script type="module">
export default {
users: [],
$eventTokens: ["userCreated"],
$on: {
userCreated(state, event) { // state is the first argument, not this
state.users = state.users.concat(event.detail);
},
// emitter inside a loop: (state, event, ...listIndexes)
}
};
</script>
</wcs-state>
<!-- The key is the wcBindable property name (not the raw event name). The token name is bare (no $) -->
<my-form data-wcs="eventToken.created: userCreated"></my-form>
```
- The event-token surface fires on **every** dispatch, including a repeat of the same payload — it is the occurrence channel, so prefer it over a property binding whenever "it happened again" is the thing you care about. (On I/O nodes, properties declared `semantics: "event"` are also exempt from the same-value guard; see `io-node-catalog.md` §0.)
- **`$on` handlers are not awaited.** An async handler which rejects is caught and reported through `console.error` naming the state and the handler; it is neither propagated nor awaited, so never sequence work on the return value — let the async work write its own state slot when it settles. Synchronous throws still propagate as programmer errors.
- Declaration errors are loud: a non-array or duplicate token list (`#18`–`#21`), an `$on` entry not in `$eventTokens` (`#23`) or not a function (`#24`), an `eventToken.` name not in `$eventTokens` (`[wcs/token-undeclared]` #1301), an element with no such wcBindable property (`#1204`).
- **Where they run**: on the root state; in a **mounted component**, on the component's own bindings (`$commandTokens` / `$eventTokens` / `$on` run there); in a volume they are not run (`console.warn`). Re-attaching the root `<wcs-state>` keeps both registries; nothing fires while it is disconnected, and a re-set replaces the `$on` subscriptions.
### state ↔ wcs-fetch working example (skeleton of the users-crud example)
```html
<script type="module" src="https://esm.run/@wcstack/fetch/auto"></script>
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
<wcs-state>
<script type="module">
export default {
$commandTokens: ["refreshList"],
$eventTokens: ["userResponded"],
// 1 fetch = 1 state slot. For outputs the element is the authority, so seed with real initial values (null)
listFetch: { value: null, loading: false, error: null, status: 0 },
createFetch: { url: "/api/users", method: "POST", manual: true,
body: { name: "" }, value: null, error: null, loading: false, status: 0 },
get "listFetch.url"() { return "/api/users"; }, // compute the URL with a nested getter inside the slot
get listRows() { return this["listFetch.value"] ?? []; }, // for: requires an array, so null-guard
$on: {
userResponded: (state, event) => {
const status = event.detail?.status ?? 0;
if (status < 200 || status >= 300) return; // wcs-fetch:response also fires on errors
state.$command.refreshList.emit();
},
},
};
</script>
</wcs-state>
<wcs-fetch data-wcs="...: listFetch; command.fetch: $command.refreshList"></wcs-fetch>
<wcs-fetch data-wcs="...: createFetch; eventToken.value: userResponded">
<wcs-fetch-header name="Content-Type" value="application/json"></wcs-fetch-header>
</wcs-fetch>
```
### spread binding (`...`)
- `...: target` wires all wcBindable properties + inputs at once. `commands`/event tokens are excluded (explicit wiring required).
- Inside for: `...: storesFetches.*` (recommended) or `...: .`.
- Last-wins override: `...: usersFetch; status: alternateStatus`.
- Right-side filters are an **error** (`#105`). Elements without a wcBindable declaration are an **error** (`[wcs/spread-no-bindable]` #1501) — statically caught for the built-in helper tags (`wcs-fetch-header` / `wcs-fetch-body` / `wcs-infinite-scroll` / `wcs-voice`). An empty-but-declared contract (`wcs-noise`) is legal: it expands to zero props.
- `undefined` state paths are write-skipped for the property (the element default survives). Clear by assigning `null`.
- **wc-bindable elements inside `for:` rows** work in both load orders: an output-only member (`<wcs-fetch>`'s `value` / `loading`, `<wcs-intersect>`'s `intersecting`, …) or a two-way member with `#init=element` takes its initial value from the row's own element, and a spread onto a not-yet-defined element waits for its definition (the rows render at once). In a shadow root with a scoped `CustomElementRegistry`, row elements wait on that registry. A value an element writes to its own row (`value: .`, `status: .`) updates that row only.
## 10. `$watch` — headless change subscription
`$renderedCallback` is binding-driven: a value nothing renders is invisible to it. **`$watch` fires on state changes whether or not the path has a DOM binding.**
```javascript
export default {
isLoading: false,
items: [],
$listKeys: { items: "id" }, // optional: a keyed refetch fires only the changed fields, with a real prev
$watch: {
// edge detection is yours: compare cur/prev in the handler
isLoading(cur, prev) {
if (cur === true && prev === false) { this.startedAt = Date.now(); }
},
// wildcard paths fire once per changed row; trailing args are this scope's indexes
"items.*.price"(cur, prev, index) {
this.lastPriceChange = `#${index}: ${prev} → ${cur}`;
},