# sapjs > sapjs is a reactive layer for hand-written HTML files. State, formulas and output are > plain attributes, and the DOM is the only store: every change rebuilds the whole state > tree by reading the live page, so there is no JavaScript state object that can drift. > One script tag, no build step, no virtual DOM. Because the data lives in the markup, > saving the file saves the app, rows and all. This file is the complete reference for agents writing sapjs apps. It is condensed from the source at https://github.com/panphora/sapjs and the human-readable site at https://saphtml.com. Every attribute name, helper, format and error code below was read out of the source. The npm package is `@panphora/sapjs`, the browser global is `Sap`, and the license is MIT-0 (MIT No Attribution). sapjs competes with Alpine plus a little hand-written JavaScript, not with React. It suits trackers, calculators, forms with real validation, small CRUD apps and prototypes that fit in one screen of readable HTML. It is a poor fit for large datasets, data behind permissions, or an app that is mostly a window onto a server. ## Install Classic script tag, auto-mounts every `[sap]` root on load: ```html ``` The bundle is an IIFE, about 49 KB minified. Do not add `type="module"` to that tag. Pin a version with `@panphora/sapjs@0.2.4` (or `@0.2`) in the URL. The same file is on unpkg at `https://unpkg.com/@panphora/sapjs/dist/sap.min.js`, and the site serves it at `https://saphtml.com/dist/sap.min.js`. Unminified: `dist/sap.js`. As a module: ```bash npm install @panphora/sapjs ``` ```js import Sap from "@panphora/sapjs"; ``` The ES module entry is `src/sap.js` (untranspiled ES2022+, no dependencies). Importing it has the same side effects as the script tag: it mounts every `[sap]` root on `DOMContentLoaded` (or immediately if the document has already loaded) and assigns `window.Sap`. sapjs needs a DOM, so it does nothing under SSR. ## Quick start A complete file. Save it as `app.html` and open it. No server, no build. ```html
$30
``` Typing in either box recomputes `total` and repaints the `` as `$30`. Pre-filling the output with the value it will paint is deliberate: the file then renders correctly before JavaScript runs, and sapjs reports `mount writes 0`, meaning it changed no bytes at mount. Leave it empty and the app still works, but mount writes 1 and sapjs logs a `W30` warning telling you to save once so the file settles. ## The pass: how everything works One change causes one pass, and a pass is the entire engine: 1. Intake. Walk the app root top to bottom, reading every `state=` declaration, every `bind` control and every `items` list into fresh plain objects. 2. Compute. Run `calc:` expressions, deepest scope first, and in dependency order inside each scope, so a calc that reads another calc evaluates after it. 3. Paint. Evaluate `text`, `show`, `attr:`, `class:`, `css:`, then `effect=`, then `invalid=`. A paint writes only when the new value differs from what is already there. 4. Discard. The objects are thrown away. Nothing is retained between passes except the compiled-expression cache. Because nothing is retained, a devtools edit, a console paste, a live-sync morph and a keystroke are all the same thing: input to the next pass. Passes are batched. A burst of writes coalesces into one pass on the next microtask, so reading painted output on the line right after a write still sees the old DOM. Call `Sap.refresh()` to force one synchronous pass and read the painted result immediately. A pass suppresses its own writes, so a paint can never schedule another pass. If an app does run away, a circuit breaker halts it after 50 passes in one macrotask and logs `E26` naming the recent triggers. ## Roots `sap` marks a mount root. Apps are isolated from each other: linting, the DOM walk, scope resolution and scheduling all stop at the root. A page may hold any number of roots. ```html
...
``` The root's `id`, or the value of `sap=` when there is no id, names the app in diagnostics. `Sap.mount(rootOrSelector)` mounts a root added after load and returns its app record; already-mounted roots are returned unchanged. `Sap.mount()` with no argument rescans the document and returns the newly mounted records. A `[sap]` root injected into the page later is picked up automatically by the mutation bridge. ## Declaring state Three attributes put a field into the state tree. ```html
``` `state=` is a space-separated list of declarations, each `name`, `name:type`, `name=default` or `name:type=default`. Types are `string` (the default), `num` and `bool`. A declared field's value lives in a same-named attribute on the declaring element, so `state="filter=all"` pairs with `filter="all"`. - Names must be valid identifiers. A hyphen halts the app with `E08`, because `due-date` parses as subtraction in an expression. - HTML lowercases attribute names, so field names are effectively lowercase. Write `calc:linetotal`, not `calc:lineTotal`. The dependency scanner that orders calcs only matches `[a-z][a-z0-9_]*` after `state.` or `item.`, so a camelCase name would also order wrongly. - Declaring a reserved sapjs word halts with `E05`, a global HTML attribute name with `E06`, the same name twice in one scope with `E04`, and `state="open"` on a `` with `E33` (use `showModal()` for a dialog; `state="open"` is fine on a `
` that also carries `scope=`, see below). A declared field with no matching attribute reads its default and writes nothing. The one exception: a field that a visibility verb (`show-when:` and family) reads is materialized into its attribute at mount if missing, so the literal comparison has something to match. `state=` is only read on a scope element: the app root, an element carrying `scope=`, an `[item]` row, or a `[detail]` panel. On a plain element it is silently ignored, which is the most common way a first sapjs app fails to work. Where it lands differs too: on a row the fields belong to `item`, and a `[detail]` panel's declarations belong to the enclosing scope so that the row's `set:` and the panel's key expression address one field. ```html
``` When a declared field's name is a native boolean attribute and the element really has that IDL property, sapjs reads it by presence rather than by value. That is what makes a native `
` work as state, and it needs a `scope=` to be read at all: ```html
Section A open

