# 7. Editing components

> Rich text, sortable lists, uploads, undo and the other editing components, and how to build your own.

Source: https://clayjs.com/docs/guide/components/

## richclay: rich text (`editable`)

`editable` turns an element into a rich text editor in edit mode. It is the default plugin, using Squire for editing and DOMPurify on paste.

```html
<div class="desc" editable><p>Two paragraphs on <b>scope</b>.</p></div>
<div class="body" editable="toolbar-on-select"><p>…</p></div>
<h1 editable="single-line">Title</h1>
```

Mode tokens: `single-line` (Enter does nothing and pasted blocks flatten), `no-toolbar` (shortcuts only), and `toolbar-on-select` (the bubble toolbar appears only for a non-empty selection). Toolbar presets: `standard` (block menu with Paragraph, H1 to H3, Quote, Code block; bold, italic, underline, strikethrough, inline code, link, undo, redo, clear; bulleted and numbered lists, quote, indent, outdent), `inline`, and `minimal`.

Saves keep only the region's own markup, with every runtime attribute, toolbar, and Squire artifact stripped. Opening a page in edit mode does not change its bytes.

What it leaves out, and what to build instead:

| Missing | Build it |
|---|---|
| Tables, checklists, dividers, callouts, toggles, embeds | Separate blocks outside the rich region (the writer's block model, [Block writer](https://clayjs.com/docs/guide/recipes/#block-writer)) |
| Image insert, captions, alignment | An image block (`<figure>` with an `<img>` and an `editmode:contenteditable` caption), or `editor.squire.insertImage(src)` for inline images |
| Screenshot paste | Squire swallows image-only pastes. Listen for `pasteImage` (below) |
| Markdown shortcuts | Only `* ` and `1. ` start lists. Watch `editor.squire` `input` events and call `editor.setBlockType("H2")` for others |
| Slash menu | A `keyup` handler for `/` on an empty paragraph opening an `editor-ui` menu ([Block writer](https://clayjs.com/docs/guide/recipes/#block-writer)) |
| Change callback | Auto-mounted editors cannot take `onChange`. Use `new clay.RichClay(el).squire.addEventListener("input", …)` or a `MutationObserver` |

`new clay.RichClay(el)` returns the instance mounted on `el`. Use it to reach `squire`, `getHTML()`, `setHTML()`, `setBlockType(tag)`, and `registerButton(def)`.

Screenshot paste into an upload, from the writer:

```js
new clay.RichClay(body).squire.addEventListener("pasteImage", async (event) => {
  const item = [...(event.detail?.clipboardData?.items || [])].find((i) => i.type.startsWith("image/"));
  const file = item?.getAsFile();
  if (!file) return;
  const src = await imageSource(file);
  if (src) insertAfter(body.closest(".block"), "image", src);
});
```

Register custom toolbar buttons **per instance**, after mount. Calling `RichClay.registerButton` globally after load does not reach editors already mounted:

```js
const editor = new clay.RichClay(el);
editor.registerButton({ id: "stamp", label: "Stamp", icon: "<svg …></svg>", run: (e) => e.squire.insertHTML("<p>Approved</p>") });
editor.options.toolbar = [...clay.RichClay.presets.standard, "stamp"];
```

Traps:

- **Mode by attribute.** `editable` is inline mode and never sanitizes your authored markup on load. `data-richclay` without `editable` is card mode: it sanitizes in place on load with an allowlist that excludes `img`, so **it deletes images on every edit-mode open**. Use `editable`.
- **Nested `editable` regions are refused.** Only the outer region mounts. Siblings are fine.
- **Custom elements** (tags with a hyphen) are skipped for bare `editable`. Use `clay-editable`.
- **The floating toolbar mounts on `document.body`, not in the top layer.** Inside `dialog.showModal()` or a `popover`, it is unusable, though shortcuts still work. Do not put `editable` inside a modal dialog. The kanban shows the card itself as a fixed overlay instead ([Detail views: the record is the modal](https://clayjs.com/docs/guide/architecture/#detail-views-the-record-is-the-modal)).
- **Two undo histories.** Inside an `editable`, Cmd+Z goes to Squire's stack. Outside, it goes to page undo, which never records rich text. Typing in a rich region, clicking elsewhere, and pressing Cmd+Z does not undo that typing.
- **Squire's undo can roll back a peer's edit** in the same region because its history stores whole-region snapshots. Keep collaborative documents in many small regions (one `editable` per block) to limit the damage.
- **Many editors are expensive.** Each multi-line editor installs document-level listeners and its own Squire instance. Do not put `editable` on every cell of a grid; use `editmode:contenteditable`.
- **Paste uses an allowlist.** Tables, `<hr>`, `<details>`, `<figure>`, iframes, and inline styles are reduced to text. A pasted remote image keeps its hotlinked `src`. A pasted `data:` image bloats every save.
- **Cloning an active region** copies runtime attributes. Strip them with `clay.RichClay.stripElement(clone)` ([Element identity: `data-id` and `id`](https://clayjs.com/docs/guide/record/#element-identity-data-id-and-id)), or clone from a `<template>`.
- **`editable` is ignored in view mode.** Viewers get plain markup.

### The page editor's Markdown shortcuts

`editable` regions and the page editor are different editors with different Markdown input rules. The page editor (`plugins/page`) converts these as you type:

| Typed at the start of a block | Becomes |
|---|---|
| `# `, `## `, `### ` | heading 1, 2 or 3 |
| `- `, `* `, `+ ` | bulleted list item |
| `1. `, `1) ` | numbered list item |
| `[] `, `[ ] `, `[x] ` | checklist item, unchecked or checked |
| `> ` | toggle |
| ` ``` ` | code block, when the whole paragraph is the fence |
| `---` | divider, when the whole paragraph is the dashes |

| Typed around text | Becomes |
|---|---|
| `**bold**`, `__bold__` | bold |
| `*italic*`, `_italic_` | italic |
| `` `code` `` | inline code |
| `~~strike~~` | strikethrough |
| `[label](address)` | a link, if the address is a safe one |

An opening delimiter must follow the start of the line, whitespace, or opening punctuation. Neither delimiter may be escaped, so `\**this\**` stays literal text. Nothing converts inside inline code, and a shortcut typed during an IME composition converts when the composition ends. Inline shortcuts delete the delimiters and apply the mark in place, keeping any existing marks on the text. Block shortcuts retype the block rather than replacing it, so its `data-id` and position survive.

**Backspace immediately after a shortcut restores the typed characters**, delimiters included, through input-rule undo. It is a single history step, does not drop a peer's concurrent edit, and reapplies on redo. After another keystroke, the shortcut is plain text and Backspace deletes one character as usual.

None of this loads for a reader. The input rules, menus, and title adapter are editor code, and a Document that never enters edit fetches none of them. A live entry fetches them while the tab still shows view ([switching without a reload](https://clayjs.com/docs/guide/edit-mode/#switching-without-a-reload-clay-mode-switch-live)).

## sortable: drag to reorder

`sortable` on a container makes its children draggable in edit mode:

```html
<div class="columns" sortable="columns">
  <section class="column" data-id="col-todo">
    <header><span class="grip" sortable-handle show-when:editmode="true">⠿</span><h2 …>To do</h2></header>
    <ol class="cards" sortable="cards">
      <li class="card" data-id="c-brief"><span class="grip" sortable-handle show-when:editmode="true">⠿</span>…</li>
    </ol>
  </section>
</div>
```

- `sortable` drags `<li>` children in `<ul>` or `<ol>`, and all children otherwise, except `editor-ui` children.
- `sortable="name"` lets every container with the same name accept items from the others for cross-column drag.
- A `[sortable-handle]` inside the container (outside nested sortables) switches it to handle-only dragging. Nested sortables are separate.
- Order saves as child order. There is no order attribute.
- Every drop fires `clay:sorted` (bubbling) with `{ item, from, to, oldIndex, newIndex }`. In a cross-container move, it fires on the source container. Read `item.dataset.id` and `to.closest(...)`, not indexes.
- `onsorted="code"` runs on drop with `this` and `evt`.
- Containers added later bind after about 200 ms.
- `window.Sortable` is SortableJS 1.15.6. `Sortable.get(el).option(name, value)` changes an instance.

Traps:

- **Handle-only mode is decided when the container binds.** If handles are injected later (like the writer's gutters), set it yourself: `Sortable.get(blocks).option("handle", "[sortable-handle]")`.
- **One stray `[sortable-handle]` makes every other item undraggable** except by its handle.
- **`onsorting` cannot veto a move** because its return value is lost. Use `Sortable.get(el).option("onMove", (evt) => …)`.
- **Hidden template children count as items.** Keep blueprints in `<template>` outside the list.
- **Sortable leaves residue** (`draggable="false"`, `style=""`, drag classes) that saves. The `sortable` plugin strips it inside `[sortable]` lists, including from a mid-drag save. A Sortable you load and wire up yourself needs a snapshot hook to strip it.
- A peer frame arriving mid-drag merges under the drag. Rare at autosave cadence, but possible.
- Write durable side effects of a move in a `clay:sorted` handler (the kanban logs "Moved to Doing" into the card's activity). If a record attribute must match its container (`data-status`), set it there too.

## undo: whole-page undo

`?plugins=undo` provides `clay.undo`: Cmd+Z, Cmd+Shift+Z, and Cmd+Y over DOM mutations, restoring the same live nodes.

- Mutations outside a commit group into an "Edit" step after 500 ms idle. **Wrap every command** so it records as one labeled step:

  ```js
  const command = (label, fn) => (clay.undo ? clay.undo.commit(label, fn) : fn());
  command("Add card", () => list.append(card));
  ```

- `commit` must be synchronous. For async work (an upload or fetch), `await` it first, then make every DOM write in one synchronous `commit`. Pausing undo across an `await` loses the mutations made before the `await`.
- Not recorded: `editor-ui`, `no-undo`, `no-watch` regions, `richclay` regions, and every change a merge brings in.
- Attribute and text steps are skipped if what they would restore has changed since (for example, by a peer). Structural steps are not: undoing "Add card" removes the card even if a peer edited it since, and redo replays without checking. In a collaborative file, treat undo as safe only for the person's own recent work. If that matters, call `clay.undo.clear()` when a `clay:sync-applied` frame touches records in the history.
- Also exposed: `clay.undo.on("undo" | "redo" | "commit" | "clear", fn)`, `canUndo`, `canRedo`, and `history`.

Traps:

- **It takes Cmd+Z in every text field**, including inputs it does not record (such as a non-persist input), so their native undo is lost.
- History is per tab, capped at 100, and not saved.
- Undoing a delete re-inserts the original node even if a peer re-created an equivalent. Dedupe by `data-id` on `clay:sync-applied` if needed.

## Plain contenteditable

For titles, labels, list items, and table cells, use `editmode:contenteditable` with `plaintext-only` ([Mode attributes: one file for editors and viewers](https://clayjs.com/docs/guide/edit-mode/#mode-attributes-one-file-for-editors-and-viewers)). It has no toolbar, Squire instance, or formatting, and is cheap at any count. Handle Enter yourself by blurring or creating the next item, as the writer's to-do list does.

## upload and quickcrop

`clay.upload(file, { onProgress, signal })` posts a file you supply to the host and never rejects. **It is not a file picker** and inserts nothing. Supply the `File` from an `<input type="file">`, a drop, or a paste, and write the URL yourself.

```js
async function imageSource(file) {
  const result = await clay.upload(file);
  if (result.ok) return result.uploads[0].url;
  if (result.code === "unsupported") {
    return new Promise((resolve) => {
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result);
      reader.readAsDataURL(file);
    });
  }
  clay.toast?.(result.msg || "Upload failed", "error");
  return null;
}
```

- Embed a `data:` URL **only** on `code === "unsupported"` (the host does not store files). On `forbidden` (the host stores files, but not for this person on this document), `too-large`, `unsupported-type`, `payment-required`, and other failures, write nothing. Embedding a refused 20 MB photo would bloat every future save.
- Other codes: `unauthorized`, `forbidden`, `timeout` (may have landed; retrying is safe), `aborted`, `network`, and `bad-response`.
- Events: `clay:upload-start`, `clay:upload-progress`, `clay:upload-done`, and `clay:upload-error`.
- Host limits: HTML Clay and Hyperclay Local allow 25 MB per file; hyperclay.com allows 10, 20, or 100 MB by plan.
- The URL is a host path: `/_/uploads/assets-<document>/<name>`. Write it exactly as returned. Every address of the host serves it, so it survives renaming or moving the document. Never rebuild it from parts.
- Uploaded assets on hyperclay.com are publicly reachable by URL even when the document is private.

`clay.quickcrop(file, { aspect, maxWidth, maxHeight, type, quality })` opens a crop dialog and resolves `{ blob, dataURL, width, height }`, or `null` on cancel. It does not upload; wrap the blob as a `File` and pass it to `clay.upload`. Only one cropper opens at a time, and it sets `body.style.overflow = "hidden"` while open, which a save at that moment writes into the file.

Put the file picker for an image block in chrome: `<input type="file" accept="image/*" hidden>` inside an `editor-ui` wrapper, clicked by your menu.

## clay-ui: toasts and dialogs

`clay-ui` provides built-in notifications and modal dialogs:

- `clay.toast(message, type)` takes `success`, `error`, `warning`, or `info`. The message is text, never markup, with no handle to dismiss early.
- `clay.toastPersistent(message, type)` stays until closed.
- `clay.confirm(prompt)` resolves on confirm and **rejects with `undefined` on cancel**. Always pass a rejection handler: `clay.confirm("Delete this card?").then(del, () => {})`. `try { await clay.confirm(...) } catch (e) { e.message }` throws inside the `catch`.
- `clay.ask(prompt, onYes, defaultValue)` resolves with the typed text.
- **Every dialog prompt and title is HTML.** `clay.confirm(prompt, extraContent)`, `clay.ask(prompt)`, `clay.tell(title, ...paragraphs)`, and `clay.snippet(title, code)` insert their strings as markup. ``clay.confirm(`Delete "${card.title}"?`)`` runs whatever markup a collaborator typed into the title. Keep prompts constant, or escape record text first.
- `clay.modal` is a single modal with `title`, `html`, `yes`, `no`, `onYes`, `open()`, and `close()`. Every setting resets on close, and opening any dialog rejects the one already open.

**Never pass a live record element as `clay.modal.html`.** The modal moves the node into its runtime-only root and removes that root on close. The element is gone from the page, and a save during the dialog drops it from the file. Pass a form built from a template, and write values back to the record on confirm. For card detail, prefer the record-as-modal pattern ([Detail views: the record is the modal](https://clayjs.com/docs/guide/architecture/#detail-views-the-record-is-the-modal)).

ClayJS's own UI (notice, indicator, dialogs, toasts) uses inline reset styles, so page CSS cannot restyle it.

## clay-events, clay-dom and All

Plain delegated `addEventListener` handlers on `document`, as in the examples, avoid every trap below and are the default. If you use these helpers, watch for their traps:

- **`onclone="code"`** runs on every element clone, with `this` as the clone. Good for minting a fresh ID on duplicate. It does not run for `template.content.cloneNode()` (a fragment); clone `template.content.firstElementChild` instead. It also runs for library clones, so keep it pure.
- **`onclickaway="code"`** closes a menu on outside click. It runs on every outside click, even when already closed.
- **`onclickchildren="code"`** runs with `this` as the clicked direct child, for tab bars and segmented controls.
- **`onmutation` and `onglobalmutation`** run when the subtree or page changes. **A handler that writes into what it watches loops forever**, even writing the same text. Guard every write: `if (this.textContent !== n) this.textContent = n`. They do not fire for merged peer changes.
- **`onrender`** runs once per element at load and again whenever the element is inserted, including every drag and merge re-insert. Keep it idempotent.
- **`el.nearest.NAME`** searches outward through siblings and cousins. In a list, it reads (and with `val` or `text` assignment writes) a neighbor's field when the record lacks one. For record-scoped access, use `el.closest("[data-id]").querySelector(...)`.
- **`el.hide()`, `el.show()`, and `All(...).css()`** write inline styles that save. Use `clay-options` or a runtime stylesheet.
- **`cycleAttr`** cycles through values in alphabetical order across the whole document. Write status cycles explicitly: `const order = ["todo", "doing", "done"]; el.dataset.status = order[(order.indexOf(el.dataset.status) + 1) % order.length];`
- **`All`**: `All.name` and `All.length` are the function's own properties, so write `All("[name]")`. `sortBy("data-amount")` compares strings; pass a function.

## Libraries that rewrite content: highlighting, math, diagrams

Prism, highlight.js, KaTeX, and Mermaid replace source text with generated markup when they run. In edit mode, that goes wrong two ways:

- **The output is saved.** If the library runs after ClayJS reads the page, its spans or SVGs count as an edit, starting an autosave that writes them into the file.
- **Sync wipes it.** An outside change brings in plain source, replacing the decorated markup. The library does not run again, so the decoration disappears until reload.

For fixed content, such as code samples in a docs page, mark the block `clay="freeze"` so sync never touches its contents. For content people or agents edit, keep the plain source as the record and render the decorated version into a `clay="editor-ui"` mount. Redraw it on load and on `clay:sync-applied` ([Reconciling in app code](https://clayjs.com/docs/guide/live-sync/#reconciling-in-app-code)).

## OverType: a markdown editor

For a notes or journal app where Markdown is the format, OverType (`https://cdn.jsdelivr.net/npm/overtype@2.6.1/dist/overtype.min.js`) is a textarea under a styled preview. **The textarea is not the record.** Keep each entry's text in a text element of the record, put the editor in a `clay="freeze"` region outside the saved records, and bind it to the open record with `clay.bindText` (`?plugins=bind-text`):

```html
<article data-id="a"><pre><code class="entry-body"># Morning

Walk by the river.</code></pre></article>

<div id="writer" clay="freeze">
  <div class="overtype-container"><div class="overtype-wrapper"><textarea class="overtype-input"></textarea></div></div>
</div>
```

```js
const binding = clay.bindText(document.querySelector('#writer textarea'), {
  onWrite: (code) => { code.closest('article').dataset.updated = Date.now(); },
  onShow: () => overtype?.updatePreview(),
  onConflict: (typed) => note('Someone changed the same words while you were typing.', typed),
  onGone: () => note('This entry was deleted somewhere else.'),
});
binding.open(document.querySelector('.entry-body'), {
  key: 'a',
  resolve: () => document.querySelector('[data-id="a"] .entry-body'),
});
```

Three reasons keep the record as text and the textarea outside it:

- **Word-by-word merges.** A `<textarea>` merges as one value, so two people editing different paragraphs collide and one loses their words. A text element merges word by word, so both edits land.
- **No library attributes in records.** OverType stamps `id`, `autocomplete`, `spellcheck`, and Grammarly attributes onto a textarea. Inside a record, those save into the file and mark the page dirty at boot.
- **Readable without JavaScript.** An `<article>` holding `<pre><code>` renders in any browser and keeps the file smaller, whereas `<textarea persist>` shows nothing until the library runs.

`bindText` returns `{ open, close, flush, current, attach, composing }`. Every `input` writes into the record at once, so a save captures it as that record's text. Changes the editor did not make (a peer merge, a restored draft, or a JSON API write) appear in the textarea with the caret kept. During an IME composition, nothing is written or shown. When it ends, both edits apply unless they touch the same stretch of text; on conflict, the record keeps the other person's words and `onConflict` gets what was typed. Opening the same `key` after a merge replaced the record's element rebinds without touching the textarea. Opening another `key` flushes the open record and shows the new one with the caret at the start.

Keep one OverType instance for the life of the tab. Normalize OverType's layout state in an `onbeforesave` on the container, and call `binding.attach(textarea)` if a library ever swaps the element. A journal built this way keeps entries as `<article data-id>` records, clones its chrome from a `<template>`, and paints the open entry with a runtime stylesheet.

## Attributes from older Hyperclay files

Older Hyperclay files can carry attributes ClayJS ignores: `movable`, `movable-handle`, `prevent-enter`, `autosize`, `ajax-form`, `ajax-button`, `send-message`, and `upgrade`. Replace `prevent-enter` with a `keydown` handler, `autosize` with CSS `field-sizing: content`, `movable` with pointer events writing `data-x` and `data-y`, and `send-message` with the `message` plugin ([message: contact forms](https://clayjs.com/docs/guide/components/#message-contact-forms)). Old saved `<html editmode="false">` and hand-written `option-visibility-styles` style tags are dead weight; ClayJS strips the root attributes on the next save.

## sap.js: records, views and totals in markup

`sap.js` (also published standalone as `@panphora/sapjs`) is a reactive attribute layer whose only store is the DOM. Every change runs one pass: it reads the app from the live page, computes `calc:` fields in dependency order, and writes only the paints that changed. Use it to build the reactive parts of a malleable document app: totals, filtered and sorted views, a form that adds records, and an editor for the selected record.

**Before building an app with sapjs, read the sapjs guide: [saphtml.com/guide.html](https://saphtml.com/guide.html) (Markdown: [saphtml.com/guide.md](https://saphtml.com/guide.md)).** It covers designing records, `each=` views, the three kinds of state, detail editors and dialogs, three complete tested apps, and a trap table. The attribute reference is [saphtml.com/llms.txt](https://saphtml.com/llms.txt). This section covers only what is specific to running sapjs inside ClayJS.

```html
<main id="app" sap>
  <output calc:total="sum(state.txns, 'amount')" text:usd2="state.total">$900.00</output>
  <table>
    <tbody items="txns">
      <tr item template data-id=""><td><input type="number" step="0.01" bind="amount" persist></td></tr>
      <tr item data-id="t-k1"><td><input type="number" step="0.01" bind="amount" persist value="900"></td></tr>
    </tbody>
  </table>
</main>
<script src="https://clayjs.com/v1/sap.js"></script>
```

Core vocabulary: `sap` marks an app root. `items="list"` holds `[item]` records, and `[item][template]` (or a shared `<template items="list">`) is the blueprint. `bind` two-way binds a control or text leaf, and `state="data-due data-done:presence"` declares fields stored as attributes. `calc:`, `text:fmt`, `show`, `class:`, `css:`, `attr:`, and `effect` paint derived values. `each="expr"` renders a view of any array whose rows edit the records they show, and `detail="LIST by KEY"` projects one record into a panel or `<dialog>`. Actions are `trigger-add`, `trigger-remove`, `move:`, `sort:`, `set:`, and `confirm=`. `Sap(el)` is a write-through proxy, and `Sap.batch(label, fn)` makes one undo step. Done means no `sap ✗` console line and `Sap.status().ok === true`.

How it fits ClayJS:

- **The core loads by itself.** On any page with a `[sap]` root, ClayJS loads the sapjs core (the `derive` plugin, [Plugins](https://clayjs.com/docs/guide/loading/#plugins)). The core covers records (`bind`, `state=`, `items`, `item`) and paints (`text`, `text:fmt`, `show`, `attr:`, `class:`, `css:`, and the `show-when:` family). Markup that needs full sapjs (`calc:`, `each`, `detail`, `effect`, `invalid`, actions, or `Sap(el)`) halts with `E50`, which names it. Load `https://clayjs.com/v1/sap.js` for full sapjs; the markup stays the same, and full sapjs takes over from the core.
- **Painted values are derived.** Comparisons, dirty checks, autosave, undo, and merge inputs leave out what a paint owns: the attribute an `attr:` names, the one class token a `class:` names, the `--` property a `css:` names, the children of a `text` element, and `hidden` under `show`. A paint never marks the page unsaved, and two tabs that change different records while repainting the same total end with the right total and no conflict.
- **Ownership is read from the markup.** It comes from the element's own attributes, never from evaluating an expression, so it holds the same in the live page, a save clone, and a frame from disk. `attr:` never owns `id`, `data-id`, `class`, `style`, `value`, `checked`, `selected`, or a name the same element declares in `state=`; those stay record.
- **Repainted before anything records a baseline.** The engine mounts during boot after frozen regions record their markup and visitor drafts restore, so the boot baseline settles on the painted page. After every merge, it repaints right away before the saved, peer, and disk baselines and before `clay:sync-applied`.
- **Painted values are still saved** so the file shows them without JavaScript. When a repaint leaves the file's painted values different from the page's (an agent wrote a record and left the old paint), one clean tab with autosave on saves the painted page after a short random delay. Other tabs that repainted the same frame see that save and skip theirs.
- **Agents and `api` rules read and write records, never paints.** Point a rule at the `bind` element or a `state=` attribute (`"state": ".state"`), never at a painted attribute. A painted value on disk can lag the record until a tab repaints and saves it.
- Rows of an `each=` view carry `clay="editor-ui"`, so views never save or sync.

A status that sets text, color, and a label, with the state stored once:

```html
<main sap>
  <table><tbody items="steps">
    <tr item class="step" data-id="s1" attr:data-state="item.state">
      <td><span class="state" bind="state" attr:aria-label="'Status: ' + item.state">accepted</span></td>
    </tr>
  </tbody></table>
</main>
```

`tr[data-state="accepted"] .state { background: var(--ok) }` colors it, and `htmlfile data --write` or the JSON API writes only `.state`.

Rules for sapjs in a malleable file:

- **Store each record once as an element, and derive everything else.** Counts, totals, and card faces computed only from records are ordinary saved content. Paint them into the record markup and ship the file pre-painted so the first pass writes nothing (`mount writes 0`). A stale painted value repaints at mount; under ClayJS the paint heal saves it, and on other hosts sapjs logs `W30` and the value goes out with the next real save.
- **Per-tab values go in `clay="freeze"`.** This includes today's date, the locale, the URL hash, the search box, or runtime data. Freeze saves an element's contents as they were at load, so put per-tab values inside the frozen element or fill it from an `effect`, which re-runs every pass.
- **Per-tab state is `transient`** (`state="q:transient open:transient"`, and `transient` on the bound search input), or the URL hash through `clay-router.js`.
- **Filter with an `each=` view or a frozen stylesheet**, never with `show=` on saved records. `show=` paints `hidden`, which ClayJS treats as derived and never syncs, but a save still writes the saving tab's filter into the file.
- **Every `detail=` panel and `<dialog>` sits inside a `clay="freeze"` wrapper.** Its controls are painted with the open record's values and would save a second copy outside freeze. Do not use `editor-ui`, which removes the element from the file. Sapjs logs `W31` for a dialog without the wrapper.
- **Give records a `data-id`**, and `data-id=""` on blueprints so `$add` and `trigger-add` mint fresh IDs. Live sync pairs elements by it.
- **Put `persist` on bound inputs inside records** so typed values reach the attributes a save captures.
- **Undo, redo, and sync merges restore `persist` controls' live values** from their attributes, so sapjs reads what the file says. Controls without `persist` are not restored; give them `persist` or re-sync them yourself.
- **Hide blueprints in your own CSS** (`[item][template] { display: none }`); sapjs's own rule arrives only with the script. Sapjs also adds `[hidden] { display: none !important }` page-wide.
- A pass is batched: after a write, call `Sap.refresh()` before reading painted output. Expressions compile with `new Function`, so a Content Security Policy without `unsafe-eval` breaks them.

## cms: content-managed pages

`?plugins=cms` turns a page into a content-managed page. A JSON rules tag maps field names to selectors, and an "Edit content" button opens a sidebar form (or edits text in place) that writes straight into the DOM.

```html
<script data-rules-name="cms" data-rules-version="1" type="application/json">
{ "title": ".page-title", "intro": "p.intro", "hero": "img.hero@src", "plans": [".plan", { "name": ".name", "price": ".price" }] }
</script>
```

It fits fixed-layout content sites such as landing pages, menus, pricing, FAQs, and team pages, where a non-developer changes copy and images. It does not fit apps where editing is the product: its form is a separate surface from the content, new list items clone the first item's attributes (including its state), and rich text inside lists is flattened in the sidebar. A file can use both if the CMS owns the header copy, the app owns `<main>`, and the rules never reach into the app's region. Give every list that can become empty a `cms-template` seed hidden by CSS, not by the `hidden` attribute (which is copied into every new item). Add `sortable` to the plugin list for drag in the sidebar.

The rules tag can declare which editor opens. `data-cms-view="inline"` edits text in place and opens small popovers for everything else, while no attribute (or `"sidebar"`) opens the form in a side panel. The "Edit content" button opens whichever the page declares.

The CMS also edits how a page looks. A rule ending in `@--name` reads and writes one CSS custom property in an element's `style` attribute. For example, `"accent": "html@--color-accent"` with `a { color: var(--color-accent) }` in your CSS gives the owner a color picker. Any `--color-*` name gets a color picker, any `--font-*` name gets a list of 40 Google Fonts, and the CMS keeps one `<link data-hcms-fonts>` loading the fonts in use. For a layout choice, put `data-hcms-component:data-layout="variant"` and `data-hcms-options:data-layout="1:cols-1 2:cols-2 3:cols-3"` on a section and key your grid CSS on `[data-layout]`: the owner gets icon tiles, and the choice writes only the attribute. Style fields on `html`, `body`, or `:root` gather in a "Page style" group at the top of the form.

## message: contact forms

`?plugins=message` sends a visitor's form to the document's owner. On hyperclay.com, the owner gets it by email and in their messages.

```html
<form message-form="Thanks, I'll reply soon">
  <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <button>Send</button>
</form>
```

- On submit, the fields go as JSON to the host's `/_/message`. The form resets, the attribute's text shows as a toast, and the form fires `clay:message-sent` or `clay:message-failed` (`detail` is `{ ok, error }`). Call `event.preventDefault()` on it to draw your own confirmation instead of the toast.
- A form whose `action` is `/_/message` is taken over the same way, so a hand-written `<form method="post" action="/_/message">` works once the plugin loads.
- `clay.message.send(data)` sends from your own code. It resolves `{ ok, error }` and never rejects.
- **The bot check is invisible and needs the plugin.** When the visitor first focuses or types in the form, the plugin fetches a one-time token from `/_/form-token`, and the host refuses a message without one. A form posted without the plugin or with JavaScript off gets an error, never a silent drop.
- Field names are what the owner reads (`**email**: ...`). Do not name a field `formToken` or `formSignals`.
- A submit handler of your own that calls `preventDefault()` keeps the form to itself; the plugin leaves it alone.
