Reactive HTML, in attributes.

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.

Get started See it move GitHub

The whole idea, in six lines

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


    

Why the page is the program

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.

save

Saving is persistence

Saving the file saves the app, data and all. No database, no backend to keep alive: the data lives in the markup.

open

Anything can write

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.

honest

View-source is honest

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.

Reach for Sap

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.

Reach for something else

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.

01Your first app, in one file

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>
  1. Copy the block and save it as app.html.
  2. Double-click it. No server, no build step. It runs.
  3. Type in either box: the total recomputes and formats as currency. You wrote no app JavaScript.

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

02The whole model in three words

Everything in Sap is one of three things. Learn these and you know the library.

structure

Declared state

Fields live on the DOM: state= attributes, bind on controls, items for lists.

compute

Derived values

Formulas that read state and produce values: calc:, sorted by dependency, not document order.

paint

Output + effects

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

1 · intake
read the DOM

Walk every state=, bind, and items into fresh objects. Nothing is retained from the last pass.

2 · compute
run calc:

Deepest scope first, dependency order within each scope. total waits for subtotal automatically.

3 · paint
write if changed

text, show, attr:, class:, css:, effects, validity. Only cells that differ are touched.

4 · discard
drop the objects

Per-pass objects are replaced wholesale on the next pass. Nothing is cached, so nothing can drift.

a devtools edit · a console paste · a live-sync update · a keystroke → all are just the next pass's input

03Live demos

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.

A todo list, in attributes

The form's bind="title" matches the row's title, so trigger-add fills the new row and clears the box. No JavaScript.


    

Stateless column sort

sort:FIELD reorders the rows and toggles direction. The row order is the entire sort state.


    

One selection, two containers, no modal library

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.


    

Live filter (hide, never remove)

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

move:to="list" moves a row to another list. The DOM move is the write. Works for keyboard users with zero extra code.


    

Cross-field validation, natively

invalid="expr" sets a custom validity message. The submit button disables itself with pure CSS — no Sap attribute.


    

Tabs with set:

A click writes a field; show reads it back. No toggle state stored anywhere but the DOM.


    

Bulk operations as one-liners

Row proxies compose with array methods. Sap.batch groups them into a single undo entry.


    

trigger-reset — restore defaults

Edit the fields, then Reset. Controls restore from an explicit default=; attribute-carried state restores from its state="field=default" declaration.


    

Two-way binding, by control type

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.


        
      

Where open really is state

A native <details> declares state="open" with presence semantics. The open panel serializes and renders pre-JS.


    

04One write path

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

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 → the browser mutates the live property → native input/change → schedule
programmatic control write → set the live property, mirror the serializable attribute (unless transient), fire a synthetic input + change
attribute statesetAttribute() → schedule directly, no synthetic event
row order → move or remove DOM rows → schedule directly; order in the DOM is the state
paint → writes the DOM silently, no event, so it can never trigger a feedback loop
any event or direct schedule → one coalesced pass on the next microtask

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

05Persistence & transient state

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

What survives a save, and what needs help

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.

The save-survival footgun. 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.
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 — sap's carrier never mirrors a selectyes
textareano — content lives in .value, not the child text a save serializesyes

transient: the opposite of persist

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:

Show saved HTML

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.

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

06Text & rich editors

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.

Plaintext by design

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

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. Paste bold or linked text — it flattens to plain text on read.

Embedding a real editor (the escape hatch)

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

Quiet the event noise

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.

Undo & autosave

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.

Not in sap state

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.

JS-state editors

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

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 opposite of the plaintext-flatten demo above. Edit a body, reorder it, add one; the markup is never touched by the engine.

07Every app is inspectable

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.

08Reference

The complete v1 vocabulary. Reads are frozen expressions; writes go through Sap(el) or actions.

Declare & bind

sapMarks 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.
transientKeep this control out of the saved file (search boxes, passwords).

Compute & paint

calc:name="expr"Computed field on the nearest scope, ordered by dependency. Cycles hard-error.
text / text:FMTPaint 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:NAMEToggle 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.

Act

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-resetRemove the nearest row / reset the scope to defaults.
move:up / down / to="list"Reorder a row, or move it to another list.
sort:FIELDStable 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 = vLive write-through proxy. Verbs: $add, $remove, $move, $reset.

JavaScript surface

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

Helpers in every expression

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.

Loud, attributed failures

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.

Extending sap

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.

09Built for agents

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.