@panphora/clayjs 1.13.1 → 1.14.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 (136) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69667 -41708
  3. package/dist/server/checkpoint.cjs +70690 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/rooms.cjs +72039 -0
  6. package/dist/server/rooms.mjs +66 -0
  7. package/docs/guide.md +94 -93
  8. package/entries/clay.js +22 -0
  9. package/package.json +16 -2
  10. package/packed-contract.json +17 -3
  11. package/src/core/conflict-download.js +11 -2
  12. package/src/core/host-meta.js +75 -0
  13. package/src/core/persist.js +11 -0
  14. package/src/core/save-core.js +266 -31
  15. package/src/core/save.js +62 -32
  16. package/src/core/snapshot.js +39 -13
  17. package/src/core/style-comparisons.js +37 -0
  18. package/src/dom/insert-style-tag.js +11 -12
  19. package/src/lib/effect.js +130 -0
  20. package/src/lib/style-adoption.js +9 -0
  21. package/src/lib/style.js +44 -0
  22. package/src/live/fast-lane.js +347 -60
  23. package/src/live/local-lane.js +32 -6
  24. package/src/live/ops.js +77 -0
  25. package/src/live/provenance.js +129 -0
  26. package/src/live/save-history.js +188 -0
  27. package/src/live/server-lane.js +9 -2
  28. package/src/live/store.js +194 -16
  29. package/src/loader-logic.js +7 -3
  30. package/src/loader.js +6 -0
  31. package/src/page/address.js +20 -0
  32. package/src/page/adopt-boot.js +189 -0
  33. package/src/page/adopt.js +240 -0
  34. package/src/page/ai-tool.js +188 -0
  35. package/src/page/announce.js +21 -0
  36. package/src/page/blocks.js +1121 -0
  37. package/src/page/blocksel.js +269 -0
  38. package/src/page/board-drag.js +226 -0
  39. package/src/page/board-panel.js +353 -0
  40. package/src/page/board-style.js +146 -0
  41. package/src/page/board-view.js +447 -0
  42. package/src/page/board.js +41 -0
  43. package/src/page/chart.js +220 -0
  44. package/src/page/checklist.js +143 -0
  45. package/src/page/chrome.js +587 -0
  46. package/src/page/csv-import.js +169 -0
  47. package/src/page/drag.js +202 -0
  48. package/src/page/drop.js +112 -0
  49. package/src/page/editor.js +384 -0
  50. package/src/page/highlight.js +268 -0
  51. package/src/page/inline.js +60 -1
  52. package/src/page/items.js +159 -0
  53. package/src/page/link.js +247 -0
  54. package/src/page/markdown.js +71 -0
  55. package/src/page/media.js +570 -0
  56. package/src/page/merge.js +130 -0
  57. package/src/page/model.js +71 -9
  58. package/src/page/placeholder.js +23 -0
  59. package/src/page/projection.js +647 -0
  60. package/src/page/recover.js +193 -0
  61. package/src/page/rich/cell.js +16 -0
  62. package/src/page/rich/chart.js +104 -0
  63. package/src/page/rich/csv.js +118 -0
  64. package/src/page/rich/page-tool.js +86 -0
  65. package/src/page/rich/tool-runtime.js +195 -0
  66. package/src/page/save-projection.js +53 -0
  67. package/src/page/save-states.js +85 -0
  68. package/src/page/schema.js +12 -5
  69. package/src/page/table-kinds.js +20 -0
  70. package/src/page/table-paste.js +57 -0
  71. package/src/page/table-select.js +244 -0
  72. package/src/page/table-style.js +25 -0
  73. package/src/page/table.js +650 -0
  74. package/src/page/themes/content.css +46 -0
  75. package/src/page/themes/light-1.css +269 -0
  76. package/src/page/themes/light-2.css +211 -0
  77. package/src/page/themes/light-3.css +130 -0
  78. package/src/page/themes/light-4.css +299 -0
  79. package/src/page/tool-data.js +25 -0
  80. package/src/page/tool-view.js +68 -0
  81. package/src/page/tools.js +133 -0
  82. package/src/page/view.js +136 -0
  83. package/src/page/ybind/author.js +25 -0
  84. package/src/page/ybind/binding.js +92 -0
  85. package/src/page/ybind/board-commands.js +170 -0
  86. package/src/page/ybind/board.js +89 -0
  87. package/src/page/ybind/bridge.js +382 -0
  88. package/src/page/ybind/conversion.js +26 -0
  89. package/src/page/ybind/core.js +7 -0
  90. package/src/page/ybind/decode.js +208 -0
  91. package/src/page/ybind/doc.js +58 -0
  92. package/src/page/ybind/external.js +190 -0
  93. package/src/page/ybind/group.js +124 -0
  94. package/src/page/ybind/index.js +12 -0
  95. package/src/page/ybind/layout.js +27 -0
  96. package/src/page/ybind/marks.js +18 -0
  97. package/src/page/ybind/migrate.js +152 -0
  98. package/src/page/ybind/moves.js +288 -0
  99. package/src/page/ybind/origins.js +257 -0
  100. package/src/page/ybind/parts.js +44 -0
  101. package/src/page/ybind/projection-map.js +92 -0
  102. package/src/page/ybind/provider.js +285 -0
  103. package/src/page/ybind/recovery.js +411 -0
  104. package/src/page/ybind/seed.js +27 -0
  105. package/src/page/ybind/table.js +166 -0
  106. package/src/page/ybind/tokens.js +93 -0
  107. package/src/page/ybind/walk.js +333 -0
  108. package/src/page/ybind/writer.js +307 -0
  109. package/src/plugins/indicator.js +16 -6
  110. package/src/plugins/live.js +742 -65
  111. package/src/plugins/page.js +547 -0
  112. package/src/plugins/undo.js +60 -3
  113. package/src/server/checkpoint/index.js +218 -0
  114. package/src/server/rooms/index.js +59 -0
  115. package/src/server/rooms/live-data-ops.js +369 -0
  116. package/src/server/rooms/live-data-rooms.js +965 -0
  117. package/src/server/rooms/live-data-tags.js +49 -0
  118. package/src/server/rooms/live-page-history.js +41 -0
  119. package/src/server/rooms/live-provenance.js +142 -0
  120. package/src/server/rooms/page-admission.js +79 -0
  121. package/src/server/rooms/page-conversion.js +62 -0
  122. package/src/server/rooms/page-rebase.js +108 -0
  123. package/src/server/rooms/page-state.js +91 -0
  124. package/src/server/rooms/page-write-admission.js +63 -0
  125. package/src/server/rooms/page-write-guard.js +23 -0
  126. package/src/server/rooms/recent.js +18 -0
  127. package/src/server/rooms/spec-wire.js +17 -0
  128. package/src/standalone.js +22 -0
  129. package/src/sync/live-sync.js +13 -2
  130. package/src/sync/stream.js +62 -16
  131. package/src/ui/index.js +4 -0
  132. package/src/ui/toast.js +21 -5
  133. package/src/vendor/pierre/pierre.vendor.js +3 -0
  134. package/src/vendor/yjs.vendor.js +7 -0
  135. package/src/page/binding.js +0 -647
  136. 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,
