@panphora/clayjs 1.13.1 → 1.15.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 (146) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69385 -40992
  3. package/dist/server/checkpoint.cjs +1 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/core.cjs +72210 -0
  6. package/dist/server/rooms.cjs +1 -0
  7. package/dist/server/rooms.mjs +66 -0
  8. package/docs/guide.md +127 -99
  9. package/entries/clay.js +22 -0
  10. package/entries/sap.js +5 -5
  11. package/package.json +16 -2
  12. package/packed-contract.json +18 -3
  13. package/src/core/boot-phase.js +6 -0
  14. package/src/core/conflict-download.js +12 -3
  15. package/src/core/host-meta.js +75 -0
  16. package/src/core/persist.js +11 -0
  17. package/src/core/save-core.js +266 -31
  18. package/src/core/save.js +154 -36
  19. package/src/core/snapshot.js +51 -13
  20. package/src/core/style-comparisons.js +37 -0
  21. package/src/dom/insert-style-tag.js +11 -12
  22. package/src/lib/derived.js +116 -0
  23. package/src/lib/effect.js +130 -0
  24. package/src/lib/mutation.js +3 -1
  25. package/src/lib/style-adoption.js +9 -0
  26. package/src/lib/style.js +47 -0
  27. package/src/live/fast-lane.js +347 -60
  28. package/src/live/local-lane.js +32 -6
  29. package/src/live/ops.js +77 -0
  30. package/src/live/provenance.js +129 -0
  31. package/src/live/save-history.js +188 -0
  32. package/src/live/server-lane.js +9 -2
  33. package/src/live/store.js +194 -16
  34. package/src/loader-logic.js +16 -4
  35. package/src/loader.js +37 -1
  36. package/src/page/address.js +20 -0
  37. package/src/page/adopt-boot.js +189 -0
  38. package/src/page/adopt.js +240 -0
  39. package/src/page/ai-tool.js +188 -0
  40. package/src/page/announce.js +21 -0
  41. package/src/page/blocks.js +1170 -0
  42. package/src/page/blocksel.js +269 -0
  43. package/src/page/board-drag.js +226 -0
  44. package/src/page/board-panel.js +353 -0
  45. package/src/page/board-style.js +146 -0
  46. package/src/page/board-view.js +447 -0
  47. package/src/page/board.js +41 -0
  48. package/src/page/chart.js +220 -0
  49. package/src/page/checklist.js +143 -0
  50. package/src/page/chrome.js +587 -0
  51. package/src/page/csv-import.js +169 -0
  52. package/src/page/drag.js +202 -0
  53. package/src/page/drop.js +112 -0
  54. package/src/page/editor.js +386 -0
  55. package/src/page/highlight.js +268 -0
  56. package/src/page/inline.js +60 -1
  57. package/src/page/items.js +159 -0
  58. package/src/page/link.js +247 -0
  59. package/src/page/markdown.js +71 -0
  60. package/src/page/media.js +570 -0
  61. package/src/page/merge.js +130 -0
  62. package/src/page/model.js +71 -9
  63. package/src/page/placeholder.js +23 -0
  64. package/src/page/projection.js +647 -0
  65. package/src/page/recover.js +193 -0
  66. package/src/page/rich/cell.js +16 -0
  67. package/src/page/rich/chart.js +104 -0
  68. package/src/page/rich/csv.js +118 -0
  69. package/src/page/rich/page-tool.js +86 -0
  70. package/src/page/rich/tool-runtime.js +195 -0
  71. package/src/page/save-projection.js +53 -0
  72. package/src/page/save-states.js +85 -0
  73. package/src/page/schema.js +12 -5
  74. package/src/page/table-kinds.js +20 -0
  75. package/src/page/table-paste.js +57 -0
  76. package/src/page/table-select.js +244 -0
  77. package/src/page/table-style.js +25 -0
  78. package/src/page/table.js +650 -0
  79. package/src/page/themes/content.css +46 -0
  80. package/src/page/themes/light-1.css +269 -0
  81. package/src/page/themes/light-2.css +211 -0
  82. package/src/page/themes/light-3.css +130 -0
  83. package/src/page/themes/light-4.css +299 -0
  84. package/src/page/tool-data.js +25 -0
  85. package/src/page/tool-view.js +68 -0
  86. package/src/page/tools.js +133 -0
  87. package/src/page/view.js +136 -0
  88. package/src/page/ybind/author.js +25 -0
  89. package/src/page/ybind/binding.js +92 -0
  90. package/src/page/ybind/board-commands.js +170 -0
  91. package/src/page/ybind/board.js +89 -0
  92. package/src/page/ybind/bridge.js +382 -0
  93. package/src/page/ybind/conversion.js +26 -0
  94. package/src/page/ybind/core.js +7 -0
  95. package/src/page/ybind/decode.js +208 -0
  96. package/src/page/ybind/doc.js +58 -0
  97. package/src/page/ybind/external.js +190 -0
  98. package/src/page/ybind/group.js +124 -0
  99. package/src/page/ybind/index.js +12 -0
  100. package/src/page/ybind/layout.js +27 -0
  101. package/src/page/ybind/marks.js +18 -0
  102. package/src/page/ybind/migrate.js +152 -0
  103. package/src/page/ybind/moves.js +288 -0
  104. package/src/page/ybind/origins.js +257 -0
  105. package/src/page/ybind/parts.js +44 -0
  106. package/src/page/ybind/projection-map.js +92 -0
  107. package/src/page/ybind/provider.js +285 -0
  108. package/src/page/ybind/recovery.js +411 -0
  109. package/src/page/ybind/seed.js +27 -0
  110. package/src/page/ybind/table.js +166 -0
  111. package/src/page/ybind/tokens.js +93 -0
  112. package/src/page/ybind/walk.js +333 -0
  113. package/src/page/ybind/writer.js +307 -0
  114. package/src/plugins/derive.js +12 -0
  115. package/src/plugins/indicator.js +16 -6
  116. package/src/plugins/live.js +742 -65
  117. package/src/plugins/page.js +547 -0
  118. package/src/plugins/undo.js +62 -4
  119. package/src/server/checkpoint/index.js +218 -0
  120. package/src/server/rooms/index.js +59 -0
  121. package/src/server/rooms/live-data-ops.js +369 -0
  122. package/src/server/rooms/live-data-rooms.js +965 -0
  123. package/src/server/rooms/live-data-tags.js +49 -0
  124. package/src/server/rooms/live-page-history.js +41 -0
  125. package/src/server/rooms/live-provenance.js +142 -0
  126. package/src/server/rooms/page-admission.js +79 -0
  127. package/src/server/rooms/page-conversion.js +62 -0
  128. package/src/server/rooms/page-rebase.js +108 -0
  129. package/src/server/rooms/page-state.js +91 -0
  130. package/src/server/rooms/page-write-admission.js +63 -0
  131. package/src/server/rooms/page-write-guard.js +23 -0
  132. package/src/server/rooms/recent.js +18 -0
  133. package/src/server/rooms/spec-wire.js +17 -0
  134. package/src/standalone.js +22 -0
  135. package/src/sync/conflict-revert.js +2 -0
  136. package/src/sync/conflicts.js +2 -2
  137. package/src/sync/live-sync.js +36 -8
  138. package/src/sync/stream.js +62 -16
  139. package/src/ui/index.js +4 -0
  140. package/src/ui/toast.js +21 -5
  141. package/src/vendor/pierre/pierre.vendor.js +3 -0
  142. package/src/vendor/sap-core.vendor.js +9 -0
  143. package/src/vendor/sap-ownership.vendor.js +111 -0
  144. package/src/vendor/yjs.vendor.js +7 -0
  145. package/src/page/binding.js +0 -647
  146. package/src/page/replay.js +0 -578
