clay.js
  1. ClayJS
  2. How ClayJS works
  3. Edit mode and view mode

Edit mode and view mode

How edit mode is decided, what changes between edit and view mode, and how to design for both.

How the mode is decided#

Three separate facts:

  • clay.canEdit is permission: whether this person may edit the document at all.
  • clay.mode is "view", "edit" or "draft", and it is a live getter.
  • clay.modeLifecycle.state.destination is where edits land: "host", "draft", or "demo" when the demo plugin saves to localStorage.

Permission alone does not open edit. An owner on hyperclay.com opens in view, with clay.canEdit === true. The host’s own answer about the document, document.writable in /_/meta, is the last word on canEdit, and a change to it fires clay:capabilitychange.

The mode this tab starts in is decided once, in this order:

  1. Draft mode, on a page that opted in (Free documents for visitors). The visitor gets the editing UI, and their work stays in their own browser. ?editmode=false, a viewonly attribute, a stale token, window.clayEditMode, or a save token all turn draft mode off. Only the owner cookie plus ?editmode=true edits the original. On a viewonly document or one with a stale token, ?editmode=true is plain edit mode as usual (The mode ladder).
  2. The reload receipt. A deliberate switch writes sessionStorage entry clay:doc:<pathname>:mode-receipt just before it reloads. Boot reads it once and removes it, accepts it only within 30 seconds and only for a reload of the same path, and then starts the tab in the mode it names. A receipt keeps one intentional reload in the same tab, not a lasting preference.
  3. ?editmode=. Only the literal true asks for edit.
  4. window.clayEditMode === true or <html opens-in="edit">.
  5. Otherwise the tab starts in view.

<html viewonly> in the file forces view mode. 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).

clay.canEdit follows its own ladder: ?editmode= first, then viewonly and a stale token, which take edit off the table, then window.clayEditMode, then a save token or the owner cookie.

clay.isOwner reflects the cookie alone. On HTML Clay, an editable page has isOwner === false. Use clay.canEdit for whether a person may edit, and clay.mode for whether this tab is editing.

Switching modes: clay.setMode#

await clay.setMode("edit") and await clay.setMode("view") switch this tab. setMode takes one argument, and anything else throws a TypeError.

Three answers:

  • { ok: true, mode, changed }: the switch committed. changed is false when the tab was already in that mode.
  • { ok: true, mode, changed: true, reloading: true }: a reload is on its way.
  • { ok: false, mode, code }: it refused, and mode is still the committed one.

The refusal codes:

  • busy: another switch to the other mode is already running.
  • not-allowed: this person may not edit, or the page is in draft mode.
  • permission-denied: the host answered that this caller may not write the document.
  • cancelled: the page moved to another document while the host answered.
  • host-failed: the workspace shell threw instead of answering.
  • host-refused: the workspace shell answered without success.
  • save-failed: the save before leaving edit failed or did not settle as saved.
  • pending-input: text or a composition is still uncommitted in the page.
  • conflict: the file has an unresolved save conflict.
  • save-unknown: the outcome of the last save could not be established.
  • stale-document: the file moved on while the session was being prepared.
  • timeout: the 15 second deadline passed.
  • load-failed: a module the entry needed failed to import.
  • unsupported-plugin: a queued entry was not a participant.

An undeclared document saves, writes the receipt and reloads. A document with <html clay-mode-switch="live"> and supported participants switches live. A workspace frame hands the change to its shell, whose reload owns it.

Draft mode always answers not-allowed: a draft stays local.

Keep the control on the committed value while a switch is pending, and show the code when it refuses:

JavaScript
const toggle = document.querySelector("[clay-mode-toggle]");
const status = document.querySelector("[data-mode-status]");
let busy = false;

function paint() {
  toggle.setAttribute("aria-checked", clay.mode === "edit" ? "true" : "false");
}
paint();
document.addEventListener("clay:modechange", paint);

toggle.addEventListener("click", async () => {
  if (busy) return;
  busy = true;
  toggle.disabled = true;
  status.textContent = "";
  const result = await clay.setMode(clay.mode === "edit" ? "view" : "edit");
  // A reload is coming: stay busy until the page goes.
  if (result.reloading) return;
  busy = false;
  toggle.disabled = false;
  if (result.ok === false) status.textContent = `Stayed in ${clay.mode}: ${result.code}`;
});

Never switch by writing the root editmode attribute. That attribute reports the committed mode; it does not change it.

