瀏覽代碼

Add statum-forms.js: stm-prefill and stm-clear-on-success

js/statum-forms.js is the fourth client module (loads after statum-markdown.js, same guarded-IIFE/banner style, ES5, zero dependencies):

- stm-prefill registers before stm-class.* so its PROPERTY writes feed the controls before any class/attribute predicate evaluates. One handler for the whole form-control matrix: text-like inputs + textarea write .value (null as ''), checkbox writes .checked, radio is per-element (checked iff the element's own value matches the resolved value; a group is prefilled by giving its members the same expression), select single writes .value, select multiple marks option.selected from an array value. File inputs are skipped entirely (script assignment is forbidden by the platform; noted in a comment). Undefined leaves the element untouched; null is '' for values and false for checked.

- Dirty tracking: one WeakSet hosted on the bridge (bridge.formsDirty plus bridge.markDirty/isDirty helpers, per the task-186 design decision) so future reconciliation code reuses the same set. Document-level CAPTURE listeners for input/change (mark the edited element) and reset (un-dirty the form and its controls; the reset event fires before defaults are restored, and the server's form-reset directive flows through the same path).

- stm-clear-on-success: document-level capture submit listener. The __stmAction promise is read in a microtask because the capture listener runs before wireAction's target-phase listener stashes the current promise. Success is read from a new __stmActionFailed outcome flag because runElementAction swallows rejections by design (so they do not surface as unhandled); the flag is pessimistic (true until the action completes) so a declined stm-confirm never clears the form. On success exactly the one form is reset (which un-dirties it); on failure values and dirty state are kept.

- statum.forms = { reset, markDirty, isDirty }: reset resolves the owning form when given an element inside one and resets only that form.

statum-document.js changes: wireAction now stashes el.__stmAction = runElementAction(el) (the seam the forms module awaits) and runElementAction maintains the __stmActionFailed outcome flag in its finally block.

Build: src/meson.build gains the statum-forms.js custom_target (ClientForms, application/javascript) and ClientForms is registered in Statum.vala after ClientMarkdown.

Docs: model.md gains Form prefill (control-type matrix, dirty semantics incl. the keyed-loop rule, radio-group pattern, file-input skip), Clear on success (single-form scoping, failure keeps fields, stm-action requirement) and a Form helpers section for statum.forms.reset/markDirty/isDirty.

Tests: tests/dom/ (new) runs 16 scenarios under a purpose-built DOM shim (capture/bubble events, form reset defaults, dirty-value control properties, fresh-identity cloneNode, tag/.class/[attr] selectors, fetch/Headers/FormData/URLSearchParams/File stubs): walk bindings, keyed/unkeyed loops, the full prefill matrix, dirty skip/per-element/reset-undirty, clear-on-success success/failure, keyed-loop form isolation, sibling-form isolation, programmatic reset, and handler ordering. State enters through the real machinery (set directive via bridge.loadEntrypoint -> applyDirectives -> renderAll).
saddle-ClankerSaddle 3 周之前
父節點
當前提交
bfbaa16d46
共有 8 個文件被更改,包括 1489 次插入 和 7 次删除
  1. 14 1
      js/statum-document.js
  2. 272 0
      js/statum-forms.js
  3. 49 0
      model.md
  4. 1 0
      src/Statum.vala
  5. 18 6
      src/meson.build
  6. 29 0
      tests/dom/run.sh
  7. 520 0
      tests/dom/scenarios.js
  8. 586 0
      tests/dom/shim.js

+ 14 - 1
js/statum-document.js

@@ -437,7 +437,9 @@
     var eventName = el.tagName === 'FORM' ? 'submit' : 'click';
     el.addEventListener(eventName, function (event) {
       event.preventDefault();
-      runElementAction(el);
+      // Stash the action promise so stm-clear-on-success (statum-forms.js)
+      // can await it and reset the form only when the action resolved.
+      el.__stmAction = runElementAction(el);
     });
   }
 
@@ -451,6 +453,14 @@
     var action = evalExpr(el.getAttribute('stm-action'), scope);
     if (!action || typeof action.uri !== 'string') return;
 
+    // Outcome flag read by stm-clear-on-success (statum-forms.js). The
+    // returned promise resolves even on failure (the catch below swallows
+    // the rejection so it does not surface as unhandled), so success needs
+    // its own signal. Pessimistic: true until the action completes
+    // successfully, so a declined confirm or an unresolvable action never
+    // reads as success.
+    el.__stmActionFailed = true;
+
     // stm-confirm: gate the action behind the confirm handler (async-capable,
     // so custom modals are supported).
     if (el.hasAttribute('stm-confirm')) {
@@ -461,13 +471,16 @@
     applyErrorClasses(el, false); // clear error state on a new attempt
     var data = collectData(el);
     setBusyState(el, true);
+    var failed = false;
     try {
       await performAction(action, data, el);
     } catch (err) {
+      failed = true;
       // Already handled: stm-on-error classes and the onErrorHandler run inside
       // performAction/reportFailure. Swallow so an HTML-triggered action does
       // not surface as an unhandled rejection.
     } finally {
+      el.__stmActionFailed = failed;
       setBusyState(el, false);
     }
   }

+ 272 - 0
js/statum-forms.js

@@ -0,0 +1,272 @@
+/*!
+ * Statum - client-side state library (forms module).
+ *
+ * Form-control bindings: `stm-prefill` (state -> control PROPERTY writes with
+ * dirty-element tracking) and `stm-clear-on-success` (reset a form after its
+ * own `stm-action` succeeds), plus the programmatic `statum.forms` API.
+ *
+ * stm-prefill writes properties, never attributes — that is its entire point.
+ * `stm-attribute.value` keeps writing the value ATTRIBUTE (the server-wins
+ * default-dirty path, issue C2); stm-prefill instead updates the control's
+ * live `.value` / `.checked` / `.selected` properties so a prefill never
+ * fights the user's typing. Elements the user has edited (dirty elements) are
+ * never overwritten by a later render cycle.
+ *
+ * Radio groups are prefilled per-element: a radio carrying stm-prefill is
+ * checked iff its own value matches the resolved value. Give every member of
+ * the group the same stm-prefill expression and the group behaves correctly
+ * (each member is checked iff its own value matches the resolved value).
+ *
+ * Load order: FOURTH - requires statum-core.js (`window.statum._bridge`).
+ * The `stm-prefill` handler orders itself before statum-document.js's
+ * `stm-class.*` so its property writes feed the controls before any
+ * class/attribute predicate evaluates. Like core: an IIFE, no dependencies,
+ * no build step, plain script tag.
+ */
+(function () {
+  'use strict';
+
+  if (!window.statum || !window.statum._bridge) {
+    console.error('[statum] statum-forms.js requires statum-core.js');
+    return;
+  }
+
+  var bridge = window.statum._bridge;
+  var evalExpr = bridge.evalExpr;
+
+
+  // ---------------------------------------------------------------------------
+  // Dirty tracking
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Elements the user has edited since they were last reset. A dirty element
+   * is skipped by stm-prefill: the user's edit outranks the state snapshot.
+   *
+   * Hosted on the bridge (not module-private) so task 186's reconciliation
+   * work can reuse the same set instead of guessing dirtiness from DOM
+   * comparison. Use `bridge.markDirty` / `bridge.isDirty`; do not poke the
+   * WeakSet directly.
+   * @type {WeakSet<Element>}
+   */
+  var formsDirty = new WeakSet();
+  bridge.formsDirty = formsDirty;
+
+  /** Mark an element dirty. @param {Element} el */
+  function markDirty(el) { formsDirty.add(el); }
+
+  /** Whether an element is dirty. @param {Element} el @returns {boolean} */
+  function isDirty(el) { return formsDirty.has(el); }
+
+  bridge.markDirty = markDirty;
+  bridge.isDirty = isDirty;
+
+  /**
+   * Un-dirty a form and all its form controls (the form element itself is
+   * rarely dirty — its inputs are — but clearing both is harmless).
+   * @param {Element} form
+   */
+  function unmarkForm(form) {
+    formsDirty.delete(form);
+    var nodes = form.querySelectorAll('button,input,select,textarea,fieldset');
+    for (var i = 0; i < nodes.length; i++) formsDirty.delete(nodes[i]);
+  }
+
+
+  // ---------------------------------------------------------------------------
+  // Document-level listeners (capture phase)
+  // ---------------------------------------------------------------------------
+
+  /** Mark the element an input/change event targeted. @param {Event} ev */
+  function onEdit(ev) {
+    var t = ev.target;
+    if (t && t.nodeType === 1) markDirty(t);
+  }
+
+  /**
+   * The reset event fires BEFORE the browser restores default values (it is
+   * cancelable), so un-dirtying during reset is correct: the values become
+   * the defaults — clean, as far as statum is concerned — and the next
+   * prefill cycle can re-apply state.
+   *
+   * Double duty: the server's `form-reset` directive calls form.reset(),
+   * which fires this same reset event, so the directive un-dirties forms
+   * through this one listener too.
+   * @param {Event} ev
+   */
+  function onReset(ev) {
+    var f = ev.target;
+    if (f && f.nodeType === 1 && f.tagName === 'FORM') unmarkForm(f);
+  }
+
+  document.addEventListener('input', onEdit, true);
+  document.addEventListener('change', onEdit, true);
+  document.addEventListener('reset', onReset, true);
+
+
+  // ---------------------------------------------------------------------------
+  // stm-prefill
+  // ---------------------------------------------------------------------------
+
+  /** Input types whose value is plain text (property `value`). */
+  var TEXTLIKE_TYPES = [
+    'text', 'email', 'number', 'password', 'search', 'tel', 'url',
+    'date', 'datetime-local', 'month', 'week', 'time', 'range', 'color', 'hidden'
+  ];
+
+  /**
+   * Bind `stm-prefill`: write the resolved expression into the control's
+   * properties (never attributes).
+   *
+   * Semantics:
+   * - Dirty elements are skipped entirely — the user's edit outranks state.
+   * - `<input type=file>` is skipped entirely: assigning `el.value` from a
+   *   script is forbidden by the platform (and silently no-ops), so there is
+   *   nothing sensible to write. No per-element warning; the file-wide
+   *   "unresolved leaves the DOM alone" policy applies.
+   * - `undefined` (unresolved expression) leaves the element untouched.
+   * - `null` is written as `''` for value writes and `false` for checked
+   *   writes.
+   * - Runs every render cycle like every handler; property writes are
+   *   naturally idempotent per cycle.
+   *
+   * @param {Element} el
+   * @param {object} scope
+   */
+  function handlePrefill(el, scope) {
+    if (isDirty(el)) return;
+    var type = el.type || (el.tagName === 'INPUT' ? 'text' : '');
+    if (el.tagName === 'INPUT' && type === 'file') return;
+
+    var value = evalExpr(el.getAttribute('stm-prefill'), scope);
+    if (value === undefined) return;
+
+    if (el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && TEXTLIKE_TYPES.indexOf(type) !== -1)) {
+      el.value = value == null ? '' : String(value);
+      return;
+    }
+    if (el.tagName === 'INPUT' && (type === 'checkbox')) {
+      el.checked = !!value;
+      return;
+    }
+    if (el.tagName === 'INPUT' && type === 'radio') {
+      // Per-element: see the module header. A group is prefilled by giving
+      // its members the same stm-prefill expression.
+      el.checked = (el.value === String(value));
+      return;
+    }
+    if (el.tagName === 'SELECT') {
+      if (type === 'multiple' || el.multiple === true) {
+        var arr = Array.isArray(value) ? value : [value];
+        var options = el.querySelectorAll('option');
+        for (var i = 0; i < options.length; i++) {
+          var opt = options[i];
+          opt.selected = arr.indexOf(String(opt.value)) !== -1;
+        }
+      } else {
+        el.value = value == null ? '' : String(value);
+      }
+      return;
+    }
+    // Other elements (buttons, fieldsets, ...): the attribute is unsupported;
+    // leave the element untouched rather than inventing a semantic.
+  }
+
+
+  // ---------------------------------------------------------------------------
+  // stm-clear-on-success
+  // ---------------------------------------------------------------------------
+
+  /** Forms already warned about a missing stm-action. @type {WeakSet} */
+  var warnSeen = new WeakSet();
+
+  /**
+   * Clear a form when its own stm-action succeeds. Registered on the document
+   * (capture phase) so it sees every submit; the promise is stashed by
+   * wireAction's target-phase listener on the form itself (el.__stmAction —
+   * see statum-document.js).
+   *
+   * The stash is read in a MICROTASK: this capture-phase listener runs before
+   * the form's target-phase listener has stashed the CURRENT action promise,
+   * so a synchronous read would see the previous submit's promise (or none).
+   * Event dispatch completes synchronously, so by the time the microtask runs
+   * the stash always holds this submit's promise.
+   *
+   * `stm-clear-on-success` is supported on a FORM whose own stm-action
+   * performs the request (wireAction wires a FORM for `submit`). If the form
+   * carries the attribute but no stm-action of its own, there is no promise
+   * to await, so the attribute is inert there; warn once per form.
+   *
+   * @param {Event} ev
+   */
+  function onSubmit(ev) {
+    var f = ev.target;
+    if (!f || f.nodeType !== 1 || f.tagName !== 'FORM') return;
+    if (!(f.hasAttribute && f.hasAttribute('stm-clear-on-success'))) return;
+    Promise.resolve().then(function () {
+      var promise = f.__stmAction;
+      if (!promise) {
+        if (!warnSeen.has(f)) {
+          warnSeen.add(f);
+          console.warn('[statum] stm-clear-on-success on a form without stm-action does nothing; ' +
+            'put stm-action on the form itself');
+        }
+        return;
+      }
+      promise.then(function () {
+        // runElementAction resolves even when the action failed (it swallows
+        // the rejection so it does not surface as unhandled), so success is
+        // read from its outcome flag — see statum-document.js.
+        if (f.__stmActionFailed === false) f.reset();
+      }, function () {
+        /* failure: leave the fields as they are */
+      });
+    });
+  }
+
+  document.addEventListener('submit', onSubmit, true);
+
+
+  // ---------------------------------------------------------------------------
+  // Programmatic API
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Resolve the form an element belongs to: the element itself if it is a
+   * form, `el.form` where available (form controls), else the closest form
+   * ancestor.
+   * @param {Element} el
+   * @returns {Element|null}
+   */
+  function resolveForm(el) {
+    if (!el) return null;
+    if (el.tagName === 'FORM') return el;
+    if (el.form) return el.form;
+    if (typeof el.closest === 'function') return el.closest('form');
+    return null;
+  }
+
+  /** Reset exactly one form (the reset event un-dirties it). @param {Element} formOrEl */
+  function resetForm(formOrEl) {
+    var f = resolveForm(formOrEl);
+    if (f) f.reset();
+  }
+
+  window.statum.forms = {
+    /** Reset one form (or the form owning an element). */
+    reset: resetForm,
+    /** Mark an element dirty (skipped by stm-prefill until reset). */
+    markDirty: markDirty,
+    /** Whether an element is dirty. */
+    isDirty: isDirty
+  };
+
+
+  // ---------------------------------------------------------------------------
+  // Registration (module seam)
+  // ---------------------------------------------------------------------------
+
+  // Before stm-class.*: the property writes feed the controls before any
+  // class/attribute predicate evaluates.
+  bridge.registerAttribute('stm-prefill', handlePrefill, { before: 'stm-class.' });
+})();

