@panphora/clayjs 1.5.3 → 1.6.1

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 (46) hide show
  1. package/README.md +5 -3
  2. package/THIRD-PARTY-NOTICES.md +6 -0
  3. package/dist/clay.standalone.js +15041 -10657
  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-core.js +35 -2
  10. package/src/core/save-feedback.js +6 -0
  11. package/src/core/save.js +157 -3
  12. package/src/core/snapshot.js +21 -0
  13. package/src/core/stale-host-notice.js +21 -24
  14. package/src/core/unsaved-warning.js +64 -16
  15. package/src/lib/content-dom.js +4 -2
  16. package/src/lib/dirty-gate.js +37 -1
  17. package/src/lib/unsaved-state.js +55 -0
  18. package/src/loader-logic.js +26 -15
  19. package/src/loader.js +10 -3
  20. package/src/plugins/ai-edit.js +489 -179
  21. package/src/plugins/cms.js +12 -0
  22. package/src/plugins/indicator.js +35 -16
  23. package/src/plugins/quickcrop.js +103 -0
  24. package/src/plugins/richclay.js +21 -0
  25. package/src/plugins/source.js +13 -6
  26. package/src/plugins/wire.js +4 -0
  27. package/src/sync/conflict-revert.js +1138 -0
  28. package/src/sync/conflicts.js +159 -0
  29. package/src/sync/live-sync.js +130 -109
  30. package/src/sync/presence.js +42 -36
  31. package/src/sync/section-notice.js +53 -28
  32. package/src/ui/bevel-controls.js +277 -0
  33. package/src/ui/bevel-dialog.js +112 -0
  34. package/src/ui/bevel.js +247 -0
  35. package/src/ui/dialogs.js +63 -86
  36. package/src/ui/index.js +22 -11
  37. package/src/ui/modal.js +80 -387
  38. package/src/ui/skins/cms-toggle.js +15 -0
  39. package/src/ui/skins/cms.js +4 -0
  40. package/src/ui/skins/quickcrop.js +4 -0
  41. package/src/ui/skins/richclay.js +4 -0
  42. package/src/ui/toast.js +116 -401
  43. package/src/ui/vendor-skin.js +65 -0
  44. package/src/vendor/hyper-morph.vendor.js +4 -4
  45. package/src/vendor/quickcrop.vendor.js +14 -8
  46. package/src/vendor/richclay.vendor.js +22 -41
package/src/core/save.js CHANGED
@@ -18,15 +18,22 @@ import {
18
18
  replacePageWith as replacePageWithCore,
19
19
  addDocumentTransform,
20
20
  isSaveInProgress,
21
- saveFateIsUnknown
21
+ saveFateIsUnknown,
22
+ withKeepalive
22
23
  } from "./save-core.js";
23
24
  import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
24
- import { seedEtag } from "./etag.js";
25
+ import { seedEtag, lastSeenEtag } from "./etag.js";
25
26
  import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
27
+ import { hasUnsavedState } from "../lib/unsaved-state.js";
26
28
  import { autosaveActive } from "../lib/autosave-state.js";
27
29
  import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
28
30
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
29
31
  import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
32
+ // A deliberate import cycle: unsaved-warning reads this module's saved baseline, and
33
+ // flush asks the SAME bytes question the close warning asks, through `bytesUnsaved`.
34
+ // Both read across it at call time, never while a module is evaluating, so neither
35
+ // side sees a half-built module.
36
+ import { bytesUnsaved } from "./unsaved-warning.js";
30
37
 
31
38
  // Keep this library's own root state out of the saved bytes.
32
39
  //
@@ -206,6 +213,45 @@ export function resumeAutosave() {
206
213
  savePageThrottled();
207
214
  }
208
215
 
