Kaynağa Gözat

Add statum-binding.js: client-side variables (stm-global/stm-local/stm-declare)

Fourth client module implementing lazy-cached page-wide and subtree-scoped variables usable in any stm-* expression. stm-global-{name} and stm-local-{name} bind expressions (evaluated at most once per render pass, only when read; cycles log an error naming the chain and read as undefined); the -from-{attribute} forms source variables from element attributes (MutationObserver, redraw only on actual value change so the walk's own attribute writes never loop), with from-value/from-checked on form controls reading the live property and redrawing per keystroke; stm-declare-{name} reserves a subtree name so a descendant binds it and siblings of the binder read it. Removes stm-def-{name}-as entirely: the walk now builds scopes through a bridge seam (buildScope/onPassStart, no-op defaults in core), and statum-mkpstm emits stm-local-{name} for pstm-input fragment parameters. Documented in TEMPLATES.md/model.md/getting-started.md/WHATS_NEW.md; frontend-demo serves the module and demonstrates the username example; ten new DOM-shim scenarios (25-34) cover scoping, laziness, globals order/duplicates, cycles, the redraw guard, both property forms, the declare example and shadowing; DOM shim gains a MutationObserver stub.
saddle-ClankerSaddle 2 hafta önce
ebeveyn
işleme
ae1dd3d1e2

+ 124 - 0
.kilo/plans/goal-30-plan.md

@@ -0,0 +1,124 @@
+# Goal 30 — Split statum.js into four modules + automatic pstm rewrite
+
+Branch `statum-js-modules` (cut from master c741172). All findings below verified
+against the source on this saddle. The locked brief and task #212 comment 814 are
+authoritative; this plan follows them, with two implementation choices called out
+where 814 left naming open.
+
+## Verified ground truth (from investigation)
+
+- js/statum.js (3,943 lines) section boundaries match the 814 inventory exactly:
+  core = 1–1123 + 3531–3561(renderAll/guards/hooks) + 3690–3943 (boot/state/init/API);
+  document = 1125–1256 + 3357–3688 minus `applyErrorClasses`; markdown = 1257–3335.
+- `processInner` :3504 hardcodes the order class→attribute→disabled→text/html/markdown/demotion→wireAction→recurse.
+  Splitting stm-markdown out (it loads AFTER document) means plain registration-order
+  execution would demote before markdown renders — the seam needs explicit ordering.
+- `applyErrorClasses` :3638 (document territory) is called by core's `reportFailure` :304 →
+  moves to core per the brief.
+- `performAction` :935 rejects on HTTP error / error-directive (`reportFailure` :302 re-throws
+  unless suppressed) → `stm-clear-on-success` can await the `__stmAction` stash.
+- Delivery today: `src/meson.build` embeds js/statum.js via mkres as `/_statum/resource/statum.js`
+  (ClientScript.vala); mkpstm `rewrite_resources_in` :401 does the generic `pstm-res-*` rewrite;
+  example/main.html:8 has the single `pstm-res-src="statum.js"` (stays UNMODIFIED — the compat
+  proof); frontend-demo/server.php:362 serves `/statum.js` off disk; index.html:405 references it,
+  with toast overrides right after. Markdown tests load the file via `std.loadFile` under a stub shim.
+- Toolchain: gcc/meson/valac + all pkg-config deps present. php NOT installed (dnf has it —
+  needed for the demo e2e). qjs builds vendored on demand.
+- No js/ references in MANIFEST.usm/.usmignore/.gitignore — packaging untouched.
+
+## Design decisions (implementation-level, stated per the "deviate only with reason" rule)
+
+1. **Bridge = `window.statum._bridge`** — core's IIFE builds the public API plus this internal
+   object holding the shared seam (evalExpr, childScope, stateScope, renderAll, registerAttribute,
+   installRenderDriver, performAction, trigger*, handlers, hooks, and the forms-hosted dirty WeakSet
+   slot). Underscore prefix follows the existing `_testMarkdown` private-by-convention precedent.
+   "Guarded IIFE" = each module starts by checking `window.statum && window.statum._bridge`,
+   else `console.error('[statum] <module> requires statum-core.js')` and returns (no throw).
+2. **registerAttribute ordering** — `bridge.registerAttribute(name, fn, opts)` where `opts.after`
+   / `opts.before` name another attribute; core resolves to the ordered handler list
+   `processInner` iterates. Document registers its handlers in today's relative order; markdown
+   registers `stm-markdown` with `after:'stm-html'`; document registers `stm-h-demotion` with
+   `after:'stm-markdown'` (present-but-late if markdown never loads — same as today's
+   "runs after html" guarantee). A split test pins the final order byte-for-byte to today's.
+3. **Interim tree coherence** — each task leaves the tree building and `meson test` green:
+   task A deletes js/statum.js and rewires meson to the modules that exist (3 after A, 4 after B);
+   the example page only becomes servable again in task C when mkpstm expands the reference.
+
+## Tasks (sequential; each a separate task branch)
+
+### Task A — Split core/document/markdown + seams (implement)
+- Create js/statum-core.js, statum-document.js, statum-markdown.js per the 814 line map;
+  delete js/statum.js. Core keeps: config/state/persistence/session/listeners/mutation,
+  headers+freshness, HTTP helpers, entrypoint/action fetch, directive dispatch + terminal
+  directives, realtime channel, boot visibility, guards/hooks/renderAll, public state accessors,
+  init/autoInit/public API, `handlers`+`trigger*` (:74/:249–307) preserved exactly,
+  `applyErrorClasses` moved in. Document keeps: loop/conditional/class/attribute/disabled
+  handlers, render walk (render/processElement/processInner/applyDefs), text/html binding,
+  h-demotion, action wiring (wireAction/runElementAction/collectData/setBusyState/reapplyDisabled).
+  Markdown = engine :1257–3335 verbatim + the stm-markdown binding + `statum.markdown.render(src)` /
+  `renderTo(el, src)` sharing the binding's single code path + the `_testMarkdown` hatch moves with it.
+- Add `_bridge`, registerAttribute seam, render-driver install point (renderAll calls the installed
+  driver inside its existing try/catch; absent driver = hooks+guards still run, no walk).
+- Rewire src/meson.build to three mkres resources (statum.js resource name disappears; ClientScript
+  class replaced by per-module classes registered in Statum.vala).
+- Tests: new tests/split (load order, public surface incl. getter/setter handlers, handler order,
+  double registration, module-without-core guard, markdown-without-document cherry-pick);
+  tests/markdown loaders switched to core+markdown (regressions + spec suite stay green, gate 95%).
+- Verify: tests/markdown/run.sh, tests/split, `meson setup && ninja && meson test`.
+
+### Task B — Forms module: stm-prefill + clear-on-success (implement)
+- js/statum-forms.js registering `stm-prefill` (early in handler order): property writes only —
+  `.value` for input/select/textarea (radio name-group matching within the form), `.checked` for
+  checkbox (boolean coercion) and radio (value match); skips any element in the dirty WeakSet.
+- Dirty tracking: one WeakSet hosted on `_bridge` (per the brief, reusable by the future task-186
+  fix) + capture-phase `input`/`change` listeners on document, and a `reset` listener that
+  un-dirties the form's controls (doubling as the server form-reset un-dirty path).
+- `__stmAction` promise stash: one-line change in statum-document.js wireAction listener
+  (`el.__stmAction = runElementAction(el); return el.__stmAction;` shape).
+- `stm-clear-on-success` on a form: submit listener awaits the triggering element's stashed
+  promise; on resolve → `form.reset()` (fires the reset un-dirty path) ; rejection → leave fields.
+  `statum.forms.reset(form)` as the programmatic equivalent.
+- Non-regression: stm-attribute.value keeps writing the attribute (server-wins, untouched);
+  no change to C1/C2/stateless-walk behaviour.
+- Tests: tests/dom — real-ish DOM shim (~250 lines, ordered attributes, classList, tree
+  mutation, atob/btoa, stubbed fetch; state injected via hand-built frames or a documented
+  `__STATUM_TEST__` hatch) covering the walk (if/else/for/text) and prefill scenarios:
+  value, dirty-skip after input, checked, radio group, clear-on-success resolve/reject,
+  forms.reset, reset-event un-dirty. model.md attribute table gains stm-prefill /
+  stm-clear-on-success rows. meson gains the 4th resource.
+- Verify: tests/dom + tests/split + markdown suite + meson test.
+
+### Task C — mkpstm rewrite + delivery + docs + demo e2e (implement)
+- mkpstm: `pstm-res-src="statum.js"` on a `<script>` expands at build time to the four ordered
+  script tags (`/_statum/resource/statum-core.js` → document → markdown → forms), one build
+  warning per page naming page + expansion; explicit module tags pass through unwarned; a page
+  mixing statum.js with explicit module tags emits the full ordered set once, deduped, and the
+  warning says so. example/main.html stays byte-identical.
+- frontend-demo: server.php serves the four module files (replacing /statum.js), index.html loads
+  them in order with the toast-override script after; demo must work end to end and the override
+  mechanism (onNotifyHandler/onConfirmHandler/onErrorHandler setters + trigger*) still functions —
+  proven by the split-suite assertions plus a served-page check (four tags in order, modules
+  fetchable, override script present).
+- WHATS_NEW.md (root): stm-prefill semantics incl. checked/radio groups, stm-clear-on-success +
+  statum.forms.reset, statum.markdown.render/renderTo, statum.registerAttribute for module
+  authors, per-module script tags + ordered load contract, automatic pstm-res rewrite with the
+  legacy-reference warning. Short, example-driven, links to model.md/getting-started.md.
+- Docs: model.md/getting-started.md delivery sections rewritten for modules + rewrite;
+  statum-issues.md S-4 note (superseded by stm-prefill) and mkpstm docs updated.
+- Verify: full build; generated HomePage.vala contains the four tags (backwards-compat proof with
+  the original unmodified reference); run statum-example + curl page/modules; install php via dnf,
+  run frontend-demo (PHP_CLI_SERVER_WORKERS=5) + curl; all three JS suites + meson test green;
+  no dead single-file remnants (`grep -r "resource/statum.js"` only matches the rewrite map + docs).
+
+## Non-goals / constraints
+- ES5, zero dependencies, no bundler, no router module; SSE/SharedWorker stays in core.
+- No behaviour change to the engine, the override mechanism, or directive semantics — moved, not
+  redesigned. Non-regression on C1/C2/stateless-walk per task 186.
+- USM packaging/MANIFEST untouched.
+
+## Risks
+- The DOM shim is the largest new test surface; scoped to the walk + prefill paths only, and
+  state injection uses a documented test hatch rather than widening the public API.
+- mkpstm expansion touches libxml2 node surgery (insert siblings before remove) — covered by a
+  generated-output check in the example build, and by keeping the generic `pstm-res-*` rewrite
+  untouched for non-script elements.

+ 69 - 17
TEMPLATES.md

@@ -127,21 +127,20 @@ the wire protocol and directives.
 ## Part II — The runtime modules
 
 Load contract: **core first, always**. `statum-core.js` owns the slot
-protocol, the directive pipeline, the realtime channel and the seams
-(`statum.registerAttribute`, the render driver, the internal
-`statum._bridge`) that every later module registers into. The canonical
-order is core → document → markdown → forms. Trailing modules may be
-cherry-picked after core (e.g. core + document for pages without markdown or
-form extras — note `stm-markdown` AND `stm-h-demotion` live in the markdown
-module). A module loaded without core logs `console.error('[statum] …
-requires statum-core.js')` and does nothing else. All modules are ES5,
-dependency-free, guarded IIFEs.
+protocol, the directive pipeline, the realtime channel and the seams later
+modules register into (the internal bridge; the attribute registry itself is
+document-side). The canonical order is core → document → markdown → binding
+(markdown and binding are order-independent of each other and need not
+co-exist). Trailing modules may be cherry-picked after core (e.g. core +
+document for pages without markdown or variables). A module loaded without
+core logs `console.error('[statum] … requires statum-core.js')` and does
+nothing else. All modules are ES5, dependency-free, guarded IIFEs.
 
 ### Expression syntax (all modules)
 
 `stm-*` attribute values are JavaScript expressions evaluated against a scope
 built from state: `{ typeName: public }` for every held slot type, plus loop
-variables and `stm-def-*` locals in nested scopes. `statum.read('counter')`
+variables and `stm-local-*` locals in nested scopes. `statum.read('counter')`
 is the programmatic equivalent. Expression compilation is cached. An
 expression evaluating to `undefined` means "not resolved yet" and LEAVES THE
 ELEMENT UNTOUCHED (the server-rendered original DOM is preserved — this is
@@ -231,11 +230,13 @@ driver. Attributes:
   expression over the item, usually a field) REUSE DOM nodes for unchanged
   keys, preserving focus/selection/input state; unkeyed loops rebuild
   wholesale. `data-stm-active="1"` + `hidden` mark an active holder.
-- **`stm-def-{name}-as`**: local definition. The expression is evaluated
-  against the incoming scope and the result is visible to this element and
-  its descendants (a child scope). Multiple defs per element; defs cannot
-  reference each other within the same element. Used heavily by generated
-  fragment markup.
+- **`stm-local-{name}="expr"`**: local variable (statum-binding.js). The
+  expression is evaluated LAZILY (at most once per render pass, only when
+  read) against the parent scope; the variable is visible to this element
+  and its descendants (a child scope) and shadows slot types/outer
+  variables. Multiple locals per element; same-element locals cannot
+  reference each other. Replaces the removed `stm-def-{name}-as`
+  (mkpstm's generated fragment-parameter attribute included).
 - **`stm-text`** / **`stm-html`**: set text content / inner HTML from an
   expression. `undefined` leaves content untouched; otherwise stringified
   (`null` → `''`). Note (known issue C1): on editable controls `stm-text`
@@ -309,10 +310,61 @@ Form-state bindings (hosted in the document module since the former
   **`statum.document.markDirty(el)`**, **`statum.document.isDirty(el)`**.
 
 
+### Section: statum-binding.js
+
+Client-side variables usable in any other `stm-*` expression. All are
+LAZY-CACHED: a binding's expression evaluates at most once per render pass,
+only when something reads the variable; the cache resets each pass. Cycles
+(`a` reading `b` reading `a`) log `[statum] variable cycle: a → b → a` and
+the variable reads as `undefined` for the pass — never a page crash.
+
+- **`stm-global-{name}="expr"`**: a page-wide variable, visible in any
+  expression anywhere — including elements that precede its declaring
+  element in document order (the pass-start sweep exposes lazy getters).
+  Duplicate declarations: last in document order wins, one warn per pass.
+- **`stm-global-{name}-from-{attribute}`**: page-wide variable whose value
+  is the element's attribute; attribute changes trigger a redraw.
+- **`stm-local-{name}="expr"`**: subtree-scoped variable (element +
+  descendants). Replaces the removed `stm-def-{name}-as`.
+- **`stm-local-{name}-from-{attribute}`**: subtree-scoped attribute-sourced
+  variable; attribute changes trigger a redraw.
+- **`stm-declare-{name}`**: declares `{name}` for a subtree WITHOUT a
+  value, so a descendant binds it (`stm-local-{name}=…` or
+  `stm-local-{name}-from-…`) and SIBLINGS of that binder can read it:
+
+```html
+<div stm-declare-username>
+  <form>
+    <input type="text" stm-local-username-from-value>
+  </form>
+  <div class="alert" stm-if="username.length < 3">
+    <p>Username is too short</p>
+  </div>
+</div>
+```
+
+**Property form**: `from-value` and `from-checked` on
+input/select/textarea read the live PROPERTY, not the attribute — an
+input/change listener updates the variable and redraws immediately (per
+keystroke). Every other attribute name (and every non-form-control element)
+uses a MutationObserver. REDRAW GUARD: an observed attribute change only
+redraws when the variable's derived value actually changed — the walk itself
+writes attributes (`stm-attribute.*`, `hidden`, `data-stm-active`), so a
+naive observer would loop forever.
+
+Declared-but-unbound variables read `undefined` (an expression like
+`x.length` then degrades to `undefined` for that expression via the
+evaluator's error policy — the page stays alive). Load order: after core and
+document; order-independent of markdown, and need not co-exist with it.
+
+
 ## Part III — Quick reference: load order and cherry-picking
 
-Canonical: core → document → markdown. Cherry-pick trailing modules only.
-Without markdown: no `stm-markdown`, no `stm-h-demotion`. Core alone is
+Canonical: core → document → markdown → binding (markdown and binding are
+order-independent of each other and need not co-exist). Cherry-pick trailing
+modules only. Without markdown: no `stm-markdown`, no `stm-h-demotion`.
+Without binding: no `stm-global-*` / `stm-local-*` / `stm-declare-*`
+variables (and the removed `stm-def-*` is inert everywhere). Core alone is
 HEADLESS — it applies state and directives with no DOM at all (point
 `statum.config` at your endpoints before init), so a non-document templating
 framework can use it as its state layer. Without document there is no render

+ 40 - 7
WHATS_NEW.md

@@ -1,18 +1,20 @@
-# What's new — headless core, three modules, mirrored form outcomes
+# What's new — headless core, four modules, client-side variables
 
-The browser client now ships as three ES5, dependency-free scripts with a
+The browser client now ships as four ES5, dependency-free scripts with a
 strict load order — and **core is headless**:
 
 ```html
 <script src="/_statum/resource/statum-core.js"></script>
 <script src="/_statum/resource/statum-document.js"></script>
 <script src="/_statum/resource/statum-markdown.js"></script>
+<script src="/_statum/resource/statum-binding.js"></script>
 ```
 
 **Load order is a contract.** `statum-core.js` must be first: it owns the slot
 protocol (state store, persistence, directives, SSE/SharedWorker realtime,
 entrypoint/action requests) and creates `window.statum`. The canonical order is
-core → document → markdown. The former fourth module, `statum-forms.js`, was
+core → document → markdown → binding (markdown and binding are
+order-independent of each other). The former `statum-forms.js` module was
 folded into `statum-document.js` (and its resource now 404s).
 
 ## Core is headless — usable without any DOM
@@ -157,6 +159,36 @@ The registry is provided by `statum-document.js` (it *is* the walk's
 machinery). Loading a registering module against core alone is safe: core
 queues the registration until document loads.
 
+## Client-side variables — the binding module
+
+`statum-binding.js` (fourth script tag, order-independent of markdown) adds
+variables usable in any other `stm-*` expression. All are lazy-cached: a
+binding evaluates at most once per render pass, only when read; cycles log
+an error and read as `undefined` instead of crashing the page.
+
+```html
+<div stm-global-vat="0.2"></div>          <!-- page-wide, any position -->
+<div stm-local-total="price * (1 + vat)">  <!-- this subtree only -->
+  <span stm-text="total"></span>
+</div>
+
+<div stm-declare-username>
+  <input stm-local-username-from-value>   <!-- live property; redraws per keystroke -->
+  <p stm-if="username.length < 3">Username is too short</p>
+</div>
+```
+
+`-from-{attribute}` forms source the variable from an attribute
+(MutationObserver, redraw only when the value actually changes — the walk's
+own attribute writes never loop). On form controls `from-value` and
+`from-checked` read the live property instead, so typing updates the
+variable immediately. `stm-declare-{name}` reserves a name for a subtree so
+a descendant can bind it and siblings of the binder can read it.
+
+**Migration**: `stm-def-{name}-as` is removed; write `stm-local-{name}`
+(same subtree semantics, now lazy). `statum-mkpstm` emits `stm-local-{name}`
+for `pstm-input` fragment parameters.
+
 ## The reenter directive
 
 A response can ask the client to re-fetch the page's entrypoint for the
@@ -173,8 +205,9 @@ action's promise (and so `stm-clear-on-success` and `statum.act`) resolves
 only after the re-entered state has been applied. A failed refresh is logged
 and swallowed.
 
-## Upcoming
+## Retired
 
-A dedicated binding module (beyond document's built-ins) is planned as a
-fourth script tag; when it lands, the expansion set and this file change
-again.
+The former `stm-def-{name}-as` attribute is gone — replaced by
+`stm-local-{name}` (see the binding section). The single-file client
+(`statum.js`) remains gone: legacy references still expand at build time
+into the four module tags above.

+ 25 - 4
frontend-demo/index.html

@@ -427,13 +427,13 @@
       <!--
         Live channel: subscribe/unsubscribe directives drive a realtime
         Server-Sent Events connection; broadcasting pushes a `set` to every
-        subscribed tab and also returns a `notify` directive. stm-def-{name}-as
+        subscribed tab and also returns a `notify` directive. stm-local-{name}
         defines a local variable for this element and its descendants.
         Open a second tab and broadcast to see the update arrive live.
       -->
       <section>
-        <h2>Live channel <span class="muted">(subscribe · realtime · notify · stm-def)</span></h2>
-        <div stm-def-stamp-as="live.at ? 'last updated ' + live.at : ''">
+        <h2>Live channel <span class="muted">(subscribe · realtime · notify · stm-local)</span></h2>
+        <div stm-local-stamp="live.at ? 'last updated ' + live.at : ''">
           <p>
             <strong stm-text="live.text">…</strong>
             <span class="muted" stm-text="stamp"></span>
@@ -456,6 +456,26 @@
           <button stm-action="user.resetAllAction" stm-confirm="Reset all session state?">Reset everything</button>
         </p>
       </section>
+
+      <!--
+        Client-side variables (stm-binding module): stm-declare-username
+        declares a variable for this subtree; the input binds it to its live
+        value property (stm-local-username-from-value), so typing updates
+        `username` and redraws immediately — the alert below reads it through
+        plain stm-if. No server round trip involved.
+      -->
+      <section>
+        <h2>Local variables <span class="muted">(stm-declare · stm-local-from-value · stm-if)</span></h2>
+        <div stm-declare-username>
+          <p><label>Username <input type="text" name="username" stm-local-username-from-value></label></p>
+          <div class="alert" stm-if="username.length < 3">
+            <p>Username is too short</p>
+          </div>
+          <div stm-if="username.length >= 3">
+            <p>Looks good, <span stm-text="username"></span>.</p>
+          </div>
+        </div>
+      </section>
     </div>
   </main>
 
@@ -464,10 +484,11 @@
 
   <!-- The library auto-initialises on DOMContentLoaded. Four modules, in load
        order: core first (it installs the seams the later modules register
-       into), then document, markdown, forms. -->
+       into), then document, markdown, binding. -->
   <script src="/statum-core.js"></script>
   <script src="/statum-document.js"></script>
   <script src="/statum-markdown.js"></script>
+  <script src="/statum-binding.js"></script>
   <script>
     // Override the default UI handlers so `notify` directives (and errors)
     // render as styled toasts instead of using the browser's native alert().

+ 1 - 1
frontend-demo/server.php

@@ -380,7 +380,7 @@ $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
 
 // Static assets: the Statum client modules, in load order (core first — the
 // later modules register into the seams it installs).
-if (preg_match('#^/(statum-(core|document|markdown))\.js$#', $path, $m)) {
+if (preg_match('#^/(statum-(core|document|markdown|binding))\.js$#', $path, $m)) {
     header('Content-Type: application/javascript');
     readfile(__DIR__ . '/../js/' . $m[1] . '.js');
     exit;

+ 8 - 5
getting-started.md

@@ -366,16 +366,19 @@ No route is specified — a GUID is auto-assigned at registration.
 
 ## JS API Reference
 
-The Statum client is embedded in the library and served as three modules from
-`/_statum/resource/statum-core.js`, `statum-document.js` and
-`statum-markdown.js`. Include them in your template, in this order (core
-creates `window.statum`; document owns the render walk, the attribute
-registry and the form bindings; markdown adds the CommonMark engine):
+The Statum client is embedded in the library and served as four modules from
+`/_statum/resource/statum-core.js`, `statum-document.js`,
+`statum-markdown.js` and `statum-binding.js`. Include them in your template,
+in this order (core creates `window.statum`; document owns the render walk,
+the attribute registry and the form bindings; markdown adds the CommonMark
+engine; binding adds client-side `stm-local-*`/`stm-global-*` variables —
+markdown and binding are order-independent of each other):
 
 ```html
 <script pstm-res-src="statum-core.js"></script>
 <script pstm-res-src="statum-document.js"></script>
 <script pstm-res-src="statum-markdown.js"></script>
+<script pstm-res-src="statum-binding.js"></script>
 ```
 
 You may cherry-pick trailing modules (core first, always): core + document

+ 306 - 0
js/statum-binding.js

@@ -0,0 +1,306 @@
+/**
+ * statum-binding.js — client-side variables for Statum pages.
+ *
+ * Load position: after statum-core.js and statum-document.js. This module
+ * and statum-markdown.js are order-independent and need not co-exist.
+ *
+ * ES5-style, zero dependencies, guarded IIFE. Attributes:
+ *
+ *   stm-global-{name}="expr"              page-wide variable, usable in any
+ *                                         expression anywhere on the page
+ *   stm-global-{name}-from-{attribute}    page-wide variable sourced from an
+ *                                         element attribute; changes to that
+ *                                         attribute trigger a redraw
+ *   stm-local-{name}="expr"               subtree-scoped variable (element +
+ *                                         descendants); replaces the removed
+ *                                         stm-def-{name}-as
+ *   stm-local-{name}-from-{attribute}     subtree-scoped attribute-sourced
+ *                                         variable; changes trigger a redraw
+ *   stm-declare-{name}                    declares {name} for a subtree so a
+ *                                         descendant can bind it and SIBLINGS
+ *                                         of that binder can read it
+ *
+ * Semantics (operator-confirmed):
+ * - LAZY-CACHED: a binding's expression evaluates at most once per render
+ *   pass, only when something reads the variable; the result is cached for
+ *   the pass and the cache resets each pass.
+ * - CYCLES are an error, not a crash: re-entrant evaluation of the SAME
+ *   binding logs '[statum] variable cycle: a → b → a' (variable names) and
+ *   the variable reads as undefined for that pass. Distinct bindings that
+ *   shadow a name are not cycles (an inner `x` may reference an outer `x`).
+ * - `from-value` / `from-checked` on input/select/textarea read the live
+ *   PROPERTY (not the attribute): an input/change listener updates the
+ *   variable and calls renderAll() directly, per keystroke. Every other
+ *   attribute name, and every non-form-control element, uses a
+ *   MutationObserver.
+ * - Redraw guard: an observed attribute change only redraws when the
+ *   variable's derived value actually changed — the walk itself writes
+ *   attributes (stm-attribute.*, hidden, data-stm-active) and a naive
+ *   observer would loop forever.
+ * - A stm-global is visible page-wide on the FIRST pass it is declared in,
+ *   even at elements that precede its declaring element in document order
+ *   (the pass-start sweep exposes lazy getters; each reader resolves the
+ *   binding on demand, at most once per pass).
+ * - Shadowing: a variable shadows slot types and outer variables with the
+ *   same name inside its scope. Duplicate stm-global-{name}: last in
+ *   document order wins, one console.warn per pass.
+ */
+(function () {
+  if (!window.statum || !window.statum._bridge) {
+    console.error('[statum] statum-binding.js requires statum-core.js');
+    return;
+  }
+  if (!window.statum.registerAttribute) {
+    console.error('[statum] statum-binding.js requires statum-document.js');
+    return;
+  }
+  var bridge = window.statum._bridge;
+  var evalExpr = bridge.evalExpr;
+  var renderAll = bridge.renderAll;
+
+  // Scope-chain marker for declared slots. Expressions evaluate through the
+  // evalScope proxy, whose `with` semantics only surface string keys, so
+  // this key is invisible to page code.
+  var DECLARED = '__stmDeclared';
+
+  /** Per-pass cache: binding key -> value. Reset at every pass start. */
+  var passCache = {};
+  /** Evaluation stack for cycle detection: {key, name}, innermost last. */
+  var evalStack = [];
+
+  /** Elements whose property listener is attached. @type {WeakSet} */
+  var propBound = new WeakSet();
+  /** Elements with a live MutationObserver. @type {WeakSet} */
+  var observed = new WeakSet();
+  /** Live property values: element -> { varName: value }. @type {WeakMap} */
+  var liveProps = new WeakMap();
+  /** Stable per-element identity for cache keys. @type {WeakMap} */
+  var elIds = new WeakMap();
+  var elSeq = 0;
+
+  function elId(el) {
+    var id = elIds.get(el);
+    if (id === undefined) { id = ++elSeq; elIds.set(el, id); }
+    return id;
+  }
+
+  /**
+   * Resolve one binding for this pass: evaluate at most once, cache, and
+   * detect re-entrant evaluation of the SAME binding (a cycle).
+   * @param {string} key unique per binding site (stable across passes)
+   * @param {string} name variable name, for diagnostics
+   * @param {Function} compute () => value
+   */
+  function resolve(key, name, compute) {
+    if (Object.prototype.hasOwnProperty.call(passCache, key)) {
+      return passCache[key];
+    }
+    for (var i = 0; i < evalStack.length; i++) {
+      if (evalStack[i].key === key) {
+        var chain = [];
+        for (var j = i; j < evalStack.length; j++) chain.push(evalStack[j].name);
+        console.error('[statum] variable cycle: ' + chain.concat(name).join(' → '));
+        return undefined;
+      }
+    }
+    evalStack.push({ key: key, name: name });
+    try {
+      var value = compute();
+      passCache[key] = value;
+      return value;
+    } finally {
+      evalStack.pop();
+    }
+  }
+
+  /**
+   * Parse a binding attribute name into {kind, name, source, attr};
+   * source is the attribute/property name for -from- forms, else null.
+   * @param {string} attr
+   */
+  function parseBinding(attr) {
+    var m;
+    if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)-from-(.+)$/)))
+      return { kind: 'global', name: m[1], source: m[2], attr: attr };
+    if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)$/)))
+      return { kind: 'global', name: m[1], source: null, attr: attr };
+    if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)-from-(.+)$/)))
+      return { kind: 'local', name: m[1], source: m[2], attr: attr };
+    if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)$/)))
+      return { kind: 'local', name: m[1], source: null, attr: attr };
+    return null;
+  }
+
+  /** Live-property form: from-value/from-checked on a form control. */
+  function isPropSource(el, source) {
+    if (source !== 'value' && source !== 'checked') return false;
+    var tag = el.tagName;
+    return tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA';
+  }
+
+  /** Attach the redraw plumbing for one -from- binding (idempotent). */
+  function bindSource(el, binding) {
+    if (isPropSource(el, binding.source)) {
+      if (propBound.has(el)) return;
+      propBound.add(el);
+      var store = liveProps.get(el) || {};
+      store[binding.name] = el[binding.source];
+      liveProps.set(el, store);
+      var onChange = function () {
+        var s = liveProps.get(el);
+        if (!s || s[binding.name] === el[binding.source]) return; // no change
+        s[binding.name] = el[binding.source];
+        renderAll();
+      };
+      el.addEventListener('input', onChange);
+      el.addEventListener('change', onChange);
+      return;
+    }
+    if (observed.has(el) || typeof MutationObserver !== 'function') return;
+    observed.add(el);
+    var last = el.getAttribute(binding.source);
+    new MutationObserver(function () {
+      var now = el.getAttribute(binding.source);
+      if (now === last) return; // same-value write: never redraw
+      last = now;
+      renderAll();
+    }).observe(el, { attributes: true, attributeFilter: [binding.source] });
+  }
+
+  /**
+   * Build one binding's value computation. Expression forms evaluate
+   * against the PARENT scope (a binding cannot reference itself or a
+   * same-element sibling, matching the stm-def semantics this replaces).
+   * -from- forms read their live source (attaching plumbing on first use).
+   */
+  function makeCompute(el, binding, parentScope) {
+    if (binding.source === null) {
+      return function () {
+        return evalExpr(el.getAttribute(binding.attr), parentScope);
+      };
+    }
+    return function () {
+      bindSource(el, binding);
+      if (isPropSource(el, binding.source)) {
+        var store = liveProps.get(el);
+        if (store && Object.prototype.hasOwnProperty.call(store, binding.name)) {
+          return store[binding.name];
+        }
+      }
+      return el.getAttribute(binding.source);
+    };
+  }
+
+  /**
+   * Expose a lazily-resolved variable on a scope. `slot`, when present, is
+   * a declared slot (see buildScope): resolution goes through the slot so
+   * the NEWEST binder in the subtree supplies the value, and the cache key
+   * is the SLOT's (one evaluation per pass no matter which getter is read).
+   */
+  function defineVar(scope, name, key, compute, slot) {
+    if (slot) slot.compute = compute;
+    Object.defineProperty(scope, name, {
+      configurable: true,
+      enumerable: true,
+      get: function () {
+        if (slot) return resolve(key, name, slot.compute);
+        return resolve(key, name, compute);
+      },
+      set: function (v) { // expressions never assign; escape hatch only
+        Object.defineProperty(scope, name, {
+          configurable: true, writable: true, value: v
+        });
+      }
+    });
+  }
+
+  /**
+   * Walk UP a scope chain for a declared slot named `name`.
+   * @returns {{key: string, compute: Function}|null}
+   */
+  function findDeclared(scope, name) {
+    var s = scope;
+    while (s) {
+      if (Object.prototype.hasOwnProperty.call(s, DECLARED) && s[DECLARED][name]) {
+        return s[DECLARED][name];
+      }
+      s = Object.getPrototypeOf(s);
+    }
+    return null;
+  }
+
+  /**
+   * Scope construction seam (document's walk calls this for every element;
+   * core defaults to identity). Elements carrying stm-local-* or
+   * stm-declare-* get a child scope with lazy getters; others pass through
+   * unchanged.
+   */
+  function buildScope(el, scope) {
+    var attrs = el.attributes;
+    var child = null;
+    var declaredHere = null;
+    for (var i = 0; i < attrs.length; i++) {
+      var attrName = attrs[i].name;
+      var binding = parseBinding(attrName);
+      if (binding && binding.kind === 'local') {
+        if (!child) child = Object.create(scope || null);
+        // If an ancestor declared this name, bind into its slot so the
+        // declarer's whole subtree (including siblings of this element)
+        // reads this binding.
+        var slot = findDeclared(child, binding.name);
+        var key = slot ? slot.key
+          : 'local:' + binding.name + ':' + elId(el) + ':' + attrName;
+        defineVar(child, binding.name, key,
+          makeCompute(el, binding, scope), slot);
+      } else if (attrName.indexOf('stm-declare-') === 0) {
+        var dName = attrName.slice('stm-declare-'.length);
+        if (!dName) continue;
+        if (!child) child = Object.create(scope || null);
+        if (!declaredHere) {
+          declaredHere = {};
+          Object.defineProperty(child, DECLARED, { value: declaredHere });
+        }
+        if (!declaredHere[dName]) {
+          var dSlot = { key: 'declare:' + dName + ':' + elId(el),
+            compute: function () { return undefined; } };
+          declaredHere[dName] = dSlot;
+          defineVar(child, dName, dSlot.key, dSlot.compute, dSlot);
+        }
+      }
+    }
+    return child || scope;
+  }
+
+  /**
+   * Pass start (document's walk driver calls this with the root scope):
+   * reset the per-pass cache and expose every stm-global-* on the root
+   * scope so they are visible page-wide. Later declarations overwrite
+   * earlier ones (getters redefine); duplicates warn once per pass.
+   */
+  function passStart(rootScope) {
+    passCache = {};
+    var seenByName = {};
+    var els = document.querySelectorAll('*');
+    for (var i = 0; i < els.length; i++) {
+      var el = els[i];
+      var attrs = el.attributes;
+      for (var j = 0; j < attrs.length; j++) {
+        var name = attrs[j].name;
+        if (name.indexOf('stm-global-') !== 0) continue;
+        var binding = parseBinding(name);
+        if (!binding) continue;
+        if (seenByName[binding.name] && seenByName[binding.name] !== el) {
+          console.warn('[statum] stm-global-' + binding.name +
+            ' declared more than once; the last declaration in document order wins');
+        }
+        seenByName[binding.name] = el;
+        defineVar(rootScope, binding.name, 'global:' + binding.name,
+          makeCompute(el, binding, rootScope));
+      }
+    }
+  }
+
+  // --- installation -------------------------------------------------------
+  bridge.buildScope(buildScope);
+  bridge.onPassStart(passStart);
+})();

