- ClayJS
- How ClayJS works
- Structuring an app
Structuring an app
How to shape a whole app: the record container, views, derived values and app state.
The record container#
Keep every record in one container in the saved document, with data-id per record and fields as attributes, text, or persist controls. Shape the container by the primary editing dimension:
- Board-first (kanban, pipeline): records live inside their column’s list.
sortablegives cross-column drag, and the column a card sits in is its status. - Table-first (finance, CRM contacts, inventory): records are
<tr data-id>rows in one<tbody>. Any board is a projection. - Document-first (writer, wiki page): records are blocks, siblings in one
sortablecontainer (Block writer).
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.
Chrome#
Chrome is the app UI around your records. Author static chrome in the file and hide it from viewers with show-when:editmode="true" (Showing and hiding edit controls: clay-options). It needs no script, is in place before ClayJS loads, and viewers never see it. The file carries its markup, which is harmless as long as nothing changes it.
Use clay="editor-ui" when hiding is not enough:
- A script changes it after load. This includes counts, summaries, status text, and a filter bar that reflects its state. In a saved element, each change is an edit: it starts an autosave, adds an undo step, syncs to peers, and can conflict with theirs. Inside
editor-ui, none of that happens. For a selection or open state on a saved element, useephemeralinstead. - It is drawn from data. Charts, canvases, and grids rendered from the record.
- It holds per-tab state. The open card, the current view, and the style that paints them (Per-tab state).
- Viewers must not receive it. The saved file has to be clean HTML without the controls.
An editor-ui element is removed from every save, so do not author it in the file. Clone it 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. Author static per-item controls, such as a grip or remove button, in the item’s <template> with show-when:editmode="true". Insert script-managed per-item controls (the writer’s block gutter, a list’s menu button) into each item as their own editor-ui element, and re-insert them in render() when missing so items added by a peer get theirs too. A per-item value computed from the record, such as a count, is saved content rather than chrome (Derived values).
Edit-only chrome lives in the same wrapper with show-when:editmode="true", or is skipped in view mode (if (clay.isEditMode) …).
Per-tab state#
Keep UI state outside saved records unless it belongs to the document for every visitor.
| 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 | clay.storage.local.get(key) / set(key, value) / remove(key) | per device and viewer, scoped to this file’s path (clay.storage.session lasts one tab) |
| Selection, hover, drag state, open menu | ephemeral on the element (Ephemeral state: per-tab state on saved elements), memory, or chrome DOM | transient, never saved or synced |
| Pointers, lobbies, who is looking at what, shared live but never saved | a never-saved live store (Live data: clay="live") | every tab sees it at once, the file never holds it |
| 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 |
For state on one element, such as the selected card, use ephemeral (Ephemeral state: per-tab state on saved elements). Paint state that comes from the hash 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 or reaches a peer. The kanban and skeleton tests check 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 reset: it autosaves, syncs to every collaborator, and lands in version history.
clay.storage keeps JSON under clay:doc:<location.pathname>:<key>, so two documents on one subdomain never share a key. Without it, a per-document key must be hand-built from the path or every document’s app collides with the next. session survives a reload and copies into a duplicated tab, so it cannot keep one tab’s state out of the next. When the browser blocks storage, every call works from memory for the life of the tab and set returns false: save the value somewhere that outlives the tab if set returns false.
Derived values#
Pick by whether the saved file must show the value:
- Computed only from the records (counts, progress badges, totals, a card’s face): ordinary saved content. Write it into the record markup, ship the file pre-painted, and recompute on every change. With sap, this is a paint (sap.js: records, views and totals in markup) that ClayJS treats as derived. Merges leave it out and the engine repaints it from the merged records, so two tabs that changed different records end with the right value and no conflict. Hand-written derived text in saved content does not get this, so give it
no-dirty. The starter Kanban paints its list counts and card faces this way. - Different per tab (search results, which entry is open, “overdue” against today’s date, OS shortcut labels, names loaded from the people plugin): write it inside a
clay="freeze"scratchpad (Regions: theclayattribute). The element saves and syncs, while its contents never do. - Pure UI (buttons, menus, handles):
editor-ui, created from a<template>(Chrome). - Optionally,
no-dirtyfor a computed value the saved file shows: it saves with the next real edit, never counts as an edit itself, and remote wins under sync. Apply the Regions and JSON under sync workaround. The finance tracker does this. - Only in the saved file: compute it into the save clone with
clay.addDocumentTransform, never the live page.
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 inside clay.effect(render), which covers record mutations, merges, undo, and redo (Reconciling in app code). Call render() from a hashchange listener when the hash affects the view.
With sapjs, every derived value here is a paint. sapjs recomputes on record mutations and clay:sync-applied by itself, and on hash changes only through clay-router.js (clay:route). An app with its own hash format keeps a hashchange listener that calls Sap.refresh(). See the sapjs guide.
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 Per-tab state:
<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:
- There is no copy, so nothing to write back and nothing to lose.
- The detail moves with the card when dragged, and merges per card under sync.
- A peer’s edit to the open card appears in place, with your caret kept.
- richclay’s body-mounted toolbar works because nothing is in the top layer.
showModal()would also writeopeninto the file.
Open on a click that is not on an editable field, handle, input, or 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 and 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.
Half-typed text in a detail field outlives the view. A comment box or description being edited sits in chrome (clay="editor-ui" or clay="freeze") and carries unsent="<name>". ClayJS keeps its value per record (the nearest [data-id]) and name in this tab, outside the file. Close the detail, take a sync redraw, or reload, and the text is back with the close warning on until it is sent.
<div class="detail" clay="editor-ui">
<textarea class="comment" unsent="comment" placeholder="Add a comment"></textarea>
<button data-action="send-comment">Send</button>
</div>
- The attribute is only a marker in the file: the region is chrome, so no save, sync frame, or undo step ever carries the text.
- Restoration runs from a
MutationObserverrather than a microtask, after the code that rebuilt the detail, so the view’sopenDetailcan clear and refill the field in the same task. To react to it, listen forclay:unsent-restored, which bubbles from the control withdetail: {base, current, stale}.staleis true when the record’s saved value changed since the person started typing, so the view can warn instead of silently overwriting. - Send and cancel call
clay.unsent.clear(input). Typing the text back to what the record already holds, or emptying it, drops it too, so the warning clears without an explicit call. clay.unsent.sizeandclay.unsent.list()({record, name, base, text}) say what is kept, andclay:unsent-changedfires ondocumentwith the count.
With sapjs, the record-as-modal pattern works unchanged over items= records, and a plain-field editor can be a detail= panel or <dialog> instead. See the sapjs guide.
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);
});
- Build new elements off-document (fill, id, log), then insert once inside the commit.
- Use one delegated click listener on
documentfor all actions. It survives merges that recreate elements. - Write activity entries from the command that made the change, never from a mutation observer, which would also log peer changes and undo replays.
- Bulk inserts (an import of hundreds of rows) cost one mutation record per inserted element. Insert a fragment once rather than row by row, inside one commit or
Sap.batch.
Soft delete is clay.trash. card.remove() above deletes for good. A delete a person might want back, or one a peer might be editing at the same moment, is an attribute instead: a record is in the trash while it carries data-deleted-at, and the record keeps its place, its id, and its content.
const item = button.closest("article.entry");
const { undo } = clay.trash(item); // stamps data-deleted-at, fires clay:trashed
clay.toastPersistent("Moved to Trash", "info", {
actions: [{ label: "Undo", onClick: () => { undo(); clay.toastPersistent("Restored", "info"); } }],
});
clay.trash(el, {at})stamps the timestamp (at, or now),clay.trash.is(el)checks trashed state,clay.trash.restore(el)removes the attribute and firesclay:restored, andclay.trash.purge(el)removes the record for good. Both events bubble from the record.- Because a delete is an attribute change, a peer editing the same record at that moment keeps their edit and the delete merges with it. Undo restores the attribute instead of an old copy of the record.
card.remove()has neither. - Leave trashed records out with a rules selector,
"article.entry:not([data-deleted-at])", or keep them in the list and show adeletedAtfield (Date.parse(el.dataset.deletedAt)) for a Trash view. Never remove records to filter. - Readers without JavaScript get the raw file, so hide trashed records there too:
<noscript><style>article.entry[data-deleted-at]{display:none}</style></noscript>. - The undo toast stays yours:
clay.toastPersistent(message, type, {actions})takes the Undo button whereclay-uiis loaded, and an app with its own toast keeps it.
Filters and view switching#
Filter and switch views without removing or mutating record elements:
- Filter by hiding records with a runtime stylesheet (skeleton, kanban), or with sapjs through an
each=view or a frozen stylesheet (sap.js: records, views and totals in markup). The finance example still uses sapshow=plus snapshot normalization, an older pattern whereshow=writeshiddeninto saved records. Never remove records to filter. - Switch layouts with CSS over the same records where possible: a list view can be the board with
display: contentson columns. - Projection views (a table of all cards, a calendar, a due-today list) render into an
editor-uicontainer and rebuild on record mutation,clay:sync-applied, andhashchange. Edits made in a projection write to the record by id.
With sapjs, a projection view is an each= attribute, and its rows edit the records they show by themselves. See the sapjs guide.
Computed totals#
Use plain JavaScript into editor-ui (Derived values) for a count or two. Use sapjs for arithmetic across rows, categories, and formats (sap.js: records, views and totals in markup, and the expense tracker in the sapjs guide). Store money as integers or plain numbers in value attributes, never as formatted strings.
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. applyData 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.
Importing and exporting records#
The Journal keeps every record in one container, so a backup, an import, a selected export and a print are four ways of copying or writing that same set. Each one is a named function in journal.html.
- Back up the exact fields, blanks and Trash included.
Backup.recordsreads every record with all of its fields, andBackup.envelopewraps them as{ format, version, exportedAt, entries }. An empty body and a trashed record are still records, so a backup restores what the file holds rather than what the view shows. Markdown is not an exact data backup. - Parse, preview and revalidate before committing.
Backup.parsereads a file inertly and refuses unknown fields, missing fields, duplicate ids and bad dates with a code.Backup.plansorts the rows into fresh, identical and conflicting ones,planRestoreofferskeepBothfor a row that changed here, andBackup.verifyreads a candidate back field for field. A refusal at any of those points changes nothing. - Commit once, synchronously.
confirmImportre-reads the limit, compares the current capture with the preview’s baseline, replans and re-verifies, then appends the prepared records in one synchronous step followed by oneclay.save.soon(). There is no await between the last check and that step, so a refusal leaves the page, the route, the selection and the dirty state as they were. - Measure the candidate against the host’s limit.
candidateWithbuilds the bytes a save would send with the new records already in place, andmeasureHTMLasksclay.save.inspectabout them. An over-limit import is refused before a single record enters the document. - Export only the bytes selected.
selectedCopyremoves every unselected article from the copy insideclay.getHTML({ transform }), so the records left out are gone from the file rather than hidden by CSS.verifySelectedCopyreads the result back and checks the exact id set. - Move a year out only after the copy you kept checks out.
YearArchive.archivedownloads the year as a.htmlclaycopy and an exact JSON backup and removes nothing; starting a download proves nothing about a second copy. Finish moving this year asks for the file the person kept, reads it inertly, and requires exactly the reviewed records, field for field. A different file, a changed record or a record that joined the year since the review refuses with nothing removed. Before removing,ArchiveMoves.stagekeeps the reviewed records in thejournal.archive-movesrecovery channel and awaits its durable receipt; a browser that cannot keep it refuses and offers the copy only. The removal itself is one synchronous step after a last re-check, followed by oneclay.save.soon(). The move is cleared only by a saved file whose bytes lack every reviewed id, so a save that never lands leaves Archive move awaiting save after a reload. A year whose entries link pictures kept in the journal’s own files can be copied but not moved, because a copy cannot carry them yet. - Print the canonical records.
printSelectedflags the chosen live articles with the ephemeralprint-pickword and print CSS narrows the printout to them, so a printout is the records themselves, not the filtered list or an open editor. The flag is never saved, and another tab’s sync never carries it.
const attempt = candidateWith(added);
if (!Backup.verify(attempt.html, attempt.expected).ok) return;
const size = await measureHTML(attempt.html, limit);
if (size.state === "over") return;
store.append(fragment);
Starting the app once#
Start the app with clay.start so initialization runs once per page. A live-sync merge can re-insert an inline script and run it again, which doubles every listener and every chrome element. clay.start runs its function once after clay.ready, and a repeat call does nothing:
clay.start(() => {
…
});
A page with more than one app script names each one so they do not share a slot: clay.start("board", fn).
Document size and record limits#
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 |
Typing also costs one Sap pass, and a pass visits every record. Sap skips rereading unchanged record values, but it still builds fresh objects for every record, so the pass grows with the record count: at 5,000 records in jsdom, a body edit’s pass takes about 27 ms (Cost at size).
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.
Styles a script writes#
Use clay.style when a script owns CSS that should stay in the saved document. The saved style paints the next load before the script runs. When the script runs, it refreshes that same tag with its current CSS:
await clay.ready;
clay.style("app-colors", ".status { color: blue; }");
The name identifies a <style data-name="app-colors" clay="no-dirty"> in the document’s head. On the first load, before any save has carried the tag, clay.style creates it. A repeated call updates the existing tag in place without adding an undo step, starting an autosave, or raising the close warning. The next real save includes the current CSS.
The one-argument form keeps an external stylesheet link current:
await clay.ready;
const link = clay.style("/app.css?v=2");
If the saved file has /app.css?v=1, this updates that link’s href to /app.css?v=2. Matching uses the URL’s origin and path, ignoring its query and fragment. The link is also no-dirty and travels with the next real save. Both forms work in edit and view mode and return the element synchronously, without waiting for the stylesheet to load.
Use clay="editor-ui" instead for CSS that viewers must not receive or that represents per-tab state, such as a temporary filter. Those styles are stripped from saved HTML. clay.style is for CSS that belongs in the file, and changing its name on every load creates another saved tag. Adding or rewriting a plain style tag without no-dirty can raise the leave warning even though the person edited nothing.