clay.toggleEditMode() is the one-line form. In draft mode it opens ?editmode=true, and otherwise it calls setMode.

Switching without a reload: clay-mode-switch=“live”#

<html clay-mode-switch="live"> is the author’s promise that every edit resource can be taken back. Without it, setMode takes the guarded reload: it saves, writes the receipt and reloads.

A document registers participants with clay.modeLifecycle.register(...), or before the loader runs through the window.clay.modeParticipants array, which survives the boot and is drained by the first entry.

A participant is { id, order, prepare }. order decides prepare order and defaults to 0. prepare(context) returns a lease:

  • ready: a promise the entry awaits before it goes on, or nothing.
  • enable: runs synchronously, once every preparation has succeeded.
  • quiesce: stops admission without losing buffers.
  • resume: takes the very same lease back after a refused exit.
  • dispose: runs only after a committed exit.

context is { generation, destination, signal, represented, reader, initial, isCurrent() }.

clay.modeLifecycle.canWrite() is true while this tab is in edit or draft and no transition is running. clay.modeLifecycle.state reports phase as idle, entering, leaving or blocked, beside mode, destination, generation and reason. A committed change fires clay:modechange, and a refusal is the returned code plus state.reason.

An id that starts with clay: is reserved for the built-in sessions.

One 15 second deadline covers the whole transition: every import a participant prepares from, the preparation itself and the commit.

prepare must not await clay.ready. The loader awaits activateInitialMode() before it resolves ready, and activateInitialMode() awaits every prepare, so a prepare that awaits ready waits on itself.

An asynchronous callback checks context.isCurrent() and clay.modeLifecycle.canWrite() before it changes a record.

A participant registered before the loader runs, with a lease that has all five members:

JavaScript
window.clay.modeParticipants = [
  {
    id: "board",
    order: 10,
    prepare: async (context) => {
      const editor = await openEditor(context.signal);
      return {
        ready: null,
        enable: () => editor.adopt(),
        quiesce: async () => editor.flush(),
        resume: () => editor.resume(),
        dispose: () => editor.teardown(),
      };
    },
  },
];

Leaving edit#

The exit waits for the writes it is allowed to wait for, and refuses with a code when it cannot finish:

  • an unsent composer or an unresolved IME composition answers pending-input.
  • an unresolved conflict answers conflict.
  • a save whose outcome is unknown answers save-unknown.
  • a failed save answers save-failed.
  • a pending upload is flushed first, and one that is still running answers pending-input.

A refused exit resumes the very same leases and leaves the page in edit, with clay.modeLifecycle.state.reason naming why. Nothing blur-submits: the exit never commits text a person did not commit.

Entering edit writes nothing. The session adopts the file it shows as its baseline (save.adoptEntryBaseline), so a clean enter and exit leaves the file byte for byte the same. The recorded case is enter-leave-zero-writes in the clayjs corpus.

Attributes across a mode change#

The four controlled families, editmode:contenteditable, editmode:onclick, viewmode:disabled and viewmode:readonly, are handed back with their authored values after a cycle.

Frozen regions keep their saved contents.

editmode on <html> follows each commit, and it is stripped from every save.

A committed live switch drops ?editmode= from the URL.

opens-in is read once at boot and never rewritten.

A complete example#

One persisted note, a participant registered before the loader, an explicit initial edit, and a composer that holds a comment until it is sent or discarded. The participant’s script goes above the loader in the same document.

HTML
<!DOCTYPE html>
<html clay-mode-switch="live">
<head>
  <meta charset="utf-8">
  <title>Notes</title>
</head>
<body>
  <article data-id="note">
    <div id="content" editmode:contenteditable inert-contenteditable="plaintext-only">A note kept in the file.</div>
    <div class="composer" clay="editor-ui">
      <input id="comment" unsent="comment" placeholder="Add a comment">
      <button id="send" type="button">Send</button>
      <button id="discard" type="button">Discard</button>
      <span id="count" aria-live="polite"></span>
    </div>
  </article>
  <script src="https://clayjs.com/v1/clay.js?plugins=unsent&exclude=richclay"></script>
</body>
</html>

The record is a controlled element, so a save writes the inert spelling back and no reader receives an editable note. The lease hands that surface to the session and takes it back on the way out, and a write that lands in a later turn checks the session is still current and still admits writes:

JavaScript
window.clay = window.clay ?? {};
window.clay.modeParticipants = window.clay.modeParticipants ?? [];

