Преглед изворни кода

Rearchitect the Statum client: headless core, forms folded into document, mirrored success/error handling

Core (js/statum-core.js) no longer touches the DOM at all: state store,
persistence, session cookie, listeners/broadcast, slot headers and
freshness, entrypoint/action requests, directive dispatch (incl. reenter),
SSE/SharedWorker realtime and the alert/confirm/notify override mechanism
stay; the body-attribute config reads, injectHiddenStyle,
warnServerSideMarkup, setBootState, evaluateGuards, applyErrorClasses, the
attribute-handler registry and statum.registerAttribute moved to
statum-document.js. Document installs itself through four bridge hooks
(renderDriver, setBootState, afterRenderCycle, applyErrorClasses) that
default to no-ops in core, so core alone boots headless and remains a
usable state layer for non-document frameworks (endpoint config via
statum.config before init). Registrations against core alone are queued and
drained when document loads, so core+markdown still works.

statum-forms.js is deleted: stm-prefill, the dirty WeakSet (still on the
bridge for task 186), the capture listeners and stm-clear-on-success moved
verbatim into statum-document.js; the public API is statum.document.{reset,
markDirty,isDirty} and statum.forms is gone.

Mirrored outcomes: stm-on-success classes on action elements (a success
clears error classes, a failure clears success classes, a new attempt
clears both), and stm-clear-on-error resets a form when its own stm-action
fails for real (HTTP error or error directive) — a declined stm-confirm is
cancellation and never clears (tracked with a cancelled outcome flag
alongside the existing failure flag).

Build and delivery: src/meson.build and Statum.vala embed three module
resources (ClientForms deleted); mkpstm expands the legacy statum.js
reference into the three ordered tags (binding module to be added when it
lands); frontend-demo serves and loads three modules, the profile form
gains stm-on-success and a stm-clear-on-error demo backed by a new
/api/profile/fail endpoint.

Tests: split suite covers headless core (no registerAttribute, stubbed-fetch
boot, queued registrations) and the pinned order (stm-prefill first); dom
suite ports the API rename and adds stm-on-success lifecycle, error/success
mirroring, stm-clear-on-error, declined-confirm no-clear and a headless
core-alone scenario (24 scenarios). Markdown suite unchanged at 96.4%.

Docs: WHATS_NEW.md rewritten; TEMPLATES.md, model.md, getting-started.md
and the statum-issues S-4 note updated for the three-module set.
saddle-ClankerSaddle пре 2 недеља
родитељ
комит
f2718a11af

+ 28 - 15
TEMPLATES.md

@@ -3,8 +3,8 @@
 This document is the reference for every tag, attribute and expression form
 that is special to Statum: the `pstm-*` preprocessor syntax handled at build
 time by `statum-mkpstm`, and the `stm-*` attributes handled at runtime by the
-four browser modules (`statum-core.js`, `statum-document.js`,
-`statum-markdown.js`, `statum-forms.js`).
+browser modules (`statum-core.js`, `statum-document.js`,
+`statum-markdown.js`).
 
 Audience: a developer writing Statum pages. For the wire protocol, directives
 and the Vala API see `model.md`; for a tutorial see `getting-started.md`.
@@ -97,26 +97,25 @@ Rewrites to `{attr}="/_statum/resource/{value}"`. `<script
 pstm-res-src="statum-core.js">` becomes `<script
 src="/_statum/resource/statum-core.js">`. Works for any attribute name
 (`src`, `href`, ...). The named resource must be registered server-side
-(`StatumConfigurator.add_resource` or the four default client modules).
+(`StatumConfigurator.add_resource` or the three default client modules).
 
 ### The legacy `statum.js` expansion (script tags)
 
 A `<script pstm-res-src="statum.js">` — the pre-split single-file client —
-expands at build time into the four module tags in canonical load order:
+expands at build time into the module tags in canonical load order:
 
 ```html
 <script src="/_statum/resource/statum-core.js"></script>
 <script src="/_statum/resource/statum-document.js"></script>
 <script src="/_statum/resource/statum-markdown.js"></script>
-<script src="/_statum/resource/statum-forms.js"></script>
 ```
 
 One build warning per page names the page and the expansion. Pages that
 reference module files directly are untouched and unwarned. A page mixing
 the legacy reference with explicit module tags is deduplicated — each module
 appears exactly once, in canonical order — and the warning says so. Plain
-`src="…"` scripts are never touched. In new pages, write the four tags
-explicitly (see Part II for the load contract).
+`src="…"` scripts are never touched. In new pages, write the three tags
+explicitly (see Part III for the load contract).
 
 ### What never reaches the browser
 
@@ -277,9 +276,10 @@ bindings:
   **`statum.markdown.renderTo(el, src)`** — same code path as the binding.
 
 
-### Section: statum-forms.js
+### Section: statum-document.js (form bindings)
 
-Form-state bindings:
+Form-state bindings (hosted in the document module since the former
+`statum-forms.js` was folded into it):
 
 - **`stm-prefill`**: writes an expression into form controls as PROPERTIES
   every cycle: text-like inputs + textarea → `.value` (null → `''`);
@@ -296,13 +296,26 @@ Form-state bindings:
   the form's action promise resolves successfully, reset exactly THAT form
   (values back to defaults, un-dirtied). On failure the fields stay. A form
   carrying it without `stm-action` warns once.
-- Programmatic: **`statum.forms.reset(formOrEl)`**, **`statum.forms.markDirty(el)`**,
-  **`statum.forms.isDirty(el)`**.
+- **`stm-clear-on-error`** (on a `<form>` with its own `stm-action`): the
+  mirror — when the form's own action FAILS (HTTP error or `error`
+  directive), reset exactly that form. A DECLINED `stm-confirm` does not
+  fire it: decline is cancellation, not error. A form carrying it without
+  `stm-action` warns once.
+- **`stm-on-success`** (on an action element): classes applied when the
+  action succeeds — the mirror of `stm-on-error`. The two are mirrored: a
+  success clears the error classes, a failure clears the success classes,
+  and a new attempt clears both at its start.
+- Programmatic: **`statum.document.reset(formOrEl)`**,
+  **`statum.document.markDirty(el)`**, **`statum.document.isDirty(el)`**.
 
 
 ## Part III — Quick reference: load order and cherry-picking
 
-Canonical: core → document → markdown → forms. Cherry-pick trailing modules
-only. Without markdown: no `stm-markdown`, no `stm-h-demotion`. Without
-forms: no `stm-prefill`, no `stm-clear-on-success`, no `statum.forms`. All
-modules assume core; loading any module first logs an error and does nothing.
+Canonical: core → document → markdown. Cherry-pick trailing modules only.
+Without markdown: no `stm-markdown`, no `stm-h-demotion`. Core alone is
+HEADLESS — it applies state and directives with no DOM at all (point
+`statum.config` at your endpoints before init), so a non-document templating
+framework can use it as its state layer. Without document there is no render
+walk, no `stm-*` bindings, no `statum.registerAttribute` (it is document
+API), no `statum.document`, no boot visibility, no guards. All modules assume
+core; loading any module first logs an error and does nothing.

+ 80 - 43
WHATS_NEW.md

@@ -1,38 +1,50 @@
-# What's new — the Statum client is now four modules
+# What's new — headless core, three modules, mirrored form outcomes
 
-The single-file client (`js/statum.js`, served as
-`/_statum/resource/statum.js`) is gone. The browser client now ships as four
-ES5, dependency-free scripts with a strict load order:
+The browser client now ships as three ES5, dependency-free scripts with a
+strict load order — and **core is headless**:
 
 ```html
 <script src="/_statum/resource/statum-core.js"></script>
 <script src="/_statum/resource/statum-document.js"></script>
 <script src="/_statum/resource/statum-markdown.js"></script>
-<script src="/_statum/resource/statum-forms.js"></script>
 ```
 
 **Load order is a contract.** `statum-core.js` must be first: it owns the slot
-protocol and installs the seams (`statum.registerAttribute`, the render
-driver, the internal bridge) that every later module registers into. The
-canonical order is core → document → markdown → forms.
-
-You may cherry-pick trailing modules (core first, always): core + document
-gives you the full state-binding walk (`stm-text`, `stm-html`, `stm-if`,
-`stm-for-…-in`, `stm-action`, …) with no markdown engine and no form extras.
-Note that `stm-markdown` *and* `stm-h-demotion` live in the markdown module,
-so a core+document page sees those attributes do nothing — remove them, or
-load the markdown module.
-
-Legacy pages that still say `<script pstm-res-src="statum.js"></script>`
-keep working: `statum-mkpstm` expands that reference at build time into the
-four tags above (one build warning per page names the expansion). A page
-mixing the legacy reference with explicit module tags is deduplicated — each
-module appears exactly once, in canonical order — and the warning says so.
-The single-file artifact itself no longer exists; in new pages, just write
-the four tags.
-
-Depth on all of this: `model.md` (wire protocol, directives, every
-attribute) and `getting-started.md` (tutorial + full JS API).
+protocol (state store, persistence, directives, SSE/SharedWorker realtime,
+entrypoint/action requests) and creates `window.statum`. The canonical order is
+core → document → markdown. The former fourth module, `statum-forms.js`, was
+folded into `statum-document.js` (and its resource now 404s).
+
+## Core is headless — usable without any DOM
+
+`statum-core.js` no longer touches the DOM at all. Everything visual — the
+render walk, the attribute registry, boot visibility, `stm-guard` links,
+error/success classes, form bindings — lives in `statum-document.js`, which
+installs itself into core through internal hooks. Core alone boots, fetches
+the entrypoint and applies directives to state, so it works as a state layer
+for non-document templating frameworks (or plain JS):
+
+```html
+<script src="/statum-core.js"></script>
+<script>
+  statum.config.entrypointUrl = '/api/entrypoint'; // before init runs
+  statum.addStateListener('profile', function (ev) { renderYourWay(ev); });
+</script>
+```
+
+Endpoint config still arrives from the page too: `statum-document.js` reads
+the `stm-entrypoint` / `stm-slots` / `stm-channel` / `stm-worker` `<body>`
+attributes into `statum.config` during its load, before core's init runs.
+Cherry-picking still works (core first, always): a headless page loads only
+core; a page without markdown loads core+document.
+
+Legacy pages that still say `<script pstm-res-src="statum.js"></script>` keep
+working: `statum-mkpstm` expands that reference at build time into the three
+tags above (one build warning per page names the expansion; explicit module
+tags are deduplicated). The single-file artifact itself no longer exists.
+
+Depth on all of this: `model.md` (wire protocol, directives, every attribute)
+and `getting-started.md` (tutorial + full JS API).
 
 ## stm-prefill — state into controls, without clobbering edits
 
@@ -66,41 +78,56 @@ The control matrix:
 **Dirty tracking is the point.** The moment a user types in or changes a
 control (capture-phase `input`/`change` listeners), it is *dirty* and
 stm-prefill never overwrites it again — the edit outranks the state
