Sfoglia il codice sorgente

Add -from-attribute.{name} spelling and stm-update-attribute.{name}

Two binding-module changes from review feedback. (1) The attribute-sourced form is now spelled -from-attribute.{name} (aligned with stm-attribute.{name}); -from-{property} remains the spelling for registered observables. Per operator clarification the event mapping stays the primary observation mechanism for BOTH spellings: whenever the source name is a registered observable on the element, the mapped events drive updates (the MutationObserver supplements so scripted attribute writes are never missed); names outside the mapping read as attributes. (2) New stm-update-attribute.{name}="expr" walk binding: sets the attribute only when the expression's EVALUATED value differs from what the binding last wrote (stored per element, separate from the DOM), so local or scripted attribute changes — a user-toggled expander — survive between renders; a first evaluation finding the attribute already matching adopts it as the baseline; undefined never touches, null/false remove, true sets bare, else stringified. Scenario 36 covers the explicit spelling (read + live update), scenario 37 covers write-once/local-survival/null-removal/baseline adoption; the demo gains a details-expander section driven by a new user.panelOpen field flipped by the user toggle; module header, TEMPLATES, model and WHATS_NEW document both. Battery green: dom 37/37, split 8/8, markdown 96.4%, meson test 1/1.
saddle-ClankerSaddle 2 settimane fa
parent
commit
6bd62b8154
8 ha cambiato i file con 267 aggiunte e 77 eliminazioni
  1. 35 11
      TEMPLATES.md
  2. 15 4
      WHATS_NEW.md
  3. 16 0
      frontend-demo/index.html
  4. 4 0
      frontend-demo/server.php
  5. 140 49
      js/statum-binding.js
  6. 5 3
      model.md
  7. 2 2
      tests/dom/run.sh
  8. 50 8
      tests/dom/scenarios.js

+ 35 - 11
TEMPLATES.md

@@ -339,13 +339,32 @@ the variable reads as `undefined` for the pass — never a page crash.
   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-{source}`**: page-wide variable sourced from an
-  element's property or attribute (see "observables" below); changes trigger
+- **`stm-global-{name}-from-{source}`**: page-wide variable sourced from a
+  live property or an attribute (see "observables" below); changes trigger
   a redraw.
 - **`stm-local-{name}="expr"`**: subtree-scoped variable (element +
   descendants). Replaces the removed `stm-def-{name}-as`.
 - **`stm-local-{name}-from-{source}`**: subtree-scoped sourced variable;
   changes trigger a redraw.
+- **`stm-update-attribute.{name}="expr"`**: set the attribute only when the
+  expression's EVALUATED value differs from what this binding last wrote.
+  Unlike `stm-attribute.{name}` (which writes every render cycle), local or
+  scripted changes to the attribute SURVIVE between renders — the binding
+  rewrites only when its own output genuinely changes. Typical use: an
+  expander whose `open` attribute is server-driven but user-toggled:
+
+  ```html
+  <details stm-update-attribute.open="section.open">
+    <summary>Section</summary>
+    …
+  </details>
+  ```
+
+  The server's value lands once; user toggles persist until the state
+  expression's value actually changes. Coercion: `undefined` never touches
+  the attribute, `null`/`false` remove it, `true` sets it bare, anything
+  else is stringified. A first evaluation that finds the attribute already
+  holding the value adopts it as the baseline instead of writing.
 - **`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:
@@ -361,12 +380,15 @@ the variable reads as `undefined` for the pass — never a page crash.
 </div>
 ```
 
-**Observables — how `-from-{source}` reads its source.** When `{source}`
-names a REGISTERED OBSERVABLE PROPERTY (defaults: `value`, `checked` — each
-with its standard `input`/`change` events) and the element has that
+**Observables — how `-from-{source}` reads its source.** Two spellings,
+one mechanism. `-from-{property}` and the explicit `-from-attribute.{name}`
+both consult the OBSERVABLES REGISTRY: property name → the standard DOM
+events that signal its change (defaults `value` and `checked` with
+`input`/`change`). When the mapping covers the name and the element has the
 property, the variable reads the live PROPERTY and listens to those events:
 every event updates the bound variables and redraws immediately (per
-keystroke for `value`). Further event-driven properties are registered
+keystroke for `value`), with a MutationObserver supplementing so scripted
+attribute writes are never missed. Further event-driven properties register
 without touching the module:
 
 ```js
