How sap works

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.

TL;DR

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>.

00Overview

What sap is, the mental model, the API surface, and the attribute vocabulary, sourced from the shipped engine (not the spec).

The core mental model

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)

1 · intake
read the DOM

Walk every state=, bind, items into fresh objects. Nothing retained from last pass. (pass.js:34-147)

→
2 · compute
run calc:

Deepest scope first, topological order within each scope. total waits for subtotal automatically. (pass.js:202-231)

→
3 · paint
write if changed

text, show, attr:, class:, css:, effects, validity. Only cells that differ are touched. (pass.js:233-237)

→
4 · discard
drop the objects

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)

a devtools edit · a console paste · a live-sync morph · a keystroke → all are just the next pass's input (README.md:109)

The main API surface

CallWhat it doesSource
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

The attribute vocabulary (what the engine actually accepts)

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.

Declare & bind

sapmount 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 templatelist field + row prototype
scope="name"nested state object
transientkeep this control out of the saved file
detail="LIST by KEY"project a selected row into a panel

Compute · paint · act

calc:namecomputed field, dependency-ordered
text · text:FMT · showpaint textContent / toggle hidden
attr:N · class:N · css:Npaint attribute / class / custom prop
effect · invalidpost-paint statement · setCustomValidity
set:f · trigger-add/remove/resetclick-write / row + scope verbs
move:up/down/to · sort:Freorder / cross-list / column sort
confirm · sap-ignoregate an action (themed dialog on platform) · opt a subtree out
Implementation vs. spec — read this first. This page documents the shipped engine in 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:

The whole idea, in six lines

Type in either field. The total recomputes and formats itself. No app JavaScript.

01Transient state

short answer

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.

Where the value lives vs. what serializes

Diagram B · runtime store vs. saved bytes (src/dom.js:139-153, src/carrier.js:85-90)

runtime (drives the app)
saved file bytes
bind="city" (normal)el.value = "Lisbon"
──▶
mirroredvalue="Lisbon"
bind="q" transientel.value = "abc"
──✕──
stripped(no value attribute)
state="secret:transient"el._sapTransient.secret = "hi"
──✕──
stripped(no secret attribute; decl stays)

The exact mechanism

Save-cycle scope, precisely. The shipped engine guarantees the transient value is excluded from serializable attributes (verified in 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.

One more precise fact the demo proves: user typing updates the live .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.

Durability: persist

sap 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.

The save-survival footgun. 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.
controldoes a typed value survive a plain save?add [persist]?
contenteditable / bound text leafyes — it is textContent, already in the saved DOMno
text / number inputno — typing updates .value, not the value attributeyes
checkbox / radiono — toggling updates .checked, not the checked attributeyes
select / select-multipleno — selecting updates .selected, not the selected attributeyes
textareano — content lives in .value, not the child text that outerHTML savesyes

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).

Show saved HTML

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.

reads root.outerHTML after the engine's strip
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.

The other carrier: 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).

Transient in action: live filter

The search box is transient. It drives a per-row calc:match → show, yet never persists. Hidden rows still count in the total.

02Rich text editing

short answer

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."

The mechanism

One honesty correction vs. the spec. The spec says plumbing rewrites text binds to 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.

Why it is architectural, not lazy

1 · escaping vs. injection

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.

2 · view-source must be correct

"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.

3 · one carrier, four paths

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).

What you get instead

A plaintext contenteditable, native form controls, and structure expressed as real DOM (lists, show, classes). Formatting is markup, not state.

Embedding a real editor (the escape hatch)

"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.

<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>

Three caveats when you embed (all code-grounded)

1 · event noise

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.

2 · undo / autosave

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.

3 · not in sap state

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.

JS-state editors

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).

A plaintext contenteditable bind

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.

The escape hatch: a block list with rich bodies

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.

03Extending sap / plugins

short answer

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.

The one extension API (with a working example)

// 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>

