@panphora/clayjs 1.5.3 → 1.6.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.
Files changed (45) hide show
  1. package/README.md +5 -3
  2. package/THIRD-PARTY-NOTICES.md +6 -0
  3. package/dist/clay.standalone.js +14965 -10714
  4. package/package.json +9 -4
  5. package/src/core/conflict-download.js +91 -0
  6. package/src/core/conflict-notice.js +720 -0
  7. package/src/core/conflict-presentation.js +301 -0
  8. package/src/core/save-conflict-notice.js +2 -192
  9. package/src/core/save-feedback.js +6 -0
  10. package/src/core/save.js +53 -1
  11. package/src/core/snapshot.js +21 -0
  12. package/src/core/stale-host-notice.js +21 -24
  13. package/src/core/unsaved-warning.js +35 -10
  14. package/src/lib/content-dom.js +4 -2
  15. package/src/lib/dirty-gate.js +9 -0
  16. package/src/lib/unsaved-state.js +55 -0
  17. package/src/loader-logic.js +26 -15
  18. package/src/loader.js +4 -3
  19. package/src/plugins/ai-edit.js +489 -179
  20. package/src/plugins/cms.js +12 -0
  21. package/src/plugins/indicator.js +35 -16
  22. package/src/plugins/quickcrop.js +103 -0
  23. package/src/plugins/richclay.js +21 -0
  24. package/src/plugins/source.js +13 -6
  25. package/src/plugins/wire.js +4 -0
  26. package/src/sync/conflict-revert.js +1138 -0
  27. package/src/sync/conflicts.js +159 -0
  28. package/src/sync/live-sync.js +130 -109
  29. package/src/sync/presence.js +42 -36
  30. package/src/sync/section-notice.js +53 -28
  31. package/src/ui/bevel-controls.js +277 -0
  32. package/src/ui/bevel-dialog.js +112 -0
  33. package/src/ui/bevel.js +247 -0
  34. package/src/ui/dialogs.js +63 -86
  35. package/src/ui/index.js +22 -11
  36. package/src/ui/modal.js +80 -387
  37. package/src/ui/skins/cms-toggle.js +15 -0
  38. package/src/ui/skins/cms.js +4 -0
  39. package/src/ui/skins/quickcrop.js +4 -0
  40. package/src/ui/skins/richclay.js +4 -0
  41. package/src/ui/toast.js +116 -401
  42. package/src/ui/vendor-skin.js +65 -0
  43. package/src/vendor/hyper-morph.vendor.js +4 -4
  44. package/src/vendor/quickcrop.vendor.js +14 -8
  45. package/src/vendor/richclay.vendor.js +22 -41
