A live, source-grounded explainer of the shipped engine. Every claim cites the real code in src/; every demo below runs the actual library inlined into this file. No network, no build step, no mocks.
The DOM is the only state store. On every change sap throws away its in-memory state and rebuilds it by reading the live DOM top to bottom, computes, paints what changed, then discards the objects. The file you save is the app.
The five answers, in one line each:
1. Transient values live only in live DOM (a runtime expando or the control's live .value) and the serializable attribute is actively stripped every pass, so they never save. 2. No rich text: a bind is textContent only by design (HTML-valued state would break "one write path / view-source is correct"); to embed a real editor, wrap its subtree in sap-ignore. 3. No plugin system: the vocabulary is frozen on purpose; the one extension point is custom Sap.formats. 4. Every way to set state converges on the same recompute pass; control-backed programmatic writes use synthetic input/change, while attribute-state writes schedule directly. 5. No modal coupling: the detail lens projects a row into any container, including a native <dialog>.
What sap is, the mental model, the API surface, and the attribute vocabulary, sourced from the shipped engine (not the spec).
Most reactive tools keep two copies of your data: one in a JS store, one on the page, and spend their lives keeping the two in sync. Sap deletes the second copy. The input's value is the quantity; the list items are the rows. When anything changes, sap reads the page, recomputes, and writes back. One copy, nothing to drift (README.md:23, src/pass.js:1-3).
Diagram A · the pass cycle — what one change does (src/pass.js:15-251)
Walk every state=, bind, items into fresh objects. Nothing retained from last pass. (pass.js:34-147)
Deepest scope first, topological order within each scope. total waits for subtotal automatically. (pass.js:202-231)
text, show, attr:, class:, css:, effects, validity. Only cells that differ are touched. (pass.js:233-237)
Per-pass objects are replaced wholesale next pass. The last set is parked on el._sapScope only so Sap(el) can read between passes. (pass.js:105, 239)
| Call | What it does | Source |
|---|---|---|
Sap(el) | Live write-through proxy onto the scope or row that owns el. Control-field writes use the carrier and synthetic events; declared-state writes update attributes or transient storage and schedule directly. | scope.js:59-155 |
Sap.refresh() | One synchronous pass. Set state, refresh, read painted output on the next line. | sap.js:119-121 |
Sap.batch(label, fn) | Run a synchronous bulk mutation. With Hyperclay undo present, label the captured mutations as one undo entry. | platform.js:5-13 |
Sap.mount(root?) | Mount a root added later, or rescan all [sap]. Skips already-mounted roots. | sap.js:100-113 |
Sap.formats.x · Sap.config({formats}) | Register a custom text format (the one extension point — see §3). | sap.js:129-134 |
Sap.status() · Sap.report() | JSON twin of the green line · JSON list of every error {code, el, expr, problem, fix}. | debug.js:66-99 |
Sap.why(el) · Sap.doctor() | How a field resolves · full-page audit (dead state, dup ids, drift). | debug.js:107-200 |
Sourced from the linter's allow-lists (src/lint.js:11-17) and error registry (src/errors.js), so this is the shipped surface, not the spec's wish-list.
sap | mount root + lint boundary |
state="a b:num c:bool d=x e:transient" | attribute-carried fields |
bind="field" | two-way; control type is the contract |
items="name" · item template | list field + row prototype |
scope="name" | nested state object |
transient | keep this control out of the saved file |
detail="LIST by KEY" | project a selected row into a panel |
calc:name | computed field, dependency-ordered |
text · text:FMT · show | paint textContent / toggle hidden |
attr:N · class:N · css:N | paint attribute / class / custom prop |
effect · invalid | post-paint statement · setCustomValidity |
set:f · trigger-add/remove/reset | click-write / row + scope verbs |
move:up/down/to · sort:F | reorder / cross-list / column sort |
confirm · sap-ignore | gate an action (themed dialog on platform) · opt a subtree out |
sapjs/. The design spec (sap-spec/) calls itself "v2" and drifts from the build in a few places. Where they disagree, the code wins and every demo here uses shipped spelling:
<main app> and offers Sap.app({compute, rows}) (02-vocabulary.md:9,39). The build mounts [sap] only (src/sap.js:83) and has no Sap.app. A <main app> demo would silently never mount.default= and rewrites text binds to plaintext-only (02-vocabulary.md:34,45). The shipped engine does neither (see §2, §4).05-...:28,100-102). That is a spec/platform concern, not implemented in this standalone engine (see §1).Type in either field. The total recomputes and formats itself. No app JavaScript.
A transient value lives only in the live DOM: a runtime-only JS expando (el._sapTransient) for declared state, or the control's live .value/.checked for a bound control. The matching serializable attribute is actively removed on every read and write, so the saved file never carries it. There are two transient carriers and both strip.
Diagram B · runtime store vs. saved bytes (src/dom.js:139-153, src/carrier.js:85-90)
state="secret:transient". parseStateDecl sets transient:true (dom.js:111-115). The value is kept in el._sapTransient; readTransient seeds it from the attribute-or-default once, then calls el.removeAttribute(name) and returns the stored value (dom.js:139-148). writeTransient stores and strips the same way (dom.js:150-153). Intake routes transient decls through readTransient (pass.js:42-45). The state= declaration token itself stays, so the field keeps existing.<input bind="q" transient>. The carrier's mirror() sees transient and removes value/checked instead of writing them (carrier.js:85-90). Intake also strips them right after reading a transient bound control (pass.js:63-66). The live property is the only store.bind on a type=password without transient halts the whole app at mount (E31); type=file always halts (E32) (lint.js:139-144). A file must never carry a cleartext password.mirror() delegates to serializeControlToAttributes (control-serialize.js, the single source of truth shared with hyperclayjs): text/number/checkbox/radio write their attribute, select writes selected on the chosen option, textarea writes a cursor-safe data-value (carrier.js:89-97). contenteditable/text-leaf need no mirror — they are textContent, saved as-is. mirror runs on a carrier write; a plain typed value is made durable by persist (below).test/transient-and-codes.test.js:33-41,71-77). It does not, on its own, re-derive paints that depend on a transient field at save time. So a transient search box that drives show/hidden on rows can leave those hidden attributes in the saved bytes. The snapshot re-derivation described by the spec (05-...:28,100-102) is not part of this standalone build (src/platform.js handles live-sync + undo refreshes, not save cloning). The "Show saved HTML" demo below therefore pairs a transient field with a normal one and shows nothing derived.
.value property but does not mirror to the value attribute on its own. Carrier writes mirror (carrier.write() → mirror(), run by Sap(el).x=v, set:, reset, projection — carrier.js:112-116), and a control marked persist mirrors on every edit (next section). So a typed value is durable when it is written or marked persist; the transient field is excluded either way.persistsap honors persist natively — no host platform required. On every edit of a [persist] control inside a [sap] root, the engine mirrors the live value to a serializable attribute through the shared serializer (actions.js:240 → control-serialize.js). hyperclayjs honors the same attribute on the platform through that same serializer, so the two agree by construction. (lint.js also allow-lists the host region attributes no-save/no-watch/no-undo.)
A textarea is the one control whose value the browser does not reload from an attribute (it reloads from child text), so its cursor-safe mirror lives in data-value. sap closes that loop on load: when it mounts a root it restores any textarea[data-value] into the textarea's content and strips the attribute (mount.js → rehydrateControlFromAttributes), so a persisted textarea round-trips standalone, no save step required. On the platform the save already finalized it to textContent, so there is nothing left to restore.
bind makes a control reactive; it does not by itself make a typed value durable. When a user types, sap updates the live .value and reschedules, but a plain bound control does not mirror to the serializable attribute (only carrier writes do). Mark it persist and the engine mirrors it on every edit (actions.js:240), so the typed value survives a plain DOM save — standalone, and on the platform where hyperclayjs does the same. Rule of thumb: bind is reactivity, persist is durability. Put persist on any user-editable control whose value must survive a reload. It is matched per control, so it goes on the control itself, not a wrapper.
| control | does a typed value survive a plain save? | add [persist]? |
|---|---|---|
| contenteditable / bound text leaf | yes — it is textContent, already in the saved DOM | no |
| text / number input | no — typing updates .value, not the value attribute | yes |
| checkbox / radio | no — toggling updates .checked, not the checked attribute | yes |
| select / select-multiple | no — selecting updates .selected, not the selected attribute | yes |
| textarea | no — content lives in .value, not the child text that outerHTML saves | yes |
sap and hyperclayjs do not fight: both write the same value through the same shared serializer (control-serialize.js), so the writes are idempotent. But never combine transient + persist: their intent is opposite. transient strips the value every pass; persist forces the live value into the saved bytes and wins, leaking exactly what transient promised to drop. On the platform, hyperclayjs's [persist] also deliberately skips hidden, password, and file: a bound hidden input already persists through sap's carrier mirror, and a password must stay transient or sap halts (E31, above).
Type in the search box (the summary proves the app reads it live), then click Show saved HTML. It prints the live root.outerHTML: the transient fields have no value/checked at all. Click Set City via Sap(el) to watch a programmatic write mirror into the attribute.
click the button to dump the live root…
highlighted = a value that survives into the file. The transient search box and VIP checkbox have no value/checked at all.
state="secret:transient"Declared transient state lives on the runtime expando el._sapTransient, never as a secret= attribute — yet the state= token itself stays, so the field still exists. The panel below is a live effect= reading the real engine on every pass (dom.js:139-153, scope.js:67-87).
The search box is transient. It drives a per-row calc:match → show, yet never persists. Hidden rows still count in the total.
No rich text, by design. A bind is textContent only for the text-leaf kind, and contenteditable="plaintext-only" is the supported authoring pattern. This is an architectural constraint, not a missing feature: state values are scalars that round-trip as text. HTML-valued state would break both "one write path" and "view-source is correct."
kindOf returns "leaf" for a contenteditable or bound text leaf (carrier.js:16). readBy leaf returns el.textContent; writeBy leaf sets el.textContent = String(v) (carrier.js:46-47, 74-76). A bound editable reads and writes plain text, never innerHTML.el.textContent with String() coercion (pass.js:297-303). Every carrier write coerces with String(v) (carrier.js:63-79). Values are scalars or arrays of scalars, never DOM fragments.bind on a container with element children halts with E20 ("a write would overwrite its children"), but a contenteditable container with children binds fine because it is treated as a leaf (lint.js:149-153, test/transient-and-codes.test.js:126-138). The editable still reads as textContent, so any inner markup is dropped from state.contenteditable="plaintext-only" (02-vocabulary.md:14,45). The shipped linter does not stamp or enforce that — it accepts any contenteditable value except "false" (lint.js:149-153). So plaintext-only is the documented authoring pattern you should write; the carrier reads/writes textContent either way.If a field held HTML, painting it via textContent would show literal <b> tags, and painting via innerHTML would be an injection vector that does not deterministically round-trip.
"The file you save is the app, correct before JS runs" breaks if a saved value does not match what renders. Scalars-as-text always match.
The scalar contract lets one carrier serve intake, proxy-set, undo replay, and mount-seed. An HTML-valued kind would violate the serializable-scalar invariant (05-...:46,100).
A plaintext contenteditable, native form controls, and structure expressed as real DOM (lists, show, classes). Formatting is markup, not state.
"No rich text" is about engine state, not the page. You can embed a real editor — a formatting contenteditable, or Slate / TinyMCE / ProseMirror — by telling sap to leave that subtree alone. The seam is sap-ignore.
isInert returns true for sap-ignore, so the pass walker never descends into it and sap neither reads nor paints inside (dom.js:14-16,25). The editor's own markup is left exactly as authored; it round-trips because Hyperclay saves the live DOM as-is.sap-ignore subtree is skipped whole during mount analysis, so foreign markup or a third-party editor's attributes inside it never trip E01 (lint.js:33-35). (A template subtree is still scanned; only sap-ignore is fully exempt.)items/item + trigger-add/move:/trigger-remove); each block body is a sap-ignore editor mount. Add, remove, and reorder stay fully reactive; the prose stays rich.<main sap>
<ul items="blocks">
<li item template>
<input bind="kind"> <!-- block metadata: reactive -->
<button move:up>↑</button>
<button trigger-remove>✕</button>
<div sap-ignore no-undo
contenteditable="true">…</div> <!-- the real editor: sap never reads this -->
</li>
<!-- …authored rows… -->
</ul>
<form trigger-add="blocks"><input bind="kind" required></form>
</main>
sap-ignore stops the DOM walk, not event delegation. sap's input/change listeners sit on document and schedule a pass for any event bubbling out of [sap], ignored subtree or not (actions.js:231-245,252-253). Firewall: stopPropagation() the editor's input/change. sap's listener is bubble-phase, so it stops; Hyperclay's [persist] is capture-phase (enablePersistentFormInputValues.js:50,64) and autosave is a MutationObserver, so neither is disturbed.
A rich editor churns the DOM. Put no-undo on the mount so its keystrokes do not flood Hyperclay's undo stack; the region stays watched, so autosave still fires (region-policy.js:75). Avoid no-watch here: it also turns off autosave-on-edit and undo for the subtree (region-policy.js:73,90), so a prose-only change would never mark the page dirty.
sap cannot read the prose, so you cannot calc:/count/search over it. If you need a word count or search index, mirror a derived plaintext into a normal field with a programmatic write: Sap(el).body = editor.textContent.
Editors that keep their canonical content in JS rather than the DOM (a TinyMCE iframe, Slate's React tree) leave the cloned DOM stale at save time. Flush content into the mount before serialize: register onSnapshot (runs on save and live-sync) or an inline [onbeforesave] (save only) (snapshot.js:55-57,132-134).
Type into the editable box. The mirror and the character counter update live, proving the bound value is the plain string. Try pasting bold or linked text — it flattens to plain text on read.
sap owns the list (add, remove, reorder are reactive), but each block body is a sap-ignore contenteditable that keeps its <b>/<i> markup — the exact opposite of the plaintext-flatten demo above. Edit a body, reorder it, add one; the markup is never touched by the engine.
There is no plugin system, and the vocabulary is frozen. The only real extension point is custom text formats: Sap.formats.x = fn or Sap.config({ formats }). The expression-helper signature is frozen forever. It is frozen on purpose, to keep failures loud and the contract zero-ambiguity.
// register a custom format — either form works
Sap.formats.eur = (n) => "€" + Number(n).toFixed(2);
Sap.config({ formats: { gbp: (n) => "£" + Number(n).toFixed(2) } });
// then use it anywhere a built-in format goes
<span text:eur="state.price"></span>
formats object lives in helpers.js:67-91. applyFormat(name, v) looks it up and throws a tagged E22 for an unknown name, or for a numeric built-in format applied to non-finite input (helpers.js:97-114).Sap.formats = formats (sap.js:129) and Sap.config({formats}) which does Object.assign(formats, options.formats) (sap.js:131-134).["state","item","el","root","fmt","num","sum","count","avg","min","max","plural","days"] (compile.js:6). Custom formats extend the fmt namespace object passed at runtime, never the parameter list, so the source-keyed compile cache never invalidates (compile.js:26-41).A foreign-dialect attribute (x-text, v-if, @click, :class) hard-errors with E01 and teaches the sap spelling (errors.js:69-82, lint.js:69-77). If authors could add vocabulary, an unknown attribute could not be told apart from a typo, and the did-you-mean guarantee collapses.
Extending the parameter list would invalidate the source-keyed cache; the fmt namespace sidesteps that. And a finite, frozen vocabulary is exactly what makes sap files reliably writable and checkable by an LLM.
<output x-text="…"> would intentionally produce sap ✗ E01 and a [sap-error] beacon, which would fail this page's own verification gate. So here is the engine's real console output verbatim — what block() prints via console.error (errors.js:116-121). Sap.report() returns the same facts as a JSON object instead: {ok, app, halted, errors[], warnings[]} (errors.js:137-146).
sap ✗ E01 foreign-dialect at main[sap] > output — x-text="state.total" problem: "x-text" is a foreign-dialect attribute; Sap has no x-text directive fix: use text="state.x" (Sap paints text only) — or wrap the subtree in sap-ignore if intentional
Sap.formats.eur was registered before mount. Type a price; the text:eur paint formats it as euros next to the built-in text:usd2.
State is "set" by anything that changes the DOM, because every change is just the next pass's input. The deliberate write surfaces are: state= declarations, two-way bind= per control type, the Sap(el) write-through proxy and its $-verbs, the action attributes (set:, trigger-*, move:, sort:), form trigger-add, and detail= write-back. They converge on one scheduler/pass model; control-backed programmatic writes share the carrier event path, while attribute-state and row-order writes schedule directly.
Diagram C · writes converge, control writes synthesize events, paints stay silent (src/carrier.js:107-129, src/actions.js:231-245, src/scope.js:83-87)
input/change → schedule() (actions.js:231-245)input + change (carrier.js:120-123)A programmatic write to a bound control looks like typing after it hits the carrier, so recompute and platform hooks can observe it through the same event surface. The honest caveats: user typing starts as a native event, attribute-carried state= writes skip synthetic events and schedule directly, row-order writes schedule after moving DOM nodes, and paints write silently.
| Mechanism | Syntax | How it writes | Source |
|---|---|---|---|
| Declared field | state="filter=all step:num=1 s:transient" | seeds the scope object at intake | dom.js:95-119; pass.js:34-50 |
| bind string | <input bind="name"> | live .value ↔ state.name; carrier writes mirror to value attr | carrier.js:44-47,96-100 |
| bind textarea | <textarea bind="bio"> | text kind; live .value ↔ state.bio; cursor-safe data-value mirror, finalized to textContent on save | control-serialize.js |
| bind number/range | <input type="number" bind="age"> | NaN-safe via _sapLastGood | carrier.js:19-29 |
| bind checkbox | <input type="checkbox" bind="done"> | boolean; mirror checked | carrier.js:33-34,92-95 |
| bind radio (by name) | <input type="radio" name="g" bind="p"> | the checked radio's value | carrier.js:35-36; pass.js:57-60 |
| bind select / multiple | <select bind="plan">, <select multiple bind="tags"> | value / array of values; mirrors the selected attribute so the choice survives a save | carrier.js:39-42,69-73; control-serialize.js |
| bind date/time | <input type="date" bind="due"> | carried as text kind; value = ISO string; mirrored | carrier.js:7-12,44-47 |
| bind contenteditable leaf | <span contenteditable="plaintext-only" bind="note"> | textContent; round-trips natively, no mirror needed | carrier.js:16,46-47,74-76 |
| Proxy scalar write | Sap(el).field = v | carrier write, else state= attr, else auto-declare on root (or E12/E15) | scope.js:106-155 |
Proxy $add | Sap(el).$add('todos') | clone template, strip template, fresh ids, append, schedule | scope.js:170-187 |
Proxy $remove/$reset/$move | row.$remove() etc. | remove / restore defaults / cross-list move | scope.js:189-226 |
Sap.batch | Sap.batch('Delete', fn) | one labeled undo entry around bulk writes | platform.js:5-13 |
set: | set:tab="'pricing'" | click → proxy[field] = value | actions.js:69-88 |
trigger-add (form) | <form trigger-add="todos"> | add row; on a form, compose from matching binds + clear + refocus | actions.js:183-209 |
trigger-remove/trigger-reset | <button trigger-remove> | $remove() / $reset() | actions.js:149-150 |
move:up/down/to | <button move:to="doing"> | DOM move is the write | actions.js:90-97,155-157 |
sort:FIELD | <button sort:price> | reorder rows; order is the state; toggles statelessly | actions.js:99-136 |
detail= write-back | edit inside a detail panel | routes to the source row's carrier (silent projection paints) | actions.js:236-243; pass.js:261-271 |
| Native form reset | <button type="reset"> | replays synthetic input/change per control | actions.js:212-224 |
| External mutation | devtools edit · console paste · live-sync morph | next pass reads it; same path as a keystroke | README.md:109; platform.js:17-45 |
The control is the contract, no modifiers. Edit the markup on the right and the live app re-mounts; edit the live app on the left and the markup re-serializes (the same finalizeControlForSave a real save runs). Every input feeds one live object.
set: — write a field on clickA click writes tab; show-when reads it back. No toggle state stored anywhere but the DOM.
trigger-add + trigger-removeThe form's bind="title" matches the row's field, so submit clones the template, fills it, and clears the box.
Sap(el) verbs + Sap.batchRow proxies compose with array methods. Sap.batch groups them into one undo entry. The delete is gated with a plain window.confirm() in the handler: confirm= only gates sap action attributes (set:, move:, sort:, trigger-*), not a native onclick (actions.js:16-37,144-170).
move:to — the DOM move is the writeMoving a row between lists IS the state change. Works for keyboard users with zero extra code.
sort:FIELD — order is the stateClick a header to reorder rows; direction toggles statelessly. The row order is the entire sort state.
trigger-reset — restore defaultsEdit the fields, then Reset. Note: the shipped engine reads explicit default= on controls (it does not auto-stamp them), so this demo authors them; attribute-state restores from its state="f=dflt" declaration.
No modal library needed. The detail="LIST by KEYEXPR" lens projects a selected row into any container and routes edits back to the source row. On a <div> or <form> it toggles hidden. On a native <dialog> it calls showModal() when a row matches and close() when none does, so a dialog needs no JavaScript.
LIST by KEYEXPR, resolves the list (walking through nested lists, so lists.cards finds a card in any list), evaluates the key and finds the record whose $key matches. A malformed spec or a throwing key expression just hides or closes the container, silently.Sap(this) inside the panel resolves the projected row, so $remove() and $move() act on the source row.method="dialog" button) clears a plain state.NAME key. With any other key the dialog stays closed until the key changes. An items= list inside a detail= panel is mount error E17.open attribute, so wrap the dialog in an element with clay="freeze", which saves it as it first appeared (clay="editor-ui" would remove it from the file; sap warns W31 without the wrapper). And declare its key :transient, so which row is open stays per tab instead of being saved and synced. state="open" on a <dialog> is still mount error E33.Click a row to fill the inline <div>. Click Edit ▸ to set the dialog's own key, and sap opens the native <dialog>; Done closes it and clears the key. Edit name or email in either; it writes back to the source row and both update.
This page is held to sap's own agent contract (demos.html:1173-1175): every demo mounts sap ✓, Sap.status().ok is true, and there are zero [sap-error] beacons — three of the contract's four clauses (the fourth, an empty Sap.doctor(), is advisory on a teaching page). Run the checks below; they call the real console API on the demos above.
// click "Run page gate" — the verdict appears here and in the devtools console
Note on the editable demo: re-mounting fresh markup leaves the old, now-disconnected root in the internal order array. Sap.refresh() skips disconnected roots (sap.js:120), but Sap.status() still counts them, so after you edit, the app count rises while connected apps stay green. The gate below reports connected apps separately.