216
+ // ============================================
217
+ // THE PREVIEW HOLD
218
+ // ============================================
219
+ //
220
+ // While an AI edit's rewrite is on screen and not yet kept, the DOM holds words the
221
+ // person has not accepted. Every save would write them, the explicit ones included,
222
+ // so for that window no save goes out at all. A save asked for meanwhile is
223
+ // remembered and runs on release; Keep releases without the replay because it saves
224
+ // right after.
225
+ let previewHold = 0;
226
+ let previewMissed = false;
227
+
228
+ export function holdAllSaves() {
229
+ previewHold++;
230
+ }
231
+
232
+ export function releaseAllSaves({ replay = true } = {}) {
233
+ if (previewHold === 0) return;
234
+ previewHold--;
235
+ if (previewHold > 0) return;
236
+ const missed = previewMissed;
237
+ previewMissed = false;
238
+ if (missed && replay) savePage();
239
+ }
240
+
241
+ export function savesHeld() {
242
+ return previewHold > 0;
243
+ }
244
+
245
+ function heldForPreview(callback, resolve) {
246
+ if (previewHold === 0) return false;
247
+ previewMissed = true;
248
+ clearExplicitSave();
249
+ const skipped = skipped_('Waiting for the AI edit to be kept or reverted');
250
+ callback(skipped);
251
+ resolve(skipped);
252
+ return true;
253
+ }
254
+
209
255
  // ============================================
210
256
  // THE CONFLICT HOLD
211
257
  // ============================================
@@ -257,7 +303,8 @@ export function isSaveConflicted() {
257
303
  * missed save. On a manual-save page with unsaved work the root stays in
258
304
  * 'conflict' and the notice stays up until the person saves, since nothing has
259
305
  * been written yet. Not clay:save-saved: no save happened, and that event runs
260
- * every [onaftersave] handler.
306
+ * every [onaftersave] handler. Either way clay:save-conflict-released fires, so a
307
+ * notice can drop the refusal it was showing.
261
308
  */
262
309
  export function conflictResolvedBySync(etag) {
263
310
  if (!conflictHold) return;
@@ -273,6 +320,7 @@ export function conflictResolvedBySync(etag) {
273
320
  detail: { timestamp: Date.now() }
274
321
  }));
275
322
  }
323
+ document.dispatchEvent(new CustomEvent('clay:save-conflict-released', { detail: { etag: etag ?? null } }));
276
324
  resumeAutosave();
277
325
  }
278
326
 
@@ -391,6 +439,8 @@ export function savePage(callback = () => {}) {
391
439
  return resolve(skipped);
392
440
  }
393
441
 
442
+ if (heldForPreview(callback, resolve)) return;
443
+
394
444
  // A save is already on the wire. Remember that a newer state is waiting rather
395
445
  // than dropping it: the in-flight request carries the older bytes, and if no
396
446
  // further mutation happens to retrigger autosave, the newer ones would never
@@ -476,6 +526,8 @@ export function savePageForce(callback = () => {}) {
476
526
  return resolve(skipped);
477
527
  }
478
528
 
529
+ if (heldForPreview(callback, resolve)) return;
530
+
479
531
  if (isSaveInProgress()) {
480
532
  pendingSave = true;
481
533
  const skipped = skipped_('Save already in progress');
@@ -515,6 +567,101 @@ export function savePageForce(callback = () => {}) {
515
567
  });
516
568
  }
517
569
 
