Guide · Visual

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.

An agent edits the saved file on disk. ClayJS turns that file into the open page on open, merges outside changes in with the sync plugin, and turns the page back into the file on save. AI agent · text editor edits the bytes directly saved file inert-contenteditable no editmode attribute open page, editor toolbar Change image contenteditable="true" editmode="true" open, and with the sync plugin: merge in Open and sync both activate editing attributes. Sync merges in saved form first. save out make editing attributes inert, drop stripped regions, keep clay="freeze" contents as loaded The host (HTML Clay locally, Hyperclay online) writes the file and delivers outside changes. A browser alone cannot.
durable record: saved, synced, seen by viewerstemporary: never saved, rebuilt on every load
Everything green exists in both forms. Everything amber exists only in the open page.

ClayJS handles two sets of marked regions:

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.

open pagesaved file toolbar toolbar =

Rung 1. Editors only

The editing UI is the app. Nobody views it anonymously, so nothing is stripped, and controls are ordinary markup in the file. The two forms are the same, apart from state ClayJS adds on open and removes on save, such as editmode="true" on <html>. So sync has nothing of yours to translate.

open pagesaved file, as a viewer sees it toolbar hidden by mode ≈

Rung 2. Editors and viewers

Controls stay in the file, each marked show-when:editmode="true". Load clay-options.js to make that attribute work. ClayJS sets editmode="true" on <html> for editors and strips that attribute from the saved file. Section 6 shows how to hide them before that script runs.

Editors and viewers load the same bytes. Rung 2 adds one difference: editing attributes such as inert-contenteditable, which sync translates for you. The cost is that viewers download the control markup.

open pagesaved file editor-ui inert-contenteditable ≠

Rung 3. Stripped controls

Controls are created at load inside clay="editor-ui", and fields use editable or editmode:contenteditable. The saved file is clean viewer HTML.

Sync skips the marked controls. You recreate them on every load and bind events by delegation.

open pagesaved file clay="editor-ui" view drawn by JS {"q1":3,"q2":7} {"q1":3,"q2":7} ≠

Rung 4. Rendered views

The durable record is data: markup, or a merge="store" JSON script. A canvas, chart or grid is drawn from it into a mount created at runtime inside clay="editor-ui". The saved file holds only the record.

A clay="freeze" mount also works. Author it empty in the file, and draw into it only after clay.ready. Drawing before clay.ready freezes the drawing into the file.

Sync merges the record and never touches the view. Write one render function and call it on load, after every local command, and on clay:sync-applied. ClayJS fires that event after it applies an outside change.

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.

ClayJS merges the agent's saved-form edit with the open page captured in saved form, activates the editing attributes in the result, then applies it to the open page while skipping the toolbar. A class added by unmarked boot code is not translated and gets saved. 1. Merge the agent's edit <h1 editmode:contenteditable inert-contenteditable> Great Work Studio </h1> The open page is captured in saved form too, to merge with this. 2. ClayJS activates the result <h1 editmode:contenteditable contenteditable > Great Work Studio </h1> Editing attributes turn on, the same way they do on open. 3. Apply to the open page toolbar: skipped, kept as is Great Work Studio class="is-editing" from boot code, unmarked. It gets saved. Use an [editmode="true"] selector instead. ClayJS can only translate differences it created or that you marked. It knows its own editing attributes and the clay="editor-ui", clay="freeze" and clay="no-…" regions. It does not know your code.

Three cases need care.

4. The mistakes, drawn

Each of these breaks saving, syncing or privacy without an error.

open 1 open 2 open 3

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.

editor A noteno-save file editor B note ignored ✕

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.

file Blue boot script title ="Red" file, next save Red the next save writes Red over the agent's Blue

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.

sync handlernone

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(…).

record (synced) {"sales": 9} chart (drawn by JS) still 5 ?

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

fileopen pageafter an outside change id=a1 id=a2 id=a3 id=x7 id=q2 id=m9 edited id=x7Send the brief your edit id=a1Write the brief the file's copy

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.

open page crop dialog save file <body style="overflow:hidden">

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.

file salary: 90kdisplay:none every byte viewer view source:salary: 90k

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.

Decision flow: temporary state goes in a JS variable or an editor-ui region; derived state is re-rendered into editor-ui; content viewers see is plain markup; clutter stays in the file, hidden by mode; secrets go in a separate file. Should it survive closing and reopening? no yes Temporary A JS variable, or DOM marked clay="editor-ui". Rebuild it on every load. Selection, previews, rung 3 controls. Is it content, or computed from it? computed content Derived Re-render it into clay="editor-ui" on load, after edits, and on clay:sync-applied. Should viewers see it? yes no, it's clutter no, it's secret Plain markup Text, attributes, child order, persist on form controls. Done. Hidden from viewers Notes and controls, in the file and hidden by mode. Not in this file A separate file behind its own access.

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.

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.