window.clay.modeParticipants.push({
  id: "notes",
  order: 10,
  prepare: async (context) => {
    const content = document.getElementById("content");
    const count = document.getElementById("count");
    const editable = "plaintext-only";

    const paint = () => {
      setTimeout(() => {
        if (!context.isCurrent() || !clay.modeLifecycle.canWrite()) return;
        count.textContent = `${content.textContent.trim().length} characters`;
      }, 0);
    };

    return {
      ready: null,
      enable() {
        content.setAttribute("contenteditable", editable);
        content.removeAttribute("inert-contenteditable");
        content.addEventListener("input", paint);
      },
      quiesce: async () => {},
      resume() {
        content.setAttribute("contenteditable", editable);
        content.removeAttribute("inert-contenteditable");
      },
      dispose() {
        content.removeEventListener("input", paint);
        content.removeAttribute("contenteditable");
        content.setAttribute("inert-contenteditable", editable);
      },
    };
  },
});
JavaScript
const comment = document.getElementById("comment");

document.getElementById("send").addEventListener("click", async () => {
  const content = document.getElementById("content");
  const line = document.createElement("p");
  line.textContent = comment.value;
  content.append(line);
  clay.unsent.clear(comment);
  comment.value = "";
  await clay.save();
});

document.getElementById("discard").addEventListener("click", () => {
  clay.unsent.clear(comment);
  comment.value = "";
});

Leaving edit is refused while the composer holds unsent text, and it succeeds once Send or Discard clears that text.

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 settles. All three are removed on every save, along with the host’s savetoken and documentetag. The saved file’s <html> carries none of them.

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.

Showing and hiding edit controls: clay-options#

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

HTML
<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>. Showing and hiding uses no JavaScript and preserves each element’s own display when shown. Because that <style> is saved, a viewer’s browser hides edit controls at first paint after the first save, before any script runs.

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

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

Visibility attributes:

AttributeVisible 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"An ancestor has NAME with a value other than a; hidden when no ancestor has NAME.
  • Rules match any ancestor, not the nearest. Nested views need different attribute names (view on the app, cardview on a card).
  • Rules compare literal strings only. For expressions, use sap’s show= (sap.js: records, views and totals in markup).
  • show-when:savestatus="error|offline" shows a “not saved” warning. show-when:pageowner="true" shows owner links.
  • For viewer-only content, use hide-when:editmode="true", which is visible when editmode is absent. show-when:editmode="false" stays hidden until boot and then appears.
  • The attribute that switches views is ordinary saved content. Setting view="list" on a saved element saves and syncs it to every collaborator. Per-tab state covers tab-local views.

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. A view boot never loads the activation code, so a viewer sees what the file holds.

AttributeIn the saved fileIn edit mode
editmode:contenteditablecontenteditable renamed to inert-contenteditable (value kept)contenteditable restored
editmode:onclickonclick renamed to inert-onclickonclick restored
editmode:resource on <script>, <style>, <link>type prefixed inert/inert/ prefix stripped and element re-inserted so it runs
viewmode:disabled, viewmode:readonly on controlsdisabled / readonly addedremoved

There is no general editmode:<anything>. Only these four attributes exist.

HTML
<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 each example’s view mode from the very first open.

Write templates in the active form. The save transform does not reach <template> content, and clones are inserted while the owner edits. A clone with inert-contenteditable would stay uneditable until reload. On the next save, the transform converts the clone like any other element.

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

Use contenteditable="plaintext-only" for titles, labels, and cells to prevent formatting and pasted markup. Make Enter blur instead of inserting a line break, as in the skeleton’s keydown handler. Use editable (richclay: rich text (editable)) only where you need rich text.

What works in view mode#

window.clay keeps ready, setMode, mode, canEdit, modeLifecycle, isEditMode, isOwner, toggleEditMode, Mutation, region, readData, writeData, and hasUnsavedChanges (always false) in both modes. It also includes whatever view-mode plugins and satellites add (clay.conflicts and clay.morph with sync, clay.cms, clay.quickcrop, clay.toast, and others). There is no save, getHTML, addDocumentTransform, onSnapshot, undo, RichClay, or upload, and no markDirty unless sync is loaded (where it is harmless).

  • Guard edit-only APIs with optional chaining (clay.addDocumentTransform?.(fn)), or return early with if (!clay.isEditMode) return. An unguarded call throws and stops the rest of the script for viewers.
  • Click handlers run for viewers too. Hidden buttons cannot be clicked, but guard edit actions anyway with if (!clay.isEditMode) return, as the examples do.
  • A viewer can still use read-only interactions: open a card, filter, search, export, or scroll the outline. Those use chrome and tab-local state, so they work the same in both modes.
  • With sync loaded, a viewer rides the saved lane and receives each accepted save, so a published page updates live (What sync is).
  • Clicking inside [trigger-save] in view mode dispatches clay:view-save-attempt, so you can explain why nothing saved.