@@ -27,6 +27,8 @@ import { seedEtag, lastSeenEtag } from "./etag.js";
27
27
  import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
28
28
  import { hasUnsavedState } from "../lib/unsaved-state.js";
29
29
  import { autosaveActive } from "../lib/autosave-state.js";
30
+ import { registerStyleAdoption } from "../lib/style-adoption.js";
31
+ import { styleComparisonStripper } from "./style-comparisons.js";
30
32
  import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS, HOST_RESPONSE_ATTRS } from "../lib/root-attrs.js";
31
33
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
32
34
  import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
@@ -188,6 +190,8 @@ let unsavedChanges = false;
188
190
  // the split is observable there.
189
191
  let lastSavedContents = '';
190
192
  let lastSavedDirty = '';
193
+ let baselineRevision = 0;
194
+ const inFlightComparisons = new Set();
191
195
  // A save was requested while one was on the wire; run one more when it settles.
192
196
  let pendingSave = false;
193
197
  // An autosave arrived while one of our saves was on the wire. When that save lands
@@ -374,12 +378,11 @@ function skipped_(msg) {
374
378
  * where the write may or may not have landed, so it is not treated as success
375
379
  * either.
376
380
  */
377
- function applySaveResult(result, forComparison, forDirty, label, gateToken, forSave = null) {
381
+ function applySaveResult(result, forComparison, forDirty, label, gateToken, forSave = null, captureId = null) {
378
382
  if (result.ok) {
379
383
  // Both baselines advance from the SAME pre-request capture, never from the
380
384
  // live DOM, so an edit made while the request was on the wire stays unsaved.
381
- lastSavedContents = forComparison;
382
- lastSavedDirty = forDirty;
385
+ setLastSavedBaselines(forComparison, forDirty);
383
386
  lastSavedBytes = forSave;
384
387
  unsavedChanges = false;
385
388
  // Generation-checked: clears the scoped-sync dirty gate only if nothing
@@ -397,9 +400,23 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken, forS
397
400
  changedBy: result.changedBy ?? null,
398
401
  afterTimeout: result.afterTimeout === true,
399
402
  etag: result.conflictEtag ?? null,
403
+ code: result.code ?? null,
404
+ reason: result.reason ?? null,
405
+ retryAfter: result.retryAfter ?? null,
406
+ captureId,
400
407
  });
401
408
  } else if (result.msgType !== 'skipped') {
402
- const extra = { status: result.status ?? null, hadTimeout: result.hadTimeout === true };
409
+ const extra = {
410
+ status: result.status ?? null,
411
+ hadTimeout: result.hadTimeout === true,
412
+ // The host's typed refusal, so a listener can react to the specific code
413
+ // rather than reading the message. `retryAfter` is only set where the host
414
+ // asked for a delay.
415
+ code: result.code ?? null,
416
+ reason: result.reason ?? null,
417
+ retryAfter: result.retryAfter ?? null,
418
+ captureId,
419
+ };
403
420
  if (!navigator.onLine) {
404
421
  setSaveState('offline', result.msg, '', extra);
405
422
  } else {
@@ -455,14 +472,14 @@ export function getLastSavedBytes() { return lastSavedBytes; }
455
472
  export function setLastSavedBaselines(forComparison, forDirty) {
456
473
  lastSavedContents = forComparison;
457
474
  lastSavedDirty = forDirty;
475
+ baselineRevision++;
458
476
  }
459
477
 
460
478
  // Kept for back-compat: a caller with one string means the page has no batching
461
479
  // region, or it does not know about the split. Setting both from it is the safe
462
480
  // reading, since the domains coincide exactly when there is no such region.
463
481
  export function setLastSavedContents(val) {
464
- lastSavedContents = val;
465
- lastSavedDirty = val;
482
+ setLastSavedBaselines(val, val);
466
483
  }
467
484
 
468
485
  /**
@@ -509,15 +526,15 @@ export function savePage(callback = () => {}) {
509
526
  // Single capture: clone once, get both versions
510
527
  // forSave strips non-persisted regions ([no-save]/[save-remove])
511
528
  // forComparison additionally strips every autosave-off region
512
- let forSave, forComparison, forDirty;
529
+ let forSave, forComparison, forDirty, captureId;
513
530
  const gateToken = gateCaptureToken();
514
531
  try {
515
- ({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
532
+ ({ forSave, forComparison, forDirty, captureId } = captureForSaveAndComparison({ requestRefresh: true }));
516
533
  } catch (err) {
517
534
  console.error('savePage: captureForSaveAndComparison failed', err);
518
535
  clearExplicitSave();
519
- setSaveState('error', err.message);
520
- const result = { msg: err.message, msgType: 'error', code: null, etag: null };
536
+ setSaveState('error', err.message, '', { sent: false, code: err.code ?? null });
537
+ const result = { msg: err.message, msgType: 'error', code: err.code ?? null, etag: null };
521
538
  if (typeof callback === 'function') {
522
539
  callback(result);
523
540
  }
@@ -545,11 +562,14 @@ export function savePage(callback = () => {}) {
545
562
  // Start debounced 'saving' state (only shows if save takes >500ms)
546
563
  setSavingState();
547
564
 
548
- // Use saveHtml directly with our pre-captured content (avoids double capture)
565
+ // Use the captured save path with our pre-captured content (avoids double capture)
566
+ const comparison = { forComparison, forDirty };
567
+ inFlightComparisons.add(comparison);
549
568
  ownSaveOnWire = true;
550
- saveHtml(forSave, (result) => {
569
+ saveCapturedHtml(forSave, captureId, (result) => {
551
570
  ownSaveOnWire = false;
552
- applySaveResult(result, forComparison, forDirty, 'updated after save', gateToken, forSave);
571
+ inFlightComparisons.delete(comparison);
572
+ applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after save', gateToken, forSave, captureId);
553
573
  if (typeof callback === 'function') {
554
574
  callback(result);
555
575
  }
@@ -587,14 +607,14 @@ export function savePageForce(callback = () => {}) {
587
607
  setOfflineStateQuiet();
588
608
  }
589
609
 
590
- let forSave, forComparison, forDirty;
610
+ let forSave, forComparison, forDirty, captureId;
591
611
  const gateToken = gateCaptureToken();
592
612
  try {
593
- ({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
613
+ ({ forSave, forComparison, forDirty, captureId } = captureForSaveAndComparison({ requestRefresh: true }));
594
614
  } catch (err) {
595
615
  console.error('savePageForce: captureForSaveAndComparison failed', err);
596
- setSaveState('error', err.message);
597
- const result = { msg: err.message, msgType: 'error', code: null, etag: null };
616
+ setSaveState('error', err.message, '', { sent: false, code: err.code ?? null });
617
+ const result = { msg: err.message, msgType: 'error', code: err.code ?? null, etag: null };
598
618
  if (typeof callback === 'function') {
599
619
  callback(result);
600
620
  }
@@ -603,10 +623,13 @@ export function savePageForce(callback = () => {}) {
603
623
 
604
624
  setSavingState();
605
625
 
626
+ const comparison = { forComparison, forDirty };
627
+ inFlightComparisons.add(comparison);
606
628
  ownSaveOnWire = true;
607
- saveHtml(forSave, (result) => {
629
+ saveCapturedHtml(forSave, captureId, (result) => {
608
630
  ownSaveOnWire = false;
609
- applySaveResult(result, forComparison, forDirty, 'updated after force save', gateToken, forSave);
631
+ inFlightComparisons.delete(comparison);
632
+ applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after force save', gateToken, forSave, captureId);
610
633
  if (typeof callback === 'function') {
611
634
  callback(result);
612
635
  }
@@ -774,6 +797,17 @@ let baselineActive = true;
774
797
  let baselineSettledAt = false;
775
798
  export function baselineSettled() { return baselineSettledAt; }
776
799
 
800
+ registerStyleAdoption(identity => {
801
+ const strip = styleComparisonStripper(identity);
802
+ lastSavedContents = strip(lastSavedContents);
803
+ lastSavedDirty = strip(lastSavedDirty);
804
+ baselineContents = strip(baselineContents);
805
+ for (const comparison of inFlightComparisons) {
806
+ comparison.forComparison = strip(comparison.forComparison);
807
+ comparison.forDirty = strip(comparison.forDirty);
808
+ }
809
+ });
810
+
777
811
  // ============================================
778
812
  // BASELINE CAPTURE (Settled Signal)
779
813
  // ============================================
@@ -787,7 +821,7 @@ export function baselineSettled() { return baselineSettledAt; }
787
821
  // WHY IMMEDIATE + CONDITIONAL UPDATE:
788
822
  // We set baseline immediately as a safety net. If the user edits or saves
789
823
  // before settle completes, we don't overwrite their work. The settled snapshot
790
- // only replaces baseline if nothing changed (lastSavedContents === immediateContents).
824
+ // only replaces baseline if no user input or baseline installation intervened.
791
825
 
792
826
  const SETTLE_MS = 500; // Wait for no mutations for this long
793
827
  const MAX_SETTLE_MS = 3000; // Max time to wait before forcing capture
@@ -803,8 +837,8 @@ function initBaselineCapture() {
803
837
  // This ensures saves during settle window work correctly
804
838
  // Store stripped version so comparisons are direct (no parsing needed)
805
839
  const { forComparison: immediateContents, forDirty: immediateDirty } = captureForComparisonAndDirty();
806
- lastSavedContents = immediateContents;
807
- lastSavedDirty = immediateDirty;
840
+ setLastSavedBaselines(immediateContents, immediateDirty);
841
+ const immediateRevision = baselineRevision;
808
842
  baselineContents = immediateContents;
809
843
  logBaseline('immediate capture', `${immediateContents.length} chars`);
810
844
 
@@ -829,18 +863,14 @@ function initBaselineCapture() {
829
863
  if (unsubscribeMutation) unsubscribeMutation();
830
864
  userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
831
865
 
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) {
866
+ // Adoption only removes owned styles from comparison state. It does not
867
+ // advance this revision. A save or sync installation does, even when its
868
+ // comparison bytes match, so settle cannot absorb a later unsent edit.
869
+ if (!userEdited && baselineRevision === immediateRevision) {
839
870
  // Store stripped version so comparisons are direct (no parsing needed)
840
871
  const gateToken = gateCaptureToken();
841
872
  const { forComparison: contents, forDirty: contentsDirty } = captureForComparisonAndDirty();
842
- lastSavedContents = contents;
843
- lastSavedDirty = contentsDirty;
873
+ setLastSavedBaselines(contents, contentsDirty);
844
874
  baselineContents = contents;
845
875
  // Boot churn (modules rewriting attributes at DOM-ready) counted toward
846
876
  // the scoped-sync gate; the settled baseline is the proof it wasn't edits.
@@ -58,6 +58,17 @@ const snapshotProvenance = new WeakMap();
58
58
  // second renderer would be two answers to "what bytes does this save send".
59
59
  let saveRenderer = null;
60
60
 
61
+ // The capture identity for save captures. A refused save that may retry reads it
62
+ // before sending and again before each retry: a capture newer than the one those
63
+ // bytes describe means the attempt is already superseded and must not be re-sent.
64
+ // It is an opaque local count, monotone per loaded module, and never goes on the
65
+ // wire; it is what binds one capture to the exact requests that carried its body.
66
+ let captureCounter = 0;
67
+
68
+ export function currentCaptureId() {
69
+ return captureCounter;
70
+ }
71
+
61
72
  export function originalSnapshotNode(cloneNode) {
62
73
  return snapshotProvenance.get(cloneNode) || null;
63
74
  }
@@ -224,7 +235,7 @@ function clonePreventingOnclone(node, deep = true) {
224
235
  finally { window.__preventOnclone = prev; }
225
236
  }
226
237
 
227
- export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
238
+ export function captureSnapshot({ flushUndo = true, authored = true, forSave = false, requestRefresh = false } = {}) {
228
239
  // Force-close any pending undo idle batch BEFORE cloning the DOM, so the
229
240
  // snapshot reflects a clean undo boundary. Without this, a save that fires
230
241
  // mid-typing would leave the idle batch open across the save boundary, and
@@ -245,7 +256,7 @@ export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
245
256
 
246
257
  for (const hook of snapshotHooks) {
247
258
  stripSnapshotRegions(clone);
248
- hook(clone, { original: originalSnapshotNode });
259
+ hook(clone, { original: originalSnapshotNode, forSave, requestRefresh });
249
260
  }
250
261
 
251
262
  // Put back any URL clay rewrote at runtime (cache-bust, refetch-on-save) so
@@ -327,6 +338,19 @@ export function captureSaveClone({ flushUndo = true, authored = true } = {}) {
327
338
  return prepareCloneForSave(captureSnapshot({ flushUndo, authored }), { authored });
328
339
  }
329
340
 
341
+ // Attributes that render exactly like their absence. A scroll lock or a browser
342
+ // extension that resets an inline style leaves `style=""` behind, and the dirty
343
+ // check must not read that as an edit the person would lose on close.
344
+ const EMPTY_INERT_ATTRS = ['style', 'class'];
345
+
346
+ function serializeForCompare(clone) {
347
+ for (const name of EMPTY_INERT_ATTRS) {
348
+ if (clone.getAttribute(name) === '') clone.removeAttribute(name);
349
+ for (const el of clone.querySelectorAll(`[${name}=""]`)) el.removeAttribute(name);
350
+ }
351
+ return "<!DOCTYPE html>" + clone.outerHTML;
352
+ }
353
+
330
354
  /**
331
355
  * Capture snapshot prepared for dirty/change comparison.
332
356
  *
@@ -355,7 +379,7 @@ export function captureForComparison({ flushUndo = true } = {}) {
355
379
 
356
380
  stripSnapshotRegions(clone);
357
381
 
358
- return "<!DOCTYPE html>" + clone.outerHTML;
382
+ return serializeForCompare(clone);
359
383
  }
360
384
 
361
385
  /**
@@ -388,7 +412,7 @@ export function captureForDirtyCheck({ flushUndo = true } = {}) {
388
412
 
389
413
  stripSnapshotRegions(clone);
390
414
 
391
- return "<!DOCTYPE html>" + clone.outerHTML;
415
+ return serializeForCompare(clone);
392
416
  }
393
417
 
394
418
  /**
@@ -419,7 +443,7 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
419
443
  hook(clone);
420
444
  }
421
445
  stripSnapshotRegions(clone);
422
- const forComparison = "<!DOCTYPE html>" + clone.outerHTML;
446
+ const forComparison = serializeForCompare(clone);
423
447
 
424
448
  let forDirty = forComparison;
425
449
  if (dirtyClone) {
@@ -431,7 +455,7 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
431
455
  hook(dirtyClone);
432
456
  }
433
457
  stripSnapshotRegions(dirtyClone);
434
- forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
458
+ forDirty = serializeForCompare(dirtyClone);
435
459
  }
436
460
 
437
461
  return { forComparison, forDirty };
@@ -445,10 +469,12 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
445
469
  *
446
470
  * @param {Object} options
447
471
  * @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
448
- * @returns {{ forSave: string, forComparison: string, forDirty: string }}
472
+ * @returns {{ forSave: string, forComparison: string, forDirty: string, captureId: number }}
449
473
  */
450
- export function captureForSaveAndComparison({ emitForSync = true, flushUndo = true } = {}) {
451
- const clone = captureSnapshot({ flushUndo });
474
+ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = true, requestRefresh = false } = {}) {
475
+ if (!Number.isSafeInteger(captureCounter + 1)) throw new Error('Save capture identity exhausted');
476
+ const captureId = ++captureCounter;
477
+ const clone = captureSnapshot({ flushUndo, forSave: emitForSync, requestRefresh });
452
478
 
453
479
  // Emit for live-sync before any stripping.
454
480
  //
@@ -459,7 +485,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
459
485
  // and a baseline the dirty check cannot reproduce warns on close forever.
460
486
  if (emitForSync) {
461
487
  document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
462
- detail: { documentElement: clone, forSave: true }
488
+ detail: { documentElement: clone, forSave: true, captureId }
463
489
  }));
464
490
  }
