Malleable HTML, drawn
You save and reopen, and an "Add item" button appears twice. Or the next save writes over an agent's edit. Both happen when the saved file and the open page drift apart. Section 6 is the smallest complete file: a task list that keeps its edits and order when you reopen it. This page assumes you have loaded ClayJS and saved through a host. If you haven't, start with Get started. The attribute reference and the APIs are in the complete guide.
1. One file, two forms
A malleable HTML file exists in two forms. The saved file is the bytes on disk. The open page is the live DOM in an editor's browser. ClayJS turns the file into the page on open, and the page back into a file on save. With the sync plugin, it also merges outside changes into the open page.
ClayJS handles two sets of marked regions:
- Stripped regions are
clay="editor-ui",clay="no-save"andclay="no-snapshot". Saving removes them. - Excluded regions are those three plus
clay="freeze". Sync skips them.
A clay="freeze" region in the file is saved with the contents it had when the page loaded.
ClayJS translates every difference between the two forms that it knows about, in both directions. So keep the two forms as close as the app allows, and mark every difference your code adds.
2. How much the two forms differ
Pick the lowest of these four rungs that fits the file. Each rung up moves the two forms further apart and costs you more rules. Gray marks a control saved in the file.
Rung 1. Editors only
Rung 2. Editors and viewers
Rung 3. Stripped controls
Rung 4. Rendered views
3. Why stripping makes sync harder
An agent edits the file on disk, so its edit arrives in saved form. Its editing attributes are inert, and from rung 3 up it has no toolbar. ClayJS brings the edit into the open page in three steps.
Three cases need care.
- An outside change never updates an excluded region, so rebuild anything inside one yourself.
- An unmarked class or button from boot code is ordinary content. The next save writes it, and an outside change can remove it.
- A viewer's page with
syncreceives the stripped file, never an editor's open page.
4. The mistakes, drawn
Each of these breaks saving, syncing or privacy without an error.
1. Injected controls without a mark
A script appends an "Add item" button at load. The button goes into the file with the next save, and the next open adds another.
Put clay="editor-ui" on the root of any control your code creates at load, or author the control in the file and hide it by mode (rung 2). Then delete the copies already saved.
2. Content inside a stripped region
You put editor notes in clay="no-save" so viewers won't see them. The notes never reach disk, and they vanish on reload. Another editor's open page receives them and ignores them.
Keep notes that editors share in the file, and hide them by mode. A stripped region holds only what you can rebuild.
3. Boot code that writes defaults
Boot code fills fields from hardcoded values or a JS object as the page opens. The next save writes those defaults over what an agent or another tab put in the file.
Read values from the DOM at clay.ready, and write a default only where the DOM holds none.
4. Handlers bound once, per element
querySelectorAll('li').forEach(li => li.onclick = …) at load. An item that arrives later by sync, or a node the merge replaced, has no handler.
Delegate: one listener on document that checks event.target.closest(…).
5. Views that do not re-render
A chart, canvas or parsed JSON object is built once at load. Sync updates the record but not the view.
Write one render function, as in rung 4, and call it on clay:sync-applied too. Write derived text only when it changed. Writing the same text leaves the file unchanged, but with the undo plugin it still adds an undo step outside clay="editor-ui".
6. Identities that change
Boot code generates a new id for each item on every load. The open page and the file then name the same item differently. When an outside change arrives while the page has unsaved edits, every item you edited shows up twice: your edited copy and the file's copy.
Mint an id or data-id once, when the item is created. Keep it through edits and moves.
7. Temporary state on durable elements
A dialog, drag or hover sets a style or class on <body> or a content node for a moment. ClayJS's crop dialog and clay.confirm set overflow: hidden on <body> while they are open, so a save in that moment writes it into the file.
Keep transient state in JS or an excluded region. To keep such a style out of every save, remove it in clay.onSnapshot(fn). fn receives a copy of the <html> element before each save or send. Change that copy, not document. For the dialog's overflow:
clay.onSnapshot((root) => {
const body = root.querySelector("body");
if (body.style.overflow === "hidden") body.style.removeProperty("overflow");
});
The hidden check keeps any other overflow value on <body>. If your page sets overflow: hidden itself, this removes it too.
8. Hiding treated as privacy
CSS, view mode, viewmode:disabled and hidden inputs change presentation. Anyone who receives the file receives every byte. Editors' tabs also send each other no-save content.
Secret data goes in a separate file behind its own access.
5. Where each piece of state goes
Ask these questions of every element, attribute and variable.
If the file itself must show a derived value, render it into a clay="no-dirty" region. Writing there starts no save. The value reaches the file with the next save of other content, and an outside change replaces it.
6. The smallest rung 2 file
This file is a task list you can add to, rename and reorder. Its controls are in the file and hidden from viewers, which makes it rung 2.
<!DOCTYPE html>
<html lang="en" autosave>
<head>
<meta charset="utf-8">
<title>Tasks</title>
<style>html:not([editmode="true"]) [show-when\:editmode="true"] { display: none }</style>
</head>
<body>
<h1 editmode:contenteditable inert-contenteditable="plaintext-only">Tasks</h1>
<ul id="tasks" sortable="tasks">
<li data-id="t-1"><span show-when:editmode="true" sortable-handle>↕</span> <span editmode:contenteditable inert-contenteditable="plaintext-only">Write the brief</span></li>
</ul>
<button show-when:editmode="true" type="button" data-action="add">Add task</button>
<template id="task-tpl">
<li><span show-when:editmode="true" sortable-handle>↕</span> <span editmode:contenteditable contenteditable="plaintext-only">New task</span></li>
</template>
<script src="https://clayjs.com/v1/clay.js?plugins=sync,sortable,indicator&exclude=richclay"></script>
<script src="https://clayjs.com/v1/clay-options.js"></script>
<script>
document.addEventListener("click", (event) => {
if (!event.target.closest('[data-action="add"]') || !clay.isEditMode) return;
const li = document.getElementById("task-tpl").content.firstElementChild.cloneNode(true);
li.dataset.id = "t-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
document.getElementById("tasks").append(li);
});
</script>
</body>
</html>
Open the file through your host. Add a task, rename it, drag it above "Write the brief", and wait for the indicator to show saved. Reopen it: there is still one Add task button, and the task keeps its new name and place.
- The record is written in its saved form (
inert-contenteditable), so a viewer gets plain text from the first open. The<template>holds the form the open page uses (contenteditable), because a clone is only ever inserted while someone is editing. - New items come from the template, never from a copy of an item on the page. Each one gets a fresh
data-id, built withoutcrypto.randomUUID, which is missing outside a secure context (for example plain http on a public host). - The click handler checks
clay.isEditMode, because hidden buttons are still in a viewer's page. sortable="tasks"makes the list draggable by each item's ↕ grip, markedsortable-handle.exclude=richclayskips the rich-text editor, because every field here is plain text.indicatorshows save status.clay-options.jsturns each distinctshow-when:attribute and value into one CSS rule, in a<style data-name="option-visibility">tag. A file gets that tag with its first save.- Without a fallback, a viewer's browser shows the controls of a never-saved file until the script runs. So this file carries one in its own
<style>tag. The backslash inshow-when\:editmodeescapes the colon. Without it, the browser drops the whole rule. The rule is keyed on:not([editmode="true"]), because the saved file has noeditmodeattribute.
Move to rung 3 only when viewers must receive HTML without the controls. For undo, a filter and a computed summary, study examples/skeleton.html. It renders the filter and summary into clay="editor-ui" and keeps its controls in the file, hidden by mode.
Full reference: the complete guide.