@@ -374,11 +396,13 @@ statum.binding.observe('selectedIndex', ['change']);
 statum.binding.observe('open', ['toggle']);       // <details>
 ```
 
-Every other name — and any registered name the element lacks as a property
-— falls back to ATTRIBUTE semantics via 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.
+Any name outside the mapping (or absent as a property on the element) is
+read as an ATTRIBUTE and observed via MutationObserver — prefer the
+explicit `-from-attribute.{name}` spelling for those, aligned with
+`stm-attribute.{name}`. REDRAW GUARD: an observed 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

+ 15 - 4
WHATS_NEW.md

@@ -188,10 +188,21 @@ keystroke — and further event-driven properties register generically:
 statum.binding.observe('selectedIndex', ['change']);
 ```
 
-Anything else is observed as an ATTRIBUTE via MutationObserver (redraw only
-when the value actually changes — the walk's own attribute writes never
-loop). `stm-declare-{name}` reserves a name for a subtree so
-a descendant can bind it and siblings of the binder can read it.
+Anything else is read as an ATTRIBUTE — prefer the explicit
+`-from-attribute.{name}` spelling, aligned with `stm-attribute.{name}` —
+and observed via MutationObserver (redraw only when the value actually
+changes; the walk's own attribute writes never loop). `stm-declare-{name}`
+reserves a name for a subtree so a descendant can bind it and siblings of
+the binder can read it.
+
+`stm-update-attribute.{name}="expr"` is the write-side counterpart: it sets
+the attribute only when the expression's EVALUATED value differs from what
+the binding last wrote, so local changes (a user-toggled `<details open>`)
+survive between renders:
+
+```html
+<details stm-update-attribute.open="section.open">…</details>
+```
 
 **Migration**: `stm-def-{name}-as` still works as an exact obsolescent
 alias of `stm-local-{name}` (one console.warn per element), so older

+ 16 - 0
frontend-demo/index.html

@@ -476,6 +476,22 @@
           </div>
         </div>
       </section>
+      <!--
+        stm-update-attribute.open: the server state lands ONCE; afterwards a
+        local toggle (the user clicking the summary) survives until the
+        expression's value genuinely changes — unlike stm-attribute.open,
+        which would clobber the toggle on every render cycle.
+      -->
+      <section>
+        <h2>Update-once attributes <span class="muted">(stm-update-attribute)</span></h2>
+        <details stm-update-attribute.open="user.panelOpen">
+          <summary>Server-driven, locally toggleable expander</summary>
+          <p>The open attribute is written only when the state expression's
+            evaluated value changes; local toggles persist between renders.
+            Cycle the user role above (its toggle action also flips this
+            panel's state) to see the server's value land again.</p>
+        </details>
+      </section>
     </div>
   </main>
 

+ 4 - 0
frontend-demo/server.php

@@ -186,6 +186,7 @@ function build_user(): array
     return [
         'role' => $role,
         'name' => $names[$role] ?? 'Guest',
+        'panelOpen' => ($_SESSION['panel_open'] ?? true),
         'toggleAction' => ['uri' => '/api/user/toggle', 'method' => 'POST'],
         'resetAllAction' => ['uri' => '/api/reset-all', 'method' => 'POST'],
     ];
@@ -563,6 +564,9 @@ if ($path === '/api/user/toggle' && $method === 'POST') {
     $order = ['guest', 'member', 'admin'];
     $cur = array_search($_SESSION['role'] ?? 'guest', $order);
     $_SESSION['role'] = $order[($cur + 1) % count($order)];
+    // The expander demo's open state rides the same toggle so
+    // stm-update-attribute.open has a state expression that really changes.
+    $_SESSION['panel_open'] = !($_SESSION['panel_open'] ?? true);
     statum_respond([set_dir(snap('user'))]);
 }
 

+ 140 - 49
js/statum-binding.js

@@ -8,19 +8,28 @@
  *
  *   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-global-{name}-from-{source}      page-wide variable sourced from a
+ *                                         live property (event-mapped) or,
+ *                                         with the explicit spelling
+ *                                         -from-attribute.{name}, an
+ *                                         attribute; changes trigger a redraw
  *   stm-local-{name}="expr"               subtree-scoped variable (element +
  *                                         descendants); supersedes
  *                                         stm-def-{name}-as (kept as an
  *                                         obsolescent alias, see below)
- *   stm-local-{name}-from-{attribute}     subtree-scoped attribute-sourced
- *                                         variable; changes trigger a redraw
+ *   stm-local-{name}-from-{source}       subtree-scoped sourced variable;
+ *                                         same two spellings as the global
+ *                                         form
  *   stm-declare-{name}                    declares {name} for a subtree so a
  *                                         descendant can bind it and SIBLINGS
  *                                         of that binder can read it
  *
+ *   stm-update-attribute.{name}="expr"   set the attribute ONLY when the
+ *                                         expression's evaluated value
+ *                                         differs from what this binding
+ *                                         last wrote (local/scripted changes
+ *                                         survive between renders)
+ *
  * The legacy `stm-def-{name}-as="expr"` spelling (the pre-binding-module
  * form, still emitted by older templates) is accepted as an exact alias of
  * `stm-local-{name}` and logs a one-time obsolescence warning per element.
@@ -130,8 +139,10 @@
   }
 
   /**
-   * Parse a binding attribute name into {kind, name, source, attr};
-   * source is the attribute/property name for -from- forms, else null.
+   * Parse a binding attribute name into {kind, name, source, attr, mode};
+   * mode is 'property' for `-from-{property}` (a registered observable) or
+   * 'attribute' for `-from-attribute.{name}` (explicit, aligned with
+   * `stm-attribute.{name}`). Expression forms have source null.
    * `stm-def-{name}-as` (the pre-binding spelling) parses as a LOCAL
    * expression binding with legacy=true: identical semantics, plus an
    * obsolescence warning at bind time.
@@ -139,12 +150,16 @@
    */
   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$]*)-from-attribute\.([\w$:-]+)$/)))