+ 19 - 2
js/statum-core.js

@@ -177,7 +177,7 @@
   /**
    * Wrap a scope for expression evaluation so bare identifiers resolve in a
    * controlled order: names on the scope (slot types, loop variables,
-   * `stm-def-*` locals) win; names missing from the scope but present on
+   * `stm-local-*` locals) win; names missing from the scope but present on
    * `globalThis` (`Math`, `parseInt`, `String`, …) forward to that global;
    * anything else resolves to `undefined` instead of throwing a
    * `ReferenceError` or leaking an unintended global. The `has` trap claims
@@ -1453,9 +1453,26 @@
     setBootState: function (fn) { if (typeof fn === 'function') setBootState = fn; },
     renderDriver: function (fn) { if (typeof fn === 'function') renderDriver = fn; },
     afterRenderCycle: function (fn) { if (typeof fn === 'function') afterRenderCycle = fn; },
-    applyErrorClasses: function (fn) { if (typeof fn === 'function') applyErrorClasses = fn; }
+    applyErrorClasses: function (fn) { if (typeof fn === 'function') applyErrorClasses = fn; },
+    /**
+     * Scope-construction and pass-start seams (statum-binding.js installs
+     * both; defaults are identity / no-op so the walk works without it).
+     * buildScope lets a module extend an element's scope before the
+     * registered handlers evaluate; onPassStart runs at walk start with the
+     * root scope (per-pass cache resets, page-wide sweeps).
+     */
+    buildScope: function (fn) { if (typeof fn === 'function') scopeBuilder = fn; },
+    onPassStart: function (fn) { if (typeof fn === 'function') passStartHook = fn; },
+    /** Read back the installed seams (document's walk consumes these). */
+    getScopeBuilder: function () { return scopeBuilder; },
+    getPassStartHook: function () { return passStartHook; }
   };
 