-snapshot. A form `reset` (a reset button, `statum.forms.reset`, or the
+snapshot. A form `reset` (a reset button, `statum.document.reset`, or the
 server's `form-reset` directive — all funnel through the same `reset` event)
 un-dirties the form's controls so state can re-apply. Because dirtiness is
-per-element (a `WeakSet`), a form inside a keyed loop instance keeps
-its dirty state across re-renders: the instance owns its nodes.
+per-element (a `WeakSet`), a form inside a keyed loop instance keeps its
+dirty state across re-renders: the instance owns its nodes.
 
-## stm-clear-on-success and statum.forms
+## Mirrored form outcomes: stm-clear-on-success, stm-clear-on-error, stm-on-success
 
-A form whose own `stm-action` succeeds can clear itself:
+A form whose own `stm-action` settles can clear itself on either outcome:
 
 ```html
 <form stm-action="profile.saveAction" stm-clear-on-success>…</form>
+<form stm-action="profile.retryAction"  stm-clear-on-error>…</form>
 ```
 
-The clear waits for the action's promise and resets the form only when it
-resolved (a failed action leaves the fields as they are). The reset also
-un-dirties the controls, so the next render cycle re-applies `stm-prefill`
-against the fresh state — and `stm-clear-on-success` is scoped to exactly
-this form; no other form on the page is touched.
+Both wait for the action's promise and read its outcome flag: success resets
+a `stm-clear-on-success` form, a real failure (HTTP error or `error`
+directive) resets a `stm-clear-on-error` form, and neither touches any other
+form on the page. **A declined `stm-confirm` never clears** — decline is
+cancellation, not error. The reset also un-dirties the controls, so the next
+render cycle re-applies `stm-prefill` against the fresh state.
 
-The `statum.forms` API:
+Action elements gained the success mirror of `stm-on-error`:
+
+```html
+<button stm-action="profile.saveAction" stm-on-success="ok" stm-on-error="error">
+  Save
+</button>
+```
+
+The two are mirrored: a success applies the success classes and clears the
+error classes; a failure does the reverse; a new attempt clears both at its
+start.
+
+The programmatic form API lives on `statum.document` now (`statum.forms` is
+gone — the module merge made it redundant):
 
 ```js
-statum.forms.reset(formOrElement); // reset the form owning the element
-statum.forms.markDirty(el);        // force an element dirty (prefill skips it)
-statum.forms.isDirty(el);          // query dirtiness
+statum.document.reset(formOrElement); // reset the form owning the element
+statum.document.markDirty(el);        // force an element dirty (prefill skips it)
+statum.document.isDirty(el);          // query dirtiness
 ```
 
 ## statum.markdown — the renderer as an API
 
-The CommonMark engine (unchanged, just moved) is now callable directly, on
-the same code path the `stm-markdown` binding uses:
+The CommonMark engine (unchanged, just moved) is callable directly, on the
+same code path the `stm-markdown` binding uses:
 
 ```js
-statum.markdown.render(src);   // → safe HTML string
+statum.markdown.render(src);       // → safe HTML string
 statum.markdown.renderTo(el, src); // replaces el's content
 ```
 
@@ -126,6 +153,10 @@ unregistered attribute warns and keeps registration order; handlers get no
 per-handler try/catch — a throwing handler aborts that render cycle, exactly
 like the built-ins.
 
+The registry is provided by `statum-document.js` (it *is* the walk's
+machinery). Loading a registering module against core alone is safe: core
+queues the registration until document loads.
+
 ## The reenter directive
 
 A response can ask the client to re-fetch the page's entrypoint for the
@@ -141,3 +172,9 @@ still wins if both appear), and the refresh is awaited — the originating
 action's promise (and so `stm-clear-on-success` and `statum.act`) resolves
 only after the re-entered state has been applied. A failed refresh is logged
 and swallowed.
+
+## Upcoming
+
+A dedicated binding module (beyond document's built-ins) is planned as a
+fourth script tag; when it lands, the expansion set and this file change
+again.

+ 19 - 4
frontend-demo/index.html

@@ -371,10 +371,13 @@
         has edited are dirty and skipped until the form resets.
         stm-clear-on-success resets this form when its own stm-action succeeds
         (the reset also un-dirties the controls, so the fresh prefill below can
-        re-apply). The stm-text line echoes the saved state back.
+        re-apply), and stm-on-success marks the submit button for the duration
+        of the next attempt — the success classes mirror stm-on-error (a
+        failure clears them and applies the error classes instead).
+        The stm-text line echoes the saved state back.
       -->
       <section>
-        <h2>Profile <span class="muted">(stm-prefill · dirty tracking · stm-clear-on-success)</span></h2>
+        <h2>Profile <span class="muted">(stm-prefill · dirty tracking · stm-clear-on-success · stm-on-success)</span></h2>
         <form stm-action="profile.saveAction" stm-clear-on-success>
           <p>
             <label>Name <input type="text" name="name" stm-prefill="profile.name"></label>
@@ -397,7 +400,7 @@
             <label><input type="radio" name="cadence" value="weekly" stm-prefill="profile.cadence"> Weekly</label>
             <label><input type="radio" name="cadence" value="never" stm-prefill="profile.cadence"> Never</label>
           </p>
-          <p><button type="submit">Save profile</button></p>
+          <p><button type="submit" stm-on-success="ok" stm-on-error="error">Save profile</button></p>
         </form>
         <p class="muted">
           Saved:
@@ -406,6 +409,19 @@
           · news <span stm-text="profile.news ? 'on' : 'off'">…</span>
           · digest <span stm-text="profile.cadence">…</span>
         </p>
+        <!--
+          stm-clear-on-error: the mirror of stm-clear-on-success. This form's
+          action always fails, so submitting it resets the form to its
+          defaults (and un-dirties it). A declined confirm would NOT clear —
+          decline is cancellation, not error — but this button has no
+          stm-confirm; try it with the DevTools confirm-cancelled case.
+        -->
+        <form stm-action="profile.failAction" stm-clear-on-error>
+          <p>
+            <label>Nickname <input type="text" name="nick" value="untyped"></label>
+          </p>
+          <p><button type="submit" stm-on-error="error">Try a failing save</button></p>
+        </form>
       </section>
 
       <!--
@@ -452,7 +468,6 @@
   <script src="/statum-core.js"></script>
   <script src="/statum-document.js"></script>
   <script src="/statum-markdown.js"></script>
-  <script src="/statum-forms.js"></script>
   <script>
     // Override the default UI handlers so `notify` directives (and errors)
     // render as styled toasts instead of using the browser's native alert().

+ 11 - 1
frontend-demo/server.php

@@ -249,6 +249,7 @@ function build_profile(): array
         'news'    => (bool)($p['news'] ?? true),
         'cadence' => (string)($p['cadence'] ?? 'weekly'),
         'saveAction' => ['uri' => '/api/profile/save', 'method' => 'POST'],
+        'failAction' => ['uri' => '/api/profile/fail', 'method' => 'POST'],
     ];
 }
 
@@ -379,7 +380,7 @@ $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
 
 // Static assets: the Statum client modules, in load order (core first — the
 // later modules register into the seams it installs).
-if (preg_match('#^/(statum-(core|document|markdown|forms))\.js$#', $path, $m)) {
+if (preg_match('#^/(statum-(core|document|markdown))\.js$#', $path, $m)) {
     header('Content-Type: application/javascript');
     readfile(__DIR__ . '/../js/' . $m[1] . '.js');
     exit;
@@ -586,6 +587,15 @@ if ($path === '/api/profile/save' && $method === 'POST') {
     ]);
 }
 