+      return { kind: 'global', name: m[1], source: m[2], attr: attr, mode: 'attribute' };
+    if ((m = attr.match(/^stm-global-([A-Za-z_$][\w$]*)-from-([A-Za-z_$][\w$]*)$/)))
+      return { kind: 'global', name: m[1], source: m[2], attr: attr, mode: 'property' };
     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$]*)-from-attribute\.([\w$:-]+)$/)))
+      return { kind: 'local', name: m[1], source: m[2], attr: attr, mode: 'attribute' };
+    if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)-from-([A-Za-z_$][\w$]*)$/)))
+      return { kind: 'local', name: m[1], source: m[2], attr: attr, mode: 'property' };
     if ((m = attr.match(/^stm-local-([A-Za-z_$][\w$]*)$/)))
       return { kind: 'local', name: m[1], source: null, attr: attr };
     if ((m = attr.match(/^stm-def-([A-Za-z_$][\w$]*)-as$/)))
@@ -155,10 +170,11 @@
   /**
    * OBSERVABLE live properties: property name → the standard events that
    * signal its change. This table — not hard-coded `if` checks — is what
-   * makes `-from-{property}` generic: `value` and `checked` ship by default
-   * (input/select/textarea fire `input`/`change`), and page code registers
-   * further event-driven properties via `statum.binding.observe()` instead
-   * of the module growing special cases.
+   * makes `-from-{property}` (and the attribute-sourced spelling, for any
+   * name the mapping covers) generic: `value` and `checked` ship by
+   * default (input/select/textarea fire `input`/`change`), and page code
+   * registers further event-driven properties via
+   * `statum.binding.observe()` instead of the module growing special cases.
    * @type {Object<string, string[]>}
    */
   var observables = {
@@ -167,9 +183,11 @@
   };
 
   /**
-   * The events that make `name` observable on `el`, or null when `el` has
-   * no such property (fall back to attribute semantics — a `value` binding
-   * on a `<div>` observes the attribute, not a property the element lacks).
+   * The events that make `name` observable on `el`, or null when the
+   * property is not registered or `el` lacks it. Consulted for BOTH
+   * spellings: `-from-{property}` and `-from-attribute.{name}` — the
+   * event mapping is the primary observation mechanism for any name it
+   * covers; the MutationObserver supplements/fallbacks for it.
    */
   function observableFor(el, name) {
     if (!Object.prototype.hasOwnProperty.call(observables, name)) return null;
@@ -178,18 +196,20 @@
   }
 
   /**
-   * Attach the redraw plumbing for one -from- binding. For an observable
-   * property (see `observables`) the listeners are attached ONCE per
-   * (element, property); the VARIABLE registration (propNames + seed)
-   * happens for every binding — several variables may bind the same
-   * property, and each must see updates (review finding V1). Everything
-   * else observes the attribute via MutationObserver.
+   * Attach the redraw plumbing for one -from- binding. The EVENT MAPPING
+   * is primary: whenever the source name is a registered observable on the
+   * element (either spelling — `-from-{property}` or
+   * `-from-attribute.{name}`), listeners on the mapped events update the
+   * bound variables and redraw. The MutationObserver supplements (and
+   * covers every non-observable name) so scripted attribute writes are
+   * never missed. Listeners attach once per (element, property); the
+   * VARIABLE registration happens for every binding (several variables may
+   * bind one source — review finding V1).
    */
   function bindSource(el, binding) {
     var propKey = binding.source;
     var events = observableFor(el, propKey);
     if (events) {
-      // Register this variable against the property (always).
       var names = propNames.get(el) || {};
       names[binding.name] = propKey;
       propNames.set(el, names);
@@ -198,31 +218,32 @@
         seed[binding.name] = el[propKey];
         liveProps.set(el, seed);
       }
-      // Attach the shared listeners once per (element, property).
       var bound = propBound.get(el);
-      if (bound && bound[propKey]) return;
       if (!bound) bound = {};
-      bound[propKey] = true;
-      propBound.set(el, bound);
-      var onChange = function () {
-        var n = propNames.get(el);
-        if (!n) return;
-        var changed = false;
-        var store = liveProps.get(el) || {};
-        for (var name in n) {
-          var key = n[name];
-          if (store[name] !== el[key]) {
-            store[name] = el[key];
-            changed = true;
+      if (!bound[propKey]) {
+        bound[propKey] = true;
+        propBound.set(el, bound);
+        var onChange = function () {
+          var n = propNames.get(el);
+          if (!n) return;
+          var changed = false;
+          var store = liveProps.get(el) || {};
+          for (var name in n) {
+            var key = n[name];
+            if (store[name] !== el[key]) {
+              store[name] = el[key];
+              changed = true;
+            }
           }
+          if (changed) liveProps.set(el, store);
+          if (changed) renderAll();
+        };
+        for (var i = 0; i < events.length; i++) {
+          el.addEventListener(events[i], onChange);
         }
-        if (changed) liveProps.set(el, store);
-        if (changed) renderAll();
-      };
-      for (var i = 0; i < events.length; i++) {
-        el.addEventListener(events[i], onChange);
       }
-      return;
+      // An observable source may still be written as an attribute; keep
+      // the observer as a supplementary signal (unless already attached).
     }
     var seen = observed.get(el);
     if (seen && seen[propKey]) return;
@@ -230,15 +251,31 @@
     if (!seen) seen = {};
     seen[propKey] = true;
     observed.set(el, seen);
-    var last = el.getAttribute(propKey);
+    var last = attrSnapshot(el, propKey);
     new MutationObserver(function () {
-      var now = el.getAttribute(propKey);
+      var now = attrSnapshot(el, propKey);
       if (now === last) return; // same-value write: never redraw
       last = now;
       renderAll();
     }).observe(el, { attributes: true, attributeFilter: [propKey] });
   }
 
+  /**
+   * Read a source's current value the way the binding should present it:
+   * the live PROPERTY when one is observable on the element, else the
+   * attribute. (For an observable property the attribute is a stale
+   * default; for a plain attribute there is no property to read.)
+   */
+  function readSource(el, source) {
+    return observableFor(el, source) ? el[source] : el.getAttribute(source);
+  }
+
+  /** Snapshot a source attribute: the string value, or null when absent. */
+  function attrSnapshot(el, name) {
+    var v = el.getAttribute(name);
+    return v == null ? null : String(v);
+  }
+
   /**
    * Build one binding's value computation. Expression forms evaluate
    * against the PARENT scope (a binding cannot reference itself or a
@@ -256,8 +293,7 @@
       // Observable properties read the live property; everything else reads
       // the attribute. Reading fresh keeps the value correct even between
       // the last event and the next render.
-      if (observableFor(el, binding.source)) return el[binding.source];
-      return el.getAttribute(binding.source);
+      return readSource(el, binding.source);
     };
   }
 
@@ -379,6 +415,61 @@
   bridge.buildScope(buildScope);
   bridge.onPassStart(passStart);
 
+  /**
+   * `stm-update-attribute.{name}="expr"` — set the attribute ONLY when the
+   * expression's evaluated value differs from the last value THIS binding
+   * wrote (stored separately from the DOM). Unlike `stm-attribute.{name}`
+   * (which writes every cycle and would clobber user/script changes made
+   * between renders), an interaction or script may change the attribute and
+   * the binding leaves it alone until the expression's own output actually
+   * changes. Example: an expander whose `open` state is server-driven but
+   * user-toggled locally — the server's value lands once, then local
+   * toggles survive until the state genuinely changes.
+   *
+   * Coercion: `undefined`/`null` remove the attribute; booleans set/remove
+   * it; anything else is stringified. The first evaluation establishes the
+   * baseline (it writes only if the attribute does not already match).
+   */
+  var lastWritten = new WeakMap(); // element -> { attrName: lastValue|null }
+
+  function handleUpdateAttribute(el, scope) {
+    var attrs = el.attributes;
+    for (var i = 0; i < attrs.length; i++) {
+      var attr = attrs[i];
+      if (attr.name.indexOf('stm-update-attribute.') !== 0) continue;
+      var target = attr.name.slice('stm-update-attribute.'.length);
+      if (!target) continue;
+      var value = evalExpr(attr.value, scope);
+      var memo = lastWritten.get(el) || {};
+      var key = target;
+      var next; // undefined => leave untouched, null => remove
+      if (value === undefined) continue; // unresolved: never touch
+      if (value === null || value === false) next = null;
+      else if (value === true) next = '';
+      else next = String(value);
+      if (Object.prototype.hasOwnProperty.call(memo, key)) {
+        if (memo[key] === next) continue; // our own value: do not rewrite
+      } else {
+        // First evaluation: if the attribute already holds this value,
+        // adopt it as the baseline without writing.
+        var current = el.getAttribute(target);
+        var currentNorm = current == null ? null : String(current);
+        if (currentNorm === (next === null ? null : next)) {
+          memo[key] = next;
+          lastWritten.set(el, memo);
+          continue;
+        }
+      }
+      memo[key] = next;
+      lastWritten.set(el, memo);
+      if (next === null) el.removeAttribute(target);
+      else el.setAttribute(target, next);
+    }
+  }
+
+  window.statum.registerAttribute('stm-update-attribute.', handleUpdateAttribute,
+    { before: 'stm-class.' });
+
   /**
    * Public surface: register (or replace) an OBSERVABLE PROPERTY — a live
    * element property that fires standard DOM events when it changes, so

+ 5 - 3
model.md

@@ -221,8 +221,10 @@ adding to the same `window.statum` global), served as default resources:
   `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-{source}`,
-  `stm-local-{name}`, `stm-local-{name}-from-{source}` and
-  `stm-declare-{name}` (see "Local definitions and client-side variables").
+  `stm-local-{name}`, `stm-local-{name}-from-{source}`,
+  `stm-declare-{name}`, and the write-on-change
+  `stm-update-attribute.{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
@@ -557,7 +559,7 @@ The `stm-local-{name}` attribute (statum-binding.js) defines a variable bound to
 </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-{source}` forms source the variable from a live property or an attribute instead of an expression: when `{source}` is a registered observable property (`value` and `checked` by default, each with its `input`/`change` events) the live PROPERTY is read and those events trigger an immediate redraw (typing updates the variable per keystroke); further properties register via `statum.binding.observe(name, events)`. Any other name — or a registered name the element lacks as a property — is observed as an ATTRIBUTE via MutationObserver, redrawing 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 is kept as an exact obsolescent alias of `stm-local-{name}` (same semantics, one console.warn per element) so older templates keep working; it still requires the binding module. `statum-mkpstm` emits the modern `stm-local-{name}` form for fragment parameters.
+`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-{source}` forms source the variable from a live property or an attribute instead of an expression: when `{source}` is a registered observable property (`value` and `checked` by default, each with its `input`/`change` events) the live PROPERTY is read and those events trigger an immediate redraw (typing updates the variable per keystroke); further properties register via `statum.binding.observe(name, events)`. Any other name — or a registered name the element lacks as a property — is read as an ATTRIBUTE (prefer the explicit `-from-attribute.{name}` spelling, aligned with `stm-attribute.{name}`) and observed via MutationObserver, redrawing 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. `stm-update-attribute.{name}="expr"` sets the attribute only when the expression's evaluated value differs from what the binding last wrote (a first evaluation finding the attribute already matching adopts it as the baseline), so local or scripted attribute changes — a user-toggled expander — survive between renders; `undefined` never touches the attribute, `null`/`false` remove it, `true` sets it bare, anything else is stringified. 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 is kept as an exact obsolescent alias of `stm-local-{name}` (same semantics, one console.warn per element) so older templates keep working; it still requires the binding module. `statum-mkpstm` emits the modern `stm-local-{name}` form for fragment parameters.
 
 ## Server-side attribute warnings
 

+ 2 - 2
tests/dom/run.sh

@@ -5,7 +5,7 @@
 # bootstrap as tests/markdown/run.sh), then runs each scenario in a fresh
 # context. Exits non-zero when any scenario fails.
 #
-# Usage: tests/dom/run.sh [scenario]   (default: all thirty-six)
+# Usage: tests/dom/run.sh [scenario]   (default: all thirty-seven)
 set -e
 cd "$(dirname "$0")/../.."
 
@@ -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 25 26 27 28 29 30 31 32 33 34 35 36}"
+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 35 36 37}"
 
 for n in $SCENARIOS; do
   echo "== dom scenario $n =="

+ 50 - 8
tests/dom/scenarios.js

@@ -46,9 +46,10 @@
  * 33  declare-without-binder reads undefined; slot shadowing both ways
  * 34  stm-local replaces stm-def (demo's stamp pattern; stm-def inert)
  * 35  several -from- bindings on ONE element all update (review V1)
- * 36  generic observables: statum.binding.observe + attribute fallback
+ * 36  observables: statum.binding.observe + explicit -from-attribute.{name}
+ * 37  stm-update-attribute.{name}: writes only on evaluated-value change
  *
- * Usage: qjs tests/dom/scenarios.js <1..36>
+ * Usage: qjs tests/dom/scenarios.js <1..37>
  */
 import * as std from 'std';
 import { installShim } from './shim.js';
@@ -1063,17 +1064,58 @@ async function main() {
     sel.selectedIndex = 1; fire(sel, 'change'); await spin(10);
     check(outSel.textContent === '1',
       'custom observable did not update via its event: ' + outSel.textContent);
-    // A registered property the element does NOT have falls back to the
-    // attribute (a real browser has no `value` property on a div; the shim
-    // does, so exercise the fallback with a property only the select had).
+    // The explicit attribute spelling for a name outside the event mapping.
     var wrap36 = el('div'); wrap36.setAttribute('stm-declare-v36', '');
-    var d36 = el('div'); d36.setAttribute('selectedIndex', 'attr');
-    d36.setAttribute('stm-local-v36-from-selectedIndex', '');
+    var d36 = el('div'); d36.setAttribute('title', 'attr');
+    d36.setAttribute('stm-local-v36-from-attribute.title', '');
     var out36 = el('span'); out36.setAttribute('stm-text', 'v36');
     wrap36.appendChild(d36); wrap36.appendChild(out36);
     body.appendChild(wrap36);
     await s._bridge.renderAll(); await spin();
-    check(out36.textContent === 'attr', 'attribute fallback broken: ' + out36.textContent);
+    check(out36.textContent === 'attr', 'attribute source not read: ' + out36.textContent);
+    d36.setAttribute('title', 'changed'); await spin(10);
+    check(out36.textContent === 'changed',
+      'explicit attribute source did not update: ' + out36.textContent);
+
+  } else if (scenario === '37') {
+    // stm-update-attribute.{name}: writes only when the EVALUATED value
+    // changes; local/scripted attribute changes survive between renders.
+    var box37 = el('div');
+    box37.setAttribute('stm-update-attribute.title', 'brand.title');
+    body.appendChild(box37);
+    await setState('brand', { title: 'v1' });
+    check(box37.getAttribute('title') === 'v1',
+      'first evaluation did not write: ' + box37.getAttribute('title'));
+    // A script/user changes the attribute; a render whose expression still
+    // yields 'v1' must NOT clobber it.
+    box37.setAttribute('title', 'user changed');
+    await setState('brand', { title: 'v1' }); // same value, new signature
+    check(box37.getAttribute('title') === 'user changed',
+      'unchanged expression clobbered a local change: ' + box37.getAttribute('title'));
+    // The expression's output changes -> write once, then leave alone again.
+    await setState('brand', { title: 'v2' });
+    check(box37.getAttribute('title') === 'v2',
+      'changed expression did not write: ' + box37.getAttribute('title'));
+    box37.setAttribute('title', 'local again');
+    await setState('brand', { title: 'v2' });
+    check(box37.getAttribute('title') === 'local again',
+      'rewrite happened despite unchanged evaluated value');
+    // null removes the attribute (once); undefined never touches it.
+    await setState('brand', { title: null });
+    check(box37.getAttribute('title') === null,
+      'null did not remove the attribute: ' + box37.getAttribute('title'));
+    // First-evaluation adoption: an attribute already holding the value is
+    // adopted as baseline; a later local change then survives unchanged
+    // renders (the observable difference between adopt and write).
+    var box37b = el('div');
+    box37b.setAttribute('title', 'v2');
+    box37b.setAttribute('stm-update-attribute.title', 'brand.title');
+    body.appendChild(box37b);
+    await setState('brand', { title: 'v2' }); // adopts 'v2' as baseline
+    box37b.setAttribute('title', 'kept');
+    await setState('brand', { title: 'v2' }); // still v2: adopted, no write
+    check(box37b.getAttribute('title') === 'kept',
+      'baseline adoption failed: ' + box37b.getAttribute('title'));
 
   } else {
     fail('unknown scenario: ' + scenario);