Guide · Complete

Building malleable HTML files with ClayJS

Contents

This is the complete reference for building malleable HTML files with ClayJS: one HTML file that is the whole app, edits itself in place, saves itself back to its host, and merges edits from other tabs, other people and agents. Read it top to bottom once. After that, sections 2 (the skeleton), 9 (architecture) and 13 (checklist and trap index) are the parts to return to.

It is written for a developer or an LLM building full apps with inline editing and inline controls: kanban boards with card detail, block writers, finance trackers, CRMs, flashcards, trackers. It covers the CMS plugin only in passing (section 8.12), because an app whose product is editing should not route editing through a form sidebar.

Every behavior stated here was read from ClayJS 1.8.2 source or executed. The four example files below are complete, tested apps that this guide quotes. A test suite boots each one in jsdom with the real loader and checks what it saves, how it merges a peer's edit, and what a viewer receives. Open any of them and view source, or download one and open it in HTML Clay to edit it:

File What it shows
examples/skeleton.html The smallest correct app: a sortable reading list with persisted checkboxes, a derived summary, a tab-local filter, sync and undo. Start every new file from it.
examples/kanban.html Columns and cards with cross-column drag, a card detail view that is the card itself, checklists, an activity log, a filter, undo.
examples/writer.html A block document: rich text blocks, headings, callouts, to-do blocks, dividers, images with upload, a gutter with drag handle and block menu, a slash menu, an outline and a word count.
examples/finance.html A budget built on sap.js: categories with budgets, transactions, computed totals saved into the file, search, CSV import and export.

Code in this guide assumes the loader URL https://clayjs.com/v1/. Section 3 covers versions. This guide is also served as plain Markdown at clayjs.com/guide.md for agents, and its ideas are drawn as diagrams in the visual guide.


1. The model

1.1 Two forms of one file

A malleable HTML file exists in two forms.

A save turns the open page back into the saved file. ClayJS does this by cloning the whole document, removing everything marked as runtime-only, applying a few transforms, serializing, and posting the full HTML to the host (POST /_/save). There is no partial save and no separate data store. The document is the database.

1.2 Three kinds of DOM

Every element on an open page belongs to one of three kinds. Decide which before you write it.

Kind What it is Where it lives Saved Sent to peers Edited by people
Record The work: card titles, checklist items, amounts, block text, order Ordinary elements, attributes, text and persist control values yes yes yes
Chrome Runtime UI: toolbars, menus, filter boxes, gutters, status text, the style that paints the open card Elements created at load inside clay="editor-ui" no no no
Derived Values computed from the record: counts, progress, totals, outlines clay="editor-ui" (tab only) or clay="no-dirty" (saved, remote wins) only if no-dirty only if no-dirty no

Nearly every bug in a malleable file is one of these three leaking into another: chrome saved into the file, a derived count merged as if someone typed it, a record edited only in a copy that never reaches the DOM.

1.3 The rules

  1. The DOM is the record. Everything durable is an element, an attribute, text, child order, or the value of a control marked persist. Never keep the authoritative copy in a JavaScript object.
  2. Chrome is built at runtime inside clay="editor-ui". It never reaches the file, a peer or undo history. Keep its blueprint in a <template> and clone it after clay.ready.
  3. Derived values are recomputed, never stored as edits. Write them into editor-ui elements, or into clay="no-dirty" elements when the saved file must show them.
  4. Every repeated item has a unique data-id from birth. Mint a fresh one on every create and every duplicate. Sync pairs elements by it.
  5. Per-tab state stays out of the record. Which card is open, the filter, the current view: URL hash, localStorage or memory, painted through a runtime <style> or editor-ui elements.
  6. A command writes the record once, in one place, wrapped in an undo commit. The mutation is what autosave, undo and sync see.
  7. Adopt the file as it is on load. Never rewrite the record at boot: it makes the page dirty, it can fall outside the save baseline, and it races peers.
  8. Reconcile after changes you did not make. A peer's frame, an agent's disk edit and an undo replay change the DOM under you. Rebuild derived and chrome state on clay:sync-applied and on undo and redo.

1.4 What the host does

ClayJS runs in the page. A host serves the file and accepts saves. The client never decides who may write: it sends the save, and the host accepts or refuses it. Hosts that run ClayJS pages today:

Section 12 covers what each host adds.


2. The skeleton file

Start every app from examples/skeleton.html. It is short enough to read in full, and every later section refers back to its parts. It is a reading list: sortable rows, a checkbox and a title per row, an Add button, a summary line and a filter.

<!DOCTYPE html>
<html lang="en" autosave>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reading list</title>
<style>
  html:not([editmode="true"]) [show-when\:editmode="true"] { display: none }
  /* app styles */
</style>
</head>
<body>
<main id="app">
  <h1 editmode:contenteditable inert-contenteditable="plaintext-only">Reading list</h1>
  <ul id="items" sortable="items">
    <li data-id="r-mmm"><span class="grip" sortable-handle show-when:editmode="true" aria-label="Drag">⠿</span><input type="checkbox" persist viewmode:disabled disabled checked> <span class="title" editmode:contenteditable inert-contenteditable="plaintext-only">The Mythical Man-Month</span><button type="button" data-action="remove" show-when:editmode="true" aria-label="Remove">×</button></li>
    <li data-id="r-sicp"><span class="grip" sortable-handle show-when:editmode="true" aria-label="Drag">⠿</span><input type="checkbox" persist viewmode:disabled disabled> <span class="title" editmode:contenteditable inert-contenteditable="plaintext-only">Structure and Interpretation of Computer Programs</span><button type="button" data-action="remove" show-when:editmode="true" aria-label="Remove">×</button></li>
  </ul>
  <button type="button" data-action="add" show-when:editmode="true">+ Add book</button>
</main>

<template id="item-tpl">
  <li><span class="grip" sortable-handle show-when:editmode="true" aria-label="Drag">⠿</span><input type="checkbox" persist viewmode:disabled> <span class="title" editmode:contenteditable contenteditable="plaintext-only"></span><button type="button" data-action="remove" show-when:editmode="true" aria-label="Remove">×</button></li>
</template>

<template id="chrome-tpl">
  <p id="summary"></p>
  <input type="search" id="filter" placeholder="Filter" aria-label="Filter">
  <style id="view-state"></style>
</template>