+ 49 - 0
model.md

@@ -241,6 +241,14 @@ The default UI behaviours for confirmations, notifications, and errors can be cu
 - `statum.onNotifyHandler`: `(kind, message) => void`. Invoked for each `notify` directive. Defaults to `window.alert(message)`.
 - `statum.onErrorHandler`: `(error, element) => boolean | Promise<boolean>`. Invoked for every `error` directive, entrypoint load failure, and HTTP/network failure. `element` is the originating action element, or `null` for the entrypoint. Returning (or resolving) `false` suppresses the default behaviour (rejecting the originating `statum.act` promise and applying any `stm-on-error` classes); any other return value leaves the default in place.
 
+## Form helpers
+
+`statum.forms` groups the programmatic side of the form bindings (see *Form prefill* and *Clear on success* above):
+
+- `statum.forms.reset(formOrEl)`: reset exactly one form. Accepts a form element, or any element inside a form (the owning form is resolved). The reset event un-dirties the form and its controls, so a later render cycle may prefill it afresh.
+- `statum.forms.markDirty(el)`: mark an element dirty; `stm-prefill` will skip it until its form is reset.
+- `statum.forms.isDirty(el)`: whether an element is dirty.
+
 ## Evaluation hooks
 
 The library exposes two no-arg callback hooks that fire before and after each render cycle (initial render, entrypoint hydration, `set`/`clear`, and realtime events):
@@ -392,6 +400,47 @@ The attribute `stm-class.x` where `x` is any class name can be used to toggle cl
 
 The attribute `stm-disabled` takes a predicate expression. When truthy, the element and all of its descendant form controls are disabled; when falsy they are enabled, e.g. `<button stm-action="typeName.submit" stm-disabled="!user.canSubmit">Submit</button>`.
 
