@panphora/clayjs 0.2.0 → 0.4.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 (43) hide show
  1. package/README.md +99 -7
  2. package/clay-internals.js +19 -0
  3. package/clay.js +18 -2
  4. package/package.json +19 -3
  5. package/sap.js +5 -5
  6. package/src/attrs/onaftersave.js +12 -1
  7. package/src/attrs/refetch-on-save.js +4 -4
  8. package/src/attrs/save-freeze.js +47 -19
  9. package/src/core/admin-contenteditable.js +2 -2
  10. package/src/core/admin-inputs.js +2 -2
  11. package/src/core/admin-onclick.js +2 -2
  12. package/src/core/admin-resources.js +23 -14
  13. package/src/core/autosave.js +11 -1
  14. package/src/core/edit-mode.js +2 -2
  15. package/src/core/host-attrs.js +46 -0
  16. package/src/core/is-edit-mode.js +14 -6
  17. package/src/core/persist.js +8 -1
  18. package/src/core/save-core.js +199 -290
  19. package/src/core/save.js +124 -84
  20. package/src/core/snapshot.js +99 -43
  21. package/src/core/unsaved-warning.js +8 -2
  22. package/src/dom/all.js +4 -29
  23. package/src/dom/form-data.js +1 -1
  24. package/src/dom/nearest.js +11 -9
  25. package/src/internals/index.js +64 -0
  26. package/src/lib/cache-bust.js +10 -0
  27. package/src/lib/cookie.js +50 -15
  28. package/src/lib/extension-noise.js +5 -1
  29. package/src/lib/mutation.js +65 -18
  30. package/src/lib/region-policy.js +17 -2
  31. package/src/lib/root-attrs.js +45 -0
  32. package/src/lib/throttle.js +25 -13
  33. package/src/loader.js +18 -24
  34. package/src/options/options.js +1 -3
  35. package/src/plugins/demo.js +7 -1
  36. package/src/sync/live-sync.js +121 -89
  37. package/src/ui/dialogs.js +10 -1
  38. package/src/ui/index.js +2 -11
  39. package/src/ui/toast.js +8 -2
  40. package/src/utils/debounce.js +24 -7
  41. package/src/vendor/hyper-undo.vendor.js +1 -1
  42. package/src/vendor/hypercms.vendor.js +29 -16
  43. package/src/vendor/richclay.vendor.js +29 -22
package/src/core/save.js CHANGED
@@ -16,14 +16,14 @@ import {
16
16
  saveHtml,
17
17
  getPageContents,
18
18
  replacePageWith as replacePageWithCore,
19
- beforeSave,
19
+ addDocumentTransform,
20
20
  isSaveInProgress
21
21
  } from "./save-core.js";
22
22
  import { captureForComparison, captureForSaveAndComparison } from "./snapshot.js";
23
23
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
24
24
 
25
25
  // Reset savestatus to 'saved' in snapshots (each module cleans up its own attrs)