<script src="https://clayjs.com/v1/clay.js?plugins=sync,sortable,undo,indicator&exclude=richclay"></script>
<script src="https://clayjs.com/v1/clay-options.js"></script>
<script src="https://clayjs.com/v1/clay-ui.js"></script>
<script>
clay.ready.then(() => {
  if (window.__appStarted) return;
  window.__appStarted = true;

  const items = document.getElementById("items");
  const newId = (prefix) => prefix + "-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
  const command = (label, fn) => (clay.undo ? clay.undo.commit(label, fn) : fn());

  clay.onSnapshot?.((root) => {
    for (const el of root.querySelectorAll(".sortable-chosen, .sortable-ghost, .sortable-drag")) {
      el.classList.remove("sortable-chosen", "sortable-ghost", "sortable-drag");
      if (!el.classList.length) el.removeAttribute("class");
    }
    for (const el of root.querySelectorAll('[draggable="false"]')) el.removeAttribute("draggable");
    for (const el of root.querySelectorAll('[style=""]')) el.removeAttribute("style");
  });
  const setText = (el, text) => { if (el.textContent !== text) el.textContent = text; };

  const chrome = document.createElement("div");
  chrome.setAttribute("clay", "editor-ui");
  chrome.append(document.getElementById("chrome-tpl").content.cloneNode(true));
  items.before(chrome);
  const filter = chrome.querySelector("#filter");
  filter.value = new URLSearchParams(location.hash.slice(1)).get("q") || "";
  filter.addEventListener("input", () => {
    history.replaceState(null, "", filter.value ? "#q=" + encodeURIComponent(filter.value) : location.pathname + location.search);
    render();
  });

  function render() {
    const rows = [...items.querySelectorAll(":scope > li")];
    const done = rows.filter((li) => li.querySelector("input").hasAttribute("checked")).length;
    setText(chrome.querySelector("#summary"), `${done} of ${rows.length} read`);
    const q = filter.value.trim().toLowerCase();
    const hidden = q ? rows.filter((li) => !li.textContent.toLowerCase().includes(q)) : [];
    setText(chrome.querySelector("#view-state"), hidden.map((li) => `#items > [data-id="${CSS.escape(li.dataset.id)}"]{display:none}`).join("\n"));
  }

  function syncCheckboxes() {
    for (const box of items.querySelectorAll("input[type=checkbox]")) box.checked = box.hasAttribute("checked");
  }

  document.addEventListener("click", (event) => {
    const button = event.target.closest("[data-action]");
    if (!button || !clay.isEditMode) return;
    if (button.dataset.action === "add") {
      const li = document.getElementById("item-tpl").content.firstElementChild.cloneNode(true);
      li.dataset.id = newId("r");
      command("Add book", () => items.append(li));
      li.querySelector(".title").focus();
    }
    if (button.dataset.action === "remove") {
      const li = button.closest("li");
      clay.confirm("Remove this book?").then(() => command("Remove book", () => li.remove()), () => {});
    }
  });

  document.addEventListener("keydown", (event) => {
    if (event.key === "Enter" && event.target.matches?.('[contenteditable="plaintext-only"]')) {
      event.preventDefault();
      event.target.blur();
    }
  });

  document.addEventListener("clay:sync-applied", () => { syncCheckboxes(); render(); });
  clay.undo?.on("undo", syncCheckboxes);
  clay.undo?.on("redo", syncCheckboxes);
  window.addEventListener("hashchange", () => { filter.value = new URLSearchParams(location.hash.slice(1)).get("q") || ""; render(); });
  new MutationObserver(render).observe(items, { subtree: true, childList: true, attributes: true, characterData: true });
  render();
});
</script>
</body>
</html>

What each part does, and the section that explains it:

The test suite checks this file opens without becoming dirty, saves the record and no chrome, keeps the filter out of the file, keeps undo and the screen in step, mints unique ids, merges a peer's edit beside an unsaved local edit, and gives a viewer inert text and disabled checkboxes.


3. Loading

3.1 The loader tag

<script src="https://clayjs.com/v1/clay.js?plugins=sync,sortable,undo,indicator&exclude=richclay"></script>

plugins= and exclude= are comma lists. Only the first of each parameter counts. Unknown names warn and are ignored.

3.2 Plugins

Plugin Default Loads in view mode Adds
richclay on no the editable attribute, clay.RichClay
source on no byte-preserving saves, clay.source
sync off yes live sync, clay.conflicts, clay.morph, the section notice and presence
sortable off no the sortable attribute, window.Sortable, clay:sorted
undo off no clay.undo (whole-page undo, takes Cmd+Z for the window)
indicator off no the save status chip
upload off no clay.upload
quickcrop off yes clay.quickcrop
cms off yes clay.cms; implies quickcrop and upload
wire off yes clay.wire, the channel to a local agent process
ai-edit off no select text, ask an agent to rewrite it; implies wire
demo off yes fakes saving into localStorage for public demos

A good default for apps: ?plugins=sync,sortable,undo,indicator, plus upload for images or attachments, plus exclude=richclay when there is no rich text.

3.3 Satellites

Satellites are separate classic script tags. Each publishes a promise on clay.loaded.

Tag Promise Adds Section
clay-ui.js clay.loaded.ui clay.toast, toastPersistent, ask, confirm, tell, snippet, modal; automatic save feedback toasts 8.6
clay-options.js clay.loaded.options show-when:, hide-when:, option:, option-not: visibility 4.3
clay-events.js clay.loaded.events onclickaway, onclickchildren, onclone, onmutation, onglobalmutation, onrender 8.7
clay-dom.js clay.loaded.dom el.nearest, el.val, el.text, el.exec, cycleAttr, clay.dom.createContentView 8.7
all.js clay.loaded.all All(selector) collection helper 8.7
clay-utils.js clay.loaded.utils clay.utils.debounce, throttle, cookie.get/remove, slugify, copyToClipboard
clay-internals.js clay.loaded.internals clay.internals: capture, transforms, raw save
clay-data.js clay.loaded.data clay.extractData, clay.applyData 9.9
sap.js clay.loaded.sap Sap, the reactive attribute layer 8.11

Satellites do not wait for clay.ready and work without clay.js at all, including in view mode and on a static host. Await the promise before using what it adds: await clay.loaded.ui before clay.toast in code that runs early. Code inside a clay.ready callback can usually use clay.toast?.(...) safely, since the satellites finish loading at about the same time, but the optional call costs nothing.

clay.standalone.js bundles the loader, every plugin and every satellite into one 2 MB file for offline use. plugins= still decides what runs. With it, remove the satellite tags and always await clay.loaded.sap or .data before use.

3.4 clay.ready

clay.ready is a promise that resolves with window.clay once boot is done and every plugin has loaded. clay:ready fires on document at the same moment. Before it resolves, members like clay.save and clay.undo do not exist. Start app code with clay.ready.then(...).

You can set configuration before the tag, and the loader merges into it: window.clay = { saveToast: true }.

3.5 Theming ClayJS's own UI

ClayJS draws a few things on your page: dialogs (clay.ask, clay.confirm, clay.tell, clay.snippet, clay.modal) and the people plugin's name prompt. By default they take your page's font, and on a page with a plain solid background, its background and text colours. Everything else comes from ClayJS's own look, Bevel.

To match them to your design, set window.clayTheme before the loader tag:

<script>
window.clayTheme = {
  tokens: { font: "Inter, sans-serif", text: "#263d4e", surface: "#ffffff", border: "#e0e7ee", accent: "#0c2635", accentText: "#ffffff", radius: "8px" },
  parts: { "dialog.title": { base: { "font-size": "18px", "font-weight": "600" } } }
};
</script>
<script src="https://clayjs.com/v1/clay.js?plugins=people"></script>

Your stylesheet still cannot reach these elements: every declaration is inline and !important, so a stray button { ... } rule never breaks a prompt. On a phone, dialog buttons stay at least 44px tall whatever the theme asks for.


4. Edit mode and view mode

4.1 How the mode is decided

clay.isEditMode is decided once at load, by the first of these that applies:

  1. ?editmode=true or ?editmode=false in the URL. Only the literal true means edit.
  2. <html viewonly> in the file means view. So does a response carrying only the pre-1.9.0 token htmlclaytoken (an out-of-date HTML Clay, which also shows an update bar).
  3. window.clayEditMode, set by a script before boot.
  4. A savetoken attribute the host put on <html> (HTML Clay 1.9.0 and newer).
  5. The isAdminOfCurrentResource cookie (hyperclay.com, Hyperclay Local).