package/src/core/save.js CHANGED
@@ -13,7 +13,7 @@ import throttle from "../lib/throttle.js";
13
13
  import Mutation from "../lib/mutation.js";
14
14
  import { isDraftMode, isEditMode, isOwner } from "./is-edit-mode.js";
15
15
  import {
16
- saveHtml,
16
+ saveCapturedHtml,
17
17
  getPageContents,
18
18
  replacePageWith as replacePageWithCore,
19
19
  addDocumentTransform,
@@ -21,12 +21,15 @@ import {
21
21
  saveFateIsUnknown,
22
22
  withKeepalive
23
23
  } from "./save-core.js";
24
- import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison, captureForDirtyCheck } from "./snapshot.js";
24
+ import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison, captureForDirtyCheck, captureSaveClone } from "./snapshot.js";
25
+ import { paintSignature } from "../lib/derived.js";
25
26
  import { NO_TRIGGER_AUTOSAVE_SELECTOR } from "../lib/region-policy.js";
26
27
  import { seedEtag, lastSeenEtag } from "./etag.js";
27
28
  import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
28
29
  import { hasUnsavedState } from "../lib/unsaved-state.js";
29
30
  import { autosaveActive } from "../lib/autosave-state.js";
31
+ import { registerStyleAdoption } from "../lib/style-adoption.js";
32
+ import { styleComparisonStripper } from "./style-comparisons.js";
30
33
  import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS, HOST_RESPONSE_ATTRS } from "../lib/root-attrs.js";
31
34
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
32
35
  import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
@@ -35,6 +38,7 @@ import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/use
35
38
  // Both read across it at call time, never while a module is evaluating, so neither
36
39
  // side sees a half-built module.
37
40
  import { bytesUnsaved } from "./unsaved-warning.js";
41
+ import { bootPhase } from "./boot-phase.js";
38
42
 
