- ClayJS
- Build
- Free documents for visitors
Free documents for visitors
Published apps anyone can use and keep: visitor drafts, downloads and the owner view.
A free document is a published app anyone can use and keep. The visitor gets the same editing UI the owner gets, their work is kept in their own browser, and the download hands them a file with that work in it. Nothing a visitor does reaches the host.
What a visitor gets#
On a page that opts in (The contract), a visitor who is not the owner is in draft mode. Every edit-only module loads, so editable, persist, undo, sortable and the rest behave as they do for the owner, and the autosave pipeline still runs. The save lane is swapped: instead of sending the document to the host, ClayJS extracts the page’s api JSON out of the bytes a real save would write and stores { "v": 1, "savedAt": "2026-10-15T00:00:00.000Z", "data": { ... } } under localStorage["clay:visitor-drafts:<slot>"]. The next load applies that JSON over the file the host served, so an owner’s later fix reaches a returning visitor while the visitor’s own work stays. The host is never told: no save, no sync, no upload, and no /_/meta discovery. Two tabs on the same page share that record: a save takes what only the other tab changed, and when both changed the same thing, this tab’s version is saved and the other tab’s record is kept under clay:visitor-drafts:<slot>:conflict.
The owner is a visitor here too. Signed in, they get the same private draft unless the URL carries ?editmode=true, which edits the original file and shows every visitor the change. Two things always edit the file itself: a document opened from file:, where draft mode never applies, and HTML Clay, which stamps a save token on the response. Hyperclay Local is not one of them. It serves the folder over http: and injects no save token, so its owner is in draft mode like anyone else and adds ?editmode=true to edit the file (The mode ladder).
The contract#
A page that wants draft mode needs all of this. The example in A complete example carries every item, and its test proves the round trip:
- An
apirules tag naming every saved field, with acms-templateseed row for every list the rules describe. Hide the seed in your own CSS ([cms-template] { display: none !important }), never withhidden, which the CMS reads as data (cms: content-managed pages). <html visitor-drafts="<site-stem>-v1" autosave>. The value is the slot: letters, digits, dot, underscore and hyphen, up to 100 characters. It names this app’s corner oflocalStorage, so two apps on one origin never mix.plugins=visitor-draftson the loader tag, plus whatever else the app needs.persiston every control the rules name. A typed value withoutpersistis lost at save (persist: form controls), and in draft mode a save is the visitor’s own copy.data-idon every record anddata-id=""on every blueprint. New rows mint their own (trigger-adddoes it, sap.js: records, views and totals in markup).<meta name="application-name" content="...">. The prompt, the intro and the download name the app from it, falling back to the title before the first:or|.- A feedback link the visitor can use: a
mailto:address in the page, not in the plugin. - Optional: a
<template visitor-intro>(The download prompt and the intro area). No template, no intro; the prompt still shows. - A round trip test in your own repo: write a draft, load the page again with that storage, and check that the values and the computed counts come back.
Bump the slot (-v2) only when an old draft cannot fit the new rules. A draft that no longer fits is parked, not thrown away (Restoring and redrawing).
The mode ladder#
How the mode is decided’s list decides the mode. Draft mode is a rung in it, between a save token and the owner cookie:
?editmode=trueor?editmode=falsein the URL. Only the literaltruemeans edit.<html viewonly>in the file, or a response carrying only the pre-1.9.0 tokenhtmlclaytoken.window.clayEditMode, set by a script before boot.- A
savetokenattribute the host put on<html>. - Draft mode, on a page that opted in (The contract).
- The
isAdminOfCurrentResourcecookie (hyperclay.com, Hyperclay Local). It grants permission, soclay.canEditis true, and the page still opens in view: the person opens it for editing withclay.setMode("edit")(How the mode is decided).
The rung is where a free document lives, so the details matter:
?editmode=falseis view mode, and nothing below it changes that.?editmode=trueedits the original only with the owner cookie, and only on a page that draft mode would otherwise claim. A query parameter on its own never grants writes to the host: an anonymous visitor with?editmode=truestays in draft mode.- The owner cookie alone is not enough either. The signed in owner gets the same draft as everyone else until they open the page with
?editmode=true, and the owner chip (The download prompt and the intro area) is their way there. viewonly, a stale token,window.clayEditModeor asavetokenthe host stamped on<html>all turn draft mode off. The first two leave it in view mode; the last two switch the load to edit mode against the host, which still decides who may write (How the mode is decided). On aviewonlydocument or one with a stale token,?editmode=trueis not draft mode either: it is the same edit mode as before, saving to a host that refuses the write.- Draft mode needs all four of the plugin name in
plugins=, a valid slot, anhttp:orhttps:page and anapirules tag. That tag also has to carrydata-rules-version="1"and hold JSON that parses; a newer version or unreadable JSON leaves the page out of draft mode with one console warning naming the problem. Onfile:it never applies, which is exactly why the download is a real file. <html draftmode="true">says which mode this load is in, besideeditmodeandpageowner(The root attributes), and is removed from every save.
What runs in draft mode#
clay.isDraftMode is true, and everything else follows from it:
- The plugins that talk to the host are dropped, even when the page names them:
sync,live,upload,quickcrop,cms,people,wire,ai-edit,demoandsource.clay.upload,clay.cms,clay.conflictsandclay.sourceare absent, and code that guards them (clay.upload?.(...)) behaves as it does for a viewer. The[refetch-on-save]attribute is dropped too, since it would fetch its element from the host again after every save. - The save lane is the browser.
clay.save()and autosave both run,savestatusmoves as usual, and the save answersmsgType: "success"withmsg: "Saved in this browser". A storage failure answers the 507 path withcode: "draft-storage"and firesclay:drafts-store-failed; the next good write clears it. - No request reaches the host. The download link goes to the host’s download page, which holds the email sign-up. The prompt’s own stylesheet is a Google Fonts link.
clay.readData()andclay.writeData()refuse. Both reject withcode: "DataUnavailableInDraft": the saved file belongs to the owner, and this visitor’s work is in their browser, so there is nothing for them to read or write there. A page that calls them in draft mode gets that refusal instead of a request (Import and export).clay.visitorDraftsis the plugin’s whole surface:
| Member | What it is |
|---|---|
active | true when the plugin found a slot and the rules tag, false otherwise |
slot | the slot from <html visitor-drafts="..."> |
key | the localStorage key, clay:visitor-drafts:<slot> |
hasDraft() | is there a stored draft for this slot |
restored() | { restored, hadDraft, parked } for this load, or null when the plugin is not active |
saveNow() | write the page to the slot now; true when it landed, false when it did not |
reset() | delete this slot and reload the page; nothing outside draft mode |
onRestore(fn) | call fn(detail) at once when the restore already ran, otherwise on clay:drafts-restored |
editOriginal() | navigate to this page with ?editmode=true |
backToDraft() | navigate back with editmode removed |
downloadUrl() | the download link the prompt is using, with src |
source | the ?src= this slot remembers, or null (The download prompt and the intro area) |
- Three events on
document:clay:drafts-restoredwith{ restored, hadDraft, parked },clay:drafts-savedwith{ data }, andclay:drafts-store-failedwith{ error }.
Restoring and redrawing#
The restore runs while ClayJS boots, before clay.ready resolves, and applies the stored JSON to the live page with applyData. sapjs and any code that waits for clay.ready therefore see the restored values, not the authored ones, and sapjs re-derives on the mutations the restore makes, so a count or a total painted in markup is right on the first paint after it. Load clay.js before sap.js for that: sapjs mounts when clay.ready resolves, and a page that mounts it first paints the authored values with no later mutation to correct them (sap.js: records, views and totals in markup).
Code that drew before clay.ready has two ways to hear about the restore: listen for clay:drafts-restored on document, or call clay.visitorDrafts.onRestore(fn), which runs fn at once when the restore already happened. Either way the detail is { restored, hadDraft, parked }.
A draft that no longer fits the page is never overwritten. When the stored JSON is not { v, data } (garbage, a bare array, a missing data), the raw value is moved to localStorage["clay:visitor-drafts:<slot>:unreadable:<ISO timestamp>"], the slot is cleared, and the page loads as authored with parked: true in the detail. When the write is refused only for keys the rules do not know, those keys are dropped and the rest is applied, so adding a field to the rules never costs a visitor their work.
The download prompt and the intro area#
Both are runtime chrome: clay="editor-ui" and sap-ignore, built by the plugin on every load. Neither is in the file, neither is saved or synced, and neither is read by sapjs.
Both remember themselves per slot in localStorage["clay:visitor-drafts:<slot>:ui"], as { "v": 1, "prompt": { "shown": ..., "dismissed": ..., "uses": ..., "downloaded": ... }, "intro": { "done": null | "closed" | "scrolled" }, "src": ... }. Clearing that key shows the intro and the prompt again.
The intro is authored per app as a <template visitor-intro>. The template holds the author’s whole strip: its markup and any <style> it needs. The plugin clones it into a section at the very top of the page body, before the app’s own content, and adds a quiet close (x) in the corner. The intro leaves for good when the visitor closes it or scrolls it past the top of the viewport; the ui memory records "closed" or "scrolled" as the reason.
<template visitor-intro>
<p>One paragraph on what the app does and who it is for.</p>
</template>
When there is a release video, add it as the first element of the template, and no <video> before then:
<video src="/_/uploads/assets/kanban/release.mp4" poster="/_/uploads/assets/kanban/release-poster.jpg" controls playsinline preload="metadata"></video>
An element inside the template with [visitor-intro-start] scrolls to the app, and one with [visitor-intro-dismiss] closes the intro the same way the x does. A deep link (a URL with a #...) skips the intro so a shared link opens where it points, a draft skips it too, and ?intro=1 forces it.
The prompt needs nothing from the app but its name: a small tab at the bottom right (“This app is free to keep”) that opens into a card holding the download link and “Don’t show again”, with a minimize button in its corner. The tab’s X and the card’s minimize button leave a small “Keep” button with the download icon that reopens the download card. That choice survives reload. It opens itself once after the visitor has touched three fields. “Don’t show again” stops those reminders for that slot but keeps the icon, including for previously dismissed notices. A storage failure opens it in its error state, and the next good save clears it. In that error state the download link does not go to the host, whose download page would read the stored copy that just failed to be written: the plugin builds the file from the live page in the browser instead, named after the page file with .htmlclay at the end.
The download link is /_/download<path>?as=htmlclay, with the page’s path (/_/download?as=htmlclay for /) and &src=<src> when this slot has one. clay.visitorDrafts.downloadUrl() returns it. ?src= is first touch: the first valid value the page sees for this slot is kept and rides into the download link and every signup. Valid values match /^[a-z0-9][a-z0-9-]{0,39}$/.
The owner chip shows only to the signed in owner, at the bottom left: it says whether this load is their private draft (“Edit the original”) or the original (“Back to my draft”) and switches with clay.visitorDrafts.editOriginal() and backToDraft().
Migrating an app’s own storage#
Not supported yet: the plugin reads no migration hook, so an app that kept its earlier data under its own localStorage key has to move those values into the page from its own code, on the first load after the switch.
A complete example#
examples/free-reading-list.html. Plugins visitor-drafts,indicator&exclude=richclay, satellites clay-options and sap.js. A reading list: a title, an author and a status select per book, one scalar goal for the year, and sapjs counting the books that are read. It is the shortest file that carries every item of the contract, and tests/unit/guide-examples/free-document.test.js runs it end to end: edit a title, add a book, check the slot, then load the file again with that storage and check the books and the counts come back. The record is authored in the saved form, with its viewmode: attributes. The reading option of a status select carries no selected: it is the first option, which is the default, and the row that is read marks it.
<!DOCTYPE html>
<html lang="en" visitor-drafts="reading-list-v1" autosave>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="application-name" content="Reading list">
<title>Reading list</title>
<script type="application/json" data-rules-name="api" data-rules-version="1">
{
"goal": "#goal@value",
"books": ["#books > li[item]:not([cms-template])", {
"id": "@data-id",
"title": ".title",
"author": ".author",
"status": "select@value"
}]
}
</script>
<style>
html:not([editmode="true"]) [show-when\:editmode="true"] { display: none }
[cms-template] { display: none !important }
:root { color-scheme: light dark; --bg: #f3f0e8; --ink: #26241f; --muted: #706b61; --line: #dbd5c8; --accent: #3d6f72 }
@media (prefers-color-scheme: dark) { :root { --bg: #1c1e21; --ink: #e9e5dd; --muted: #a39e94; --line: #3b4046; --accent: #82b6b8 } }
body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 system-ui, sans-serif }
main { max-width: 640px; margin: 0 auto; padding: 32px 20px 64px }
h1 { margin: 0 0 8px; font-size: 26px }
.count { color: var(--muted) }
.count output { font-variant-numeric: tabular-nums; color: var(--ink) }
#books { list-style: none; padding: 0; margin: 20px 0 8px }
#books > li { display: flex; gap: 8px; align-items: baseline; padding: 6px 0; border-bottom: 1px solid var(--line) }
#books .title { flex: 1 1 auto; font-weight: 500 }
#books .author { color: var(--muted); font-size: 14px }
button { font: inherit; color: inherit; background: none; border: 1px solid var(--line); border-radius: 6px; padding: 2px 10px; cursor: pointer }
input[type="number"] { font: inherit; color: inherit; background: transparent; border: 1px solid var(--line); border-radius: 4px; padding: 2px 6px; width: 5em; text-align: right }
select { font: inherit; color: inherit; background: transparent; border: 1px solid var(--line); border-radius: 4px; padding: 2px 4px }
.feedback { margin-top: 28px; font-size: 14px; color: var(--muted) }
[contenteditable]:focus { outline: 2px solid var(--accent); border-radius: 3px }
</style>
</head>
<body>
<main id="app" sap>
<h1 editmode:contenteditable inert-contenteditable="plaintext-only">Reading list</h1>
<p class="count">Books read: <output clay="no-dirty" calc:read="state.books.filter(b => b.status === 'read').length" text="state.read">1</output> of <output clay="no-dirty" calc:total="state.books.length" text="state.total">2</output></p>
<p><label for="goal">Books to read this year</label> <input id="goal" type="number" min="0" step="1" bind="goal" persist viewmode:readonly readonly value="24"></p>
<ul id="books" items="books">
<li item cms-template data-id=""><span class="title"></span><span class="author"></span><select bind="status" persist><option>reading</option><option>read</option></select></li>
<li item data-id="b-mmm"><span class="title" editmode:contenteditable inert-contenteditable="plaintext-only">The Mythical Man-Month</span> <span class="author" editmode:contenteditable inert-contenteditable="plaintext-only">Fred Brooks</span> <select bind="status" persist viewmode:disabled disabled><option>reading</option><option selected>read</option></select> <button type="button" show-when:editmode="true" trigger-remove aria-label="Remove book">×</button></li>
<li item data-id="b-sicp"><span class="title" editmode:contenteditable inert-contenteditable="plaintext-only">Structure and Interpretation of Computer Programs</span> <span class="author" editmode:contenteditable inert-contenteditable="plaintext-only">Abelson and Sussman</span> <select bind="status" persist viewmode:disabled disabled><option>reading</option><option>read</option></select> <button type="button" show-when:editmode="true" trigger-remove aria-label="Remove book">×</button></li>
</ul>
<p><button type="button" show-when:editmode="true" trigger-add="books">+ Add book</button></p>
<p class="feedback"><a href="mailto:hello@clayjs.com?subject=Reading%20list">Tell me what this app is missing</a></p>
<template items="books">
<li item data-id="">
<span class="title" editmode:contenteditable contenteditable="plaintext-only"></span>
<span class="author" editmode:contenteditable contenteditable="plaintext-only"></span>
<select bind="status" persist><option>reading</option><option>read</option></select>
<button type="button" show-when:editmode="true" trigger-remove aria-label="Remove book">×</button>
</li>
</template>
</main>
<template visitor-intro>
<p>A reading list that lives in your browser. Add books, set the ones you have read, and keep a goal for the year. Nothing leaves this machine unless you download the file.</p>
</template>
<script src="https://clayjs.com/v1/clay.js?plugins=visitor-drafts,indicator&exclude=richclay"></script>
<script src="https://clayjs.com/v1/clay-options.js"></script>
<script src="https://clayjs.com/v1/sap.js"></script>
</body>
</html>