Why it is frozen (the real reason)

Loud failure / zero ambiguity

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.

Cache stability + agent-authorability

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.

Why there is no live "foreign dialect" demo on this page. Mounting <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

A registered custom format, live

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.

04All the ways to set state

short answer

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.

One recompute path (with its real write-path differences)

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)

typing in a bound control
Sap(el).field = v
set: / form trigger-add / detail edit
trigger-* / move: / sort:
declared state attr write
▶
schedule
▼
native input → browser mutates the live property → native input/change → schedule() (actions.js:231-245)
control programmatic write → carrier.write(): set live property → mirror serializable attr (unless transient) → fire synthetic input + change (carrier.js:120-123)
attribute state → setAttribute() / writeTransient() → schedule() directly, no synthetic event (scope.js:83-87)
row order → remove/move DOM rows → schedule() directly; order in the DOM is the state (actions.js:90-136)
paint → writes the DOM silently (no event) to avoid feedback loops; projection uses {silent:true} (pass.js:261-271)
any event or direct schedule → one coalesced pass on the next microtask (scheduler.js:18-25)

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.

The complete reference (every mechanism, grounded)

MechanismSyntaxHow it writesSource
Declared fieldstate="filter=all step:num=1 s:transient"seeds the scope object at intakedom.js:95-119; pass.js:34-50
bind string<input bind="name">live .value ↔ state.name; carrier writes mirror to value attrcarrier.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 savecontrol-serialize.js
bind number/range<input type="number" bind="age">NaN-safe via _sapLastGoodcarrier.js:19-29
bind checkbox<input type="checkbox" bind="done">boolean; mirror checkedcarrier.js:33-34,92-95
bind radio (by name)<input type="radio" name="g" bind="p">the checked radio's valuecarrier.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 savecarrier.js:39-42,69-73; control-serialize.js
bind date/time<input type="date" bind="due">carried as text kind; value = ISO string; mirroredcarrier.js:7-12,44-47
bind contenteditable leaf<span contenteditable="plaintext-only" bind="note">textContent; round-trips natively, no mirror neededcarrier.js:16,46-47,74-76
Proxy scalar writeSap(el).field = vcarrier write, else state= attr, else auto-declare on root (or E12/E15)scope.js:106-155
Proxy $addSap(el).$add('todos')clone template, strip template, fresh ids, append, schedulescope.js:170-187
Proxy $remove/$reset/$moverow.$remove() etc.remove / restore defaults / cross-list movescope.js:189-226
Sap.batchSap.batch('Delete', fn)one labeled undo entry around bulk writesplatform.js:5-13
set:set:tab="'pricing'"click → proxy[field] = valueactions.js:69-88
trigger-add (form)<form trigger-add="todos">add row; on a form, compose from matching binds + clear + refocusactions.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 writeactions.js:90-97,155-157
sort:FIELD<button sort:price>reorder rows; order is the state; toggles statelesslyactions.js:99-136
detail= write-backedit inside a detail panelroutes 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 controlactions.js:212-224
External mutationdevtools edit · console paste · live-sync morphnext pass reads it; same path as a keystrokeREADME.md:109; platform.js:17-45

Two-way binding by control type — editable

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.

editable ✎

set: — write a field on click

A click writes tab; show-when reads it back. No toggle state stored anywhere but the DOM.

form trigger-add + trigger-remove

The form's bind="title" matches the row's field, so submit clones the template, fills it, and clears the box.

Sap(el) verbs + Sap.batch

Row 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 write

Moving a row between lists IS the state change. Works for keyboard users with zero extra code.

sort:FIELD — order is the state

Click a header to reorder rows; direction toggles statelessly. The row order is the entire sort state.

trigger-reset — restore defaults

Edit 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.

05Modals / showing item details

short answer

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.

The mechanism

Two rules for a dialog in a saved file. Opening writes an 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.

One selection, two containers, no modal library

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.

06Verify it yourself

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.