+  /** @type {Function} identity default; statum-binding.js replaces it. */
+  var scopeBuilder = function (el, scope) { return scope; };
+  /** @type {Function|null} no-op default; statum-binding.js replaces it. */
+  var passStartHook = null;
+
   /** @type {Array<[string, Function, object|undefined]>} */
   var pendingRegistrations = [];
 

+ 11 - 18
js/statum-document.js

@@ -10,7 +10,7 @@
  *     `stm-attribute.*`, `stm-disabled`, `stm-text`, `stm-html`), wires
  *     `stm-action` elements, and recurses into child elements. The
  *     structural attributes (`stm-for`, `stm-if`/`stm-else-if`/`stm-else` and
- *     `stm-def-*`) stay hardcoded in the walk: they build the scope and the
+ *     `stm-local-*` / `stm-declare-*` variables) stay hardcoded in the walk: they build the scope and the
  *     element list every handler receives.
  *   - the attribute-handler REGISTRY and the public `statum.registerAttribute`
  *     seam: module authors register `stm-*` handlers here; the walk iterates
@@ -391,23 +391,14 @@
   }
 
   /**
-   * Collect `stm-def-{name}-as` attributes on an element into a child scope.
-   * Each expression is evaluated against the incoming scope (so the new
-   * variables cannot reference each other within the same element), and the
-   * results are added to a child scope visible to the element and its
-   * descendants.
+   * Construct the element's effective scope. Local variable bindings
+   * (stm-local-*, stm-declare-* — statum-binding.js, which may be absent)
+   * attach through the bridge's scope-construction seam; without that
+   * module the incoming scope passes through unchanged.
    */
