@panphora/clayjs 1.5.2 → 1.5.3

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.
@@ -1,4 +1,4 @@
1
- /* clayjs 1.5.2 standalone build: every clayjs module in one file. https://clayjs.com/offline
1
+ /* clayjs 1.5.3 standalone build: every clayjs module in one file. https://clayjs.com/offline
2
2
  Third-party code inside keeps its own license (Sortable MIT, Squire MIT, DOMPurify Apache-2.0 OR MPL-2.0, MicroModal MIT, parse5 MIT): https://clayjs.com/THIRD-PARTY-NOTICES.md */
3
3
  (() => {
4
4
  var __create = Object.create;
@@ -541,6 +541,7 @@
541
541
  return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
542
542
  }
543
543
  function markGestureTurn() {
544
+ anyGesture = true;
544
545
  gestureTaskActive = true;
545
546
  lastTrustedGestureTs = now();
546
547
  setTimeout(() => {
@@ -565,6 +566,9 @@
565
566
  function markUserDriven() {
566
567
  userDrivenSinceLastSave = true;
567
568
  }
569
+ function gestureSeen() {
570
+ return anyGesture;
571
+ }
568
572
  function consumeUserDriven() {
569
573
  const v2 = userDrivenSinceLastSave;
570
574
  userDrivenSinceLastSave = false;
@@ -581,13 +585,14 @@
581
585
  function clearExplicitSave() {
582
586
  explicitSaveIntent = false;
583
587
  }
584
- var GESTURE_EVENTS, RECENT_GESTURE_MS, gestureTaskActive, lastTrustedGestureTs, userDrivenSinceLastSave, explicitSaveIntent, installed;
588
+ var GESTURE_EVENTS, RECENT_GESTURE_MS, gestureTaskActive, lastTrustedGestureTs, anyGesture, userDrivenSinceLastSave, explicitSaveIntent, installed;
585
589
  var init_user_gesture = __esm({
586
590
  "src/lib/user-gesture.js"() {
587
591
  GESTURE_EVENTS = ["pointerdown", "pointerup", "click", "keydown", "beforeinput", "change", "submit", "paste", "drop", "cut"];
588
592
  RECENT_GESTURE_MS = 500;
589
593
  gestureTaskActive = false;
590
594
  lastTrustedGestureTs = -Infinity;
595
+ anyGesture = false;
591
596
  userDrivenSinceLastSave = false;
592
597
  explicitSaveIntent = false;
593
598
  installed = false;
@@ -2497,6 +2502,7 @@
2497
2502
  var save_exports = {};
2498
2503
  __export(save_exports, {
2499
2504
  addDocumentTransform: () => addDocumentTransform,
2505
+ baselineSettled: () => baselineSettled,
2500
2506
  conflictResolvedBySync: () => conflictResolvedBySync,
2501
2507
  default: () => save_default,
2502
2508
  getLastSavedBytes: () => getLastSavedBytes,
@@ -2754,6 +2760,9 @@
2754
2760
  }
2755
2761
  });
2756
2762
  }
2763
+ function baselineSettled() {
2764
+ return baselineSettledAt;
2765
+ }
2757
2766
  function initBaselineCapture() {
2758
2767
  if (!isEditMode) return;
2759
2768
  let userEdited = false;
@@ -2789,6 +2798,8 @@
2789
2798
  }
2790
2799
  baselineActive = false;
2791
2800
  document.documentElement.setAttribute("savestatus", "saved");
2801
+ baselineSettledAt = true;
2802
+ document.dispatchEvent(new CustomEvent("clay:baseline-settled"));
2792
2803
  };
2793
2804
  unsubscribeMutation = mutation_default.onAnyChange(
2794
2805
  { debounce: SETTLE_MS, omitChangeDetails: true, require: "autosave" },
@@ -2856,7 +2867,7 @@
2856
2867
  initSaveKeyboardShortcut();
2857
2868
  initHyperclaySaveButton();
2858
2869
  }
2859
- var savingTimeout, unsavedChanges, lastSavedContents, lastSavedDirty, pendingSave, autosaveSuspended, autosaveMissed, conflictHold, conflictEtag, lastSavedBytes, throttledSave, baselineContents, baselineActive, SETTLE_MS, MAX_SETTLE_MS, save_default;
2870
+ var savingTimeout, unsavedChanges, lastSavedContents, lastSavedDirty, pendingSave, autosaveSuspended, autosaveMissed, conflictHold, conflictEtag, lastSavedBytes, throttledSave, baselineContents, baselineActive, baselineSettledAt, SETTLE_MS, MAX_SETTLE_MS, save_default;
2860
2871
  var init_save = __esm({
2861
2872
  "src/core/save.js"() {
2862
2873
  init_throttle();
@@ -2900,6 +2911,7 @@
2900
2911
  throttledSave = throttle_default(savePage, 1200);
2901
2912
  baselineContents = "";
2902
2913
  baselineActive = true;
2914
+ baselineSettledAt = false;
2903
2915
  SETTLE_MS = 500;
2904
2916
  MAX_SETTLE_MS = 3e3;
2905
2917
  if (document.readyState === "loading") {
@@ -12183,6 +12195,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12183
12195
  init_host_meta();
12184
12196
  init_etag();
12185
12197
  init_dirty_gate();
12198
+ init_user_gesture();
12186
12199
  init_stream();
12187
12200
  init_save();
12188
12201
  ({ createIdentityStore } = HyperMorph);
@@ -12274,6 +12287,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12274
12287
  this._saveEpoch = 0;
12275
12288
  this._saveSavedHandler = null;
12276
12289
  this._saveConflictHandler = null;
12290
+ this._settledHandler = null;
12277
12291
  this._lastIdentityMap = null;
12278
12292
  this._applyGen = 0;
12279
12293
  this._holdRetryPeer = null;
@@ -12321,6 +12335,18 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12321
12335
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
12322
12336
  }
12323
12337
  }
12338
+ /**
12339
+ * Seed both merge bases (the peer lane's and the disk lane's) with the page as
12340
+ * it stands, so a first frame that arrives after local edits merges instead of
12341
+ * holding. A page already dirty has no trustworthy base; it keeps null and holds.
12342
+ */
12343
+ _seedBases() {
12344
+ if (pageMaybeDirty()) return;
12345
+ const clone = captureSnapshot({ flushUndo: false });
12346
+ this.lastHtml = serializeForSync(clone);
12347
+ this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
12348
+ this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
12349
+ }
12324
12350
  /**
12325
12351
  * Start the LiveSync system
12326
12352
  * Can be called after stop() to restart with a new file
@@ -12347,14 +12373,32 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12347
12373
  this._lastExternalSeq = 0;
12348
12374
  this._pendingExternal = null;
12349
12375
  this.resumeId = this.generateResumeId();
12350
- if (this.lane === "live" && !pageMaybeDirty()) {
12351
- const clone = captureSnapshot({ flushUndo: false });
12352
- this.lastHtml = serializeForSync(clone);
12353
- this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
12354
- this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
12355
- }
12376
+ if (this.lane === "live") this._seedBases();
12356
12377
  console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
12357
12378
  const gen = ++this._startGen;
12379
+ if (this.lane === "live" && !baselineSettled()) {
12380
+ const applyGen = this._applyGen;
12381
+ const saveEpoch = this._saveEpoch;
12382
+ const startHtml = this.lastHtml;
12383
+ const startDiskTicket = this._diskBaseTicket;
12384
+ const startDiskSeeded = this._diskBase !== null;
12385
+ this._settledHandler = () => {
12386
+ this._settledHandler = null;
12387
+ if (this.isDestroyed || this._startGen !== gen) return;
12388
+ if (gestureSeen()) return;
12389
+ if (pageMaybeDirty()) return;
12390
+ if (this._saveTicket !== 0) return;
12391
+ if (startHtml !== null && this._applyGen === applyGen && this._saveEpoch === saveEpoch && this.lastHtml === startHtml) {
12392
+ const clone = captureSnapshot({ flushUndo: false });
12393
+ this.lastHtml = serializeForSync(clone);
12394
+ this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
12395
+ }
12396
+ if (startDiskSeeded && this._diskBaseTicket === startDiskTicket) {
12397
+ this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
12398
+ }
12399
+ };
12400
+ document.addEventListener("clay:baseline-settled", this._settledHandler, { once: true });
12401
+ }
12358
12402
  this._ready = this._resolveProfile().then(() => {
12359
12403
  if (this.isDestroyed || this._startGen !== gen) return;
12360
12404
  this.connect();
@@ -12410,6 +12454,10 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
12410
12454
  document.removeEventListener("clay:save-conflict", this._saveConflictHandler);
12411
12455
  this._saveConflictHandler = null;
12412
12456
  }
12457
+ if (this._settledHandler) {
12458
+ document.removeEventListener("clay:baseline-settled", this._settledHandler);
12459
+ this._settledHandler = null;
12460
+ }
12413
12461
  this._queuedSend = null;
12414
12462
  if (this._rafHandle != null) {
12415
12463
  this._cancelFrame(this._rafHandle);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable HTML files. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT-0",
package/src/core/save.js CHANGED
@@ -572,6 +572,11 @@ let baselineContents = '';
572
572
  // The baseline veto only guards the load-time settle window; captureBaseline
573
573
  // disarms it. See the comment there.
574
574
  let baselineActive = true;
575
+ // True once the load-time settle has run. Live sync refreshes an untouched tab's
576
+ // merge bases at this moment: modules that build DOM at boot (an editor mounting
577
+ // from an editmode:resource script) are in the page by now.
578
+ let baselineSettledAt = false;
579
+ export function baselineSettled() { return baselineSettledAt; }
575
580
 
576
581
  // ============================================
577
582
  // BASELINE CAPTURE (Settled Signal)
@@ -658,6 +663,8 @@ function initBaselineCapture() {
658
663
  baselineActive = false;
659
664
 
660
665
  document.documentElement.setAttribute('savestatus', 'saved');
666
+ baselineSettledAt = true;
667
+ document.dispatchEvent(new CustomEvent('clay:baseline-settled'));
661
668
  };
662
669
 
663
670
  // Start settle observer - fires when no mutations for SETTLE_MS.
@@ -44,6 +44,9 @@ const RECENT_GESTURE_MS = 500;
44
44
 
45
45
  let gestureTaskActive = false;
46
46
  let lastTrustedGestureTs = -Infinity;
47
+ // Set by the first trusted gesture and never cleared. Live sync reads it at the
48
+ // load-time settle: a page nobody has touched yet holds no one's edit.
49
+ let anyGesture = false;
47
50
  let userDrivenSinceLastSave = false;
48
51
  let explicitSaveIntent = false;
49
52
  let installed = false;
@@ -55,6 +58,7 @@ function now() {
55
58
  }
56
59
 
57
60
  function markGestureTurn() {
61
+ anyGesture = true;
58
62
  gestureTaskActive = true;
59
63
  lastTrustedGestureTs = now();
60
64
  // Clear the same-turn flag on the next macrotask. The MutationObserver
@@ -100,6 +104,11 @@ export function markUserDriven() {
100
104
  userDrivenSinceLastSave = true;
101
105
  }
102
106
 
107
+ /** True once any trusted gesture has reached the page. */
108
+ export function gestureSeen() {
109
+ return anyGesture;
110
+ }
111
+
103
112
  /**
104
113
  * Read-and-reset the accumulated bit. Called at the ACTUAL save send (not on a
105
114
  * save that never ships), so it survives the autosave debounce and coalescing.
@@ -144,6 +153,7 @@ export function _resetUserGesture() {
144
153
  gestureTaskActive = false;
145
154
  lastTrustedGestureTs = -Infinity;
146
155
  userDrivenSinceLastSave = false;
156
+ anyGesture = false;
147
157
  explicitSaveIntent = false;
148
158
  }
149
159
 
@@ -57,6 +57,7 @@ import './section-notice.js';
57
57
  import { hostMeta } from '../core/host-meta.js';
58
58
  import { recordEtag, seedEtag, lastSeenEtag, conditionalSaves } from '../core/etag.js';
59
59
  import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged, gateMarkDirty } from '../lib/dirty-gate.js';
60
+ import { gestureSeen } from '../lib/user-gesture.js';
60
61
  import { SyncStream } from './stream.js';
61
62
 
62
63
  // What a live-sync merge never reads or touches on any side: editor chrome,
@@ -144,7 +145,7 @@ function pairedBaseline() {
144
145
  const { forComparison, forDirty } = captureForComparisonAndDirty({ flushUndo: false });
145
146
  return [forComparison, forDirty];
146
147
  }
147
- import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes, conflictResolvedBySync, isSaveConflicted } from '../core/save.js';
148
+ import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes, conflictResolvedBySync, isSaveConflicted, baselineSettled } from '../core/save.js';
148
149
 
149
150
  /**
150
151
  * The two live-sync wires, and the rule for choosing between them.
@@ -303,6 +304,7 @@ class LiveSync {
303
304
  this._saveEpoch = 0;
304
305
  this._saveSavedHandler = null;
305
306
  this._saveConflictHandler = null;
307
+ this._settledHandler = null;
306
308
 
307
309
  // The identityMap that came with lastHtml, so the peer-lane dirty diff
308
310
  // can resolve synthetic identities on its base tree.
@@ -380,6 +382,19 @@ class LiveSync {
380
382
  }
381
383
  }
382
384
 
385
+ /**
386
+ * Seed both merge bases (the peer lane's and the disk lane's) with the page as
387
+ * it stands, so a first frame that arrives after local edits merges instead of
388
+ * holding. A page already dirty has no trustworthy base; it keeps null and holds.
389
+ */
390
+ _seedBases() {
391
+ if (pageMaybeDirty()) return;
392
+ const clone = captureSnapshot({ flushUndo: false });
393
+ this.lastHtml = serializeForSync(clone);
394
+ this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
395
+ this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
396
+ }
397
+
383
398
  /**
384
399
  * Start the LiveSync system
385
400
  * Can be called after stop() to restart with a new file
@@ -417,22 +432,53 @@ class LiveSync {
417
432
  this._pendingExternal = null;
418
433
  this.resumeId = this.generateResumeId();
419
434
 
420
- // Seed both merge bases (the peer lane's and the disk lane's) with the page
421
- // as served, so a first frame that arrives after local edits merges instead
422
- // of holding. A page already dirty at start has no trustworthy base; it
423
- // keeps null and holds.
424
- if (this.lane === 'live' && !pageMaybeDirty()) {
425
- const clone = captureSnapshot({ flushUndo: false });
426
- this.lastHtml = serializeForSync(clone);
427
- this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
428
- this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
429
- }
435
+ // Seed both merge bases now, so a first frame that arrives after local edits
436
+ // merges instead of holding. A page still booting is refreshed at the settle
437
+ // below: a base taken before a module built its DOM makes the first dirty merge
438
+ // see both sides add that DOM, and keep it twice.
439
+ if (this.lane === 'live') this._seedBases();
430
440
 
431
441
  console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
432
442
  // One discovery request stands between here and the stream. It is memoized and
433
443
  // bounded, and it does not gate ordinary saves — /_/save is a separate lane that
434
444
  // never moved. Snapshots produced during the window are queued, not dropped.
435
445
  const gen = ++this._startGen;
446
+ if (this.lane === 'live' && !baselineSettled()) {
447
+ const applyGen = this._applyGen;
448
+ const saveEpoch = this._saveEpoch;
449
+ const startHtml = this.lastHtml;
450
+ const startDiskTicket = this._diskBaseTicket;
451
+ const startDiskSeeded = this._diskBase !== null;
452
+ this._settledHandler = () => {
453
+ this._settledHandler = null;
454
+ if (this.isDestroyed || this._startGen !== gen) return;
455
+ // A person has touched the page: whatever they did (a drag, a button whose
456
+ // handler edits a turn later) may be in it and not yet saved. Keep the start
457
+ // seed, as 1.5.2 did; only an untouched tab gets the settled page as its base.
458
+ if (gestureSeen()) return;
459
+ // The settle clears the gate only when it saw no user edit, so a dirty page
460
+ // here holds work the base must not absorb: keep the start seed.
461
+ if (pageMaybeDirty()) return;
462
+ // A save this tab sent is still unconfirmed: what it carries is not yet
463
+ // common history, and a refused save must merge against the older base.
464
+ if (this._saveTicket !== 0) return;
465
+ // Refresh each lane only while it still holds what start left. A frame, a
466
+ // landed relay, an own save or a disk frame since then gave it a newer base
467
+ // (an own save counts even when its relay failed and lastHtml never moved).
468
+ // Only replace a seed start took, never fill a base start left empty: a page
469
+ // dirty at start held its frames in 1.5.2, and its own scripts may still be
470
+ // rewriting content the merge cannot reach.
471
+ if (startHtml !== null && this._applyGen === applyGen && this._saveEpoch === saveEpoch && this.lastHtml === startHtml) {
472
+ const clone = captureSnapshot({ flushUndo: false });
473
+ this.lastHtml = serializeForSync(clone);
474
+ this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
475
+ }
476
+ if (startDiskSeeded && this._diskBaseTicket === startDiskTicket) {
477
+ this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
478
+ }
479
+ };
480
+ document.addEventListener('clay:baseline-settled', this._settledHandler, { once: true });
481
+ }
436
482
  // Exposed so a caller (and the tests) can await the point where the stream is
437
483
  // actually open, rather than counting microtasks behind the discovery request.
438
484
  this._ready = this._resolveProfile().then(() => {
@@ -514,6 +560,11 @@ class LiveSync {
514
560
  this._saveConflictHandler = null;
515
561
  }
516
562
 
563
+ if (this._settledHandler) {
564
+ document.removeEventListener('clay:baseline-settled', this._settledHandler);
565
+ this._settledHandler = null;
566
+ }
567
+
517
568
  this._queuedSend = null;
518
569
 
519
570
  // Cancel any pending frame and clear the queue. A morph already in