+// The profile fail action: always answers with an error directive, so the
+// demo can show stm-on-error (and, on the second form, stm-clear-on-error)
+// against a real Statum-shaped failure.
+if ($path === '/api/profile/fail' && $method === 'POST') {
+    statum_respond([
+        ['type' => 'error', 'message' => 'The profile could not be saved (by design)'],
+    ]);
+}
+
 if ($path === '/api/notice/set' && $method === 'POST') {
     $_SESSION['notice'] = '<div class="ok"><strong>Notice:</strong> updated at ' . date('H:i:s') . '</div>';
     statum_respond([set_dir(snap('notice'))]);

+ 12 - 10
getting-started.md

@@ -70,7 +70,6 @@ It provides:
     <script pstm-res-src="statum-core.js"></script>
     <script pstm-res-src="statum-document.js"></script>
     <script pstm-res-src="statum-markdown.js"></script>
-    <script pstm-res-src="statum-forms.js"></script>
 </head>
 <body>
     <header><h1>My App</h1></header>
@@ -144,7 +143,7 @@ All `pstm-*` elements **must appear inside `<head>`** (except `pstm-content` and
 | `<pstm-fragment name="card" />` | body | Inlines a fragment (component) by name. |
 | `pstm-materialise-as="div"` | `<body>` attr | When this body is composited into a template or fragment, it is wrapped in the named element. |
 | `pstm-res-src="logo.png"` | any attr | Rewrites to `src="/_statum/resource/logo.png"`. Also `pstm-res-href`, etc. |
-| `pstm-res-src="statum.js"` | `<script>` | **Legacy.** Expands at build time into the four client module tags in load order (one warning per page; explicit module tags are deduped). Prefer the four explicit tags. |
+| `pstm-res-src="statum.js"` | `<script>` | **Legacy.** Expands at build time into the three client module tags in load order (one warning per page; explicit module tags are deduped). Prefer the three explicit tags. |
 
 ### Head merging
 
@@ -367,22 +366,25 @@ No route is specified — a GUID is auto-assigned at registration.
 
 ## JS API Reference
 
-The Statum client is embedded in the library and served as four modules from
-`/_statum/resource/statum-core.js`, `statum-document.js`,
-`statum-markdown.js` and `statum-forms.js`. Include them in your template, in
-this order (core installs the seams the later modules register into):
+The Statum client is embedded in the library and served as three modules from
+`/_statum/resource/statum-core.js`, `statum-document.js` and
+`statum-markdown.js`. Include them in your template, in this order (core
+creates `window.statum`; document owns the render walk, the attribute
+registry and the form bindings; markdown adds the CommonMark engine):
 
 ```html
 <script pstm-res-src="statum-core.js"></script>
 <script pstm-res-src="statum-document.js"></script>
 <script pstm-res-src="statum-markdown.js"></script>
-<script pstm-res-src="statum-forms.js"></script>
 ```
 
 You may cherry-pick trailing modules (core first, always): core + document
-gives the full state-binding walk without the markdown engine or the form
-bindings — but note `stm-markdown` and `stm-h-demotion` live in the markdown
-module and do nothing without it.
+gives the full state-binding walk without the markdown engine — but note
+`stm-markdown` and `stm-h-demotion` live in the markdown module and do
+nothing without it. Core ALONE is headless: it never touches the DOM, so a
+non-document templating framework can use it as its state layer (write your
+endpoint URLs into `statum.config` before `statum.init()` runs and consume
+`statum.addStateListener`).
 
 A legacy `<script pstm-res-src="statum.js"></script>` still works:
 `statum-mkpstm` expands it at build time into the four tags above (one build

+ 157 - 294
js/statum-core.js

@@ -3,20 +3,40 @@
  *
  * An HTMX-inspired state layer. The library manages signed "snapshots" of
  * application state (one per slot type), keeps them fresh via the server's
- * `transmit_after` / `invalid_after` timestamps, and binds them to the DOM
- * through `stm-*` attributes. See model.md for the full specification.
+ * `transmit_after` / `invalid_after` timestamps, and ships them to the server
+ * with every request through the X-Statum-Slot machinery. See model.md for
+ * the full specification.
+ *
+ * CORE IS HEADLESS: it owns the slot protocol (state store, persistence, the
+ * request/directive machinery, the realtime channel) and the UI handler
+ * overrides, and NEVER touches the DOM. It therefore works alone — point
+ * `statum.config` at your endpoints and init() fetches the entrypoint and
+ * applies directives to state — which makes core a usable state layer for
+ * non-document templating frameworks.
+ *
+ * Everything DOM-related lives in statum-document.js, which installs four
+ * hooks through the module bridge:
+ *
+ *   bridge.setBootState(ready)  - stm-preloader / stm-content visibility
+ *   bridge.renderDriver(scope)  - the render walk (the whole DOM scan)
+ *   bridge.afterRenderCycle()   - stm-guard link evaluation (guards)
+ *   bridge.applyErrorClasses(el,on) - stm-on-error / stm-on-success classes
+ *
+ * All four default to no-ops, so headless pages lose nothing by running core
+ * alone. Endpoint overrides also arrive from document (it reads the
+ * `stm-entrypoint` / `stm-slots` / `stm-channel` / `stm-worker` body
+ * attributes into `statum.config`); init() uses config as given and never
+ * reads the DOM itself.
  *
  * The client ships as modules, each a plain IIFE with no dependencies and no
  * build step, loaded in this order:
  *
- *   1. statum-core.js     - this file: the slot protocol (persistence, the
- *                           X-Statum-Slot request machinery, directive
- *                           application, the realtime channel), the
- *                           alert/confirm/notify overrides, boot, and the
- *                           module seam (`statum._bridge` /
- *                           `statum.registerAttribute`). Self-sufficient;
- *                           creates `window.statum`.
- *   2. statum-document.js - the render walk and the structural/DOM bindings.
+ *   1. statum-core.js     - this file: creates `window.statum`.
+ *   2. statum-document.js - the render walk, the DOM bindings, the attribute
+ *                           registry (`statum.registerAttribute`) and the
+ *                           form bindings (`stm-prefill`,
+ *                           `stm-clear-on-success`, `stm-clear-on-error`,
+ *                           `statum.document`).
  *   3. statum-markdown.js - the CommonMark engine and `stm-markdown`.
  *
  * Targets modern evergreen browsers (uses fetch, BroadcastChannel,
@@ -44,8 +64,11 @@
   // ---------------------------------------------------------------------------
 
   /**
-   * Endpoint URLs. These defaults may be overridden per-page with the
-   * `stm-entrypoint` and `stm-slots` attributes on the `<body>` element.
+   * Endpoint URLs. Defaults; pages override them either by writing
+   * `statum.config` (headless pages do this before init runs) or with the
+   * `stm-entrypoint` / `stm-slots` / `stm-channel` / `stm-worker` attributes
+   * on the `<body>` element, which statum-document.js reads into this object
+   * during its own (synchronous) load. Core itself never reads the DOM.
    *
    * @type {{entrypointUrl: string, slotsUrl: string}}
    */
@@ -92,13 +115,12 @@
   /** Whether {@link init} has already run. */
   var initialized = false;
 
-  /** Whether the entrypoint has been fetched (guards without pre-entrypoint
-   *  only evaluate after this). */
+  /**
+   * Whether the entrypoint has been fetched. Post-entrypoint guard
+   * evaluation (statum-document.js) is gated on it through the bridge.
+   */
   var entrypointLoaded = false;
 
-  /** Set when a guard has triggered a redirect, so further work is skipped. */
-  var guardRedirected = false;
-
   // ---------------------------------------------------------------------------
   // Small utilities
   // ---------------------------------------------------------------------------
@@ -131,7 +153,7 @@
 
   /** Read a cookie value, or `null` if absent. @param {string} name */
   function getCookie(name) {
-    var escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
+    var escaped = name.replace(/([.$?*|{}()[\]\/+^])/g, '\\$1');
     var match = document.cookie.match(new RegExp('(?:^|; )' + escaped + '=([^;]*)'));
     return match ? decodeURIComponent(match[1]) : null;
   }
@@ -163,50 +185,55 @@
    * try/catch of the compiled expression still turns property access on a
    * resolved `undefined`/`null` into `undefined` for that expression alone.
    * @param {object} scope
-   * @returns {Proxy<object>}
+   * @returns {
+     {
+       get: function(string, *): *,
+       set: function(string, *): boolean,
+       has: function(string): boolean
+     }
+   }
    */
   function evalScope(scope) {
     return new Proxy(scope, {
-      has: function () { return true; },
       get: function (target, name) {
+        if (name === Symbol.unscopables) return undefined;
         if (name in target) return target[name];
-        if (typeof name === 'string' && name in globalThis) return globalThis[name];
+        if (name in globalThis) return globalThis[name];
         return undefined;
-      }
+      },
+      set: function (target, name, value) {
+        target[name] = value;
+        return true;
+      },
+      has: function () { return true; }
     });
   }
 
   /**
-   * Compile (and cache) an expression string into a function of `scope`.
-   * Uses `with` so that arbitrary type names and loop variables are resolved
-   * as bare identifiers. Returns a function that yields `undefined` on any
-   * runtime or compile error.
+   * Compile an expression once. The compiled form evaluates the expression
+   * source inside a `with` over an {@link evalScope} proxy, with an inner
+   * try/catch so a property access on an unset slot's `undefined` public data
+   * degrades to `undefined` for that expression instead of throwing.
    * @param {string} expr
-   * @returns {Function}
+   * @returns {function(object): *}
    */
   function compileExpr(expr) {
     var cached = exprCache.get(expr);
-    if (cached !== undefined) return cached;
-    var fn;
-    try {
-      // Function-constructor bodies are non-strict by default, so `with` is
-      // permitted. The inner try/catch turns runtime errors (property access
-      // on an unset slot's `undefined`) into `undefined` so a missing path
-      // simply yields no value; missing identifiers already resolve to
-      // `undefined` via the evalScope proxy.
-      fn = new Function('scope', 'with(scope){try{return (' + expr + ');}catch(e){return undefined;}}');
-    } catch (e) {
-      fn = function () { return undefined; };
-    }
+    if (cached) return cached;
+    var fn = new Function('scopeProxy', 'with (scopeProxy) { try { return (' +
+      expr + '); } catch (e) { return undefined; } }');
     exprCache.set(expr, fn);
     return fn;
   }
 
   /**
-   * Evaluate an expression against a scope.
+   * Evaluate an expression against a scope. An unset/empty expression and any
+   * parse error resolve to `undefined` (the "leave original DOM content"
+   * policy; no warning — an author may legitimately bind an expression that
+   * references a slot the page does not hold yet).
    * @param {string} expr
    * @param {object} [scope]
-   * @returns {*} `undefined` if the expression is empty or fails.
+   * @returns {*}
    */
   function evalExpr(expr, scope) {
     if (expr == null || expr === '') return undefined;
@@ -307,8 +334,9 @@
 
   /**
    * Surface a failure: run `triggerError`, and if the default is not
-   * suppressed, apply `stm-on-error` classes (when an origin element exists)
-   * and re-throw so the originating promise rejects.
+   * suppressed, apply the error-state classes (when an origin element exists;
+   * through the document-installed hook) and re-throw so the originating
+   * promise rejects.
    * @param {StatumError} error
    * @param {HTMLElement|null} element
    */
@@ -935,7 +963,6 @@
    * @returns {Promise<void>}
    */
   function loadEntrypoint() {
-    if (guardRedirected) return Promise.resolve();
     // From this point on guards are "post-entrypoint" (all of them evaluate).
     entrypointLoaded = true;
     var attempt = async function () {
@@ -1156,164 +1183,32 @@
   function channelUnsubscribe(key) { channelManager.unsubscribe(key); }
 
   // ---------------------------------------------------------------------------
-  // Error-state classes
-  // ---------------------------------------------------------------------------
-  // (Moved from the render-walk section in statum-document.js so that
-  // reportFailure above is self-contained without that module.)
-
-
-  /** Apply or remove `stm-on-error` classes on an action element. */
-  function applyErrorClasses(el, on) {
-    if (!el || !el.hasAttribute || !el.hasAttribute('stm-on-error')) return;
-    var classes = (el.getAttribute('stm-on-error') || '').split(/\s+/).filter(Boolean);
-    classes.forEach(function (c) { on ? el.classList.add(c) : el.classList.remove(c); });
-  }
-
-  // ---------------------------------------------------------------------------
-  // Module seam: attribute-handler registry + render driver
+  // Document seam: the hooks statum-document.js installs
   // ---------------------------------------------------------------------------
   //
-  // statum-core.js is self-sufficient: it owns the slot protocol, the UI
-  // handler overrides and the boot sequence, and never references the other
-  // modules. statum-document.js (the render walk) and statum-markdown.js
-  // (the CommonMark engine) load AFTER core and register through this seam.
-  //
-  // `_bridge` (underscore = private by convention, like `_testMarkdown`) is
-  // the internal cross-module surface; `statum.registerAttribute` is the
-  // public entry point module authors use.
+  // Core never touches the DOM, so everything visual — the boot visibility
+  // toggle, the render walk, the guard evaluation, the error-state classes —
+  // reaches the DOM through these hooks, defaulting to no-ops. With
+  // statum-document.js loaded every hook is real; without it core still boots,
+  // fetches the entrypoint and applies directives to state (headless use).
 
-  /**
-   * Resolved execution order of the per-element `stm-*` bindings: an ordered
-   * list of `{name, fn, prefix}`. The render walk (statum-document.js)
-   * iterates it for every element. A throwing handler aborts that render
-   * cycle through renderAll's try/catch, exactly as the previous hardcoded
-   * call chain did, so handlers get no per-handler try/catch here.
-   * @type {Array<{name: string, fn: Function, prefix: boolean}>}
-   */
-  var attributeHandlers = [];
+  /** @type {Function} stm-preloader / stm-content visibility; no-op headless. */
+  var setBootState = function () {};
 
-  /**
-   * Register a handler for an `stm-*` attribute.
-   *
-   * `name` is either an exact attribute name (`'stm-disabled'`) or, when it
-   * ends with a dot, a prefix matcher (`'stm-class.'` matches any
-   * `stm-class.{name}`). The handler is called once per element per render
-   * cycle when the element carries at least one matching attribute; it then
-   * iterates the element's attributes itself (several may match a prefix).
-   *
-   * `opts.after` / `opts.before` name another registered attribute and the
-   * handler is inserted directly after / before it. A constraint naming an
-   * unregistered attribute warns and keeps plain registration order, so
-   * loading a module early cannot break the page. Registering the same name
-   * twice warns and ignores the second handler.
-   *
-   * @param {string} name
-   * @param {Function} fn `fn(el, scope)`
-   * @param {{after?: string, before?: string}} [opts]
-   */
-  function registerAttribute(name, fn, opts) {
-    if (typeof name !== 'string' || !name || typeof fn !== 'function') {
-      console.warn('[statum] registerAttribute: expected an attribute name and a handler function');
-      return;
-    }
-    for (var i = 0; i < attributeHandlers.length; i++) {
-      if (attributeHandlers[i].name === name) {
-        console.warn('[statum] registerAttribute: "' + name + '" is already registered; ignoring');
-        return;
-      }
-    }
-    var entry = { name: name, fn: fn, prefix: name.charAt(name.length - 1) === '.' };
-    var anchor = opts && (opts.after || opts.before);
-    var at = anchor ? handlerIndex(anchor) : -1;
-    if (anchor && at === -1) {
-      console.warn('[statum] registerAttribute: "' + anchor +
-        '" is not registered; keeping registration order for "' + name + '"');
-    }
-    if (at !== -1 && opts.after) at += 1; // insert directly after the anchor
-    if (at === -1) attributeHandlers.push(entry);
-    else attributeHandlers.splice(at, 0, entry);
-  }
-
-  /** Position of a registered handler by name, or -1. */
-  function handlerIndex(name) {
-    for (var i = 0; i < attributeHandlers.length; i++) {
-      if (attributeHandlers[i].name === name) return i;
-    }
-    return -1;
-  }
-
-  /**
-   * The render driver: installed by statum-document.js at load time, called
-   * by renderAll to walk the document. When absent (core loaded alone)
-   * render cycles still run the evaluate hooks and guards but skip the DOM
-   * walk.
-   * @type {Function|null}
-   */
+  /** @type {Function} The render walk driver `fn(scope)`; absent headless. */
   var renderDriver = null;
 
-  /** Install the render driver. @param {Function} fn `fn(scope)` */
-  function installRenderDriver(fn) {
-    if (typeof fn === 'function') renderDriver = fn;
-  }
-
   /**
-   * The module bridge: everything the other modules need from core (and the
-   * one thing core needs from them, the render driver) crosses here. The
-   * evaluate hooks stay core-private on purpose — no other module needs
-   * them; modules register attribute handlers instead.
+   * @type {Function} Post-walk cycle hook `fn()`: stm-guard evaluation. Runs
+   * after the afterEvaluate hooks, exactly where evaluateGuards used to.
    */
-  var bridge = {
-    /** Expression machinery (core-owned). */
-    evalExpr: evalExpr,
-    childScope: childScope,
-    stateScope: stateScope,
-    /** Render cycle + requests. */
-    renderAll: renderAll,
-    loadEntrypoint: loadEntrypoint,
-    performAction: performAction,
-    /** UI handler overrides (runElementAction gates on confirm). */
-    triggerConfirm: triggerConfirm,
-    triggerNotify: triggerNotify,
-    triggerError: triggerError,
-    handlers: handlers,
-    /** Error-state classes for action elements. */
-    applyErrorClasses: applyErrorClasses,
-    /** The seam itself. */
-    registerAttribute: registerAttribute,
-    attributeHandlers: attributeHandlers,
-    installRenderDriver: installRenderDriver
-  };
-
+  var afterRenderCycle = function () {};
 
   /**
-   * Evaluate every `<link rel="stm-guard">` in the document head. A guard whose
-   * `require` predicate is false redirects the page to its `href`.
-   *
-   * Guards marked `pre-entrypoint` are evaluated on every cycle (including the
-   * initial, pre-entrypoint render, which uses only hydrated state); guards
-   * without it are evaluated only once the entrypoint has loaded. On every later
-   * evaluate cycle (set/clear) all guards are re-checked, so logging out, for
-   * example, can bounce the user off a guarded page.
+   * @type {Function} `fn(el, on)`: apply or clear the stm-on-error /
+   * stm-on-success classes on an action element. No-op headless.
    */
-  function evaluateGuards() {
-    if (guardRedirected) return;
-    var scope = stateScope();
-    var guards = document.querySelectorAll('link[rel~="stm-guard"]');
-    for (var i = 0; i < guards.length; i++) {
-      var g = guards[i];
-      var preEntrypoint = g.hasAttribute('pre-entrypoint');
-      if (!preEntrypoint && !entrypointLoaded) continue;
-      var require = g.getAttribute('require');
-      if (!require) continue;
-      if (!evalExpr(require, scope)) {
-        guardRedirected = true;
-        var href = g.getAttribute('href') || '/';
-        console.warn('[statum] guard failed (' + require + '); redirecting to ' + href);
-        window.location.href = href;
-        return;
-      }
-    }
-  }
+  var applyErrorClasses = function () {};
 
   // ---------------------------------------------------------------------------
   // Evaluation hooks (no-arg callbacks before/after each render cycle)
@@ -1322,46 +1217,25 @@
   var beforeHooks = [];
   var afterHooks = [];
 
-  /** Re-render the whole document against the current state. */
+  /**
+   * Run one render cycle: the beforeEvaluate hooks, the render walk (when a
+   * document module has installed its driver), the afterEvaluate hooks, then
+   * the document-side cycle hook (guard evaluation). Headless (no driver), the
+   * cycle still runs the hooks — they observe state changes, not DOM.
+   */
   function renderAll() {
-    if (guardRedirected) return;
     beforeHooks.forEach(function (fn) { try { fn(); } catch (e) { console.error('[statum] beforeEvaluate hook error', e); } });
     try {
       // The DOM walk lives in statum-document.js and is installed through
-      // the seam; without it the cycle still runs hooks and guards.
+      // the seam; without it the cycle still runs hooks and the cycle hook.
       if (renderDriver) renderDriver(stateScope());
     }
     catch (e) { console.error('[statum] render error', e); }
     afterHooks.forEach(function (fn) { try { fn(); } catch (e) { console.error('[statum] afterEvaluate hook error', e); } });
-    evaluateGuards();
-  }
-
-
-  // ---------------------------------------------------------------------------
-  // Boot visibility (stm-preloader / stm-content)
-  // ---------------------------------------------------------------------------
-
-  /**
-   * Toggle pre/post-entrypoint visibility with the `hidden` attribute. While
-   * loading, `stm-preloader` elements are shown and `stm-content` elements
-   * hidden; once ready, the reverse.
-   *
-   * To avoid a flash of un-bound template before the script runs, authors may
-   * pre-set `hidden` directly on `stm-content` in the HTML (e.g.
-   * `<div stm-content hidden>`); the library removes it once ready. The library
-   * also sets `hidden` itself during loading as a fallback for authors who do
-   * not pre-set it.
-   * @param {boolean} ready
-   */
-  function setBootState(ready) {
-    var preloaders = document.querySelectorAll('[stm-preloader]');
-    var contents = document.querySelectorAll('[stm-content]');
-    for (var i = 0; i < preloaders.length; i++) {
-      ready ? preloaders[i].setAttribute('hidden', '') : preloaders[i].removeAttribute('hidden');
-    }
-    for (var j = 0; j < contents.length; j++) {
-      ready ? contents[j].removeAttribute('hidden') : contents[j].setAttribute('hidden', '');
+    try {
+      afterRenderCycle();
     }
+    catch (e) { console.error('[statum] render cycle hook error', e); }
   }
 
   // ---------------------------------------------------------------------------
@@ -1450,73 +1324,20 @@
   // ---------------------------------------------------------------------------
 
   /**
-   * Inject the stylesheet that makes the `hidden` attribute authoritative.
-   * Statum toggles `stm-if`/`stm-else` branches and loop holders via
-   * `hidden`, whose user-agent rule loses specificity battles to ordinary
-   * author CSS (a rule like `li { display: flex }` let hidden loop holders
-   * leak as phantom rows). Idempotent: skipped when the stylesheet is
-   * already present.
-   */
-  function injectHiddenStyle() {
-    var id = 'statum-hidden-style';
-    if (document.getElementById(id)) return;
-    var head = document.head;
-    if (!head) return;
-    var style = document.createElement('style');
-    style.id = id;
-    style.textContent = '[hidden]{display:none !important}';
-    head.appendChild(style);
-  }
-
-  /**
-   * Scan the document for `pstm-*` attributes and `<pstm-*>` elements, which
-   * are intended for server-side pre-rendering and should never reach the
-   * browser. Emits a single `console.warn` summarizing them.
-   */
-  function warnServerSideMarkup() {
-    var found = [];
-    var els = document.querySelectorAll('*');
-    for (var i = 0; i < els.length; i++) {
-      var el = els[i];
-      var tag = el.tagName.toLowerCase();
-      if (tag.indexOf('pstm-') === 0) found.push('<' + tag + '>');
-      var attrs = el.attributes;
-      for (var j = 0; j < attrs.length; j++) {
-        var name = attrs[j].name;
-        if (name.indexOf('pstm-') === 0) found.push('<' + tag + '> ' + name);
-      }
-    }
-    if (found.length) {
-      console.warn('[statum] ' + found.length + ' server-side pstm-* attribute/element(s) ' +
-        'found in the DOM; they should have been resolved during pre-rendering: ' +
-        found.slice(0, 10).join(', ') + (found.length > 10 ? ' …' : ''));
-    }
-  }
-
-  /**
-   * Initialize Statum: read URL overrides, run the session-cookie check,
-   * hydrate persisted state, open the cross-tab channel, fetch the entrypoint,
-   * and render. Safe to call multiple times.
+   * Initialize Statum: run the session-cookie check, hydrate persisted state,
+   * open the cross-tab channel, fetch the entrypoint, and run render cycles.
+   * Safe to call multiple times.
+   *
+   * Headless by design: endpoint config comes from `statum.config` (pages —
+   * or statum-document.js, reading the `stm-*` body attributes — write it
+   * before init runs), and the boot-state toggle is the document hook. Nothing
+   * here scans the DOM.
    * @returns {Promise<void>}
    */
   async function init() {
     if (initialized) return;
     initialized = true;
 
-    // Before the first render and the boot visibility toggling, so `hidden`
-    // wins over author CSS from the very first paint.
-    injectHiddenStyle();
-
-    var body = document.body;
-    if (body) {
-      if (body.hasAttribute('stm-entrypoint')) config.entrypointUrl = body.getAttribute('stm-entrypoint');
-      if (body.hasAttribute('stm-slots')) config.slotsUrl = body.getAttribute('stm-slots');
-      if (body.hasAttribute('stm-channel')) config.channelUrl = body.getAttribute('stm-channel');
-      if (body.hasAttribute('stm-worker')) config.workerUrl = body.getAttribute('stm-worker');
-    }
-
-    try { warnServerSideMarkup(); } catch (e) {}
-
     try { initSession(); } catch (e) {}
     try { hydrate(); } catch (e) {}
     setupChannel();
@@ -1534,8 +1355,14 @@
   }
 
   function autoInit() {
-    if (document.readyState === 'loading') {
-      document.addEventListener('DOMContentLoaded', init);
+    var ready = 'loading';
+    try { ready = document.readyState; } catch (e) {}
+    if (ready === 'loading') {
+      try {
+        document.addEventListener('DOMContentLoaded', init);
+      } catch (e) {
+        init();
+      }
     } else {
       init();
     }
@@ -1569,13 +1396,6 @@
     addStateListener: addStateListener,
     /** Remove a previously-attached listener. */
     removeStateListener: removeStateListener,
-    /**
-     * Register a handler for an `stm-*` attribute (module seam — see the
-     * registry in statum-core.js). `name` is an exact attribute name, or a
-     * prefix matcher when it ends with `.`; `opts.after` / `opts.before`
-     * order the handler against another registered attribute.
-     */
-    registerAttribute: registerAttribute,
     /** Register a callback that runs before each render cycle (no arguments). */
     beforeEvaluate: function (fn) { if (typeof fn === 'function') beforeHooks.push(fn); },
     /** Register a callback that runs after each render cycle (no arguments). */
@@ -1593,8 +1413,51 @@
   // The seam must be exposed before any other module loads. autoInit only
   // ever runs on DOMContentLoaded (or immediately at load); both happen
   // outside this script tag, so the later module tags always see the bridge.
-  window.statum._bridge = bridge;
+  window.statum._bridge = {
+    /** Expression machinery (core-owned). */
+    evalExpr: evalExpr,
+    childScope: childScope,
+    stateScope: stateScope,
+    /** Whether the entrypoint has been fetched (guards gate on it). */
+    entrypointLoaded: function () { return entrypointLoaded; },
+    /** Render cycle + requests. */
+    renderAll: renderAll,
+    loadEntrypoint: loadEntrypoint,
+    performAction: performAction,
+    /** UI handler overrides (runElementAction gates on confirm). */
+    triggerConfirm: triggerConfirm,
+    triggerNotify: triggerNotify,
+    triggerError: triggerError,
+    handlers: handlers,
+    /**
+     * Attribute-handler registration. The registry itself lives in
+     * statum-document.js (it is DOM machinery: the walk iterates it); until
+     * that module loads, registrations are QUEUED here so a module can load
+     * against core alone (tests load core+markdown, for instance). Document
+     * drains the queue through the real registry when it installs it.
+     */
+    registerAttribute: function (name, fn, opts) {
+      pendingRegistrations.push([name, fn, opts]);
+    },
+    /** Take (and clear) the registrations queued before document loaded. */
+    takePendingRegistrations: function () {
+      var pending = pendingRegistrations;
+      pendingRegistrations = [];
+      return pending;
+    },
+    /**
+     * Hooks statum-document.js installs (core defaults the first to a no-op
+     * and the rest to "absent"; the setters keep the names stable for that
+     * module's installer).
+     */
+    setBootState: function (fn) { if (typeof fn === 'function') setBootState = fn; },
+    renderDriver: function (fn) { if (typeof fn === 'function') renderDriver = fn; },
+    afterRenderCycle: function (fn) { if (typeof fn === 'function') afterRenderCycle = fn; },
+    applyErrorClasses: function (fn) { if (typeof fn === 'function') applyErrorClasses = fn; }
+  };
+
+  /** @type {Array<[string, Function, object|undefined]>} */
+  var pendingRegistrations = [];
 
   autoInit();
 })();
-

+ 610 - 55
js/statum-document.js

@@ -1,15 +1,44 @@
 /*!
  * Statum - client-side state library (document module).
  *
- * The render walk: expands `stm-for` loops and `stm-if` chains, applies the
- * structural and DOM-level `stm-*` bindings (`stm-class.*`,
- * `stm-attribute.*`, `stm-disabled`, `stm-text`, `stm-html`), wires
- * `stm-action` elements, and recurses into child elements. The bindings
- * register through core's attribute seam and this module installs core's
- * render driver, so `renderAll` drives the walk defined here. The
- * structural attributes (`stm-for`, `stm-if`/`stm-else-if`/`stm-else` and
- * `stm-def-*`) stay hardcoded in the walk: they build the scope and the
- * element list every handler receives.
+ * Everything DOM. statum-core.js is headless (state, directives, SSE, workers,
+ * persistence — no DOM at all); this module owns the presentation side and
+ * installs itself into core through the module bridge at load time:
+ *
+ *   - the render walk: expands `stm-for` loops and `stm-if` chains, applies
+ *     the structural and DOM-level `stm-*` bindings (`stm-class.*`,
+ *     `stm-attribute.*`, `stm-disabled`, `stm-text`, `stm-html`), wires
+ *     `stm-action` elements, and recurses into child elements. The
+ *     structural attributes (`stm-for`, `stm-if`/`stm-else-if`/`stm-else` and
+ *     `stm-def-*`) stay hardcoded in the walk: they build the scope and the
+ *     element list every handler receives.
+ *   - the attribute-handler REGISTRY and the public `statum.registerAttribute`
+ *     seam: module authors register `stm-*` handlers here; the walk iterates
+ *     them in resolved order.
+ *   - boot: reads the `stm-entrypoint` / `stm-slots` / `stm-channel` /
+ *     `stm-worker` `<body>` attributes into `statum.config` (core's own
+ *     mutable config object — no new API), manages the `stm-preloader` /
+ *     `stm-content` boot visibility, injects the `hidden` stylesheet and
+ *     warns about leftover server-side `pstm-*` markup.
+ *   - guards: `stm-guard` links, evaluated through core's afterRenderCycle
+ *     hook (redirected pages stop rendering).
+ *   - error/success state: `stm-on-error` / `stm-on-success` classes on action
+ *     elements, applied through core's reportFailure path.
+ *   - forms (merged from the former statum-forms.js): `stm-prefill`
+ *     (state -> control PROPERTY writes with dirty-element tracking),
+ *     `stm-clear-on-success` / `stm-clear-on-error` and the `statum.document`
+ *     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.
  *
  * Load order: SECOND - requires statum-core.js, which creates `window.statum`
  * and the `_bridge` this module registers through. Like core: an IIFE, no
@@ -31,7 +60,75 @@
   var stateScope = bridge.stateScope;
   var triggerConfirm = bridge.triggerConfirm;
   var performAction = bridge.performAction;
-  var applyErrorClasses = bridge.applyErrorClasses;
+
+
+  // ---------------------------------------------------------------------------
+  // Attribute-handler registry (module seam)
+  // ---------------------------------------------------------------------------
+  //
+  // `_bridge` (underscore = private by convention, like `_testMarkdown`) is
+  // the internal cross-module surface; `statum.registerAttribute` is the
+  // public entry point module authors use.
+
+  /**
+   * Resolved execution order of the per-element `stm-*` bindings: an ordered
+   * list of `{name, fn, prefix}`. The render walk below iterates it for every
+   * element. A throwing handler aborts that render cycle through renderAll's
+   * try/catch, exactly as the previous hardcoded call chain did, so handlers
+   * get no per-handler try/catch here.
+   * @type {Array<{name: string, fn: Function, prefix: boolean}>}
+   */
+  var attributeHandlers = [];
+
+  /**
+   * Register a handler for an `stm-*` attribute.
+   *
+   * `name` is either an exact attribute name (`'stm-disabled'`) or, when it
+   * ends with a dot, a prefix matcher (`'stm-class.'` matches any
+   * `stm-class.{name}`). The handler is called once per element per render
+   * cycle when the element carries at least one matching attribute; it then
+   * iterates the element's attributes itself (several may match a prefix).
+   *
+   * `opts.after` / `opts.before` name another registered attribute and the
+   * handler is inserted directly after / before it. A constraint naming an
+   * unregistered attribute warns and keeps plain registration order, so
+   * loading a module early cannot break the page. Registering the same name
+   * twice warns and ignores the second handler.
+   *
+   * @param {string} name
+   * @param {Function} fn `fn(el, scope)`
+   * @param {{after?: string, before?: string}} [opts]
+   */
+  function registerAttribute(name, fn, opts) {
+    if (typeof name !== 'string' || !name || typeof fn !== 'function') {
+      console.warn('[statum] registerAttribute: expected an attribute name and a handler function');
+      return;
+    }
+    for (var i = 0; i < attributeHandlers.length; i++) {
+      if (attributeHandlers[i].name === name) {
+        console.warn('[statum] registerAttribute: "' + name + '" is already registered; ignoring');
+        return;
+      }
+    }
+    var entry = { name: name, fn: fn, prefix: name.charAt(name.length - 1) === '.' };
+    var anchor = opts && (opts.after || opts.before);
+    var at = anchor ? handlerIndex(anchor) : -1;
+    if (anchor && at === -1) {
+      console.warn('[statum] registerAttribute: "' + anchor +
+        '" is not registered; keeping registration order for "' + name + '"');
+    }
+    if (at !== -1 && opts.after) at += 1; // insert directly after the anchor
+    if (at === -1) attributeHandlers.push(entry);
+    else attributeHandlers.splice(at, 0, entry);
+  }
+
+  /** Position of a registered handler by name, or -1. */
+  function handlerIndex(name) {
+    for (var i = 0; i < attributeHandlers.length; i++) {
+      if (attributeHandlers[i].name === name) return i;
+    }
+    return -1;
+  }
 
 
   // ---------------------------------------------------------------------------
@@ -170,7 +267,7 @@
   // ---------------------------------------------------------------------------
   // stm-text / stm-html
   // ---------------------------------------------------------------------------
-  // (stm-markdown and stm-h-demotion moved to statum-markdown.js and register
+  // (stm-markdown and stm-h-demotion live in statum-markdown.js and register
   // through the same seam, ordered after `stm-html`.)
 
   /**
@@ -298,43 +395,32 @@
    * Each expression is evaluated against the incoming scope (so the new
    * variables cannot reference each other within the same element), and the
    * results are added to a child scope visible to the element and its
-   * descendants. Multiple defs on one element are supported. If there are no
-   * defs, the original scope is returned unchanged.
-   * @param {Element} el
-   * @param {object} scope
-   * @returns {object}
+   * descendants.
    */
   function applyDefs(el, scope) {
+    var s = Object.create(scope || null);
     var attrs = el.attributes;
-    var child = null;
     for (var i = 0; i < attrs.length; i++) {
       var name = attrs[i].name;
       if (name.indexOf('stm-def-') === 0 && name.slice(-3) === '-as') {
-        var varName = name.slice(8, -3);
-        if (!varName) continue;
-        if (!child) child = Object.create(scope || null);
-        child[varName] = evalExpr(attrs[i].value, scope);
+        var localName = name.slice('stm-def-'.length, -3);
+        s[localName] = evalExpr(attrs[i].value, scope);
       }
     }
-    return child || scope;
+    return s;
   }
 
   /**
-   * Bind the non-control-flow attributes and recurse into children.
-   *
-   * Handlers run in the seam's resolved order (see registerAttribute in
-   * statum-core.js): once all modules have loaded it is stm-class.* ->
-   * stm-attribute.* -> stm-disabled -> stm-text -> stm-html ->
-   * stm-markdown -> stm-h-demotion, the order this walk used when it called
-   * the handlers directly. A throwing handler aborts the render cycle via
-   * renderAll's try/catch, as before.
+   * Run the registered bindings on one element. The resolved handler order
+   * (registration + after/before constraints) replaces the previous hardcoded
+   * call chain: a throwing handler aborts that render cycle via renderAll's
+   * try/catch, as before.
    */
   function processInner(el, scope) {
     var s = applyDefs(el, scope); // local definitions: element + descendants
     el.__stmScope = s; // latest scope, used when an action fires
-    var registered = bridge.attributeHandlers;
-    for (var i = 0; i < registered.length; i++) {
-      var h = registered[i];
+    for (var i = 0; i < attributeHandlers.length; i++) {
+      var h = attributeHandlers[i];
       if (h.prefix ? hasAttrPrefix(el, h.name) : el.hasAttribute(h.name)) h.fn(el, s);
     }
     wireAction(el);
@@ -437,43 +523,59 @@
     var eventName = el.tagName === 'FORM' ? 'submit' : 'click';
     el.addEventListener(eventName, function (event) {
       event.preventDefault();
-      // Stash the action promise so stm-clear-on-success (statum-forms.js)
-      // can await it and reset the form only when the action resolved.
+      // Stash the action promise so stm-clear-on-success /
+      // stm-clear-on-error (below) can await it and reset the form only on
+      // the matching outcome.
       el.__stmAction = runElementAction(el);
     });
   }
 
   /**
    * Resolve and perform the action attached to an element, managing busy and
-   * error-state classes around the request.
+   * error/success-state classes around the request.
    * @param {HTMLElement} el
    */
   async function runElementAction(el) {
     var scope = el.__stmScope || stateScope();
     var action = evalExpr(el.getAttribute('stm-action'), scope);
-    if (!action || typeof action.uri !== 'string') return;
+    if (!action || typeof action.uri !== 'string') {
+      // Unresolvable action: nothing was requested, so neither outcome
+      // applies (cancellation, not error — a reset would eat the user's
+      // input for a no-op).
+      el.__stmActionFailed = true;
+      el.__stmActionCancelled = true;
+      return;
+    }
 
-    // Outcome flag read by stm-clear-on-success (statum-forms.js). The
-    // returned promise resolves even on failure (the catch below swallows
+    // Outcome flags read by stm-clear-on-success / stm-clear-on-error (below).
+    // 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.
+    // its own signal. Pessimistic: `failed` stays true until the action
+    // completes successfully, so a declined confirm or an unresolvable
+    // action never reads as success. `cancelled` marks the paths where no
+    // request was ever made (unresolvable action, declined stm-confirm):
+    // decline is cancellation, not error, so stm-clear-on-error must not
+    // fire there either.
     el.__stmActionFailed = true;
+    el.__stmActionCancelled = false;
 
     // stm-confirm: gate the action behind the confirm handler (async-capable,
     // so custom modals are supported).
     if (el.hasAttribute('stm-confirm')) {
       var confirmed = await triggerConfirm(el.getAttribute('stm-confirm'), el);
-      if (!confirmed) return;
+      if (!confirmed) {
+        el.__stmActionCancelled = true;
+        return;
+      }
     }
 
-    applyErrorClasses(el, false); // clear error state on a new attempt
+    applyErrorClasses(el, false); // clear error/success state on a new attempt
     var data = collectData(el);
     setBusyState(el, true);
     var failed = false;
     try {
       await performAction(action, data, el);
+      applyErrorClasses(el, 'success'); // success classes mirror the error ones
     } catch (err) {
       failed = true;
       // Already handled: stm-on-error classes and the onErrorHandler run inside
@@ -485,20 +587,473 @@
     }
   }
 
+
+  // ---------------------------------------------------------------------------
+  // Error/success-state classes (installed into core's reportFailure path)
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Apply or remove the `stm-on-error` / `stm-on-success` classes on an action
+   * element. The two are mirrored: a failure applies the error classes and
+   * clears the success classes; a success applies the success classes and
+   * clears the error classes; a new attempt (or the no-attribute case) clears
+   * both.
+   *
+   * Installed into core as `bridge.applyErrorClasses` so core's
+   * reportFailure stays self-contained without knowing about classes; the
+   * walk's own new-attempt call site (runElementAction) clears both.
+   * @param {HTMLElement} el
+   * @param {boolean|'success'} on `true` applies the error classes (and
+   *   clears success); `'success'` applies the success classes (and clears
+   *   error); `false` clears both.
+   */
+  function applyErrorClasses(el, on) {
+    if (!el || !el.hasAttribute) return;
+    if (!el.hasAttribute('stm-on-error') && !el.hasAttribute('stm-on-success')) return;
+    var errClasses = el.hasAttribute('stm-on-error')
+      ? (el.getAttribute('stm-on-error') || '').split(/\s+/).filter(Boolean)
+      : [];
+    var okClasses = el.hasAttribute('stm-on-success')
+      ? (el.getAttribute('stm-on-success') || '').split(/\s+/).filter(Boolean)
+      : [];
+    var mode = on === 'success' ? 'success' : (on ? 'error' : 'clear');
+    errClasses.forEach(function (c) {
+      if (mode === 'error') el.classList.add(c); else el.classList.remove(c);
+    });
+    okClasses.forEach(function (c) {
+      if (mode === 'success') el.classList.add(c); else el.classList.remove(c);
+    });
+  }
+
+
+  // ---------------------------------------------------------------------------
+  // Guards (stm-guard links) — installed into core's afterRenderCycle hook
+  // ---------------------------------------------------------------------------
+
+  /** Set when a guard has triggered a redirect, so further work is skipped. */
+  var guardRedirected = false;
+
+  /**
+   * Evaluate every `<link rel="stm-guard">` in the document head. A guard whose
+   * `require` predicate is false redirects the page to its `href`.
+   *
+   * Guards marked `pre-entrypoint` are evaluated on every cycle (including the
+   * initial, pre-entrypoint render, which uses only hydrated state); guards
+   * without it are evaluated only once the entrypoint has loaded. On every later
+   * evaluate cycle (set/clear) all guards are re-checked, so logging out, for
+   * example, can bounce the user off a guarded page.
+   */
+  function evaluateGuards() {
+    if (guardRedirected) return;
+    var scope = stateScope();
+    var guards = document.querySelectorAll('link[rel~="stm-guard"]');
+    for (var i = 0; i < guards.length; i++) {
+      var g = guards[i];
+      var preEntrypoint = g.hasAttribute('pre-entrypoint');
+      if (!preEntrypoint && !bridge.entrypointLoaded()) continue;
+      var require = g.getAttribute('require');
+      if (!require) continue;
+      if (!evalExpr(require, scope)) {
+        guardRedirected = true;
+        var href = g.getAttribute('href') || '/';
+        console.warn('[statum] guard failed (' + require + '); redirecting to ' + href);
+        window.location.href = href;
+        return;
+      }
+    }
+  }
+
+
+  // ---------------------------------------------------------------------------
+  // Forms (merged from the former statum-forms.js)
+  // ---------------------------------------------------------------------------
+  // stm-prefill / dirty tracking / stm-clear-on-success / stm-clear-on-error,
+  // all hosted here; the public API is `statum.document`.
+
+  /**
+   * 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 / stm-clear-on-error
+  // ---------------------------------------------------------------------------
+
+  /** Forms already warned about a missing stm-action. @type {WeakSet} */
+  var warnSeen = new WeakSet();
+
+  /** Warn once about a clear-on-* attribute on a form without stm-action. */
+  function warnNoAction(f, attr) {
+    if (warnSeen.has(f)) return;
+    warnSeen.add(f);
+    console.warn('[statum] ' + attr + ' on a form without stm-action does nothing; ' +
+      'put stm-action on the form itself');
+  }
+
+  /**
+   * Reset a form when its own stm-action settles on the matching outcome.
+   * 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 above).
+   *
+   * 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` and `stm-clear-on-error` are 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.
+   *
+   * A DECLINED stm-confirm never clears: decline is cancellation, not error.
+   * runElementAction resolves (never rejects) for declines, and marks the
+   * outcome `cancelled` (`__stmActionCancelled === true`), which this
+   * success-path check excludes. The reject handler below is only reached on
+   * a real request failure, where `__stmActionFailed === true` by
+   * construction.
+   *
+   * @param {Event} ev
+   */
+  function onSubmit(ev) {
+    var f = ev.target;
+    if (!f || f.nodeType !== 1 || f.tagName !== 'FORM') return;
+    var onSuccess = f.hasAttribute && f.hasAttribute('stm-clear-on-success');
+    var onError = f.hasAttribute && f.hasAttribute('stm-clear-on-error');
+    if (!onSuccess && !onError) return;
+    Promise.resolve().then(function () {
+      var promise = f.__stmAction;
+      if (!promise) {
+        if (onSuccess) warnNoAction(f, 'stm-clear-on-success');
+        if (onError) warnNoAction(f, 'stm-clear-on-error');
+        return;
+      }
+      // runElementAction RESOLVES even when the action failed (it swallows
+      // the rejection so it does not surface as unhandled), so both outcomes
+      // are read from its flags on the resolve path: success clears with
+      // __stmActionFailed === false, failure with === true (and not
+      // cancelled — a declined confirm is neither). The reject handler is
+      // defensive only; by construction the promise never rejects.
+      promise.then(function () {
+        if (f.__stmActionCancelled === true) return; // decline: no request, no clear
+        if (onSuccess && f.__stmActionFailed === false) f.reset();
+        else if (onError && f.__stmActionFailed === true) f.reset();
+      }, function () {
+        if (onError && f.__stmActionFailed === true && f.__stmActionCancelled !== true) {
+          f.reset();
+        }
+      });
+    });
+  }
+
+  document.addEventListener('submit', onSubmit, true);
+
+
   // ---------------------------------------------------------------------------
-  // Registration (module seam)
+  // 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.document = {
+    /** 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
+  };
+
+
+  // ---------------------------------------------------------------------------
+  // Boot (moved from core): body-attribute config, boot visibility, styles
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Inject the stylesheet that makes the `hidden` attribute authoritative.
+   * Statum toggles `stm-if`/`stm-else` branches and loop holders via
+   * `hidden`, whose user-agent rule loses specificity battles to ordinary
+   * author CSS (a rule like `li { display: flex }` let hidden loop holders
+   * leak as phantom rows). Idempotent: skipped when the stylesheet is
+   * already present.
+   */
+  function injectHiddenStyle() {
+    var id = 'statum-hidden-style';
+    if (document.getElementById(id)) return;
+    var head = document.head;
+    if (!head) return;
+    var style = document.createElement('style');
+    style.id = id;
+    style.textContent = '[hidden]{display:none !important}';
+    head.appendChild(style);
+  }
+
+  /**
+   * Scan the document for `pstm-*` attributes and `<pstm-*>` elements, which
+   * are intended for server-side pre-rendering and should never reach the
+   * browser. Emits a single `console.warn` summarizing them.
+   */
+  function warnServerSideMarkup() {
+    var found = [];
+    var els = document.querySelectorAll('*');
+    for (var i = 0; i < els.length; i++) {
+      var el = els[i];
+      var tag = el.tagName.toLowerCase();
+      if (tag.indexOf('pstm-') === 0) found.push('<' + tag + '>');
+      var attrs = el.attributes;
+      for (var j = 0; j < attrs.length; j++) {
+        var name = attrs[j].name;
+        if (name.indexOf('pstm-') === 0) found.push('<' + tag + '> ' + name);
+      }
+    }
+    if (found.length) {
+      console.warn('[statum] ' + found.length + ' server-side pstm-* attribute/element(s) ' +
+        'found in the DOM; they should have been resolved during pre-rendering: ' +
+        found.slice(0, 10).join(', ') + (found.length > 10 ? ' …' : ''));
+    }
+  }
+
+  /**
+   * Read the `stm-entrypoint` / `stm-slots` / `stm-channel` / `stm-worker`
+   * `<body>` attributes into core's config object (`statum.config`). Core's
+   * init() then uses the config as given — it never reads the DOM itself, so
+   * headless pages point `statum.config` at their own endpoints instead.
+   */
+  function readBootConfig() {
+    var body = document.body;
+    if (!body) return;
+    if (body.hasAttribute('stm-entrypoint')) window.statum.config.entrypointUrl = body.getAttribute('stm-entrypoint');
+    if (body.hasAttribute('stm-slots')) window.statum.config.slotsUrl = body.getAttribute('stm-slots');
+    if (body.hasAttribute('stm-channel')) window.statum.config.channelUrl = body.getAttribute('stm-channel');
+    if (body.hasAttribute('stm-worker')) window.statum.config.workerUrl = body.getAttribute('stm-worker');
+  }
+
+  /**
+   * Toggle pre/post-entrypoint visibility with the `hidden` attribute. While
+   * loading, `stm-preloader` elements are shown and `stm-content` elements
+   * hidden; once ready, the reverse.
+   *
+   * To avoid a flash of un-bound template before the script runs, authors may
+   * pre-set `hidden` directly on `stm-content` in the HTML (e.g.
+   * `<div stm-content hidden>`); the library removes it once ready. The library
+   * also sets `hidden` itself during loading as a fallback for authors who do
+   * not pre-set it.
+   * @param {boolean} ready
+   */
+  function setBootState(ready) {
+    var preloaders = document.querySelectorAll('[stm-preloader]');
+    var contents = document.querySelectorAll('[stm-content]');
+    for (var i = 0; i < preloaders.length; i++) {
+      ready ? preloaders[i].setAttribute('hidden', '') : preloaders[i].removeAttribute('hidden');
+    }
+    for (var j = 0; j < contents.length; j++) {
+      ready ? contents[j].removeAttribute('hidden') : contents[j].setAttribute('hidden', '');
+    }
+  }
+
+
+  // ---------------------------------------------------------------------------
+  // Installation
   // ---------------------------------------------------------------------------
 
   // Registration order mirrors the order these handlers had when processInner
-  // called them directly; statum-markdown.js constrains its own handlers
+  // called them directly; stm-prefill (from the former forms module) is first
+  // so its property writes feed the controls before any class/attribute
+  // predicate evaluates; statum-markdown.js constrains its own handlers
   // relative to `stm-html`.
-  bridge.registerAttribute('stm-class.', handleClass);
-  bridge.registerAttribute('stm-attribute.', handleAttribute);
-  bridge.registerAttribute('stm-disabled', handleDisabled);
-  bridge.registerAttribute('stm-text', handleText);
-  bridge.registerAttribute('stm-html', handleHtml);
-
-  // Core's renderAll drives the walk defined in this module.
-  bridge.installRenderDriver(function (scope) { render(document.body, scope); });
-})();
+  registerAttribute('stm-prefill', handlePrefill);
+  registerAttribute('stm-class.', handleClass);
+  registerAttribute('stm-attribute.', handleAttribute);
+  registerAttribute('stm-disabled', handleDisabled);
+  registerAttribute('stm-text', handleText);
+  registerAttribute('stm-html', handleHtml);
+
+  // The registry itself becomes the seam other modules register through.
+  // First drain anything a module registered against core alone (markdown,
+  // for instance, may legitimately load without this module): its
+  // after-constraints name document handlers that now exist, so they resolve
+  // properly instead of warn-and-keep-position.
+  bridge.takePendingRegistrations().forEach(function (reg) {
+    registerAttribute(reg[0], reg[1], reg[2]);
+  });
+  bridge.attributeHandlers = attributeHandlers;
+  bridge.registerAttribute = registerAttribute;
+  window.statum.registerAttribute = registerAttribute;
+
+  // Install the presentation hooks core calls. Core's renderAll drives the
+  // walk defined here; reportFailure reaches the classes defined here; init
+  // reaches the boot visibility defined here; every render cycle ends with
+  // the guard evaluation defined here. The driver itself honours the guard
+  // state: once a guard has redirected, walks are skipped (core's renderAll
+  // no longer holds that flag — the mandate's "driver checks its own guard
+  // state before walking" mechanism).
+  bridge.renderDriver(function (scope) {
+    if (guardRedirected) return;
+    render(document.body, scope);
+  });
+  bridge.setBootState(setBootState);
+  bridge.afterRenderCycle(evaluateGuards);
+  bridge.applyErrorClasses(applyErrorClasses);
 
+  // Boot configuration must be in place before core's init() runs: the same
+  // script tags that load this module also trigger core's autoInit on
+  // DOMContentLoaded, and a synchronous page load has this module loaded by
+  // then. (Direct statum.init() calls by embedding frameworks happen after
+  // the module set loads, so the ordering holds there too.)
+  readBootConfig();
+
+  // Before the first render, so `hidden` wins over author CSS from the very
+  // first paint.
+  try { injectHiddenStyle(); } catch (e) {}
+  try { warnServerSideMarkup(); } catch (e) {}
+})();

+ 0 - 272
js/statum-forms.js

@@ -1,272 +0,0 @@
-/*!
- * 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.' });
-})();

+ 5 - 3
js/statum-markdown.js

@@ -7,9 +7,11 @@
  * `statum.markdown` API. The engine itself has no DOM references; only the
  * bindings touch elements.
  *
- * Load order: THIRD - requires statum-core.js (`window.statum._bridge`); the
- * `stm-markdown` handler orders itself after statum-document.js's `stm-html`.
- * Like core: an IIFE, no dependencies, no build step, plain script tag.
+ * Load order: THIRD - requires statum-core.js (`window.statum._bridge`);
+ * the `stm-markdown` handler orders itself after statum-document.js's
+ * `stm-html`. (The attribute registry lives in statum-document.js; if that
+ * module is absent, core queues the registration until it loads.) Like core:
+ * an IIFE, no dependencies, no build step, plain script tag.
  */
 (function () {
   'use strict';

+ 34 - 21
model.md

@@ -207,29 +207,37 @@ adding to the same `window.statum` global), served as default resources:
 - `/_statum/resource/statum-core.js` — the slot protocol: storage and
   persistence, `X-Statum-Slot` assembly, entrypoint/action fetch, directive
   dispatch, SSE/SharedWorker delivery, the alert/confirm/notify override
-  mechanism, and the seams modules register into (`statum.registerAttribute`,
-  the render driver, the internal bridge).
-- `/_statum/resource/statum-document.js` — the render walk and the structural
-  bindings: `stm-text`, `stm-html`, `stm-attribute.*`, `stm-class.*`,
-  `stm-if/else`, `stm-for-…-in`, `stm-disabled`, `stm-def-*`, `stm-action`.
+  mechanism, and the seams modules register into (the internal bridge; the
+  attribute registry itself is document-side).
+- `/_statum/resource/statum-document.js` — everything DOM: the render walk
+  and the structural bindings (`stm-text`, `stm-html`, `stm-attribute.*`,
+  `stm-class.*`, `stm-if/else`, `stm-for-…-in`, `stm-disabled`, `stm-def-*`,
+  `stm-action`), the attribute-handler registry (`statum.registerAttribute`),
+  boot visibility, `stm-guard` links, `stm-on-error`/`stm-on-success`
+  classes, and the form bindings (`stm-prefill`, `stm-clear-on-success`,
+  `stm-clear-on-error`, the `statum.document` API).
 - `/_statum/resource/statum-markdown.js` — the CommonMark engine plus the
   `stm-markdown` binding, `stm-h-demotion`, and the programmatic
   `statum.markdown` API. Zero DOM dependencies in the engine itself.
-- `/_statum/resource/statum-forms.js` — the form bindings `stm-prefill` and
-  `stm-clear-on-success`, the `statum.forms` API, and the shared dirty set.
 
-**Load order is a contract**: core first, then document, markdown, forms.
-Core installs the seams; every later module registers its attribute handlers
-and render pieces into them. Trailing modules may be omitted (a core +
-document page has the full state-binding walk minus markdown and the form
-extras — note `stm-markdown` and `stm-h-demotion` live in the markdown
-module and are inert without it).
+**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
+endpoints before init). When document is loaded it reads the
+`stm-entrypoint`/`stm-slots`/`stm-channel`/`stm-worker` body attributes into
+`statum.config` and installs the presentation hooks.
+
+**Load order is a contract**: core first, then document, markdown. Document
+owns the registry; every later module registers its attribute handlers into
+it (a module loaded against core alone has its registrations queued until
+document arrives). Trailing modules may be omitted (a core + document page
+has the full state-binding walk minus markdown — note `stm-markdown` and
+`stm-h-demotion` live in the markdown module and are inert without it).
 
 `statum-mkpstm` expands a legacy `<script pstm-res-src="statum.js">` at
-build time into the four tags in canonical order, once per module even when
+build time into the three tags in canonical order, once per module even when
 the page mixes the legacy reference with explicit module tags, and prints
 one build warning per expanded page naming the expansion. New pages should
-reference the four modules explicitly.
+reference the three modules explicitly.
 
 ### Extending the client: statum.registerAttribute
 
@@ -299,11 +307,12 @@ The default UI behaviours for confirmations, notifications, and errors can be cu
 
 ## Form helpers
 
-`statum.forms` groups the programmatic side of the form bindings (see *Form prefill* and *Clear on success* above):
+`statum.document` groups the programmatic side of the form bindings (see
+*Form prefill* and *Clear on success / clear on error* 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.
+- `statum.document.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.document.markDirty(el)`: mark an element dirty; `stm-prefill` will skip it until its form is reset.
+- `statum.document.isDirty(el)`: whether an element is dirty.
 
 ## Evaluation hooks
 
@@ -482,12 +491,16 @@ Once the user edits a control (an `input` or `change` event, observed with a doc
 
 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)`.
+Dirty state is exposed for programmatic use (tests, reconciliation logic): `statum.document.markDirty(el)`, `statum.document.isDirty(el)`.
 
-## Clear on success
+## Clear on success / clear on error
 
 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.
 
+`stm-clear-on-error` is the mirror: it resets the form once its own action **fails** (an HTTP error, a network error, or an `error` directive). A **declined `stm-confirm` never fires it** — a decline is cancellation, not error, and no request was made. Only the form carrying the attribute is reset; sibling forms keep their values and dirty state. A form carrying either attribute without `stm-action` warns once (there is no promise to await).
+
+The counterpart on action elements, `stm-on-success`, applies classes when the action succeeds — the mirror of `stm-on-error`. The two are mirrored: a success applies the success classes and clears the error classes, a failure does the reverse, and a new attempt clears both at its start.
+
 ```html
 <form stm-action="typeName.saveThing" stm-clear-on-success>
     <input name="label" stm-prefill="typeName.draftLabel">

+ 0 - 1
src/Statum.vala

@@ -74,7 +74,6 @@ 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>();
         }
 

+ 9 - 19
src/meson.build

@@ -1,13 +1,15 @@
 # Embed the Statum client scripts (js/statum-core.js, js/statum-document.js,
-# js/statum-markdown.js, js/statum-forms.js and js/statum-worker.js) into the
-# library as precompressed ConstantStatumResource classes, served by default at
+# js/statum-markdown.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 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; forms adds the form
-# bindings). The former single-file statum.js resource no longer exists.
+# The three client modules are separate resources loaded in order by pages
+# (core creates window.statum and is headless — state, directives, SSE,
+# workers, persistence; document owns the render walk, the DOM bindings, the
+# forms bindings and the attribute registry; markdown adds the CommonMark
+# engine). The former statum-forms.js module was folded into statum-document;
+# the single-file statum.js resource no longer exists either.
 client_core = custom_target('statum-client-core',
     input: files('../js/statum-core.js'),
     output: 'ClientCore.vala',
@@ -44,18 +46,6 @@ 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',
@@ -104,7 +94,7 @@ sources = files(
 
 library_version = meson.project_version()
 libstatum = shared_library('statum-@0@'.format(library_version),
-    sources, client_core, client_document, client_markdown, client_forms, client_worker,
+    sources, client_core, client_document, client_markdown, client_worker,
     dependencies: [
         glib_dep,
         gobject_dep,

+ 3 - 2
statum-issues.md

@@ -28,7 +28,7 @@ Percipio friction report.
 
 ---
 
-## Client / JS side (formerly `js/statum.js`, now the four modules in `js/`)
+## Client / JS side (formerly `js/statum.js`, now the modules in `js/`)
 
 ### S-1. Slot names that shadow JS globals silently break `stm-if` → blank page — SEVERE (orig #1)
 - **What:** naming a slot `eval` made `stm-if="… eval == null …"` evaluate
@@ -67,7 +67,8 @@ Percipio friction report.
   `!important`. At minimum, document this prominently.
 
 ### S-4. `stm-text` on an editable `<input>`/`<textarea>` resets the user's typing (orig #4)
-- **Status: addressed by `stm-prefill`** (statum-forms.js). The shipped
+- **Status: addressed by `stm-prefill`** (now in statum-document.js, formerly
+  statum-forms.js). The shipped
   mitigation is the `stm-prefill` binding: property writes with per-element
   dirty tracking, so an edit is never overwritten until the form resets. This
   supersedes the "set-once / `stm-value`" proposal below —

+ 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 nineteen)
+# Usage: tests/dom/run.sh [scenario]   (default: all twenty-four)
 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}"
+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}"
 
 for n in $SCENARIOS; do
   echo "== dom scenario $n =="

+ 188 - 20
tests/dom/scenarios.js

@@ -25,13 +25,18 @@
  * 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
+ * 15  statum.document.reset: resets exactly the given form
  * 16  handler order: stm-prefill registered before stm-class.*
  * 17  reenter: action triggers an in-place entrypoint refresh (fetched twice)
  * 18  reenter ordering: refresh before navigate, both execute
  * 19  reenter failure: logged only; navigate still runs; promise resolves
+ * 20  stm-on-success: applied on success, cleared on the next attempt
+ * 21  stm-on-error/on-success mirroring: failure clears success + vice versa
+ * 22  stm-clear-on-error: a failing action resets the form
+ * 23  declined stm-confirm does NOT clear (clear-on-error present)
+ * 24  headless core: stubbed fetch applies state, no walk, no crash
  *
- * Usage: qjs tests/dom/scenarios.js <1..19>
+ * Usage: qjs tests/dom/scenarios.js <1..24>
  */
 import * as std from 'std';
 import { installShim } from './shim.js';
@@ -58,7 +63,6 @@ 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. */
@@ -106,13 +110,47 @@ async function main() {
   installShim();
   var log = globalThis.__shim.log;
   var body = globalThis.__shim.body;
-  loadAll();
+
+  // Scenario 24 runs HEADLESS: core alone, no document module.
+  if (scenario !== '24') loadAll();
+  else load('statum-core.js');
 
   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');
+
+  if (scenario === '24') {
+    // Headless core: stub the entrypoint fetch, boot, and prove state was
+    // applied with no walk and no crash.
+    check(s.registerAttribute === undefined,
+      'headless core must not expose registerAttribute (it is document API)');
+    check(s.document === undefined, 'headless core must not expose statum.document');
+    sigCounter++;
+    var snap = { slot: { key: 'k-h', type: 'h', scope: 'page' }, public: { hello: 'world' } };
+    globalThis.__shim.setFetch(function () {
+      return globalThis.__shim.okResponse([{
+        type: 'set',
+        snapshot_frame: { content: JSON.stringify(snap), signature: 'sig-h-' + sigCounter }
+      }]);
+    });
+    await s.init();
+    await spin(50);
+    check(s.read('h') && s.read('h').hello === 'world',
+      'headless boot did not apply the entrypoint state: ' + JSON.stringify(s.state()));
+    check(JSON.stringify(s.state()) === '{"h":{"hello":"world"}}',
+      'unexpected state: ' + JSON.stringify(s.state()));
+    // A render cycle with no driver and no hooks must not crash or warn.
+    s._bridge.renderAll();
+    await spin(20);
+    check(log.errors.length === 0, 'headless core errored: ' + JSON.stringify(log.errors));
+    check(log.warns.length === 0, 'headless core warned: ' + JSON.stringify(log.warns));
+    print('scenario ' + scenario + ': OK');
+    return;
+  }
+
+  check(s.document && typeof s.document.reset === 'function', 'statum.document.reset missing');
+  check(typeof s.document.markDirty === 'function' && typeof s.document.isDirty === 'function',
+    'statum.document.markDirty/isDirty missing');
+  check(s.forms === undefined, 'statum.forms must be gone (merged into statum.document)');
   check(s._bridge.formsDirty instanceof WeakSet, 'bridge.formsDirty missing');
   check(typeof s._bridge.markDirty === 'function' && typeof s._bridge.isDirty === 'function',
     'bridge.markDirty/isDirty missing');
@@ -298,7 +336,7 @@ async function main() {
     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');
+    check(s.document.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));
@@ -318,7 +356,7 @@ async function main() {
     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(s.document.isDirty(i1) && !s.document.isDirty(i2), 'dirty flags wrong');
     check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
 
   } else if (scenario === '10') {
@@ -331,11 +369,11 @@ async function main() {
     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');
+    check(s.document.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');
+    check(!s.document.isDirty(i1), 'reset did not un-dirty');
 
     await setState('p', { v: 'zzz' });
     check(i1.value === 'zzz', 'prefill did not re-apply after reset');
@@ -361,7 +399,7 @@ async function main() {
     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(!s.document.isDirty(i1), 'form not un-dirtied after success');
     check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
 
   } else if (scenario === '12') {
@@ -382,7 +420,7 @@ async function main() {
     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(s.document.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).
@@ -438,7 +476,7 @@ async function main() {
     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(!s.document.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'; })
@@ -470,9 +508,9 @@ async function main() {
     await spin(50);
 
     check(ia.value === 'dflt-a', 'form A not reset');
-    check(!s.forms.isDirty(ia), 'form A not un-dirtied');
+    check(!s.document.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(s.document.isDirty(ib), 'form B dirty state was touched');
     check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
 
   } else if (scenario === '15') {
@@ -487,15 +525,15 @@ async function main() {
 
     ia.value = 'x'; fire(ia, 'input');
     ib.value = 'y'; fire(ib, 'input');
-    s.forms.reset(fa);
+    s.document.reset(fa);
     check(ia.value === 'dflt-a', 'form A not reset');
-    check(!s.forms.isDirty(ia), 'form A not un-dirtied');
+    check(!s.document.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');
+    check(s.document.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);
+    s.document.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));
 
@@ -649,6 +687,136 @@ async function main() {
     // Core logs the swallowed reenter failure by design; nothing else may error.
     log.errors.length = 0;
 
+  } else if (scenario === '20') {
+    // stm-on-success: applied on success, cleared on the next attempt.
+    var b = el('button');
+    b.setAttribute('stm-action', 'p.act');
+    b.setAttribute('stm-on-success', 'saved ok');
+    body.appendChild(b);
+
+    await setState('p', { act: { uri: '/save', method: 'POST' } });
+
+    globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
+    fire(b, 'click');
+    await spin(50);
+    check(b.classList.contains('saved') && b.classList.contains('ok'),
+      'success classes not applied: ' + JSON.stringify(b.classList));
+
+    // A new attempt clears them at attempt start. The fetch stub below never
+    // resolves, freezing the attempt mid-flight so the cleared state (before
+    // any outcome re-applies classes) is observable.
+    var hang = new Promise(function () {});
+    globalThis.__shim.setFetch(function () { return hang; });
+    fire(b, 'click');
+    await spin(20);
+    check(!b.classList.contains('saved') && !b.classList.contains('ok'),
+      'success classes not cleared on a new attempt');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '21') {
+    // Mirroring: a failure applies stm-on-error and clears stm-on-success;
+    // a later success applies stm-on-success and clears stm-on-error.
+    var b = el('button');
+    b.setAttribute('stm-action', 'p.act');
+    b.setAttribute('stm-on-error', 'boom');
+    b.setAttribute('stm-on-success', 'yay');
+    body.appendChild(b);
+
+    await setState('p', { act: { uri: '/save', method: 'POST' } });
+
+    globalThis.__shim.setFetch(function () { return globalThis.__shim.failResponse(500); });
+    fire(b, 'click');
+    await spin(50);
+    check(b.classList.contains('boom'), 'error classes not applied');
+    check(!b.classList.contains('yay'), 'success classes survived a failure');
+    log.errors.length = 0;
+
+    globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
+    fire(b, 'click');
+    await spin(50);
+    check(b.classList.contains('yay'), 'success classes not applied after a failure');
+    check(!b.classList.contains('boom'), 'error classes survived a success');
+
+    // And an error-directive failure mirrors the HTTP failure exactly.
+    globalThis.__shim.setFetch(function () {
+      return globalThis.__shim.okResponse([{ type: 'error', message: 'nope' }]);
+    });
+    fire(b, 'click');
+    await spin(50);
+    check(b.classList.contains('boom'), 'error classes not applied (error directive)');
+    check(!b.classList.contains('yay'), 'success classes survived an error directive');
+    log.errors.length = 0;
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '22') {
+    // stm-clear-on-error: a failing action resets the form (values and dirty).
+    var f = el('form');
+    f.setAttribute('stm-action', 'p.act');
+    f.setAttribute('stm-clear-on-error', '');
+    var i1 = input('text', 'a', 'dflt');
+    i1.setAttribute('stm-prefill', 'p.v');
+    f.appendChild(i1);
+    body.appendChild(f);
+
+    await setState('p', { v: 'from state', act: { uri: '/save', method: 'POST' } });
+    i1.value = 'user typed';
+    fire(i1, 'input');
+    check(s.document.isDirty(i1), 'input not dirty after edit');
+
+    globalThis.__shim.setFetch(function () { return globalThis.__shim.failResponse(500); });
+    fire(f, 'submit');
+    await spin(50);
+
+    check(i1.value === 'dflt', 'form was not reset on failure: ' + JSON.stringify(i1.value));
+    check(!s.document.isDirty(i1), 'form was not un-dirtied on failure');
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+    // A success must NOT reset a stm-clear-on-error form.
+    i1.value = 'typed again';
+    fire(i1, 'input');
+    globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
+    fire(f, 'submit');
+    await spin(50);
+    check(i1.value === 'typed again', 'stm-clear-on-error fired on success: ' + JSON.stringify(i1.value));
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+  } else if (scenario === '23') {
+    // A declined stm-confirm never clears: decline is cancellation, not error.
+    var f = el('form');
+    f.setAttribute('stm-action', 'p.act');
+    f.setAttribute('stm-confirm', 'sure?');
+    f.setAttribute('stm-clear-on-error', '');
+    var i1 = input('text', 'a', 'dflt');
+    f.appendChild(i1);
+    body.appendChild(f);
+
+    await setState('p', { act: { uri: '/save', method: 'POST' } });
+    i1.value = 'user typed';
+    fire(i1, 'input');
+
+    // Decline the confirm (custom handler returning false).
+    s.onConfirmHandler = function () { return false; };
+    fire(f, 'submit');
+    await spin(50);
+
+    check(i1.value === 'user typed', 'declined confirm cleared the form: ' + JSON.stringify(i1.value));
+    check(s.document.isDirty(i1), 'declined confirm un-dirtied the form');
+    var posts = globalThis.__shim.fetchCalls.filter(function (c) { return c.method === 'POST'; });
+    check(posts.length === 0, 'a declined confirm still fired a request: ' + JSON.stringify(posts));
+    check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
+
+    // The error-directive path of clear-on-error: failure with a Statum body.
+    s.onConfirmHandler = null; // window.confirm fallback: shim returns true
+    i1.value = 'typed more';
+    fire(i1, 'input');
+    globalThis.__shim.setFetch(function () {
+      return globalThis.__shim.okResponse([{ type: 'error', message: 'nope' }]);
+    });
+    fire(f, 'submit');
+    await spin(50);
+    check(i1.value === 'dflt', 'error directive did not reset a clear-on-error form: ' + JSON.stringify(i1.value));
+    log.errors.length = 0;
+
   } else {
     fail('unknown scenario: ' + scenario);
   }

+ 1 - 1
tests/dom/shim.js

@@ -13,7 +13,7 @@
  *    `[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
+ *    phases and event.target set — statum-document.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),

+ 2 - 2
tests/split/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/split/run.sh [scenario]   (default: all seven)
+# Usage: tests/split/run.sh [scenario]   (default: all eight)
 set -e
 cd "$(dirname "$0")/../.."
 
@@ -19,7 +19,7 @@ else
   QJS=$(command -v qjs)
 fi
 
-SCENARIOS="${1:-1 2 3 4 5 6 7}"
+SCENARIOS="${1:-1 2 3 4 5 6 7 8}"
 
 for n in $SCENARIOS; do
   echo "== split scenario $n =="

+ 69 - 31
tests/split/scenarios.js

@@ -5,15 +5,20 @@
  * once per scenario) and loads a specific subset of the module files in a
  * specific order, proving:
  *
- *   1  core-alone        - window.statum, seam, overrides, driver-less render
- *   2  core+document     - render driver installed, handler order pinned
- *   3  core+doc+markdown - full order, statum.markdown API, test hatch
- *   4  core+markdown     - cherry-pick without the walk (crash-free)
- *   5  document alone    - guarded IIFE: console.error, nothing attached
- *   6  double register   - warns, order unchanged
- *   7  unknown after     - warns, registration position kept
+ *   1  core-alone        - window.statum (headless: state/directives/boot),
+ *                          overrides, driver-less render, NO registerAttribute
+ *   2  core headless boot- stubbed fetch -> entrypoint state applied, no walk
+ *   3  core+document     - registry installed, handler order pinned
+ *                          (stm-prefill first), statum.document API,
+ *                          statum.forms gone
+ *   4  core+doc+markdown - full order, statum.markdown API, test hatch
+ *   5  core+markdown     - cherry-pick without the walk (crash-free; queued
+ *                          registration)
+ *   6  document alone    - guarded IIFE: console.error, nothing attached
+ *   7  double register   - warns, order unchanged
+ *   8  unknown anchor    - warns, registration position kept
  *
- * Usage: qjs tests/split/scenarios.js <1|2|3|4|5|6|7>
+ * Usage: qjs tests/split/scenarios.js <1..8>
  */
 import * as std from 'std';
 import { installShim } from './shim.js';
@@ -42,6 +47,20 @@ function handlerNames() {
   return globalThis.statum._bridge.attributeHandlers.map(function (h) { return h.name; });
 }
 
+function installEntryStub() {
+  var snap = { slot: { key: 'k-s', type: 's', scope: 'page' }, public: { v: 42 } };
+  globalThis.__shim.setFetch(function () {
+    return globalThis.__shim.okResponse([{
+      type: 'set',
+      snapshot_frame: { content: JSON.stringify(snap), signature: 'sig-s-1' }
+    }]);
+  });
+}
+
+async function spin(n) {
+  for (var i = 0; i < (n || 30); i++) await Promise.resolve();
+}
+
 installShim();
 var log = globalThis.__shim.log;
 
@@ -52,9 +71,12 @@ if (scenario === '1') {
   check(s.version === '0.1.0', 'version missing');
   check(typeof s.config === 'object' && s.config.entrypointUrl === '/_statum/entrypoint',
     'config missing');
-  check(typeof s.registerAttribute === 'function', 'registerAttribute is not a function');
+  check(typeof s.registerAttribute === 'undefined',
+    'registerAttribute leaked into headless core (it is document API now)');
   check(s._bridge, '_bridge is not present');
   check(typeof s.Error === 'function', 'statum.Error missing');
+  check(typeof s._bridge.registerAttribute === 'function',
+    'bridge.registerAttribute (the pre-document queue) is not a function');
   check(log.errors.length === 0, 'unexpected console.error at load: ' + JSON.stringify(log.errors));
   check(log.warns.length === 0, 'unexpected console.warn at load: ' + JSON.stringify(log.warns));
 
@@ -70,19 +92,40 @@ if (scenario === '1') {
     check(s[p] === null, p + ' did not null out a non-function (string)');
   });
 
-  // No render driver installed: a render cycle must not crash.
-  check(s._bridge.attributeHandlers.length === 0, 'handlers registered without modules');
+  // No render driver installed: a render cycle must not crash, and the
+  // pre-document registration queue stays empty.
   s._bridge.renderAll();
   check(log.errors.length === 0, 'renderAll without a driver errored: ' + JSON.stringify(log.errors));
 
 } else if (scenario === '2') {
+  // Headless boot: stub the entrypoint fetch, init, and prove state was
+  // applied with no DOM module loaded.
+  load('statum-core.js');
+  var s = globalThis.statum;
+  installEntryStub();
+  await s.init();
+  await spin();
+  check(s.read('s') && s.read('s').v === 42,
+    'headless init did not apply entrypoint state: ' + JSON.stringify(s.state()));
+  check(s._bridge.entrypointLoaded(), 'entrypointLoaded is not exposed as true after boot');
+  check(log.errors.length === 0, 'headless boot errored: ' + JSON.stringify(log.errors));
+  check(log.warns.length === 0, 'headless boot warned: ' + JSON.stringify(log.warns));
+
+} else if (scenario === '3') {
   load('statum-core.js');
   load('statum-document.js');
   var s = globalThis.statum;
+  check(typeof s.registerAttribute === 'function',
+    'registerAttribute is not public on the document module');
   var names = handlerNames();
   check(JSON.stringify(names) === JSON.stringify(
-    ['stm-class.', 'stm-attribute.', 'stm-disabled', 'stm-text', 'stm-html']),
+    ['stm-prefill', 'stm-class.', 'stm-attribute.', 'stm-disabled', 'stm-text', 'stm-html']),
     'handler order is ' + JSON.stringify(names));
+  check(s.document && typeof s.document.reset === 'function', 'statum.document.reset missing');
+  check(typeof s.document.markDirty === 'function' && typeof s.document.isDirty === 'function',
+    'statum.document.markDirty/isDirty missing');
+  check(s.forms === undefined, 'statum.forms must be gone');
+  check(s._bridge.formsDirty instanceof WeakSet, 'bridge.formsDirty missing');
 
   // The driver is installed: renderAll walks document.body and applies a
   // binding. Scope comes from the (empty) store, so use a literal expression.
@@ -94,14 +137,14 @@ if (scenario === '1') {
     'render driver did not apply stm-text (got ' + JSON.stringify(el.textContent) + ')');
   check(log.errors.length === 0, 'unexpected console.error: ' + JSON.stringify(log.errors));
 
-} else if (scenario === '3') {
+} else if (scenario === '4') {
   load('statum-core.js');
   load('statum-document.js');
   load('statum-markdown.js');
   var s = globalThis.statum;
   var names = handlerNames();
   check(JSON.stringify(names) === JSON.stringify(
-    ['stm-class.', 'stm-attribute.', 'stm-disabled', 'stm-text', 'stm-html',
+    ['stm-prefill', 'stm-class.', 'stm-attribute.', 'stm-disabled', 'stm-text', 'stm-html',
      'stm-markdown', 'stm-h-demotion']),
     'full handler order is ' + JSON.stringify(names));
   // "directly after" from the ordered list above.
@@ -136,27 +179,22 @@ if (scenario === '1') {
   check(typeof s._testMarkdown === 'function', '_testMarkdown is not exposed');
   check(log.errors.length === 0, 'unexpected console.error: ' + JSON.stringify(log.errors));
 
-} else if (scenario === '4') {
-  // Cherry-pick: markdown without the walk module. 'after: stm-html' cannot
-  // resolve (document is absent) so it warns and keeps registration order —
-  // that is the designed degradation, not a failure.
+} else if (scenario === '5') {
+  // Cherry-pick: markdown without the walk module. The registration lands in
+  // core's pre-document QUEUE (no warning — it is the designed headless
+  // degradation), the API works, and a render cycle with no driver still runs.
   load('statum-core.js');
   load('statum-markdown.js');
   var s = globalThis.statum;
   check(s.markdown.render('# x') === '<h1>x</h1>\n', 'markdown.render failed without statum-document.js');
-  var names = handlerNames();
-  check(names.indexOf('stm-markdown') !== -1 && names.indexOf('stm-h-demotion') !== -1,
-    'handlers not registered: ' + JSON.stringify(names));
-  check(names.indexOf('stm-h-demotion') === names.indexOf('stm-markdown') + 1,
-    'demotion constraint broke without the walk: ' + JSON.stringify(names));
+  check(s._bridge.attributeHandlers === undefined,
+    'attributeHandlers must be absent without statum-document.js');
   check(log.errors.length === 0, 'unexpected console.error: ' + JSON.stringify(log.errors));
-  check(log.warns.length === 1 && log.warns[0].indexOf('stm-html') !== -1,
-    'expected one unresolvable-constraint warning, got ' + JSON.stringify(log.warns));
-  // A render cycle with no driver still runs.
+  check(log.warns.length === 0, 'queued registration must not warn: ' + JSON.stringify(log.warns));
   s._bridge.renderAll();
   check(log.errors.length === 0, 'renderAll errored without a driver: ' + JSON.stringify(log.errors));
 
-} else if (scenario === '5') {
+} else if (scenario === '6') {
   // document without core: the guarded IIFE must record one console.error,
   // not throw, and attach nothing.
   load('statum-document.js');
@@ -172,7 +210,7 @@ if (scenario === '1') {
     'error message: ' + JSON.stringify(log.errors[0]));
   check(!globalThis.statum, 'markdown attached window.statum without core');
 
-} else if (scenario === '6') {
+} else if (scenario === '7') {
   load('statum-core.js');
   load('statum-document.js');
   var before = handlerNames();
@@ -183,16 +221,16 @@ if (scenario === '1') {
   check(JSON.stringify(before) === JSON.stringify(after),
     'order changed on double registration: ' + JSON.stringify(after));
 
-} else if (scenario === '7') {
+} else if (scenario === '8') {
   load('statum-core.js');
   load('statum-document.js');
   var before = handlerNames();
-  globalThis.statum.registerAttribute('stm-prefill', function () {}, { after: 'stm-nope' });
+  globalThis.statum.registerAttribute('stm-widget', function () {}, { after: 'stm-nope' });
   check(log.warns.length === 1, 'unresolvable constraint did not warn: ' + JSON.stringify(log.warns));
   check(log.warns[0].indexOf('stm-nope') !== -1, 'warn names the missing anchor: ' + JSON.stringify(log.warns[0]));
   var after = handlerNames();
   check(after.length === before.length + 1, 'handler was not registered');
-  check(after[after.length - 1] === 'stm-prefill',
+  check(after[after.length - 1] === 'stm-widget',
     'handler did not keep registration position: ' + JSON.stringify(after));
 
 } else {

+ 30 - 0
tests/split/shim.js

@@ -90,4 +90,34 @@ export function installShim() {
   globalThis.addEventListener = function (t, f) { (listeners[t] = listeners[t] || []).push(f); };
   globalThis.removeEventListener = function () {};
   globalThis.__STATUM_TEST__ = true;
+
+  // Minimal fetch/Headers stub so scenarios can prove core's headless boot
+  // (the full-featured DOM version lives in tests/dom/shim.js).
+  var fetchHandler = null;
+  function Headers() { this._h = {}; }
+  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;
+  };
+  globalThis.Headers = Headers;
+
+  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 || []); }
+    };
+  }
+  globalThis.fetch = function (url, init) {
+    var res = fetchHandler ? fetchHandler(String(url), init || {}) : okResponse([]);
+    return Promise.resolve(res);
+  };
+  globalThis.__shim.setFetch = function (fn) { fetchHandler = fn; };
+  globalThis.__shim.okResponse = okResponse;
 }

+ 11 - 6
tools/statum-mkpstm/statum-mkpstm.vala

@@ -16,7 +16,7 @@ namespace Statum.Tools {
      * result is precompressed with identity/gzip/zstd/br.
      *
      * Legacy client references: a `<script pstm-res-src="statum.js">` (the
-     * pre-split single-file client) expands into the four module tags in
+     * pre-split single-file client) expands into the module tags in
      * canonical load order, with plain final `src` attributes. Explicit
      * module references the expansion covers are subsumed and later
      * duplicates dropped, so each module appears exactly once; one build
@@ -36,13 +36,18 @@ namespace Statum.Tools {
         private Dictionary<string, Xml.Doc*> fragments_by_name = new Dictionary<string, Xml.Doc*>();
 
         /**
-         * The Statum client ships as four modules with a strict load order
-         * (core first: every later module registers into the seams core
-         * installs). A legacy single-file `pstm-res-src="statum.js"` reference
-         * expands at build time into exactly these four tags.
+         * The Statum client ships as modules with a strict load order (core
+         * first: every later module registers into the seams core installs). A
+         * legacy single-file `pstm-res-src="statum.js"` reference expands at
+         * build time into exactly these tags.
+         *
+         * NOTE (rearchitecture): statum-forms.js was folded into
+         * statum-document.js (core is now headless), so the expansion set is
+         * these THREE modules. A separate upcoming module (the binding layer)
+         * will be added here when it lands — revisit this list then.
          */
         private const string[] CLIENT_MODULE_ORDER = {
-            "statum-core.js", "statum-document.js", "statum-markdown.js", "statum-forms.js"
+            "statum-core.js", "statum-document.js", "statum-markdown.js"
         };
 
         private const OptionEntry[] options = {