570
+ // Resolve on the next save that settles, or reject once the deadline has passed.
571
+ // Every settled state is one, not just success: a save that failed is an answer about
572
+ // the page's bytes too, and the caller decides what to do with it.
573
+ function settled(deadline) {
574
+ const events = ['clay:save-saved', 'clay:save-error', 'clay:save-offline', 'clay:save-conflict'];
575
+ return new Promise((resolve, reject) => {
576
+ let timer = null;
577
+ const cleanup = () => {
578
+ clearTimeout(timer);
579
+ for (const name of events) document.removeEventListener(name, onSettle);
580
+ };
581
+ const onSettle = () => {
582
+ cleanup();
583
+ resolve();
584
+ };
585
+ const remaining = deadline - Date.now();
586
+ if (remaining <= 0) {
587
+ reject(Object.assign(new Error('Save timed out.'), { state: 'failed' }));
588
+ return;
589
+ }
590
+ timer = setTimeout(() => {
591
+ cleanup();
592
+ reject(Object.assign(new Error('Save timed out.'), { state: 'failed' }));
593
+ }, remaining);
594
+ for (const name of events) document.addEventListener(name, onSettle);
595
+ });
596
+ }
597
+
598
+ function flushError(message, state) {
599
+ return Object.assign(new Error(message), { state });
600
+ }
601
+
602
+ // Resolve when `name` fires on document, or reject at the deadline.
603
+ function nextEvent(name, deadline) {
604
+ return new Promise((resolve, reject) => {
605
+ const remaining = deadline - Date.now();
606
+ if (remaining <= 0) return reject(flushError('Save timed out.', 'failed'));
607
+ const timer = setTimeout(() => {
608
+ document.removeEventListener(name, onEvent);
609
+ reject(flushError('Save timed out.', 'failed'));
610
+ }, remaining);
611
+ const onEvent = () => { clearTimeout(timer); resolve(); };
612
+ document.addEventListener(name, onEvent, { once: true });
613
+ });
614
+ }
615
+
616
+ // Save now; answer only when the host has accepted the bytes the page holds now.
617
+ // Resolves { state: 'view' | 'clean' | 'saved', etag }. Rejects with an Error carrying
618
+ // state 'failed' (including a timeout), 'conflict', or 'blocked' (the bytes are on the
619
+ // host but the page holds work a save cannot write, registered through
620
+ // clay.registerUnsavedState; only a person can resolve that). Never overwrites.
621
+ // `keepalive` is a transport hint honoured under 60 KB of UTF-8.
622
+ export async function flushSave({ keepalive = false, timeoutMs = 15000 } = {}) {
623
+ if (!isEditMode) return { state: 'view', etag: null };
624
+ const deadline = Date.now() + timeoutMs;
625
+ let timer = null;
626
+ const timeout = new Promise((_, reject) => {
627
+ timer = setTimeout(() => reject(flushError('Save timed out.', 'failed')), Math.max(0, timeoutMs));
628
+ });
629
+ try {
630
+ return await Promise.race([flushBytes(keepalive, deadline), timeout]);
631
+ } catch (err) {
632
+ if (err && typeof err.state === 'string') throw err;
633
+ throw flushError(err?.message || 'Save failed', 'failed');
634
+ } finally {
635
+ clearTimeout(timer);
636
+ }
637
+ }
638
+
639
+ async function flushBytes(keepalive, deadline) {
640
+ // Load-time module churn is not the person's work: autosave never saves it, and
641
+ // neither does a flush. Wait for the baseline to settle (at most MAX_SETTLE_MS).
642
+ if (!baselineSettled()) await nextEvent('clay:baseline-settled', deadline);
643
+
644
+ let saved = false;
645
+ let etag = null;
646
+ for (let attempt = 0; attempt < 4; attempt++) {
647
+ if (isSaveInProgress()) await settled(deadline);
648
+ if (isSaveConflicted()) throw flushError('This document has a conflict to resolve.', 'conflict');
649
+ const token = gateCaptureToken();
650
+ if (!bytesUnsaved()) {
651
+ gateClearIfUnchanged(token);
652
+ if (hasUnsavedState()) throw flushError('This document has unsaved work a save cannot write.', 'blocked');
653
+ return { state: saved ? 'saved' : 'clean', etag: etag ?? lastSeenEtag() };
654
+ }
655
+ const result = await withKeepalive(keepalive, () => savePage());
656
+ // The lane was busy (a drained pending save is on the wire): wait it out next turn.
657
+ if (result.msgType === 'skipped') continue;
658
+ if (!result.ok) throw flushError(result.msg || 'Save failed', result.msgType === 'conflict' || isSaveConflicted() ? 'conflict' : 'failed');
659
+ saved = true;
660
+ etag = result.etag ?? null;
661
+ }
662
+ throw flushError('The document kept changing while saving.', 'failed');
663
+ }
664
+
518
665
  /**
519
666
  * Keep this tab's version, over the one the host is holding.
520
667
  *
@@ -705,6 +852,13 @@ export function savePageThrottled(callback = () => {}) {
705
852
  return Promise.resolve(skipped);
706
853
  }
707
854
 
855
+ if (previewHold > 0) {
856
+ previewMissed = true;
857
+ const skipped = skipped_('Waiting for the AI edit to be kept or reverted');
858
+ callback(skipped);
859
+ return Promise.resolve(skipped);
860
+ }
861
+
708
862
  // Every autosave path lands here — the mutation-driven one, the [persist] input
709
863
  // timer, and live-sync's convergence save after a protected apply — which is
710
864
  // why the suspension lives at this one entry rather than at each caller.
@@ -679,3 +679,24 @@ export function captureBodyForSync() {
679
679
  export function getPageContents() {
680
680
  return captureForSave({ emitForSync: false });
681
681
  }
682
+
683
+ /**
684
+ * The page as this tab had it before a merge replaced some of it, as the bytes a
685
+ * save would have written. The ledger's clone is shared and must come back
686
+ * untouched, so this works on a copy. A snapshot-domain clone still needs the save
687
+ * preparation (transforms, the [no-save] strip) but not the authored onbeforesave
688
+ * handlers: this is a download, not a save. A save-domain clone is already prepared.
689
+ */
690
+ export function serializeRecoveryClone(clone, { prepared = false } = {}) {
691
+ const copy = clonePreventingOnclone(clone);
692
+ return serializeClone(prepared ? copy : prepareCloneForSave(copy, { authored: false }));
693
+ }
694
+
695
+ /**
696
+ * The live page as a save would write it, for a download: the save transforms and
697
+ * the [no-save] strip, but no authored handler runs and the undo batch stays open.
698
+ * Nothing is sent and no event fires.
699
+ */
700
+ export function captureRecoveryPage() {
701
+ return serializeSaveClone(captureSaveClone({ flushUndo: false, authored: false }));
702
+ }
@@ -19,17 +19,16 @@ import { set, make } from "../lib/hostile-css.js";
19
19
  // One line, no choice to make: nothing on this page can fix it, so offering a button