When the page editor is ready#

The mode this tab starts in is decided at load, but a page document is ready to edit later. clay.page.ready resolves when its editor is mounted and accepts typing. clay.page.status holds that boot result: { phase, reason, installId, at }.

clay.ready does not wait for the page editor. Because plugins/page.js exports no ready, await clay.ready can resolve while the saved article is still on screen. Late plugins do wait, so a page that ships with them needs nothing extra.

Each boot stage records a performance.mark with { detail: { installId } }:

  • document:boot: an installation starts, before the source is validated.
  • document:assets-ready: the editor’s code is loaded.
  • document:authority-ready: the capability and first-save gates passed, the page handle is admitted, and the binding is created. Writer election and the article’s feature views are still ahead.
  • document:input-ready: adoption succeeded and editing was enabled on the mounted view. A failed or cancelled installation never emits it.

When boot is blocked, clay.page.ready still resolves rather than throwing an error. clay.page.status.phase is blocked, and reason names where boot stopped (missing-capability, first-save-refused, first-save-conflict, admission-failed, editor-assets-failed, mount-failed, adoption-refused, adoption-timeout, adoption-error, page-replaced, view-detached, no-page, ambiguous-page, invalid-page-source, invalid-page-json, unsupported-page-format, missing-page-store, boot-incomplete). Nothing accepts keystrokes: the saved article stays as the host served it, or an already-mounted editor is left read-only because the page was replaced. Base your fallback on phase, not on a caught exception:

JavaScript
const boot = await clay.page?.ready;
if (boot?.phase !== "ready") document.documentElement.setAttribute("read-only", "true");

clay.page.install() disposes the last installation and starts a fresh one with its own id, letting a page retry after a failure without reloading.

The page editor on a phone#

At widths up to 560 px, the page editor’s toolbar keeps four controls: Bold, Italic, Link, and More. Each is at least 44 by 44 CSS px inside a 204 px bar with 4 px gaps and 8 px padding. The selection bubble is hidden so two bars never appear at once.

More holds the rest: Strikethrough, Code, bulleted, numbered, and checklist lists, Turn into, Insert, Move up, Move down, Indent, and Outdent. Turn into and Insert open the same pickers used on desktop. Move and indent actions apply to the caret’s block when nothing more is selected.

The bar is positioned from the visual viewport, not the layout viewport. Its top and horizontal bounds come from the visible area, so an on-screen keyboard cannot cover it and panning the visual viewport moves it with the page. The safe-area inset is read once per layout frame. Menus stay inside the visual viewport, open above the bar when there is room, and scroll internally. Desktop layouts are unchanged.

Keyboard navigation in the page editor#

Escape closes an open menu or the link popover first, returning focus to the control that opened it or to the editor.

From a block selection, Escape moves focus from the writing surface to the first toolbar control while keeping the selection, so the keyboard is never trapped in contenteditable. In the toolbar, Left and Right move between controls, and Escape returns to the editor. The toolbar is a single tab stop with a roving tabindex: Tab leaves it forward and Shift-Tab leaves it backward. Inside a table, Tab moves across cells and Shift-Tab moves back. The editor takes Tab for nothing else.

A table’s column resize handle is a focusable vertical separator named for its column, with aria-valuemin 48, aria-valuemax 1200, and the current width in aria-valuenow. Left and Right preview a 10 px change (50 px with Shift), while Home and End preview the minimum and maximum. Enter commits the change, and Escape cancels it. Holding an arrow key previews across repeated keydowns. The keyup that ends the repeat, Enter, or leaving the handle commits one command, so a held arrow key records a single undo item. Double-click fits the column, and pointer dragging is unchanged. On a phone, the column’s Width menu (Wider, Narrower, Fit to content, and Reset width once the column has a width) does the same job.

Part of the ClayJS complete guideDownload the whole guide as Markdown