-  function applyDefs(el, scope) {
-    var s = Object.create(scope || null);
-    var attrs = el.attributes;
-    for (var i = 0; i < attrs.length; i++) {
-      var name = attrs[i].name;
-      if (name.indexOf('stm-def-') === 0 && name.slice(-3) === '-as') {
-        var localName = name.slice('stm-def-'.length, -3);
-        s[localName] = evalExpr(attrs[i].value, scope);
-      }
-    }
-    return s;
+  function buildScopeFor(el, scope) {
+    var builder = bridge.getScopeBuilder();
+    return builder ? builder(el, scope) : scope;
   }
 
   /**
@@ -417,7 +408,7 @@
    * try/catch, as before.
    */
   function processInner(el, scope) {
-    var s = applyDefs(el, scope); // local definitions: element + descendants
+    var s = buildScopeFor(el, scope); // local variables (binding module)
     el.__stmScope = s; // latest scope, used when an action fires
     for (var i = 0; i < attributeHandlers.length; i++) {
       var h = attributeHandlers[i];
@@ -1039,6 +1030,8 @@
   // state before walking" mechanism).
   bridge.renderDriver(function (scope) {
     if (guardRedirected) return;
+    var passStart = bridge.getPassStartHook();
+    if (passStart) passStart(scope); // per-pass resets + global sweeps
     render(document.body, scope);
   });
   bridge.setBootState(setBootState);

+ 20 - 11
model.md

@@ -211,7 +211,7 @@ adding to the same `window.statum` global), served as default resources:
   attribute registry itself is document-side).
 - `/_statum/resource/statum-document.js` — everything DOM: the render walk
   and the structural bindings (`stm-text`, `stm-html`, `stm-attribute.*`,
-  `stm-class.*`, `stm-if/else`, `stm-for-…-in`, `stm-disabled`, `stm-def-*`,
+  `stm-class.*`, `stm-if/else`, `stm-for-…-in`, `stm-disabled`,
   `stm-action`), the attribute-handler registry (`statum.registerAttribute`),
   boot visibility, `stm-guard` links, `stm-on-error`/`stm-on-success`
   classes, and the form bindings (`stm-prefill`, `stm-clear-on-success`,
@@ -219,6 +219,10 @@ adding to the same `window.statum` global), served as default resources:
 - `/_statum/resource/statum-markdown.js` — the CommonMark engine plus the
   `stm-markdown` binding, `stm-h-demotion`, and the programmatic
   `statum.markdown` API. Zero DOM dependencies in the engine itself.
+- `/_statum/resource/statum-binding.js` — client-side variables:
+  `stm-global-{name}`, `stm-global-{name}-from-{attribute}`,
+  `stm-local-{name}`, `stm-local-{name}-from-{attribute}` and
+  `stm-declare-{name}` (see "Local definitions and client-side variables").
 
 **Core is headless**: it applies state and directives without touching the
 DOM, so it is usable alone as a state layer (point `statum.config` at your
@@ -226,18 +230,21 @@ endpoints before init). When document is loaded it reads the
 `stm-entrypoint`/`stm-slots`/`stm-channel`/`stm-worker` body attributes into
 `statum.config` and installs the presentation hooks.
 
-**Load order is a contract**: core first, then document, markdown. Document
-owns the registry; every later module registers its attribute handlers into
+**Load order is a contract**: core first, then document, markdown, binding
+(markdown and binding are order-independent of each other). Document owns
+the registry; every later module registers its attribute handlers into
 it (a module loaded against core alone has its registrations queued until
 document arrives). Trailing modules may be omitted (a core + document page
-has the full state-binding walk minus markdown — note `stm-markdown` and
-`stm-h-demotion` live in the markdown module and are inert without it).
+has the full state-binding walk minus markdown and variables — note
+`stm-markdown` and `stm-h-demotion` live in the markdown module, and the
+`stm-local-*`/`stm-global-*`/`stm-declare-*` variables live in the binding
+module; both are inert without their module).
 
 `statum-mkpstm` expands a legacy `<script pstm-res-src="statum.js">` at