39
43
  // Keep this library's own root state out of the saved bytes.
40
44
  //
@@ -188,6 +192,8 @@ let unsavedChanges = false;
188
192
  // the split is observable there.
189
193
  let lastSavedContents = '';
190
194
  let lastSavedDirty = '';
195
+ let baselineRevision = 0;
196
+ const inFlightComparisons = new Set();
191
197
  // A save was requested while one was on the wire; run one more when it settles.
192
198
  let pendingSave = false;
193
199
  // An autosave arrived while one of our saves was on the wire. When that save lands
@@ -374,12 +380,11 @@ function skipped_(msg) {
374
380
  * where the write may or may not have landed, so it is not treated as success
375
381
  * either.
376
382
  */
377
- function applySaveResult(result, forComparison, forDirty, label, gateToken, forSave = null) {
383
+ function applySaveResult(result, forComparison, forDirty, label, gateToken, forSave = null, captureId = null, quietConflict = false) {
378
384
  if (result.ok) {
379
385
  // Both baselines advance from the SAME pre-request capture, never from the
380
386
  // live DOM, so an edit made while the request was on the wire stays unsaved.
381
- lastSavedContents = forComparison;
382
- lastSavedDirty = forDirty;
387
+ setLastSavedBaselines(forComparison, forDirty);
383
388
  lastSavedBytes = forSave;
384
389
  unsavedChanges = false;
385
390
  // Generation-checked: clears the scoped-sync dirty gate only if nothing
@@ -391,15 +396,39 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken, forS
391
396
  logBaseline(label, `${lastSavedContents.length} chars`);
392
397
  releaseConflictHold();
393
398
  } else if (result.msgType === 'conflict') {
399
+ // A refused heal leaves the tab as it was: still clean, no save event, no
400
+ // hold. The save that beat it reaches this tab through sync.
401
+ if (quietConflict) {
402
+ if (savingTimeout) {
403
+ clearTimeout(savingTimeout);
404
+ savingTimeout = null;
405
+ }
406
+ document.documentElement.setAttribute('savestatus', 'saved');
407
+ return;
408
+ }
394
409
  holdForConflict(result.conflictEtag);
395
410
  setSaveState('conflict', result.msg, result.msgType, {
396
411
  hadTimeout: result.hadTimeout === true,
397
412
  changedBy: result.changedBy ?? null,
398
413
  afterTimeout: result.afterTimeout === true,
399
414
  etag: result.conflictEtag ?? null,
415
+ code: result.code ?? null,
416
+ reason: result.reason ?? null,
417
+ retryAfter: result.retryAfter ?? null,
418
+ captureId,
400
419
  });
401
420
  } else if (result.msgType !== 'skipped') {
402
- const extra = { status: result.status ?? null, hadTimeout: result.hadTimeout === true };
421
+ const extra = {
422
+ status: result.status ?? null,
423
+ hadTimeout: result.hadTimeout === true,
424
+ // The host's typed refusal, so a listener can react to the specific code
425
+ // rather than reading the message. `retryAfter` is only set where the host
426
+ // asked for a delay.
427
+ code: result.code ?? null,
428
+ reason: result.reason ?? null,
429
+ retryAfter: result.retryAfter ?? null,
430
+ captureId,
431
+ };
403
432
  if (!navigator.onLine) {
404
433
  setSaveState('offline', result.msg, '', extra);
405
434
  } else {
@@ -455,14 +484,14 @@ export function getLastSavedBytes() { return lastSavedBytes; }
455
484
  export function setLastSavedBaselines(forComparison, forDirty) {
456
485
  lastSavedContents = forComparison;
457
486
  lastSavedDirty = forDirty;
487
+ baselineRevision++;
458
488
  }
459
489
 
460
490
  // Kept for back-compat: a caller with one string means the page has no batching
461
491
  // region, or it does not know about the split. Setting both from it is the safe
462
492
  // reading, since the domains coincide exactly when there is no such region.
463
493
  export function setLastSavedContents(val) {
464
- lastSavedContents = val;
465
- lastSavedDirty = val;
494
+ setLastSavedBaselines(val, val);
466
495
  }
467
496
 
468
497
  /**
@@ -509,15 +538,15 @@ export function savePage(callback = () => {}) {
509
538
  // Single capture: clone once, get both versions
510
539
  // forSave strips non-persisted regions ([no-save]/[save-remove])
511
540
  // forComparison additionally strips every autosave-off region
512
- let forSave, forComparison, forDirty;
541
+ let forSave, forComparison, forDirty, captureId;
513
542
  const gateToken = gateCaptureToken();
514
543
  try {
515
- ({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
544
+ ({ forSave, forComparison, forDirty, captureId } = captureForSaveAndComparison({ requestRefresh: true }));
516
545
  } catch (err) {
517
546
  console.error('savePage: captureForSaveAndComparison failed', err);
518
547
  clearExplicitSave();
519
- setSaveState('error', err.message);
520
- const result = { msg: err.message, msgType: 'error', code: null, etag: null };
548
+ setSaveState('error', err.message, '', { sent: false, code: err.code ?? null });
549
+ const result = { msg: err.message, msgType: 'error', code: err.code ?? null, etag: null };
521
550
  if (typeof callback === 'function') {
522
551
  callback(result);
523
552
  }
@@ -545,11 +574,14 @@ export function savePage(callback = () => {}) {
545
574
  // Start debounced 'saving' state (only shows if save takes >500ms)
546
575
  setSavingState();
547
576
 
548
- // Use saveHtml directly with our pre-captured content (avoids double capture)
577
+ // Use the captured save path with our pre-captured content (avoids double capture)
578
+ const comparison = { forComparison, forDirty };
579
+ inFlightComparisons.add(comparison);
549
580
  ownSaveOnWire = true;
550
- saveHtml(forSave, (result) => {
581
+ saveCapturedHtml(forSave, captureId, (result) => {
551
582
  ownSaveOnWire = false;
552
- applySaveResult(result, forComparison, forDirty, 'updated after save', gateToken, forSave);
583
+ inFlightComparisons.delete(comparison);
584
+ applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after save', gateToken, forSave, captureId);
553
585
  if (typeof callback === 'function') {
554
586
  callback(result);
555
587
  }
@@ -565,7 +597,7 @@ export function savePage(callback = () => {}) {
565
597
  * @param {Function} callback - Optional callback for custom handling
566
598
  * @returns {Promise<{msg: string, msgType: string}>}
567
599
  */
568
- export function savePageForce(callback = () => {}) {
600
+ export function savePageForce(callback = () => {}, { quietConflict = false } = {}) {
569
601
  return new Promise((resolve) => {
570
602
  if (!isEditMode && !window.clay?.testMode) {
571
603
  const skipped = skipped_('Not in edit mode');
@@ -587,14 +619,14 @@ export function savePageForce(callback = () => {}) {
587
619
  setOfflineStateQuiet();
588
620
  }
589
621
 
590
- let forSave, forComparison, forDirty;
622
+ let forSave, forComparison, forDirty, captureId;
591
623
  const gateToken = gateCaptureToken();
592
624
  try {
593
- ({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
625
+ ({ forSave, forComparison, forDirty, captureId } = captureForSaveAndComparison({ requestRefresh: true }));
594
626
  } catch (err) {
595
627
  console.error('savePageForce: captureForSaveAndComparison failed', err);
596
- setSaveState('error', err.message);
597
- const result = { msg: err.message, msgType: 'error', code: null, etag: null };
628
+ setSaveState('error', err.message, '', { sent: false, code: err.code ?? null });
629
+ const result = { msg: err.message, msgType: 'error', code: err.code ?? null, etag: null };
598
630
  if (typeof callback === 'function') {
599
631
  callback(result);
600
632
  }
@@ -603,10 +635,13 @@ export function savePageForce(callback = () => {}) {
603
635
 
604
636
  setSavingState();
605
637
 
638
+ const comparison = { forComparison, forDirty };
639
+ inFlightComparisons.add(comparison);
606
640
  ownSaveOnWire = true;
607
- saveHtml(forSave, (result) => {
641
+ saveCapturedHtml(forSave, captureId, (result) => {
608
642
  ownSaveOnWire = false;
609
- applySaveResult(result, forComparison, forDirty, 'updated after force save', gateToken, forSave);
643
+ inFlightComparisons.delete(comparison);
644
+ applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after force save', gateToken, forSave, captureId, quietConflict);
610
645
  if (typeof callback === 'function') {
611
646
  callback(result);
612
647
  }
@@ -774,6 +809,67 @@ let baselineActive = true;
774
809
  let baselineSettledAt = false;
775
810
  export function baselineSettled() { return baselineSettledAt; }
776
811
 
812
+ // A repaint that leaves the page's painted values different from the file's means
813
+ // the file is stale: an agent wrote a record without painting it. One clean,
814
+ // autosaving tab saves the painted page so the bytes on disk heal. The delay is
815
+ // jittered and the check reruns when it fires, so when several tabs repaint the
816
+ // same frame, the first save reaches the others and they skip.
817
+ const PAINT_HEAL_MIN_MS = 250;
818
+ const PAINT_HEAL_SPREAD_MS = 1250;
819
+ let paintHealTimer = null;
820
+
821
+ const HEAL_KEY = 'clay:paint-heal:' + location.pathname;
822
+
823
+ function hashSignature(s) {
824
+ let h = 5381;
825
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
826
+ return String(h >>> 0);
827
+ }
828
+
829
+ function healedFrom(signature) {
830
+ try { return localStorage.getItem(HEAL_KEY) === hashSignature(signature); } catch { return false; }
831
+ }
832
+
833
+ function noteHealedFrom(signature) {
834
+ try { localStorage.setItem(HEAL_KEY, hashSignature(signature)); } catch {}
835
+ }
836
+
837
+ /** The painted values of the page as it would be saved. */
838
+ export function currentPaintSignature() {
839
+ return paintSignature(captureSaveClone({ flushUndo: false }));
840
+ }
841
+
842
+ /**
843
+ * Save the page if its painted values differ from the file's. `fileSignature` is
844
+ * the file's paintSignature, or a function returning it (or null when unknown)
845
+ * when the timer fires.
846
+ */
847
+ export function schedulePaintHeal(fileSignature) {
848
+ if (!isEditMode || isDraftMode) return;
849
+ clearTimeout(paintHealTimer);
850
+ paintHealTimer = setTimeout(() => {
851
+ paintHealTimer = null;
852
+ const onDisk = typeof fileSignature === 'function' ? fileSignature() : fileSignature;
853
+ if (onDisk == null || !autosaveActive() || !baselineSettled() || isSaveConflicted() || autosaveIsSuspended()) return;
854
+ if (captureForDirtyCheck({ flushUndo: false }) !== lastSavedDirty) return;
855
+ if (onDisk === currentPaintSignature()) return;
856
+ if (healedFrom(onDisk)) return;
857
+ noteHealedFrom(onDisk);
858
+ savePageForce(() => {}, { quietConflict: true });
859
+ }, PAINT_HEAL_MIN_MS + Math.random() * PAINT_HEAL_SPREAD_MS);
860
+ }
861
+
862
+ registerStyleAdoption(identity => {
863
+ const strip = styleComparisonStripper(identity);
864
+ lastSavedContents = strip(lastSavedContents);
865
+ lastSavedDirty = strip(lastSavedDirty);
866
+ baselineContents = strip(baselineContents);
867
+ for (const comparison of inFlightComparisons) {
868
+ comparison.forComparison = strip(comparison.forComparison);
869
+ comparison.forDirty = strip(comparison.forDirty);
870
+ }
871
+ });
872
+
777
873
  // ============================================
778
874
  // BASELINE CAPTURE (Settled Signal)
779
875
  // ============================================
@@ -787,10 +883,23 @@ export function baselineSettled() { return baselineSettledAt; }
787
883
  // WHY IMMEDIATE + CONDITIONAL UPDATE:
788
884
  // We set baseline immediately as a safety net. If the user edits or saves
789
885
  // before settle completes, we don't overwrite their work. The settled snapshot
790
- // only replaces baseline if nothing changed (lastSavedContents === immediateContents).
886
+ // only replaces baseline if no user input or baseline installation intervened.
791
887
 
792
888
  const SETTLE_MS = 500; // Wait for no mutations for this long
793
889
  const MAX_SETTLE_MS = 3000; // Max time to wait before forcing capture
890
+ const MAX_HOLD_MS = 10000; // A held settle releases itself after this
891
+
892
+ let settleNow = null;
893
+
894
+ /**
895
+ * The loader's mount phase calls this once the derive engine has taken its first
896
+ * pass. The settle runs at once on the painted page, unless a person already
897
+ * edited or a save already installed a baseline (captureBaseline's guard).
898
+ */
899
+ export function releaseBootSettle() {
900
+ bootPhase.holdSettle = false;
901
+ if (settleNow) settleNow();
902
+ }
794
903
 
795
904
  function initBaselineCapture() {
796
905
  if (!isEditMode) return;
@@ -803,8 +912,8 @@ function initBaselineCapture() {
803
912
  // This ensures saves during settle window work correctly
804
913
  // Store stripped version so comparisons are direct (no parsing needed)
805
914
  const { forComparison: immediateContents, forDirty: immediateDirty } = captureForComparisonAndDirty();
806
- lastSavedContents = immediateContents;
807
- lastSavedDirty = immediateDirty;
915
+ setLastSavedBaselines(immediateContents, immediateDirty);
916
+ const immediateRevision = baselineRevision;
808
917
  baselineContents = immediateContents;
809
918
  logBaseline('immediate capture', `${immediateContents.length} chars`);
810
919
 
@@ -829,18 +938,14 @@ function initBaselineCapture() {
829
938
  if (unsubscribeMutation) unsubscribeMutation();
830
939
  userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
831
940
 
832
- // Only update if no user edits AND no saves occurred during settle.
833
- // BOTH baselines have to be untouched, not just the autosave one: a save
834
- // whose only change was inside a batching region advances lastSavedDirty
835
- // while leaving lastSavedContents byte-identical to the immediate capture.
836
- // Checking one would let a second, unsent edit in that region be captured
837
- // here as though it had been saved.
838
- if (!userEdited && lastSavedContents === immediateContents && lastSavedDirty === immediateDirty) {
941
+ // Adoption only removes owned styles from comparison state. It does not
942
+ // advance this revision. A save or sync installation does, even when its
943
+ // comparison bytes match, so settle cannot absorb a later unsent edit.
944
+ if (!userEdited && baselineRevision === immediateRevision) {
839
945
  // Store stripped version so comparisons are direct (no parsing needed)
840
946
  const gateToken = gateCaptureToken();
841
947
  const { forComparison: contents, forDirty: contentsDirty } = captureForComparisonAndDirty();
842
- lastSavedContents = contents;
843
- lastSavedDirty = contentsDirty;
948
+ setLastSavedBaselines(contents, contentsDirty);
844
949
  baselineContents = contents;
845
950
  // Boot churn (modules rewriting attributes at DOM-ready) counted toward
846
951
  // the scoped-sync gate; the settled baseline is the proof it wasn't edits.
@@ -862,19 +967,32 @@ function initBaselineCapture() {
862
967
  baselineSettledAt = true;
863
968
  document.dispatchEvent(new CustomEvent('clay:baseline-settled'));
864
969
  };
970
+ settleNow = captureBaseline;
971
+
972
+ // While the loader holds the settle for a derive engine's first pass, neither
973
+ // the quiet timer nor the max timeout may settle; releaseBootSettle does.
974
+ const settle = () => {
975
+ if (!bootPhase.holdSettle) captureBaseline();
976
+ };
865
977
 
866
978
  // Start settle observer - fires when no mutations for SETTLE_MS.
867
979
  // require:'autosave' so churn in no-save / save-* / no-watch regions doesn't
868
980
  // keep resetting the settle timer or count toward the baseline.
869
981
  unsubscribeMutation = Mutation.onAnyChange(
870
982
  { debounce: SETTLE_MS, omitChangeDetails: true, require: 'autosave' },
871
- captureBaseline
983
+ settle
872
984
  );
873
985
 
874
986
  // Max timeout fallback
875
987
  setTimeout(() => {
876
- if (!settled) captureBaseline();
988
+ if (!settled) settle();
877
989
  }, MAX_SETTLE_MS);
990
+
991
+ // The loader releases a held settle after the derive engine mounts. If boot
992
+ // never gets there (a plugin's ready stalls, boot throws), release it anyway.
993
+ setTimeout(() => {
994
+ if (!settled && bootPhase.holdSettle) releaseBootSettle();
995
+ }, MAX_HOLD_MS);
878
996
  }
879
997
 
880
998
  // Run when DOM is ready
@@ -43,6 +43,7 @@ import { restoreAuthoredUrls } from '../lib/authored-url.js';
43
43
  import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, STRIP_FROM_DIRTY_CHECK, NO_TRIGGER_AUTOSAVE_SELECTOR, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
44
44
  import { TAB_LOCAL_ROOT_ATTRS, stripRouteAttrs } from '../lib/root-attrs.js';
45
45
  import { stripEphemeral } from '../lib/ephemeral.js';
46
+ import { stripDerived } from '../lib/derived.js';
46
47
  import { createContentView } from '../lib/content-dom.js';
47
48
 
48
49
  // =============================================================================
@@ -58,6 +59,17 @@ const snapshotProvenance = new WeakMap();
58
59
  // second renderer would be two answers to "what bytes does this save send".
59
60
  let saveRenderer = null;
60
61
 
62
+ // The capture identity for save captures. A refused save that may retry reads it
63
+ // before sending and again before each retry: a capture newer than the one those
64
+ // bytes describe means the attempt is already superseded and must not be re-sent.
65
+ // It is an opaque local count, monotone per loaded module, and never goes on the
66
+ // wire; it is what binds one capture to the exact requests that carried its body.
67
+ let captureCounter = 0;
68
+
69
+ export function currentCaptureId() {
70
+ return captureCounter;
71
+ }
72
+
61
73
  export function originalSnapshotNode(cloneNode) {
62
74
  return snapshotProvenance.get(cloneNode) || null;
63
75
  }
@@ -224,7 +236,7 @@ function clonePreventingOnclone(node, deep = true) {
224
236
  finally { window.__preventOnclone = prev; }
225
237
  }
226
238
 
227
- export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
239
+ export function captureSnapshot({ flushUndo = true, authored = true, forSave = false, requestRefresh = false } = {}) {
228
240
  // Force-close any pending undo idle batch BEFORE cloning the DOM, so the
229
241
  // snapshot reflects a clean undo boundary. Without this, a save that fires
230
242
  // mid-typing would leave the idle batch open across the save boundary, and
@@ -245,7 +257,7 @@ export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
245
257
 
246
258
  for (const hook of snapshotHooks) {
247
259
  stripSnapshotRegions(clone);
248
- hook(clone, { original: originalSnapshotNode });
260
+ hook(clone, { original: originalSnapshotNode, forSave, requestRefresh });
249
261
  }
250
262
 
251
263
  // Put back any URL clay rewrote at runtime (cache-bust, refetch-on-save) so
@@ -302,9 +314,17 @@ function prepareCloneForSave(clone, { authored = true } = {}) {
302
314
  el.remove();
303
315
  }
304
316
 
317
+ stripDiagnosticBeacons(clone);
318
+
305
319
  return clone;
306
320
  }
307
321
 
322
+ // sapjs marks an element it cannot run with a sap-error beacon. It describes this
323
+ // tab's engine, never the document, so no save carries it.
324
+ function stripDiagnosticBeacons(clone) {
325
+ for (const el of clone.querySelectorAll("[sap-error]")) el.removeAttribute("sap-error");
326
+ }
327
+
308
328
  /**
309
329
  * The save-domain clone, for a caller that needs the tree rather than the bytes.
310
330
  *
@@ -327,6 +347,20 @@ export function captureSaveClone({ flushUndo = true, authored = true } = {}) {
327
347
  return prepareCloneForSave(captureSnapshot({ flushUndo, authored }), { authored });
328
348
  }
329
349
 
350
+ // Attributes that render exactly like their absence. A scroll lock or a browser
351
+ // extension that resets an inline style leaves `style=""` behind, and the dirty
352
+ // check must not read that as an edit the person would lose on close.
353
+ const EMPTY_INERT_ATTRS = ['style', 'class'];
354
+
355
+ function serializeForCompare(clone) {
356
+ stripDerived(clone);
357
+ for (const name of EMPTY_INERT_ATTRS) {
358
+ if (clone.getAttribute(name) === '') clone.removeAttribute(name);
359
+ for (const el of clone.querySelectorAll(`[${name}=""]`)) el.removeAttribute(name);
360
+ }
361
+ return "<!DOCTYPE html>" + clone.outerHTML;
362
+ }
363
+
330
364
  /**
331
365
  * Capture snapshot prepared for dirty/change comparison.
332
366
  *
@@ -355,7 +389,7 @@ export function captureForComparison({ flushUndo = true } = {}) {
355
389
 
356
390
  stripSnapshotRegions(clone);
357
391
 
358
- return "<!DOCTYPE html>" + clone.outerHTML;
392
+ return serializeForCompare(clone);
359
393
  }
360
394
 
361
395
  /**
@@ -388,7 +422,7 @@ export function captureForDirtyCheck({ flushUndo = true } = {}) {
388
422
 
389
423
  stripSnapshotRegions(clone);
390
424
 
391
- return "<!DOCTYPE html>" + clone.outerHTML;
425
+ return serializeForCompare(clone);
392
426
  }
393
427
 
394
428
  /**
@@ -419,7 +453,7 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
419
453
  hook(clone);
420
454
  }
421
455
  stripSnapshotRegions(clone);
422
- const forComparison = "<!DOCTYPE html>" + clone.outerHTML;
456
+ const forComparison = serializeForCompare(clone);
423
457
 
424
458
  let forDirty = forComparison;
425
459
  if (dirtyClone) {
@@ -431,7 +465,7 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
431
465
  hook(dirtyClone);
432
466
  }
433
467
  stripSnapshotRegions(dirtyClone);
434
- forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
468
+ forDirty = serializeForCompare(dirtyClone);
435
469
  }
436
470
 
437
471
  return { forComparison, forDirty };
@@ -445,10 +479,12 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
445
479
  *
446
480
  * @param {Object} options
447
481
  * @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
448
- * @returns {{ forSave: string, forComparison: string, forDirty: string }}
482
+ * @returns {{ forSave: string, forComparison: string, forDirty: string, captureId: number }}
449
483
  */
450
- export function captureForSaveAndComparison({ emitForSync = true, flushUndo = true } = {}) {
451
- const clone = captureSnapshot({ flushUndo });
484
+ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = true, requestRefresh = false } = {}) {
485
+ if (!Number.isSafeInteger(captureCounter + 1)) throw new Error('Save capture identity exhausted');
486
+ const captureId = ++captureCounter;
487
+ const clone = captureSnapshot({ flushUndo, forSave: emitForSync, requestRefresh });
452
488
 
453
489
  // Emit for live-sync before any stripping.
454
490
  //
@@ -459,7 +495,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
459
495
  // and a baseline the dirty check cannot reproduce warns on close forever.
460
496
  if (emitForSync) {
461
497
  document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
462
- detail: { documentElement: clone, forSave: true }
498
+ detail: { documentElement: clone, forSave: true, captureId }
463
499
  }));
464
500
  }
465
501
 
@@ -488,6 +524,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
488
524
  for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
489
525
  el.remove();
490
526
  }
527
+ stripDiagnosticBeacons(clone);
491
528
  const forSave = serializeSaveClone(clone);
492
529
 
493
530
  // Compare clone: strip every autosave-off region, then run hooks
@@ -499,7 +536,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
499
536
  hook(compareClone);
500
537
  }
501
538
  stripSnapshotRegions(compareClone);
502
- const forComparison = "<!DOCTYPE html>" + compareClone.outerHTML;
539
+ const forComparison = serializeForCompare(compareClone);
503
540
 
504
541
  // Dirty clone: same shape as the compare clone, one selector weaker.
505
542
  let forDirty = forComparison;
@@ -512,10 +549,10 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
512
549
  hook(dirtyClone);
513
550
  }
514
551
  stripSnapshotRegions(dirtyClone);
515
- forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
552
+ forDirty = serializeForCompare(dirtyClone);
516
553
  }
517
554
 
518
- return { forSave, forComparison, forDirty };
555
+ return { forSave, forComparison, forDirty, captureId };
519
556
  }
520
557
 
521
558
  /**
@@ -570,6 +607,7 @@ export function captureForMerge() {
570
607
  for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
571
608
  el.remove();
572
609
  }
610
+ stripDiagnosticBeacons(clone);
573
611
 
574
612
  for (const el of compareClone.querySelectorAll(STRIP_FROM_DIRTY_CHECK)) {
575
613
  el.remove();
@@ -0,0 +1,37 @@
1
+ import { parse } from '../vendor/parse5.vendor.js';
2
+ import { getStyleBaseUrl } from '../dom/insert-style-tag.js';
3
+
4
+ export function styleComparisonStripper(identity) {
5
+ const cache = new Map();
6
+ return (html) => {
7
+ if (!html || !html.includes(identity.name !== undefined ? '<style' : '<link')) return html;
8
+ if (cache.has(html)) return cache.get(html);
9
+ const spans = [];
10
+ const tree = parse(html, { sourceCodeLocationInfo: true, scriptingEnabled: true });
11
+ const visit = (node) => {
12
+ const attrs = new Map((node.attrs || []).map(({ name, value }) => [name, value]));
13
+ const owned = identity.name !== undefined
14
+ ? node.tagName === 'style' && (attrs.get('data-name') === identity.name ||
15
+ (identity.legacyId !== undefined && attrs.get('id') === identity.legacyId && !attrs.has('data-name')))
16
+ : node.tagName === 'link' && attrs.get('rel')?.toLowerCase() === 'stylesheet' &&
17
+ getStyleBaseUrl(attrs.get('href') ?? null) === identity.href;
18
+ const loc = node.sourceCodeLocation;
19
+ if (owned && loc?.startTag) {
20
+ spans.push([loc.startTag.startOffset, loc.endTag?.endOffset ?? loc.endOffset]);
21
+ } else {
22
+ for (const child of node.childNodes || []) visit(child);
23
+ }
24
+ };
25
+ visit(tree);
26
+ spans.sort((a, b) => a[0] - b[0]);
27
+ let result = '';
28
+ let offset = 0;
29
+ for (const [from, to] of spans) {
30
+ result += html.slice(offset, from);
31
+ offset = to;
32
+ }
33
+ result += html.slice(offset);
34
+ cache.set(html, result);
35
+ return result;
36
+ };
37
+ }
@@ -1,3 +1,12 @@
1
+ export function getStyleBaseUrl(url) {
2
+ try {
3
+ const parsed = new URL(url, window.location.href);
4
+ return parsed.origin + parsed.pathname;
5
+ } catch {
6
+ return url;
7
+ }
8
+ }
9
+
1
10
  /**
2
11
  * Insert styles into the document (inline CSS or external stylesheet).
3
12
  *
@@ -62,24 +71,14 @@ function insertStyles(nameOrHref, cssOrCallback, callback) {
62
71
  const href = nameOrHref;
63
72
  const cb = typeof cssOrCallback === 'function' ? cssOrCallback : undefined;
64
73
 
65
- // Helper to get base URL without query params (for comparison)
66
- const getBaseUrl = (url) => {
67
- try {
68
- const parsed = new URL(url, window.location.href);
69
- return parsed.origin + parsed.pathname;
70
- } catch {
71
- return url;
72
- }
73
- };
74
-
75
74
  // Normalize href to full URL path (without query params) for comparison
76
- const normalizedHref = getBaseUrl(href);
75
+ const normalizedHref = getStyleBaseUrl(href);
77
76
 
78
77
  // Find all links with matching normalized path (ignoring query params like ?v=)
79
78
  const existingLinks = [...document.querySelectorAll('link[rel="stylesheet"]')]
80
79
  .filter(el => {
81
80
  try {
82
- return getBaseUrl(el.getAttribute('href')) === normalizedHref;
81
+ return getStyleBaseUrl(el.getAttribute('href')) === normalizedHref;
83
82
  } catch {
84
83
  return false;
85
84
  }