The browser client now ships as four ES5, dependency-free scripts with a strict load order — and core is headless:
<script src="/_statum/resource/statum-core.js"></script>
<script src="/_statum/resource/statum-document.js"></script>
<script src="/_statum/resource/statum-markdown.js"></script>
<script src="/_statum/resource/statum-binding.js"></script>
Load order is a contract. statum-core.js must be first: it owns the slot
protocol (state store, persistence, directives, SSE/SharedWorker realtime,
entrypoint/action requests) and creates window.statum. The canonical order is
core → document → markdown → binding (markdown and binding are
order-independent of each other). The former statum-forms.js module was
folded into statum-document.js (and its resource now 404s).
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):
<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 writes a state expression into form controls as properties,
not attributes, every render cycle:
<input type="text" name="name" stm-prefill="profile.name">
<select name="theme" stm-prefill="profile.theme">…</select>
<input type="checkbox" name="news" stm-prefill="profile.news">
The control matrix:
textarea — writes .value (null/undefined
become ''; undefined leaves the element untouched)..value; multiple: sets each option's
.selected from an array of string values..checked, coerced truthy/falsy.Radio — per-element: give every member of the group the same
expression and the one whose value matches is checked.
<input type="radio" name="cadence" value="daily" stm-prefill="profile.cadence">
<input type="radio" name="cadence" value="weekly" stm-prefill="profile.cadence">
File inputs are skipped: assigning .value from a script is forbidden
by the platform, so there is nothing sensible to write.
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.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.
A form whose own stm-action settles can clear itself on either outcome:
<form stm-action="profile.saveAction" stm-clear-on-success>…</form>
<form stm-action="profile.retryAction" stm-clear-on-error>…</form>
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.
Action elements gained the success mirror of stm-on-error:
<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):
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
The CommonMark engine (unchanged, just moved) is callable directly, on the
same code path the stm-markdown binding uses:
statum.markdown.render(src); // → safe HTML string
statum.markdown.renderTo(el, src); // replaces el's content
Escape-first posture unchanged: raw source HTML is neutralised and
javascript:/data: URLs never survive as hrefs.
Module authors extend the render walk without touching core:
statum.registerAttribute('stm-foo', function (el, scope) {
// called once per element per render cycle when el carries stm-foo
}, { after: 'stm-html' }); // or before: 'stm-class.'
A trailing dot makes the name a prefix matcher ('stm-class.' matches
stm-class.hidden); the handler then iterates the element's attributes
itself. after/before name another registered attribute and insert
relative to it — order matters (stm-prefill runs before stm-class.*;
markdown renders before stm-h-demotion demotes). A constraint naming an
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.
statum-binding.js (fourth script tag, order-independent of markdown) adds
variables usable in any other stm-* expression. All are lazy-cached: a
binding evaluates at most once per render pass, only when read; cycles log
an error and read as undefined instead of crashing the page.
<div stm-global-vat="0.2"></div> <!-- page-wide, any position -->
<div stm-local-total="price * (1 + vat)"> <!-- this subtree only -->
<span stm-text="total"></span>
</div>
<div stm-declare-username>
<input stm-local-username-from-value> <!-- live property; redraws per keystroke -->
<p stm-if="username.length < 3">Username is too short</p>
</div>
-from-{attribute} forms source the variable from an attribute
(MutationObserver, redraw only when the value actually changes — the walk's
own attribute writes never loop). On form controls from-value and
from-checked read the live property instead, so typing updates the
variable immediately. stm-declare-{name} reserves a name for a subtree so
a descendant can bind it and siblings of the binder can read it.
Migration: stm-def-{name}-as is removed; write stm-local-{name}
(same subtree semantics, now lazy). statum-mkpstm emits stm-local-{name}
for pstm-input fragment parameters.
A response can ask the client to re-fetch the page's entrypoint for the current URL and apply its directives in place — no page reload:
{ "type": "reenter" }
Use it after an action that invalidates page-level state. reenter runs
after form-reset/form-submit and before navigate/post (navigation
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.
The former stm-def-{name}-as attribute is gone — replaced by
stm-local-{name} (see the binding section). The single-file client
(statum.js) remains gone: legacy references still expand at build time
into the four module tags above.