# clayjs
> clayjs makes an HTML file malleable: the page you are looking at can be edited like a
> document and saved, whole, back into its own file. One script tag, no build step, no
> account. The document is the app AND the database: state lives in the DOM, and a save
> is one POST of the entire serialized document to the host that holds the file.
This file is the complete reference for agents, condensed from https://clayjs.com/docs,
https://clayjs.com/advanced, and https://clayjs.com/plugins. Human-readable versions live
at those URLs. clayjs is MIT-0 licensed. npm builds for bundlers: @panphora/clayjs.
Building a whole app (inline editing, controls, sync, undo, derived values)? Read the
guide, https://clayjs.com/guide.md: the model, a complete starter file, architecture,
recipes, limits and a trap index, with four tested example apps. Every page on
clayjs.com also has a Markdown copy at the same path plus `.md`; https://clayjs.com/docs.md
lists the reference pages.
## The one-paragraph integration
To make a static HTML file malleable: add
`` as the last element inside `
`.
Add an `autosave` attribute to the `` element. Add an `editable` attribute to each
element a person should edit as rich text (headings, paragraphs, note containers). Add a
`persist` attribute to any form control whose value should survive saving. Mark editing
controls with `show-when:editmode="true"` and load `clay-options.js`, so viewers never see
them. Put UI that a script builds or changes at runtime inside `clay="editor-ui"`. Do not write any
save logic or add a save button: clayjs saves the whole document automatically, and the
host writes it to disk.
## Hosts (who writes the file)
clayjs is client-side; a host accepts the save and writes bytes:
- HTML Clay, a small desktop app (https://htmlclay.com): serves local `.htmlclay` files
at http://127.0.0.1, writes saves back to disk, keeps automatic version history.
- hyperclay.com: hosts the same files online.
- Your own server: implement one route (see "The endpoint spec" below, ~20 lines).
## Loading
```html
```
The default build includes the save lifecycle and rich text (`editable`). Plugins load
conditionally through the URL, in the browser only:
- `?plugins=` comma-separated plugins to add, e.g. `clay.js?plugins=sync,undo`
- `?exclude=` remove a default-on plugin, e.g. `clay.js?exclude=richclay`. One `exclude=` takes a
comma list (`?exclude=source,richclay`); a second `exclude=` parameter is ignored.
Loadable plugins: `richclay` (default on), `source` (default on), `ai-edit` (brings `wire`),
`indicator`, `sync`, `live`, `sortable`, `undo`, `cms`, `quickcrop`, `upload`, `people`, `wire`, `demo`,
`visitor-drafts`. The default-on plugins load in edit mode only. `visitor-drafts` turns the page into a
free document: a visitor who is not the owner gets the editing UI and their work is kept in their own
browser (guide section 14).
Everything else is a separate library ("satellite") with its own script tag: clay-ui,
clay-events, clay-options, clay-dom, all.js, clay-utils, clay-internals, clay-data,
sap.js. To call a satellite from an inline script right after its tag, wait for it:
`await clay.loaded.ui` (or `.dom`, `.all`, `.utils`, `.events`, `.options`, `.data`,
`.sap`, `.internals`).
Offline: `https://clayjs.com/v1/clay.standalone.js` is the whole library in one
readable file (core, every plugin, every satellite), built from the same source.
Download it, keep it beside the HTML file, and load it with the same query params:
``. `plugins=` then decides
what runs, not what downloads, and every `clay.loaded.*` promise exists without a
satellite tag. Delete any satellite tags the page had: the file carries them all,
and a second `sap.js` mounts a second runtime. `clay.Sap` and `clay.extractData`,
which a tag defines synchronously, arrive one microtask later here; `await
clay.loaded.sap` (or `.data`) covers both. `/1.1.0/clay.standalone.js` is that exact
release, pinned, and every later release has the same form; the npm tarball carries it
at `dist/clay.standalone.js`; the file's first line names its version.
Two things still reach the network, both opt-in: the CMS sidebar's font comes from
jsDelivr and falls back to the system monospace, and autosave debugging
(`localStorage` key `clay:debug:autosave`) imports its diff library from esm.sh.
Saving still needs a host that writes the file; HTML Clay does that on the machine
itself, with no network.
## Core API: window.clay
- `clay.ready` — Promise that resolves once clayjs has booted (core loaded, plugins
attached). In inline scripts, `await clay.ready` before touching anything else.
`clay:ready` fires on `document` at the same moment.
- `clay.theme(options)` — theme the UI ClayJS draws (dialogs and the people name prompt).
`options` is `{ auto, colorScheme, tokens, parts }`, the same shape as `window.clayTheme`
set before the loader. `clay.theme(false)` keeps the exact Bevel look, `clay.theme(null)`
restores the default (the page's font and solid colours over Bevel), `clay.theme()`
returns the current configuration. Invalid options throw a `TypeError` and keep the
previous theme. Applies to the next surface drawn. See `docs/injected-ui.md`, "Page themes".
- `clay.save()` — snapshot the page and save it. Skips when nothing changed. Returns
`Promise<{ok, msg, msgType, code, etag}>`; check `ok`. `msgType` is `success`,
`error`, `skipped` (nothing was sent), `conflict` (HTTP 412, see `clay:save-conflict`;
the result also carries `changedBy`, `afterTimeout` and `conflictEtag`), or `unknown`
(the request timed out, so the write may or may not have landed). A host may answer
with its own severity, such as `warning`. `skipped` covers both "No changes to save"
and "Save already in progress" (the bytes are queued and sent after the in-flight
save), so branch on `msgType`, not `ok`. Never rejects.
- `clay.save.force()` — save even when nothing appears to have changed.
- `clay.save.flush({keepalive, timeoutMs})` — save now, and answer only when the host has
accepted the bytes the page holds at that moment. Resolves `{state, etag}`, where `state`
is `view` (this page cannot save), `clean` (the file already holds these bytes, so nothing
was sent) or `saved` (this call wrote them, and `etag` is the stamp the host accepted).
Rejects with an `Error` whose `state` is `failed`, `conflict` or `blocked`: `blocked` means
the bytes are on the host but the page holds work a save cannot write, reported by a check
registered through `clay.registerUnsavedState`, so only a person can resolve it. A save
already on the wire is waited out; an edit made while a request is in flight forces another
save. A refusal is reported, never resolved by overwriting: keeping this tab's version
stays a person's decision (`clay.save.overwrite`). In the first seconds after load it
waits for the page to settle, so load-time rendering is not written as an edit. `keepalive`
asks the transport to see the request through a page unload and is honoured under a 60 KB
UTF-8 body only; `timeoutMs` (default 15000) bounds the whole call.
- `clay.hasUnsavedChanges()` — synchronous answer to "is there work here that no save has
written?", the same question the close warning asks: an edit in the DOM, or work a check
registered through `clay.registerUnsavedState` reports as pending. `false` in view mode.
- `clay.extractData(rules?)` — read structured JSON from the live page. Supplied by the
`clay-data.js` satellite. Omitted rules use the page's `api` rules tag.
- `clay.applyData(values, {rules})` — apply structured JSON to the live page atomically.
Supplied by the `clay-data.js` satellite. It changes content only and refuses executable
content, unsafe attributes, unknown keys, and HTML replacement without changing the page.
- `clay.readData(rules?)` — read the saved file as `{data, etag}`. Always present in core,
and rejects when the host does not announce `data-read`, and in draft mode, where the saved
file is not this visitor's to read, with `code: "DataUnavailableInDraft"`.
- `clay.writeData(values, {rules, ifMatch})` — conditionally write JSON into the saved file
and return `{data, etag}`. Always present in core, and rejects when the host does not
announce `data-write`, and in draft mode with `code: "DataUnavailableInDraft"`.
- `clay.getHTML()` — the exact HTML string a save would send, after all cleanup.
- `clay.addDocumentTransform(fn)` — register a callback that receives the cloned
document before serialization. The live page is never touched. Runs on every change
check too, so keep it pure and repeatable (no timestamps, counters, or network).
- `clay.onSnapshot(fn)` — like `addDocumentTransform`, but runs for every snapshot,
including live-sync broadcasts.
- `clay.markDirty()` — tell live sync the person just edited something it cannot see: an
editor whose own UI is `editor-ui` and which writes its data itself. Call it on each
user edit. Without it, an edit made while an incoming update waits on a script or
stylesheet load can be recorded as already saved. Present on every edit-mode page, and in view mode when `sync` is loaded.
- `clay.isEditMode` — whether this session may edit.
- `clay.isOwner` — whether the platform's owner cookie is set (URL and global overrides
don't affect it, unlike `isEditMode`).
- `clay.isDraftMode`: whether this visitor's work is being kept in their own browser instead
of saved to the host. Needs a page that opted in with `visitor-drafts` (a slot on ``, the
plugin in `plugins=`, an http(s) page, an `api` rules tag), so it is `false` everywhere else.
Draft mode also sets `draftmode="true"` on ``, removed from every save (visitor-drafts plugin).
- `clay.visitorDrafts`: the draft store, with `active`, `slot`, `key`, `hasDraft()`, `restored()`
(`{restored, hadDraft, parked}`), `saveNow()`, `reset()`, `onRestore(fn)`, `editOriginal()`,
`backToDraft()`, `downloadUrl()`, `source`. Inert outside draft mode. Events:
`clay:drafts-restored`, `clay:drafts-saved`, `clay:drafts-store-failed` (visitor-drafts plugin).
- `clay.siteVisibility` — `"private"` or `"public"` when the host says how it serves this
site (hyperclay.com tells the owner's tab), otherwise `null`. Read once, at boot.
- `clay.toggleEditMode()` — flip between edit and view mode (reloads the page).
- `clay.cacheBust(el)` — re-download one resource by stamping `?v=` onto its `href` or
`src`.
- `clay.Mutation` — the shared mutation hub (one MutationObserver for the whole page,
region rules applied). `clay.Mutation.onAnyChange(opts, cb)` respects
`clay="no-watch"` regions.
- `clay.morph(oldEl, newEl)` — content-based DOM morphing engine: morphs `oldEl` in
place to match `newEl`, preserving focus, inputs, and animations (sync plugin).
Its signature is unchanged on hyper-morph 1.0.
- `clay.undo` — document-wide undo singleton: `clay.undo.undo()` / `clay.undo.redo()`
(undo plugin).
- `clay.ephemeral` — per-tab state on saved elements: `add(el, word)`, `remove`,
`toggle(el, word, force)`, `has`. See Ephemeral state.
- `clay.cms` — the content panel: `clay.cms.open()` (cms plugin).
- `clay.RichClay` — the rich-text editor class behind `editable` (richclay plugin).
- `clay.quickcrop(file, opts)` — crop modal: resolves `{blob, dataURL, width, height}`,
or `null` if cancelled (quickcrop plugin).
- `clay.upload(file, {onProgress, signal})` — post a `File` you supply (from an
`` registry and return the record), `canRename()` and
`rename(name)` (change the name this browser chose, keeping its id; only when the host
names nobody, since a host's name is changed in the host) (people plugin).
- `clay.author(el, attr = "data-by")` — stamp `el` with your id and record you in the
registry. With no host it asks your name once, remembered in this browser. Resolves
your record, `null` if you cancel, and rejects with `code: "people-unavailable"` when
the host failed to answer (offer a retry). On a host that names people it asks the
host again first, so a sign-out or rename in the app is never stamped with the old
name. Edit mode only (people plugin).
- `clay.wire` — per-file control channel to a process on the user's machine: `send`,
`cancel`, `get`, `list`, `isBusy`, `on` (wire plugin).
- `clay.live(name)` — the page's `
```
Edit mode is client-side behavior. Whether a save is accepted is always the server's
decision.
## The endpoint spec (host it yourself)
A clayjs server implements one route:
- Route: `POST /_/save`
- Body: the file's full HTML, as plain text. Always; this route has exactly one body
shape on every host.
- Header: `Document-URL`, the full URL of the page being saved.
- Success: `200` with JSON `{ "msg": "Saved" }`
- Failure: any non-2xx. The status is authoritative; host-generated failures use JSON
`{ "msg": "why" }`, but clients tolerate non-JSON intermediary errors.
A complete server in a couple dozen lines of Express:
```js
import express from "express";
import fs from "node:fs/promises";
import path from "node:path";
const root = process.cwd();
const app = express();
app.use(express.static(root));
app.use(express.text({ type: "*/*", limit: "10mb" }));
app.post("/_/save", async (req, res) => {
try {
const origin = req.get("Origin");
if (origin && new URL(origin).host !== req.get("Host"))
return res.status(403).json({ msg: "Cross-origin save refused", code: "forbidden" });
const page = decodeURIComponent(new URL(req.get("Document-URL")).pathname);
const target = path.join(root, path.normalize(page.endsWith("/") ? page + "index.html" : page));
if (!target.startsWith(root + path.sep))
return res.status(403).json({ msg: "Outside the folder", code: "forbidden" });
if (!/^\s*/i.test(req.body))
return res.status(422).json({ msg: "Not a complete HTML document", code: "invalid-document" });
await fs.writeFile(target, req.body);
res.json({ msg: "Saved" });
} catch {
res.status(500).json({ msg: "Couldn't save" });
}
});
app.listen(4600);
```
On your own host nothing arms edit mode for you, so set `window.clayEditMode` before the
script tag. Two details you may ignore: every save also carries a
`Save-Trigger: user|auto` header (whether a person triggered it), and HTML Clay uses a
token variant, `POST /_/save/{token}`, read from ``.
## Plugins
- `richclay` (default on) — the `editable` attribute: floating toolbar, clean HTML
output. Tokens: `single-line`, `no-toolbar`, `toolbar-on-select`. In the saved file
the attribute is an inert marker; no editor chrome is written to disk.
- `indicator` — a ready-made save-status chip driven by the `clay:save-*` events. It
shows Saving… and Saved. With `clay-ui.js` loaded, a failed or offline save is
reported by a persistent toast instead of the chip, so no save is reported twice.
- `sync` — live sync: outside file changes and other open tabs merge into the page in
place, preserving focus, caret, and unsaved edits (a three-way merge: both sides'
edits survive, and the incoming change wins where both changed the same words).
Powers `clay.morph` and
the `merge="name"` JSON script-tag merging.
- `sortable` — drag-drop reordering; fires `clay:sorted`.
- `undo` — document-wide undo/redo built on DOM mutations: moves, deletions, and
attribute changes all reverse cleanly. Respects `clay="no-undo"`.
- `cms` (`?plugins=cms`) — a content sidebar that builds itself from your page. Declare
content with a rules tag; whoever has edit access sees friendly fields (text, rich
text, images, lists), not markup:
```html
```
Each entry is a field: a name, then the selector it edits. `@src` becomes an
image-upload control, `li[]` a reorderable list, and the `products` shape a list of
cards. Open the sidebar with `?cms=true` on the URL or `clay.cms.open()`. The same
rules tag can carry more tokens (`data-rules-name="api cms"`) to also power the data
library's JSON API.
- `quickcrop` (loads with cms, or ask by name) — crop modal for image uploads:
`const shot = await clay.quickcrop(file, { aspect: 16/9 })` resolves
`{blob, dataURL, width, height}` or `null` if cancelled. The CMS uses it for any
image field marked `data-hcms-crop="1:1"` (aspects: `1:1`, `16:9`, `free` on the
attribute; a plain number in code).
- `upload` (ask by name) — `clay.upload(file)` posts a file you supply to the host and
resolves with its URL. It is not a picker; see `clay.upload` above.
- `people` (ask by name) — who is editing and who wrote what: `clay.me`,
`clay.people`, `clay.author`. Names live in the document as
`
Name
`, and authored elements
carry `data-by="id"`.
- `message` (ask by name) — a visitor's contact form to the document's owner. A form
with `message-form="
"`, or with `action="/_/message"`, is POSTed as
JSON to `/_/message` with the `Document-URL` header, carrying a one-time token from
`GET /_/form-token` (the host's bot check). `clay.message.send(data)` resolves
`{ ok, error }` and never rejects. Events on the form: `clay:message-sent`,
`clay:message-failed` (cancel to skip the toast).
- `wire` (`?plugins=wire,sync`) — a per-file control channel between the page and a
process running in the user's terminal (see `htmlclay wire serve -- `).
The page sends a request, the process answers with progress, and the process edits
the file; HTML never travels over the wire. The change reaches the open page as an
ordinary external file change, through live sync.
```js
const run = clay.wire.send({ ask: "tighten the intro" });
clay.wire.on((state, frame) => console.log(state.state, frame && frame.text));
const outcome = await run.done;
```
`send` returns its handle immediately; `handle.done` resolves with the final
snapshot and never rejects. `on` takes a function, not an event name, and returns
its own unsubscribe. A listener's second argument is the inbound frame when one
caused the update, which today means `wire/status` and nothing else: `state.text`
is sticky across later states, so the frame is the only way to tell a new status
line from the same line arriving again.
Also `cancel`, `get`, `list`, `isBusy`. Works in view mode too.
- `ai-edit` (opt in with `?plugins=ai-edit`, brings `wire` with it) —
select text and ask an agent to rewrite it. Select words in any block (a paragraph, heading, list
item, table cell...) and press ⌘J (Ctrl+J elsewhere), or click the small **AI** chip at the end
of the selection. The panel opens under the selection with the words highlighted. The request
carries the block's HTML, the selected text, and the text's character offsets inside the block;
a selection across blocks sends the nearest container, and one across top-level blocks sends
the whole page. ⌘J with nothing to open is left to the browser, and so is ⌘J in a page's own
input, textarea or select. Pages built from `[data-edit-id]` sections also get the hover chip,
a click on bare section padding, and the **AI** bubble for the whole page (just left of the
CMS's Edit content toggle when there is one).
The box composes and nothing else. Any click on the page away from it closes it, a click
inside the block being edited included, and the click still lands where it was aimed. Text
typed and not sent is kept as that target's draft and comes back when the box is opened on the
same target again, so a click away costs nothing. A sent comment is that target's draft too
until its reply is ready, so Error and Stop reopen the box with it. The box's own Stop, Keep
and Revert are gone: once a request is sent the whole live state moves to a bar fixed at the
bottom centre of the viewport, and the ring stays on the block being edited while the bar is up.
The bar shows one state at a time. **Working** shows the host's own progress line and **Stop**,
which cancels the request and leaves the page exactly as it was. **Ready** shows `Edit ready.`
with any warning the reply carried and the model that wrote it, and offers **Keep** and **Revert**.
**Error** shows the host's message and an X that closes the
bar, with the edit already rewound; a cancellation on the host's own side, the AI Editing switch
going off or HTML Clay quitting, reports `HTML Clay stopped this edit.` there. After Keep the
bar reports `Saving…` and then the host's answer, and closes itself about a second and a half
later. Keep saves what the live preview shows, a fix made by hand in it included, and a save
that fails or conflicts stays on the bar with its message and its X instead of closing. Revert
rewinds the edit and closes the bar. While the reply waits, every save is held, explicit ones
included (`clay.save()`, ⌘S, `[persist]`, the wire's pre-send flush): Revert lets a save asked
for meanwhile run, and Keep releases the hold and saves the rewrite through `clay.save()`. A
reply that adds a script, an inline event handler, a `javascript:` URL, `srcdoc`, or an `iframe`,
`frame`, `object` or `embed` the original did not have is refused before it is shown, and the
bar says so.
Escape reads the bar first: in Working it stops the request, in Ready it keeps the edit, in
Error it closes the bar, and in Saving it does nothing. With no bar and the box open it closes
the box and keeps the draft. It stands down when the page has a claim on it too: the event was
already handled, a `[data-clay-modal]`, `dialog[open]` or `[aria-modal="true"]` element is open,
or the caret is in a field the page owns. On a page with `[data-edit-id]` sections a second
click on the whole page bubble closes an empty box and keeps a typed one open. A leading
`@token` picks the engine, `@file.ext` adds context, and `@page` saves the page first so the
helper reads it from disk.
A page can open the composer itself, prefilled with a suggested request:
```js
await clay.aiEdit.open(element, { prompt: "Add a bar chart above the table. Keep the table." });
```
`open` opens the existing composer and returns true when opened, false when unavailable, busy
or the target cannot be edited. It does not send automatically. The prompt seeds an empty
per-target draft; previously typed text is preserved. The target must be a connected content
element in this document. Controls and transient UI are not valid targets. A listed but
disabled helper opens the existing setup explanation with Send unavailable, just like the
keyboard shortcut.
Edit mode only, and dormant unless `clay.wire.helpers()` lists an `ai-edit` helper. Listed as
`unavailable` (the host's switch is off), ⌘J opens the panel with a note and no Send. HTML Clay
and Hyperclay Local both answer it.
- `source` (default on; `?exclude=source` to turn it off) — saves the file rather than
a fresh printout of the page. Without it, every save rebuilds the document from the DOM, which reorders
attributes, renormalises quoting and reprints every tag: a save that changes nothing
rewrites about 88% of a hand-written file's lines. With it, clay keeps the bytes the
file was loaded from, pairs each node to its place in them, and reprints only what
changed. Edit mode only.
```js
clay.source.stats(); // { installed, paired, saves, reprints, partialReprints, lastReprint, ... }
document.addEventListener("clay:save-reprinted", (e) => console.warn(e.detail.scope, e.detail.reason));
clay.source.text(); // the bytes clay believes are on disk right now
clay.source.locate(el); // { from, to, line, column } for a live element, or null
```
`text()` and `locate()` are the pair an editing tool needs: read the file as it will be
written, point at an element in the live page, and express an edit as a range in those
bytes rather than as a DOM mutation. No ids written into the file, and no map that has to
survive a reload. Both answer for the file as of the last accepted save or the last
live-sync frame from disk, whichever came later: a refresh still waiting runs first.
**The offsets and `column` are UTF-16 code units into `text()`, not bytes.** Slice the
string `text()` returns and the answer is exact. Hand the same numbers to something that
counts bytes, such as a file seek or most Python file APIs, and it writes to the wrong
place, silently, and only on documents with non-ASCII content: one line of
`café 🎉 naïve` puts the same position at 51 code units and 55 bytes.
`locate()` answers `null` rather than guessing, and the reasons differ enough to matter:
the element was created after load, so it is not in the file yet; or it is an implied
``, `` or `` the author never wrote and that holds nothing; or the
plugin never installed, which is the whole document rather than that one element.
`stats().installed` and `stats().refused` tell the last case from the first two. An
implied tag that does hold content locates to the bytes it occupies, which are its
children's, because there are no tag bytes to include.
Every save is reparsed and compared against the page before it is sent. Where it
differs, the element it differs in is printed the way the browser would print it and
the rest is still copied, widening to the parent while the check still fails. Only a
failure that reaches ``, or has no element to point at, sends the ordinary full
serialization, so the worst case is the behaviour without the plugin.
`clay:save-reprinted` fires for an accepted save that needed either, with
`detail.scope` `'partial'` or `'full'` (and `detail.printed`, the element count, on a
partial one), and `stats()` counts them as `partialReprints` and `reprints`. A render
the host never accepted counts nothing. It costs an HTML parser (about
48 KB compressed) and one request for the file's own bytes at load, both in edit mode
only; `exclude=source` is how a page declines to pay that. It does nothing, silently
and safely, when that request fails, redirects, is not `text/html`, or returns
something whose doctype and document-level comments are not this document's. It also
declines two document shapes it cannot describe: one where the parser moves content
out of a `` it was written inside, so the source order and the tree order
disagree, and one with no content inside `` at all. `clay.source.stats().refused`
says which, and saves on such a page are the ordinary full serialization.
- `demo` — saves into browser storage instead of a host; used by clayjs.com's live
demos. `clay.demo.reset()` forgets the browser copy.
## Satellites (separate libraries, one script tag each)
- `clay-ui.js` — ready-made toasts, modals, and ask/confirm/tell dialogs. Listens to
`clay:save-*` events automatically, so adding it upgrades save feedback for free:
a failed or offline save shows a toast that stays up until the next save succeeds,
and Saved shows as a toast only when the `indicator` chip is not loaded. Set
`clay.saveToast = true` (or `window.clay = { saveToast: true }` before clayjs loads)
to show Saved as a toast, with the host's message, instead of on the chip.
- `clay.toast(message, type)` — `success` (default), `info`, `warning` or `error`;
gone on click or after about 6.6 s. `clay.toastPersistent(message, type)` stays
until closed.
- `clay.ask(prompt, onYes, defaultValue, extraHtml)`, `clay.confirm(prompt, onYes,
extraHtml)`, `clay.tell(title, ...paragraphs)` and `clay.snippet(title, code,
extraHtml)` — the prompt is the dialog's title, and Enter confirms. Ask, confirm and
tell return a promise that resolves on OK or Confirm (ask with the typed text) and
rejects on Cancel, close or Escape; an `onYes` that throws keeps the dialog open and
toasts the error. Snippet's promise resolves when it closes.
- `clay.modal` — the dialog they are built on. Set `title`, `html`, `yes`, `no`
(markup or a node each), `closeHtml` (any non-empty value shows the close button)
and `width` (default `600px`), register `onYes(fn)` / `onNo(fn)`, then `open()`.
Every setting resets after the modal closes.
- `clay-events.js` — HTML attributes for the events HTML forgot: `onclickaway`,
`onclickchildren`, `onrender`, `onmutation`, `onglobalmutation` (alias
`onpagemutation`), `onclone`. A mutation handler that writes into what it watches runs
forever, even when it writes the same text: guard it,
`if (this.textContent !== n) this.textContent = n`.
- `clay-options.js` — declarative show/hide: an ancestor carries a state attribute
(`view="kanban"`), descendants opt in or out with `show-when:view` (alias `option:view`),
`hide-when:view` or `option-not:view`. Any ancestor matches, not only the nearest.
- `clay-dom.js` — small prototype helpers for DOM-first apps: `el.nearest.menu`,
`el.val.title`, `el.exec.action()` (getter proxies reading the nearest element with
that attribute or class; "nearest" searches siblings and cousins too, so in a list it
can reach a neighbouring record), and `el.cycleAttr(1, "data-status")`, which sets the
attribute to the next value used anywhere in the document, in alphabetical order.
`el.cycle(order, attr)` is different: it replaces the element with a copy of another
element carrying `attr`. `clay.dom.createContentView(root, {capability})` returns an
inert view of a subtree with a region capability filtered out (`data` drops `no-data`
and `editor-ui`); `clay.dom.cleanContentClone(node, opts)` returns one cleaned clone.
- `all.js` — a chainable wrapper over querySelectorAll:
`All(".card").css({ opacity: 1 }).onclick(e => …)`. Events are property-form
(`.onclick`, `.oninput`); classes go through `.classList`. Zero dependencies.
- `clay-utils.js` — the small stuff: throttle, debounce, `cookie.get` / `cookie.remove`,
slugify, copyToClipboard.
- `clay-data.js` — the live page as JSON. `clay.extractData(rules?)` reads the live DOM.
`clay.applyData(values, {rules})` validates the complete change, then changes the live
DOM atomically. It writes content only. Scripts, styles, event handlers, executable
URLs, `innerHTML`, `outerHTML`, and unknown keys are refused without a partial change.
Load it with `await clay.loaded.data`.
Core adds the saved file operations beside that satellite. `await clay.readData(rules?)`
returns `{data, etag}`. `await clay.writeData(values, {rules, ifMatch})` returns
`{data, etag}` after the corresponding sync frame has reached the live page. All four
methods use the page's `api` rules tag when rules are omitted. Explicit rules are sent as
the URL encoded JSON `data` query on the document's own path.
`writeData` always sends `If-Match`, using the supplied value or the page's current save
version. It does not retry a `412`, adopt the JSON response's ETag as the page save version,
or send while the page is dirty, saving, conflicted, held, or disconnected from live sync.
It holds automatic and manual saves until the incoming file update is applied, then replays
any save requested during the hold. A successful write whose sync frame does not arrive
within 12 seconds resolves with `{data, etag, pageUpdatePending: true, message}`. The write
has landed in that case, so callers must not retry it automatically. Server failures reject
with an `Error` carrying `status`, `error`, `message`, and `details`.
- `sap.js` — reactive templates with the DOM itself as the state: attributes and
elements are the source of truth, templates re-render when the DOM they depend on
changes. No virtual DOM, no store.
- `clay-internals.js` — the pieces clayjs builds itself out of, for code that sits
inside the save lifecycle (see below).
## clay.internals (low level)
One extra script tag, ``;
`clay.loaded.internals` resolves when ready. Lower level than `clay.*` deliberately.
- `captureSnapshot()` — the cloned document element, with form values synced,
`onbeforesnapshot` handlers run, and `no-snapshot` regions plus browser-extension
noise removed. The shared base every lane starts from.
- `captureForSave()` — the exact bytes a save would send, stripping and all.
- `addDocumentTransform(fn)` — same registry as `clay.addDocumentTransform`. Runs on
the clone during save preparation and on every change check; keep it pure and
repeatable, or the page never reads clean and autosave never stops.
- `region.resolveRegionPolicy(node)` — the resolved region axes for a node:
`{watched, autosaveTriggered, dirtyTracked, undoable, persist, extension}`.
`autosaveTriggered` is whether an edit here starts a save by itself; `dirtyTracked`
is whether it counts as unsaved work at all. They differ only inside
`no-trigger-autosave`; `no-dirty` clears both.
- `region.isInert(node)` — whether a node is invisible to the whole mutation system
(`no-watch` and extension noise).
- `region.isSnapshotRemoved(node)` — whether a node is dropped from every snapshot
(`no-snapshot`). Ancestor-aware.
- `region.addRegionToken(el, token)` — add one token to an element's `clay` attribute,
keeping the tokens already there: `addRegionToken(toolbar, "no-save")`.
- `region.PERSIST` / `region.REGION_ATTRS` — the persist values (`full`, `frozen`,
`none`) and the canonical token list.
- `region.selectors` — `stripFromSave`, `stripFromComparison`, `stripFromDirtyCheck`,
`noTriggerAutosave`, `snapshotRemove`, `freeze`: the selectors clayjs itself strips
with, legacy spellings included.
- `save.saveHtml(html, cb)` — send an HTML string to the save endpoint. Bypasses the
snapshot pipeline entirely: nothing is stripped and no hook runs. Resolves
`{ok, msg, msgType, code, etag}`; never rejects.
- `save.replacePageWith(url, cb)` — fetch another URL's HTML and save it over the
current file. Same result shape.
- `save.isSaveInProgress()` — whether a save is in flight right now. Check it before
`saveHtml`; firing into an in-flight save is how you lose an edit.
Writing a custom attribute that touches the save? Ask clayjs what a region means
instead of matching `[no-save]` yourself:
```js
await clay.loaded.internals;
const { resolveRegionPolicy, addRegionToken, PERSIST } = clay.internals.region;
clay.internals.addDocumentTransform(clone => {
for (const el of clone.querySelectorAll("[count-of]")) {
if (resolveRegionPolicy(el).persist !== PERSIST.FULL) continue;
el.textContent = clone.querySelectorAll(el.getAttribute("count-of")).length;
}
});
```
Derive from the document, don't stamp the clock: a transform that writes something new
each run (a timestamp) leaves the page permanently dirty.
## Pages
- https://clayjs.com — the tutorial
- https://clayjs.com/get-started — zero to a saving file in four steps
- https://clayjs.com/docs — the reference
- https://clayjs.com/advanced — regions, save hooks, satellites
- https://clayjs.com/plugins — the heavyweight plugins
- https://htmlclay.com — the desktop app; https://htmlclay.com/features — everything it does
- https://github.com/panphora/clayjs — source