Declare it. Don't wire it.
Sap is a tiny reactive layer for hand-written HTML files. You write state, formulas, and paints as plain attributes; Sap rebuilds everything from the live DOM on every change. The DOM is the only store: one copy of your data, nothing to sync, and the file you save is the app. No build step. No virtual DOM. No state object.
Type in either field. The total recomputes and formats itself. No JavaScript to write.
You just watched an app compute with no JavaScript. Here is why that works, and why you would want it.
Most reactive tools keep two copies of your data: one in a JavaScript store, one on the page. Their whole job is keeping the two in step, and that sync is where the bugs live: stale views, hydration mismatches, the store insisting on one thing while the screen shows another.
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.
Saving the file saves the app, data and all. No database, no backend to keep alive: the data lives in the markup.
No private store, no privileged writer. A devtools edit, a console paste, a live-sync update from another tab: each is a real change Sap reads on its next pass, same as a keystroke.
Every paint targets a real attribute, so the saved file renders correctly before a line of JavaScript runs. What you read is what the app shows.
Human-scale tools: trackers, calculators, forms with real validation, small CRUD apps, prototypes that fit in one screen of readable HTML. And anything an AI writes, because every mistake answers back with the element and the fix. It competes with Alpine plus a sprinkle of JavaScript, not with React.
Huge datasets, private data behind permissions, or apps that are mostly a window onto a server. Sap says so out loud instead of pretending otherwise.
Saving is persistence. Copying is forking. View-source is the codebase.
One script tag, then write HTML. Sap auto-mounts every [sap] on load. This is a whole file: copy it, save it, open it.
<!doctype html>
<script src="https://cdn.jsdelivr.net/npm/sapjs/dist/sap.min.js"></script>
<main sap>
<label>Quantity <input type="number" bind="qty" value="3"></label>
<label>Unit price <input type="number" bind="price" value="10"></label>
<output calc:total="state.qty * state.price" text:usd="state.total">$30</output>
</main>
app.html.
The saved markup already reads $30, because every paint targets a real attribute, so the file is correct before JS even loads. Made a typo? Open the devtools console: Sap names the element, the mistake, and the fix (see section 07).
Everything in Sap is one of three things. Learn these and you know the library.
Fields live on the DOM: state= attributes, bind on controls, items for lists.
Formulas that read state and produce values: calc:, sorted by dependency, not document order.
Write to the page: text, show, attr:, class:, css:, and effect=.
Reads are expressions over state (the nearest scope) and item (the nearest row).
Writes go through Sap(el) in your handlers, or the action attributes. One write path, always.
The pass cycle — what one change does
Walk every state=, bind, and items into fresh objects. Nothing is retained from the last pass.
Deepest scope first, dependency order within each scope. total waits for subtotal automatically.
text, show, attr:, class:, css:, effects, validity. Only cells that differ are touched.
Per-pass objects are replaced wholesale on the next pass. Nothing is cached, so nothing can drift.
Every demo below runs the real library, live on this page. New here? The first few are the gentlest. The green line under each is what Sap prints when an app mounts: the fields, calcs, and paints it found, plus the pass time. Section 07 reads it line by line.
The form's bind="title" matches the row's title, so trigger-add fills the new row and clears the box. No JavaScript.
sort:FIELD reorders the rows and toggles direction. The row order is the entire sort state.
detail="LIST by KEY" projects the selected row into any container. Click a row to fill the inline panel; click Edit ▸ to open the same lens in a native <dialog>. Edits in either write back to the source row.
A per-row calc:match drives show. Hidden rows still count in the total, like a spreadsheet. The search box is transient: it filters live but is never written to the saved file.
move:to="list" moves a row to another list. The DOM move is the write. Works for keyboard users with zero extra code.
invalid="expr" sets a custom validity message. The submit button disables itself with pure CSS — no Sap attribute.
set:A click writes a field; show reads it back. No toggle state stored anywhere but the DOM.
Row proxies compose with array methods. Sap.batch groups them into a single undo entry.
trigger-reset — restore defaultsEdit the fields, then Reset. Controls restore from an explicit default=; attribute-carried state restores from its state="field=default" declaration.
The control is the contract — no modifiers, ever. Every input feeds one live object. Hit Edit markup ✎ to change the source and watch the app re-mount; edit the live app and the markup re-serializes, the same way a save does.
open really is stateA native <details> declares state="open" with presence semantics. The open panel serializes and renders pre-JS.
Every change is just the next pass's input, so there is one place state gets written and one model to learn. Typing in a field, a Sap(el) assignment, a set: click, a row move, a live-sync update from another tab: all funnel into the same scheduled recompute.
Writes converge — control writes synthesize events, paints stay silent
input/change → scheduleinput + changesetAttribute() → schedule directly, no synthetic eventThe honest caveats behind "one path": user typing starts as a native event; a programmatic write to a bound control synthesizes input/change so platform hooks observe it the same way; attribute-carried state= writes schedule directly with no event; row-order writes schedule after moving the nodes; and paints write silently. Different entry points, one recompute.
"The file you save is the app" is true for structure and authored values: the markup, the rows, a contenteditable's text. There is one honest nuance for typed form values, and one attribute that handles it.
When a user types into an <input>, sap updates the control's live .value and recomputes, but it does not copy that into the saved value= attribute. Only a programmatic write (Sap(el).x = v, set:, a reset, a projection) mirrors back to the attribute. So a plain "save the DOM" keeps everything you authored, plus contenteditable text, but misses values the user typed and never wrote through code.
On Hyperclay, the [persist] attribute closes that gap: it syncs a control's live value into its serializable attribute on every keystroke, so the typed value lands in the saved file. The rule of thumb: bind is reactivity; [persist] is durability. Put persist on any user-editable control whose value must survive a reload.
bind makes a control reactive; it does not make a typed value durable on its own. persist is matched per control — input[persist], textarea[persist], select[persist] — 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 — sap's carrier never mirrors a select | yes |
| textarea | no — content lives in .value, not the child text a save serializes | yes |
Mark a control or field transient and sap actively strips its value on every pass, so it never reaches the saved file: search boxes, scratch state, anything you don't want frozen into the document. Two safety facts:
bind on a type=password without transient halts the app at mount (E31); a type=file always halts (E32). A saved file must never carry a cleartext password.transient + [persist]: their intent is opposite. transient strips the value; [persist] forces it into the saved clone and wins, leaking exactly what transient promised to drop.Type in the search box (the readout proves the app reads it live), then click Show saved HTML. It dumps the live 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.
sap state is plaintext: a bind reads and writes a control's textContent, never its inner HTML. That keeps every value a scalar that round-trips as text, so view-source stays correct and one carrier serves every write. Rich, formatted editors live behind sap, not inside its state.
A bound contenteditable reads as plain text: paste bold or linked content and it flattens to the string on read. Formatting is markup, not state. What you get is native form controls, a plaintext contenteditable, and structure expressed as real DOM (lists, show, classes).
Type into the editable box. The mirror and the character counter update live, proving the bound value is the plain string. Paste bold or linked text — it flattens to plain text on read.
"No rich text" is about engine state, not the page. To embed a real editor — a formatting contenteditable, or Slate / TinyMCE / ProseMirror — tell sap to leave that subtree alone with sap-ignore. The pass walker never descends into it, the linter skips it whole (so a third-party editor's foreign attributes never trip E01), and the markup round-trips because the live DOM is saved as-is.
The Notion shape: sap owns the block list (items/item + trigger-add/move:/trigger-remove), and each block body is a sap-ignore editor mount. Add, remove, and reorder stay fully reactive; the prose stays rich.
sap-ignore stops the DOM walk, not event delegation. stopPropagation() the editor's input/change so it doesn't schedule needless passes; Hyperclay's capture-phase hooks still see it.
A rich editor churns the DOM. Put no-undo on the mount so its keystrokes don't flood the undo stack; the region stays watched, so autosave still fires. Avoid no-watch — it turns autosave off too.
sap can't read the prose, so you can't calc: or search over it. Need a word count? Mirror a derived plaintext into a normal field: Sap(el).body = editor.textContent.
Editors that keep canonical content in JS (a TinyMCE iframe, Slate's tree) leave the cloned DOM stale at save. Flush into the mount first via an onSnapshot hook or an inline [onbeforesave].
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 opposite of the plaintext-flatten demo above. Edit a body, reorder it, add one; the markup is never touched by the engine.
Sap prints a machine-readable green line on mount and answers structured questions on demand. Run these against the demos above (open your devtools console to see the formatted blocks too).
// click a button — output appears here and in the devtools console
Sap.status() | JSON twin of the green line: fields, calcs, paints, rows, mount writes, last pass. |
Sap.report() | Every error as {code, el, expr, problem, fix} — searchable, copy-pasteable. |
Sap.why(el) | How a field resolves: its declaring element, carrier, raw value, last paint. |
Sap.doctor() | Full-page audit: dead state, duplicate ids, drift, long expressions. |
Sap.refresh() | One synchronous pass. Set state, refresh, read the painted DOM on the next line. |
The complete v1 vocabulary. Reads are frozen expressions; writes go through Sap(el) or actions.
sap | Marks a mount root. Apps are isolated; lint stops at the boundary. |
state="a b:num c:bool d=x" | Attribute-carried fields. Bare = string, :num, :bool, name=default. |
bind="field" | Two-way bind on a control or text leaf. The control type is the value contract. |
items="name" | A list field; [item template] child is the row prototype, cloned by add. |
scope="name" | A nested state object, read as state.name from the parent. |
transient | Keep this control out of the saved file (search boxes, passwords). |
calc:name="expr" | Computed field on the nearest scope, ordered by dependency. Cycles hard-error. |
text / text:FMT | Paint textContent, optionally formatted (usd, pct, int, date, clock…). |
show="expr" | Toggle the native hidden attribute. |
attr:NAME="expr" | Paint an attribute. Native booleans (disabled…) toggle by presence. |
class:NAME / css:NAME | Toggle a class / write a --NAME custom property. |
effect="stmt" | A statement run after each paint: charts, document.title, third-party sync. |
invalid="expr" | Truthy string → setCustomValidity; gates native form submission. |
set:field="expr" | Write a field on click. |
trigger-add="list" | Add a row. On a <form> it fires on Enter, fills the new row from the form's matching-named fields, then clears them. |
trigger-remove / trigger-reset | Remove the nearest row / reset the scope to defaults. |
move:up / down / to="list" | Reorder a row, or move it to another list. |
sort:FIELD | Stable column sort; direction toggles statelessly. |
confirm="msg" | Gate a Sap action (set:, move:, sort:, trigger-*) or a form's trigger-add. Uses the platform's themed dialog when Hyperclay is loaded, native window.confirm otherwise. For a native onclick, call window.confirm() in the handler. |
detail="LIST by KEY" | Project the selected row into a panel; edits route back to the source. |
Sap(el).field = v | Live write-through proxy. Verbs: $add, $remove, $move, $reset. |
Sap(el) | Live write-through proxy onto the scope or row that owns el. Verbs: $add, $remove, $move, $reset. |
Sap.refresh() | Run one synchronous pass. Set state, refresh, then read the painted DOM on the next line. |
Sap.mount(root?) | Mount a root added after load, or rescan all [sap]. Already-mounted roots are skipped. |
Sap.batch(label, fn) | Run a bulk mutation; with Hyperclay undo present, label it as one undo entry. |
Sap.formats.x / Sap.config({formats}) | Register a custom text format — the one extension point. |
Sap.status() / report() / why(el) / doctor() | Inspect a mounted app: status twin · error list · field resolution · full-page audit (section 07). |
num · sum · count · avg · min · max · plural · days
sum(state.lines, 'linetotal') reads like a spreadsheet formula. Field-string forms are lint-checkable against your declared row fields.
A throwing paint keeps its last-good value, sets a sap-error beacon, and logs one error. Structural contradictions halt the app at mount, so the file stays byte-frozen.
E01 foreign dialect · E07 calc cycle · E22 bad format · E31 bare password.
The vocabulary is frozen on purpose: an unknown attribute is always a typo, never a plugin, so the did-you-mean guarantee holds. The one extension point is a custom text format: Sap.formats.eur = n => '€' + Number(n).toFixed(2), then use it anywhere a built-in goes, like text:eur="state.price". There are no custom directives or plugins in v1.
Sap files are easy for an LLM to write and verify, because every mistake answers back with a code, the exact element, and a copy-pasteable fix.
// A typo'd field name doesn't fail silently — it teaches: sap ✗ E12 unknown-state-key at <output text="state.totl"> — main[sap] > p > output problem: no field "totl" is declared in this scope. Declared: total, qty, price did you mean: state.total fix: text="state.total"
Foreign-dialect attributes (x-text, v-if, @click, :class) each map to their Sap
spelling. The definition of done is three checks: zero sap ✗, Sap.status().ok, and
zero [sap-error] elements. Sap.doctor() findings (dead state, long expressions, high-precision paints) are advisory, not blocking.