@panphora/clayjs 1.5.2 → 1.6.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 (46) hide show
  1. package/README.md +5 -3
  2. package/THIRD-PARTY-NOTICES.md +6 -0
  3. package/dist/clay.standalone.js +14984 -10685
  4. package/package.json +9 -4
  5. package/src/core/conflict-download.js +91 -0
  6. package/src/core/conflict-notice.js +720 -0
  7. package/src/core/conflict-presentation.js +301 -0
  8. package/src/core/save-conflict-notice.js +2 -192
  9. package/src/core/save-feedback.js +6 -0
  10. package/src/core/save.js +60 -1
  11. package/src/core/snapshot.js +21 -0
  12. package/src/core/stale-host-notice.js +21 -24
  13. package/src/core/unsaved-warning.js +35 -10
  14. package/src/lib/content-dom.js +4 -2
  15. package/src/lib/dirty-gate.js +9 -0
  16. package/src/lib/unsaved-state.js +55 -0
  17. package/src/lib/user-gesture.js +10 -0
  18. package/src/loader-logic.js +26 -15
  19. package/src/loader.js +4 -3
  20. package/src/plugins/ai-edit.js +489 -179
  21. package/src/plugins/cms.js +12 -0
  22. package/src/plugins/indicator.js +35 -16
  23. package/src/plugins/quickcrop.js +103 -0
  24. package/src/plugins/richclay.js +21 -0
  25. package/src/plugins/source.js +13 -6
  26. package/src/plugins/wire.js +4 -0
  27. package/src/sync/conflict-revert.js +1138 -0
  28. package/src/sync/conflicts.js +159 -0
  29. package/src/sync/live-sync.js +188 -116
  30. package/src/sync/presence.js +42 -36
  31. package/src/sync/section-notice.js +53 -28
  32. package/src/ui/bevel-controls.js +277 -0
  33. package/src/ui/bevel-dialog.js +112 -0
  34. package/src/ui/bevel.js +247 -0
  35. package/src/ui/dialogs.js +63 -86
  36. package/src/ui/index.js +22 -11
  37. package/src/ui/modal.js +80 -387
  38. package/src/ui/skins/cms-toggle.js +15 -0
  39. package/src/ui/skins/cms.js +4 -0
  40. package/src/ui/skins/quickcrop.js +4 -0
  41. package/src/ui/skins/richclay.js +4 -0
  42. package/src/ui/toast.js +116 -401
  43. package/src/ui/vendor-skin.js +65 -0
  44. package/src/vendor/hyper-morph.vendor.js +4 -4
  45. package/src/vendor/quickcrop.vendor.js +14 -8
  46. package/src/vendor/richclay.vendor.js +22 -41
@@ -56,8 +56,10 @@ import { presence } from './presence.js';
56
56
  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
- import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged, gateMarkDirty } from '../lib/dirty-gate.js';
59
+ import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged } from '../lib/dirty-gate.js';
60
+ import { gestureSeen } from '../lib/user-gesture.js';
60
61
  import { SyncStream } from './stream.js';
62
+ import { conflicts, beginApply, completeApply, failApply } from './conflicts.js';
61
63
 
62
64
  // What a live-sync merge never reads or touches on any side: editor chrome,
63
65
  // content kept out of the save or the snapshot, frozen regions, and nodes
@@ -144,7 +146,7 @@ function pairedBaseline() {
144
146
  const { forComparison, forDirty } = captureForComparisonAndDirty({ flushUndo: false });
145
147
  return [forComparison, forDirty];
146
148
  }
147
- import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes, conflictResolvedBySync, isSaveConflicted } from '../core/save.js';
149
+ import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes, conflictResolvedBySync, isSaveConflicted, baselineSettled } from '../core/save.js';
148
150
 