20
20
  // would be a lie. Dismissable, because after you have read it, it is only in the way.
21
21
 
22
- const BG = "var(--clay-notice-bg,#222)";
23
- const INK = "var(--clay-notice-ink,#fff)";
24
- const EDGE = "var(--clay-notice-edge,rgba(255,255,255,.28))";
25
- const FONT = "14px/1.45 system-ui,-apple-system,'Segoe UI',sans-serif";
26
-
27
22
  const MESSAGE =
28
23
  "This page can't be edited: the app serving it is out of date. " +
29
24
  "Update HTML Clay to 1.9.0 or newer.";
30
25
 
31
26
  let root = null;
32
27
 
28
+ // Bevel is fetched only when the warning is actually shown. This module loads on every
29
+ // page, view mode included, and almost none of them ever show it.
30
+ let ui = null;
31
+
33
32
  // A phone keyboard shrinks the visual viewport but leaves fixed elements pinned to the
34
33
  // layout viewport, so a bottom-anchored bar parks itself behind the keyboard. Same fix
35
34
  // as the conflict notice.
@@ -48,32 +47,24 @@ function dismiss() {
48
47
  }
49
48
 
50
49
  function build() {
51
- root = make("div", [
50
+ root = ui.bevelSurface("div", [
52
51
  "position:fixed", "left:50%", "transform:translateX(-50%)",
53
- "z-index:2147483001", "display:flex", "align-items:center", "gap:10px",
54
- "max-width:calc(100vw - 24px)", "flex-wrap:wrap", "justify-content:center",
55
- "padding:9px 12px", "border-radius:10px",
56
- `background:${BG}`, `color:${INK}`, `border:1px solid ${EDGE}`,
57
- "box-shadow:0 6px 24px rgba(0,0,0,.32),0 1px 2px rgba(0,0,0,.24)",
58
- `font:${FONT}`, "text-align:left",
52
+ "z-index:2147483001", "display:flex", "align-items:center", "gap:10px 12px",
53
+ "width:max-content", "max-width:calc(100vw - 24px)", "flex-wrap:wrap",
54
+ "padding:8px 8px 8px 14px", "text-align:left", `color-scheme:${ui.pageScheme()}`,
59
55
  ]);
60
56
  // Three markers, and each is load-bearing on a page that CAN save: this element is
61
57
  // injected, so it is in no document on disk and must never reach one, never wake the
62
58
  // watcher, and never ride out in a snapshot to somebody else's browser.
63
- root.setAttribute("clay", "no-save no-watch no-snapshot");
59
+ root.setAttribute("clay", ui.RUNTIME_ONLY);
64
60
  root.setAttribute("data-clay-stale-host", "");
65
61
  root.setAttribute("role", "alert");
66
62
 
67
- root.append(make("span", ["margin-right:2px"], MESSAGE));
68
-
69
- const close = make("button", [
70
- "all:initial", "box-sizing:border-box", "cursor:pointer", `font:${FONT}`,
71
- "color:" + INK, "opacity:.72", "padding:2px 6px", "border-radius:6px", "flex:none",
72
- ], "Dismiss");
73
- close.type = "button";
63
+ const dot = make("span", ["all:initial", "color-scheme:inherit", "display:inline-block", "flex:none", "width:8px", "height:8px", "border-radius:50%", `background:${ui.TOKENS.ox}`]);
64
+ dot.setAttribute("clay", ui.RUNTIME_ONLY);
65
+ const close = ui.bevelButton("Dismiss", { small: true, variant: "quiet", onClick: dismiss });
74
66
  close.setAttribute("aria-label", "Dismiss this message");
75
- close.addEventListener("click", dismiss);
76
- root.append(close);
67
+ root.append(dot, ui.bevelText("span", [], MESSAGE), close);
77
68
 
78
69
  document.body.appendChild(root);
79
70
  place();
@@ -81,7 +72,7 @@ function build() {
81
72
  window.visualViewport?.addEventListener("scroll", place);
82
73
  }
83
74
 
84
- function init() {
75
+ async function init() {
85
76
  if (!servedStaleToken()) return;
86
77
  // ?editmode=true outranks the stale-host check by design: that is a person at the
87
78
  // keyboard asking for editing on this load, and is-edit-mode.js gives it to them.
@@ -89,7 +80,13 @@ function init() {
89
80
  // told its reader the opposite. The message is only true while editing is actually
90
81
  // off, so it is shown only then.
91
82
  if (isEditMode) return;
83
+ const [controls, bevel] = await Promise.all([import("../ui/bevel-controls.js"), import("../ui/bevel.js")]);
84
+ ui = { ...controls, TOKENS: bevel.TOKENS };
92
85
  build();
93
86
  }
94
87
 
95
- onDomReady(init);
88
+ let shown = Promise.resolve();
89
+ onDomReady(() => { shown = init(); });
90
+
91
+ // Settles once the warning is on the page, or straight away when there is none to show.
92
+ export const whenShown = () => shown;
@@ -19,6 +19,20 @@ import { isEditMode } from "./is-edit-mode.js";
19
19
  import { captureForDirtyCheck } from "./snapshot.js";
20
20
  import { getLastSavedDirty } from "./save.js";
21
21
  import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
22
+ import { hasUnsavedState } from "../lib/unsaved-state.js";
23
+
24
+ // One beforeunload let through, for the reload a person confirmed with two presses
25
+ // in the conflict notice. Nothing else is switched off: the next close still warns.
26
+ let discardPermit = false;
27
+ export function reloadAfterDiscard({ isCurrent, reload }) {
28
+ if (!isCurrent()) return false;
29
+ discardPermit = true;
30
+ // Browsers fire beforeunload for a reload on their own schedule; a permit still
31
+ // unused a second later means the navigation did not happen.
32
+ setTimeout(() => { discardPermit = false; }, 1000);
33
+ reload();
34
+ return true;
35
+ }
22
36
 
23
37
  // Pre-load diff library if debug mode is on (so it's ready for unload)
24
38
  preloadIfEnabled();
@@ -29,23 +43,57 @@ preloadIfEnabled();
29
43
  // sandboxed document, which cannot read cookies at all. Those are exactly the
30
44
  // documents where an unsaved edit is easiest to lose. If the page is editable,
31
45
  // the person editing it deserves the warning.
46
+ //
47
+ // The same predicate is exposed as `clay.hasUnsavedChanges`, which a host asks
48
+ // synchronously before deciding whether a save is worth asking for.
49
+ export function hasUnsavedChanges() {
50
+ if (!isEditMode) return false;
51
+
52
+ // Work outside the DOM first: it needs no capture, and a capture that throws
53
+ // must not hide it. The demo plugin does not save it either, so it warns there too.
54
+ if (hasUnsavedState()) return true;
55
+
56
+ // The demo plugin saves the page's bytes into this browser's own storage, so
57
+ // leaving loses none of them.
58
+ if (window.clay?.demo) return false;
59
+
60
+ return bytesUnsaved();
61
+ }
62
+
63
+ // The page's own bytes against the last bytes the host accepted. A capture that throws
64
+ // counts as unsaved: something on the page is broken, and "nothing to lose" is the one
65
+ // answer that can lose work.
66
+ //
67
+ // The DIRTY domain, not the autosave domain. An edit inside a no-trigger-autosave
68
+ // region never starts a save by itself, which is exactly why closing the tab on one has
69
+ // to warn: nothing else is going to write it.
70
+ export function bytesUnsaved() {
71
+ if (!isEditMode) return false;
72
+ try {
73
+ return captureForDirtyCheck() !== getLastSavedDirty();
74
+ } catch {
75
+ return true;
76
+ }
77
+ }
78
+
32
79
  window.addEventListener('beforeunload', (event) => {
33
80
  if (!isEditMode) return;
34
- // The demo plugin saves into this browser's own storage, so leaving the page
35
- // loses nothing a prompt could protect.
36
- if (window.clay?.demo) return;
37
-
38
- // The DIRTY domain, not the autosave domain. An edit inside a
39
- // no-trigger-autosave region never starts a save by itself, which is exactly
40
- // why closing the tab on one has to warn: nothing else is going to write it.
41
- const currentForCompare = captureForDirtyCheck();
42
- const lastSaved = getLastSavedDirty();
43
-
44
- if (currentForCompare !== lastSaved) {
45
- // Debug: log what's different before showing the warning
46
- logUnloadDiffSync(currentForCompare, lastSaved);
47
-
48
- event.preventDefault();
49
- event.returnValue = '';
81
+
82
+ if (discardPermit) {
83
+ discardPermit = false;
84
+ return;
85
+ }
86
+
87
+ if (!hasUnsavedChanges()) return;
88
+
89
+ event.preventDefault();
90
+ event.returnValue = '';
91
+ // Debug only, after the decision: a capture that throws here must not cancel the warning.
92
+ if (!window.clay?.demo && !hasUnsavedState()) {
93
+ try {
94
+ const currentForCompare = captureForDirtyCheck();
95
+ const lastSaved = getLastSavedDirty();
96
+ if (currentForCompare !== lastSaved) logUnloadDiffSync(currentForCompare, lastSaved);
97
+ } catch {}
50
98
  }
51
99
  });
@@ -27,8 +27,10 @@ function matchesWithin(node, selector, boundary, inherit) {
27
27
 
28
28
  function importTree(source, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps) {
29
29
  if (source.nodeType === 1 && (
30
- (inherit ? hasCapability(source, capability) : matchesWithin(source, capabilityMatch, boundary, false)) ||
31
- (exclude && matchesWithin(source, exclude, boundary, inherit))
30
+ (inherit
31
+ ? hasCapability(source, capability)
32
+ : matchesWithin(source, capabilityMatch, boundary, false)) ||
33
+ (exclude && (inherit && source !== boundary ? source.matches(exclude) : matchesWithin(source, exclude, boundary, inherit)))
32
34
  )) return null
33
35
  const copy = targetDocument.importNode(source, false)
34
36
  maps.cloneToLive.set(copy, source)
@@ -35,6 +35,26 @@ let clearedAt = 0;
35
35
  let paused = false;
36
36
  let started = false;
37
37
 
38
+ // The state change, announced on the document: `clay:dirty` when work nothing has
39
+ // written appears, `clay:clean` when a save (or the oracle) has accounted for it. A
40
+ // burst of edits is ONE announcement, since what a listener wants is the state, not
41
+ // the edit. Only a transition is dispatched.
42
+ let announcedDirty = false;
43
+ let queued = false;
44
+
45
+ function scheduleAnnounce() {
46
+ if (!isEditMode || queued) return;
47
+ queued = true;
48
+ queueMicrotask(() => {
49
+ queued = false;
50
+ if (typeof document === 'undefined') return;
51
+ const dirty = pageMaybeDirty();
52
+ if (dirty === announcedDirty) return;
53
+ announcedDirty = dirty;
54
+ document.dispatchEvent(new CustomEvent(dirty ? 'clay:dirty' : 'clay:clean'));
55
+ });
56
+ }
57
+
38
58
  const PERSIST_CONTROLS = `input${PERSIST}, textarea${PERSIST}, select${PERSIST}`;
39
59
 
40
60
  // Regions the loss oracle never sees. The hub feed already skips them, through
@@ -81,6 +101,7 @@ function onUserInput(event) {
81
101
  if (!(el.matches('input, textarea, select') || el.isContentEditable)) return;
82
102
  if (el.closest(GATE_IGNORE)) return;
83
103
  changes++;
104
+ scheduleAnnounce();
84
105
  }
85
106
 
86
107
  export function startDirtyGate() {
@@ -89,7 +110,10 @@ export function startDirtyGate() {
89
110
  Mutation.onAnyChange(
90
111
  { omitChangeDetails: true, require: 'dirty' },
91
112
  () => {
92
- if (!paused) changes++;
113
+ if (!paused) {
114
+ changes++;
115
+ scheduleAnnounce();
116
+ }
93
117
  }
94
118
  );
95
119
  document.addEventListener('input', onUserInput, true);
@@ -151,6 +175,7 @@ export function probeMarkClean() {
151
175
  for (const el of gatedPersistControls()) {
152
176
  probeCache.set(el, controlSignature(el));
153
177
  }
178
+ scheduleAnnounce();
154
179
  }
155
180
 
156
181
  export function pageMaybeDirty() {
@@ -173,6 +198,7 @@ export function gateCaptureToken() {
173
198
  /** A change nothing in the DOM recorded: the page holds text a save has not written. */
174
199
  export function gateMarkDirty() {
175
200
  changes++;
201
+ scheduleAnnounce();
176
202
  }
177
203
 
178
204
  export function gateClearIfUnchanged(token) {
@@ -186,8 +212,18 @@ export function gateClearIfUnchanged(token) {
186
212
  for (const [el, sig] of token.probe) {
187
213
  if (controlSignature(el) === sig) probeCache.set(el, sig);
188
214
  }
215
+ scheduleAnnounce();
189
216
  }
190
217
 
191
218
  if (typeof document !== 'undefined' && isEditMode) {
192
219
  startDirtyGate();
193
220
  }
221
+
222
+ // Public for editors whose own surface is editor-ui (so neither gate feed sees it):
223
+ // a person's edit made while a live-sync frame waits on a resource would otherwise be
224
+ // recorded as saved. Set here, not in the loader, because the sync plugin is what
225
+ // loads this module.
226
+ if (typeof window !== 'undefined') {
227
+ window.clay = window.clay || {};
228
+ window.clay.markDirty = gateMarkDirty;
229
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * unsaved-state.js: "this tab holds work its saved bytes cannot show".
3
+ *
4
+ * The close warning compares the page's bytes with the last save. Some work is
5
+ * not in those bytes: text a live-sync merge replaced, kept in memory until the
6
+ * person reviews it, or an editor's model ahead of the DOM it writes into. Its
7
+ * owner registers a synchronous check here, and the close warning fires while
8
+ * any check says pending. Checks run fresh each time, so a forgotten state can
9
+ * never outlive what it describes, and a save never clears one.
10
+ */
11
+ const sources = new Map();
12
+ let nextKey = 0;
13
+
14
+ export function registerUnsavedState({ id = '', isPending }) {
15
+ const key = ++nextKey;
16
+ sources.set(key, { id, isPending });
17
+ let disposed = false;
18
+ return {
19
+ changed() {
20
+ if (disposed) return;
21
+ document.dispatchEvent(new CustomEvent('clay:unsaved-state-changed', {
22
+ detail: { pending: pendingIds() },
23
+ }));
24
+ },
25
+ dispose() {
26
+ if (disposed) return;
27
+ disposed = true;
28
+ sources.delete(key);
29
+ },
30
+ };
31
+ }
32
+
33
+ // A check that throws counts as pending: unknown is not clean.
34
+ function isPending(source) {
35
+ try {
36
+ return !!source.isPending();
37
+ } catch (err) {
38
+ console.error(`[clay] unsaved-state check "${source.id}" threw`, err);
39
+ return true;
40
+ }
41
+ }
42
+
43
+ export function pendingIds() {
44
+ return [...sources.values()].filter(isPending).map((s) => s.id);
45
+ }
46
+
47
+ export function hasUnsavedState() {
48
+ for (const source of sources.values()) if (isPending(source)) return true;
49
+ return false;
50
+ }
51
+
52
+ if (typeof window !== 'undefined') {
53
+ window.clay = window.clay || {};
54
+ window.clay.registerUnsavedState = registerUnsavedState;
55
+ }