@@ -0,0 +1,720 @@
1
+ import { isEditMode } from "./is-edit-mode.js";
2
+ import { rowOf } from "./conflict-presentation.js";
3
+ import { downloadRecovery } from "./conflict-download.js";
4
+ import onDomReady from "../lib/dom-ready.js";
5
+ import { set, make } from "../lib/hostile-css.js";
6
+ import { isSaveConflicted } from "./save.js";
7
+ import { gateCaptureToken } from "../lib/dirty-gate.js";
8
+ import { reloadAfterDiscard } from "./unsaved-warning.js";
9
+ import { bevelButton, bevelIconButton, bevelSurface, bevelWell, bevelText, protectIcon, RUNTIME_ONLY } from "../ui/bevel-controls.js";
10
+ import { TOKENS, FONT_SANS, FONT_MONO } from "../ui/bevel.js";
11
+
12
+ // One notice for everything this tab holds that the page no longer shows: edits
13
+ // another save replaced (the ledger, clay.conflicts) and a save the host refused.
14
+ // It never decides for the person. Minimize, Escape, a save, a reconnect or a later
15
+ // frame leave every loss where it is; only Accept theirs (or Writer's own merge)
16
+ // acknowledges one, and only the ids the panel actually drew.
17
+ //
18
+ // Ledger changes draw on the next frame, not at once: a listener that claims its
19
+ // own records inside clay:sync-applied (Writer) must get there before the first
20
+ // draw, or the bar flashes a count that is not the person's to deal with.
21
+
22
+ export const WARN = "light-dark(#D08A1E, #E3A33F)";
23
+ const ARM_MS = 5000;
24
+ const TOAST_MS = 4000;
25
+ const DOUBLE_PRESS_MS = 500;
26
+
27
+ // The host names what moved the file when it knows. Anything unrecognised falls
28
+ // back to the phrase that is true in every case.
29
+ const SOURCES = {
30
+ "another-tab": "in another tab",
31
+ "another-person": "by someone else",
32
+ "an-agent": "by an agent",
33
+ };
34
+
35
+ const MINIMIZE_SVG = '<svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true"><path d="M2.5 9.5h7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="square"/></svg>';
36
+
37
+ const EYE_SVG = '<svg viewBox="0 0 256 256" width="16" height="16" fill="currentColor" aria-hidden="true">' + '<path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/>' + '</svg>';
38
+
39
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
40
+
41
+ const BLOCKED_ROW = "This spot changed again. Review the latest edit or download your copy.";
42
+
43
+ let view = "hidden";
44
+ let listOpen = false;
45
+ let renderedIds = [];
46
+ const seenIds = new Set();
47
+ const freshIds = new Set();
48
+ let refused = null;
49
+ let refusalGeneration = 0;
50
+ let armed = null;
51
+ let armTimer = null;
52
+ let busy = null;
53
+ let keepFailed = false;
54
+ let downloaded = false;
55
+ let downloadFailed = false;
56
+ const blockedRows = new Map();
57
+ let ledgerRevision = 0;
58
+ let frameQueued = false;
59
+ let checkRefusal = false;
60
+ let toastText = null;
61
+ let toastTimer = null;
62
+ let pendingFocus = null;
63
+ let armedAt = 0;
64
+ let armLost = false;
65
+ let keepAttempt = 0;
66
+ let lastAnnounced = null;
67
+ let panelEl = null;
68
+
69
+ let root = null;
70
+ let wellEl = null;
71
+ let keyed = new Map();
72
+
73
+ const ledger = () => window.clay?.conflicts || null;
74
+ const losses = () => ledger()?.list().filter((r) => !r.claimedBy && r.kind !== "apply-incomplete") ?? [];
75
+ const failures = () => ledger()?.list().filter((r) => !r.claimedBy && r.kind === "apply-incomplete") ?? [];
76
+
77
+ // A refused save goes first: saving is blocked until it is answered.
78
+ function mode() {
79
+ if (refused) return "refused";
80
+ if (losses().length) return "replaced";
81
+ if (failures().length) return "failed";
82
+ return null;
83
+ }
84
+
85
+ function el(tag, rules, text) {
86
+ const node = make(tag, rules, text);
87
+ if (rules[0] === "all:initial") set(node, "color-scheme", "inherit");
88
+ node.setAttribute("clay", RUNTIME_ONLY);
89
+ return node;
90
+ }
91
+
92
+ function key(node, name) {
93
+ keyed.set(name, node);
94
+ return node;
95
+ }
96
+
97
+ function schemeOf() {
98
+ const s = getComputedStyle(document.documentElement).colorScheme;
99
+ return s === "light" || s === "dark" ? s : "light dark";
100
+ }
101
+
102
+ // A phone keyboard shrinks the visual viewport but leaves fixed elements pinned to
103
+ // the layout viewport, which parks a bottom-anchored bar behind the keyboard at the
104
+ // exact moment somebody is typing. Lift it by the difference instead.
105
+ function place() {
106
+ if (!root) return;
107
+ const vv = window.visualViewport;
108
+ const lift = vv ? Math.max(0, window.innerHeight - vv.height - vv.offsetTop) : 0;
109
+ set(root, "bottom", `calc(${16 + lift}px + env(safe-area-inset-bottom,0px))`);
110
+ if (panelEl) set(panelEl, "max-height", `${Math.max(160, (vv?.height || window.innerHeight) - 32)}px`);
111
+ }
112
+
113
+ function build() {
114
+ root = el("div", [
115
+ "all:initial", "position:fixed", "left:50%", "transform:translateX(-50%)",
116
+ "z-index:2147483001", "display:none", "flex-direction:column", "align-items:center",
117
+ "max-width:calc(100vw - 24px)", `font:14px/1.5 ${FONT_SANS}`, `color:${TOKENS.ink}`,
118
+ "text-align:left",
119
+ ]);
120
+ root.setAttribute("data-clay-conflict", "");
121
+ document.body.appendChild(root);
122
+ window.visualViewport?.addEventListener("resize", place);
123
+ window.visualViewport?.addEventListener("scroll", place);
124
+ }
125
+
126
+ export function schedule() {
127
+ if (frameQueued) return;
128
+ frameQueued = true;
129
+ const run = () => { frameQueued = false; render(); };
130
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
131
+ else setTimeout(run, 16);
132
+ }
133
+
134
+ function render() {
135
+ if (checkRefusal) {
136
+ checkRefusal = false;
137
+ if (refused && !isSaveConflicted()) refused = null;
138
+ }
139
+ const m = mode();
140
+ if (!m) {
141
+ view = "hidden";
142
+ listOpen = false;
143
+ seenIds.clear();
144
+ freshIds.clear();
145
+ renderedIds = [];
146
+ disarm();
147
+ lastAnnounced = null;
148
+ armLost = false;
149
+ } else if (view === "hidden") {
150
+ view = "bar";
151
+ }
152
+ if (!root) {
153
+ if (view === "hidden" && !toastText) return;
154
+ if (!document.body) return;
155
+ build();
156
+ }
157
+ // A body replaced wholesale takes the root with it.
158
+ if (!root.isConnected && document.body) document.body.appendChild(root);
159
+
160
+ const active = document.activeElement;
161
+ let focusKey = null;
162
+ for (const [name, node] of keyed) if (node === active) focusKey = name;
163
+ const scroll = wellEl ? wellEl.scrollTop : 0;
164
+ keyed = new Map();
165
+ wellEl = null;
166
+ panelEl = null;
167
+ root.replaceChildren();
168
+ set(root, "color-scheme", schemeOf());
169
+
170
+ if (toastText) {
171
+ root.append(drawToast(toastText));
172
+ } else if (view === "bar") {
173
+ root.append(drawBar(m));
174
+ } else if (view === "panel") {
175
+ root.append(drawPanel(m));
176
+ }
177
+ set(root, "display", toastText || view !== "hidden" ? "flex" : "none");
178
+ if (view !== "hidden") {
179
+ const section = document.querySelector("[data-clay-section-notice]");
180
+ if (section) set(section, "display", "none");
181
+ }
182
+ if (wellEl) wellEl.scrollTop = scroll;
183
+
184
+ const want = pendingFocus || focusKey;
185
+ if (want && keyed.has(want)) keyed.get(want).focus({ preventScroll: true });
186
+ // A request for a control the toast is standing in for waits until the toast goes.
187
+ if (!toastText) pendingFocus = null;
188
+ place();
189
+ }
190
+
191
+ function dot() {
192
+ return el("span", ["all:initial", "display:inline-block", "flex:none", "width:8px", "height:8px", "border-radius:50%", `background:${WARN}`]);
193
+ }
194
+
195
+ function refusedSource() {
196
+ return SOURCES[refused?.changedBy] || null;
197
+ }
198
+
199
+ function barMessage(m, n) {
200
+ if (m === "refused") {
201
+ const named = refusedSource();
202
+ if (!named && refused.afterTimeout) {
203
+ return "This page changed elsewhere, possibly by your own save that timed out. Your edits here are safe.";
204
+ }
205
+ return `This page changed ${named || "elsewhere"}. Your edits here are safe.`;
206
+ }
207
+ if (m === "failed") return "A sync update did not finish. Your edits here are safe.";
208
+ return `Another edit replaced ${plural(n, "change")}.`;
209
+ }
210
+
211
+ function titleOf(m, n) {
212
+ if (m === "refused") return `This page changed ${refusedSource() || "elsewhere"}`;
213
+ if (m === "failed") return "A sync update did not finish";
214
+ return `Another edit replaced ${plural(n, "change")}`;
215
+ }
216
+
217
+ function drawToast(text) {
218
+ const t = bevelSurface("div", ["display:block", `border-left:3px solid ${TOKENS.teal}`, "padding:10px 14px", "width:max-content", "max-width:calc(100vw - 24px)"]);
219
+ t.setAttribute("role", "status");
220
+ t.textContent = text;
221
+ return t;
222
+ }
223
+
224
+ function drawBar(m) {
225
+ const n = m === "failed" ? failures().length : losses().length;
226
+ const bar = bevelSurface("div", ["display:flex", "flex-wrap:wrap", "align-items:center", "gap:10px 12px", "padding:8px 8px 8px 14px", "width:max-content", "max-width:calc(100vw - 24px)"]);
227
+ // A redraw rebuilds the bar; only a new message is announced again.
228
+ const message = barMessage(m, n);
229
+ if (message !== lastAnnounced) {
230
+ bar.setAttribute("role", m === "refused" ? "alert" : "status");
231
+ lastAnnounced = message;
232
+ }
233
+ bar.append(dot(), bevelText("span", [], message), key(bevelButton("Review", { onClick: openPanel }), "review"));
234
+ return bar;
235
+ }
236
+
237
+ function action(label, name, { variant = "default", onClick, push = false } = {}) {
238
+ const phone = window.innerWidth < 480;
239
+ const extra = phone
240
+ ? ["flex:1 1 100%", ...(variant === "primary" ? ["order:-1"] : [])]
241
+ : (push ? ["margin-left:auto"] : []);
242
+ return key(bevelButton(label, { variant, onClick, extra }), name);
243
+ }
244
+
245
+ function lede(text) {
246
+ return bevelText("div", ["display:block", "padding:0 16px 12px", `color:${TOKENS["ink-2"]}`, "font-size:13.5px"], text);
247
+ }
248
+
249
+ function drawPanel(m) {
250
+ const rows = m === "replaced" ? losses() : m === "failed" ? failures() : [];
251
+ const held = m === "refused" ? losses() : rows;
252
+ if (seenIds.size) for (const r of rows) if (!seenIds.has(r.id)) freshIds.add(r.id);
253
+ for (const id of [...freshIds]) if (!rows.some((r) => r.id === id)) freshIds.delete(id);
254
+
255
+ const title = titleOf(m, rows.length);
256
+ const maxHeight = Math.max(160, (window.visualViewport?.height || window.innerHeight) - 32);
257
+ const panel = bevelSurface("div", ["display:flex", "flex-direction:column", "width:520px", "max-width:calc(100vw - 24px)", `max-height:${maxHeight}px`]);
258
+ panelEl = panel;
259
+ panel.setAttribute("role", "dialog");
260
+ panel.setAttribute("aria-label", title);
261
+
262
+ const head = el("div", ["all:initial", "display:flex", "align-items:center", "gap:8px", "padding:12px 12px 8px 16px", `font:14px/1.5 ${FONT_SANS}`, `color:${TOKENS.ink}`]);
263
+ const t = key(bevelText("b", ["font-weight:600", "font-size:14.5px", "outline:none"], title), "title");
264
+ t.setAttribute("tabindex", "-1");
265
+ head.append(dot(), t);
266
+ if (m === "replaced" && freshIds.size) {
267
+ head.append(bevelText("span", [
268
+ `font:600 10.5px/1 ${FONT_MONO}`, "letter-spacing:.12em", "text-transform:uppercase", "padding:3px 6px",
269
+ `color:color-mix(in srgb, ${TOKENS.brass} 80%, ${TOKENS.ink})`, `background:${TOKENS["brass-soft"]}`,
270
+ ], `${freshIds.size} new`));
271
+ }
272
+ const minimizeWrap = el("span", ["all:initial", "display:inline-flex", "margin-left:auto"]);
273
+ const minimize = key(bevelIconButton(MINIMIZE_SVG, { label: "Minimize", onClick: minimizePanel }), "minimize");
274
+ minimize.title = "Minimize to the bar. Nothing is accepted until you choose.";
275
+ minimizeWrap.append(minimize);
276
+ head.append(minimizeWrap);
277
+ panel.append(head);
278
+
279
+ if (m === "refused") {
280
+ panel.append(lede("Your edits here are safe and not saved yet; nothing will be overwritten until you choose. Their version is not on this page, so there is no list of edits to compare. Keep yours, which saves over theirs, or accept theirs, which reloads the page and drops your unsaved edits."));
281
+ if (held.length) {
282
+ panel.append(lede(`${plural(held.length, "earlier replaced edit")} ${held.length === 1 ? "is" : "are"} also held in this tab. Download my copy includes them.`));
283
+ }
284
+ if (keepFailed) panel.append(lede("That did not save either. Your edits here are still here."));
285
+ if (armLost) panel.append(lede("Something changed on the page after your first press, so nothing was dropped. Press Accept theirs again if you still want their version."));
286
+ if (armed) {
287
+ const warn = el("div", [
288
+ "all:initial", "display:block", "margin:0 16px 12px", "padding:8px 10px",
289
+ `border-left:3px solid ${WARN}`, `background:color-mix(in srgb, ${WARN} 14%, transparent)`,
290
+ `color:${TOKENS.ink}`, `font:13px/1.5 ${FONT_SANS}`,
291
+ ]);
292
+ warn.append(el("b", ["all:initial", "font:inherit", "font-weight:700", "color:inherit"], "This drops your unsaved edits."), document.createTextNode(" Download your copy first if you might want them. Press again to confirm."));
293
+ panel.append(warn);
294
+ }
295
+ } else if (m === "failed") {
296
+ panel.append(lede("Part of an update from elsewhere could not be applied, so this page may be missing some of it. Your edits here are safe. Download a copy of the page as you had it, or accept to keep the page as it is."));
297
+ } else {
298
+ panel.append(lede("The page is showing the other edit. This stays until you choose. You can also save a copy of the page as you had it."));
299
+ }
300
+
301
+ const actions = el("div", ["all:initial", "display:flex", "flex-wrap:wrap", "gap:8px", "padding:0 16px 14px"]);
302
+ const dl = action(downloadFailed ? "Try again" : downloaded ? "Downloaded" : "Download my copy", "download", { onClick: onDownload });
303
+ actions.append(dl);
304
+ if (m === "refused") {
305
+ const keep = action(busy === "overwrite" ? "Saving…" : keepFailed ? "Try again" : "Keep mine", "keep", { onClick: onKeep });
306
+ keep.setDisabled(busy === "overwrite");
307
+ actions.append(keep, action(armed ? "Yes, drop my edits" : "Accept theirs", "accept", { variant: "primary", onClick: onRefusedAccept, push: true }));
308
+ } else {
309
+ if (m === "replaced") {
310
+ const pending = ledger().hasPendingApply();
311
+ const put = action(pending ? "Finishing the incoming edit…" : busy === "revert" ? "Putting back…" : "Revert to mine", "revert", { onClick: onRevert });
312
+ put.setDisabled(pending || busy === "revert");
313
+ actions.append(put);
314
+ }
315
+ actions.append(action("Accept theirs", "accept", { variant: "primary", onClick: onAccept, push: true }));
316
+ }
317
+ panel.append(actions);
318
+
319
+ if (m === "replaced") {
320
+ if (!listOpen) {
321
+ const more = el("div", ["all:initial", "display:flex", "align-items:center", "gap:8px", `border-top:1px solid ${TOKENS.line}`, "padding:8px 10px 8px 16px", `color:${TOKENS.muted}`, `font:13px/1.5 ${FONT_SANS}`]);
322
+ const seeWrap = el("span", ["all:initial", "display:inline-flex", "margin-left:auto"]);
323
+ seeWrap.append(key(bevelButton("See the edits", { small: true, onClick: toggleList }), "list"));
324
+ more.append(el("span", ["all:initial", "font:inherit", "color:inherit"], plural(rows.length, "edit")), seeWrap);
325
+ panel.append(more);
326
+ } else {
327
+ panel.append(drawWell(rows));
328
+ }
329
+ }
330
+
331
+ renderedIds = held.map((r) => r.id);
332
+ for (const r of rows) seenIds.add(r.id);
333
+ return panel;
334
+ }
335
+
336
+ function drawWell(rows) {
337
+ // The sunk ground, not the recess: the recess's lighter blue glares against the panel in dark mode.
338
+ const well = bevelWell(["flex:1 1 auto", "margin:0 16px 12px", "min-height:0", "overflow:auto", `background:${TOKENS.sunk}`, `border:1px solid ${TOKENS["line-2"]}`]);
339
+ const head = el("div", ["all:initial", "position:sticky", "top:0", "z-index:1", "display:flex", "align-items:center", "gap:8px", "padding:8px 12px", `background:${TOKENS.sunk}`, `border-bottom:1px solid ${TOKENS["line-2"]}`, `color:${TOKENS.muted}`, `font:600 10.5px/1 ${FONT_MONO}`, "letter-spacing:.12em", "text-transform:uppercase"]);
340
+ const hideWrap = el("span", ["all:initial", "display:inline-flex", "margin-left:auto"]);
341
+ hideWrap.append(key(bevelButton("Hide", { small: true, variant: "quiet", onClick: toggleList }), "list"));
342
+ head.append(el("span", ["all:initial", "font:inherit", "color:inherit", "letter-spacing:inherit", "text-transform:inherit"], "Replaced edits"), hideWrap);
343
+ well.append(head);
344
+ rows.forEach((r, i) => well.append(buildRow(r, i)));
345
+ wellEl = well;
346
+ return well;
347
+ }
348
+
349
+ function valueLine(label, value, { mine }) {
350
+ const cells = [bevelText("span", [`color:${TOKENS.faint}`, `font-family:${FONT_SANS}`], label)];
351
+ const box = bevelText("span", [
352
+ "display:-webkit-box", "-webkit-line-clamp:2", "-webkit-box-orient:vertical", "overflow:hidden",
353
+ "overflow-wrap:anywhere",
354
+ ...(mine
355
+ ? [`color:${TOKENS.ink}`]
356
+ : [`color:${TOKENS.muted}`]),
357
+ ]);
358
+ const full = (value.cutBefore ? "…" : "") + value.before + value.hit + value.after + (value.cutAfter ? "…" : "");
359
+ box.title = full;
360
+ if (value.before) box.append(bevelText("span", [`color:${TOKENS.faint}`], (value.cutBefore ? "…" : "") + value.before));
361
+ const hit = bevelText("span", mine
362
+ ? [`background:color-mix(in srgb, ${WARN} 18%, transparent)`, "padding:0 4px"]
363
+ : (value.before || value.after
364
+ ? ["text-decoration:underline", `text-decoration-color:color-mix(in srgb, ${WARN} 70%, transparent)`, "text-underline-offset:3px"]
365
+ : []), value.hit || "(empty)");
366
+ box.append(hit);
367
+ if (value.after) box.append(bevelText("span", [`color:${TOKENS.faint}`], value.after + (value.cutAfter ? "…" : "")));
368
+ cells.push(box);
369
+ return cells;
370
+ }
371
+
372
+ function buildRow(record, index) {
373
+ const row = rowOf(record);
374
+ const box = bevelText("div", ["display:block", "padding:8px 12px 10px", ...(index ? [`border-top:1px solid ${TOKENS.line}`] : [])]);
375
+ const where = bevelText("div", ["display:flex", "align-items:center", "gap:4px", "font-size:12.5px", "font-weight:600", `color:${TOKENS["ink-2"]}`, "margin-bottom:4px"]);
376
+ where.append(bevelText("span", [], row.name));
377
+ if (row.target && row.target.isConnected) where.append(key(eyeButton(row), `eye:${record.id}`));
378
+ box.append(where);
379
+ if (row.sentence) box.append(bevelText("div", ["display:block", "font-size:13px", `color:${TOKENS["ink-2"]}`], row.sentence));
380
+ if (blockedRows.has(record.id)) box.append(bevelText("div", ["display:block", "font-size:13px", "margin-top:4px", `color:${WARN}`], blockedRows.get(record.id)));
381
+ if (row.yours) {
382
+ const grid = bevelText("div", ["display:grid", "grid-template-columns:44px 1fr", "gap:3px 10px", "align-items:start", `font:12.5px/1.5 ${FONT_MONO}`, ...(row.sentence ? ["margin-top:4px"] : [])]);
383
+ grid.append(...valueLine("Yours", row.yours, { mine: true }));
384
+ if (row.now) grid.append(...valueLine("Now", row.now, { mine: false }));
385
+ box.append(grid);
386
+ }
387
+ return box;
388
+ }
389
+
390
+ function eyeButton(row) {
391
+ const base = ["all:initial", "color-scheme:inherit", "box-sizing:border-box", "cursor:pointer", "display:inline-grid", "place-items:center", "width:22px", "height:20px", `color:${TOKENS.ink}`];
392
+ const b = el("button", base);
393
+ b.type = "button";
394
+ b.innerHTML = EYE_SVG;
395
+ protectIcon(b.firstElementChild, 16);
396
+ b.setAttribute("aria-label", `Show ${row.name} on page`);
397
+ b.title = "Show on page";
398
+ const paint = (extra) => { b.style.cssText = ""; for (const r of [...base, ...extra]) { const i = r.indexOf(":"); b.style.setProperty(r.slice(0, i), r.slice(i + 1), "important"); } };
399
+ b.addEventListener("pointerenter", () => paint([`background:color-mix(in srgb, ${WARN} 16%, transparent)`]));
400
+ b.addEventListener("pointerleave", () => paint([]));
401
+ b.addEventListener("focus", () => paint([`outline:2px solid ${TOKENS.brass}`, "outline-offset:1px"]));
402
+ b.addEventListener("blur", () => paint([]));
403
+ b.addEventListener("click", () => ring(row.target));
404
+ return b;
405
+ }
406
+
407
+ // The eye: scroll the element into view and draw a ring over it. The ring is ours
408
+ // and runtime-only; the page's element gets no attribute, class or style, so the
409
+ // dirty gate and the saved bytes see nothing.
410
+ let ringEl = null;
411
+ let ringTimer = null;
412
+ let ringTarget = null;
413
+
414
+ function dropRing() {
415
+ clearTimeout(ringTimer);
416
+ window.removeEventListener("scroll", placeRing, true);
417
+ window.removeEventListener("resize", placeRing);
418
+ ringEl?.remove();
419
+ ringEl = null;
420
+ ringTarget = null;
421
+ }
422
+
423
+ function placeRing() {
424
+ if (!ringEl || !ringTarget) return;
425
+ const r = ringTarget.getBoundingClientRect();
426
+ set(ringEl, "left", `${r.left}px`);
427
+ set(ringEl, "top", `${r.top}px`);
428
+ set(ringEl, "width", `${r.width}px`);
429
+ set(ringEl, "height", `${r.height}px`);
430
+ }
431
+
432
+ function ring(target) {
433
+ dropRing();
434
+ if (!target || !target.isConnected) return;
435
+ const still = !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
436
+ const behavior = still ? "instant" : "smooth";
437
+ // The notice sits at the bottom of the viewport: centre the target in what is left
438
+ // above it, or the outline lands on the panel instead of the text.
439
+ const top = root?.getBoundingClientRect().top ?? 0;
440
+ const cover = root && top > 0 ? Math.max(0, window.innerHeight - top) : 0;
441
+ if (!cover) {
442
+ target.scrollIntoView?.({ block: "center", behavior });
443
+ } else {
444
+ // Centre first, so a page whose own scroller holds the target still brings it
445
+ // up; then move the window the rest of the way. A target taller than the free
446
+ // space shows its top instead.
447
+ target.scrollIntoView?.({ block: "center", behavior: "instant" });
448
+ const r = target.getBoundingClientRect();
449
+ const free = window.innerHeight - cover;
450
+ const shift = r.height > free - 24 ? r.top - 12 : r.top + r.height / 2 - free / 2;
451
+ window.scrollBy?.({ top: shift, behavior });
452
+ }
453
+ ringTarget = target;
454
+ ringEl = el("div", ["all:initial", "color-scheme:inherit", "position:fixed", "pointer-events:none", "z-index:2147483001", `outline:2px solid ${WARN}`, "outline-offset:3px", `background:color-mix(in srgb, ${WARN} 14%, transparent)`]);
455
+ document.body.appendChild(ringEl);
456
+ placeRing();
457
+ window.addEventListener("scroll", placeRing, true);
458
+ window.addEventListener("resize", placeRing);
459
+ ringTimer = setTimeout(dropRing, 2500);
460
+ }
461
+
462
+ function openPanel() {
463
+ view = "panel";
464
+ pendingFocus = "title";
465
+ render();
466
+ }
467
+
468
+ function minimizePanel() {
469
+ dropRing();
470
+ view = "bar";
471
+ disarm();
472
+ pendingFocus = "review";
473
+ render();
474
+ }
475
+
476
+ function toggleList() {
477
+ listOpen = !listOpen;
478
+ if (listOpen) freshIds.clear();
479
+ render();
480
+ }
481
+
482
+ // The ids copied before anything else runs: a record that landed after the draw was never shown, so this choice does not cover it, and a record something claimed since then is its owner's to settle.
483
+ function onAccept() {
484
+ const l = ledger();
485
+ const ids = renderedIds.filter((id) => { const r = l?.get(id); return r && !r.claimedBy; });
486
+ if (l && ids.length) l.acknowledge(ids, { reason: "accepted" });
487
+ view = mode() ? "bar" : "hidden";
488
+ pendingFocus = "review";
489
+ schedule();
490
+ }
491
+
492
+ // Revert to mine. The ids are the rendered ones, copied before anything runs, as
493
+ // Accept reads them. The module that does the work loads on first use, and only
494
+ // here, where the sync plugin has published the ledger. What it could not put back
495
+ // keeps its row, with the reason under it, until the ledger moves on.
496
+ async function onRevert() {
497
+ const l = ledger();
498
+ if (busy || !l || l.hasPendingApply()) return;
499
+ const ids = renderedIds.filter((id) => { const r = l.get(id); return r && !r.claimedBy; });
500
+ if (!ids.length) return;
501
+ busy = "revert";
502
+ render();
503
+ // Focus that lands outside the notice while the revert and its save run is the
504
+ // person moving on; Review takes focus afterwards only if it stayed here.
505
+ let stayed = true;
506
+ const track = (e) => { stayed = !!root?.contains(e.target); };
507
+ document.addEventListener("focus", track, true);
508
+ let result;
509
+ try {
510
+ const { revertConflicts } = await import("../sync/conflict-revert.js");
511
+ result = await revertConflicts(ids);
512
+ } catch (err) {
513
+ console.error("[clay] Revert to mine did not finish", err);
514
+ result = { revertedIds: [], blockedIds: ids, saveResult: null };
515
+ } finally {
516
+ document.removeEventListener("focus", track, true);
517
+ }
518
+ busy = null;
519
+ for (const id of result.blockedIds) blockedRows.set(id, BLOCKED_ROW);
520
+ view = mode() ? "bar" : "hidden";
521
+ pendingFocus = stayed ? "review" : null;
522
+ const saving = result.saveResult && (result.saveResult.ok || result.saveResult.msg === "Save already in progress");
523
+ if (result.revertedIds.length && saving) toast(`Put back ${plural(result.revertedIds.length, "change")}. Saving.`);
524
+ else render();
525
+ }
526
+
527
+ let downloadedTimer = null;
528
+
529
+ // Download my copy. Nothing here acknowledges or releases a record.
530
+ function onDownload() {
531
+ const l = ledger();
532
+ // A refusal ends in a reload that takes every record with it, so its copy carries
533
+ // all of them; a replaced notice covers the rows it drew.
534
+ const records = refused ? (l?.list() ?? []) : renderedIds.map((id) => l?.get(id)).filter(Boolean);
535
+ const ok = downloadRecovery(records, { refused: !!refused });
536
+ downloadFailed = !ok;
537
+ downloaded = ok;
538
+ clearTimeout(downloadedTimer);
539
+ if (ok) downloadedTimer = setTimeout(() => { downloaded = false; schedule(); }, 1200);
540
+ render();
541
+ }
542
+
543
+ function disarm() {
544
+ clearTimeout(armTimer);
545
+ armTimer = null;
546
+ armed = null;
547
+ }
548
+
549
+ async function onKeep() {
550
+ if (busy) return;
551
+ const attempt = ++keepAttempt;
552
+ busy = "overwrite";
553
+ keepFailed = false;
554
+ disarm();
555
+ render();
556
+ let result;
557
+ try {
558
+ result = await window.clay?.save?.overwrite?.();
559
+ } catch {
560
+ result = { ok: false };
561
+ }
562
+ // A newer attempt, a new refusal or a landed save already settled this one.
563
+ if (attempt !== keepAttempt || busy !== "overwrite") return;
564
+ if (!result?.ok && isSaveConflicted()) {
565
+ // Left showing: the save still has not happened.
566
+ busy = null;
567
+ keepFailed = true;
568
+ }
569
+ schedule();
570
+ }
571
+
572
+ // Two presses. The first is a choice, not a confirmation, and this is the one
573
+ // control here that throws away work nothing can bring back. The second only
574
+ // counts if nothing changed in between: a new refusal, a new loss or new typing
575
+ // each disarm it.
576
+ function onRefusedAccept() {
577
+ if (!armed) {
578
+ armed = { refusalGeneration, ledgerRevision, dirtyGeneration: gateCaptureToken().gen };
579
+ armedAt = Date.now();
580
+ armLost = false;
581
+ clearTimeout(armTimer);
582
+ armTimer = setTimeout(() => { disarm(); schedule(); }, ARM_MS);
583
+ render();
584
+ return;
585
+ }
586
+ // The second half of a double click is not a second decision.
587
+ if (Date.now() - armedAt < DOUBLE_PRESS_MS) return;
588
+ const token = armed;
589
+ const current = () =>
590
+ armed === token &&
591
+ token.refusalGeneration === refusalGeneration &&
592
+ token.ledgerRevision === ledgerRevision &&
593
+ token.dirtyGeneration === gateCaptureToken().gen;
594
+ if (!reloadAfterDiscard({ isCurrent: current, reload: () => window.location.reload() })) {
595
+ disarm();
596
+ armLost = true;
597
+ render();
598
+ }
599
+ }
600
+
601
+ export function toast(text) {
602
+ toastText = text;
603
+ clearTimeout(toastTimer);
604
+ toastTimer = setTimeout(() => { toastText = null; schedule(); }, TOAST_MS);
605
+ render();
606
+ }
607
+
608
+ function onLedgerChanged() {
609
+ ledgerRevision++;
610
+ if (armed) disarm();
611
+ for (const id of [...blockedRows.keys()]) if (!ledger()?.get(id)) blockedRows.delete(id);
612
+ schedule();
613
+ }
614
+
615
+ function onSaveConflict(e) {
616
+ const d = e?.detail || {};
617
+ refused = { generation: ++refusalGeneration, changedBy: d.changedBy, afterTimeout: !!d.afterTimeout, etag: d.etag ?? null };
618
+ // The refusal answers the save a toast may still be announcing; it replaces the
619
+ // toast at once.
620
+ toastText = null;
621
+ clearTimeout(toastTimer);
622
+ disarm();
623
+ busy = null;
624
+ keepFailed = false;
625
+ keepAttempt++;
626
+ armLost = false;
627
+ schedule();
628
+ }
629
+
630
+ // An old save event cannot erase a newer refusal: only a tab that is no longer
631
+ // holding one clears it. Losses are untouched; a save never acknowledges.
632
+ function onSaveSettled(e) {
633
+ if (isSaveConflicted()) {
634
+ // save.js fires clay:save-saved just before it releases the hold, in the same
635
+ // task; look again once it has.
636
+ if (e.type === "clay:save-saved") queueMicrotask(() => { if (!isSaveConflicted()) onSaveSettled(e); });
637
+ return;
638
+ }
639
+ const keptMine = busy === "overwrite" && e.type === "clay:save-saved";
640
+ if (refused || busy) {
641
+ refused = null;
642
+ busy = null;
643
+ keepFailed = false;
644
+ armLost = false;
645
+ disarm();
646
+ }
647
+ if (keptMine) toast("Saved your version over theirs.");
648
+ else schedule();
649
+ }
650
+
651
+ // On a manual page, conflictResolvedBySync can release the hold without firing
652
+ // clay:save-conflict-resolved. Checked a frame later, after the apply finished.
653
+ function onSyncApplied() {
654
+ if (!refused) return;
655
+ checkRefusal = true;
656
+ schedule();
657
+ }
658
+
659
+ // conflictResolvedBySync released the hold on a manual page with unsaved work:
660
+ // no save happened and no clay:save-conflict-resolved fired, but their version
661
+ // is on this page now, so the refusal is over.
662
+ function onConflictReleased() {
663
+ if (!refused || isSaveConflicted()) return;
664
+ refused = null;
665
+ busy = null;
666
+ keepFailed = false;
667
+ armLost = false;
668
+ disarm();
669
+ schedule();
670
+ }
671
+
672
+ function onKeydown(e) {
673
+ if (e.key !== "Escape") return;
674
+ if (armed) {
675
+ e.preventDefault();
676
+ disarm();
677
+ render();
678
+ return;
679
+ }
680
+ if (view !== "panel") return;
681
+ const a = document.activeElement;
682
+ if (a && a !== document.body && !root?.contains(a)) return;
683
+ e.preventDefault();
684
+ minimizePanel();
685
+ }
686
+
687
+ function onInput(e) {
688
+ if (!armed || root?.contains(e.target)) return;
689
+ disarm();
690
+ schedule();
691
+ }
692
+
693
+ // Focus that leaves the notice while a toast stands in for it is the person moving
694
+ // on: the control the toast replaced does not take focus back afterwards.
695
+ function onFocus(e) {
696
+ if (toastText && pendingFocus && !root?.contains(e.target)) pendingFocus = null;
697
+ }
698
+
699
+ function init() {
700
+ if (!isEditMode) return;
701
+ document.addEventListener("clay:sync-conflicts-changed", onLedgerChanged);
702
+ // An apply beginning or ending changes only whether Revert may run.
703
+ document.addEventListener("clay:unsaved-state-changed", schedule);
704
+ document.addEventListener("clay:save-conflict", onSaveConflict);
705
+ document.addEventListener("clay:save-saved", onSaveSettled);
706
+ document.addEventListener("clay:save-conflict-resolved", onSaveSettled);
707
+ document.addEventListener("clay:sync-applied", onSyncApplied);
708
+ document.addEventListener("clay:save-conflict-released", onConflictReleased);
709
+ document.addEventListener("keydown", onKeydown);
710
+ document.addEventListener("input", onInput, true);
711
+ document.addEventListener("focus", onFocus, true);
712
+ if (ledger()?.size) schedule();
713
+ }
714
+
715
+ onDomReady(init);
716
+
717
+ export const _test = {
718
+ state: () => ({ view, listOpen, renderedIds: renderedIds.slice(), refused, armed, busy, ledgerRevision }),
719
+ render,
720
+ };