-build time into the three tags in canonical order, once per module even when
+build time into the four tags in canonical order, once per module even when
 the page mixes the legacy reference with explicit module tags, and prints
 one build warning per expanded page naming the expansion. New pages should
-reference the three modules explicitly.
+reference the four modules explicitly.
 
 ### Extending the client: statum.registerAttribute
 
@@ -364,7 +371,7 @@ The Javascript library also has some functionality inspired by HTMX using attrib
 
 Attribute values used as predicates (`stm-if`, `stm-else-if`, `stm-class.x`) are evaluated as JavaScript expressions against a scope built from the current state. Because the page's HTML is trusted, expressions are evaluated directly rather than via a restricted parser. In this scope each type name is bound to that slot's current `public` object, plus any in-scope loop variables. For example, `stm-if="typeName.level > 5"` evaluates `typeName.level > 5` with `typeName` bound to that type's `public` data.
 
-Bare identifiers resolve in a controlled order: names on the scope (slot types, loop variables, `stm-def-*` locals) win; names missing from the scope but present as genuine JavaScript globals (`Math`, `parseInt`, `String`, …) forward to that global; anything else resolves to `undefined` rather than throwing a `ReferenceError` or leaking an unintended global. An expression that mentions a slot the client does not hold therefore degrades gracefully — `unsetSlot == null` evaluates to `true` — and property access on an unset slot's `undefined` yields `undefined` for that expression alone.
+Bare identifiers resolve in a controlled order: names on the scope (slot types, loop variables, `stm-local-*` locals) win; names missing from the scope but present as genuine JavaScript globals (`Math`, `parseInt`, `String`, …) forward to that global; anything else resolves to `undefined` rather than throwing a `ReferenceError` or leaking an unintended global. An expression that mentions a slot the client does not hold therefore degrades gracefully — `unsetSlot == null` evaluates to `true` — and property access on an unset slot's `undefined` yields `undefined` for that expression alone.
 
 ## URL Overrides
 