26
- beforeSave(clone => {
26
+ addDocumentTransform(clone => {
27
27
  clone.setAttribute('savestatus', 'saved');
28
28
  });
29
29
 
@@ -38,8 +38,9 @@ let savingTimeout = null;
38
38
  *
39
39
  * @param {string} state - One of: 'saving', 'saved', 'offline', 'error'
40
40
  * @param {string} msg - Optional message (e.g., error details)
41
+ * @param {string} msgType - Optional severity from the server (e.g., 'warning')
41
42
  */
42
- function setSaveState(state, msg = '') {
43
+ function setSaveState(state, msg = '', msgType = '') {
43
44
  if (savingTimeout) {
44
45
  clearTimeout(savingTimeout);
45
46
  savingTimeout = null;
@@ -48,7 +49,7 @@ function setSaveState(state, msg = '') {
48
49
  document.documentElement.setAttribute('savestatus', state);
49
50
 
50
51
  const event = new CustomEvent(`clay:save-${state}`, {
51
- detail: { msg, timestamp: Date.now() }
52
+ detail: { msg, msgType, timestamp: Date.now() }
52
53
  });
53
54
  document.dispatchEvent(event);
54
55
  }
@@ -91,27 +92,68 @@ window.addEventListener('online', () => {
91
92
  });
92
93
 
93
94
  // ============================================
94
- // POST-SAVE BASELINE RECAPTURE
95
+ // THE BASELINE IS THE BYTES WE SENT
95
96
  // ============================================
96
- // After a successful save, onaftersave handlers may modify the live DOM
97
- // (e.g., cacheBust updates ?v= query params). We recapture the baseline
98
- // after these sync handlers complete to prevent false "unsaved changes" warnings.
99
-
100
- document.addEventListener('clay:save-saved', () => {
101
- // Use setTimeout(0) to run after all sync onaftersave handlers complete
102
- setTimeout(() => {
103
- // Store stripped version so comparisons are direct (no parsing needed)
104
- const contents = captureForComparison();
105
- lastSavedContents = contents;
106
- logBaseline('recaptured after onaftersave', `${contents.length} chars`);
107
- }, 0);
108
- });
97
+ // There is deliberately no post-save re-read of the live DOM here.
98
+ //
99
+ // One used to exist, because [onaftersave] handlers mutate the page after a save
100
+ // lands (cacheBust rewrites ?v= query params) and without it the page read dirty
101
+ // forever. But a re-read cannot tell that churn apart from something the user typed
102
+ // while the request was in flight, so it recorded those keystrokes as saved without
103
+ // ever sending them: the edit was gone, with no error, no dirty flag, and no
104
+ // close-tab warning.
105
+ //
106
+ // Post-save mutators are made invisible to the comparison instead — they mark what
107
+ // they touch `no-trigger-autosave`, which strips it from every comparison capture
108
+ // (see cache-bust.js and refetch-on-save.js). That leaves the baseline free to stay
109
+ // exactly what savePage sent, which is the only value that is true by construction.
109
110
 
110
111
  // Re-export from core for backward compatibility
111
- export { beforeSave, getPageContents };
112
+ export { addDocumentTransform, getPageContents };
112
113
 
113
114
  let unsavedChanges = false;
114
115
  let lastSavedContents = '';
116
+ // A save was requested while one was on the wire; run one more when it settles.
117
+ let pendingSave = false;
118
+
119
+ function skipped_(msg) {
120
+ return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
121
+ }
122
+
123
+ /**
124
+ * Apply one save result to the page's state.
125
+ *
126
+ * A 'skipped' result means the bytes never reached the wire, so the baseline must
127
+ * NOT advance: recording content as saved when it was never sent is the same defect
128
+ * as re-reading the live DOM after a save, one layer down. 'unknown' is a timeout,
129
+ * where the write may or may not have landed, so it is not treated as success
130
+ * either.
131
+ */
132
+ function applySaveResult(result, forComparison, label) {
133
+ if (result.ok) {
134
+ lastSavedContents = forComparison;
135
+ unsavedChanges = false;
136
+ // The server's severity rides through untouched: a save can land AND carry a
137
+ // warning, and the UI module is what decides how to render that.
138
+ setSaveState('saved', result.msg || 'Saved', result.msgType);
139
+ logBaseline(label, `${lastSavedContents.length} chars`);
140
+ } else if (result.msgType !== 'skipped') {
141
+ if (!navigator.onLine) {
142
+ setSaveState('offline', result.msg);
143
+ } else {
144
+ setSaveState('error', result.msg);
145
+ }
146
+ }
147
+ }
148
+
149
+ // Run the save that arrived while this one was in flight. savePage does its own
150
+ // dirty check, so if nothing actually changed it resolves 'skipped' and stops:
151
+ // this cannot spin.
152
+ function drainPendingSave() {
153
+ if (!pendingSave) return;
154
+ pendingSave = false;
155
+ savePage();
156
+ }
115
157
 
116
158
  // State accessors for autosave module
117
159
  export function getUnsavedChanges() { return unsavedChanges; }
@@ -132,14 +174,18 @@ export function setLastSavedContents(val) { lastSavedContents = val; }
132
174
  export function savePage(callback = () => {}) {
133
175
  return new Promise((resolve) => {
134
176
  if (!isEditMode && !window.clay?.testMode) {
135
- const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
177
+ const skipped = skipped_('Not in edit mode');
136
178
  callback(skipped);
137
179
  return resolve(skipped);
138
180
  }
139
181
 
140
- // Don't start a new save if one is already in progress
182
+ // A save is already on the wire. Remember that a newer state is waiting rather
183
+ // than dropping it: the in-flight request carries the older bytes, and if no
184
+ // further mutation happens to retrigger autosave, the newer ones would never
185
+ // reach disk at all.
141
186
  if (isSaveInProgress()) {
142
- const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
187
+ pendingSave = true;
188
+ const skipped = skipped_('Save already in progress');
143
189
  callback(skipped);
144
190
  return resolve(skipped);
145
191
  }
@@ -154,13 +200,13 @@ export function savePage(callback = () => {}) {
154
200
  // Single capture: clone once, get both versions
155
201
  // forSave strips non-persisted regions ([no-save]/[save-remove])
156
202
  // forComparison additionally strips every autosave-off region
157
- let forSave, forComparison;
203
+ let forSave, forComparison, snapshotHtml;
158
204
  try {
159
- ({ forSave, forComparison } = captureForSaveAndComparison());
205
+ ({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
160
206
  } catch (err) {
161
207
  console.error('savePage: captureForSaveAndComparison failed', err);
162
208
  setSaveState('error', err.message);
163
- const result = { msg: err.message, msgType: 'error' };
209
+ const result = { msg: err.message, msgType: 'error', code: null, etag: null };
164
210
  if (typeof callback === 'function') {
165
211
  callback(result);
166
212
  }
@@ -173,7 +219,7 @@ export function savePage(callback = () => {}) {
173
219
 
174
220
  // Skip if content hasn't changed
175
221
  if (!unsavedChanges) {
176
- const skipped = { msg: 'No changes to save', msgType: 'skipped' };
222
+ const skipped = skipped_('No changes to save');
177
223
  callback(skipped);
178
224
  return resolve(skipped);
179
225
  }
@@ -182,32 +228,14 @@ export function savePage(callback = () => {}) {
182
228
  setSavingState();
183
229
 
184
230
  // Use saveHtml directly with our pre-captured content (avoids double capture)
185
- saveHtml(forSave, (err, data) => {
186
- if (!err) {
187
- // SUCCESS - store stripped version for future comparisons
188
- lastSavedContents = forComparison;
189
- unsavedChanges = false;
190
- setSaveState('saved', data?.msg || 'Saved');
191
- logBaseline('updated after save', `${lastSavedContents.length} chars`);
192
- } else {
193
- // FAILED - determine if it's offline or server error
194
- if (!navigator.onLine) {
195
- setSaveState('offline', err.message);
196
- } else {
197
- setSaveState('error', err.message);
198
- }
199
- }
200
-
201
- // Call user callback if provided (preserve server's msgType)
202
- const result = {
203
- msg: err?.message || data?.msg,
204
- msgType: err ? 'error' : (data?.msgType || 'success')
205
- };
231
+ saveHtml(forSave, (result) => {
232
+ applySaveResult(result, forComparison, 'updated after save');
206
233
  if (typeof callback === 'function') {
207
234
  callback(result);
208
235
  }
209
236
  resolve(result);
210
- });
237
+ drainPendingSave();
238
+ }, { snapshotHtml });
211
239
  });
212
240
  }
213
241
 
@@ -220,13 +248,14 @@ export function savePage(callback = () => {}) {
220
248
  export function savePageForce(callback = () => {}) {
221
249
  return new Promise((resolve) => {
222
250
  if (!isEditMode && !window.clay?.testMode) {
223
- const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
251
+ const skipped = skipped_('Not in edit mode');
224
252
  callback(skipped);
225
253
  return resolve(skipped);
226
254
  }
227
255
 
228
256
  if (isSaveInProgress()) {
229
- const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
257
+ pendingSave = true;
258
+ const skipped = skipped_('Save already in progress');
230
259
  callback(skipped);
231
260
  return resolve(skipped);
232
261
  }
@@ -236,13 +265,13 @@ export function savePageForce(callback = () => {}) {
236
265
  setOfflineStateQuiet();
237
266
  }
238
267
 
239
- let forSave, forComparison;
268
+ let forSave, forComparison, snapshotHtml;
240
269
  try {
241
- ({ forSave, forComparison } = captureForSaveAndComparison());
270
+ ({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
242
271
  } catch (err) {
243
272
  console.error('savePageForce: captureForSaveAndComparison failed', err);
244
273
  setSaveState('error', err.message);
245
- const result = { msg: err.message, msgType: 'error' };
274
+ const result = { msg: err.message, msgType: 'error', code: null, etag: null };
246
275
  if (typeof callback === 'function') {
247
276
  callback(result);
248
277
  }
@@ -251,29 +280,14 @@ export function savePageForce(callback = () => {}) {
251
280
 
252
281
  setSavingState();
253
282
 
254
- saveHtml(forSave, (err, data) => {
255
- if (!err) {
256
- lastSavedContents = forComparison;
257
- unsavedChanges = false;
258
- setSaveState('saved', data?.msg || 'Saved');
259
- logBaseline('updated after force save', `${lastSavedContents.length} chars`);
260
- } else {
261
- if (!navigator.onLine) {
262
- setSaveState('offline', err.message);
263
- } else {
264
- setSaveState('error', err.message);
265
- }
266
- }
267
-
268
- const result = {
269
- msg: err?.message || data?.msg,
270
- msgType: err ? 'error' : (data?.msgType || 'success')
271
- };
283
+ saveHtml(forSave, (result) => {
284
+ applySaveResult(result, forComparison, 'updated after force save');
272
285
  if (typeof callback === 'function') {
273
286
  callback(result);
274
287
  }
275
288
  resolve(result);
276
- });
289
+ drainPendingSave();
290
+ }, { snapshotHtml });
277
291
  });
278
292
  }
279
293
 
@@ -288,13 +302,16 @@ export function replacePageWith(url) {
288
302
  return;
289
303
  }
290
304
 
291
- replacePageWithCore(url, (err, data) => {
292
- if (err) {
293
- // Emit error event (save-toast will show toast if loaded)
294
- setSaveState('error', err.message || "Failed to save template");
295
- } else {
296
- // Only reload if save was successful
305
+ replacePageWithCore(url, (result) => {
306
+ // Reload ONLY on a save that actually landed. A skipped result means the
307
+ // replacement never happened (busy lane, or not in edit mode), and reloading
308
+ // then presented a no-op as a completed swap.
309
+ if (result.ok) {
297
310
  window.location.reload();
311
+ } else if (result.msgType === 'skipped') {
312
+ setSaveState('error', result.msg || 'Template not saved');
313
+ } else {
314
+ setSaveState('error', result.msg || 'Failed to save template');
298
315
  }
299
316
  });
300
317
  }
@@ -304,6 +321,9 @@ const throttledSave = throttle(savePage, 1200);
304
321
 
305
322
  // Baseline for autosave comparison
306
323
  let baselineContents = '';
324
+ // The baseline veto only guards the load-time settle window; captureBaseline
325
+ // disarms it. See the comment there.
326
+ let baselineActive = true;
307
327
 
308
328
  // ============================================
309
329
  // BASELINE CAPTURE (Settled Signal)
@@ -371,6 +391,14 @@ function initBaselineCapture() {
371
391
  logBaseline('settled skipped', userEdited ? 'user edited' : 'save occurred during settle');
372
392
  }
373
393
 
394
+ // The load-time veto has done its job. It exists so setup churn from modules
395
+ // booting cannot trigger a save, and that window is over once mutations have
396
+ // settled. Leaving it armed for the life of the tab is what made an undo back
397
+ // to the page's original state unsaveable: it differed from lastSavedContents
398
+ // but matched baselineContents, so autosave vetoed it forever and only a manual
399
+ // save could persist the revert.
400
+ baselineActive = false;
401
+
374
402
  document.documentElement.setAttribute('savestatus', 'saved');
375
403
  };
376
404
 
@@ -407,29 +435,41 @@ if (document.readyState === 'loading') {
407
435
  */
408
436
  export function savePageThrottled(callback = () => {}) {
409
437
  if (!isEditMode) {
410
- const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
438
+ const skipped = skipped_('Not in edit mode');
411
439
  callback(skipped);
412
440
  return Promise.resolve(skipped);
413
441
  }
414
442
 
415
- // For autosave: check both that content changed from baseline AND from last save
416
- // This prevents saves from initial setup mutations
443
+ // For autosave: while the page is still settling, content must differ from BOTH
444
+ // the load-time baseline and the last save, so module setup churn cannot trigger
445
+ // a save. Once settled, the baseline veto is disarmed and only the last save
446
+ // matters — otherwise undoing back to the page's original state can never be
447
+ // persisted, because it matches the baseline forever.
417
448
  // Compare directly - stored versions are already stripped
418
449
  const currentForCompare = captureForComparison();
419
- const differsFromBaseline = currentForCompare !== baselineContents;
450
+ const differsFromBaseline = !baselineActive || currentForCompare !== baselineContents;
420
451
  const differsFromLastSave = currentForCompare !== lastSavedContents;
421
452
 
422
453
  logSaveCheck('throttled vs baseline', !differsFromBaseline);
423
454
  logSaveCheck('throttled vs lastSave', !differsFromLastSave);
424
455
 
425
456
  if (!(differsFromBaseline && differsFromLastSave)) {
426
- const skipped = { msg: 'No changes to save', msgType: 'skipped' };
457
+ const skipped = skipped_('No changes to save');
427
458
  callback(skipped);
428
459
  return Promise.resolve(skipped);
429
460
  }
430
461
 
431
462
  unsavedChanges = true;
432
- return throttledSave(callback);
463
+ // The throttled promise can reject now that a throwing save no longer strands
464
+ // its callers (see throttle.js), and both autosave call sites are fire-and-forget,
465
+ // with no reach into this module's error path. Catch it here, the one place that
466
+ // owns that path, so every caller keeps the never-rejects contract savePage
467
+ // documents and a throw surfaces as a failed save instead of an unhandled rejection.
468
+ return throttledSave(callback).catch(err => {
469
+ const result = { ok: false, msg: err?.message || 'Save failed', msgType: 'error', code: null, etag: null };
470
+ setSaveState('error', result.msg);
471
+ return result;
472
+ });
433
473
  }
434
474
 
435
475
  /**
@@ -19,7 +19,7 @@
19
19
  * ▼ ▼
20
20
  * ┌─────────────────────────┐ ┌─────────────────────────┐
21
21
  * │ 3a. PREPARE HOOKS │ │ 3b. DONE │
22
- * │ onPrepareForSave │ │ (live-sync stops here) │
22
+ * │ addDocumentTransform │ │ (live-sync stops here) │
23
23
  * │ [onbeforesave] │ │ │
24
24
  * │ [save-remove] │ │ → emits snapshot-ready │
25
25
  * │ │ └─────────────────────────┘
@@ -38,13 +38,38 @@
38
38
 
39
39
  import { stripExtensionNoise } from '../lib/extension-noise.js';
40
40
  import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
41
+ import { saveTransport, DESKTOP_JSON } from './host-attrs.js';
42
+ import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
41
43
 
42
44
  // =============================================================================
43
45
  // HOOK REGISTRIES
44
46
  // =============================================================================
45
47
 
46
48
  const snapshotHooks = []; // Phase 2: Always run (form sync)
47
- const prepareForSaveHooks = []; // Phase 3a: Save only (strip admin)
49
+ const documentTransforms = []; // Phase 3a: Save only (strip admin)
50
+
51
+ /**
52
+ * Run every authored handler of one kind over a clone, and never let one of them
53
+ * take the page down with it.
54
+ *
55
+ * These attributes hold page-author JavaScript, and the capture pipeline runs on
56
+ * the boot path: save.js captures a baseline during module evaluation, inside the
57
+ * dynamic import the loader awaits. An unguarded throw in one page attribute
58
+ * therefore cost the document its entire client rather than costing that one
59
+ * handler its effect.
60
+ *
61
+ * @param {HTMLElement} clone
62
+ * @param {string} attr - e.g. 'onbeforesave'
63
+ */
64
+ function runAuthoredHandlers(clone, attr) {
65
+ for (const el of clone.querySelectorAll(`[${attr}]`)) {
66
+ try {
67
+ new Function(el.getAttribute(attr)).call(el);
68
+ } catch (err) {
69
+ console.error(`[${attr}] handler failed:`, err);
70
+ }
71
+ }
72
+ }
48
73
 
49
74
  /**
50
75
  * Register a hook that runs on EVERY snapshot (save AND sync).
@@ -57,18 +82,21 @@ export function onSnapshot(callback) {
57
82
  }
58
83
 
59
84
  /**
60
- * Register a hook that runs ONLY when preparing for save.
85
+ * Register a transform that runs over a detached clone when preparing to save.
61
86
  * Use for: stripping admin elements, cleanup.
62
87
  *
88
+ * It is a TRANSFORM, not a save lifecycle event: it runs over the clone that
89
+ * gets saved AND over the clone used to answer "has anything changed", and that
90
+ * second one runs on dirty checks that never become a save. So it must be pure
91
+ * and repeatable — no counters, no logging, no network. The two clones have
92
+ * different visibility rules, which is why neither run can be skipped.
93
+ *
63
94
  * @param {Function} callback - Receives the cloned document element
64
95
  */
65
- export function onPrepareForSave(callback) {
66
- prepareForSaveHooks.push(callback);
96
+ export function addDocumentTransform(callback) {
97
+ documentTransforms.push(callback);
67
98
  }
68
99
 
69
- // Backwards compat alias
70
- export const beforeSave = onPrepareForSave;
71
-
72
100
  // =============================================================================
73
101
  // CAPTURE FUNCTIONS
74
102
  // =============================================================================
@@ -94,8 +122,8 @@ export function captureSnapshot() {
94
122
  // mid-typing would leave the idle batch open across the save boundary, and
95
123
  // Cmd+Z after save would restore to a state earlier than the last save.
96
124
  // No-op when undo isn't loaded or no batch is pending.
97
- if (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo && window.hyperclay.undo.flush) {
98
- window.hyperclay.undo.flush();
125
+ if (typeof window !== 'undefined' && window.clay?.undo?.flush) {
126
+ window.clay.undo.flush();
99
127
  }
100
128
 
101
129
  const clone = clonePreventingOnclone(document.documentElement);
@@ -104,9 +132,7 @@ export function captureSnapshot() {
104
132
  hook(clone);
105
133
  }
106
134
 
107
- for (const el of clone.querySelectorAll('[onbeforesnapshot]')) {
108
- new Function(el.getAttribute('onbeforesnapshot')).call(el);
109
- }
135
+ runAuthoredHandlers(clone, 'onbeforesnapshot');
110
136
 
111
137
  for (const el of clone.querySelectorAll(SNAPSHOT_REMOVE_SELECTOR)) {
112
138
  el.remove();
@@ -129,20 +155,20 @@ export function captureSnapshot() {
129
155
  */
130
156
  function prepareCloneForSave(clone) {
131
157
  // Run inline [onbeforesave] handlers
132
- for (const el of clone.querySelectorAll('[onbeforesave]')) {
133
- new Function(el.getAttribute('onbeforesave')).call(el);
158
+ runAuthoredHandlers(clone, 'onbeforesave');
159
+
160
+ // Run registered prepare hooks ([freeze]/[save-freeze] innerHTML restore lives here)
161
+ for (const hook of documentTransforms) {
162
+ hook(clone);
134
163
  }
135
164
 
136
- // Remove elements that shouldn't be saved ([no-save] / legacy [save-remove])
165
+ // Strip [no-save] / legacy [save-remove] LAST (snapshot-algorithm step 7): a
166
+ // prepare hook (freeze restore) can re-inject [no-save] content into the clone,
167
+ // so the strip must run after the hooks or that content leaks to disk.
137
168
  for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
138
169
  el.remove();
139
170
  }
140
171
 
141
- // Run registered prepare hooks ([freeze]/[save-freeze] innerHTML restore lives here)
142
- for (const hook of prepareForSaveHooks) {
143
- hook(clone);
144
- }
145
-
146
172
  return "<!DOCTYPE html>" + clone.outerHTML;
147
173
  }
148
174
 
@@ -159,9 +185,7 @@ export function captureForComparison() {
159
185
  const clone = captureSnapshot();
160
186
 
161
187
  // Run inline [onbeforesave] handlers
162
- for (const el of clone.querySelectorAll('[onbeforesave]')) {
163
- new Function(el.getAttribute('onbeforesave')).call(el);
164
- }
188
+ runAuthoredHandlers(clone, 'onbeforesave');
165
189
 
166
190
  // Strip before hooks (hooks see the "final" state)
167
191
  for (const el of clone.querySelectorAll(STRIP_FROM_COMPARISON)) {
@@ -169,7 +193,7 @@ export function captureForComparison() {
169
193
  }
170
194
 
171
195
  // Run registered prepare hooks
172
- for (const hook of prepareForSaveHooks) {
196
+ for (const hook of documentTransforms) {
173
197
  hook(clone);
174
198
  }
175
199
 
@@ -184,53 +208,56 @@ export function captureForComparison() {
184
208
  *
185
209
  * @param {Object} options
186
210
  * @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
187
- * @returns {{ forSave: string, forComparison: string }}
211
+ * @returns {{ forSave: string, forComparison: string, snapshotHtml: ?string }}
188
212
  */
189
213
  export function captureForSaveAndComparison({ emitForSync = true } = {}) {
190
214
  const clone = captureSnapshot();
191
215
 
216
+ // The unstripped snapshot, for a host that asked for the desktop JSON envelope:
217
+ // the save then sends both the stripped document and this. Returned to the caller
218
+ // rather than parked on a window global, so it can only ever be paired with the
219
+ // content captured alongside it. As a global it was cleared on success only, so
220
+ // two captures without an intervening successful save shipped a stale snapshot
221
+ // next to fresh content. Captured only when the document DECLARES the transport;
222
+ // this used to key off `location.hostname`, which set it on every localhost page
223
+ // whether or not its host wanted it.
224
+ const snapshotHtml = saveTransport() === DESKTOP_JSON
225
+ ? '<!DOCTYPE html>' + clone.outerHTML
226
+ : null;
227
+
192
228
  // Emit for live-sync before any stripping
193
229
  if (emitForSync) {
194
230
  document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
195
231
  detail: { documentElement: clone }
196
232
  }));
197
-
198
- // Store snapshot HTML for Hyperclay Local platform sync
199
- // This allows the save system to send both stripped and full versions
200
- const isHyperclayLocal = window.location.hostname === 'localhost' ||
201
- window.location.hostname === '127.0.0.1';
202
- if (isHyperclayLocal) {
203
- window.__hyperclaySnapshotHtml = '<!DOCTYPE html>' + clone.outerHTML;
204
- }
205
233
  }
206
234
 
207
235
  // Run inline [onbeforesave] handlers
208
- for (const el of clone.querySelectorAll('[onbeforesave]')) {
209
- new Function(el.getAttribute('onbeforesave')).call(el);
210
- }
236
+ runAuthoredHandlers(clone, 'onbeforesave');
211
237
 
212
238
  // Clone for comparison before stripping (cheaper than cloning live DOM)
213
239
  const compareClone = clonePreventingOnclone(clone);
214
240
 
215
- // Save clone: strip [no-save]/[save-remove], then run hooks (freeze restore in hooks)
241
+ // Save clone: run hooks (freeze restore lives here), THEN strip [no-save]/[save-remove]
242
+ // LAST (snapshot-algorithm step 7) so freeze-restored [no-save] content can't leak to disk.
243
+ for (const hook of documentTransforms) {
244
+ hook(clone);
245
+ }
216
246
  for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
217
247
  el.remove();
218
248
  }
219
- for (const hook of prepareForSaveHooks) {
220
- hook(clone);
221
- }
222
249
  const forSave = "<!DOCTYPE html>" + clone.outerHTML;
223
250
 
224
251
  // Compare clone: strip every autosave-off region, then run hooks
225
252
  for (const el of compareClone.querySelectorAll(STRIP_FROM_COMPARISON)) {
226
253
  el.remove();
227
254
  }
228
- for (const hook of prepareForSaveHooks) {
255
+ for (const hook of documentTransforms) {
229
256
  hook(compareClone);
230
257
  }
231
258
  const forComparison = "<!DOCTYPE html>" + compareClone.outerHTML;
232
259
 
233
- return { forSave, forComparison };
260
+ return { forSave, forComparison, snapshotHtml };
234
261
  }
235
262
 
236
263
  /**
@@ -257,6 +284,35 @@ export function captureForSave({ emitForSync = true } = {}) {
257
284
  return prepareCloneForSave(clone);
258
285
  }
259
286
 
287
+ /**
288
+ * The bytes a live-sync broadcast carries: the snapshot, minus the root
289
+ * attributes that belong to this tab alone. A third serialization of the same
290
+ * clone alongside the snapshot and the document, and the only one that crosses
291
+ * into another person's browser.
292
+ *
293
+ * Takes the clone rather than capturing one, because the caller already has the
294
+ * snapshot-ready clone and capturing again would run every hook a second time.
295
+ * (Not to be confused with captureBodyForSync below, which is the older
296
+ * body-innerHTML helper and unrelated to the live-sync lane.)
297
+ *
298
+ * The clone is detached and unobserved, so removing the attributes in place and
299
+ * putting them back is exact, and far cheaper than cloning the tree again. The
300
+ * finally is load-bearing: the save path reads this same clone afterwards.
301
+ */
302
+ export function serializeForSync(clone) {
303
+ const removed = [];
304
+ for (const name of TAB_LOCAL_ROOT_ATTRS) {
305
+ if (!clone.hasAttribute(name)) continue;
306
+ removed.push([name, clone.getAttribute(name)]);
307
+ clone.removeAttribute(name);
308
+ }
309
+ try {
310
+ return clone.outerHTML;
311
+ } finally {
312
+ for (const [name, value] of removed) clone.setAttribute(name, value);
313
+ }
314
+ }
315
+
260
316
  /**
261
317
  * PHASE 1-2 (body only): For live-sync between admin users.
262
318
  *
@@ -13,7 +13,7 @@
13
13
  * Requires the 'save-system' module (automatically included as dependency).
14
14
  */
15
15
 
16
- import { isOwner, isEditMode } from "./is-edit-mode.js";
16
+ import { isEditMode } from "./is-edit-mode.js";
17
17
  import { captureForComparison } from "./snapshot.js";
18
18
  import { getLastSavedContents } from "./save.js";
19
19
  import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
@@ -21,8 +21,14 @@ import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug.js";
21
21
  // Pre-load diff library if debug mode is on (so it's ready for unload)
22
22
  preloadIfEnabled();
23
23
 
24
+ // Gated on isEditMode, not isOwner. isOwner means the platform's admin cookie
25
+ // specifically, so gating on it switched the warning off for every host that
26
+ // authenticates another way: htmlclay, anything using a root save token, and any
27
+ // sandboxed document, which cannot read cookies at all. Those are exactly the
28
+ // documents where an unsaved edit is easiest to lose. If the page is editable,
29
+ // the person editing it deserves the warning.
24
30
  window.addEventListener('beforeunload', (event) => {
25
- if (!isOwner || !isEditMode) return;
31
+ if (!isEditMode) return;
26
32
 
27
33
  // Compare directly - both are already stripped
28
34
  const currentForCompare = captureForComparison();