+## Form prefill
+
+The attribute `stm-prefill` binds a form control's **value** to an expression, writing the control's *properties* (`.value`, `.checked`, `.selected`) — never its attributes. This is the counterpart to `stm-attribute.value`, which writes the value *attribute* (the server-rendered default): a prefill updates the live control so it never fights the user's typing.
+
+```html
+<input type="text" stm-prefill="user.displayName">
+<textarea stm-prefill="user.bio"></textarea>
+```
+
+Semantics per control type:
+
+- **Text-like inputs** (`text`, `email`, `number`, `password`, `search`, `tel`, `url`, `date`, `datetime-local`, `month`, `week`, `time`, `range`, `color`, `hidden`) and `<textarea>`: `value` is written as a string (`null` becomes the empty string).
+- **Checkbox**: `checked` is set to the boolean coercion of the value.
+- **Radio**: per-element — a radio carrying `stm-prefill` is checked iff its own `value` attribute equals the stringified resolved value. Give every member of a group the same expression and the group behaves correctly: each member is checked iff its own value matches. A value matching no member leaves all unchecked.
+- **`<select>`** (single): `value` is set; a value with no matching option leaves nothing selected (per DOM semantics).
+- **`<select multiple>`**: an array value marks each option `selected` iff its value is in the array; a non-array value behaves like the single case.
+- **`<input type="file">`** is skipped entirely: script assignment of a file input's value is forbidden by the platform, so nothing happens and nothing is warned.
+
+An `undefined` result (unresolved expression) leaves the element untouched, per the file-wide "unresolved leaves the DOM alone" policy; `null` writes `''` for value writes and `false` for checked writes.
+
+### Dirty tracking
+
+Once the user edits a control (an `input` or `change` event, observed with a document-level capture listener), that element becomes *dirty* and is skipped by every later `stm-prefill` cycle — the user's edit outranks the state snapshot. It stays dirty until the owning form is reset. The reset event fires *before* the browser restores default values, so a reset also un-dirties the form and its controls, letting the next render cycle re-apply state. The server's `form-reset` directive calls `form.reset()` and therefore un-dirties through the same path.
+
+Inside `stm-for` loops, dirtiness is per DOM element: a keyed loop reuses nodes and keeps their dirty state, while a re-rendered instance with a new key starts clean.
+
+Dirty state is exposed for programmatic use (tests, reconciliation logic): `statum.forms.markDirty(el)`, `statum.forms.isDirty(el)`.
+
+## Clear on success
+
+The attribute `stm-clear-on-success` (on a `<form>` that also carries `stm-action`) resets the form once its own action **succeeds** — the request resolved and no `error` directive came back. On failure the fields are left exactly as they are (values and dirty state), so the user can correct and retry. The reset also un-dirties the form, so a later render cycle may prefill it afresh.
+
+```html
+<form stm-action="typeName.saveThing" stm-clear-on-success>
+    <input name="label" stm-prefill="typeName.draftLabel">
+    <button type="submit">Save</button>
+</form>
+```
+
+Scoping is strict: only the one form that carried the action is reset — sibling forms, forms in other loop instances, everything else keeps its values and dirty state. `stm-clear-on-success` requires the form itself to carry `stm-action` (a submit button inside the form with its own `stm-action` fires a click action, not a form submit); a form carrying the attribute without one logs a single warning.
+
 ## Actions
 
 The attribute `stm-action` binds the element's primary action (`click` for buttons and links, `submit` for forms), overriding the default behaviour to instead perform the Statum action referenced by the given path into state. For example `<button stm-action="typeName.deleteItem">Delete</button>`.

+ 1 - 0
src/Statum.vala

@@ -74,6 +74,7 @@ namespace Statum {
             container.register_startup<ClientCore>().as<StatumResource>();
             container.register_startup<ClientDocument>().as<StatumResource>();
             container.register_startup<ClientMarkdown>().as<StatumResource>();
+            container.register_startup<ClientForms>().as<StatumResource>();
             container.register_startup<ClientWorker>().as<StatumResource>();
         }
 

+ 18 - 6
src/meson.build

@@ -1,13 +1,13 @@
 # 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-forms.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.
 #
-# The three client modules are separate resources loaded in order by pages
+# The four client modules are separate resources loaded in order by pages
 # (core creates window.statum and the module seam; document installs the
-# render walk; markdown adds the CommonMark engine). The former single-file
-# statum.js resource no longer exists.
+# render walk; markdown adds the CommonMark engine; forms adds the form
+# bindings). The former single-file statum.js resource no longer exists.
 client_core = custom_target('statum-client-core',
     input: files('../js/statum-core.js'),
     output: 'ClientCore.vala',
@@ -44,6 +44,18 @@ client_markdown = custom_target('statum-client-markdown',
               '@INPUT@']
 )
 
