# Practices for whole apps

> What the starter templates do so that two people can edit at once, deletes come back, saves tell the truth and documents never save by accident.

Source: https://clayjs.com/docs/practices/

These practices come from the Journal, Kanban and Document starter templates. Each one fixes a problem every malleable document runs into once more than one person, tab or device edits it. Each entry says what to do, why, and the smallest code that shows it. The templates are the reference implementations.

## Declare how shared attributes merge

When two tabs change the same attribute at once, live sync keeps one value and records a conflict. Some attributes are not single values. A card's labels are a set of words, and a "next number" counter only ever goes up. Declare those on the container, and both edits land:

```html
<main id="board" merge-tokens="data-labels data-members">
  …cards with data-labels="bug urgent"…
</main>
<div id="board-meta" data-next-card="15" merge-max="data-next-card"></div>
```

- `merge-tokens` merges space-separated words: one tab adds `urgent`, the other adds `design`, and the card keeps both.
- `merge-max` keeps the larger number, so a counter never goes backwards.

The declaration covers every element inside the one that carries it. See [live sync](https://clayjs.com/docs/guide/live-sync/) for how a three-way merge decides everything else.

## Give every reorderable item a stable `data-id`

Two people moving different cards in one list both keep their move only when the merge can tell the cards apart. It matches items by `data-id`, so give each one an id that never changes, generated once when the item is created:

```js
const rid = (prefix) => {
  const [a, b] = crypto.getRandomValues(new Uint32Array(2));
  return `${prefix}-${a.toString(36)}${b.toString(36)}`;
};
card.dataset.id = rid("c");
```

Never reuse an index or a display number as identity: two tabs adding a card at once will pick the same one.

## Repair duplicate display numbers after a sync

Two tabs adding a record at once both take the next number, so one label can end up on two records. Keep the number a label and the `data-id` the identity: after `clay:baseline-settled` and after every `clay:sync-applied`, renumber the duplicates and leave the ids alone.

```js
function repairNumbers() {
  const undo = window.clay?.undo;
  undo?.pause?.();
  try {
    const byId = (a, b) => (a.dataset.id < b.dataset.id ? -1 : 1);
    const groups = new Map();
    for (const card of Data.cards()) {
      const n = card.dataset.num;
      if (!n) continue;
      if (!groups.has(n)) groups.set(n, []);
      groups.get(n).push(card);
    }
    const extra = [...groups.values()].filter(g => g.length > 1).flatMap(g => g.sort(byId).slice(1)).sort(byId);
    for (const card of extra) card.dataset.num = String(Data.nextNumber());
    if (extra.length) { renderAll(); window.clay?.save?.soon?.(); }
  } finally {
    undo?.resume?.();
  }
}

document.addEventListener("clay:baseline-settled", repairNumbers);
document.addEventListener("clay:sync-applied", repairNumbers);
```

- The first card by `data-id` in each group keeps its number, and each later one takes `Data.nextNumber()`, which starts above the highest number on the board and above the counter's own `data-next-card`, so no number is handed out twice.
- Repair is maintenance, not an action: it runs inside `undo.pause()` and resumes in `finally`, and `clay.undo.ignoreAttribute("data-num")` keeps the number outside history, so a repair never makes an undo or a redo of the card refuse.
- Renumber after the session settles its baseline as well as after each applied frame: a repair made before the baseline is adopted belongs to that baseline and saves nothing.

See [undo: what a step never erases](https://clayjs.com/docs/plugins/undo/#what-a-step-never-erases) for how one of the page's own attributes is kept outside history.

Reference: `starter-templates/templates/01-kanban/kanban.html` `repairNumbers` and `Data.nextNumber`; `starter-templates/templates/01-kanban/tests/numbers.spec.mjs`, `starter-templates/templates/01-kanban/tests/two-tabs.spec.mjs`.

## Capture with a composer, never a dialog

`prompt()` blocks the page, cannot be styled, cannot keep a half-typed draft and throws away the keystroke after it. Put a small form next to the collection it adds to instead, and give it one keyboard contract: Enter adds one item and keeps the composer open and focused for the next one, Shift+Enter is a newline inside the same title, and Escape closes the composer without adding anything.

```html
<form class="capture-form" clay="editor-ui" unsent-key="capture:LIST_ID">
  <label>Card title<textarea name="title" unsent="card" rows="2"></textarea></label>
  <div class="capture-actions">
    <button type="submit">Add card</button>
    <button type="button" data-act="preview-capture" hidden>Preview cards</button>
    <button type="button" data-act="close-capture">Close</button>
  </div>
  <p class="capture-message" role="status"></p>
</form>
```

```js
form.addEventListener("keydown", (e) => {
  if (e.isComposing || e.keyCode === 229 || capture.composing) return;
  if (e.key === "Enter" && !e.shiftKey) {
    e.preventDefault();
    submitCapture();
    return;
  }
  if (e.key !== "Escape") return;
  e.preventDefault();
  closeCapture();          // the form goes, its text stays in the unsent draft
});
```

- Mount the composer as `clay="editor-ui"` beside the collection, never inside it: chrome is not saved, does not sync and cannot be dragged as an item.
- The submit button and Enter take the same path, behind one synchronous `submitting` flag, so a double click or a click that follows a blur adds once.
- Add through one command (`commitAction("Add card", …)` and `commitAction("Add list", …)` in the Kanban) after the element is built off-document: one undo step, one save, one mutation.
- An empty submission keeps focus, adds nothing and does not advance the numbering counter.
- Touch reaches the same path: the Add, Preview and Close buttons call the same functions, so no keyboard event is required.

## Name a composer's draft by its destination

A composer that will create a record has no record of its own to belong to: `unsent="…"` alone keys the draft on whatever ancestor happens to be nearest, which for a card composer is the column and for a new list is nothing at all. Name the destination with `unsent-key` on the form, assigned when the form mounts, and keep card and list composers in their own namespace: `capture:<listId>` for a column, `capture:board` for a new list. `LIST_ID` in the markup above is a placeholder for `setAttribute("unsent-key", …)`, never a shared literal key.

```js
form.setAttribute("unsent-key", "capture:" + (kind === "list" ? "board" : listId));
```

- The form leaves on Escape, and outside edit mode it is hidden rather than emptied; its text stays in the draft across a reload, and the next open puts it back.
- Only the exact revision that was sent is cleared: compare the area's value with the string that was submitted, and leave it alone when a keystroke or a restore changed it in between.
- Every key in the capture namespace is a destination, not a record id: read `clay.unsent.list()` and recognize the `capture:` prefix instead of treating each record as a card id. A destination that no longer exists keeps a recoverable draft with Copy text and Discard; it must not fall back to the first remaining list.
- A blur submits one item only after a microtask, from `relatedTarget` when the browser has it, and only when the next focus is outside the form, the tab is still in an edit mode and no composition, preview or submit is running. Blur into the form's own buttons lets the button decide; blur during an IME composition keeps the draft and adds nothing, and `compositionend` does not submit either.

## Make a paste literal and a batch an explicit command

Pasting into a composer changes only the textarea. More than one nonblank line offers Preview cards; the preview lists the exact titles and an Add N cards button, and only that button adds. No timer, paste event or blur creates a batch.

```js
function batchTitles(raw) {
  return raw.replace(/\r\n?/g, "\n").split("\n")
    .map(title => title.trim()).filter(Boolean);
}
```

```js
const batch = titles.map(title => makeCard(title, rid("c"), 0));
const fragment = document.createDocumentFragment();
for (const card of batch) fragment.append(card);
const commit = commitAction("Add N cards", () => {
  for (const card of batch) card.dataset.num = String(Data.nextNumber());
  target.parent.insertBefore(fragment, target.before);
  return batch;
});
```

- Blank lines are dropped and duplicates kept: a paste is text, not markdown, CSV or a bullet list, and nothing is interpreted.
- Validate every title before allocating a number or inserting anything (300 characters a card, 100 cards a batch), and show the limit instead of truncating the text: a `maxlength` on the paste area would silently cut a batch.
- Confirmation recomputes the titles, the tab's mode and the anchor immediately before the write. If the text changed, preview it again and ask again; if the list or the card it was going to sit before is gone, refuse and keep the draft. Never silently append.
- The whole batch is one synchronous command and one fragment insertion, so it is one undo step, one save and one mutation record per inserted element.

## Say when a filter hides what was just added

An addition that disappears behind an active filter looks like lost work. After a successful capture, recompute the filter you already have over the new record instead of trusting the screen: nothing hidden needs no message, some hidden says how many, and all hidden says so with a Show button that opens the card through the route.

```js
const hidden = added.filter(card => !matches(card, filter));
const message = hidden.length === added.length
  ? `Added ${added.length} card${added.length === 1 ? "" : "s"}, but your filters hide ${added.length === 1 ? "it" : "them"}.`
  : `Added ${added.length} card${added.length === 1 ? "" : "s"}. ${hidden.length} ${hidden.length === 1 ? "is" : "are"} hidden by your filters.`;
```

- Show routes to the new card and selects its list on a phone; it never silently clears the filter the person set.
- Say the count, not "card added": when three of five added cards are visible, the other two are the ones the message is about.
- Report a successful write even when the result is hidden, and never remove records to filter.

## Keep long text as text in the record, and the editor outside it

A `<textarea>` value merges as one value, so two people editing different paragraphs of one entry collide. Text nodes merge word by word. Store the text as text inside the record, put the editor in a `clay="freeze"` region outside the records, and bind the two with `clay.bindText` (the `bind-text` plugin):

```html
<article class="entry" data-id="e1">
  <pre><code class="entry-body">The first line becomes the heading.</code></pre>
</article>

<div clay="freeze" id="writer"><textarea></textarea></div>
```

```js
const binding = clay.bindText(textarea, {
  onConflict: (typed) => showNote("Someone changed the same words while you were typing.", typed),
  onGone: (typed) => showNote("This entry was deleted somewhere else.", typed),
});
binding.open(entry.querySelector(".entry-body"), { key: entry.dataset.id });
```

Every keystroke is written into the record, so a save and a peer's merge both see it. A peer's change to the record shows in the textarea with the caret kept. During an IME composition nothing is written until it ends. See [plugins/bind-text](https://clayjs.com/docs/plugins/bind-text/).

## Delete to a Trash, with Undo

A deleted record should be able to come back, and a peer's delete should not erase what someone else just wrote. `clay.trash` marks a record with `data-deleted-at` instead of removing it, so it keeps its place, id and content:

```js
const { undo } = clay.trash(entry);
showToast("Moved to Trash", { action: "Undo", run: undo });

clay.trash.is(entry);
clay.trash.restore(entry);
clay.trash.purge(entry);
```

Each call is one undo step when the undo plugin is loaded, and fires `clay:trashed` or `clay:restored` on the record. Hide trashed records with CSS (`[data-deleted-at]:not([data-deleted-at=""])`) so the file reads correctly without JavaScript, and include `data-deleted-at` in your rules tag so the JSON API reports it.

A record left blank is removed automatically only by the tab that created it: a peer may have just created that blank record and be about to type in it.

## Build the save chip from root attributes

"Saved" while new writing has not reached the host, or for a visitor's local draft, misleads. ClayJS sets two attributes on `<html>`: `savestatus` (what the last save did: `saving`, `saved`, `error`, `offline`, `conflict`) and `unsaved="true"` (work since that save). Draw the chip with CSS over them, and no script has to track events:

```css
.chip::after { content: "Saved"; }
html[unsaved] .chip::after { content: "Unsaved"; }
html[savestatus="error"] .chip::after { content: "Not saved"; }
html[savestatus="offline"] .chip::after { content: "Offline"; }
html[draftmode] .chip::after { content: "Saved in this browser"; }
```

`error` and `offline` outrank `unsaved`. On either, offer a way to keep the work, such as downloading `clay.getHTML()` as a file.

## Keep UI memory scoped to the document

Every document on one subdomain shares `localStorage`, so a raw key such as `openEntry` leaks between them. `clay.storage.session` and `clay.storage.local` scope each key to the document:

```js
clay.storage.session.set("ui", { openId: entry.dataset.id });
const { openId } = clay.storage.session.get("ui") || {};
```

Use `session` for what one tab is doing (the open entry, a scroll position) and `local` for preferences that should follow the person between visits.

## Keep search, range, calendar and paging transient, inside freeze wrappers that are always in the file

Search text, a date range, the shown calendar month and the page of results describe what one tab is looking at, not what the document says. Keep each one in a transient field, or in `clay.storage.session` when it has to survive a reload in the same tab, and render its controls inside a `clay="freeze"` wrapper that is in the file from the start. A wrapper that a script adds or removes is a change to the document, so filtering or paging would mark the page dirty and send a sync frame.

```html
<div class="app" sap state="shown:transient from:transient to:transient calendarMonth:transient">
  <div class="search" clay="no-dirty">
    <input id="q" type="search" bind="q" transient>
  </div>
  <div class="cal-slot" clay="freeze">…the calendar…</div>
  <div class="filters" id="filters" clay="freeze">…From and To…</div>
  <div class="list-more" clay="freeze">…the more button…</div>
</div>
```

- The state names decide what a field is: `from:transient`, `to:transient`, `calendarMonth:transient` and `shown:transient` never save or sync, and `bind="q" transient` does the same for the search box.
- `clay="no-dirty"` on the search wrapper means typing there marks nothing unsaved, so a tab that only searches shows no leave warning.
- Each control sits in a freeze wrapper (`#filters`, `.cal-slot`, `.list-more`) with `show` and `hidden` on the element inside it, so the wrapper is never added or removed while the person filters.
- The saved bytes stay where they are: the Journal's tests read `clay.getHTML()` before and after a search, a range change and a month step, and expect the same string.

See [Per-tab state](https://clayjs.com/docs/guide/architecture/#per-tab-state).

Reference: `starter-templates/templates/00-journal/journal.html`; `starter-templates/tests/dom/journal-date-filters.test.js`, `starter-templates/tests/dom/journal-calendar.test.js`.

## Open in view, and offer a way into edit

Opening a document should never save it by accident. Documents open in view mode, even for someone who can edit. Read `clay.canEdit` to decide whether to offer editing, and `clay.mode` (`"view"` or `"edit"`) for what this tab is doing now:

```html
<button clay-mode-toggle hidden>Edit</button>
<span data-mode-status aria-live="polite"></span>
<script>
  const button = document.querySelector("[clay-mode-toggle]");
  const status = document.querySelector("[data-mode-status]");
  let busy = false;

  function paint() {
    button.hidden = !clay.canEdit;
    button.textContent = clay.mode === "edit" ? "Done" : "Edit";
  }
  paint();
  document.addEventListener("clay:modechange", paint);
  document.addEventListener("clay:capabilitychange", paint);

  button.onclick = async () => {
    if (busy) return;
    busy = true;
    button.disabled = true;
    status.textContent = "";
    const result = await clay.setMode(clay.mode === "edit" ? "view" : "edit");
    if (result.reloading) return;
    busy = false;
    button.disabled = false;
    if (result.ok === false) status.textContent = `Stayed in ${clay.mode}: ${result.code}`;
  };
</script>
```

- Mark your own toggle `clay-mode-toggle` so ClayJS does not also show its first-visit hint about the keyboard shortcut.
- Or load the `menu` plugin (`plugins=…,menu`), which adds an Edit switch and a small menu in the top right for anyone who can edit. See [plugins/menu](https://clayjs.com/docs/plugins/menu/).
- A tool page that must be editable the moment it opens declares `<html opens-in="edit">`.

See [edit mode](https://clayjs.com/docs/guide/edit-mode/) for how the mode is decided.

## Read the mode when you use it, and start and stop editors with the session

`clay.mode`, `clay.isEditMode` and `clay.canEdit` are read at the moment you ask, and a live switch changes them without a reload. A copy taken at boot keeps answering with the old one, so never store it in a variable. On a `clay-mode-switch="live"` document, an editor that is not a ClayJS plugin registers a participant, and its lease starts the editor in `enable` and stops it in `dispose`:

```js
window.clay.modeParticipants = window.clay.modeParticipants ?? [];
window.clay.modeParticipants.push({
  id: "board",
  prepare: async (context) => {
    const editor = await openEditor(context.signal);
    return {
      enable: () => editor.adopt(),
      dispose: () => editor.teardown(),
    };
  },
});
```

Register before the loader runs so the first entry drains the array.

See [switching without a reload](https://clayjs.com/docs/guide/edit-mode/#switching-without-a-reload-clay-mode-switch-live).

## Guard a write with `clay.modeLifecycle.canWrite()`, not `clay.canEdit`

`canEdit` is permission, not the current session: it stays true in view and while a transition runs. A write that lands in a later turn asks whether the session still exists and still admits writes:

```js
setTimeout(() => {
  if (!context.isCurrent() || !clay.modeLifecycle.canWrite()) return;
  record.textContent = text;
}, 0);
```

## Let leaving edit refuse, and show why

The exit waits for the writes it can wait for and refuses with a code (`pending-input`, `conflict`, `save-unknown`, `save-failed`) rather than losing text. Nothing blur-submits. A page's own composer keeps its draft through `unsent`, and its Send or Discard clears it with `clay.unsent.clear(input)`.

```js
const result = await clay.setMode("view");
if (result.ok === false) status.textContent = `Stayed in edit: ${result.code}`;
```

See [leaving edit](https://clayjs.com/docs/guide/edit-mode/#leaving-edit).

## Enter edit without writing

Entering edit adopts the file the tab shows as its baseline, so a clean enter and exit sends no save, and the recorded case is `enter-leave-zero-writes` in the clayjs corpus. Saving in `enable` or on `clay:modechange` writes a file nobody changed.

## Declare `clay-mode-switch="live"` only when every edit resource can be taken back

Without it, `clay.setMode` saves, writes a single-use receipt and reloads, which is always safe. With it, the switch is live, so every listener, editor and timer the page starts in edit must be stopped by a lease.

See [switching without a reload](https://clayjs.com/docs/guide/edit-mode/#switching-without-a-reload-clay-mode-switch-live).

## Trust the receipt, not the page you are looking at

A reader's article is a projection of one accepted file, not a copy of it: a block the file did not change keeps its node, so the DOM can hold an open toggle, a mounted tool and its state, none of which the file says. Reading that DOM back and calling it the saved document builds a file nobody saved. The facts worth keeping are the ones that came with the file: the exact bytes, their ETag and the sequence they cover.

A reader reports each file it applied, and nothing else, on `clay:reader-status`: `{ state: "applied", etag, seq }`, or `{ state: "blocked" }` when it could not read or apply one. Track those, not the DOM:

```js
document.addEventListener("clay:reader-status", (event) => {
  if (event.detail.state !== "applied") return;
  showVersion(event.detail);   // { state, etag, seq }
});
```

On a page that names `sync` as well, the reader takes the lane's own disk applies; `clay:sync-applied` carries those, and its `source: "peer"` frames are previews of typing nobody saved ([Reconciling in app code](https://clayjs.com/docs/guide/live-sync/#reconciling-in-app-code)).

## Give a reader a transport that cannot write

A reader has no save half, and the safest way to keep that true is to give it a transport with no write path at all: the startup GET once the stream opens, the saved lane's frames, and a refetch to repair what it missed. No POST, no live relay, no write credentials. The reader's own stream (`src/sync/saved-stream.js`) is exactly that, and `plugins=page` in view mode loads it without generic sync, live or upload.

Two rules keep a reader honest. What the stream has received is not what the page holds: a cursor says what will be replayed, never what is on screen, so a duplicate frame, an older one, or a stamp with no bytes behind it moves nothing. And a refused or failed read keeps the old article and the old receipt and reports the failure, rather than showing bytes it could not apply or acknowledging a file it never read.

## Keep UI configuration synchronous, import the controls on first use

Configuration should answer now. `clay.theme()` reads and writes the theme without waiting for a stylesheet, an element or a module, and it throws on invalid input rather than failing later in a dialog. Keep it that way by putting the configuration in one small module with no imports (`src/ui/theme-config.js`) and letting the code that paints a surface bring its own.

```js
window.clayTheme = { tokens: { accent: "#0c2635", accentText: "#ffffff" } };
clay.theme({ tokens: { radius: "8px" } });   // answers at once, before any UI exists
```

A reader that never opens a dialog or shows a notice should not download the UI that would paint one. Import the theme UI and its controls from the surface that needs them, so a Document's reader carries the configuration and nothing else.

## Ship the theme and its fonts with the release

A saved document links its stylesheet by URL, so that URL has to answer with everything the page needs in one request: the theme rules and the font faces, compiled, with the font files served from the same release. An `@import` chain, a second stylesheet, or Google Fonts costs a round trip and breaks the appearance the moment the page is opened offline.

```html
<link rel="stylesheet" href="https://clayjs.com/v1/src/page/themes/light-1.css">
```

That address is one compiled file. Inter, Newsreader, Work Sans and IBM Plex come from the release's `fonts/` folder, and the release manifest lists the stylesheet and every font with a hash and a size, so a local host reproduces the same look from the same inventory.

## Tune a late plugin when it arrives

`source`, `sortable` and `menu` load after `clay:ready`, because nothing on first paint waits for them. A page that wants to configure one cannot do it in the boot callback; it waits for `clay:plugin-ready`, which fires once per late plugin with `detail.plugin` naming it:

```js
document.addEventListener("clay:plugin-ready", (event) => {
  if (event.detail.plugin !== "sortable") return;
  for (const list of document.querySelectorAll("ol.cards")) {
    const sortable = window.Sortable?.get?.(list);
    sortable?.option("delay", 220);
    sortable?.option("delayOnTouchOnly", true);
  }
});
```

The Kanban tunes its drag that way: a phone needs a 220 ms hold before a card starts moving, and the plugin that owns the drag is not there when the board boots. `clay.plugins` says the plugin is in the plan; the event says it is ready.

## Derive the title from the model, and let a custom title opt out

A title that is typed separately from the content goes stale: rename the H1, or let a server checkpoint write the file, and the tab, the search result and the saved `<title>` describe different documents. Derive it instead, from one pure function over the captured model, and use it in both the client's save projection and the server's checkpoint renderer:

```js
const title = documentTitle(model);   // the first top-level H1, or "Untitled"
```

Keep an explicit opt-out for the person who wants a title the content does not carry: `<meta name="clay-seo-title-mode" content="custom">` in `<head>` says the authored `<title>` is the owner, and neither the projection nor a checkpoint rewrites it. Never derive a title from an H2 or from the first heading you find: a page with no top-level H1 is `Untitled`, and its author can set a custom title.

A title is content, not identity. Do not rename files, routes or folder paths from it.

## Make an input shortcut one Backspace away from the text you typed

A Markdown shortcut that cannot be taken back is a trap: `**bold**` typed by accident leaves a bold paragraph and no characters to undo. Run input-rule inversions through the editor's own history (`undoInputRule`) so Backspace right after the shortcut restores exactly what was typed, in the same undo stack as every other edit, with peer edits intact.

Two rules make it hold under collaboration. Retype a block in place rather than deleting and inserting one, so its id and position survive and a peer's edit to that block is not lost. And apply the mark over the body where it already is, so the marks the text carried inside the delimiters stay.

## Fit the phone toolbar to the visual viewport, and give the keyboard a way out

A mobile toolbar positioned from `window.innerHeight` hides behind the on-screen keyboard; a fixed bar with a keyboard-height guess hides at the wrong moment. Place it from the visual viewport instead: `visualViewport.offsetTop`, `height` and `width` give the area that is actually visible, whether the keyboard is up or the viewport has panned. Read the safe-area inset once per layout frame and constrain menus to the same box.

Then make it reachable without a pointer. The bar is one tab stop with a roving tabindex, Left and Right walk it, and Escape returns to the editor; from the editor, Escape leaves the writing surface for the bar while keeping the selection, so contenteditable never becomes a keyboard trap. The same rule applies to the small pointer targets around content: a column resize handle is a focusable separator with a name and a value, arrows preview, Enter commits and Escape cancels, so nothing is pointer-only.

## Position a move by its stable neighbour, and refuse a stale intent

A move names the card it goes before, or the end of a list, and resolves that name into a node at the moment of the move, because between opening the position dialog and pressing Apply a peer can delete the anchor, move it to another list, or replace the card: an index captured when the form opened would land somewhere nobody chose. The Kanban keeps both ids and the moving node, re-resolves them at Apply, and treats a missing neighbour, a missing destination or a replaced node as a changed target: it refreshes the choices, keeps the form open and asks for another Apply.

```js
function resolveMove({ cardId, listId, beforeId }) {
  const card = Data.card(cardId);
  const list = Data.list(listId);
  const parent = list?.querySelector(':scope > ol.cards');
  const before = beforeId === null ? null : Data.card(beforeId);
  if (!card || !parent) return null;
  if (beforeId !== null && before?.parentElement !== parent) return null;
  return { card, parent, before };
}

const target = resolveMove({ cardId: card.dataset.id, listId, beforeId });
if (!target || target.card !== card) return 'stale';
```

- The position is a neighbour's `data-id` or an explicit end, never a number: `before:${id}` or `end`. A `null` anchor is the one intention that means "the end", and it is chosen, not assumed.
- `beforeId` naming a card that now sits in another list is refused, not read as an index into the destination.
- The refusal has to be actionable: the dialog comes back with freshly resolved choices and says the position changed, so nobody's move silently lands elsewhere.

## Give every pointer gesture a keyboard and a touch equivalent, behind the same guard

A move that is only a drag is out of reach of a keyboard, and on a phone it is a press-and-hold nobody was told about, so the same move is also a menu item, a button and a keyboard shortcut. The shortcut takes the same path as the button: one action table for every click, and every mutation through the one gate that asks, at that moment, whether a write is admitted, so a key can never write where a click cannot.

```js
if (key === 'm') { event.preventDefault(); dispatchAction('move-card', { el: faceOf(card), card }); }

const editableNow = () => !readOnly && (window.clay?.mode === 'edit' || window.clay?.mode === 'draft');
function commitAction(label, mutate) {
  if (!editableNow()) return null;
  …
}
```

- The keyboard handler returns before any shortcut on an event something already handled, an IME composition, a modifier key, or a keystroke inside `input`, `textarea`, `select` or contenteditable.
- `dispatchAction` refuses an edit action when the board is not editable, and `commitAction` checks again as it runs, so a mode change during an `await` still refuses.
- The move itself is `clay.sortable.move`, whose own `not-editable` refusal is the same admission the drop obeys: the keyboard, the button and the drag cannot disagree about whether this page may be written.

## Project the record into a native modal, and own its draft by a stable key

Opening a card used to decorate a saved record with detail markup; now one `<dialog>` is built at load from a runtime blueprint, marked `clay="editor-ui"` so it is never saved, never synced and is no part of any record, and it projects whichever card it currently shows: every field reads from that record and writes back to it, and the dialog's `unsent-key` is the record's id, so a draft is found by an explicit owner and reopening the card shows the unsent text.

```js
const part = sel => {
  const el = chrome.querySelector(sel).cloneNode(true);
  el.setAttribute('clay', 'editor-ui');
  return el;
};
const detailEl = part('#card-detail');

function bindDetailTo(card) {
  detailEl.setAttribute('unsent-key', card.dataset.id);
  detailEl.dataset.card = card.dataset.id;
}
```

- The dialog lives outside `#board` and outside every card, so opening or closing a card changes no saved node, no order and no attribute.
- `unsent-key` names the card, not the nearest saved ancestor: the fields no longer sit inside the record, so the key has to be the card id, which is what a reopen and a later reload both resolve.
- A change or submit handler drops its event when `detailEl.dataset.card` no longer matches the card it looked up, so a lookup that answers after another card opened writes the original card.

## Preflight the whole undo step, in both directions, before either stack moves

An undo that half-applies leaves a page nobody can describe and a history that no longer matches it. Check every write of the step against the document as it stands, with no write of its own, and replay only when all of them still hold. The same check runs for redo, so a step that cannot go back cannot go forward either, and a refusal leaves both stacks exactly where they were:

```js
const receipt = clay.undo.undo();
if (!receipt.ok) showRefusedStep(receipt);   // nothing in the document moved
```

- A step is a list, not one write. A two-field edit whose second field a peer changed is refused whole, never applied for its first field.
- The check compares saved content and child order, not the DOM's memory. A repaint, a `draggable`, or a tab-local attribute is ignored, so a mere redraw never refuses a step that is still safe.
- The refusal comes back as a receipt (`{ ok: false, code, entryId, label, direction }`) and as `clay:undo-refused`, and the step stays on top: the page presents it, it never silently skips to an older step.
- `clay.undo.discard(entryId)` is the only way past a refused step, and it drops that one entry and touches nothing else.

Reference: `hyper-undo/test-node/guarded-replay.test.js` (probe 3, redo of a refused step over a peer's title), `clayjs/tests/unit/undo-guards.test.js`.

## Guard a destructive undo with the complete identity of what it restores

Deleting a card is one step that a single undo has to reverse exactly. Undoing the delete puts the same node back, in the same list, at the same place, so the check before it has to name all of that, and it has to refuse rather than guess when any of it changed:

```js
const receipt = clay.undo.command("Delete card", () => card.remove());
// later, Undo "Delete card" is refused, not replayed, if a peer:
//   - edited the card the step removed (content-changed)
//   - inserted, removed or moved a neighbour in the list (structure-changed)
//   - created a node that already carries the removed card's data-id (duplicate-id)
```

- The step's certificate holds the removed subtree's saved content and the child sequence of the list, so a peer's edit to a card nobody can see any more still refuses the restore.
- Identity is `data-id`, never a position or a display number, so a neighbour's move is a structural change and not a silent shift.
- Content that keeps its own history (a `clay="no-undo"` region, a `richclay` editor) inside the removed subtree refuses the step as `protected`: restoring the parent would erase writing undo never recorded.

Reference: `starter-templates/tests/dom/kanban-undo-conflicts.test.js` (the four refusal tests), `hyper-undo/test-node/guarded-replay.test.js` (probe 4).

## Commit one explicit synchronous command after async work, and re-check the target inside it

A dialog action usually starts with an `await`: a lookup, an upload, a save. The DOM writes belong after it, in one synchronous command, and that command has to ask again whether the node it is about to write to still exists, because the thing a peer deleted during the wait has no business getting a write or an entry:

```js
const receipt = clay.undo.command("Save description", () => {
  const live = Data.card(id);
  if (!live || !live.isConnected) return false;
  live.querySelector(".card-desc").textContent = text;
  return true;
});
if (!receipt) toast("That card is gone.");
```

- Never `await` inside the command: a write after the first `await` would land in a different step, or in none. A command declared `async`, or one that returns a promise, throws instead of recording.
- Resolve the target by its `data-id` inside the callback, never by a node captured before the `await`. A card whose id no longer answers, or whose node was replaced, gets no write and no history entry.
- A card that disappeared this way is not an undo problem: it is the page's own material, which [undo recovery](https://clayjs.com/docs/plugins/undo/#recovery) keeps for review and a reload.

Reference: `starter-templates/tests/dom/kanban-undo-conflicts.test.js` ("a destination removed during an await records nothing at all"), `starter-templates/tests/dom/kanban-async.test.js`.

## Leave a plain field's own undo to the field

Cmd+Z in an `input`, a `textarea`, a `select` or a contenteditable subtree belongs to the browser and to that control's own history, not to the page. ClayJS checks the key event's composed path, an IME composition, and whether another handler already called `preventDefault`, and returns without claiming the key. The page keeps its own Undo and Redo controls for its own steps:

```js
input.addEventListener("keydown", (event) => {
  if (event.key !== "Enter" || event.isComposing) return;
  clay.undo.command("Rename card", () => { title.textContent = input.value; });
});
```

- A field the page has not written yet is not content: typing changes the live property and records nothing, so the first Cmd+Z undoes the keystroke, as it should.
- Add `persist` to a control whose value is part of the document, or write it inside one command on Save or Enter. Then the commit is one labelled step that page undo owns.
- Do not fight the browser by binding Cmd+Z yourself inside the field: two histories on one keypress means one of them is always wrong, and the person cannot tell which.

Reference: `starter-templates/tests/dom/kanban-undo-conflicts.test.js` ("Cmd+Z in a card dialog input never reaches the page's undo"), `hyper-undo/test-node/native-key-routing.test.js`.

## Keep one history, and address every toast to its own entry

A per-feature undo stack, or a toast whose Undo means "whatever is on top", reverses the wrong action the moment somebody does something else. Every mutation goes through the one page history, and a toast carries the id of the entry its action made:

```js
const topEntryId = () => (clay.undo.history.at(-1) || {}).id || null;

function restoreEntry(entryId) {
  if (!entryId || topEntryId() !== entryId) return false;
  return clay.undo.undo({ entryId });
}
```

- `clay.undo.undo({ entryId })` replays an entry only when it is the next one, and answers `{ ok: false, code: "stale-entry" }` otherwise, so a late button can never reverse somebody else's newer step.
- Disable the toast's Undo when a newer entry is on top rather than hiding it, and point at the page's own history controls: the step is not lost, it is just no longer next.
- Repaint that button from `clay:undo-change`, which fires after every entry, replay, refusal, dismissal and clear, so the disabled state follows the real stack and not a local guess.
- One stack means one label per action too: `clay.undo.command("Delete card", ...)` beside a generic idle batch reads as two different features in the same history.

Reference: `starter-templates/tests/dom/kanban-undo-actions.test.js` ("the delete toast's Undo is disabled once a newer action is on top, and does nothing"), `starter-templates/tests/dom/kanban-undo-conflicts.test.js` ("a stale delete toast is disabled once a newer entry is on top").

## Keep session history in the tab and recovery in the document

Two things look alike and must not be confused. The undo stack is this tab's and this editing session's: it holds live nodes and replay plans, a reload starts it empty, and no second control or feature should keep a copy of it. Recovery is the document's: it holds plain data, it outlives the tab, and it is what a later session reviews.

```js
const written = await clay.undo.keep({ key: `card:${id}`, label: title, payload: { text: typed } });
if (written.ok) for (const draft of drafts) clay.unsent.drop(draft.record, draft.name);

const cleared = await clay.undo.dismissKept(record.id, { revision: record.revision });
if (!cleared.ok) toast("Somebody changed that copy. Review it again.");
```

- A refused step goes into recovery on its own: the refusal is announced first, and the copy is made behind it, so a store that will not answer changes neither the receipt nor the stack.
- Clear a kept copy only when something durable covers it: an accepted save (`clay.save.flush()` answering `saved` or `clean`), never a DOM state that merely looks right.
- Dismiss at the revision the reviewer was looking at. A copy another tab replaced since then is left as it is, with `{ ok: false, code: "stale" }`, so two people reviewing one document cannot delete each other's copy by accident.
- Recovery is not a save and not a second page: nothing renders from it, a normal save clears nothing a person has not dealt with, and the material appears only in the review panel.

Reference: `clayjs/tests/unit/undo-recovery.test.js` ("a kept record outlives the tab and lists on the next edit session", "dismissing at a stale revision refuses and leaves the record"), `starter-templates/tests/dom/kanban-undo-recovery.test.js`.

## Review recovery against the record as it is now, and read it again before replacing

A recovery copy is only worth keeping if it is compared with the record as it is now, not with the record as it was when the copy was made: a reload, another tab or a peer may have saved a newer version of that text while the person was reading. The review row says which saved text it compared with, and the Replace action reads the record again at the moment of replacing. When the text moved, the replacement is refused and the row shows the review again instead of overwriting the newer version.

```js
const live = article && !isTrashed(article) ? readEntry(article) : null;
what.textContent = record.base && !same(record.base, live)
  ? `Unsaved text for ${label}. The entry changed after this copy was made.`
  : `Unsaved text for ${label}, not in the saved journal.`;

const row = { current: live };
action("Replace current text", () => {
  const now = readEntry(article);
  if (!same(now, row.current)) {
    row.current = now;
    what.textContent = `Unsaved text for ${label}. The entry changed while you were reading it, so compare again.`;
    return Promise.resolve();
  }
  article.querySelector(".entry-body").textContent = body ?? "";
  touch(article);
  stage(article.dataset.id, { body: body ?? "", source: { id: record.id, revision: record.revision } });
});
```

- A copy whose record is gone says so and offers Keep as a new entry, so the text is not lost to a delete that happened after it was typed.
- The row keeps the text it read in `row.current`, refuses a click that no longer matches it, and takes the newer text as the thing to compare against, so the second click replaces exactly what the person has just compared.
- A trashed record is not a target: `live` is `null` and the row offers no Replace at all.

See [recovery](https://clayjs.com/docs/recovery/).

Reference: `starter-templates/templates/00-journal/journal.html` `rowFor`; `starter-templates/tests/dom/journal-recovery-review.test.js` ("an entry edited while the review is open refuses the stale replacement").

## Check the host's upload access before offering private pictures

A private HTML document does not make a file URL private. The host decides that, and its `/_/meta` answer says what it decided in `document.upload.access`. Read that value before you offer a picture feature, and pass `requiredAccess` to `clay.upload` or `clay.upload.start`, so a host that cannot keep the bytes private settles as `{ ok: false, code: "privacy-unsupported" }` before any bytes leave.

A missing value means public, and so does any value you do not recognize. Access is never inferred from the page URL, the plan or the filename. See the Private media section of [plugins/upload](https://clayjs.com/docs/plugins/upload/#private-media).

```js
const ACCESS = "document-or-local";

const task = clay.upload.start(job.file, { requiredAccess: ACCESS });
```

A host that promises neither settles the task with `code: "privacy-unsupported"` and the state `failed`. The Journal inserts a picture only on `succeeded`, so that host gets no picture and the entry stays text.

Reference: `clayjs/tests/unit/upload-task.test.js`, `hyperclay/tests/db/private-upload-discovery.test.js`.

## Bind an upload to the document's id, and check read access on every request

This one is for whoever writes a host. Store a document's uploads under a folder named for the document's id, `uploads/assets-document-<document id>/`, and link them at `/_/uploads/assets-document-<document id>/<file>`.

Then check the document's current read permission on every route that serves those bytes, answer 404 to a reader without access, and send `Cache-Control: private, no-store`. Renaming, moving or changing who can read a document must not detach its pictures' protection, and no URL should have to be rewritten when permission changes. See the Privacy section of [guide/hosts](https://clayjs.com/docs/guide/hosts/#privacy).

```html
<img src="/_/uploads/assets-document-<document id>/garden.jpg" alt="The garden in June">
```

Reference: `hyperclay/tests/db/document-upload-binding.test.js`, `hyperclay/tests/db/move-document-uploads.test.js`.

## Run each upload as one task, and insert once per finished attempt

A progress bar, a Cancel button and a Retry button need an upload with an identity of its own. Use `clay.upload.start(file, options)` for anything like that, follow it with `task.subscribe`, draw the shared row with `clay.upload.mount`, and insert the reference only when the attempt settles `succeeded`.

The task's `id` stays the same for its whole life and `attempt` increases only on an explicit `retry()`. A late progress callback or a second Retry click belongs to an attempt that is already handled, so it must not add a second picture. See the Tasks section of [plugins/upload](https://clayjs.com/docs/plugins/upload/#tasks).

```js
const task = clay.upload.start(job.file, { requiredAccess: "document-or-local" });
const seen = new Set();

task.subscribe(snapshot => {
  if (snapshot.state === "succeeded" && !seen.has(snapshot.attempt)) {
    seen.add(snapshot.attempt);
    const upload = snapshot.result?.uploads?.[0];
    if (upload && typeof upload.url === "string" && !job.record) uploaded(job, upload);
  }
});

clay.upload.mount(task, trayEl());
```

Reference: `clayjs/tests/unit/upload-task.test.js`, `starter-templates/tests/dom/journal-images.test.js`.

## Keep an uploaded picture's address in recovery until a save carries it

Storing image bytes is not the same as saving their reference. When an upload finishes and its Markdown reference has not been saved yet, stage the address in the document's recovery store under its own channel.

On load, drop a kept address that the saved file already carries and offer the rest again. A covering save clears only its own link, so a reload or a crash between the upload and the save leaves no stored picture nobody can find. See [recovery](https://clayjs.com/docs/recovery/).

```js
const CHANNEL = "journal.pending-images";

function readSaved(html) {
  const doc = new DOMParser().parseFromString(html, "text/html");
  const out = new Map();
  for (const [key, payload] of pending) {
    const body = bodyIn(doc, payload.entryId);
    if (body !== null && carries(body, payload)) out.set(key, payload);
  }
  return out;
}
const describe = record => ({ label: "Uploaded picture", text: record?.payload?.url ?? "" });
const channel = clay.recovery.register({ channel: CHANNEL, version: 1, readSaved, describe });
```

`readSaved` answers only the records the parsed file still needs, so a save that mentions the picture ends its recovery entry.

Reference: `starter-templates/tests/dom/journal-image-recovery.test.js`.

## Draw pictures from the Markdown, and keep the upload UI out of the record

The saved entry holds one Markdown image reference and nothing else. Previews, the image strip under the editor and the upload row are drawn from that text and marked `clay="editor-ui"`, so they never reach the saved file.

One reference must not become several saved copies: a preview, a thumbnail and the reference. A picture from this host loads directly, and a picture from another host loads only after the reader asks for it.

```js
function imagePolicy(url) {
  const raw = String(url ?? "").trim();
  if (!raw || /[\u0000-\u001f\u007f]/.test(raw)) return { kind: "blocked" };
  let parsed;
  try { parsed = new URL(raw, location.href); } catch { return { kind: "blocked" }; }
  if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) return { kind: "blocked" };
  const route = parsed.pathname.startsWith("/_/") && !parsed.pathname.startsWith("/_/uploads/");
  return parsed.origin === location.origin && !route
    ? { kind: "local", src: parsed.href }
    : { kind: "external", src: parsed.href, host: parsed.host };
}

el = document.createElement("div");
el.className = "image-strip";
el.setAttribute("clay", "editor-ui");
$("#writer").after(el);
```

Reference: `starter-templates/tests/dom/journal-pictures-view.test.js`, `starter-templates/tests/dom/journal-images.test.js`.

## Check a host by its whole release closure, fonts included

This one is for whoever writes a host. Before you call a release offline ready, check that every file its index lists is present with the recorded hash and that every module import resolves inside the release. The index lists about 550 files: modules, plugins, satellites, vendored libraries, theme sheets and fonts, so check all of them rather than one guessed boot plan.

The same check runs in three modes, so run it against the checkout you build from, the unpacked release tree you ship and a running host:

```bash
node scripts/verify-offline-closure.mjs --package-root .
node scripts/verify-offline-closure.mjs --published-root node_modules/@panphora/clayjs
node scripts/verify-offline-closure.mjs --base-url http://localhost:4000/_/clayjs/<version>/ --index path/to/host/offline-manifest.json
```

With `--base-url`, `--index` names the host's own bundled index. Without it, the check compares the host against the checkout's index.

Reference: `clayjs/tests/unit/build/offline-closure.test.js`, `clayjs/tests/unit/build/offline-host-contract.test.js`.

## Map an old library URL to a bundled copy by the exact URL

A host author ships a release that carries copies of the exact library builds older documents load from other CDNs, and the release index maps each whole URL to its copy:

```json
"aliases": {
  "https://cdn.jsdelivr.net/npm/overtype@2.6.1/dist/overtype.min.js": "compat/overtype-2.6.1.min.js",
  "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js": "compat/highlight-11.9.0.min.js",
  "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;0,600;1,400;1,500&family=IBM+Plex+Sans:wght@400;500;600&display=swap": "fonts/ibm-plex.css"
}
```

The copy is the same classic build, so it still defines the same globals (`window.OverType`, `window.hljs`). Match the whole URL: another version, a query string, another file or another host stays on the network. Matching by file name or a prefix would serve the wrong library to a document that asked for a different version.

Reference: `clayjs/tests/unit/build/offline-journal.test.js`, `hyperclay-local/tests/unit/offline-journal.test.js`.

## Rewrite runtime URLs only in attributes, and write the public URL back before every save

Whoever writes a host does the projection on serve and the canonicalization on every write, and both come from `@panphora/clayjs/offline-runtime`. Only `src` and `href` attributes that name a recognized runtime URL become the local address, and the mapping goes into a `<script type="application/json" id="clay-runtime-map">` descriptor:

```js
const { projectRuntimeHTML, canonicalizeRuntimeHTML } = runtimeAssets;

function projectServed(req, html) {
  const result = projectRuntimeHTML(html, {
    releases: runtimeAssets.releases,
    selectedVersion: runtimeAssets.selectedVersion,
    documentURL: `http://${req.get("host")}${req.originalUrl}`,
  });
  if (result.unavailable) {
    const detail = `${result.unavailable.reason} ${result.unavailable.versions.join(",")}`.trim();
    req.res.set("Clay-Runtime-Unavailable", detail);
  }
  return result.mappings.length ? result.html : html;
}

function canonicalForWrite(html) {
  const origin = html.match(LOOPBACK_RUNTIME);
  const documentURL = origin ? `${origin[0].toLowerCase()}/` : "http://localhost/";
  const result = canonicalizeRuntimeHTML(html, { releases: runtimeAssets.releases, documentURL });
  return result.changed === 0 && !result.descriptorRemoved ? html : result.html;
}
```

Every write goes through the second call: save, data API writes, restore, export and live-sync frames. A saved or downloaded file must never carry a host-local address, and authored text, code samples and Markdown must not change. See [Offline in a local host](https://clayjs.com/docs/guide/hosts/#offline-in-a-local-host).

Reference: `hyperclay-local/tests/unit/runtime-canonical-writes.test.js`, `clayjs/tests/unit/build/offline-host-contract.test.js`.

## Serve one exact release per page, and never mix in another release's files

This one is for whoever writes a host. Bundle one exact published release, chosen in your `offline-runtime.lock.json`, and serve it at `/_/clayjs/<version>/` from that release's own index:

```json
{ "selected": "1.15.2", "versions": ["1.15.2"] }
```

A page that pins a release the host does not bundle, names a file the release lacks, or mixes versions is left on its public URLs and answered with a `Clay-Runtime-Unavailable` header:

```js
unavailable: {
  reason: missing ? "release-missing" : absent ? "path-missing" : "mixed-versions",
  versions: [...versions].sort(),
}
```

The header carries that reason and the versions involved, so an operator can tell a document that will not load offline from one that will. A page must never run files from two releases, and the host never substitutes another release's copy of a file.

Reference: `hyperclay-local/tests/unit/runtime-assets-http.test.js`, `clayjs/tests/unit/build/offline-host-contract.test.js`.

## Prove offline support with a real document's late loads

If you write a host, prove it with a real document rather than a manifest. Open the document through the host, request every path that document loads after boot, then save it and open it again:

```html
<script src="https://clayjs.com/v1/clay.js?plugins=sortable,people,upload,sync,visitor-drafts,unsent,menu,undo&exclude=richclay"></script>
```

The Kanban board boots with a few dozen modules and then asks for `derive`, the merge engine, SortableJS on the first drag and the live-sync modules as they are needed, about 116 release paths in all. ClayJS's cross-host contract test opens that real board in both local hosts, requests each of those paths through the host, saves the served document and checks the disk bytes carry the public URLs again. Lazy modules are invisible to a boot graph, so a host that passes a boot check can still break on the first drag with the network off.

Reference: `clayjs/tests/unit/build/offline-host-contract.test.js`.

## Draw presence from the host's filtered roster

Presence comes from the roster the host sends this recipient, filtered by this recipient's own access. Read it from `clay.presence` and draw what arrived.

Never build a roster from page data or from peer frames. Presence must not reveal people the recipient may not see, and nothing in the page can widen the roster the host chose.

```js
clay.presence.subscribe(({ people, anonymous }) => {
  draw(people.map((person) => ({ id: person.id, name: person.name, you: person.you, canEdit: person.canEdit })));
  drawCount(anonymous);
});
```

Reference: `clayjs/tests/unit/presence-chrome.test.js`, `clayjs/tests/unit/presence-store.test.js`.

## Publish a target only while its control is focused, and clear it on the way out

For a record, set its target while the writing control has focus. For the page, the editor clears on blur, during composition, on `pagehide`, when the host stops announcing page awareness, and on disposal.

A stale target says someone is writing who has left. Clear it on the way out.

```js
title.addEventListener("focus", () => {
  clay.presence.setTarget({ kind: "record", id: card.id, field: "title" });
});
title.addEventListener("blur", () => {
  clay.presence.setTarget(null);
});
```

Reference: `clayjs/tests/unit/page/awareness.test.js`, `clayjs/tests/unit/presence-lifecycle.test.js`.

## Keep presence out of the file

Mark every piece of presence UI `clay="no-save no-watch no-snapshot"`, and keep remote carets as editor decorations.

Presence is runtime state, never document content. It must not reach a save, a snapshot, or the page's own change detection, and a roster change is not an edit.

```js
function runtimeRoot(el) {
  el.setAttribute("clay", ui.RUNTIME_ONLY);
  return el;
}
```

Reference: `clayjs/tests/unit/presence-chrome.test.js`, `clayjs/tests/unit/page/awareness.test.js`.

## Place a remote caret by relative position, and hide what you cannot place

Publish a selection as the block id plus a Yjs relative position, resolve it against the current document, and draw nothing when it does not resolve.

A remote caret must never be drawn in the wrong place, so a failed resolution is hidden rather than clamped into a neighbouring block.

```js
const relativeTo = (pd, doc, w, pos, assoc) =>
  toBase64(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(pd.flow, yIndexAt(doc, w.holes, pos, w.visible), assoc)));

const abs = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(fromBase64(end.rel)), pd.ydoc);
if (!abs || abs.type !== pd.flow) return null;
```

Reference: `clayjs/tests/unit/page/ybind/awareness.test.js`, `clayjs/tests/unit/page/awareness.test.js`.

## Bind a presence target to the connection that sent it, and expire it by the host's clock

A target belongs to one admitted document connection, the session the host issued that connection, not to an account. A frame whose `n` is at or below the last one that session accepted is ignored, so a stale target cannot return after a newer clear. A target expires 15 seconds after the host received it, by the host's clock, and a renewal resets that clock. Each recipient gets a roster filtered by its own view access. One session cannot spend another's allowance: 10 updates a second, with a burst of 20.

```js
if (request.n <= session.n) return { status: 'stale' };
if (!takeToken(session, at)) return { status: 'rate-limited' };

const checked = validateTarget(request.editing);
if (!checked.ok) return { status: 'invalid', reason: checked.reason };

session.n = request.n;
session.target = checked.target;
session.receivedAt = checked.target === null ? 0 : at;
```

Reference: `hyperclay/tests/db/presence-awareness-http.test.js`, `hyperclay-local/tests/unit/local-presence.test.js`.

## Hand presence to the next owner on a mode switch, and never let a reader write

One store is the sender for a document. A mode switch hands the stream and the presence lease to the successor: the reader keeps its lane and its queue until the switch commits, and the editor that takes the document attaches its own lease over the same store. A lease that a newer one replaced detaches nothing, so giving an obsolete lease back cannot clear the newer owner's roster. A reader's lease has no write half, so a target set in view mode publishes nothing anywhere.

```js
const lease = {
  get active() {
    return store.owner === lease;
  },
  detach() {
    if (store.owner !== lease) return;
    store.owner = null;
    store.clear();
  },
};
this.owner = lease;

presenceLease = presenceStore.attachTransport({ send: () => Promise.resolve(false) });
```

Reference: `clayjs/tests/unit/presence-lifecycle.test.js`, `clayjs/tests/unit/reader-presence.test.js`.

## Back up exact record fields, blanks and Trash included, then preview the restore

A backup of what the view shows is not a backup of the file. Read every record with all of its fields, keep the empty bodies and the trashed ones, and write them into one envelope that names its format and version. An incoming file is then parsed inertly and previewed as additions, identical rows and conflicts before anything is written.

```js
const envelope = entries => ({
  format: BACKUP_FORMAT, version: BACKUP_VERSION, exportedAt: new Date().toISOString(), entries
});
```

See [Importing and exporting records](https://clayjs.com/docs/guide/architecture/#importing-and-exporting-records).

Reference: `starter-templates/tests/dom/journal-backup.test.js`, `starter-templates/tests/dom/journal-restore.test.js`.

## Keep a byte budget per boot, and fail a test when it grows

Size creeps back one import at a time, so no single change ever looks like the one that made a release too big. Measure what each boot downloads and hold it: a reader's view, an editor's edit and a visitor's draft each get their own row, and a test fails when a mode goes over its row. `scripts/measure-load.mjs` reads the document's ClayJS tags and follows the loader's boot order, the boot prefix and then each core module and plugin awaited in turn, and reports the Brotli bytes, the gzip bytes, the request count and the fetch depth of that boot; `measureBuilt` measures the built release the same way, from its own boot plan.

```json
"document.html": {
  "view": { "requests": 45, "depth": 19, "brotli": 101376, "css": { "requests": 1, "depth": 1 }, "fonts": { "requests": 10, "depth": 2, "raw": 464896 } },
  "edit": { "requests": 158, "depth": 49, "brotli": 584704, "css": { "requests": 1, "depth": 1 }, "fonts": { "requests": 10, "depth": 2, "raw": 464896 } }
}
```

```js
const measured = await measureBuilt({ html: htmlOf(fixture), mode, dir });
expect(measured.js.brotli).toBeLessThanOrEqual(limits.brotli);
```

- One row per boot mode, so a change that only costs an editor bytes cannot hide behind a reader's budget.
- Requests and fetch depth are budgeted beside the bytes, because a mode can get slower without getting bigger.
- Styles and fonts are counted on their own lines, so a byte ceiling cannot hide half a megabyte of fonts.

See [Add ClayJS to a page](https://clayjs.com/docs/guide/loading/) for how a mode decides what it loads.

Reference: `clayjs/scripts/measure-load.mjs`, `clayjs/scripts/load-budget.json`, `clayjs/tests/unit/build/load-budget.test.js`.

## Measure the bytes a save will send against the host's limit

Text length and compressed length do not describe what a host admits, because it admits the bytes of the body it receives. Measure the exact UTF-8 string a save is about to send, at the one place every save lane passes through, and refuse before sending when it is over the limit the host advertised.

```js
const size = await clay.save.inspect(html);
if (size.state === "over") return;
```

See [Size limits](https://clayjs.com/docs/guide/saving/#size-limits).

Reference: `clayjs/src/core/save-size.js`, `clayjs/tests/unit/save-size-paths.test.js`.

## Project an import off the document and revalidate it before one synchronous commit

A preview must show bytes that are not the page yet, and a refused import must change nothing. Build the candidate on a detached clone with `clay.getHTML({ transform })`, read it back field for field, recheck the mode and the current limit, and only then write in one synchronous step.

```js
const attempt = candidateWith(added);
if (!Backup.verify(attempt.html, attempt.expected).ok) return;
store.append(fragment);
```

See [Importing and exporting records](https://clayjs.com/docs/guide/architecture/#importing-and-exporting-records).

Reference: `starter-templates/tests/dom/journal-import-preflight.test.js`.

## Physically omit unselected records from an exported file

Hiding a record with CSS keeps its text in the bytes, and anyone who downloads the file can read it. Remove every unselected record from the copy before it is serialized, then read the result back and check the exact set of ids it carries.

```js
for (const a of root.querySelectorAll("article.entry:not([cms-template])")) {
  if (!selected.has(a.dataset.id)) a.remove();
}
```

See [Importing and exporting records](https://clayjs.com/docs/guide/architecture/#importing-and-exporting-records).

Reference: `starter-templates/tests/dom/journal-selected-export.test.js`.

## Verify an archive, and keep a recovery copy, before removing its records

Starting a download does not prove a second copy exists. Move records out of a document in two stages: first write the archive and remove nothing, then ask for the file the person actually kept, read it inertly, and remove only when it holds exactly the reviewed records and the page has not changed since. Keep the reviewed records in a durable recovery channel before the removal, and clear that only when a saved file no longer carries their ids.

```js
const stored = await ArchiveMoves.stage(moveId, { year, entries: reviewed, archiveDigest });
if (!stored) return { ok: false, code: "unstored", copyOnly: true };
if (!clay.modeLifecycle.canWrite() || !sameSet(reviewed, recordsFor(year)).ok) {
  ArchiveMoves.discard(moveId);
  return { ok: false, code: "changed-after-staging" };
}
for (const record of reviewed) Entries.byId(record.id)?.remove();
clay.save.soon();
```

See [Importing and exporting records](https://clayjs.com/docs/guide/architecture/#importing-and-exporting-records).

Reference: `starter-templates/tests/dom/journal-year-archive.test.js`.

## Print the canonical records, not the filtered view

A printout that follows the active filter or an open editor silently stops after one page of results. Print the live record articles themselves, hide the chrome and the templates in print CSS, and narrow to a chosen set with an ephemeral flag that no save and no peer sync can carry.

See [Importing and exporting records](https://clayjs.com/docs/guide/architecture/#importing-and-exporting-records).

Reference: `starter-templates/tests/dom/journal-print.test.js`.