The open attribute is real DOM state: it serializes and renders before JavaScript.

``` `bind="field"` declares a field carried by a form control or a text leaf. `items="name"` declares a field that is an array of rows. `scope="name"` opens a nested state object read as `state.name` from the parent; scopes nest to any depth (`state.a.b.n`). `sap-ignore` on any element makes it and its subtree invisible to sapjs: the pass never walks into it, the linter skips it whole, and mutations inside it schedule nothing. This is the escape hatch for third-party widgets and rich-text editors. ## bind: two-way, by control type The control is the contract. There are no modifiers. | Control | Value read and written | |---|---| | `input[type=text]`, `email`, `url`, `date`, `time`, `search`, `tel`, `password` | string | | `input[type=number]`, `input[type=range]` | number | | `input[type=checkbox]` | boolean | | `input[type=radio]` (put `bind` on each radio in the `name` group) | the checked radio's value | | `input[type=hidden]` | string | | `textarea` | string | | `select` | the selected option's value | | `select[multiple]` | array of value strings | | `[contenteditable]` or an empty text leaf such as `` | `textContent` | Number reads never produce `NaN`: an empty field reads `0`, and a value the browser flags as bad input falls back to the `value` attribute or the last good number. Mount errors on `bind`: - `bind` on `input[type=file]` always halts (`E32`); files never serialize into an HTML file. - `bind` on `input[type=password]` halts unless the input also carries `transient` (`E31`). A saved file must never carry a cleartext password. - `bind` on an element that has child elements and is not `contenteditable` halts (`E20`), because the first write would wipe the subtree. - `attr:value`, `attr:checked` or `attr:selected` on a bound control halts (`E30`); `bind` owns those attributes. An `effect=` that assigns `value` or `checked` on a bound control halts for the same reason. - `text` or `text:FMT` on a form control halts (`E18`); paint into an `` or ``. `show=` on a control is allowed. ### step on a bound text input A `step` attribute on a bound text-kind input (`type=text`, `tel` or `search`) makes ArrowUp and ArrowDown adjust the value the way a native number input does, without the spinner. Shift multiplies the step by 10, and `min` and `max` clamp the result. Native `type=number` inputs are left alone. ```html ``` ### default: the reset value `default` on a bound control supplies the value `trigger-reset` and `$reset()` restore. Without it a reset falls back to the control's `value` attribute, or `checked` for checkboxes and radios. Attribute-carried state uses the default inside its own `state=` declaration instead. ```html
``` ## calc: computed fields ```html
``` A `calc:` field belongs to the nearest owner: the row when the element is inside an `[item]`, otherwise the nearest scope. Order is by dependency, not document position, so `total` waits for `subtotal` with no help. Deeper scopes compute before shallower ones, so a row calc is ready when the parent aggregates over it. A cycle logs `E07` naming the chain, then falls back to source-order evaluation: the values may be wrong, but the app keeps running. An expression that throws sets the field to `undefined`, logs `E24`, and puts a `sap-error` attribute on the element. Computed fields are read-only. Writing one from the scope that carries the `calc:` attribute is rejected with `E16`. Writing one from further out is not caught, but it never sticks either, because the next pass recomputes the field; all it leaves behind is a junk declaration on the root. ## Paints | Attribute | Writes | |---|---| | `text="expr"` | `textContent` | | `text:FMT="expr"` | `textContent`, run through the named format | | `show="expr"` | toggles the native `hidden` attribute (falsy hides) | | `attr:NAME="expr"` | an attribute; native booleans toggle by presence | | `class:NAME="expr"` | adds or removes one class | | `css:NAME="expr"` | sets the `--NAME` custom property in the inline style | | `effect="statement"` | runs a statement after paint, for side effects only | | `invalid="expr"` | passes a truthy string to `setCustomValidity`, empty string clears it | ```html

Done

``` Native boolean attributes recognised by `attr:`: `disabled`, `readonly`, `required`, `checked`, `selected`, `multiple`, `open`, `inert`, `autofocus`, `novalidate`, `hidden`. For visibility use `show=`, not `attr:hidden`, which warns with `W03`. A paint writes only what changed, and a throwing paint writes nothing: the element keeps its last good value, gets a `sap-error="E24"` attribute, and one attributed error is logged. `effect=` is compiled as a statement and is for side effects only: touch `el`, set `document.title`, drive a chart library. Assigning to `state.*` inside an effect does nothing, because it mutates a per-pass object that is then discarded. Write state through `onclick` and `Sap(this)` instead. `invalid=` is the one expression that fails quietly. If it throws, the field is treated as valid and nothing is logged, so guard those expressions against undefined or a bug there silently disables the gate. sapjs adopts three CSS rules at mount, through `adoptedStyleSheets` so they never serialize into the saved file (engines without constructable stylesheets get a `