465
491
 
@@ -499,7 +525,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
499
525
  hook(compareClone);
500
526
  }
501
527
  stripSnapshotRegions(compareClone);
502
- const forComparison = "<!DOCTYPE html>" + compareClone.outerHTML;
528
+ const forComparison = serializeForCompare(compareClone);
503
529
 
504
530
  // Dirty clone: same shape as the compare clone, one selector weaker.
505
531
  let forDirty = forComparison;
@@ -512,10 +538,10 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
512
538
  hook(dirtyClone);
513
539
  }
514
540
  stripSnapshotRegions(dirtyClone);
515
- forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
541
+ forDirty = serializeForCompare(dirtyClone);
516
542
  }
517
543
 
518
- return { forSave, forComparison, forDirty };
544
+ return { forSave, forComparison, forDirty, captureId };
519
545
  }
520
546
 
521
547
  /**
@@ -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
  }
@@ -0,0 +1,130 @@
1
+ // clay.effect(fn): run fn soon and again after anything that can change what it
2
+ // reads. Local edits arrive through the mutation hub; a live-sync merge runs with
3
+ // the hub paused, so clay:sync-applied covers it; undo and redo write through the
4
+ // hub as well, and their events are a backstop. fn runs with the hub paused, so
5
+ // its own writes (derived counts, totals) never re-trigger it, never start an
6
+ // autosave and never enter undo history. The first run is on a microtask too:
7
+ // pausing the hub mid-way through an undo.commit would split the command. fn must
8
+ // be synchronous: writes after an await land outside the pause and would loop.
9
+ //
10
+ // In edit mode each effect warns once about the misuses it can see: a write to
11
+ // saved, dirty-tracked content (saved with the file, but never marks it unsaved),
12
+ // an effect created inside an effect, undo.commit inside an effect (checked by the
13
+ // undo plugin through effect.running), and an effect that keeps re-running.
14
+ import { isTabLocalAttr } from "./root-attrs.js";
15
+
16
+ const LOOP_RUNS = 50;
17
+ const LOOP_WINDOW_MS = 1000;
18
+ const NO_SNAPSHOT = '[clay~="no-snapshot"], [no-snapshot], [snapshot-remove]';
19
+
20
+ let running = 0;
21
+
22
+ const describe = (node) => {
23
+ const el = node.nodeType === 1 ? node : node.parentElement;
24
+ if (!el) return "a detached node";
25
+ let s = el.localName;
26
+ if (el.id) s += "#" + el.id;
27
+ else if (el.classList.length) s += "." + [...el.classList].slice(0, 2).join(".");
28
+ return "<" + s + ">";
29
+ };
30
+
31
+ function savedWrite(records) {
32
+ const policy = window.clay?.region?.resolveRegionPolicy;
33
+ if (!policy) return null;
34
+ const tracked = (el) => !!el && !el.closest(NO_SNAPSHOT) && policy(el).dirtyTracked;
35
+ for (const r of records) {
36
+ if (r.type === "attributes" && isTabLocalAttr(r.attributeName, r.target)) continue;
37
+ const el = r.target.nodeType === 1 ? r.target : r.target.parentElement;
38
+ if (!tracked(el)) continue;
39
+ // A child added or removed counts only when it is saved itself: chrome
40
+ // inserted into a saved parent (clay="editor-ui") is stripped on save.
41
+ if (r.type === "childList" && ![...r.addedNodes, ...r.removedNodes].some((n) => n.nodeType !== 1 || tracked(n))) continue;
42
+ return r.target;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ export function effect(fn) {
48
+ const clay = window.clay;
49
+ const Mutation = clay.Mutation;
50
+ const warned = new Set();
51
+ const warn = (key, message) => {
52
+ if (warned.has(key)) return;
53
+ warned.add(key);
54
+ console.warn("clay.effect: " + message);
55
+ };
56
+ let queued = false;
57
+ let stopped = false;
58
+ let windowStart = 0;
59
+ let runs = 0;
60
+
61
+ if (running && clay.isEditMode) {
62
+ console.warn("clay.effect: an effect was created inside another effect, so a new one starts on every run. Create effects once, in clay.start.");
63
+ }
64
+
65
+ const watchLoop = () => {
66
+ const now = Date.now();
67
+ if (now - windowStart > LOOP_WINDOW_MS) { windowStart = now; runs = 0; }
68
+ if (++runs > LOOP_RUNS) {
69
+ warn("loop", `this effect ran more than ${LOOP_RUNS} times in a second. Something keeps changing the document, often a timer or async callback the effect started.`);
70
+ }
71
+ };
72
+
73
+ const run = () => {
74
+ queued = false;
75
+ if (stopped) return;
76
+ let observer = null;
77
+ Mutation.pause();
78
+ running++;
79
+ try {
80
+ if (clay.isEditMode === true) {
81
+ watchLoop();
82
+ if (!warned.has("saved")) {
83
+ observer = new MutationObserver(() => {});
84
+ observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
85
+ }
86
+ }
87
+ const result = fn();
88
+ if (result && typeof result.then === "function") {
89
+ stop();
90
+ console.error("clay.effect: fn returned a promise. It must be synchronous, so this effect was stopped.");
91
+ }
92
+ } catch (err) {
93
+ console.error("clay.effect:", err);
94
+ } finally {
95
+ running--;
96
+ Mutation.resume();
97
+ const records = observer ? observer.takeRecords() : [];
98
+ observer?.disconnect();
99
+ if (records.length) {
100
+ const target = savedWrite(records);
101
+ if (target) {
102
+ warn("saved", `this effect changed ${describe(target)}, which is saved with the file. Effect writes never mark the page unsaved, so a merge can leave it dirty with no save scheduled. Draw derived values inside clay="no-dirty" or clay="editor-ui".`);
103
+ }
104
+ }
105
+ }
106
+ };
107
+ const schedule = () => {
108
+ if (queued || stopped) return;
109
+ queued = true;
110
+ queueMicrotask(run);
111
+ };
112
+
113
+ const offMutation = Mutation.onAnyChange({ debounce: 0, require: "observed" }, schedule);
114
+ document.addEventListener("clay:sync-applied", schedule);
115
+ clay.undo?.on?.("undo", schedule);
116
+ clay.undo?.on?.("redo", schedule);
117
+
118
+ const stop = () => {
119
+ stopped = true;
120
+ offMutation();
121
+ document.removeEventListener("clay:sync-applied", schedule);
122
+ clay.undo?.off?.("undo", schedule);
123
+ clay.undo?.off?.("redo", schedule);
124
+ };
125
+
126
+ schedule();
127
+ return stop;
128
+ }
129
+
130
+ Object.defineProperty(effect, "running", { get: () => running > 0 });
@@ -0,0 +1,9 @@
1
+ let adopter = () => {};
2
+
3
+ export function registerStyleAdoption(fn) {
4
+ adopter = fn;
5
+ }
6
+
7
+ export function adoptStyleComparisons(identity) {
8
+ adopter(identity);
9
+ }
@@ -0,0 +1,44 @@
1
+ import { insertStyles, getStyleBaseUrl } from "../dom/insert-style-tag.js";
2
+ import Mutation from "./mutation.js";
3
+ import { adoptStyleComparisons } from "./style-adoption.js";
4
+
5
+ // A style a script owns. The tag saves with the page, so the next load paints
6
+ // styled before any script runs. Each call writes the current CSS into the saved
7
+ // tag in place (or points the saved <link> at the current href), so a page saved
8
+ // by an older script picks up newer styles. The tag is no-dirty: an update is
9
+ // carried by the next real save and never counts as an edit, starts an autosave
10
+ // or raises the close warning.
11
+ const markNoDirty = (el) => {
12
+ const tokens = (el.getAttribute("clay") ?? "").split(/\s+/).filter(Boolean);
13
+ if (!tokens.includes("no-dirty")) el.setAttribute("clay", [...tokens, "no-dirty"].join(" "));
14
+ };
15
+
16
+ export function style(nameOrHref, css) {
17
+ return writeStyle(nameOrHref, css);
18
+ }
19
+
20
+ export function styleWithLegacyId(name, css, legacyId) {
21
+ return writeStyle(name, css, legacyId);
22
+ }
23
+
24
+ function writeStyle(nameOrHref, css, legacyId) {
25
+ const inline = typeof css === "string";
26
+ const identity = inline ? { name: nameOrHref, legacyId } : { href: getStyleBaseUrl(nameOrHref) };
27
+ const legacy = legacyId === undefined ? [] : document.querySelectorAll(`style[id="${legacyId}"]:not([data-name])`);
28
+ Mutation.pause();
29
+ try {
30
+ adoptStyleComparisons(identity);
31
+ for (const el of legacy) el.dataset.name = nameOrHref;
32
+ if (inline) {
33
+ const el = insertStyles(nameOrHref, css, markNoDirty);
34
+ if (legacyId !== undefined) el.id = legacyId;
35
+ return el;
36
+ }
37
+ return insertStyles(nameOrHref, (link) => {
38
+ markNoDirty(link);
39
+ if (link.getAttribute("href") !== nameOrHref) link.setAttribute("href", nameOrHref);
40
+ });
41
+ } finally {
42
+ Mutation.resume();
43
+ }
44
+ }