Edit mode only asks for the editing UI. On a host that has not authorized this person every save is refused (savestatus="error"). The host decides who writes.

clay.isOwner reflects the cookie alone. On HTML Clay an editable page has isOwner === false. Use clay.isEditMode for "can edit", never isOwner.

clay.toggleEditMode() flips ?editmode= and reloads.

4.2 The root attributes

At load ClayJS sets editmode="true|false" and pageowner="true|false" on <html>, in both modes. In edit mode it also sets savestatus (saving, saved, error, offline, conflict) once the page has settled. All three are removed from every save, as are the host's savetoken and documentetag. The saved file's <html> carries none of them.

Consequence: before ClayJS runs, editmode is absent, not "false". CSS keyed on html[editmode="false"] shows edit chrome to visitors until boot. Key on html:not([editmode="true"]) instead.

4.3 Showing and hiding edit controls: clay-options

Load clay-options.js and mark edit-only controls with show-when:editmode="true":

<button type="button" data-action="add" show-when:editmode="true">+ Add book</button>

The satellite compiles each distinct pattern into one CSS rule in <style data-name="option-visibility"> in <head>, so showing and hiding costs no JavaScript and respects the element's own display when shown. That style is saved. After the first save, a viewer's browser hides edit controls at first paint, before any script runs.

Before the first save there is no saved rule. Add the fallback rule to your own stylesheet in every file, as the skeleton does:

html:not([editmode="true"]) [show-when\:editmode="true"] { display: none }

The family:

Attribute Visible when
show-when:NAME="a|b" (alias option:NAME) the element or any ancestor has NAME equal to a or b
hide-when:NAME="a|b" hidden when the element or any ancestor has NAME equal to one of them; shown otherwise, including when no ancestor has NAME
option-not:NAME="a" some ancestor has NAME with a value other than a; hidden when no ancestor has NAME

4.4 Mode attributes: one file for editors and viewers

Four attributes make an element behave differently by mode. A save transform writes the inert form into the file, and edit mode activates it on load. View mode never loads the code, so a viewer sees exactly what the file holds.

Attribute In the saved file In edit mode
editmode:contenteditable contenteditable renamed to inert-contenteditable (value kept) contenteditable restored
editmode:onclick onclick renamed to inert-onclick onclick restored
editmode:resource on <script>, <style>, <link> type prefixed inert/ the inert/ prefix stripped and the element re-inserted so it runs
viewmode:disabled, viewmode:readonly on controls disabled / readonly added removed

There is no general editmode:<anything>; these four are all.

<span class="title" editmode:contenteditable inert-contenteditable="plaintext-only">Title</span>
<input type="checkbox" persist viewmode:disabled disabled>
<input type="number" bind="amount" persist viewmode:readonly readonly value="72">
<script editmode:resource type="inert/text/javascript" src="board-editor.js"></script>

Author records in the saved form, as above. A file written with live contenteditable or without disabled gives every viewer editable text and live checkboxes until the owner's first save rewrites it. The test suite checks the examples' view mode from the very first open.

Write templates in the active form. The transform does not reach <template> content, and a clone is inserted while the owner is editing. A clone carrying inert-contenteditable would not be editable until reload. On the next save the transform converts the clone like any other element.

<template id="item-tpl">
  <li><input type="checkbox" persist viewmode:disabled> <span class="title" editmode:contenteditable contenteditable="plaintext-only"></span></li>
</template>

contenteditable="plaintext-only" is the right value for titles, labels and cells: no formatting, no pasted markup. Make Enter blur instead of inserting a line break, as in the skeleton's keydown handler. Use editable (8.1) only where rich text is the point.

4.5 What a viewer gets

In view mode window.clay keeps ready, isEditMode, isOwner, toggleEditMode, Mutation, region, readData, writeData and hasUnsavedChanges (always false), plus whatever view-mode plugins and satellites add (clay.conflicts and clay.morph with sync, clay.cms, clay.quickcrop, clay.toast and the rest). There is no save, getHTML, addDocumentTransform, onSnapshot, undo, RichClay, upload, and no markDirty unless sync is loaded (it is harmless there).


5. The record

5.1 What a save writes

A save writes the current document with these changes:

Everything else on the page is written as it is. That includes things builders forget:

Never saved: JavaScript objects, localStorage, canvas pixels, scroll position, focus, selection, shadow DOM content (only the host element's light DOM and attributes), iframe content, the live value of a control without persist, and password, hidden and file input values even with persist.

5.2 Regions: the clay attribute

clay="token token" on an element applies to it and all its descendants. The bare attribute form (<div no-save>) means the same.

Token Saved file Sent to peers Starts autosave Counts as unsaved In undo Use for
(none) current yes yes yes yes the record
editor-ui removed no no no no all runtime chrome and tab-only derived values
no-save removed carried in the bytes, never applied no no yes rarely; prefer editor-ui
no-snapshot removed no (each tab keeps its own) no no yes part of editor-ui
freeze inner HTML as at load no no no yes an authored mount whose runtime content must never save
no-dirty current yes, remote wins no no yes derived values the saved file must show (9.4)
no-trigger-autosave current yes no yes yes a heavy editor whose saves you batch yourself (6.3)
no-watch current yes, remote wins no no no rarely: high-frequency content that is disposable
no-undo current yes yes yes no content with its own undo (richclay sets it at runtime)
no-data current yes yes yes yes content the data API and extractData must skip

editor-ui bundles no-data no-save no-snapshot no-watch no-undo. Use editor-ui for chrome, not no-save. A no-save element is still inside the snapshot live sync sends to peers. Peers never apply it, but its text travels to every edit-mode tab (viewers receive the saved file, without it), and no-save alone still records undo steps. Older docs advised clay="no-save no-watch" for chrome; editor-ui replaces that.

Legacy spellings still work: save-remove is no-save no-undo, save-ignore is no-dirty no-undo, save-freeze is freeze no-undo, mutations-ignore is no-watch, snapshot-remove is no-snapshot.

Traps in this table:

5.3 persist: form controls

A control's typed value is a property, not an attribute, and serialization only sees attributes. Without persist, a typed value is lost on save: the file keeps the authored value. Put persist on every <input>, <textarea> and <select> whose value is record.

<input type="checkbox" persist viewmode:disabled disabled checked>
<input type="number" step="0.01" persist value="312.40">
<textarea persist></textarea>
<select persist><option>todo</option><option selected>doing</option></select>

After undo, redo or a merge, the screen can disagree with the file. Undo and sync restore the attribute, but the browser's checkbox and input display follow the property once the user has touched the control. Re-sync the property from the attribute on all three events, as the skeleton and kanban do for checkboxes:

function syncCheckboxes() {
  for (const box of root.querySelectorAll("input[type=checkbox][persist]")) box.checked = box.hasAttribute("checked");
}
document.addEventListener("clay:sync-applied", syncCheckboxes);
clay.undo?.on("undo", syncCheckboxes);
clay.undo?.on("redo", syncCheckboxes);

Text and number inputs need the same for value, and it matters more there: a save copies the live value into the file, so after an undo that left the old number on screen, the next save writes the undone value back. The finance tracker re-syncs every persisted input, skipping the focused one after a merge (7.4), then refreshes sap:

function syncControls({ keepFocused = false } = {}) {
  for (const input of app.querySelectorAll("input[persist]")) {
    if (keepFocused && input === document.activeElement) continue;
    const value = input.getAttribute("value") ?? "";
    if (input.value !== value) input.value = value;
  }
  Sap.refresh();
}
clay.undo?.on("undo", () => syncControls());
clay.undo?.on("redo", () => syncControls());
document.addEventListener("clay:sync-applied", () => syncControls({ keepFocused: true }));

5.4 Where to keep data that is not visible text

In order of preference:

  1. Attributes on the element they describe. <li class="card" data-id="c-7f3a" data-due="2026-11-02" data-points="3">. Saved, merged per attribute, readable by agents, moves with the element. The default.

  2. Hidden elements inside the record. <ul hidden class="archive">, the card detail inside the card (9.5). Same properties, good for archived items and detail fields.

  3. A JSON script with merge. For data that markup expresses badly (formula grids, chart series, settings objects):

    <script type="application/json" id="model" merge="store">{"series":[{"id":"s1","points":[3,5,8]}]}</script>
    
    function writeModel(model) {
      document.getElementById("model").textContent = JSON.stringify(model).replace(/</g, "\\u003c");
    }
    

    Always escape <: a value containing </script> ends the element early and corrupts the file on reload. Re-parse on every clay:sync-applied, because a merge changes the text, not your parsed object. Same-key edits from two writers resolve silently to the incoming one (7.3).

  4. <template> content: never for mutable data. It is saved, but edits there never trigger autosave.

5.5 Identity

Live sync and undo pair elements across versions by data-id, then id, used only when the value is unique on its side and the tag names match. Without an id, elements pair by content, which works for distinct text and fails for identical new items.

5.6 Assets

An image's durable form is its src. Upload the file and write the returned URL (8.5). Where the host cannot store files, embed a data: URL, which works anywhere but makes every save carry the bytes. Keep embedded images small. Every host returns a host path such as /_/uploads/assets-<name>/photo-ab12.png, which keeps working on that host when the document is renamed or moved. A document opened straight from disk shows those images broken; Export as zip takes a document with its files.


6. Saving

6.1 Edit mode and the save path

In edit mode ClayJS saves the whole document to POST /_/save on the page's origin (or /_/save/<savetoken> on HTML Clay). Cmd+S (Ctrl+S elsewhere) and a click inside any [trigger-save] element call clay.save() on every edit-mode page, with or without autosave. A close warning appears whenever there are unsaved changes.

6.2 Autosave

<html autosave> present at load turns on autosave. Adding or removing it later changes nothing.

The load-settle window. At load ClayJS takes a baseline of the document and retakes it once the page has been quiet for 500 ms (at most 3 s), unless the person typed or a save ran. Then it sets savestatus="saved" and fires clay:baseline-settled. Anything app code writes into the record during that window is absorbed into the baseline: it is not saved, not autosaved, and not counted as unsaved. It reaches the file only when some later edit triggers a save. So:

6.3 Manual-save pages and batching

For a long-form writer where each autosave is unwelcome, omit autosave. Cmd+S, [trigger-save] and the close warning still work. Save when the tab hides:

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden" && clay.isEditMode && clay.hasUnsavedChanges()) {
    clay.save.flush({ keepalive: true }).catch(() => {});
  }
});