@@ -539,17 +546,19 @@ The `stm-for-{x}-in` attribute (where `{x}` is the loop variable name) repeats t
 
 When the referenced array changes, the loop is re-rendered. If a `stm-key="{expression}"` attribute is present, items are matched across renders by the key value, preserving DOM identity (and therefore focus, selection, and input state) for items whose key is unchanged. When no `stm-key` is given, the loop falls back to a full re-render.
 
-## Local definitions
+## Local definitions and client-side variables
 
-The attribute `stm-def-{name}-as` (where `{name}` is the variable name) defines a variable bound to an expression, available to the element and all of its descendants. The expression is evaluated in the current scope and its result is added to a child scope, so it can shadow a type binding or loop variable for the element's subtree. The value is re-evaluated on every render, so it stays reactive, and multiple `stm-def-*` attributes may be placed on a single element:
+The `stm-local-{name}` attribute (statum-binding.js) defines a variable bound to an expression, available to the element and all of its descendants. The expression is evaluated in the parent scope and its result is added to a child scope, so it can shadow a type binding or loop variable for the element's subtree. Evaluation is LAZY: the expression runs at most once per render pass, only when something reads the variable, and the result is cached for the pass. Multiple `stm-local-*` attributes may be placed on a single element:
 
 ```html
-<div stm-def-total-as="cart.count * 9.99" stm-def-empty-as="cart.count === 0">
+<div stm-local-total="cart.count * 9.99" stm-local-empty="cart.count === 0">
     <span stm-text="total"></span>
     <p stm-if="empty">Your cart is empty.</p>
 </div>
 ```
 
+`stm-global-{name}` declares a page-wide variable usable in any expression on the page (regardless of document order; duplicates resolve last-in-document-order with a warning). The `-from-{attribute}` forms source the variable from an element attribute instead of an expression — for `value`/`checked` on form controls the live PROPERTY is read (typing updates the variable and triggers an immediate redraw); other attributes use a MutationObserver and redraw only when the derived value actually changes. `stm-declare-{name}` declares a name for a subtree without a value so a descendant can bind it and siblings of that binder can read it. Referencing a variable that is part of an evaluation cycle logs an error naming the cycle and reads as `undefined` for that pass. The former `stm-def-{name}-as` attribute has been removed; `statum-mkpstm` now emits `stm-local-{name}` for fragment parameters.
+
 ## Server-side attribute warnings
 
 Attributes and elements prefixed with `pstm-` are intended for server-side pre-rendering (see `future.md`) and must not reach the browser. On initialization the library scans the document for any `pstm-*` attributes or `<pstm-*>` elements and emits a single `console.warn` summarizing them, since their presence indicates a template that was not fully pre-rendered.

+ 4 - 3
src/Statum.vala

@@ -68,12 +68,13 @@ namespace Statum {
 
             // Embed the Statum client scripts as default resources, served from
             // /_statum/resource/statum-core.js, statum-document.js,
-            // statum-markdown.js and statum-worker.js (precompressed at build
-            // time). Apps get them for free and may add their own resources
-            // via StatumConfigurator.add_resource.
+            // statum-markdown.js, statum-binding.js and statum-worker.js
+            // (precompressed at build time). Apps get them for free and may
+            // add their own resources via StatumConfigurator.add_resource.
             container.register_startup<ClientCore>().as<StatumResource>();
             container.register_startup<ClientDocument>().as<StatumResource>();
             container.register_startup<ClientMarkdown>().as<StatumResource>();
+            container.register_startup<ClientBinding>().as<StatumResource>();
             container.register_startup<ClientWorker>().as<StatumResource>();
         }
 

