@panphora/clayjs 1.11.2 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ import { PROMPT_CSS } from "./prompt-style.js";
2
+
3
+ const FONT_HREF = "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500&family=IBM+Plex+Sans:wght@400;500;600&family=Newsreader:opsz,wght@6..72,500&display=swap";
4
+
5
+ const ASIDE = `<aside class="hcp" id="hcp" hidden aria-label="Download this app" data-hcp-state="tab" data-hcp-reason="open" sap-ignore clay="editor-ui">
6
+ <section class="hcp-card" id="hcp-card" role="dialog" aria-modal="false" aria-labelledby="hcp-title">
7
+ <button type="button" class="hcp-x" data-hcp="minimize" aria-label="Minimize"><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M3 12h10v2H3z"/></svg></button>
8
+ <div class="hcp-kicker">Free download</div>
9
+ <h2 class="hcp-title" id="hcp-title"><span data-hcp-for="use">Your numbers are saved in this browser only.</span><span data-hcp-for="open">This app is free to keep.</span><span data-hcp-for="return">Your numbers are saved in this browser only.</span><span data-hcp-for="error">Your work isn't being saved in this browser.</span></h2>
10
+ <p class="hcp-body"><span data-hcp-for="open">Download it as one file and use it on your computer, no account needed.</span><span data-hcp-for="use">Download the file to keep them on your computer for good.</span><span data-hcp-for="return">Download the file to keep them on your computer for good.</span><span data-hcp-for="error">Storage is full or turned off here. Download the file to keep what you've done.</span></p>
11
+ <div class="hcp-file">
12
+ <svg width="26" height="30" viewBox="0 0 26 30" fill="none" aria-hidden="true"><path d="M2 1h15l7 7v21H2z" fill="var(--hcp-surface)" stroke="currentColor" stroke-width="2"/><path d="M17 1v7h7" stroke="currentColor" stroke-width="2"/><path d="M7 15h12M7 19h12M7 23h8" stroke="var(--hcp-muted)" stroke-width="2"/></svg>
13
+ <div><b class="hcp-name" data-hcp-name>This app</b><small>One file with the whole app and your numbers in it</small></div>
14
+ </div>
15
+ <p class="hcp-body">Open it with HTML Clay, a free app for Mac, Windows and Linux. It opens the file in a window and saves every change into it, so your numbers stay on your computer.</p>
16
+ <div class="hcp-actions">
17
+ <a class="hcp-btn hcp-btn--primary" data-hcp-link href="/_/download?as=htmlclay"><span><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M7 1h2v8.6l2.8-2.8 1.4 1.4L8 13.4 2.8 8.2l1.4-1.4L7 9.6zM2 13h12v2H2z"/></svg>Download free</span></a>
18
+ <button type="button" class="hcp-btn" data-hcp="minimize"><span>Not now</span></button>
19
+ </div>
20
+ <div class="hcp-foot"><small>Free, and the file is yours to keep.</small><button type="button" class="hcp-quiet" data-hcp="dismiss">Don&rsquo;t show again</button></div>
21
+ </section>
22
+ <div class="hcp-dock">
23
+ <button type="button" class="hcp-btn hcp-tab" data-hcp="open" aria-controls="hcp-card" aria-expanded="false" aria-label="This app is free to keep"><span><svg width="16" height="18" viewBox="0 0 16 18" fill="none" aria-hidden="true"><path d="M1.5 1h8.5l4.5 4.5V17h-13z" stroke="currentColor" stroke-width="2"/><path d="M10 1v5h4.5" stroke="currentColor" stroke-width="2"/><path d="M8 8v6M5.5 11.5 8 14l2.5-2.5" stroke="currentColor" stroke-width="1.8"/></svg><span class="hcp-tab__label">This app is free to keep</span></span></button>
24
+ <button type="button" class="hcp-x" data-hcp="dismiss" aria-label="Hide this for good"><svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><path d="M1.4 0 6 4.6 10.6 0 12 1.4 7.4 6 12 10.6 10.6 12 6 7.4 1.4 12 0 10.6 4.6 6 0 1.4z"/></svg></button>
25
+ </div>
26
+ </aside>`;
27
+
28
+ export function mountPrompt({ memory, appName, link, onError }) {
29
+ if (!document.getElementById("hcp-style")) {
30
+ document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" clay="editor-ui" href="${FONT_HREF}">\n<style clay="editor-ui" id="hcp-style">${PROMPT_CSS}</style>`);
31
+ }
32
+ if (!document.getElementById("hcp")) document.body.insertAdjacentHTML("beforeend", ASIDE);
33
+
34
+ const root = document.getElementById("hcp");
35
+ const ui = memory.get();
36
+ if (!ui.prompt || typeof ui.prompt !== "object") ui.prompt = {};
37
+ const mem = ui.prompt;
38
+ const USES_TO_SHOW = 3;
39
+ const IDLE_MS = 2500;
40
+ const TEXTY = /^(INPUT|TEXTAREA|SELECT)$/;
41
+
42
+ const linkEl = root.querySelector("[data-hcp-link]");
43
+ linkEl.href = link;
44
+
45
+ if (appName) root.querySelector("[data-hcp-name]").textContent = appName;
46
+
47
+ const tab = root.querySelector("[data-hcp=open]");
48
+ function set(state, reason) {
49
+ root.hidden = false;
50
+ root.setAttribute("data-hcp-state", state);
51
+ if (reason) root.setAttribute("data-hcp-reason", reason);
52
+ tab.setAttribute("aria-expanded", state === "card" ? "true" : "false");
53
+ if (state === "card") { mem.shown = true; memory.save(); }
54
+ lift();
55
+ }
56
+
57
+ function isHostField(el) {
58
+ return el && !root.contains(el) && (TEXTY.test(el.tagName) || el.isContentEditable);
59
+ }
60
+
61
+ let timer = null;
62
+ function maybeShow(reason) {
63
+ clearTimeout(timer);
64
+ timer = setTimeout(() => {
65
+ if (mem.shown || mem.dismissed || root.getAttribute("data-hcp-state") !== "tab") return;
66
+ const a = document.activeElement;
67
+ if (isHostField(a)) {
68
+ const r = a.getBoundingClientRect();
69
+ const w = Math.min(360, innerWidth - 32);
70
+ if (innerWidth < 700 || (r.right > innerWidth - w - 32 && r.bottom > innerHeight - 380)) return;
71
+ }
72
+ set("card", reason);
73
+ }, IDLE_MS);
74
+ }
75
+
76
+ const touched = [];
77
+ function onUse(e) {
78
+ if (root.contains(e.target) || !isHostField(e.target)) return;
79
+ if (touched.indexOf(e.target) < 0) touched.push(e.target);
80
+ mem.uses = Math.max(mem.uses || 0, touched.length);
81
+ if (!mem.shown) memory.save();
82
+ if (touched.length >= USES_TO_SHOW) maybeShow("use");
83
+ }
84
+
85
+ function onFocusIn(e) {
86
+ if (!isHostField(e.target)) return;
87
+ if (root.getAttribute("data-hcp-state") === "card") set(mem.shown ? "mini" : "tab");
88
+ if (innerWidth < 700) root.setAttribute("data-hcp-typing", "");
89
+ }
90
+ function onFocusOut() {
91
+ setTimeout(() => { if (!isHostField(document.activeElement)) root.removeAttribute("data-hcp-typing"); }, 50);
92
+ }
93
+
94
+ let liftFrame = 0;
95
+ function lift() {
96
+ if (liftFrame) return;
97
+ liftFrame = requestAnimationFrame(() => {
98
+ liftFrame = 0;
99
+ const y = innerHeight - 3, x = innerWidth - 24;
100
+ let raise = 0;
101
+ const hits = document.elementsFromPoint ? document.elementsFromPoint(x, y) : [];
102
+ for (let i = 0; i < hits.length; i++) {
103
+ for (let el = hits[i]; el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
104
+ if (root.contains(el) || el.closest("[data-hcp-preview]")) break;
105
+ const pos = getComputedStyle(el).position;
106
+ if (pos === "fixed" || pos === "sticky") { raise = Math.max(raise, Math.ceil(innerHeight - el.getBoundingClientRect().top)); break; }
107
+ }
108
+ }
109
+ root.style.setProperty("--hcp-lift", (raise > 0 && raise < innerHeight / 3 ? raise : 0) + "px");
110
+ });
111
+ }
112
+
113
+ function downloadCurrent() {
114
+ const getHTML = window.clay && window.clay.getHTML;
115
+ if (typeof getHTML !== "function" || typeof URL === "undefined" || typeof URL.createObjectURL !== "function") return false;
116
+ const segment = location.pathname.split("/").filter(Boolean).pop() || "index.html";
117
+ const objectUrl = URL.createObjectURL(new Blob([getHTML()], { type: "text/html" }));
118
+ const a = document.createElement("a");
119
+ a.href = objectUrl;
120
+ a.download = segment.replace(/\.html$/, ".htmlclay");
121
+ document.body.appendChild(a);
122
+ a.click();
123
+ a.remove();
124
+ setTimeout(() => URL.revokeObjectURL?.(objectUrl), 1000);
125
+ return true;
126
+ }
127
+
128
+ root.addEventListener("click", (e) => {
129
+ const t = e.target.closest("[data-hcp]");
130
+ if (t) {
131
+ const act = t.getAttribute("data-hcp");
132
+ if (act === "open") { set("card", "open"); linkEl.focus(); }
133
+ else if (act === "minimize") { set("mini"); tab.focus(); }
134
+ else if (act === "dismiss") {
135
+ if (root.getAttribute("data-hcp-reason") === "error") { set("mini"); tab.focus(); }
136
+ else { mem.dismissed = true; memory.save(); set("dismissed"); }
137
+ }
138
+ return;
139
+ }
140
+ if (e.target.closest("[data-hcp-link]")) {
141
+ if (root.getAttribute("data-hcp-reason") === "error") {
142
+ e.preventDefault();
143
+ downloadCurrent();
144
+ }
145
+ mem.downloaded = true; mem.shown = true; memory.save();
146
+ }
147
+ });
148
+ root.addEventListener("keydown", (e) => {
149
+ if (e.key === "Escape" && root.getAttribute("data-hcp-state") === "card") { set("mini"); tab.focus(); }
150
+ });
151
+
152
+ function showError() { set("card", "error"); }
153
+ function clearError() {
154
+ if (root.getAttribute("data-hcp-reason") === "error") root.setAttribute("data-hcp-reason", "use");
155
+ }
156
+ function hideForIntro(on) {
157
+ if (on) root.setAttribute("data-hcp-intro", "");
158
+ else root.removeAttribute("data-hcp-intro");
159
+ }
160
+
161
+ const api = { set, root, showError, clearError, hideForIntro };
162
+
163
+ if (mem.dismissed) { root.setAttribute("data-hcp-state", "dismissed"); return api; }
164
+
165
+ document.addEventListener("input", onUse, true);
166
+ document.addEventListener("change", onUse, true);
167
+ document.addEventListener("focusin", onFocusIn);
168
+ document.addEventListener("focusout", onFocusOut);
169
+ addEventListener("scroll", lift, { passive: true });
170
+ addEventListener("resize", lift);
171
+ document.addEventListener("transitionend", lift, true);
172
+
173
+ set(mem.shown ? "mini" : "tab");
174
+ if (!mem.shown && (mem.uses || 0) >= USES_TO_SHOW) maybeShow("return");
175
+
176
+ return api;
177
+ }
@@ -0,0 +1,21 @@
1
+ const SRC = /^[a-z0-9][a-z0-9-]{0,39}$/;
2
+
3
+ export function rememberSource(memory, search = window.location.search) {
4
+ const mem = memory.get();
5
+ let incoming = null;
6
+ try { incoming = new URLSearchParams(search).get("src"); } catch {}
7
+ if (!mem.src && incoming && SRC.test(incoming)) { mem.src = incoming; memory.save(); }
8
+ return mem.src;
9
+ }
10
+
11
+ export function downloadUrl(src, loc = window.location) {
12
+ let path = loc.pathname.replace(/\/+$/, "");
13
+ if (path && !/\.(html|htmlclay)$/.test(path)) path += ".html";
14
+ return `/_/download${path}?as=htmlclay${src ? `&src=${encodeURIComponent(src)}` : ""}`;
15
+ }
16
+
17
+ export function signupTag(slot, template) {
18
+ const explicit = template?.getAttribute("signup-tag");
19
+ const name = explicit || slot.replace(/-v\d+$/, "");
20
+ return `app-${name}`.slice(0, 50);
21
+ }
@@ -0,0 +1,342 @@
1
+ import { extractData, applyData, engine } from "../../vendor/hyper-html-api-data.vendor.js";
2
+ import { draftSlot, setDraftStoreFailed } from "../../core/draft-mode.js";
3
+
4
+ export const KEY_PREFIX = "clay:visitor-drafts:";
5
+ export const RECORD_VERSION = 1;
6
+
7
+ export function safeStorage() {
8
+ try { return window.localStorage; } catch { return null; }
9
+ }
10
+
11
+ const isPlain = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
12
+
13
+ function pathParts(label) {
14
+ const parts = [];
15
+ for (const match of label.matchAll(/([^.[\]]+)|\[(\d+)\]/g)) {
16
+ parts.push(match[2] !== undefined ? Number(match[2]) : match[1]);
17
+ }
18
+ return parts;
19
+ }
20
+
21
+ function dropPath(data, label) {
22
+ const parts = pathParts(label);
23
+ let node = data;
24
+ for (let i = 0; i < parts.length - 1; i++) {
25
+ node = node?.[parts[i]];
26
+ if (node == null || typeof node !== "object") return;
27
+ }
28
+ if (node && typeof node === "object") delete node[parts[parts.length - 1]];
29
+ }
30
+
31
+ // True when everything `wanted` says is present in `got`. Extra fields in `got`
32
+ // (a rule the draft predates) and key order do not matter.
33
+ export function fitsInto(wanted, got) {
34
+ if (Array.isArray(wanted)) {
35
+ return Array.isArray(got) && got.length === wanted.length && wanted.every((item, i) => fitsInto(item, got[i]));
36
+ }
37
+ if (isPlain(wanted)) {
38
+ return isPlain(got) && Object.keys(wanted).every((name) => fitsInto(wanted[name], got[name]));
39
+ }
40
+ return wanted === got;
41
+ }
42
+
43
+ function same(a, b) {
44
+ return fitsInto(a, b) && fitsInto(b, a);
45
+ }
46
+
47
+ const hasIds = (rows) => Array.isArray(rows)
48
+ && rows.every((row) => isPlain(row) && (typeof row.id === "string" || typeof row.id === "number"))
49
+ && new Set(rows.map((row) => row.id)).size === rows.length;
50
+
51
+ // Fields a draft predates take the page's current value, matched by row id, so
52
+ // the engine pairs rows by what the visitor saved rather than by position.
53
+ function complete(draft, page, top = false) {
54
+ if (isPlain(draft) && isPlain(page)) {
55
+ const out = { ...draft };
56
+ for (const name of Object.keys(page)) {
57
+ if (name in draft) out[name] = complete(draft[name], page[name]);
58
+ else if (!top) out[name] = page[name];
59
+ }
60
+ return out;
61
+ }
62
+ if (hasIds(draft) && hasIds(page)) {
63
+ const byId = new Map(page.map((row) => [row.id, row]));
64
+ return draft.map((row) => (byId.has(row.id) ? complete(row, byId.get(row.id)) : row));
65
+ }
66
+ return draft;
67
+ }
68
+
69
+ // Three-way merge of this tab's value with another tab's, from the value both
70
+ // started at. Top-level keys and nested objects merge per field. A list both tabs
71
+ // changed is a conflict, since a card moved between lists or a reorder cannot be
72
+ // told apart from a delete and an add: this tab's value wins.
73
+ export function merge3(base, mine, theirs) {
74
+ if (same(mine, theirs) || same(theirs, base)) return { value: mine, conflict: false };
75
+ if (same(mine, base)) return { value: theirs, conflict: false };
76
+ if (isPlain(mine) && isPlain(theirs)) {
77
+ const value = {};
78
+ let conflict = false;
79
+ for (const name of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
80
+ const part = merge3(isPlain(base) ? base[name] : undefined, mine[name], theirs[name]);
81
+ conflict ||= part.conflict;
82
+ if (part.value !== undefined) value[name] = part.value;
83
+ }
84
+ return { value, conflict };
85
+ }
86
+ return { value: mine, conflict: true };
87
+ }
88
+
89
+ function storageFailure(doc, error) {
90
+ setDraftStoreFailed(true);
91
+ doc.dispatchEvent(new CustomEvent("clay:drafts-store-failed", { detail: { error } }));
92
+ const failure = new Error("Couldn't save in this browser. Storage is full or turned off.");
93
+ failure.code = "draft-storage";
94
+ return failure;
95
+ }
96
+
97
+ export function createStore({ doc = document, storage = safeStorage(), now = () => new Date() } = {}) {
98
+ const slot = draftSlot(doc);
99
+ const key = KEY_PREFIX + slot;
100
+ const rules = engine.findRulesIn(doc, "api").rules;
101
+
102
+ let lastRaw = null;
103
+ let base = null;
104
+ let unparked = null;
105
+ let stopped = false;
106
+
107
+ function getRaw() {
108
+ try { return storage ? storage.getItem(key) : null; } catch { return null; }
109
+ }
110
+
111
+ function parse(raw) {
112
+ try {
113
+ const parsed = JSON.parse(raw);
114
+ if (isPlain(parsed) && parsed.v === RECORD_VERSION && isPlain(parsed.data)) return parsed;
115
+ } catch {}
116
+ return null;
117
+ }
118
+
119
+ function read() {
120
+ const raw = getRaw();
121
+ if (raw == null) return null;
122
+ return { raw, record: parse(raw) };
123
+ }
124
+
125
+ function live() {
126
+ return extractData(doc, rules);
127
+ }
128
+
129
+ // What a save would store right now: persisted controls keep typing in their
130
+ // live value, which only the save capture copies into the markup.
131
+ function captured() {
132
+ const getHTML = window.clay?.getHTML;
133
+ if (typeof getHTML !== "function") return live();
134
+ return extractData(new DOMParser().parseFromString(getHTML.call(window.clay), "text/html"), rules);
135
+ }
136
+
137
+ function storedKeys() {
138
+ try {
139
+ if (typeof storage.keys === "function") return storage.keys();
140
+ const names = [];
141
+ for (let i = 0; i < storage.length; i++) names.push(storage.key(i));
142
+ return names;
143
+ } catch {
144
+ return [];
145
+ }
146
+ }
147
+
148
+ function snapshot() {
149
+ try { return JSON.stringify(captured()); } catch { return null; }
150
+ }
151
+
152
+ function park(raw, { keepSlot = false } = {}) {
153
+ const kept = storedKeys().find((name) => name.startsWith(key + ":unreadable:") && storage.getItem(name) === raw);
154
+ if (kept) {
155
+ unparked = null;
156
+ if (!keepSlot) { try { storage.removeItem(key); } catch {} }
157
+ return kept;
158
+ }
159
+ const parkedKey = key + ":unreadable:" + now().toISOString();
160
+ try {
161
+ storage.setItem(parkedKey, raw);
162
+ } catch {
163
+ unparked = raw;
164
+ console.warn("visitor-drafts: this browser's saved copy no longer fits the page, and there is no room to keep it aside; nothing will be saved over it");
165
+ return null;
166
+ }
167
+ unparked = null;
168
+ if (!keepSlot) { try { storage.removeItem(key); } catch {} }
169
+ console.warn(keepSlot
170
+ ? "visitor-drafts: part of this browser's saved copy no longer fits the page; the original was kept under"
171
+ : "visitor-drafts: this browser's saved copy no longer fits the page; it was kept under", parkedKey);
172
+ return parkedKey;
173
+ }
174
+
175
+ // Applies `data` to a copy of the page first, so a draft that does not fit never
176
+ // touches the live page. Top-level keys that do not read back are dropped.
177
+ function fit(data) {
178
+ const wanted = complete(JSON.parse(JSON.stringify(data)), live(), true);
179
+ const dropped = [];
180
+ for (let attempt = 0; attempt < 4; attempt++) {
181
+ const trial = doc.cloneNode(true);
182
+ try {
183
+ applyData(trial, wanted, rules);
184
+ } catch (error) {
185
+ const unknown = error?.details?.unknownKeys ?? error?.unknownKeys;
186
+ if (Array.isArray(unknown) && unknown.length) {
187
+ for (const label of unknown) dropPath(wanted, label);
188
+ continue;
189
+ }
190
+ const failing = Object.keys(wanted).filter((name) => {
191
+ try { applyData(doc.cloneNode(true), { [name]: wanted[name] }, rules); return false; } catch { return true; }
192
+ });
193
+ if (!failing.length) return null;
194
+ for (const name of failing) delete wanted[name];
195
+ dropped.push(...failing);
196
+ continue;
197
+ }
198
+ const got = extractData(trial, rules);
199
+ const misfits = Object.keys(wanted).filter((name) => !fitsInto(wanted[name], got[name]));
200
+ if (!misfits.length) return { data: wanted, dropped };
201
+ for (const name of misfits) delete wanted[name];
202
+ dropped.push(...misfits);
203
+ }
204
+ return null;
205
+ }
206
+
207
+ function parkedResult(raw) {
208
+ const parkedKey = park(raw);
209
+ if (!parkedKey) lastRaw = raw;
210
+ base = live();
211
+ return parkedKey
212
+ ? { restored: false, hadDraft: true, parked: true }
213
+ : { restored: false, hadDraft: true, parked: false, blocked: true };
214
+ }
215
+
216
+ function restore() {
217
+ const found = read();
218
+ if (!found) {
219
+ base = live();
220
+ return { restored: false, hadDraft: false };
221
+ }
222
+ if (!found.record) return parkedResult(found.raw);
223
+ const fitted = fit(found.record.data);
224
+ if (!fitted || !Object.keys(fitted.data).length) return parkedResult(found.raw);
225
+ applyData(doc, fitted.data, rules);
226
+ lastRaw = found.raw;
227
+ base = live();
228
+ if (fitted.dropped.length) {
229
+ const parkedKey = park(found.raw, { keepSlot: true });
230
+ return { restored: true, hadDraft: true, data: fitted.data, partial: true, parked: Boolean(parkedKey), blocked: !parkedKey };
231
+ }
232
+ return { restored: true, hadDraft: true, data: fitted.data };
233
+ }
234
+
235
+ // `base` may hold fields this page cannot read (carried from a tab on newer
236
+ // rules), so only what the page captures is compared.
237
+ function changed() {
238
+ if (stopped) return false;
239
+ try { return !fitsInto(captured(), base); } catch { return true; }
240
+ }
241
+
242
+ // One copy, the latest: a save that would replace another tab's change keeps it
243
+ // here first, and does not save at all when there is no room for it.
244
+ function keepConflict(raw) {
245
+ const conflictKey = key + ":conflict";
246
+ try {
247
+ storage.setItem(conflictKey, raw);
248
+ } catch (error) {
249
+ throw storageFailure(doc, error);
250
+ }
251
+ console.warn("visitor-drafts: another tab changed the same thing; its copy was kept under", conflictKey);
252
+ }
253
+
254
+ // Another tab may have written since this tab last read or wrote: what only it
255
+ // changed is taken. Fields the stored record has and this page's rules lack (a
256
+ // tab opened before the owner added a field) are carried through, at any depth.
257
+ function merge(mine) {
258
+ const current = getRaw();
259
+ const record = current == null ? null : parse(current);
260
+ if (!record) return { data: mine, stored: null };
261
+ let data = mine;
262
+ if (current !== lastRaw) {
263
+ const merged = merge3(base, mine, record.data);
264
+ if (merged.conflict) keepConflict(current);
265
+ data = merged.value;
266
+ }
267
+ data = complete(data, record.data);
268
+ return { data, stored: same(data, record.data) ? current : null };
269
+ }
270
+
271
+ function adopt(data) {
272
+ const fitted = fit(data);
273
+ if (!fitted || !Object.keys(fitted.data).length) return null;
274
+ applyData(doc, fitted.data, rules);
275
+ try { window.Sap?.refresh?.(); } catch {}
276
+ doc.dispatchEvent(new CustomEvent("clay:drafts-restored", { detail: { restored: true, hadDraft: true, parked: false, external: true } }));
277
+ return fitted.data;
278
+ }
279
+
280
+ function write(html) {
281
+ if (stopped) return null;
282
+ if (unparked != null && park(unparked, { keepSlot: true }) === null) {
283
+ throw storageFailure(doc, new Error("an earlier saved copy could not be kept aside"));
284
+ }
285
+ const mine = extractData(new DOMParser().parseFromString(html, "text/html"), rules);
286
+ const current = getRaw();
287
+ if (fitsInto(mine, base) && (current === lastRaw || current == null)) {
288
+ setDraftStoreFailed(false);
289
+ doc.dispatchEvent(new CustomEvent("clay:drafts-saved", { detail: { data: mine, unchanged: true } }));
290
+ return mine;
291
+ }
292
+ const { data, stored } = merge(mine);
293
+ const raw = stored ?? JSON.stringify({ v: RECORD_VERSION, savedAt: now().toISOString(), data });
294
+ if (!stored) {
295
+ try {
296
+ if (!storage) throw new Error("no storage");
297
+ storage.setItem(key, raw);
298
+ } catch (error) {
299
+ throw storageFailure(doc, error);
300
+ }
301
+ }
302
+ const taken = Object.keys(mine).filter((name) => !fitsInto(mine[name], data[name]));
303
+ if (taken.length) adopt(Object.fromEntries(taken.map((name) => [name, data[name]])));
304
+ lastRaw = raw;
305
+ base = data;
306
+ setDraftStoreFailed(false);
307
+ doc.dispatchEvent(new CustomEvent("clay:drafts-saved", { detail: { data } }));
308
+ return data;
309
+ }
310
+
311
+ function writeLive() {
312
+ try { write(window.clay.getHTML()); return true; } catch { return false; }
313
+ }
314
+
315
+ function flush() {
316
+ if (stopped || !changed()) return true;
317
+ return writeLive();
318
+ }
319
+
320
+ // A storage event can arrive late, so read the slot itself rather than trusting
321
+ // the event's value. A tab with its own edit merges at its next save instead.
322
+ function adoptExternal() {
323
+ if (stopped) return false;
324
+ const raw = getRaw();
325
+ if (raw == null || raw === lastRaw || changed()) return false;
326
+ const record = parse(raw);
327
+ const adopted = record && adopt(record.data);
328
+ if (!adopted) return false;
329
+ lastRaw = raw;
330
+ base = { ...base, ...adopted };
331
+ return true;
332
+ }
333
+
334
+ function clear() {
335
+ stopped = true;
336
+ unparked = null;
337
+ setDraftStoreFailed(false);
338
+ try { storage.removeItem(key); } catch {}
339
+ }
340
+
341
+ return { slot, key, rules, read, restore, write, writeLive, flush, changed, adoptExternal, park, clear, snapshot };
342
+ }
@@ -0,0 +1,14 @@
1
+ export function createUiMemory(slot, storage) {
2
+ const key = `clay:visitor-drafts:${slot}:ui`;
3
+ const blank = () => ({ v: 1, prompt: {}, intro: { done: null }, src: null });
4
+ let mem = blank();
5
+ try {
6
+ const parsed = JSON.parse(storage?.getItem(key) || "null");
7
+ if (parsed && typeof parsed === "object" && parsed.v === 1) mem = { ...blank(), ...parsed };
8
+ } catch {}
9
+ return {
10
+ get: () => mem,
11
+ save() { try { storage?.setItem(key, JSON.stringify(mem)); } catch {} },
12
+ reset() { mem = blank(); try { storage?.removeItem(key); } catch {} },
13
+ };
14
+ }
@@ -0,0 +1,111 @@
1
+ import { isDraftMode, isEditMode, isOwner } from "../core/is-edit-mode.js";
2
+ import { hasSaveToken } from "../core/host-attrs.js";
3
+ import { draftSlot, setDraftTransport, setDraftFlush } from "../core/draft-mode.js";
4
+ import { createStore, safeStorage } from "./visitor-drafts/store.js";
5
+ import { createUiMemory } from "./visitor-drafts/ui-memory.js";
6
+ import { rememberSource, downloadUrl, signupTag } from "./visitor-drafts/source.js";
7
+ import { mountPrompt } from "./visitor-drafts/prompt.js";
8
+ import { mountIntro } from "./visitor-drafts/intro.js";
9
+ import { mountOwnerChip } from "./visitor-drafts/owner-chip.js";
10
+
11
+ const DOWNLOAD_URL = "/_/download?as=htmlclay";
12
+
13
+ let store = null;
14
+ let restoredDetail = null;
15
+ let downloadLink = DOWNLOAD_URL;
16
+ let source = null;
17
+
18
+ const slot = draftSlot();
19
+ const rulesTag = document.querySelector('script[data-rules-name~="api"]');
20
+
21
+ if (isDraftMode && slot && rulesTag) {
22
+ store = createStore();
23
+ setDraftTransport((html) => store.write(html));
24
+ const result = store.restore();
25
+ restoredDetail = { restored: result.restored, hadDraft: result.hadDraft, parked: Boolean(result.parked), ...(result.partial && { partial: true }), ...(result.blocked && { blocked: true }) };
26
+ document.dispatchEvent(new CustomEvent("clay:drafts-restored", { detail: restoredDetail }));
27
+ const memory = createUiMemory(slot, safeStorage());
28
+ source = rememberSource(memory);
29
+ downloadLink = downloadUrl(source);
30
+ const appName = document.querySelector('meta[name="application-name"]')?.content?.trim() || document.title.split(/:|\s\|\s/)[0].trim() || "This app";
31
+ const template = document.querySelector("template[visitor-intro]");
32
+ const start = () => {
33
+ const prompt = mountPrompt({ memory, appName, link: downloadLink });
34
+ mountIntro({ memory, template, appName, link: downloadLink, tag: signupTag(slot, template), source, hadDraft: result.hadDraft, prompt, snapshot: store.snapshot });
35
+ if (isOwner) mountOwnerChip();
36
+ document.addEventListener("clay:drafts-store-failed", () => prompt.showError());
37
+ document.addEventListener("clay:drafts-saved", () => prompt.clearError());
38
+ };
39
+ document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", start, { once: true }) : start();
40
+ setDraftFlush(() => store.flush());
41
+ window.addEventListener("storage", (event) => { if (event.key === store.key) store.adoptExternal(); });
42
+ const flush = () => store.flush();
43
+ document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") flush(); });
44
+ window.addEventListener("pagehide", flush);
45
+ } else if (!slot || !rulesTag) {
46
+ const missing = [];
47
+ if (!slot) missing.push('a visitor-drafts="<slot>" attribute on <html>');
48
+ if (!rulesTag) missing.push('a <script data-rules-name~="api"> rules tag');
49
+ console.warn(`visitor-drafts: this page keeps no draft (missing ${missing.join(" and ")})`);
50
+ } else if (isOwner && isEditMode && !hasSaveToken()) {
51
+ mountOwnerChip();
52
+ }
53
+
54
+ export const ready = Promise.resolve();
55
+
56
+ function editOriginal() {
57
+ const url = new URL(window.location.href);
58
+ url.searchParams.set("editmode", "true");
59
+ window.location.href = url.toString();
60
+ return true;
61
+ }
62
+
63
+ function backToDraft() {
64
+ const url = new URL(window.location.href);
65
+ url.searchParams.delete("editmode");
66
+ window.location.href = url.toString();
67
+ return true;
68
+ }
69
+
70
+ function inertVisitorDrafts() {
71
+ return {
72
+ active: false,
73
+ slot: null,
74
+ key: null,
75
+ hasDraft: () => false,
76
+ restored: () => null,
77
+ saveNow: () => false,
78
+ reset: () => false,
79
+ onRestore: () => false,
80
+ editOriginal,
81
+ backToDraft,
82
+ downloadUrl: () => DOWNLOAD_URL,
83
+ source: null,
84
+ };
85
+ }
86
+
87
+ export const visitorDrafts = store ? {
88
+ active: true,
89
+ slot: store.slot,
90
+ key: store.key,
91
+ hasDraft: () => Boolean(store.read()),
92
+ restored: () => restoredDetail,
93
+ saveNow: () => store.writeLive(),
94
+ reset() {
95
+ store.clear();
96
+ window.location.reload();
97
+ return true;
98
+ },
99
+ onRestore(fn) {
100
+ if (restoredDetail) {
101
+ fn(restoredDetail);
102
+ return true;
103
+ }
104
+ document.addEventListener("clay:drafts-restored", (event) => fn(event.detail), { once: true });
105
+ return false;
106
+ },
107
+ editOriginal,
108
+ backToDraft,
109
+ downloadUrl: () => downloadLink,
110
+ source,
111
+ } : inertVisitorDrafts();