# 3. Edit mode and view mode

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

Source: https://clayjs.com/docs/guide/edit-mode/

## 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](https://clayjs.com/docs/guide/free-documents/)). 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](https://clayjs.com/docs/guide/free-documents/#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:

```js
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:

```js
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 example=mode-cycle-page
<!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:

```js example=mode-cycle-participant
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);
      },
    };
  },
});
```

```js example=mode-cycle-submit
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:

| Attribute | Visible 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](https://clayjs.com/docs/guide/components/#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](https://clayjs.com/docs/guide/architecture/#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.

| Attribute | In the saved file | In edit mode |
|---|---|---|
| `editmode:contenteditable` | `contenteditable` renamed to `inert-contenteditable` (value kept) | `contenteditable` restored |
| `editmode:onclick` | `onclick` renamed to `inert-onclick` | `onclick` 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 controls | `disabled` / `readonly` added | removed |

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`)](https://clayjs.com/docs/guide/components/#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](https://clayjs.com/docs/guide/live-sync/#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:

```js
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.