A collaborative page should keep autosave on: peers see an edit only after it saves (7.1).

To batch saves inside an autosaving page, wrap the heavy region in clay="no-trigger-autosave": edits there still count as unsaved and still warn on close, but never start a save. Your own clay.save() (on blur, or every 30 s) writes them, and so does any save another edit triggers.

clay.Mutation.pause() and resume() hide DOM mutations from the mutation hub's listeners (autosave's mutation trigger among them) for the duration, but the next save still writes them. They do not pause input: input and change events on controls, including synthetic ones, still mark the page dirty and start autosave. Pair them in try/finally; an unbalanced pause silences mutation-driven autosave for the rest of the session.

6.4 The save API

clay.save() returns Promise<{ ok, msg, msgType, code, etag }> and never rejects. One exception to that shape in 1.8.2: when capturing the page fails (a registered hook threw, 6.6) the result has no ok field at all, only msgType: "error".

Result Meaning
ok: true, msgType: "success" (or "warning") the host accepted these bytes
ok: false, msgType: "skipped", msg: "No changes to save" nothing in the dirty domain changed
ok: false, msgType: "skipped", msg: "Save already in progress" a save is in flight; these bytes are queued and sent after it
ok: false, msgType: "conflict" HTTP 412: the file changed elsewhere (6.5); also carries changedBy, afterTimeout, conflictEtag
ok: false, msgType: "error" the host refused or the network failed
ok: false, msgType: "unknown" 12 s timeout; ClayJS asks the host whether it landed

Branch on msgType, not ok. ok: false also means "nothing to save" and "queued behind another save". To know that the host holds what the page holds now, use flush:

6.5 Conflicts on save (412)

On hosts that announce conditional (all three hosts in 1.4), every save carries If-Match with the version the tab last saw. If the file changed elsewhere and live sync has not merged that change yet, the host answers 412 and writes nothing.

6.6 Hooks

Hooks shape the saved bytes without touching the live page. They run on a clone.

Hook Runs on Use for
onbeforesnapshot="code" attribute the clone, for every snapshot: save, change check, sync broadcast normalizing tab-local state out of anything that leaves the tab
onbeforesave="code" attribute the save clone and change-check clones (not sync broadcasts) cleaning a subtree before it is written
clay.onSnapshot(fn) every snapshot; fn(clone, { original }) stripping library residue everywhere
clay.addDocumentTransform(fn) the save clone and every change check writing derived values into the saved copy only
onaftersave="code" attribute the live element after each accepted save rarely

In attribute hooks this is the cloned element. Example from the finance tracker, which keeps sap's search filter (rows hidden with hidden) out of the file and out of peers' copies:

<tbody items="txns" onbeforesnapshot="this.querySelectorAll(':scope > [item][hidden]').forEach((row) => row.removeAttribute('hidden'))">

Rules:

Strip library residue once, at the snapshot level:

clay.onSnapshot?.((root) => {
  for (const el of root.querySelectorAll(".sortable-chosen, .sortable-ghost, .sortable-drag")) {
    el.classList.remove("sortable-chosen", "sortable-ghost", "sortable-drag");
    if (!el.classList.length) el.removeAttribute("class");
  }
  for (const el of root.querySelectorAll('[draggable="false"]')) el.removeAttribute("draggable");
  for (const el of root.querySelectorAll('[style=""]')) el.removeAttribute("style");
});

Only strip draggable="false" if your own markup never authors it.

6.7 Save status

Style from the root attribute, with no JavaScript:

html[savestatus="saving"] #status::after { content: "Saving…" }
html[savestatus="error"] #status::after { content: "Not saved" }
html[savestatus="conflict"] #status::after { content: "Changed elsewhere" }

Put #status in chrome. Or load the indicator plugin for a ready-made chip. With clay-ui.js loaded, save errors and offline become persistent toasts. Without the indicator plugin, clay-ui also toasts "Saved" after every save; with it, set clay.saveToast = true to get those toasts too.

Events, all on document:

Event When detail
clay:ready boot finished { clay }
clay:baseline-settled the load-settle baseline was taken none
clay:save-saving a save has been in flight 500 ms { msg, msgType, timestamp } (msgType is "")
clay:save-saved the host accepted { msg, msgType, timestamp }
clay:save-error refused, or a network failure while online { msg, msgType, timestamp } (msgType is "" in 1.8.2; the event name is the type)
clay:save-offline failed while the browser is offline { msg, msgType, timestamp } (msgType is "")
clay:save-conflict 412 adds changedBy, afterTimeout, etag
clay:save-conflict-resolved sync merged the version that caused the 412 { timestamp }
clay:dirty, clay:clean unsaved-work state changed none
clay:unsaved-state-changed a registered state called changed() { pending }

clay:dirty means "maybe dirty": typing a letter and deleting it leaves it raised until the next check.


7. Live sync and collaboration

7.1 What sync is

?plugins=sync keeps every open copy of the file converging. It is a save-gated, whole-document, three-way DOM merge, not keystroke streaming.

So collaborators see each other's work a few seconds after each pause. On a page without autosave they see nothing until someone saves, and overlapping work only meets at save time. Every collaborative file uses autosave.

7.2 What a merge does

Situation (this tab A unsaved, incoming B) Result
A edits one paragraph, B another both kept
A and B edit different words of one paragraph both kept
A and B change the same word B wins; A's version goes to the conflict ledger
Both add class tokens (done and urgent) both kept
Both set one attribute (data-status) differently B wins; ledger record
Both reorder a list differently B's order; ledger record
A edits a card, B reorders the list B's order with A's edit, no conflict
A edits a card B deleted the card survives with A's edit
A deleted a card B edited the card comes back with B's edit; ledger record
Both append an item with its own data-id both land
Both append an item without ids distinct text: both land; similar text: A's item dropped, ledger record; identical or blank: one dropped, no record
A is typing in a focused input its value and focus kept

More engine rules:

7.3 Regions and JSON under sync

Known issue (ClayJS 1.8.2): when a frame replaces a no-dirty region that this tab had also changed, while the tab also holds an unsaved edit outside that region, the replacement is logged in clay.conflicts as a loss and raises the conflict notice, although the docs say it happens with no warning. Two tabs that each edited the data will each have recomputed the totals differently, so this fires on concurrent edits whenever computed totals live in no-dirty. Until it is fixed, acknowledge those records yourself, as the finance tracker does:

document.addEventListener("clay:sync-applied", () => {
  const derived = clay.conflicts.list().filter((c) =>
    c.detail === "remote-wins" && [c.el, c.local].some((n) => n?.closest?.('[clay~="no-dirty"]')));
  if (derived.length) clay.conflicts.acknowledge(derived.map((c) => c.id), { reason: "reconciled" });
});

JSON. A JSON script without merge is one value: two writers changing it means one whole blob goes to the ledger. With merge="name" it merges per key: objects per key, arrays of objects by an id field (merge-key="taskId" names another), arrays of primitives by value. A key both sides changed takes the incoming value silently: no ledger record, no notice. Keep fields where a silent loss matters (an amount, a status) in the DOM, where an overlap shows. Use per-entry keys (notes: { "n-1": ..., "n-2": ... }) so two people adding entries never touch the same key. The merged text is re-serialized, so blob formatting is not kept.

7.4 Focus, typing and long-running editors

7.5 Reconciling in app code

After clay:sync-applied, rebuild everything derived from the record and every piece of chrome that points into it:

document.addEventListener("clay:sync-applied", (event) => {
  syncCheckboxes();
  render();
});

detail on clay:sync-applied: { seq, source: "peer" | "disk", by, report, conflictIds, unresolved }, plus etag and html for disk frames. by is { id, name } on hyperclay.com peer frames and null elsewhere. report.applied, report.conflicts and report.localDiverged describe the merge.

7.6 The conflict ledger and notice

Losses (where this tab's work was replaced) enter clay.conflicts:

The built-in notice reads "Another edit replaced N changes" and offers Revert to mine, Accept theirs, Download my copy and a list of the edits. Revert puts the selected losses back as an ordinary local edit, then saves.

On hyperclay.com, a frame stamped with by also shows a one-line "Name changed this section" beside the field the reader last focused.

7.7 Presence

hyperclay.com shows a fixed avatar stack (initials, editors solid, viewers hollow, a "+N viewing" count) when two or more people have the file open. There is no API: no event, no roster, no cursor or section data. An app cannot build its own "who is here" or "X is editing this card". HTML Clay and Hyperclay Local have no presence.

7.8 Agents

An agent edits a malleable file the way a person in another tab does.


8. Editing components

8.1 richclay: rich text (editable)

editable on an element makes it a rich text editor in edit mode (Squire underneath, DOMPurify on paste). It is the default plugin.

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

Tokens: single-line (Enter does nothing, pasted blocks flatten), no-toolbar (shortcuts only), toolbar-on-select (the floating toolbar appears only for a non-empty selection: the "bubble toolbar"). 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, minimal.

What saves: 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 does not do, and what to build instead:

Missing Build it
Tables, checklists, dividers, callouts, toggles, embeds As separate blocks outside the rich region (the writer's block model, 10.2)
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 (10.2)
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 already mounted on el. Use it to reach squire, getHTML(), setHTML(), setBlockType(tag), registerButton(def).

Screenshot paste into an upload, from the writer:

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);
});

Custom toolbar buttons must be registered per instance, after mount; a global RichClay.registerButton after load does not reach editors already mounted:

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:

8.2 sortable: drag to reorder

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

Traps:

8.3 undo: whole-page undo

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

Traps:

8.4 Plain contenteditable