+ 15 - 3
src/meson.build

@@ -1,6 +1,6 @@
 # Embed the Statum client scripts (js/statum-core.js, js/statum-document.js,
-# js/statum-markdown.js and js/statum-worker.js) into the library as
-# precompressed ConstantStatumResource classes, served by default at
+# js/statum-markdown.js, js/statum-binding.js and js/statum-worker.js) into the
+# library as precompressed ConstantStatumResource classes, served by default at
 # /_statum/resource/<name>. Generated at build time by the in-tree
 # statum-mkres tool — no installed tool required.
 #
@@ -46,6 +46,18 @@ client_markdown = custom_target('statum-client-markdown',
               '@INPUT@']
 )
 
+client_binding = custom_target('statum-client-binding',
+    input: files('../js/statum-binding.js'),
+    output: 'ClientBinding.vala',
+    command: [statum_mkres,
+              '-o', '@OUTPUT@',
+              '-n', 'statum-binding.js',
+              '--class-name', 'ClientBinding',
+              '--ns', 'Statum',
+              '-c', 'application/javascript',
+              '@INPUT@']
+)
+
 client_worker = custom_target('statum-client-worker',
     input: files('../js/statum-worker.js'),
     output: 'ClientWorker.vala',
@@ -94,7 +106,7 @@ sources = files(
 
 library_version = meson.project_version()
 libstatum = shared_library('statum-@0@'.format(library_version),
-    sources, client_core, client_document, client_markdown, client_worker,
+    sources, client_core, client_document, client_markdown, client_binding, client_worker,
     dependencies: [
         glib_dep,
         gobject_dep,

+ 1 - 1
tests/dom/run.sh

@@ -19,7 +19,7 @@ else
   QJS=$(command -v qjs)
 fi
 
-SCENARIOS="${1:-1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24}"
+SCENARIOS="${1:-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}"
 
 for n in $SCENARIOS; do
   echo "== dom scenario $n =="

+ 171 - 1
tests/dom/scenarios.js

@@ -36,7 +36,7 @@
  * 23  declined stm-confirm does NOT clear (clear-on-error present)
  * 24  headless core: stubbed fetch applies state, no walk, no crash
  *
- * Usage: qjs tests/dom/scenarios.js <1..24>
+ * Usage: qjs tests/dom/scenarios.js <1..34>
  */
 import * as std from 'std';
 import { installShim } from './shim.js';
@@ -114,6 +114,8 @@ async function main() {
   // Scenario 24 runs HEADLESS: core alone, no document module.
   if (scenario !== '24') loadAll();
   else load('statum-core.js');
+  // Binding-module scenarios additionally load it (after document).
+  if (scenario >= 25) load('statum-binding.js');
 
   var s = globalThis.statum;
   check(s && s._bridge, 'statum did not load');
@@ -817,6 +819,174 @@ async function main() {
     check(i1.value === 'dflt', 'error directive did not reset a clear-on-error form: ' + JSON.stringify(i1.value));
     log.errors.length = 0;
 
+  } else if (scenario === '25') {
+    // stm-local basic: subtree scoping + evaluation through stm-text.
+    var holder = el('div');
+    holder.setAttribute('stm-local-x', '1 + 1');
+    var span = el('span'); span.setAttribute('stm-text', 'x');
+    holder.appendChild(span);
+    body.appendChild(holder);
+    var outside = el('span'); outside.setAttribute('stm-text', 'typeof x');
+    body.appendChild(outside);
+    await s._bridge.renderAll(); await spin();
+    check(span.textContent === '2', 'stm-local-x did not resolve: ' + span.textContent);
+    check(outside.textContent === 'undefined', 'local leaked outside its subtree');
+
+  } else if (scenario === '26') {
+    // Lazy evaluation: once per pass even when read many times.
+    var calls = 0;
+    globalThis.__count = function () { calls++; return calls; };
+    var holder2 = el('div');
+    holder2.setAttribute('stm-local-n', '__count()');
+    var a2 = el('span'); a2.setAttribute('stm-text', 'n');
+    var b2 = el('span'); b2.setAttribute('stm-text', 'n + n');
+    holder2.appendChild(a2); holder2.appendChild(b2);
+    body.appendChild(holder2);
+    await s._bridge.renderAll(); await spin();
+    check(a2.textContent === '1' && b2.textContent === '2', 'lazy value wrong: ' + a2.textContent + '/' + b2.textContent);
+    check(calls === 1, 'expression evaluated ' + calls + ' times in one pass (must be 1)');
+    await s._bridge.renderAll(); await spin();
+    check(calls === 2, 'second pass did not re-evaluate (calls=' + calls + ')');
+
+  } else if (scenario === '27') {
+    // stm-global: visible page-wide on the FIRST pass (the pass-start sweep
+    // exposes lazy getters; a reader before the declaring element resolves
+    // the same binding on demand), and stable across passes.
+    var early = el('span'); early.setAttribute('stm-text', 'typeof g');
+    body.appendChild(early);
+    var late = el('div'); late.setAttribute('stm-global-g', '41 + 1');
+    var lateSpan = el('span'); lateSpan.setAttribute('stm-text', 'g');
+    late.appendChild(lateSpan);
+    body.appendChild(late);
+    await s._bridge.renderAll(); await spin();
+    check(early.textContent === 'number', 'global not visible before its declaration on pass 1');
+    await s._bridge.renderAll(); await spin();
+    check(early.textContent === 'number', 'global lost on second pass');
+    // Prove the VALUE is reachable, not just the type, via an if-predicate.
+    var gate = el('div'); gate.setAttribute('stm-if', 'g === 42');
+    body.appendChild(gate);
+    await s._bridge.renderAll(); await spin();
+    check(!gate.hasAttribute('hidden'), 'g === 42 did not evaluate true');
+
+  } else if (scenario === '28') {
+    // Duplicate stm-global: last in document order wins, one warn per pass.
+    var g1 = el('div'); g1.setAttribute('stm-global-dup', '1');
+    var g2 = el('div'); g2.setAttribute('stm-global-dup', '2');
+    var rd = el('span'); rd.setAttribute('stm-text', 'dup');
+    body.appendChild(g1); body.appendChild(g2); body.appendChild(rd);
+    log.warns.length = 0;
+    await s._bridge.renderAll(); await spin();
+    check(rd.textContent === '2', 'last duplicate did not win: ' + rd.textContent);
+    check(log.warns.length === 1, 'expected exactly one duplicate warn, got ' + log.warns.length);
+    log.warns.length = 0;
+    await s._bridge.renderAll(); await spin();
+    check(log.warns.length === 1, 'warn must fire once per pass');
+
+  } else if (scenario === '29') {
+    // Cycles: logged as an error, variables read undefined, walk survives.
+    var ca = el('div'); ca.setAttribute('stm-global-a', 'b');
+    var cb = el('div'); cb.setAttribute('stm-global-b', 'a');
+    var ra = el('span'); ra.setAttribute('stm-text', 'typeof a');
+    body.appendChild(ca); body.appendChild(cb); body.appendChild(ra);
+    await s._bridge.renderAll(); await spin();
+    check(log.errors.length >= 1 && /variable cycle/.test(log.errors[0]),
+      'cycle not logged: ' + JSON.stringify(log.errors));
+    check(ra.textContent === 'undefined', 'cyclic variable must read undefined');
+    log.errors.length = 0;
+
+  } else if (scenario === '30') {
+    // from-attribute (non-control): observer redraws on real change only.
+    var host = el('div'); host.setAttribute('title', 'first');
+    host.setAttribute('stm-local-t', 't'); // reads via the from binding below
+    host.setAttribute('stm-local-t-from-title', '');
+    // ^ both spellings would conflict; use only the from- form:
+    host.removeAttribute('stm-local-t');
+    var outT = el('span'); outT.setAttribute('stm-text', 't');
+    host.appendChild(outT);
+    body.appendChild(host);
+    var cycles = 0;
+    s.afterEvaluate(function () { cycles++; });
+    await s._bridge.renderAll(); await spin();
+    check(outT.textContent === 'first', 'from-title not read: ' + outT.textContent);
+    cycles = 0;
+    host.setAttribute('title', 'first'); // same value: no redraw
+    await spin(10);
+    check(cycles === 0, 'same-value attribute write triggered a redraw');
+    host.setAttribute('title', 'second'); // real change: redraw
+    await spin(10);
+    check(cycles >= 1, 'changed attribute did not redraw');
+    check(outT.textContent === 'second', 'variable not updated after change: ' + outT.textContent);
+
+  } else if (scenario === '31') {
+    // The operator's example: declare + local-from-value + stm-if.
+    var box = el('div'); box.setAttribute('stm-declare-username', '');
+    var f31 = el('form');
+    var inp = input('text', 'username', '');
+    inp.setAttribute('stm-local-username-from-value', '');
+    f31.appendChild(inp);
+    var alertBox = el('div'); alertBox.setAttribute('stm-if', 'username.length < 3');
+    alertBox.textContent = 'Username is too short';
+    var okBox = el('div'); okBox.setAttribute('stm-if', 'username.length >= 3');
+    var okName = el('span'); okName.setAttribute('stm-text', 'username');
+    okBox.appendChild(okName);
+    box.appendChild(f31); box.appendChild(alertBox); box.appendChild(okBox);
+    body.appendChild(box);
+    await s._bridge.renderAll(); await spin();
+    check(!alertBox.hasAttribute('hidden'), 'alert must show with empty username');
+    check(okBox.hasAttribute('hidden'), 'ok box must hide with empty username');
+    inp.value = 'ab'; fire(inp, 'input'); await spin(10);
+    check(!alertBox.hasAttribute('hidden'), 'alert must show for "ab"');
+    inp.value = 'abc'; fire(inp, 'input'); await spin(10);
+    check(alertBox.hasAttribute('hidden'), 'alert must hide for "abc"');
+    check(!okBox.hasAttribute('hidden') && okName.textContent === 'abc',
+      'ok box wrong: hidden=' + okBox.hasAttribute('hidden') + ' text=' + okName.textContent);
+
+  } else if (scenario === '32') {
+    // from-checked on a checkbox.
+    var box32 = el('div'); box32.setAttribute('stm-declare-agree', '');
+    var cbx = input('checkbox', 'agree', '');
+    cbx.setAttribute('stm-local-agree-from-checked', '');
+    var lab = el('span'); lab.setAttribute('stm-text', 'agree ? "yes" : "no"');
+    box32.appendChild(cbx); box32.appendChild(lab);
+    body.appendChild(box32);
+    await s._bridge.renderAll(); await spin();
+    check(lab.textContent === 'no', 'unchecked must read false: ' + lab.textContent);
+    cbx.checked = true; fire(cbx, 'change'); await spin(10);
+    check(lab.textContent === 'yes', 'checked must read true: ' + lab.textContent);
+
+  } else if (scenario === '33') {
+    // Declare without binder: undefined, page alive; shadowing.
+    var box33 = el('div'); box33.setAttribute('stm-declare-later', '');
+    var probe = el('span'); probe.setAttribute('stm-text', 'typeof later');
+    box33.appendChild(probe);
+    body.appendChild(box33);
+    await s._bridge.renderAll(); await spin();
+    check(probe.textContent === 'undefined', 'declared-but-unbound must read undefined');
+    // Shadowing: a variable named like a slot type wins in its scope.
+    await setState('counter', { n: 5 });
+    var sh = el('div'); sh.setAttribute('stm-local-counter', '99');
+    var shOut = el('span'); shOut.setAttribute('stm-text', 'typeof counter');
+    sh.appendChild(shOut); body.appendChild(sh);
+    var plain = el('span'); plain.setAttribute('stm-text', 'counter.n');
+    body.appendChild(plain);
+    await s._bridge.renderAll(); await spin();
+    check(shOut.textContent === 'number', 'shadowing variable must hide the slot type: ' + shOut.textContent);
+    check(plain.textContent === '5', 'slot type must still read outside the shadow');
+
+  } else if (scenario === '34') {
+    // stm-local replaces stm-def: the demo's stamp pattern, plus confirm the
+    // old attribute is inert (documented removal).
+    var box34 = el('div'); box34.setAttribute('stm-local-stamp', "'updated '" + ' + counter.n');
+    var out34 = el('span'); out34.setAttribute('stm-text', 'stamp');
+    box34.appendChild(out34); body.appendChild(box34);
+    await setState('counter', { n: 7 });
+    check(out34.textContent === 'updated 7', 'stamp local wrong: ' + out34.textContent);
+    var legacy = el('div'); legacy.setAttribute('stm-def-old-as', '1');
+    var legacyOut = el('span'); legacyOut.setAttribute('stm-text', 'typeof old');
+    legacy.appendChild(legacyOut); body.appendChild(legacy);
+    await s._bridge.renderAll(); await spin();
+    check(legacyOut.textContent === 'undefined', 'stm-def must be inert after removal');
+
   } else {
     fail('unknown scenario: ' + scenario);
   }

+ 44 - 1
tests/dom/shim.js

@@ -191,9 +191,15 @@ export function installShim() {
       }
       if (hit) hit.value = String(v);
       else this.attributes.push({ name: n, value: String(v) });
+      if (this.__stmObservers) {
+        this.__stmObservers.forEach(function (o) { o.cb([]); });
+      }
     };
     el.removeAttribute = function (n) {
       this.attributes = this.attributes.filter(function (a) { return a.name !== n; });
+      if (this.__stmObservers) {
+        this.__stmObservers.forEach(function (o) { o.cb([]); });
+      }
     };
     el.hasAttribute = function (n) {
       for (var i = 0; i < this.attributes.length; i++) {
@@ -572,6 +578,42 @@ export function installShim() {
     return Promise.resolve(res);
   };
 
+  // --- MutationObserver stub ---------------------------------------------
+  // Real browsers deliver attribute mutations asynchronously; the shim runs
+  // them SYNCHRONOUSLY inside setAttribute/removeAttribute (patched below
+  // once, when the first element is created — the patch lives on the
+  // prototype-less makeElement factory by wrapping every element's methods
+  // would be too invasive, so we wrap at creation time instead: see
+  // makeElement's setAttribute/removeAttribute above — they call
+  // notifyObservers(el) after mutating the attribute list).
+  var observers = new WeakMap(); // element -> [{filter, cb}]
+  function notifyObservers(el) {
+    if (el.__stmObservers) {
+      el.__stmObservers.forEach(function (o) { o.cb([]); });
+    }
+  }
+
+  globalThis.MutationObserver = function (callback) {
+    this._cb = callback;
+    this._targets = [];
+  };
+  globalThis.MutationObserver.prototype.observe = function (target, opts) {
+    var list = target.__stmObservers || (target.__stmObservers = []);
+    var self = this;
+    list.push({ cb: function () { self._cb([]); } });
+    this._targets.push(target);
+  };
+  globalThis.MutationObserver.prototype.disconnect = function () {
+    var self = this;
+    this._targets.forEach(function (t) {
+      if (t.__stmObservers) {
+        t.__stmObservers = t.__stmObservers.filter(function (o) {
+          return o.cb._owner !== self;
+        });
+      }
+    });
+  };
+
   globalThis.__shim = {
     log: log,
     body: body,
@@ -580,7 +622,8 @@ export function installShim() {
     fetchCalls: fetchCalls,
     setFetch: function (fn) { fetchHandler = fn; },
     okResponse: okResponse,
-    failResponse: failResponse
+    failResponse: failResponse,
+    flushObservers: notifyObservers
   };
   globalThis.__STATUM_TEST__ = true;
 }

+ 2 - 2
tools/statum-mkpstm/statum-mkpstm.vala

@@ -47,7 +47,7 @@ namespace Statum.Tools {
          * will be added here when it lands — revisit this list then.
          */
         private const string[] CLIENT_MODULE_ORDER = {
-            "statum-core.js", "statum-document.js", "statum-markdown.js"
+            "statum-core.js", "statum-document.js", "statum-markdown.js", "statum-binding.js"
         };
 
         private const OptionEntry[] options = {
@@ -384,7 +384,7 @@ namespace Statum.Tools {
                 foreach (var input_name in inputs) {
                     var expr = include->get_prop(input_name);
                     if (expr != null) {
-                        wrapper->set_prop(@"stm-def-$input_name-as", (!)expr);
+                        wrapper->set_prop(@"stm-local-$input_name", (!)expr);
                     }
                 }
             } else {