# 8. Structuring an app

> How to shape a whole app: the record container, views, derived values and app state.

Source: https://clayjs.com/docs/guide/architecture/

## The record container

Keep every record in one container in the saved document, with `data-id` per record and fields as attributes, text, or persist controls. Shape the container by the primary editing dimension:

- **Board-first** (kanban, pipeline): records live inside their column's list. `sortable` gives cross-column drag, and the column a card sits in is its status.
- **Table-first** (finance, CRM contacts, inventory): records are `<tr data-id>` rows in one `<tbody>`. Any board is a projection.
- **Document-first** (writer, wiki page): records are blocks, siblings in one `sortable` container ([Block writer](https://clayjs.com/docs/guide/recipes/#block-writer)).

Never store a record twice. A JSON mirror of DOM records, or a rendered copy beside a model, drifts the first time a merge or a hand edit touches one of them.

## Chrome

Chrome is the app UI around your records. Author static chrome in the file and hide it from viewers with `show-when:editmode="true"` ([Showing and hiding edit controls: clay-options](https://clayjs.com/docs/guide/edit-mode/#showing-and-hiding-edit-controls-clay-options)). It needs no script, is in place before ClayJS loads, and viewers never see it. The file carries its markup, which is harmless as long as nothing changes it.

Use `clay="editor-ui"` when hiding is not enough:

- **A script changes it after load.** This includes counts, summaries, status text, and a filter bar that reflects its state. In a saved element, each change is an edit: it starts an autosave, adds an undo step, syncs to peers, and can conflict with theirs. Inside `editor-ui`, none of that happens. For a selection or open state on a saved element, use `ephemeral` instead.
- **It is drawn from data.** Charts, canvases, and grids rendered from the record.
- **It holds per-tab state.** The open card, the current view, and the style that paints them ([Per-tab state](https://clayjs.com/docs/guide/architecture/#per-tab-state)).
- **Viewers must not receive it.** The saved file has to be clean HTML without the controls.

An `editor-ui` element is removed from every save, so do not author it in the file. Clone it from a `<template>` into one `clay="editor-ui"` wrapper after `clay.ready`:

```js
const chrome = document.createElement("div");
chrome.id = "chrome";
chrome.setAttribute("clay", "editor-ui");
chrome.append(document.getElementById("chrome-tpl").content.cloneNode(true));
board.querySelector(".board-head").append(chrome);
```

Chrome may sit inside the record container (the kanban puts its filter in the board header): `editor-ui` removes it wherever it is. Author static per-item controls, such as a grip or remove button, in the item's `<template>` with `show-when:editmode="true"`. Insert script-managed per-item controls (the writer's block gutter, a list's menu button) into each item as their own `editor-ui` element, and re-insert them in `render()` when missing so items added by a peer get theirs too. A per-item value computed from the record, such as a count, is saved content rather than chrome ([Derived values](https://clayjs.com/docs/guide/architecture/#derived-values)).

Edit-only chrome lives in the same wrapper with `show-when:editmode="true"`, or is skipped in view mode (`if (clay.isEditMode) …`).

## Per-tab state

Keep UI state outside saved records unless it belongs to the document for every visitor.

| State | Where | Why |
|---|---|---|
| Which card or entry is open, the filter, search text, current view, sort | URL hash: `#card=c-brief&q=pricing` | per tab, survives reload, shareable, Back closes the card, never saved or synced |
| Collapsed sections, column widths, density, last opened entry | `clay.storage.local.get(key)` / `set(key, value)` / `remove(key)` | per device and viewer, scoped to this file's path (`clay.storage.session` lasts one tab) |
| Selection, hover, drag state, open menu | `ephemeral` on the element ([Ephemeral state: per-tab state on saved elements](https://clayjs.com/docs/guide/record/#ephemeral-state-per-tab-state-on-saved-elements)), memory, or chrome DOM | transient, never saved or synced |
| Pointers, lobbies, who is looking at what, shared live but never saved | a never-saved live store ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)) | every tab sees it at once, the file never holds it |
| A default view visitors land on, WIP limits, column names, label definitions | attributes on a settings element in the record | genuinely part of the document |

For state on one element, such as the selected card, use `ephemeral` ([Ephemeral state: per-tab state on saved elements](https://clayjs.com/docs/guide/record/#ephemeral-state-per-tab-state-on-saved-elements)). **Paint state that comes from the hash with a runtime stylesheet** inside chrome, rewritten from the hash, instead of toggling classes or attributes on records:

```js
const rules = [];
if (openId) {
  const sel = `.card[data-id="${CSS.escape(openId)}"]`;
  rules.push(`${sel}{position:fixed;z-index:20;top:6vh;left:0;right:0;margin:auto;width:min(560px,92vw);max-height:88vh;overflow:auto}`);
  rules.push(`${sel} .detail{display:block}`, `#chrome .backdrop{display:block}`);
}
setText(document.getElementById("view-state"), rules.join("\n"));
```

Nothing on a record changes, so opening a card or filtering never makes the page dirty or reaches a peer. The kanban and skeleton tests check that.

When a view switch must use `clay-options` on a saved ancestor attribute, normalize it out of every snapshot so it stays tab-local:

```html
<main id="app" view="board" onbeforesnapshot="this.setAttribute('view', 'board')">
```

Never put per-viewer state on `<html>` or any record element without that reset: it autosaves, syncs to every collaborator, and lands in version history.

`clay.storage` keeps JSON under `clay:doc:<location.pathname>:<key>`, so two documents on one subdomain never share a key. Without it, a per-document key must be hand-built from the path or every document's app collides with the next. `session` survives a reload and copies into a duplicated tab, so it cannot keep one tab's state out of the next. When the browser blocks storage, every call works from memory for the life of the tab and `set` returns `false`: save the value somewhere that outlives the tab if `set` returns `false`.

## Derived values

Pick by whether the saved file must show the value:

- **Computed only from the records** (counts, progress badges, totals, a card's face): ordinary saved content. Write it into the record markup, ship the file pre-painted, and recompute on every change. With sap, this is a paint ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)) that ClayJS treats as derived. Merges leave it out and the engine repaints it from the merged records, so two tabs that changed different records end with the right value and no conflict. Hand-written derived text in saved content does not get this, so give it `no-dirty`. The starter Kanban paints its list counts and card faces this way.
- **Different per tab** (search results, which entry is open, "overdue" against today's date, OS shortcut labels, names loaded from the people plugin): write it inside a `clay="freeze"` scratchpad ([Regions: the `clay` attribute](https://clayjs.com/docs/guide/record/#regions-the-clay-attribute)). The element saves and syncs, while its contents never do.
- **Pure UI** (buttons, menus, handles): `editor-ui`, created from a `<template>` ([Chrome](https://clayjs.com/docs/guide/architecture/#chrome)).
- **Optionally, `no-dirty`** for a computed value the saved file shows: it saves with the next real edit, never counts as an edit itself, and remote wins under sync. Apply the [Regions and JSON under sync](https://clayjs.com/docs/guide/live-sync/#regions-and-json-under-sync) workaround. The finance tracker does this.
- **Only in the saved file**: compute it into the save clone with `clay.addDocumentTransform`, never the live page.

Always write derived text through a guard, because writing the same text is still a mutation:

```js
const setText = (el, text) => { if (el.textContent !== text) el.textContent = text; };
```

Recompute inside `clay.effect(render)`, which covers record mutations, merges, undo, and redo ([Reconciling in app code](https://clayjs.com/docs/guide/live-sync/#reconciling-in-app-code)). Call `render()` from a `hashchange` listener when the hash affects the view.

With sapjs, every derived value here is a paint. sapjs recomputes on record mutations and `clay:sync-applied` by itself, and on hash changes only through `clay-router.js` (`clay:route`). An app with its own hash format keeps a `hashchange` listener that calls `Sap.refresh()`. See the [sapjs guide](https://saphtml.com/guide.html).

## Detail views: the record is the modal

Keep a record's detail fields **inside the record element**, hidden on the card face by CSS, and show the record itself as an overlay with the runtime stylesheet from [Per-tab state](https://clayjs.com/docs/guide/architecture/#per-tab-state):

```html
<li class="card" data-id="c-brief">
  <span class="grip" sortable-handle show-when:editmode="true">⠿</span>
  <h3 editmode:contenteditable inert-contenteditable="plaintext-only">Write the launch brief</h3>
  <div class="detail">
    <div class="desc" editable><p>Two paragraphs on <b>scope</b> and audience.</p></div>
    <ul class="checklist">…</ul>
    <ol class="activity"><li data-at="2026-10-07">Created</li></ol>
    <footer class="card-actions" show-when:editmode="true">…</footer>
  </div>
</li>
```

```css
.detail { display: none }
```

Why this beats a dialog with a copy:

- There is no copy, so nothing to write back and nothing to lose.
- The detail moves with the card when dragged, and merges per card under sync.
- A peer's edit to the open card appears in place, with your caret kept.
- richclay's body-mounted toolbar works because nothing is in the top layer. `showModal()` would also write `open` into the file.

Open on a click that is not on an editable field, handle, input, or link. Close on the backdrop, Escape (unless typing), and Back. When a peer deletes the open record, `render()` finds it gone and clears the hash. Caveat: `position: fixed` breaks inside an ancestor with `transform`, `filter`, or `contain`.

When a dialog must aggregate several records, render it into `editor-ui` and write each change back to its record by id. Call `clay.markDirty()` per edit if the record is not written immediately, and re-render it on `clay:sync-applied` without disturbing the focused field.

**Half-typed text in a detail field outlives the view.** A comment box or description being edited sits in chrome (`clay="editor-ui"` or `clay="freeze"`) and carries `unsent="<name>"`. ClayJS keeps its value per record (the nearest `[data-id]`) and name in this tab, outside the file. Close the detail, take a sync redraw, or reload, and the text is back with the close warning on until it is sent.

```html
<div class="detail" clay="editor-ui">
  <textarea class="comment" unsent="comment" placeholder="Add a comment"></textarea>
  <button data-action="send-comment">Send</button>
</div>
```

- The attribute is only a marker in the file: the region is chrome, so no save, sync frame, or undo step ever carries the text.
- Restoration runs from a `MutationObserver` rather than a microtask, after the code that rebuilt the detail, so the view's `openDetail` can clear and refill the field in the same task. To react to it, listen for `clay:unsent-restored`, which bubbles from the control with `detail: {base, current, stale}`. `stale` is true when the record's saved value changed since the person started typing, so the view can warn instead of silently overwriting.
- Send and cancel call `clay.unsent.clear(input)`. Typing the text back to what the record already holds, or emptying it, drops it too, so the warning clears without an explicit call.
- `clay.unsent.size` and `clay.unsent.list()` (`{record, name, base, text}`) say what is kept, and `clay:unsent-changed` fires on `document` with the count.

With sapjs, the record-as-modal pattern works unchanged over `items=` records, and a plain-field editor can be a `detail=` panel or `<dialog>` instead. See the [sapjs guide](https://saphtml.com/guide.html).

## Commands

A command is a function that changes the record once:

```js
const actions = {
  "add-card"(button) {
    const card = fromTemplate("card-tpl");
    card.dataset.id = newId("c");
    logActivity(card, "Created");
    command("Add card", () => button.closest(".column").querySelector(".cards").append(card));
    focusText(card.querySelector("h3"));
  },
  "delete-card"(button) {
    const card = button.closest(".card");
    clay.confirm("Delete this card?").then(() => {
      command("Delete card", () => card.remove());
      writeHash({ card: "" });
    }, () => {});
  },
};
const editOnly = new Set(["add-card", "delete-card"]);

document.addEventListener("click", (event) => {
  const button = event.target.closest("[data-action]");
  if (!button) return;
  if (editOnly.has(button.dataset.action) && !clay.isEditMode) return;
  actions[button.dataset.action](button);
});
```

- Build new elements off-document (fill, id, log), then insert once inside the commit.
- Use one delegated click listener on `document` for all actions. It survives merges that recreate elements.
- Write activity entries from the command that made the change, never from a mutation observer, which would also log peer changes and undo replays.
- Bulk inserts (an import of hundreds of rows) cost one mutation record per inserted element. Insert a fragment once rather than row by row, inside one commit or `Sap.batch`.

**Soft delete is `clay.trash`.** `card.remove()` above deletes for good. A delete a person might want back, or one a peer might be editing at the same moment, is an attribute instead: a record is in the trash while it carries `data-deleted-at`, and the record keeps its place, its id, and its content.

```js
const item = button.closest("article.entry");
const { undo } = clay.trash(item);          // stamps data-deleted-at, fires clay:trashed
clay.toastPersistent("Moved to Trash", "info", {
  actions: [{ label: "Undo", onClick: () => { undo(); clay.toastPersistent("Restored", "info"); } }],
});
```

- `clay.trash(el, {at})` stamps the timestamp (`at`, or now), `clay.trash.is(el)` checks trashed state, `clay.trash.restore(el)` removes the attribute and fires `clay:restored`, and `clay.trash.purge(el)` removes the record for good. Both events bubble from the record.
- Because a delete is an attribute change, a peer editing the same record at that moment keeps their edit and the delete merges with it. Undo restores the attribute instead of an old copy of the record. `card.remove()` has neither.
- Leave trashed records out with a rules selector, `"article.entry:not([data-deleted-at])"`, or keep them in the list and show a `deletedAt` field (`Date.parse(el.dataset.deletedAt)`) for a Trash view. Never remove records to filter.
- Readers without JavaScript get the raw file, so hide trashed records there too: `<noscript><style>article.entry[data-deleted-at]{display:none}</style></noscript>`.
- The undo toast stays yours: `clay.toastPersistent(message, type, {actions})` takes the Undo button where `clay-ui` is loaded, and an app with its own toast keeps it.

## Filters and view switching

Filter and switch views without removing or mutating record elements:

- **Filter by hiding** records with a runtime stylesheet (skeleton, kanban), or with sapjs through an `each=` view or a frozen stylesheet ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup)). The finance example still uses sap `show=` plus snapshot normalization, an older pattern where `show=` writes `hidden` into saved records. Never remove records to filter.
- **Switch layouts with CSS** over the same records where possible: a list view can be the board with `display: contents` on columns.
- **Projection views** (a table of all cards, a calendar, a due-today list) render into an `editor-ui` container and rebuild on record mutation, `clay:sync-applied`, and `hashchange`. Edits made in a projection write to the record by id.

With sapjs, a projection view is an `each=` attribute, and its rows edit the records they show by themselves. See the [sapjs guide](https://saphtml.com/guide.html).

## Computed totals

Use plain JavaScript into `editor-ui` ([Derived values](https://clayjs.com/docs/guide/architecture/#derived-values)) for a count or two. Use sapjs for arithmetic across rows, categories, and formats ([sap.js: records, views and totals in markup](https://clayjs.com/docs/guide/components/#sap-js-records-views-and-totals-in-markup), and the expense tracker in the sapjs guide). Store money as integers or plain numbers in `value` attributes, never as formatted strings.

## Import and export

Export reads the record, import writes it, and both buttons live in chrome. The finance tracker's export:

```js
const rows = Sap(app).txns.map((t) => [t.date, t.memo, t.cat, t.amount]);
const blob = new Blob([toCSV([["date", "memo", "category", "amount"], ...rows])], { type: "text/csv" });
const link = Object.assign(document.createElement("a"), { href: URL.createObjectURL(blob), download: "transactions.csv" });
link.click();
URL.revokeObjectURL(link.href);
```

Without sap, read the record with `clay-data`'s `clay.extractData(rules)`, which returns strings and skips `editor-ui` and `no-data` regions:

```js
await clay.loaded.data;
const { rows } = clay.extractData({ rows: [".txn", { id: "@data-id", date: ".date", amount: ".amt", memo: ".memo" }] });
```

`clay.applyData({ rows }, { rules })` writes a whole list back. It grows and shrinks the list to match and refuses unknown keys, scripts, and event handlers without a partial write. To append, pass the existing rows plus the new ones. `applyData` pairs incoming rows with existing elements by content and order, **not** by an `id` field. Reorder the rows and edit them in the same call, and an element can be rewritten as a different record while keeping the old record's unexposed attributes. When identity matters, update records yourself: find each element by `data-id`, write its fields, and append new ones from a template.

ClayJS has no CSV parser. The finance tracker has a 20-line RFC 4180 parser (quoted fields, doubled quotes, CRLF) and imports with sap:

```js
Sap.batch("Import CSV", () => {
  for (const r of body) {
    const row = Sap(app).$add("txns");
    row.date = pick(r, "date");
    row.memo = pick(r, "memo");
    row.cat = pick(r, "category");
    row.amount = Number(pick(r, "amount").replace(/[$,]/g, "")) || 0;
  }
});
```

A rules tag with `data-rules-name="api"` gives agents and the host's data API the same shape your export uses.

## Importing and exporting records

The Journal keeps every record in one container, so a backup, an import, a selected export and a print are four ways of copying or writing that same set. Each one is a named function in `journal.html`.

- **Back up the exact fields, blanks and Trash included.** `Backup.records` reads every record with all of its fields, and `Backup.envelope` wraps them as `{ format, version, exportedAt, entries }`. An empty body and a trashed record are still records, so a backup restores what the file holds rather than what the view shows. Markdown is not an exact data backup.
- **Parse, preview and revalidate before committing.** `Backup.parse` reads a file inertly and refuses unknown fields, missing fields, duplicate ids and bad dates with a code. `Backup.plan` sorts the rows into fresh, identical and conflicting ones, `planRestore` offers `keepBoth` for a row that changed here, and `Backup.verify` reads a candidate back field for field. A refusal at any of those points changes nothing.
- **Commit once, synchronously.** `confirmImport` re-reads the limit, compares the current capture with the preview's baseline, replans and re-verifies, then appends the prepared records in one synchronous step followed by one `clay.save.soon()`. There is no await between the last check and that step, so a refusal leaves the page, the route, the selection and the dirty state as they were.
- **Measure the candidate against the host's limit.** `candidateWith` builds the bytes a save would send with the new records already in place, and `measureHTML` asks `clay.save.inspect` about them. An over-limit import is refused before a single record enters the document.
- **Export only the bytes selected.** `selectedCopy` removes every unselected article from the copy inside `clay.getHTML({ transform })`, so the records left out are gone from the file rather than hidden by CSS. `verifySelectedCopy` reads the result back and checks the exact id set.
- **Move a year out only after the copy you kept checks out.** `YearArchive.archive` downloads the year as a `.htmlclay` copy and an exact JSON backup and removes nothing; starting a download proves nothing about a second copy. **Finish moving this year** asks for the file the person kept, reads it inertly, and requires exactly the reviewed records, field for field. A different file, a changed record or a record that joined the year since the review refuses with nothing removed. Before removing, `ArchiveMoves.stage` keeps the reviewed records in the `journal.archive-moves` recovery channel and awaits its durable receipt; a browser that cannot keep it refuses and offers the copy only. The removal itself is one synchronous step after a last re-check, followed by one `clay.save.soon()`. The move is cleared only by a saved file whose bytes lack every reviewed id, so a save that never lands leaves **Archive move awaiting save** after a reload. A year whose entries link pictures kept in the journal's own files can be copied but not moved, because a copy cannot carry them yet.
- **Print the canonical records.** `printSelected` flags the chosen live articles with the ephemeral `print-pick` word and print CSS narrows the printout to them, so a printout is the records themselves, not the filtered list or an open editor. The flag is never saved, and another tab's sync never carries it.

```js
const attempt = candidateWith(added);
if (!Backup.verify(attempt.html, attempt.expected).ok) return;
const size = await measureHTML(attempt.html, limit);
if (size.state === "over") return;
store.append(fragment);
```

## Starting the app once

Start the app with `clay.start` so initialization runs once per page. A live-sync merge can re-insert an inline script and run it again, which doubles every listener and every chrome element. `clay.start` runs its function once after `clay.ready`, and a repeat call does nothing:

```js
clay.start(() => {
  …
});
```

A page with more than one app script names each one so they do not share a slot: `clay.start("board", fn)`.

## Document size and record limits

Every save captures and serializes the whole document, every change check captures it again, and every peer merges the whole document per accepted save. Measured in jsdom and scaled to a browser (estimates):

| Records (about 425 bytes each) | File size | Capture per check | Full save | Peer merge |
|---|---|---|---|---|
| 300 | 128 KB | ~10 ms | ~25 ms | ~10 ms |
| 1,500 | 640 KB | ~55 ms | ~140 ms | ~60 ms |
| 3,000 | 1.3 MB | ~120 ms | ~300 ms | ~190 ms |
| 6,000 | 2.6 MB | ~250 ms | ~650 ms | ~400 ms |

Typing also costs one Sap pass, and a pass visits every record. Sap skips rereading unchanged record values, but it still builds fresh objects for every record, so the pass grows with the record count: at 5,000 records in jsdom, a body edit's pass takes about 27 ms ([Cost at size](https://clayjs.com/docs/plugins/sap/#cost-at-size)).

Up to about 1,000 records feels instant. From 1,000 to 3,000 there is a visible hitch at each save. Past about 3,000 records or 1.5 MB, autosave stalls typing and peers lag. Hosts cap a save at 5 MB (hyperclay.com), 20 MB (Hyperclay Local), and 50 MB (HTML Clay). Design for archiving: one file per year, a hidden archive section that can move to another file, or one file per project.

## Styles a script writes

Use `clay.style` when a script owns CSS that should stay in the saved document. The saved style paints the next load before the script runs. When the script runs, it refreshes that same tag with its current CSS:

```js
await clay.ready;
clay.style("app-colors", ".status { color: blue; }");
```

The name identifies a `<style data-name="app-colors" clay="no-dirty">` in the document's head. On the first load, before any save has carried the tag, `clay.style` creates it. A repeated call updates the existing tag in place without adding an undo step, starting an autosave, or raising the close warning. The next real save includes the current CSS.

The one-argument form keeps an external stylesheet link current:

```js
await clay.ready;
const link = clay.style("/app.css?v=2");
```

If the saved file has `/app.css?v=1`, this updates that link's `href` to `/app.css?v=2`. Matching uses the URL's origin and path, ignoring its query and fragment. The link is also `no-dirty` and travels with the next real save. Both forms work in edit and view mode and return the element synchronously, without waiting for the stylesheet to load.

Use `clay="editor-ui"` instead for CSS that viewers must not receive or that represents per-tab state, such as a temporary filter. Those styles are stripped from saved HTML. `clay.style` is for CSS that belongs in the file, and changing its name on every load creates another saved tag. Adding or rewriting a plain style tag without `no-dirty` can raise the leave warning even though the person edited nothing.