For titles, labels, list items and table cells, use editmode:contenteditable with plaintext-only (4.4). No toolbar, no Squire, no formatting, cheap at any count. Handle Enter yourself (blur, or create the next item as the writer's to-do list does).

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

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

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. Pass the blob to clay.upload, wrapped as a File. One cropper at a time; it sets body.style.overflow = "hidden" while open, which a save at that moment writes into the file.

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

8.6 clay-ui: toasts and dialogs

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 yes. For card detail, prefer the record-as-modal pattern (9.5).

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

8.7 clay-events, clay-dom and All

Useful pieces, each with a trap:

Plain delegated addEventListener handlers on document, as in the examples, avoid every one of these traps and are the default.

8.8 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. With new OverType(el, { persist: true }) the saved file holds only a <textarea persist> and its wrapper; everything else is editor-ui. Keep one live instance at a time, and normalize OverType's layout state in an onbeforesave on the container. 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.

8.9 Bevel components

Bevel (@panphora/bevel) is a separate component library with a kanban board, an editable data grid, inline edit, upload dropzone and modal, all with state in markup. ClayJS does not load it. Load bevel.css and bevel.js yourself and register Bevel.prepareForSave with clay.onSnapshot. It has its own drag and events and is not wired to ClayJS undo or sortable. Use it when a ready-made grid or board is acceptable; build from this guide when the app needs its own shape.

8.10 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, upgrade. Replace them with a few lines of code (prevent-enter: a keydown handler; autosize: CSS field-sizing: content; movable: pointer events writing data-x/data-y). 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.

8.11 sap.js: computed values in markup

sap.js (satellite, also standalone as @panphora/sapjs) is a reactive attribute layer whose only store is the DOM. Every change runs one pass that reads the page, computes calc: fields in dependency order and writes only changed paints. Use it for number-heavy apps: budgets, invoices, trackers with totals.

<main id="app" sap>
  <output clay="no-dirty" calc:totalspent="sum(state.txns, 'amount')" text:usd2="state.totalspent">$900.00</output>
  <table>
    <tbody items="txns">
      <tr item id="t-k1">
        <td><input type="number" step="0.01" bind="amount" persist viewmode:readonly readonly value="900"></td>
      </tr>
      <tr item template><td><input type="number" step="0.01" bind="amount" persist viewmode:readonly></td></tr>
    </tbody>
  </table>
</main>

The vocabulary:

How it fits ClayJS: it rides the mutation hub so its paints never trigger autosave, re-derives on clay:sync-applied and on undo, and mirrors bound values into attributes on programmatic writes. persist on bound controls keeps typed values. Sap reads a control's live value, not its attribute, so after undo and redo re-sync the inputs first (the syncControls code in 5.3) or totals and the next save keep the undone number.

Rules for sap in a malleable file, all from the finance tracker:

8.12 The CMS, in passing

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

<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 content sites: landing pages, menus, pricing, FAQs, team pages, where the layout is fixed and 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: 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.


9. App architecture

9.1 The record container

One container in the saved document holds every record, with data-id per record and fields as attributes, text or persist controls. Shape it by the primary editing dimension:

Never store a record twice. A JSON mirror of DOM records, or a rendered copy beside a model, drifts the first time a merge or a hand edit touches one of them.

9.2 Chrome

Clone chrome from a <template> into one clay="editor-ui" wrapper after clay.ready:

const chrome = document.createElement("div");
chrome.id = "chrome";
chrome.setAttribute("clay", "editor-ui");
chrome.append(document.getElementById("chrome-tpl").content.cloneNode(true));
board.querySelector(".board-head").append(chrome);

Chrome may sit inside the record container (the kanban puts its filter in the board header): editor-ui removes it wherever it is. Per-item chrome (the writer's block gutter, the kanban's count badges) is inserted into each item as its own editor-ui element and re-inserted by render() when missing, so items added by a peer get theirs too.

Edit-only chrome lives in the same wrapper with show-when:editmode="true", or is not built at all in view mode (if (clay.isEditMode) …).

9.3 Per-tab state

State Where Why
Which card or entry is open, the filter, search text, current view, sort URL hash: #card=c-brief&q=pricing per tab, survives reload, shareable, Back closes the card, never saved or synced
Collapsed sections, column widths, density, last opened entry localStorage, keyed by the file's path per device and viewer; wrap reads and writes in try/catch
Selection, hover, drag state, open menu memory, or chrome DOM transient
A default view visitors land on, WIP limits, column names, label definitions attributes on a settings element in the record genuinely part of the document

Paint per-tab state with a runtime stylesheet inside chrome, rewritten from the hash, instead of toggling classes or attributes on records:

const rules = [];
if (openId) {
  const sel = `.card[data-id="${CSS.escape(openId)}"]`;
  rules.push(`${sel}{position:fixed;z-index:20;top:6vh;left:0;right:0;margin:auto;width:min(560px,92vw);max-height:88vh;overflow:auto}`);
  rules.push(`${sel} .detail{display:block}`, `#chrome .backdrop{display:block}`);
}
setText(document.getElementById("view-state"), rules.join("\n"));

Nothing on a record changes, so opening a card or filtering never makes the page dirty and never reaches a peer. The kanban and skeleton tests check exactly that.

When a view switch must use clay-options on a saved ancestor attribute, normalize it out of every snapshot so it stays tab-local:

<main id="app" view="board" onbeforesnapshot="this.setAttribute('view', 'board')">

Never put per-viewer state on <html> or any record element without that: it autosaves, syncs to every collaborator and lands in version history.

9.4 Derived values

Pick by whether the saved file must show the value:

Always write derived text through a guard, because writing the same text is still a mutation:

const setText = (el, text) => { if (el.textContent !== text) el.textContent = text; };

Recompute on three triggers: record mutations (a MutationObserver on the record container, coalesced with a microtask), clay:sync-applied, and hashchange when the hash affects the view. Observe the record, not the chrome, so derived writes do not trigger themselves.

9.5 Detail views: the record is the modal

Keep a record's detail fields inside the record element, hidden on the card face by CSS, and show the record itself as an overlay with the runtime stylesheet from 9.3.

<li class="card" data-id="c-brief">
  <span class="grip" sortable-handle show-when:editmode="true">⠿</span>
  <h3 editmode:contenteditable inert-contenteditable="plaintext-only">Write the launch brief</h3>
  <div class="detail">
    <div class="desc" editable><p>Two paragraphs on <b>scope</b> and audience.</p></div>
    <ul class="checklist">…</ul>
    <ol class="activity"><li data-at="2026-10-07">Created</li></ol>
    <footer class="card-actions" show-when:editmode="true">…</footer>
  </div>
</li>
.detail { display: none }

Why this beats a dialog with a copy:

Open on a click that is not on an editable field, a handle, an input or a link. Close on the backdrop, Escape (unless typing) and Back. When a peer deletes the open record, render() finds it gone and clears the hash. Caveat: position: fixed breaks inside an ancestor with transform, filter or contain.

When a dialog must aggregate several records, render it into editor-ui, write each change back to its record by id, call clay.markDirty() per edit if the record is not written immediately, and re-render it on clay:sync-applied without disturbing the focused field.

9.6 Commands

A command is a function that changes the record once:

const actions = {
  "add-card"(button) {
    const card = fromTemplate("card-tpl");
    card.dataset.id = newId("c");
    logActivity(card, "Created");
    command("Add card", () => button.closest(".column").querySelector(".cards").append(card));
    focusText(card.querySelector("h3"));
  },
  "delete-card"(button) {
    const card = button.closest(".card");
    clay.confirm("Delete this card?").then(() => {
      command("Delete card", () => card.remove());
      writeHash({ card: "" });
    }, () => {});
  },
};
const editOnly = new Set(["add-card", "delete-card"]);

document.addEventListener("click", (event) => {
  const button = event.target.closest("[data-action]");
  if (!button) return;
  if (editOnly.has(button.dataset.action) && !clay.isEditMode) return;
  actions[button.dataset.action](button);
});

9.7 Filters and view switching

9.8 Computed totals

Plain JavaScript into editor-ui (9.4) for counts and progress. Sap for anything with arithmetic across rows, categories and formats (8.11). Store money as integers or as plain numbers in value attributes, never as formatted strings.

9.9 Import and export

Export reads the record, import writes it, and both buttons live in chrome. The finance tracker's export:

const rows = Sap(app).txns.map((t) => [t.date, t.memo, t.cat, t.amount]);
const blob = new Blob([toCSV([["date", "memo", "category", "amount"], ...rows])], { type: "text/csv" });
const link = Object.assign(document.createElement("a"), { href: URL.createObjectURL(blob), download: "transactions.csv" });
link.click();
URL.revokeObjectURL(link.href);

Without sap, read the record with clay-data's clay.extractData(rules), which returns strings and skips editor-ui and no-data regions:

await clay.loaded.data;
const { rows } = clay.extractData({ rows: [".txn", { id: "@data-id", date: ".date", amount: ".amt", memo: ".memo" }] });

clay.applyData({ rows }, { rules }) writes a whole list back: it grows and shrinks the list to match and refuses unknown keys, scripts and event handlers without a partial write. To append, pass the existing rows plus the new ones. It pairs incoming rows with existing elements by content and order, not by an id field: reorder the rows and edit them in the same call, and an element can be rewritten as a different record while keeping the old record's unexposed attributes. When identity matters, update records yourself: find each element by data-id, write its fields, and append new ones from a template.

ClayJS has no CSV parser. The finance tracker has a 20-line RFC 4180 parser (quoted fields, doubled quotes, CRLF) and imports with sap:

Sap.batch("Import CSV", () => {
  for (const r of body) {
    const row = Sap(app).$add("txns");
    row.date = pick(r, "date");
    row.memo = pick(r, "memo");
    row.cat = pick(r, "category");
    row.amount = Number(pick(r, "amount").replace(/[$,]/g, "")) || 0;
  }
});

A rules tag with data-rules-name="api" gives agents and the host's data API the same shape your export uses.

9.10 The boot guard

A live-sync merge can re-insert an inline script, which runs it again: every listener doubles and every chrome element appears twice. Guard the app script:

clay.ready.then(() => {
  if (window.__boardStarted) return;
  window.__boardStarted = true;
  …
});

9.11 Size

Every save captures and serializes the whole document, every change check captures it again, and every peer merges the whole document per accepted save. Measured in jsdom and scaled to a browser (estimates):

Records (about 425 bytes each) File size Capture per check Full save Peer merge
300 128 KB ~10 ms ~25 ms ~10 ms
1,500 640 KB ~55 ms ~140 ms ~60 ms
3,000 1.3 MB ~120 ms ~300 ms ~190 ms
6,000 2.6 MB ~250 ms ~650 ms ~400 ms

Up to about 1,000 records feels instant. From 1,000 to 3,000 there is a visible hitch at each save. Past about 3,000 records or 1.5 MB, autosave stalls typing and peers lag. Hosts cap a save at 5 MB (hyperclay.com), 20 MB (Hyperclay Local) and 50 MB (HTML Clay). Design for archiving: one file per year, a hidden archive section that can move to another file, or one file per project.


10. Recipes

10.1 Kanban with card detail

examples/kanban.html. Plugins sync,sortable,undo,indicator, satellites clay-options and clay-ui.

Extend with labels (a settings element listing <li data-label="bug" data-color="…">, cards carrying data-labels="bug ui"), due dates (data-due plus a hash filter "due this week"), WIP limits (an attribute on the column, a warning in an editor-ui badge), and a list view (a projection or display: contents).

10.2 Block writer

examples/writer.html. Plugins sync,sortable,undo,upload,indicator, satellite clay-ui.

One editable per block, not one big editor. The document is #blocks[sortable="blocks"], a list of sibling .block[data-id][data-type] elements:

data-type Content
text .body[editable="toolbar-on-select"]: paragraphs, H2/H3, lists, quotes
callout the same, styled as a callout
todo ul.todos of li[data-id] with a persist checkbox and an editmode:contenteditable span; Enter adds the next item, Backspace on an empty item removes it
divider <hr>
image <figure> with <img> and an editmode:contenteditable caption

This model gives drag, block menus, non-rich blocks and per-block Squire undo (which limits the peer-rollback trap), at the cost of selection and Enter-to-split across blocks.

Missing pieces to add on the same model: a table block (<table> with editmode:contenteditable cells and your own Tab and add-row code), a code block (<pre><code editmode:contenteditable> with a copy button in chrome), a toggle (<details>, noting open saves), anchor ids on headings (assign id from text on blur, not on load).

10.3 Finance tracker

examples/finance.html. Plugins sync,undo,indicator&exclude=richclay, satellites clay-options, clay-ui, sap.js.

The authored file already shows every computed value, so sap mounts without changing the record and a reader without JavaScript sees correct totals.

10.4 CRM follow-up list

Table-first. <tr data-id data-stage="lead" data-next="2026-10-14"> rows with editmode:contenteditable name and company cells, a persisted <select> for stage, a persisted <input type="date"> for the next contact, and an editable notes cell or a hidden notes element shown in the record-as-modal detail. Projections into editor-ui: "due today" (rows whose data-next is today or earlier) and a pipeline board (columns by stage, each card a link to the row's detail). Totals by stage with sap if deals carry amounts. mailto: links for contact. CSV import merges by email or id. Single owner.

10.5 Flashcards

Decks as <section data-id> records, cards as <article data-id data-due data-interval data-ease> with editmode:contenteditable front and back. Study mode is tab-local: the hash holds the deck and the current card, a runtime stylesheet shows one card and hides the back until revealed. Grading writes the new data-due, data-interval and data-ease on the card (an SM-2 style schedule), inside one undo commit. The "due today" count is an editor-ui projection.

10.6 Typed table (a spreadsheet you can be honest about)

A fixed schema of named columns, rows as <tr item data-id> in a sap items list, inputs bound and persisted per cell, computed columns with calc:, totals with sum, filters by hiding, CSV in and out. Tab between cells with a keydown handler on the table. A few hundred to two thousand rows per file.

10.7 Slides

One <section class="slide" data-id data-layout="title|bullets|image|quote"> per slide in a 16:9 box, editable text, sortable to reorder, images through upload. Present mode is tab-local (hash #present=3): fullscreen, scroll-snap, arrow keys, speaker notes in an <aside> hidden while presenting. PDF through @page { size: 16in 9in } and the browser's print.

10.8 Content site

Without the CMS: editable regions for copy, upload and quickcrop for images, sortable lists for features. With the CMS: a rules tag and ?plugins=cms (8.12).


11. Limits, and what to build instead

ClayJS gives one file per document, whole-document saves, save-gated three-way merging, per-tab undo, and editing components that write plain HTML. It does not give keystroke-level co-editing, operational transforms or CRDTs, cursors, comments, per-row permissions, a formula engine, virtualization, background jobs or cross-file queries. Promise what the file can do.

Competitor What ClayJS gives What is missing Build this instead
Google Docs Rich text with a bubble toolbar, word-level merge at save time, recoverable conflicts, version history from the host, ai-edit Tables in rich text, comments, suggestion mode, live cursors, keystroke co-editing, pagination, footnotes, .docx export A block writer (10.2): table, code, callout and image blocks; comments as <aside data-id data-anchor> elements; print CSS for PDF; turn-taking or different-section editing
Google Sheets Persisted inputs, sap formulas over named fields, aggregates, formats, CSV A1 references, ranges, fill-down, recalculation graph, virtualization, range selection, charts, pivots, 10k-row data A typed table (10.6) of a few thousand rows, one file per year
Google Slides Slides as sections, sortable, uploads, view mode as the published deck Free positioning, guides, masters, presenter view in a second window, transitions, PPTX A flow-layout deck with fixed layouts per slide (10.7)
Trello, Linear Sortable columns, inline editing, checklists, attachments, undo, sync with per-card identity Notifications, reminders, assignments, cross-board search, mentions, integrations, boards past a few thousand cards The kanban (10.1) with labels, due dates and a due-soon projection
Notion One rich document per file, blocks, structured pages A workspace of many pages, databases with relations and rollups, backlinks, workspace search, page permissions A folder of files, one per page or database, plus an index file; links are <a href>
CRM Records, pipeline board, notes, CSV, totals Email logging, reminders, dedupe, multi-user ownership, per-row permissions, public intake The follow-up list (10.4)
Finance apps Rows with amounts, budgets, sap totals, CSV import of bank exports Bank sync, learned categorization, multi-currency, scheduled transactions, encryption at rest A monthly or yearly budget file with CSV import (10.3)
Full CMS Per-page CMS sidebar, uploads with crop, view mode as the site, a JSON data API Multi-page collections, shared layouts, drafts and scheduled publishing, roles, media library, i18n One file per page, a shared stylesheet, the CMS for non-technical editors of each page

Cross-cutting:


12. Hosts, assets and privacy

12.1 What each host gives

HTML Clay Hyperclay Local hyperclay.com
Who edits the local user the local user; teammates through device sync owner, team editors, edit-link holders
Live sync tabs on this machine tabs, plus other devices through the sync engine every editor and viewer
Outside disk edits reach open tabs yes (agents, text editors) yes no disk except through device sync
Presence none none avatar stack, no API
by on frames, section notice no no yes
Wire and ai-edit yes yes no
Data API write yes yes read only
Uploads 25 MB, in ~/htmlclay/uploads/ 25 MB, in the root's uploads/ 10, 20 or 100 MB by plan, in the account's uploads/
Version history yes 60 days, newest 20 a version per save; a restore does not reach open editors' tabs, so reload after restoring
Max save 50 MB 20 MB 5 MB

A plain static server or a file opened from disk can show the file in view mode but cannot save it. Use the demo plugin for a public demo that saves into each visitor's browser.

On hyperclay.com every tab holds its own sync connection. Over HTTP/1.1 a browser allows six connections per origin, so a seventh tab of the same site can stall.

12.2 Getting started on each

12.3 Privacy

The saved file is the data. Everyone who can download it can read everything in it: hidden elements, data-* attributes, JSON scripts, CSS-hidden sections and everything the owner typed. show-when:editmode, hidden and display: none are presentation.


13. Checklist and trap index

13.1 Before you ship a file

  1. Loads with clay.js as a classic script at the end of <body>, every tag on the same version prefix.
  2. <html autosave> unless it is a deliberate manual-save writer.
  3. sync is in the plugin list, and exclude=richclay if there is no rich text.
  4. The fallback rule html:not([editmode="true"]) [show-when\:editmode="true"] { display: none } is in the stylesheet, and every edit-only control has show-when:editmode="true".
  5. Records are authored in the saved form (inert-contenteditable, disabled, readonly); templates in the active form.
  6. Every repeated record has a unique data-id; templates carry none; every create and duplicate mints a fresh one.
  7. Every control whose value matters has persist; no filter or search box does.
  8. All chrome is cloned from a <template> into clay="editor-ui" after clay.ready; nothing in the file is chrome.
  9. Tab-local state is in the hash, localStorage or memory, painted through a runtime style; opening, filtering and searching leave clay.hasUnsavedChanges() false.
  10. Derived values are in editor-ui, or no-dirty when the file must show them; every derived write is guarded.
  11. Commands are wrapped in clay.undo.commit; checkboxes and inputs are re-synced from attributes on undo, redo and clay:sync-applied.
  12. A clay:sync-applied handler re-renders and closes views whose record vanished.
  13. The app script has a boot guard; all listeners are delegated.
  14. Nothing writes the record at boot.
  15. Edit-only calls are guarded for view mode, and edit actions check clay.isEditMode.
  16. clay.confirm calls have a rejection handler.
  17. Save failures are visible (indicator, savestatus CSS or clay-ui).
  18. Library residue (Sortable, third-party editors) is stripped in a snapshot hook.
  19. Images use clay.upload, embedding only on unsupported.
  20. The file opens in view mode with JavaScript disabled and shows correct content and no edit controls.
  21. With sap: Sap.status().ok, the live root matches the file after load (no computed value rewritten), no [sap-error], rows with your own ids.

13.2 Trap index

Trap Section
CSS keyed on editmode="false" shows edit chrome before boot 4.2
A never-saved file shows show-when:editmode controls to viewers without the fallback rule 4.3
show-when matches any ancestor, not the nearest 4.3
A view attribute on a saved element syncs to every collaborator 4.3, 9.3
Records authored with live contenteditable stay editable for viewers until the first save 4.4
Templates in the inert form give clones that cannot be edited until reload 4.4
Unguarded edit-only API calls throw in view mode 4.5
showModal(), hidden, .selected and library inline styles save 5.1
no-save chrome still reaches peers; use editor-ui 5.2
Edits in no-watch or no-dirty are invisible to save() and the close warning 5.2
Runtime changes inside freeze are never saved, even by force() 5.2
Rendering into freeze before clay.ready freezes the render 5.2
A control without persist loses its typed value 5.3
Undo and merges restore checked/value attributes but not what the control shows, and the next save writes what is shown 5.3, 8.3
JSON with an unescaped </script> corrupts the file 5.4
<template> content edits never autosave 5.4
Items without ids added in two tabs: one is dropped 5.5, 7.2
Cloned records share a data-id, which counts as no id 5.5
crypto.randomUUID is missing outside secure contexts 5.5
Writes in the first 0.5 to 3 s after load are absorbed into the baseline and not saved 6.2
A failed save is not retried until the next edit 6.2
ok: false also means "nothing to save" and "queued" 6.4
clay.getHTML() runs hooks and closes the undo batch 6.4
A 412 conflict stops autosave until resolved 6.5
Impure hooks keep the page permanently dirty 6.6
A throwing onSnapshot or addDocumentTransform callback blocks every save 6.6
onaftersave writing to the live DOM loops autosave 6.6
Without autosave, collaborators see nothing until a save 7.1
Listeners attached to records are lost when a merge recreates them 7.2
A peer's frame re-runs inline scripts 7.2, 9.10
A no-dirty region replaced by a peer raises a false conflict notice (1.8.2) 7.3
Same-key JSON edits under merge are lost silently 7.3
Rewriting the record at boot makes incoming frames hold 7.4
Parsed JSON goes stale after a merge 7.5
There is no presence API 7.7
data-richclay card mode deletes images on load 8.1
The richclay toolbar is unusable inside showModal() and popovers 8.1
Rich text has its own undo; page undo never records it 8.1, 8.3
Squire undo can roll back a peer's edit in the same region 8.1
Global registerButton after load does not reach mounted editors 8.1
Screenshot paste does nothing without a pasteImage listener 8.1
Sortable handle mode is fixed at bind time 8.2
onsorting cannot veto a move 8.2
Sortable residue saves 8.2, 6.6
The undo plugin takes Cmd+Z in every text field 8.3
Undoing an add removes the record even after a peer edited it 8.3
Pausing undo across an await loses the earlier mutations 8.3
Sap reads live control values: undo without re-syncing inputs saves the undone number 5.3, 8.11
clay.upload is not a picker; embed only on unsupported 8.5
clay.confirm rejects with undefined on cancel 8.6
A live element passed to clay.modal.html is deleted 8.6
Mutation hooks that write what they watch loop forever 8.7
onclone does not fire for template fragments 8.7
el.nearest reads a neighbour record's field 8.7
hide(), show(), css() write saved inline styles 8.7
Sap reuses row-N ids after reload 8.11
A file whose painted values do not match sap's state writes at load 8.11
Dialog prompts are HTML: record text in clay.confirm runs a collaborator's markup 8.6
editmode:resource type="inert/javascript" activates as javascript and never runs 4.4
Sap's template row shows before sap loads 8.11
CMS cms-template with hidden makes every new item invisible 8.12
Records stored twice drift 9.1
applyData pairs rows by content and order, not by id 9.9
Activity logged from a mutation observer also logs peers and undo 9.6
Saves slow down past about 1,000 records and stall past 3,000 9.11
A version restore on hyperclay.com does not reach open editors 12.1
Hidden is not private 12.3