149
151
  /**
150
152
  * The two live-sync wires, and the rule for choosing between them.
@@ -208,15 +210,16 @@ class LiveSync {
208
210
  this._agreeSeq = 0;
209
211
  // The ticket of the save currently in flight, taken when it captured.
210
212
  this._saveTicket = 0;
211
- // Conflicts an incoming frame won over this tab's unsaved text, kept until
212
- // this tab's next successful save. While any are here the page stays
213
- // dirty: no clean frame clears the gate or advances a saved baseline, and
214
- // no automatic save runs from the merge path, so the close warning holds
215
- // and the lost text stays reachable. Each entry is a hyper-morph conflict
216
- // (`local` is the text this tab lost).
217
- this.unresolvedConflicts = [];
218
- // Each conflict's ticket: the moment its frame applied.
219
- this._conflictTickets = new WeakMap();
213
+ // The peer lane's base carries a ticket from the same counter as the disk
214
+ // lane, so an accepted save and a frame install in the order the document
215
+ // held their bytes, not the order their promises settled.
216
+ this._peerBaseTicket = 0;
217
+ // The newest accepted save's capture ticket. A frame older than it does not
218
+ // adopt its own stamp: the save's response already recorded a newer one.
219
+ this._acceptedSaveTicket = 0;
220
+ // The ticket of the latest frame of either lane whose morph has run. The
221
+ // boot settle must not replace a base a morph already moved past.
222
+ this._morphTicket = 0;
220
223
  this.clientId = this.generateClientId();
221
224
 
222
225
  // Per-stream resume id for the htmlclay replay server's wire contract.
@@ -231,9 +234,9 @@ class LiveSync {
231
234
  // held so the commit relay can pair the host's stamp with the content that
232
235
  // stamp actually describes. Set at snapshot-ready, which fires before the
233
236
  // save POST goes out, and consumed by _relayCommit when the response lands.
234
- // Never reconstructed from lastHtml: that is the last relay that COMPLETED,
235
- // which lags the save whenever an earlier relay is still in flight, and
236
- // pairing a fresh stamp with older bytes is the one thing spec §10 forbids.
237
+ // Never reconstructed from lastHtml: that is the newest accepted save or
238
+ // applied frame, which can still lag the save in flight, and pairing a fresh
239
+ // stamp with older bytes is the one thing spec §10 forbids.
237
240
  this._savedSnapshot = null;
238
241
  this._sendInFlight = false;
239
242
  this._queuedSend = null;
@@ -303,15 +306,15 @@ class LiveSync {
303
306
  this._saveEpoch = 0;
304
307
  this._saveSavedHandler = null;
305
308
  this._saveConflictHandler = null;
309
+ this._settledHandler = null;
306
310
 
307
311
  // The identityMap that came with lastHtml, so the peer-lane dirty diff
308
312
  // can resolve synthetic identities on its base tree.
309
313
  this._lastIdentityMap = null;
310
314
 
311
- // Bumped whenever an APPLY (or a reset) rewrites lastHtml. A POST's
312
- // success callback carries no ordering guarantee against the SSE stream,
313
- // so a delayed response must not rewind lastHtml past a frame that
314
- // applied while it was on the wire.
315
+ // Bumped whenever an APPLY (or a reset) rewrites lastHtml. The boot settle
316
+ // reads it: a lane a frame already replaced since start is not refreshed
317
+ // from whatever DOM that frame left behind.
315
318
  this._applyGen = 0;
316
319
 
317
320
  // Hold-retry timers, one per pending slot. A held frame's slot and seq
@@ -343,6 +346,12 @@ class LiveSync {
343
346
  this.onNotification = null;
344
347
  }
345
348
 
349
+ // Compatibility: the raw engine reports of every open loss. The ledger
350
+ // (clay.conflicts) is the real owner; nothing here removes a record.
351
+ get unresolvedConflicts() {
352
+ return conflicts.list().filter((c) => c.kind !== 'apply-incomplete');
353
+ }
354
+
346
355
  _log(message, data = null) {
347
356
  if (!this.debug) return;
348
357
  const prefix = `[LiveSync ${new Date().toISOString()}]`;
@@ -380,6 +389,18 @@ class LiveSync {
380
389
  }
381
390
  }
382
391
 
392
+ /**
393
+ * Seed both merge bases (the peer lane's and the disk lane's) with the page as
394
+ * it stands, so a first frame that arrives after local edits merges instead of
395
+ * holding. A page already dirty has no trustworthy base; it keeps null and holds.
396
+ */
397
+ _seedBases() {
398
+ if (pageMaybeDirty()) return;
399
+ const clone = captureSnapshot({ flushUndo: false });
400
+ this._setPeerBase(serializeForSync(clone), this.identity.exportMap(clone, originalSnapshotNode), this._ticket());
401
+ this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
402
+ }
403
+
383
404
  /**
384
405
  * Start the LiveSync system
385
406
  * Can be called after stop() to restart with a new file
@@ -404,12 +425,14 @@ class LiveSync {
404
425
  // so mint a fresh resume id — this stream must not resume the previous one.
405
426
  this.lastHtml = null;
406
427
  this._lastIdentityMap = null;
428
+ // A ticket of its own, as the disk lane's reset takes: a save accepted or
429
+ // a frame settling from before this start cannot land in the new run.
430
+ this._peerBaseTicket = this._ticket();
407
431
  // The reset takes a ticket of its own, so a writer from before this start
408
432
  // (a save response still on the wire) cannot land in the new run.
409
433
  this._diskBase = null;
410
434
  this._diskBaseTicket = this._ticket();
411
435
  this._saveTicket = 0;
412
- this.unresolvedConflicts = [];
413
436
  this._savedSnapshot = null;
414
437
  this._applyGen++;
415
438
  this.lastSeenSeq = 0;
@@ -417,22 +440,56 @@ class LiveSync {
417
440
  this._pendingExternal = null;
418
441
  this.resumeId = this.generateResumeId();
419
442
 
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
- }
443
+ // Seed both merge bases now, so a first frame that arrives after local edits
444
+ // merges instead of holding. A page still booting is refreshed at the settle
445
+ // below: a base taken before a module built its DOM makes the first dirty merge
446
+ // see both sides add that DOM, and keep it twice.
447
+ if (this.lane === 'live') this._seedBases();
430
448
 
431
449
  console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
432
450
  // One discovery request stands between here and the stream. It is memoized and
433
451
  // bounded, and it does not gate ordinary saves — /_/save is a separate lane that
434
452
  // never moved. Snapshots produced during the window are queued, not dropped.
435
453
  const gen = ++this._startGen;
454
+ if (this.lane === 'live' && !baselineSettled()) {
455
+ const applyGen = this._applyGen;
456
+ const morphTicket = this._morphTicket;
457
+ const saveEpoch = this._saveEpoch;
458
+ const startHtml = this.lastHtml;
459
+ const startDiskTicket = this._diskBaseTicket;
460
+ const startDiskSeeded = this._diskBase !== null;
461
+ this._settledHandler = () => {
462
+ this._settledHandler = null;
463
+ if (this.isDestroyed || this._startGen !== gen) return;
464
+ // A person has touched the page: whatever they did (a drag, a button whose
465
+ // handler edits a turn later) may be in it and not yet saved. Keep the start
466
+ // seed, as 1.5.2 did; only an untouched tab gets the settled page as its base.
467
+ if (gestureSeen()) return;
468
+ // The settle clears the gate only when it saw no user edit, so a dirty page
469
+ // here holds work the base must not absorb: keep the start seed.
470
+ if (pageMaybeDirty()) return;
471
+ // A save this tab sent is still unconfirmed: what it carries is not yet
472
+ // common history, and a refused save must merge against the older base.
473
+ if (this._saveTicket !== 0) return;
474
+ // A frame morphed the page since start: its base is newer than
475
+ // anything the settle would capture.
476
+ if (this._morphTicket !== morphTicket) return;
477
+ // Refresh each lane only while it still holds what start left. A frame, a
478
+ // landed relay, an own save or a disk frame since then gave it a newer base
479
+ // (an own save counts even when its relay failed and lastHtml never moved).
480
+ // Only replace a seed start took, never fill a base start left empty: a page
481
+ // dirty at start held its frames in 1.5.2, and its own scripts may still be
482
+ // rewriting content the merge cannot reach.
483
+ if (startHtml !== null && this._applyGen === applyGen && this._saveEpoch === saveEpoch && this.lastHtml === startHtml) {
484
+ const clone = captureSnapshot({ flushUndo: false });
485
+ this._setPeerBase(serializeForSync(clone), this.identity.exportMap(clone, originalSnapshotNode), this._ticket());
486
+ }
487
+ if (startDiskSeeded && this._diskBaseTicket === startDiskTicket) {
488
+ this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
489
+ }
490
+ };
491
+ document.addEventListener('clay:baseline-settled', this._settledHandler, { once: true });
492
+ }
436
493
  // Exposed so a caller (and the tests) can await the point where the stream is
437
494
  // actually open, rather than counting microtasks behind the discovery request.
438
495
  this._ready = this._resolveProfile().then(() => {
@@ -452,16 +509,9 @@ class LiveSync {
452
509
  // today) counts as now.
453
510
  const ticket = this._saveTicket || this._ticket();
454
511
  this._saveTicket = 0;
512
+ if (ticket > this._acceptedSaveTicket) this._acceptedSaveTicket = ticket;
455
513
  const bytes = getLastSavedBytes();
456
514
  if (bytes != null) this._setDiskBase(bytes, ticket);
457
- // A save this tab made is the one act that acknowledges a lost
458
- // conflict: the person wrote the page as it stands. Only the page as
459
- // it stood at the capture: a conflict from a frame that applied after
460
- // it is not in these bytes, and it keeps the page dirty.
461
- this.unresolvedConflicts = this.unresolvedConflicts.filter(
462
- (c) => this._conflictTickets.get(c) > ticket
463
- );
464
- if (this.unresolvedConflicts.length) gateMarkDirty();
465
515
  this._relayCommit();
466
516
  };
467
517
  document.addEventListener('clay:save-saved', this._saveSavedHandler);
@@ -476,7 +526,6 @@ class LiveSync {
476
526
  queueMicrotask(() => {
477
527
  if (this.isDestroyed) return;
478
528
  if (refusedBy !== lastSeenEtag()) return;
479
- if (this.unresolvedConflicts.length) return;
480
529
  conflictResolvedBySync(refusedBy);
481
530
  this._saveAfterMerge(pageMaybeDirty(), false);
482
531
  });
@@ -514,6 +563,11 @@ class LiveSync {
514
563
  this._saveConflictHandler = null;
515
564
  }
516
565
 
566
+ if (this._settledHandler) {
567
+ document.removeEventListener('clay:baseline-settled', this._settledHandler);
568
+ this._settledHandler = null;
569
+ }
570
+
517
571
  this._queuedSend = null;
518
572
 
519
573
  // Cancel any pending frame and clear the queue. A morph already in
@@ -785,7 +839,7 @@ class LiveSync {
785
839
  if (etag && html === this.lastHtml) {
786
840
  this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
787
841
  recordEtag(etag);
788
- if (isSaveConflicted() && this.unresolvedConflicts.length === 0) {
842
+ if (isSaveConflicted()) {
789
843
  conflictResolvedBySync(etag);
790
844
  this._saveAfterMerge(pageMaybeDirty(), false);
791
845
  }
@@ -840,7 +894,7 @@ class LiveSync {
840
894
  // The save that follows this capture stores these bytes, so this is the
841
895
  // content its stamp will describe. Held for _relayCommit, and overwritten
842
896
  // by the next capture, so it always names the save currently in flight.
843
- this._savedSnapshot = { html, identityMap };
897
+ this._savedSnapshot = { html, identityMap, ticket: this._saveTicket };
844
898
  };
845
899
 
846
900
  document.addEventListener('clay:snapshot-ready', this._snapshotHandler);
@@ -902,22 +956,29 @@ class LiveSync {
902
956
  // stored, and §10 is explicit that a stamp must never travel on its own.
903
957
  if (!pending || typeof pending.html !== 'string') return;
904
958
 
905
- this._enqueueSend(pending.html, pending.identityMap, etag || null);
906
- }
959
+ // Skip only an unstamped repeat, judged against the base as it stood. A
960
+ // landed save is news even when its bytes equal the last relay: "Keep mine"
961
+ // can restore exactly those bytes after the peers moved on, and they must
962
+ // hear it.
963
+ const repeat = !etag && pending.html === this.lastHtml;
907
964
 
908
- _postUpdate(html, identityMap, etag = null, attempt = 0) {
909
- // Skip only an unstamped repeat. A landed save is news even when its bytes
910
- // equal the last relay: "Keep mine" can restore exactly those bytes after
911
- // the peers moved on, and they must hear it.
912
- if (!etag && html === this.lastHtml) {
965
+ // The host accepted these bytes, so every later accepted save descends from
966
+ // them: they are the peer lane's base from this moment, not once the relay
967
+ // returns. A frame whose morph ran after this capture installs its own,
968
+ // newer base when its resources settle, and the ticket keeps it there.
969
+ this._setPeerBase(pending.html, pending.identityMap, pending.ticket);
970
+
971
+ if (repeat) {
913
972
  this._log('Skipping send - HTML unchanged');
914
973
  return;
915
974
  }
975
+ this._enqueueSend(pending.html, pending.identityMap, etag || null);
976
+ }
916
977
 
917
- this._log(`Sending update (HTML length: ${html.length}, lastHtml length: ${this.lastHtml?.length || 0})`);
978
+ _postUpdate(html, identityMap, etag = null, attempt = 0) {
979
+ this._log(`Sending update (HTML length: ${html.length})`);
918
980
 
919
981
  this._sendInFlight = true;
920
- const gen = this._applyGen;
921
982
  let retry = false;
922
983
 
923
984
  // Absolute against the real origin, so a <base href> in the page cannot
@@ -936,18 +997,7 @@ class LiveSync {
936
997
  ...(etag ? { etag } : {})
937
998
  })
938
999
  }).then(response => {
939
- if (response.ok) {
940
- // A frame that applied while this POST was on the wire has already
941
- // advanced lastHtml past this snapshot; assigning would rewind the
942
- // diff base to pre-frame state and misclassify the frame's content
943
- // as local edits on the next dirty apply.
944
- if (this._applyGen === gen) {
945
- this.lastHtml = html;
946
- this._lastIdentityMap = identityMap || null;
947
- } else {
948
- this._log('Skipping lastHtml advance: a frame applied during the POST');
949
- }
950
- } else {
1000
+ if (!response.ok) {
951
1001
  console.warn('[LiveSync] Save returned status:', response.status);
952
1002
  retry = response.status >= 500 || response.status === 408 || response.status === 429;
953
1003
  }
@@ -1233,6 +1283,22 @@ class LiveSync {
1233
1283
  this._diskBaseTicket = ticket;
1234
1284
  }
1235
1285
 
1286
+ /**
1287
+ * The one writer of the peer lane's base. Keeps the newest moment: a save
1288
+ * accepted after a later frame's morph, or a frame whose resource wait
1289
+ * outlasts a later save, can no longer rewind it.
1290
+ */
1291
+ _setPeerBase(html, identityMap, ticket) {
1292
+ if (ticket < this._peerBaseTicket) return false;
1293
+ this._peerBaseTicket = ticket;
1294
+ this.lastHtml = html;
1295
+ this._lastIdentityMap =
1296
+ identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)
1297
+ ? identityMap
1298
+ : null;
1299
+ return true;
1300
+ }
1301
+
1236
1302
  _requestFrame(cb) {
1237
1303
  if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
1238
1304
  return window.requestAnimationFrame(cb);
@@ -1272,7 +1338,7 @@ class LiveSync {
1272
1338
  * @param {boolean} lane.synthetic - whether this tab's synthetic ids apply
1273
1339
  * @param {object} [lane.extra] - extra mergeDocument options (the disk lane's beforeApply)
1274
1340
  */
1275
- async _mergeIncoming(html, identityMap, { base, baseIdentityMap = null, captureLocal, synthetic, extra = {} }) {
1341
+ async _mergeIncoming(html, identityMap, { base, baseIdentityMap = null, captureLocal, synthetic, source = 'peer', seq = null, etag = null, extra = {} }) {
1276
1342
  const store = this.identity;
1277
1343
  // A frame may neither write these onto our root nor, by not carrying them,
1278
1344
  // take ours away. Returning false is hyper-morph's veto for both directions.
@@ -1312,6 +1378,7 @@ class LiveSync {
1312
1378
  remoteWins: (el) => el.matches(REMOTE_WINS_SELECTOR),
1313
1379
  ignoreAttribute: isRootLocalAttr,
1314
1380
  protectFocusedValue: true,
1381
+ keepLiveOnly: source === 'peer',
1315
1382
  scripts: { mergeTags: mergeTagRecognizers },
1316
1383
  hooks: { beforeAttributeUpdated },
1317
1384
  ...extra,
@@ -1326,10 +1393,13 @@ class LiveSync {
1326
1393
  // loaded and run. Resuming mutations earlier would let late scripts' DOM
1327
1394
  // changes look like user edits and echo back out.
1328
1395
  let pending;
1396
+ let localRoot = null;
1397
+ let applyId = null;
1329
1398
  if (base == null) {
1330
1399
  pending = HyperMorph.mergeDocument({
1331
1400
  ...common,
1332
1401
  base: null,
1402
+ local: { root: captureLocal(), toLive: originalSnapshotNode },
1333
1403
  identity: { local: localIdentity, remote: sideIdentity(identityMap) },
1334
1404
  });
1335
1405
  } else if (!dirty) {
@@ -1348,6 +1418,7 @@ class LiveSync {
1348
1418
  ...common,
1349
1419
  base: baseDoc,
1350
1420
  local: { root: localRoot, toLive: originalSnapshotNode },
1421
+ fastPath: true,
1351
1422
  identity: {
1352
1423
  base: localIdentity,
1353
1424
  local: localIdentity,
@@ -1355,41 +1426,47 @@ class LiveSync {
1355
1426
  },
1356
1427
  });
1357
1428
  } else {
1358
- pending = HyperMorph.mergeDocument({
1359
- ...common,
1360
- base,
1361
- local: { root: captureLocal(), toLive: originalSnapshotNode },
1362
- identity: {
1363
- base: sideIdentity(baseIdentityMap),
1364
- local: localIdentity,
1365
- remote: sideIdentity(identityMap),
1366
- },
1429
+ localRoot = captureLocal();
1430
+ applyId = beginApply({
1431
+ source, seq, etag, root: localRoot,
1432
+ domain: source === 'disk' ? 'save' : 'sync',
1367
1433
  });
1434
+ try {
1435
+ pending = HyperMorph.mergeDocument({
1436
+ ...common,
1437
+ base,
1438
+ local: { root: localRoot, toLive: originalSnapshotNode },
1439
+ identity: {
1440
+ base: sideIdentity(baseIdentityMap),
1441
+ local: localIdentity,
1442
+ remote: sideIdentity(identityMap),
1443
+ },
1444
+ });
1445
+ } catch (err) {
1446
+ failApply(applyId, err);
1447
+ throw err;
1448
+ }
1368
1449
  }
1369
1450
  // The apply itself ran synchronously inside mergeDocument; only resource
1370
1451
  // loads are still pending. This is the moment the frame's bytes were true
1371
1452
  // of the document, so the lane dates its base here, not after the await:
1372
1453
  // a save that captures during the wait is newer than this frame.
1373
1454
  const ticket = this._ticket();
1374
- const report = await pending;
1455
+ this._morphTicket = ticket;
1456
+ let report;
1457
+ try {
1458
+ report = await pending;
1459
+ } catch (err) {
1460
+ if (applyId) failApply(applyId, err, { ticket });
1461
+ throw err;
1462
+ }
1375
1463
  const typedDuringWait = gateCaptureToken().gen !== token.gen;
1376
1464
 
1377
- // A conflict the frame won leaves this tab's text in report.conflicts and
1378
- // nowhere else. It is kept until this tab's next successful save, so the
1379
- // page stays dirty (the close warning holds) however many clean frames
1380
- // follow, and nothing writes the page out from under it automatically.
1381
- if (dirty && report.conflicts.length) {
1382
- for (const c of report.conflicts) this._conflictTickets.set(c, ticket);
1383
- this.unresolvedConflicts.push(...report.conflicts);
1384
- }
1385
- // Clean only when nothing of this tab's survived, nothing of it was lost,
1386
- // and nothing lost earlier is still waiting on a save.
1387
- if (
1388
- dirty &&
1389
- !report.localDiverged &&
1390
- report.conflicts.length === 0 &&
1391
- this.unresolvedConflicts.length === 0
1392
- ) {
1465
+ // What the frame won over this tab's unsaved edits goes to the ledger with
1466
+ // the clone the merge read as "mine". Earlier losses live in the ledger,
1467
+ // not in the page's dirty state.
1468
+ const conflictIds = applyId ? completeApply(applyId, report.conflicts, { ticket }) : [];
1469
+ if (dirty && !report.localDiverged) {
1393
1470
  gateClearIfUnchanged(token);
1394
1471
  }
1395
1472
  // Synthetic ids converge: every live element the frame named takes the
@@ -1400,20 +1477,18 @@ class LiveSync {
1400
1477
  const synthetic = mapIds.has(id) ? id : syntheticByAuthored.get(id);
1401
1478
  if (synthetic) store.adopt(el, synthetic);
1402
1479
  }
1403
- return { report, ticket, typedDuringWait };
1480
+ return { report, ticket, typedDuringWait, conflictIds };
1404
1481
  }
1405
1482
 
1406
1483
  /**
1407
1484
  * The automatic save a merge may owe: the merge kept this tab's edits
1408
1485
  * (convergence), or the person typed during the awaited apply (the mutation
1409
1486
  * feed was paused, so nothing else will schedule it). Only where autosave is
1410
- * on, and never over an unresolved conflict: that save would write the page
1411
- * with the lost text gone and clear the one signal that says so.
1487
+ * on.
1412
1488
  */
1413
1489
  _saveAfterMerge(diverged, typedDuringWait) {
1414
1490
  if (!(diverged || typedDuringWait)) return;
1415
1491
  if (!autosaveActive()) return;
1416
- if (this.unresolvedConflicts.length) return;
1417
1492
  savePageThrottled();
1418
1493
  }
1419
1494
 
@@ -1487,11 +1562,14 @@ class LiveSync {
1487
1562
  // knowingly missing what disk holds, and a stamp there would let its next
1488
1563
  // save replace that change with nobody told.
1489
1564
 
1490
- const { report, typedDuringWait: typed } = await this._mergeIncoming(html, identityMap, {
1565
+ const { report, ticket, typedDuringWait: typed, conflictIds } = await this._mergeIncoming(html, identityMap, {
1491
1566
  base: this.lastHtml,
1492
1567
  baseIdentityMap: this._lastIdentityMap,
1493
1568
  captureLocal: () => captureSnapshot({ flushUndo: false }),
1494
1569
  synthetic: true,
1570
+ source: 'peer',
1571
+ seq,
1572
+ etag,
1495
1573
  });
1496
1574
  diverged = report.localDiverged;
1497
1575
  typedDuringWait = typed;
@@ -1510,11 +1588,9 @@ class LiveSync {
1510
1588
  // two of a burst would read the protected section as clean and clobber
1511
1589
  // it) and could dedupe away the convergence send. Convergence is driven
1512
1590
  // by the explicit save below instead.
1513
- this.lastHtml = html;
1514
- this._lastIdentityMap =
1515
- identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)
1516
- ? identityMap
1517
- : null;
1591
+ // A save captured during this frame's resource wait and already accepted
1592
+ // is newer than the frame: it keeps the base, and its stamp too.
1593
+ this._setPeerBase(html, identityMap, ticket);
1518
1594
  this._applyGen++;
1519
1595
 
1520
1596
  // The stamp of section 6, taken now that the bytes it describes are the
@@ -1522,7 +1598,7 @@ class LiveSync {
1522
1598
  // above, and deliberately the same moment: the two claims a page makes
1523
1599
  // when it adopts a stamp are "the host stores this version" and "I hold
1524
1600
  // it", and only the second one is this tab's to make.
1525
- if (typeof etag === 'string' && etag) {
1601
+ if (!(this._acceptedSaveTicket > ticket) && typeof etag === 'string' && etag) {
1526
1602
  recordEtag(etag);
1527
1603
  stamped = true;
1528
1604
  }
@@ -1547,13 +1623,10 @@ class LiveSync {
1547
1623
  // gate or a diverged merge leaves the base where it was, and the
1548
1624
  // convergence save moves it once the merge is on disk.
1549
1625
  //
1550
- // Unresolved conflicts keep every saved baseline where it is too: the
1551
- // page is dirty by definition until this tab saves.
1552
1626
  if (
1553
1627
  this.lane === 'live' &&
1554
1628
  !diverged &&
1555
- !pageMaybeDirty() &&
1556
- this.unresolvedConflicts.length === 0
1629
+ !pageMaybeDirty()
1557
1630
  ) {
1558
1631
  const { forSave, forComparison, forDirty } = captureForSaveAndComparison({
1559
1632
  emitForSync: false,
@@ -1580,7 +1653,7 @@ class LiveSync {
1580
1653
  // returns above without reaching here, so nothing can name an author for a
1581
1654
  // change this tab never took. Null on every frame the host did not stamp.
1582
1655
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1583
- detail: { seq, source: 'peer', by: by || null, report }
1656
+ detail: { seq, source: 'peer', by: by || null, report, unresolved: this.unresolvedConflicts, conflictIds }
1584
1657
  }));
1585
1658
  } finally {
1586
1659
  this._log('applyUpdate - morph complete, resuming mutations');
@@ -1599,9 +1672,7 @@ class LiveSync {
1599
1672
  // isPaused is lifted so the convergence save's relay reaches peers.
1600
1673
  // Only where autosave is on: a manual-save page must never auto-write, so
1601
1674
  // there the merge stays local, still dirty, until the person saves.
1602
- // Not over a lost conflict: the hold and its bar stay up as the signal that
1603
- // this tab lost text, until the person saves.
1604
- if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
1675
+ if (stamped) conflictResolvedBySync(etag);
1605
1676
  this._saveAfterMerge(diverged, typedDuringWait);
1606
1677
  }
1607
1678
 
@@ -1666,10 +1737,13 @@ class LiveSync {
1666
1737
  // file. The merged document is activated for edit mode before it is
1667
1738
  // applied, exactly as boot would have, so every node, local or incoming,
1668
1739
  // goes live from its own saved form.
1669
- const { report, ticket, typedDuringWait: typed } = await this._mergeIncoming(html, null, {
1740
+ const { report, ticket, typedDuringWait: typed, conflictIds } = await this._mergeIncoming(html, null, {
1670
1741
  base: this._diskBase,
1671
1742
  captureLocal: () => captureForMerge().saveClone,
1672
1743
  synthetic: false,
1744
+ source: 'disk',
1745
+ seq,
1746
+ etag,
1673
1747
  extra: { beforeApply: (doc) => activateIncomingDoc(doc.documentElement) },
1674
1748
  });
1675
1749
  diverged = report.localDiverged;
@@ -1697,8 +1771,7 @@ class LiveSync {
1697
1771
  if (
1698
1772
  this.lane === 'live' &&
1699
1773
  !diverged &&
1700
- !pageMaybeDirty() &&
1701
- this.unresolvedConflicts.length === 0
1774
+ !pageMaybeDirty()
1702
1775
  ) {
1703
1776
  // Clean apply: the DOM now IS the disk state, so a local comparison
1704
1777
  // capture of it is the truthful baseline. The next no-op save skips,
@@ -1714,8 +1787,7 @@ class LiveSync {
1714
1787
  // newer peer frame. Rebuild it exactly the way the send pipeline
1715
1788
  // does, so it stays in the snapshot domain.
1716
1789
  const clone = captureSnapshot({ flushUndo: false });
1717
- this.lastHtml = serializeForSync(clone);
1718
- this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
1790
+ this._setPeerBase(serializeForSync(clone), this.identity.exportMap(clone, originalSnapshotNode), ticket);
1719
1791
  this._applyGen++;
1720
1792
  }
1721
1793
 
@@ -1729,6 +1801,8 @@ class LiveSync {
1729
1801
  // so this tab's next save copies from the file somebody else just wrote.
1730
1802
  html,
1731
1803
  report,
1804
+ conflictIds,
1805
+ unresolved: this.unresolvedConflicts,
1732
1806
  }
1733
1807
  }));
1734
1808
  } finally {
@@ -1743,9 +1817,7 @@ class LiveSync {
1743
1817
  // (our edits + their bytes). The baseline was left pre-external, so the
1744
1818
  // save sees both as changes and writes the merge back. Only where
1745
1819
  // autosave is on, as in the peer lane.
1746
- // Not over a lost conflict: the hold and its bar stay up as the signal that
1747
- // this tab lost text, until the person saves.
1748
- if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
1820
+ if (stamped) conflictResolvedBySync(etag);
1749
1821
  this._saveAfterMerge(diverged, typedDuringWait);
1750
1822
  }
1751
1823
 
@@ -1809,5 +1881,5 @@ if (typeof window !== 'undefined') {
1809
1881
  // Export for the clayjs module system. The class itself is exported so
1810
1882
  // tests can create fresh instances without driving the singleton's
1811
1883
  // EventSource/snapshot wiring.
1812
- export { liveSync, LiveSync, morph, WIRE_PROFILES };
1884
+ export { liveSync, LiveSync, morph, WIRE_PROFILES, conflicts, SYNC_IGNORE_SELECTOR, REMOTE_WINS_SELECTOR };
1813
1885
  export default liveSync;