+client_forms = custom_target('statum-client-forms',
+    input: files('../js/statum-forms.js'),
+    output: 'ClientForms.vala',
+    command: [statum_mkres,
+              '-o', '@OUTPUT@',
+              '-n', 'statum-forms.js',
+              '--class-name', 'ClientForms',
+              '--ns', 'Statum',
+              '-c', 'application/javascript',
+              '@INPUT@']
+)
+
 client_worker = custom_target('statum-client-worker',
     input: files('../js/statum-worker.js'),
     output: 'ClientWorker.vala',
@@ -92,7 +104,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_forms, client_worker,
     dependencies: [
         glib_dep,
         gobject_dep,

+ 29 - 0
tests/dom/run.sh

@@ -0,0 +1,29 @@
+#!/bin/sh
+# Run the Statum DOM-walk + forms test scenarios.
+#
+# Builds the vendored QuickJS interpreter if no `qjs` is on PATH (same
+# 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 sixteen)
+set -e
+cd "$(dirname "$0")/../.."
+
+ROOT=$(pwd)
+QJS="$ROOT/tools/qjs/qjs"
+
+if ! command -v qjs >/dev/null 2>&1; then
+  echo "== building vendored QuickJS =="
+  "$ROOT/tools/qjs/build.sh"
+else
+  QJS=$(command -v qjs)
+fi
+
+SCENARIOS="${1:-1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16}"
+
+for n in $SCENARIOS; do
+  echo "== dom scenario $n =="
+  "$QJS" "$ROOT/tests/dom/scenarios.js" "$n"
+done
+
+echo "OK"

+ 520 - 0
tests/dom/scenarios.js

@@ -0,0 +1,520 @@
+/**
+ * DOM-walk + forms-module scenarios for the Statum client.
+ *
+ * Each scenario runs in a FRESH QuickJS context (run.sh invokes this script
+ * once per scenario) with a minimal DOM shim (shim.js) that supports the
+ * render walk and the forms module end to end, including capture-phase
+ * events, form reset defaults and a scriptable fetch stub.
+ *
+ * State enters through the real machinery: a `set` directive returned by the
+ * fetch stub and applied by bridge.loadEntrypoint() (which re-renders through
+ * applyDirectives -> renderAll). Signatures vary per step so the idempotency
+ * guard never short-circuits a deliberate re-render.
+ *
+ *   1  walk: stm-if/stm-else chain, stm-text, stm-class.*
+ *   2  stm-for keyed: node identity preserved per key, removal honored
+ *   3  stm-for unkeyed: instances rebuilt (all identities change)
+ *   4  stm-prefill: input + textarea property writes (no attribute)
+ *   5  stm-prefill: select single + multiple (array)
+ *   6  stm-prefill: checkbox truthy/falsy/null
+ *   7  stm-prefill: radio group, same expression per member
+ *   8  dirty skip: dirty input keeps user value, clean sibling re-fills
+ *   9  dirty is per element within a form
+ * 10  reset event un-dirties; prefill re-applies after reset
+ * 11  stm-clear-on-success: success resets + un-dirties the form
+ * 12  stm-clear-on-success: failure keeps values and dirty state
+ * 13  keyed loop of forms: clearing instance 2 leaves 1 and 3 alone
+ * 14  two sibling forms: clearing A leaves B alone
+ * 15  statum.forms.reset: resets exactly the given form
+ * 16  handler order: stm-prefill registered before stm-class.*
+ *
+ * Usage: qjs tests/dom/scenarios.js <1..16>
+ */
+import * as std from 'std';
+import { installShim } from './shim.js';
+
+// Repo root as a CWD-relative path; run.sh cds to the repo root first (same
+// convention as tests/markdown/spec-test.js).
+var ROOT = '.';
+var scenario = scriptArgs.length > 1 ? scriptArgs[1] : '';
+
+function fail(msg) {
+  std.err.puts('FAIL [' + scenario + '] ' + msg + '\n');
+  std.exit(1);
+}
+
+function check(cond, msg) {
+  if (!cond) fail(msg);
+}
+
+function load(name) {
+  (0, eval)(std.loadFile(ROOT + '/js/' + name));
+}
+
+function loadAll() {
+  load('statum-core.js');
+  load('statum-document.js');
+  load('statum-markdown.js');
+  load('statum-forms.js');
+}
+
+/** Yield to the job queue n times so promise chains settle. */
+async function spin(n) {
+  for (var i = 0; i < (n || 30); i++) await Promise.resolve();
+}
+
+var sigCounter = 0;
+
+/**
+ * Put `public` data into a slot type through a set directive, then re-render.
+ * Uses loadEntrypoint (public bridge machinery) so the exact production path
+ * (fetch -> handleResponse -> applyDirectives -> renderAll) runs.
+ */
+async function setState(typeName, publicData) {
+  sigCounter++;
+  var snapshot = { slot: { key: 'k-' + typeName, type: typeName, scope: 'page' }, public: publicData };
+  globalThis.__shim.setFetch(function () {
+    return globalThis.__shim.okResponse([{
+      type: 'set',
+      snapshot_frame: { content: JSON.stringify(snapshot), signature: 'sig-' + sigCounter }
+    }]);
+  });
+  await globalThis.statum._bridge.loadEntrypoint();
+  await spin();
+}
+
+/** Fire a real (bubbling, non-cancelable-by-default) event on an element. */
+function fire(el, type) {
+  var evt = globalThis.__shim.makeEvent(type);
+  el.dispatchEvent(evt);
+  return evt;
+}
+
+function el(tag) { return globalThis.__shim.makeElement(tag); }
+function input(type, name, value) {
+  var i = el('input');
+  if (type) i.setAttribute('type', type);
+  if (name) i.setAttribute('name', name);
+  if (value !== undefined) i.setAttribute('value', value);
+  return i;
+}
+
+async function main() {
+  installShim();
+  var log = globalThis.__shim.log;
+  var body = globalThis.__shim.body;
+  loadAll();
+
+  var s = globalThis.statum;
+  check(s && s._bridge, 'statum did not load');
+  check(s.forms && typeof s.forms.reset === 'function', 'statum.forms.reset missing');
+  check(typeof s.forms.markDirty === 'function' && typeof s.forms.isDirty === 'function',
+    'statum.forms.markDirty/isDirty missing');
+  check(s._bridge.formsDirty instanceof WeakSet, 'bridge.formsDirty missing');
+  check(typeof s._bridge.markDirty === 'function' && typeof s._bridge.isDirty === 'function',
+    'bridge.markDirty/isDirty missing');
+
+  if (scenario === '1') {
+    // if/else-if/else chain + stm-text + stm-class.*
+    var w = el('div');
+    var a = el('p'); a.setAttribute('stm-if', "m.mood === 'up'"); a.textContent = 'up';
+    var b = el('p'); b.setAttribute('stm-else-if', "m.mood === 'mid'"); b.textContent = 'mid';
+    var c = el('p'); c.setAttribute('stm-else', ''); c.textContent = 'down';
+    w.appendChild(a); w.appendChild(b); w.appendChild(c);
+    var t = el('span'); t.setAttribute('stm-text', 'm.mood');
+    var cl = el('span'); cl.setAttribute('stm-class.hi', "m.mood === 'up'");
+    w.appendChild(t); w.appendChild(cl);
+    body.appendChild(w);
+
+    await setState('m', { mood: 'up' });
+    check(!a.hasAttribute('hidden') && b.hasAttribute('hidden') && c.hasAttribute('hidden'),
+      'branch up not exclusively shown');
+    check(t.textContent === 'up', 'stm-text wrote ' + JSON.stringify(t.textContent));
+    check(cl.classList.contains('hi'), 'stm-class.hi not applied');
+
+    await setState('m', { mood: 'mid' });
+    check(a.hasAttribute('hidden') && !b.hasAttribute('hidden') && c.hasAttribute('hidden'),
+      'branch mid not exclusively shown');
+    check(!cl.classList.contains('hi'), 'stm-class.hi not removed');
+    check(t.textContent === 'mid', 'stm-text not updated');
+
+    await setState('m', { mood: 'zzz' });
+    check(a.hasAttribute('hidden') && b.hasAttribute('hidden') && !c.hasAttribute('hidden'),
+      'else branch not shown');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '2') {
+    // Keyed loop: identity per key preserved; removal honored.
+    var holder = el('div');
+    holder.setAttribute('stm-for-item-in', 'list.items');
+    holder.setAttribute('stm-key', 'item.id');
+    var row = el('p');
+    row.setAttribute('stm-text', 'item.name');
+    holder.appendChild(row);
+    body.appendChild(holder);
+
+    await setState('list', { items: [{ id: 1, name: 'a' }, { id: 2, name: 'b' }, { id: 3, name: 'c' }] });
+    var insts = holder.parentNode.children.filter(function (n) { return n !== holder; });
+    // holder is children[0]; instances follow it in order.
+    var kids = body.children.filter(function (n) { return n !== holder; });
+    check(kids.length === 3, 'expected 3 instances, got ' + kids.length);
+    var k1 = kids[0], k2 = kids[1], k3 = kids[2];
+    // The stm-text binding is on the template's child, so read the child.
+    check(k1.children[0].textContent === 'a' && k2.children[0].textContent === 'b' &&
+      k3.children[0].textContent === 'c',
+      'instance texts: ' + kids.map(function (n) { return n.children[0] && n.children[0].textContent; }).join(','));
+
+    await setState('list', { items: [{ id: 1, name: 'a' }, { id: 3, name: 'c3' }] });
+    var kids2 = body.children.filter(function (n) { return n !== holder; });
+    check(kids2.length === 2, 'expected 2 instances after removal, got ' + kids2.length);
+    check(kids2[0] === k1, 'key 1 node identity not preserved');
+    check(kids2[1] === k3, 'key 3 node identity not preserved');
+    check(kids2[1].children[0].textContent === 'c3', 'key 3 text not refreshed');
+    check(k2.parentNode === null, 'removed instance still attached');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '3') {
+    // Unkeyed loop: rebuild wholesale.
+    var holder = el('div');
+    holder.setAttribute('stm-for-item-in', 'list.items');
+    var row = el('p');
+    row.setAttribute('stm-text', 'item.name');
+    holder.appendChild(row);
+    body.appendChild(holder);
+
+    await setState('list', { items: [{ name: 'a' }, { name: 'b' }] });
+    var kids = body.children.filter(function (n) { return n !== holder; });
+    check(kids.length === 2 && kids[0].children[0].textContent === 'a' &&
+      kids[1].children[0].textContent === 'b', 'initial render');
+
+    await setState('list', { items: [{ name: 'a' }, { name: 'b' }] });
+    var kids2 = body.children.filter(function (n) { return n !== holder; });
+    check(kids2.length === 2, 'instance count changed');
+    check(kids2[0] !== kids[0] && kids2[1] !== kids[1], 'unkeyed loop reused nodes');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '4') {
+    // Prefill writes the value PROPERTY, never the value attribute.
+    var f = el('form');
+    var ti = input('text', 'who'); ti.setAttribute('stm-prefill', 'p.who');
+    var ta = el('textarea'); ta.setAttribute('name', 'bio'); ta.setAttribute('stm-prefill', 'p.bio');
+    f.appendChild(ti); f.appendChild(ta);
+    body.appendChild(f);
+
+    await setState('p', { who: 'ada', bio: 'hi' });
+    check(ti.value === 'ada', 'text input value = ' + JSON.stringify(ti.value));
+    check(ta.value === 'hi', 'textarea value = ' + JSON.stringify(ta.value));
+    check(ti.getAttribute('value') === null, 'text input value ATTRIBUTE was written');
+    check(ta.getAttribute('stm-prefill') === 'p.bio', 'stm-prefill attribute consumed');
+
+    await setState('p', { who: null, bio: undefined });
+    check(ti.value === '', 'null should prefill as empty string, got ' + JSON.stringify(ti.value));
+    check(ta.value === 'hi', 'undefined must leave the element untouched');
+
+    await setState('p', { who: 42, bio: 'x' });
+    check(ti.value === '42', 'number stringified: ' + JSON.stringify(ti.value));
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '5') {
+    // Select single + multiple.
+    var f = el('form');
+    var sel = el('select'); sel.setAttribute('stm-prefill', 'p.pick');
+    var o1 = el('option'); o1.setAttribute('value', 'a'); o1.textContent = 'A';
+    var o2 = el('option'); o2.setAttribute('value', 'b'); o2.textContent = 'B';
+    sel.appendChild(o1); sel.appendChild(o2);
+    var multi = el('select'); multi.setAttribute('multiple', ''); multi.setAttribute('stm-prefill', 'p.set');
+    var m1 = el('option'); m1.setAttribute('value', 'x');
+    var m2 = el('option'); m2.setAttribute('value', 'y');
+    var m3 = el('option'); m3.setAttribute('value', 'z');
+    multi.appendChild(m1); multi.appendChild(m2); multi.appendChild(m3);
+    f.appendChild(sel); f.appendChild(multi);
+    body.appendChild(f);
+
+    await setState('p', { pick: 'b', set: ['x', 'z'] });
+    check(sel.value === 'b', 'single select value = ' + JSON.stringify(sel.value));
+    check(o2.selected === true && o1.selected === false, 'single select option states wrong');
+    check(m1.selected === true && m2.selected === false && m3.selected === true,
+      'multiple select option states wrong');
+
+    await setState('p', { pick: 'nope', set: 'y' });
+    check(sel.value === '', 'unmatched single select should yield empty value');
+    check(m1.selected === false && m2.selected === true && m3.selected === false,
+      'non-array multiple value must behave like single');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '6') {
+    // Checkbox: truthy -> checked, falsy/null -> unchecked.
+    var f = el('form');
+    var cb = input('checkbox', 'ok'); cb.setAttribute('stm-prefill', 'p.ok');
+    f.appendChild(cb);
+    body.appendChild(f);
+
+    await setState('p', { ok: true });
+    check(cb.checked === true, 'truthy did not check');
+    await setState('p', { ok: false });
+    check(cb.checked === false, 'falsy did not uncheck');
+    await setState('p', { ok: null });
+    check(cb.checked === false, 'null did not uncheck');
+    await setState('p', { ok: 'yes' });
+    check(cb.checked === true, 'non-empty string should check');
+    await setState('p', {}); // ok undefined -> untouched
+    check(cb.checked === true, 'undefined must leave checked untouched');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '7') {
+    // Radio group: every member carries the same expression; the member whose
+    // own value matches the resolved value gets checked.
+    var f = el('form');
+    var mk = function (v) {
+      var r = input('radio', 'g', v);
+      r.setAttribute('stm-prefill', 'p.choice');
+      f.appendChild(r);
+      return r;
+    };
+    var ra = mk('a'), rb = mk('b'), rc = mk('c');
+    body.appendChild(f);
+
+    await setState('p', { choice: 'b' });
+    check(ra.checked === false && rb.checked === true && rc.checked === false,
+      'state b checked the wrong member(s)');
+    await setState('p', { choice: 'd' });
+    check(ra.checked === false && rb.checked === false && rc.checked === false,
+      'state d (no match) must leave all unchecked');
+    await setState('p', { choice: null });
+    check(rb.checked === false, 'null must uncheck (String(null) matches nothing)');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '8') {
+    // A dirty element is never overwritten; a clean sibling still fills.
+    var f = el('form');
+    var i1 = input('text', 'one'); i1.setAttribute('stm-prefill', 'p.one');
+    var i2 = input('text', 'two'); i2.setAttribute('stm-prefill', 'p.two');
+    f.appendChild(i1); f.appendChild(i2);
+    body.appendChild(f);
+
+    await setState('p', { one: 'x', two: 'x' });
+    check(i1.value === 'x' && i2.value === 'x', 'initial fill failed');
+    fire(i1, 'input'); // user edits input 1
+    check(s.forms.isDirty(i1), 'input event did not mark the element dirty');
+
+    await setState('p', { one: 'y', two: 'y' });
+    check(i1.value === 'x', 'dirty element was overwritten: ' + JSON.stringify(i1.value));
+    check(i2.value === 'y', 'clean sibling not re-filled');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '9') {
+    // Dirtiness is per element.
+    var f = el('form');
+    var i1 = input('text', 'one'); i1.setAttribute('stm-prefill', 'p.v');
+    var i2 = input('text', 'two'); i2.setAttribute('stm-prefill', 'p.v');
+    f.appendChild(i1); f.appendChild(i2);
+    body.appendChild(f);
+
+    await setState('p', { v: 'a' });
+    fire(i1, 'change');
+    await setState('p', { v: 'b' });
+    check(i1.value === 'a', 'dirty element overwritten');
+    check(i2.value === 'b', 'clean element not filled');
+    check(s.forms.isDirty(i1) && !s.forms.isDirty(i2), 'dirty flags wrong');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '10') {
+    // Reset restores defaults, un-dirties, and prefill re-applies.
+    var f = el('form');
+    var i1 = input('text', 'one', 'init'); i1.setAttribute('stm-prefill', 'p.v');
+    f.appendChild(i1);
+    body.appendChild(f);
+
+    await setState('p', { v: 'state' });
+    check(i1.value === 'state', 'prefill did not apply');
+    fire(i1, 'input');
+    check(s.forms.isDirty(i1), 'not dirty after edit');
+
+    f.reset();
+    check(i1.value === 'init', 'reset did not restore the attribute default: ' + JSON.stringify(i1.value));
+    check(!s.forms.isDirty(i1), 'reset did not un-dirty');
+
+    await setState('p', { v: 'zzz' });
+    check(i1.value === 'zzz', 'prefill did not re-apply after reset');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '11') {
+    // Clear-on-success: action resolves -> form resets and un-dirties.
+    var f = el('form');
+    f.setAttribute('stm-action', 'p.act');
+    f.setAttribute('stm-clear-on-success', '');
+    var i1 = input('text', 'one', 'dflt'); i1.setAttribute('stm-prefill', 'p.v');
+    f.appendChild(i1);
+    body.appendChild(f);
+
+    await setState('p', { v: 'typed', act: { uri: '/save', method: 'POST' } });
+    check(i1.value === 'typed', 'prefill did not apply');
+    fire(i1, 'input'); // user edits -> dirty; reset must still win via clear
+
+    globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
+    fire(f, 'submit');
+    await spin(50);
+
+    check(globalThis.__shim.fetchCalls.some(function (c) { return c.url === '/save'; }),
+      'the action request never fired');
+    check(i1.value === 'dflt', 'form not reset after success: ' + JSON.stringify(i1.value));
+    check(!s.forms.isDirty(i1), 'form not un-dirtied after success');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '12') {
+    // Clear-on-success: failure keeps values and dirty state.
+    var f = el('form');
+    f.setAttribute('stm-action', 'p.act');
+    f.setAttribute('stm-clear-on-success', '');
+    var i1 = input('text', 'one', 'dflt'); i1.setAttribute('stm-prefill', 'p.v');
+    f.appendChild(i1);
+    body.appendChild(f);
+
+    await setState('p', { v: 'typed', act: { uri: '/save', method: 'POST' } });
+    fire(i1, 'input');
+    i1.value = 'edited';
+
+    globalThis.__shim.setFetch(function () { return globalThis.__shim.failResponse(500); });
+    fire(f, 'submit');
+    await spin(50);
+
+    check(i1.value === 'edited', 'form was reset despite failure: ' + JSON.stringify(i1.value));
+    check(s.forms.isDirty(i1), 'dirty state lost on failure');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+    // Error-directive variant (statum response carrying an error directive).
+    globalThis.__shim.setFetch(function () {
+      return globalThis.__shim.okResponse([{ type: 'error', message: 'nope' }]);
+    });
+    fire(f, 'submit');
+    await spin(50);
+    check(i1.value === 'edited', 'form was reset after an error directive');
+    // Core logs error directives by design; nothing else may error.
+    check(log.errors.length === 1 && log.errors[0].indexOf('error directive') !== -1,
+      'unexpected console.error: ' + JSON.stringify(log.errors));
+    log.errors.length = 0;
+
+  } else if (scenario === '13') {
+    // Keyed loop of forms: clearing one instance leaves the others intact.
+    var holder = el('div');
+    holder.setAttribute('stm-for-item-in', 'p.rows');
+    holder.setAttribute('stm-key', 'item.id');
+    var row = el('form');
+    row.setAttribute('stm-action', 'item.act');
+    row.setAttribute('stm-clear-on-success', '');
+    var ri = input('text', 'val', 'blank');
+    ri.setAttribute('stm-prefill', 'item.val');
+    row.appendChild(ri);
+    holder.appendChild(row);
+    body.appendChild(holder);
+
+    var mkAct = function (uri) { return { uri: uri, method: 'POST' }; };
+    await setState('p', {
+      rows: [
+        { id: 1, val: 'one', act: mkAct('/save1') },
+        { id: 2, val: 'two', act: mkAct('/save2') },
+        { id: 3, val: 'three', act: mkAct('/save3') }
+      ]
+    });
+    var forms = body.querySelectorAll('form').filter(function (n) {
+      // The loop template lives inside the (hidden) holder; instances sit in
+      // their own DIVs outside it.
+      var p = n.parentNode;
+      return p !== holder && p.tagName === 'DIV';
+    });
+    check(forms.length === 3, 'expected 3 instance forms, got ' + forms.length);
+    check(forms[0].children[0].value === 'one', 'instance 1 prefill failed');
+
+    // Dirty instance 2's input by editing it.
+    var i2 = forms[1].children[0];
+    i2.value = 'user2';
+    fire(i2, 'input');
+    // Instance 1 and 3 inputs are clean (state-filled).
+    globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
+    fire(forms[1], 'submit');
+    await spin(50);
+
+    check(forms[1].children[0].value === 'blank', 'instance 2 not reset: ' + JSON.stringify(forms[1].children[0].value));
+    check(!s.forms.isDirty(forms[1].children[0]), 'instance 2 not un-dirtied');
+    check(forms[0].children[0].value === 'one', 'instance 1 was touched');
+    check(forms[2].children[0].value === 'three', 'instance 3 was touched');
+    var saved = globalThis.__shim.fetchCalls.filter(function (c) { return c.method === 'POST'; })
+      .map(function (c) { return c.url; });
+    check(saved.indexOf('/save2') !== -1 && saved.indexOf('/save1') === -1 && saved.indexOf('/save3') === -1,
+      'wrong action fired: ' + JSON.stringify(saved));
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '14') {
+    // Two sibling forms: clear-on-success on A only.
+    var fa = el('form');
+    fa.setAttribute('stm-action', 'p.act');
+    fa.setAttribute('stm-clear-on-success', '');
+    var ia = input('text', 'a', 'dflt-a');
+    ia.setAttribute('stm-prefill', 'p.va');
+    fa.appendChild(ia);
+    var fb = el('form');
+    fb.setAttribute('stm-action', 'p.act');
+    var ib = input('text', 'b', 'dflt-b');
+    ib.setAttribute('stm-prefill', 'p.vb');
+    fb.appendChild(ib);
+    body.appendChild(fa); body.appendChild(fb);
+
+    await setState('p', { va: 'A', vb: 'B', act: { uri: '/save', method: 'POST' } });
+    fire(ib, 'input'); // B becomes dirty
+
+    globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
+    fire(fa, 'submit');
+    await spin(50);
+
+    check(ia.value === 'dflt-a', 'form A not reset');
+    check(!s.forms.isDirty(ia), 'form A not un-dirtied');
+    check(ib.value === 'B', 'form B value was touched');
+    check(s.forms.isDirty(ib), 'form B dirty state was touched');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '15') {
+    // statum.forms.reset resets exactly the given form.
+    var fa = el('form');
+    var ia = input('text', 'a', 'dflt-a');
+    fa.appendChild(ia);
+    var fb = el('form');
+    var ib = input('text', 'b', 'dflt-b');
+    fb.appendChild(ib);
+    body.appendChild(fa); body.appendChild(fb);
+
+    ia.value = 'x'; fire(ia, 'input');
+    ib.value = 'y'; fire(ib, 'input');
+    s.forms.reset(fa);
+    check(ia.value === 'dflt-a', 'form A not reset');
+    check(!s.forms.isDirty(ia), 'form A not un-dirtied');
+    check(ib.value === 'y', 'form B was touched');
+    check(s.forms.isDirty(ib), 'form B dirty state was touched');
+
+    // Passing a control inside a form resolves the owning form.
+    ib.value = 'z';
+    s.forms.reset(ib);
+    check(ib.value === 'dflt-b', 'forms.reset(el) did not resolve the owning form');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '16') {
+    // Handler order: stm-prefill first (property writes feed later predicates).
+    var names = s._bridge.attributeHandlers.map(function (h) { return h.name; });
+    check(names.indexOf('stm-prefill') === 0, 'stm-prefill is not first: ' + JSON.stringify(names));
+    check(names.indexOf('stm-class.') === 1, 'stm-class. is not second: ' + JSON.stringify(names));
+    check(names.indexOf('stm-markdown') === names.indexOf('stm-html') + 1, 'markdown ordering broke');
+    check(names[names.length - 1] === 'stm-h-demotion', 'last handler changed: ' + JSON.stringify(names));
+    check(log.warns.length === 0, 'warnings: ' + JSON.stringify(log.warns));
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else {
+    fail('unknown scenario: ' + scenario);
+  }
+
+  check(log.errors.length === 0, 'unexpected console.error: ' + JSON.stringify(log.errors));
+  print('scenario ' + scenario + ': OK');
+}
+
+main().catch(function (e) {
+  std.err.puts('FAIL [' + scenario + '] ' + (e && e.stack || e) + '\n');
+  std.exit(1);
+});

+ 586 - 0
tests/dom/shim.js

@@ -0,0 +1,586 @@
+/**
+ * Minimal DOM shim for the tests/dom suite.
+ *
+ * Unlike the split/markdown shims (which only need statum to LOAD), this one
+ * supports driving the render walk and the forms module end to end:
+ *
+ *  - elements with ORDERED attributes, get/set/has/removeAttribute, a real
+ *    classList backed by the `class` attribute, children/parent hierarchy,
+ *    appendChild/insertBefore/removeChild, nextElementSibling,
+ *    cloneNode(true) with FRESH node identity (listeners and live control
+ *    state are NOT copied; attributes and children are),
+ *  - querySelectorAll for tag, `.class`, `[attr]`, `[attr="v"]`,
+ *    `[attr~="v"]`, `*` and comma lists of those (everything the walk and
+ *    the forms module ask for),
+ *  - an EventTarget model on document AND elements with CAPTURE and bubble
+ *    phases and event.target set — statum-forms.js relies on document-level
+ *    capture listeners,
+ *  - form semantics: el.form (owner form), form.reset() (fires a cancelable
+ *    `reset` event, then restores control defaults from attributes),
+ *    computed select.value/select.multiple/option.value, input.type,
+ *  - the fetch-adjacent globals QuickJS lacks (Headers, URLSearchParams,
+ *    FormData, btoa/TextEncoder) plus a scriptable fetch stub, and in-memory
+ *    localStorage/sessionStorage.
+ *
+ * document.readyState stays 'loading' so statum-core.js only registers a
+ * DOMContentLoaded listener; tests drive renderAll() directly.
+ */
+export function installShim() {
+  var log = { errors: [], warns: [], messages: [] };
+  var fetchCalls = [];
+  var fetchHandler = null;
+
+  // -------------------------------------------------------------------------
+  // Events
+  // -------------------------------------------------------------------------
+
+  function makeEvent(type, opts) {
+    return {
+      type: type,
+      target: null,
+      currentTarget: null,
+      eventPhase: '',
+      cancelable: !opts || opts.cancelable !== false,
+      defaultPrevented: false,
+      preventDefault: function () { this.defaultPrevented = true; }
+    };
+  }
+
+  function addEventTarget(obj) {
+    obj._listeners = { capture: {}, bubble: {} };
+    obj.addEventListener = function (type, fn, capture) {
+      var bag = capture ? this._listeners.capture : this._listeners.bubble;
+      (bag[type] = bag[type] || []).push(fn);
+    };
+    obj.removeEventListener = function () {};
+    obj.dispatchEvent = function (evt) {
+      evt.target = this;
+      // Path from the target's parent up to (and including) document.
+      var path = [];
+      var n = this.parentNode;
+      while (n) { path.push(n); n = n.parentNode; }
+      function run(node, bag) {
+        var list = bag[evt.type];
+        if (!list) return;
+        list = list.slice();
+        for (var i = 0; i < list.length; i++) list[i].call(node, evt);
+      }
+      // Capture: outermost ancestor (document) down to the parent.
+      evt.eventPhase = 'capture';
+      for (var i = path.length - 1; i >= 0; i--) {
+        evt.currentTarget = path[i];
+        run(path[i], path[i]._listeners.capture);
+      }
+      // Target: both listener kinds.
+      evt.eventPhase = 'target';
+      evt.currentTarget = this;
+      run(this, this._listeners.capture);
+      run(this, this._listeners.bubble);
+      // Bubble: parent up to document.
+      evt.eventPhase = 'bubble';
+      for (var j = 0; j < path.length; j++) {
+        evt.currentTarget = path[j];
+        run(path[j], path[j]._listeners.bubble);
+      }
+      return !evt.defaultPrevented;
+    };
+  }
+
+  // -------------------------------------------------------------------------
+  // Selector matching (tag / .class / [attr] / [attr=v] / [attr~=v] / *)
+  // -------------------------------------------------------------------------
+
+  function parseComplex(sel) {
+    var preds = [];
+    var s = sel.replace(/^\s+|\s+$/g, '');
+    while (s) {
+      var m;
+      if (s.charAt(0) === '*') {
+        preds.push(function () { return true; });
+        s = s.slice(1);
+      } else if ((m = s.match(/^([a-zA-Z][a-zA-Z0-9]*)/))) {
+        var tag = m[1].toUpperCase();
+        preds.push(function (el) { return el.tagName === tag; });
+        s = s.slice(m[1].length);
+      } else if ((m = s.match(/^\.([a-zA-Z0-9_-]+)/))) {
+        var cls = m[1];
+        preds.push(function (el) { return hasClass(el, cls); });
+        s = s.slice(m[0].length);
+      } else if ((m = s.match(/^\[([a-zA-Z0-9_-]+)(?:([~^|*]?=)("([^"]*)"|'([^']*)'|([^\]\s]+)))?\]/))) {
+        var name = m[1], op = m[2] || null, val = m[4] != null ? m[4] : (m[5] != null ? m[5] : m[6]);
+        preds.push(function (el) {
+          var cur = el.getAttribute(name);
+          if (cur === null) return false;
+          if (!op) return true;
+          if (op === '=') return cur === val;
+          if (op === '~=') return (' ' + cur + ' ').split(/\s+/).indexOf(val) !== -1;
+          if (op === '^=') return cur.indexOf(val) === 0;
+          if (op === '$=') return cur.indexOf(val) === cur.length - val.length;
+          if (op === '*=') return cur.indexOf(val) !== -1;
+          if (op === '|=') return cur === val || cur.indexOf(val + '-') === 0;
+          return false;
+        });
+        s = s.slice(m[0].length);
+      } else {
+        return null; // unsupported selector: matches nothing
+      }
+      s = s.replace(/^\s+/, '');
+    }
+    return preds.length ? preds : null;
+  }
+
+  function matches(el, complex) {
+    for (var i = 0; i < complex.length; i++) {
+      if (!complex[i](el)) return false;
+    }
+    return true;
+  }
+
+  function descendants(root, out) {
+    var kids = root.children || [];
+    for (var i = 0; i < kids.length; i++) {
+      out.push(kids[i]);
+      descendants(kids[i], out);
+    }
+    return out;
+  }
+
+  function qsa(root, selector) {
+    var complexes = String(selector).split(',').map(function (c) { return parseComplex(c); });
+    var all = descendants(root, []);
+    var hits = [];
+    for (var i = 0; i < all.length; i++) {
+      for (var j = 0; j < complexes.length; j++) {
+        if (complexes[j] && matches(all[i], complexes[j])) { hits.push(all[i]); break; }
+      }
+    }
+    return hits;
+  }
+
+  // -------------------------------------------------------------------------
+  // Elements
+  // -------------------------------------------------------------------------
+
+  function hasClass(el, cls) {
+    var cur = el.getAttribute('class') || '';
+    return cur.split(/\s+/).indexOf(cls) !== -1;
+  }
+
+  function makeElement(tag) {
+    if (String(tag || '').toLowerCase() === 'form') return makeForm();
+    var el = {
+      nodeType: 1,
+      tagName: (tag || 'div').toUpperCase(),
+      /** Ordered {name, value} pairs; re-setting keeps the original slot. */
+      attributes: [],
+      children: [],
+      parentNode: null,
+      innerHTML: '',
+      textContent: '',
+      style: {},
+      value: undefined,
+      checked: undefined,
+      selected: undefined
+    };
+
+    // ----- attributes -----
+    el.setAttribute = function (n, v) {
+      var hit = null;
+      for (var i = 0; i < this.attributes.length; i++) {
+        if (this.attributes[i].name === n) { hit = this.attributes[i]; break; }
+      }
+      if (hit) hit.value = String(v);
+      else this.attributes.push({ name: n, value: String(v) });
+    };
+    el.removeAttribute = function (n) {
+      this.attributes = this.attributes.filter(function (a) { return a.name !== n; });
+    };
+    el.hasAttribute = function (n) {
+      for (var i = 0; i < this.attributes.length; i++) {
+        if (this.attributes[i].name === n) return true;
+      }
+      return false;
+    };
+    el.getAttribute = function (n) {
+      for (var i = 0; i < this.attributes.length; i++) {
+        if (this.attributes[i].name === n) return this.attributes[i].value;
+      }
+      return null;
+    };
+
+    // ----- classList (backed by the `class` attribute) -----
+    el.classList = {
+      add: function (c) {
+        var cur = el.getAttribute('class') || '';
+        var parts = cur ? cur.split(/\s+/).filter(Boolean) : [];
+        if (parts.indexOf(c) === -1) { parts.push(c); el.setAttribute('class', parts.join(' ')); }
+      },
+      remove: function (c) {
+        var cur = el.getAttribute('class') || '';
+        var parts = cur ? cur.split(/\s+/).filter(function (p) { return p && p !== c; }) : [];
+        if (parts.length) el.setAttribute('class', parts.join(' '));
+        else el.removeAttribute('class');
+      },
+      contains: function (c) { return hasClass(el, c); }
+    };
+
+    // ----- tree -----
+    el.appendChild = function (c) {
+      if (c.parentNode) c.parentNode.removeChild(c);
+      c.parentNode = this;
+      this.children.push(c);
+      return c;
+    };
+    el.insertBefore = function (c, ref) {
+      if (c.parentNode) c.parentNode.removeChild(c);
+      c.parentNode = this;
+      var i = ref ? this.children.indexOf(ref) : this.children.length;
+      if (i < 0) i = this.children.length;
+      this.children.splice(i, 0, c);
+      return c;
+    };
+    el.removeChild = function (c) {
+      var i = this.children.indexOf(c);
+      if (i >= 0) this.children.splice(i, 1);
+      c.parentNode = null;
+      return c;
+    };
+    el.remove = function () { if (this.parentNode) this.parentNode.removeChild(this); };
+    Object.defineProperty(el, 'nextElementSibling', {
+      get: function () {
+        if (!this.parentNode) return null;
+        var i = this.parentNode.children.indexOf(this);
+        return this.parentNode.children[i + 1] || null;
+      }
+    });
+    Object.defineProperty(el, 'nextSibling', {
+      get: function () { return this.nextElementSibling; }
+    });
+    el.contains = function (other) {
+      while (other) { if (other === this) return true; other = other.parentNode; }
+      return false;
+    };
+    Object.defineProperty(el, 'firstChild', { get: function () { return this.children[0] || null; } });
+    el.cloneNode = function (deep) {
+      var copy = makeElement(this.tagName);
+      copy.attributes = this.attributes.map(function (a) { return { name: a.name, value: a.value }; });
+      copy.innerHTML = this.innerHTML;
+      if (this.textContent !== undefined && this.textContent !== '') copy.textContent = this.textContent;
+      // value/checked/selected are live state, not attributes: NOT copied
+      // (fresh controls read as clean, exactly like the browser).
+      if (deep) {
+        for (var i = 0; i < this.children.length; i++) copy.appendChild(this.children[i].cloneNode(true));
+      }
+      return copy;
+    };
+    el.querySelectorAll = function (sel) { return qsa(this, sel); };
+    el.querySelector = function (sel) { return qsa(this, sel)[0] || null; };
+    el.closest = function (sel) {
+      var complexes = String(sel).split(',').map(function (c) { return parseComplex(c); });
+      var n = this;
+      while (n && n.nodeType === 1) {
+        for (var j = 0; j < complexes.length; j++) {
+          if (complexes[j] && matches(n, complexes[j])) return n;
+        }
+        n = n.parentNode;
+      }
+      return null;
+    };
+    Object.defineProperty(el, 'form', {
+      get: function () {
+        var n = this.parentNode;
+        while (n) { if (n.tagName === 'FORM') return n; n = n.parentNode; }
+        return null;
+      }
+    });
+
+    // ----- control-type property semantics -----
+    if (el.tagName === 'INPUT') {
+      var live = { value: false, checked: false, _v: '', _c: false };
+      Object.defineProperty(el, 'type', {
+        get: function () { return this.getAttribute('type') || 'text'; },
+        set: function (v) { this.setAttribute('type', v); }
+      });
+      Object.defineProperty(el, 'value', {
+        get: function () {
+          if (live.value) return live._v;
+          var a = this.getAttribute('value');
+          return a !== null ? a : '';
+        },
+        set: function (v) { live.value = true; live._v = v; }
+      });
+      Object.defineProperty(el, 'checked', {
+        get: function () { return live.checked ? live._c : this.hasAttribute('checked'); },
+        set: function (v) { live.checked = true; live._c = !!v; }
+      });
+    } else if (el.tagName === 'TEXTAREA') {
+      var ta = { value: false, _v: '' };
+      Object.defineProperty(el, 'value', {
+        get: function () { return ta.value ? ta._v : (this.textContent || ''); },
+        set: function (v) { ta.value = true; ta._v = v; }
+      });
+    } else if (el.tagName === 'SELECT') {
+      Object.defineProperty(el, 'type', {
+        get: function () { return this.multiple ? 'select-multiple' : 'select-one'; }
+      });
+      Object.defineProperty(el, 'multiple', {
+        get: function () { return this.hasAttribute('multiple'); },
+        set: function (v) { if (v) this.setAttribute('multiple', ''); else this.removeAttribute('multiple'); }
+      });
+      Object.defineProperty(el, 'value', {
+        get: function () {
+          var opts = qsa(this, 'option');
+          for (var i = 0; i < opts.length; i++) {
+            if (opts[i].selected) return opts[i].value;
+          }
+          return '';
+        },
+        set: function (v) {
+          var opts = qsa(this, 'option');
+          var s = v == null ? '' : String(v);
+          for (var i = 0; i < opts.length; i++) opts[i].selected = opts[i].value === s;
+        }
+      });
+    } else if (el.tagName === 'OPTION') {
+      Object.defineProperty(el, 'value', {
+        get: function () {
+          var a = this.getAttribute('value');
+          return a !== null ? a : (this.textContent || '');
+        },
+        set: function (v) { this.setAttribute('value', v); }
+      });
+    }
+
+    addEventTarget(el);
+    return el;
+  }
+
+  /** Restore a form's controls to their attribute-declared defaults. */
+  function restoreDefaults(form) {
+    var controls = qsa(form, 'input,select,textarea');
+    for (var i = 0; i < controls.length; i++) {
+      var c = controls[i];
+      if (c.tagName === 'INPUT') {
+        var t = c.getAttribute('type') || 'text';
+        if (t === 'checkbox' || t === 'radio') {
+          c.checked = c.hasAttribute('checked');
+        } else if (t !== 'file') {
+          c.value = c.getAttribute('value') !== null ? c.getAttribute('value') : '';
+        }
+      } else if (c.tagName === 'TEXTAREA') {
+        c.value = c.getAttribute('value') !== null ? c.getAttribute('value') : (c.textContent || '');
+      } else if (c.tagName === 'SELECT') {
+        var opts = qsa(c, 'option');
+        for (var j = 0; j < opts.length; j++) opts[j].selected = opts[j].hasAttribute('selected');
+      }
+    }
+  }
+
+  function makeForm() {
+    var f = makeElement('_form'); // generic path; '_form' is not special-cased
+    f.tagName = 'FORM';
+    f.reset = function () {
+      var evt = makeEvent('reset');
+      this.dispatchEvent(evt);
+      if (evt.defaultPrevented) return;
+      restoreDefaults(this);
+    };
+    f.submit = function () {};
+    return f;
+  }
+
+  // -------------------------------------------------------------------------
+  // FormData / Headers / URLSearchParams
+  // -------------------------------------------------------------------------
+
+  function FormData(form) {
+    this._pairs = [];
+    if (!form) return;
+    var controls = qsa(form, 'input,select,textarea');
+    for (var i = 0; i < controls.length; i++) {
+      var c = controls[i];
+      var name = c.getAttribute('name');
+      if (!name) continue;
+      if (c.tagName === 'INPUT') {
+        var t = c.getAttribute('type') || 'text';
+        if (t === 'checkbox' || t === 'radio') { if (c.checked) this._pairs.push([name, c.value == null ? '' : String(c.value)]); }
+        else if (t === 'file') { /* no file state in the shim */ }
+        else this._pairs.push([name, c.value == null ? '' : String(c.value)]);
+      } else if (c.tagName === 'TEXTAREA') {
+        this._pairs.push([name, c.value == null ? '' : String(c.value)]);
+      } else if (c.tagName === 'SELECT') {
+        var opts = qsa(c, 'option');
+        for (var j = 0; j < opts.length; j++) {
+          if (opts[j].selected) this._pairs.push([name, opts[j].value]);
+        }
+      }
+    }
+  }
+  FormData.prototype.forEach = function (fn) {
+    for (var i = 0; i < this._pairs.length; i++) fn(this._pairs[i][1], this._pairs[i][0]);
+  };
+
+  /** Minimal File stand-in so `value instanceof File` checks in core pass. */
+  function File() {}
+  globalThis.File = File;
+
+  function Headers(init) {
+    this._h = {};
+    if (init && typeof init.forEach === 'function') {
+      var self = this;
+      init.forEach(function (v, k) { self._h[String(k).toLowerCase()] = String(v); });
+    }
+  }
+  Headers.prototype.set = function (n, v) { this._h[String(n).toLowerCase()] = String(v); };
+  Headers.prototype.append = function (n, v) {
+    var k = String(n).toLowerCase();
+    this._h[k] = this._h[k] ? this._h[k] + ', ' + v : String(v);
+  };
+  Headers.prototype.get = function (n) {
+    var k = Object.keys(this._h).filter(function (x) { return x === String(n).toLowerCase(); })[0];
+    return k !== undefined ? this._h[k] : null;
+  };
+
+  function URLSearchParams(params) {
+    this._pairs = [];
+    if (params && typeof params === 'object') {
+      var self = this;
+      Object.keys(params).forEach(function (k) {
+        var v = params[k];
+        self._pairs.push([k, v == null ? '' : String(v)]);
+      });
+    }
+  }
+  URLSearchParams.prototype.toString = function () {
+    return this._pairs.map(function (p) {
+      return encodeURIComponent(p[0]) + '=' + encodeURIComponent(p[1]);
+    }).join('&');
+  };
+
+  function makeStorage() {
+    var m = {};
+    return {
+      get length() { return Object.keys(m).length; },
+      key: function (i) { return Object.keys(m)[i] || null; },
+      getItem: function (k) { return k in m ? m[k] : null; },
+      setItem: function (k, v) { m[k] = String(v); },
+      removeItem: function (k) { delete m[k]; }
+    };
+  }
+
+  // -------------------------------------------------------------------------
+  // Globals
+  // -------------------------------------------------------------------------
+
+  globalThis.console = {
+    log: function () { log.messages.push(['log', Array.prototype.slice.call(arguments)]); },
+    info: function () { log.messages.push(['info', Array.prototype.slice.call(arguments)]); },
+    debug: function () { log.messages.push(['debug', Array.prototype.slice.call(arguments)]); },
+    warn: function () {
+      log.warns.push(Array.prototype.slice.call(arguments).join(' '));
+      log.messages.push(['warn', Array.prototype.slice.call(arguments)]);
+    },
+    error: function () {
+      log.errors.push(Array.prototype.slice.call(arguments).join(' '));
+      log.messages.push(['error', Array.prototype.slice.call(arguments)]);
+    }
+  };
+
+  var body = makeElement('body');
+  var documentElement = makeElement('html');
+  documentElement.appendChild(body);
+  var doc = {
+    nodeType: 9,
+    readyState: 'loading',
+    cookie: '',
+    body: body,
+    head: makeElement('head'),
+    documentElement: documentElement,
+    createElement: function (tag) { return tag.toLowerCase() === 'form' ? makeForm() : makeElement(tag); },
+    createTextNode: function (t) { return { nodeType: 3, textContent: t }; },
+    getElementById: function (id) {
+      return qsa(documentElement, '*').filter(function (el) { return el.getAttribute('id') === id; })[0] || null;
+    },
+    querySelectorAll: function (sel) { return qsa(documentElement, sel); },
+    querySelector: function (sel) { return qsa(documentElement, sel)[0] || null; }
+  };
+  addEventTarget(doc);
+  globalThis.document = doc;
+  // Event propagation continues through the document node (browsers run
+  // document-level capture listeners before the target), so the tree root's
+  // parent IS the document. qsa/closest must stop at element boundaries.
+  documentElement.parentNode = doc;
+
+  globalThis.window = globalThis;
+  globalThis.location = { href: 'http://localhost/', protocol: 'http:', addEventListener: function () {} };
+  globalThis.navigator = { userAgent: 'qjs' };
+  globalThis.addEventListener = function () {};
+  globalThis.removeEventListener = function () {};
+  globalThis.alert = function () {};
+  globalThis.confirm = function () { return true; };
+  globalThis.Headers = Headers;
+  globalThis.URLSearchParams = URLSearchParams;
+  globalThis.FormData = FormData;
+  globalThis.localStorage = makeStorage();
+  globalThis.sessionStorage = makeStorage();
+  globalThis.TextEncoder = function () {
+    this.encode = function (s) {
+      var out = [];
+      for (var i = 0; i < s.length; i++) out.push(s.charCodeAt(i) & 0xff);
+      return out;
+    };
+  };
+  globalThis.btoa = function (s) {
+    var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+    var out = '';
+    for (var i = 0; i < s.length; i += 3) {
+      var b1 = s.charCodeAt(i), b2 = s.charCodeAt(i + 1), b3 = s.charCodeAt(i + 2);
+      out += chars.charAt(b1 >> 2);
+      out += chars.charAt(((b1 & 3) << 4) | ((b2 || 0) >> 4));
+      out += b2 !== undefined ? chars.charAt(((b2 & 15) << 2) | ((b3 || 0) >> 6)) : '=';
+      out += b3 !== undefined ? chars.charAt(b3 & 63) : '=';
+    }
+    return out;
+  };
+
+  /** Canned responses for the fetch stub. */
+  function okResponse(directives) {
+    return {
+      ok: true,
+      status: 200,
+      headers: { get: function (n) { return String(n).toLowerCase() === 'content-type' ? 'application/vnd.statum+json' : null; } },
+      json: function () { return Promise.resolve(directives || []); }
+    };
+  }
+  function failResponse(status) {
+    return {
+      ok: false,
+      status: status || 500,
+      headers: { get: function () { return null; } },
+      json: function () { return Promise.resolve([]); }
+    };
+  }
+  globalThis.fetch = function (url, init) {
+    fetchCalls.push({
+      url: String(url),
+      method: (init && init.method) || 'GET',
+      body: init && init.body
+    });
+    var res = fetchHandler
+      ? fetchHandler(String(url), init || {})
+      : okResponse([]);
+    return Promise.resolve(res);
+  };
+
+  globalThis.__shim = {
+    log: log,
+    body: body,
+    makeElement: makeElement,
+    makeEvent: makeEvent,
+    fetchCalls: fetchCalls,
+    setFetch: function (fn) { fetchHandler = fn; },
+    okResponse: okResponse,
+    failResponse: failResponse
+  };
+  globalThis.__STATUM_TEST__ = true;
+}