@panphora/clayjs 1.5.3 → 1.6.1

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 +15041 -10657
  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-core.js +35 -2
  10. package/src/core/save-feedback.js +6 -0
  11. package/src/core/save.js +157 -3
  12. package/src/core/snapshot.js +21 -0
  13. package/src/core/stale-host-notice.js +21 -24
  14. package/src/core/unsaved-warning.js +64 -16
  15. package/src/lib/content-dom.js +4 -2
  16. package/src/lib/dirty-gate.js +37 -1
  17. package/src/lib/unsaved-state.js +55 -0
  18. package/src/loader-logic.js +26 -15
  19. package/src/loader.js +10 -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 +130 -109
  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,9 +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
60
  import { gestureSeen } from '../lib/user-gesture.js';
61
61
  import { SyncStream } from './stream.js';
62
+ import { conflicts, beginApply, completeApply, failApply } from './conflicts.js';
62
63
 
63
64
  // What a live-sync merge never reads or touches on any side: editor chrome,
64
65
  // content kept out of the save or the snapshot, frozen regions, and nodes
@@ -209,15 +210,16 @@ class LiveSync {
209
210
  this._agreeSeq = 0;
210
211
  // The ticket of the save currently in flight, taken when it captured.
211
212
  this._saveTicket = 0;
212
- // Conflicts an incoming frame won over this tab's unsaved text, kept until
213
- // this tab's next successful save. While any are here the page stays
214
- // dirty: no clean frame clears the gate or advances a saved baseline, and
215
- // no automatic save runs from the merge path, so the close warning holds
216
- // and the lost text stays reachable. Each entry is a hyper-morph conflict
217
- // (`local` is the text this tab lost).
218
- this.unresolvedConflicts = [];
219
- // Each conflict's ticket: the moment its frame applied.
220
- 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;
221
223
  this.clientId = this.generateClientId();
222
224
 
223
225
  // Per-stream resume id for the htmlclay replay server's wire contract.
@@ -232,9 +234,9 @@ class LiveSync {
232
234
  // held so the commit relay can pair the host's stamp with the content that
233
235
  // stamp actually describes. Set at snapshot-ready, which fires before the
234
236
  // save POST goes out, and consumed by _relayCommit when the response lands.
235
- // Never reconstructed from lastHtml: that is the last relay that COMPLETED,
236
- // which lags the save whenever an earlier relay is still in flight, and
237
- // 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.
238
240
  this._savedSnapshot = null;
239
241
  this._sendInFlight = false;
240
242
  this._queuedSend = null;
@@ -310,10 +312,9 @@ class LiveSync {
310
312
  // can resolve synthetic identities on its base tree.
311
313
  this._lastIdentityMap = null;
312
314
 
313
- // Bumped whenever an APPLY (or a reset) rewrites lastHtml. A POST's
314
- // success callback carries no ordering guarantee against the SSE stream,
315
- // so a delayed response must not rewind lastHtml past a frame that
316
- // 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.
317
318
  this._applyGen = 0;
318
319
 
319
320
  // Hold-retry timers, one per pending slot. A held frame's slot and seq
@@ -345,6 +346,12 @@ class LiveSync {
345
346
  this.onNotification = null;
346
347
  }
347
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
+
348
355
  _log(message, data = null) {
349
356
  if (!this.debug) return;
350
357
  const prefix = `[LiveSync ${new Date().toISOString()}]`;
@@ -390,8 +397,7 @@ class LiveSync {
390
397
  _seedBases() {
391
398
  if (pageMaybeDirty()) return;
392
399
  const clone = captureSnapshot({ flushUndo: false });
393
- this.lastHtml = serializeForSync(clone);
394
- this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
400
+ this._setPeerBase(serializeForSync(clone), this.identity.exportMap(clone, originalSnapshotNode), this._ticket());
395
401
  this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
396
402
  }
397
403
 
@@ -419,12 +425,14 @@ class LiveSync {
419
425
  // so mint a fresh resume id — this stream must not resume the previous one.
420
426
  this.lastHtml = null;
421
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();
422
431
  // The reset takes a ticket of its own, so a writer from before this start
423
432
  // (a save response still on the wire) cannot land in the new run.
424
433
  this._diskBase = null;
425
434
  this._diskBaseTicket = this._ticket();
426
435
  this._saveTicket = 0;
427
- this.unresolvedConflicts = [];
428
436
  this._savedSnapshot = null;
429
437
  this._applyGen++;
430
438
  this.lastSeenSeq = 0;
@@ -445,6 +453,7 @@ class LiveSync {
445
453
  const gen = ++this._startGen;
446
454
  if (this.lane === 'live' && !baselineSettled()) {
447
455
  const applyGen = this._applyGen;
456
+ const morphTicket = this._morphTicket;
448
457
  const saveEpoch = this._saveEpoch;
449
458
  const startHtml = this.lastHtml;
450
459
  const startDiskTicket = this._diskBaseTicket;
@@ -462,6 +471,9 @@ class LiveSync {
462
471
  // A save this tab sent is still unconfirmed: what it carries is not yet
463
472
  // common history, and a refused save must merge against the older base.
464
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;
465
477
  // Refresh each lane only while it still holds what start left. A frame, a
466
478
  // landed relay, an own save or a disk frame since then gave it a newer base
467
479
  // (an own save counts even when its relay failed and lastHtml never moved).
@@ -470,8 +482,7 @@ class LiveSync {
470
482
  // rewriting content the merge cannot reach.
471
483
  if (startHtml !== null && this._applyGen === applyGen && this._saveEpoch === saveEpoch && this.lastHtml === startHtml) {
472
484
  const clone = captureSnapshot({ flushUndo: false });
473
- this.lastHtml = serializeForSync(clone);
474
- this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
485
+ this._setPeerBase(serializeForSync(clone), this.identity.exportMap(clone, originalSnapshotNode), this._ticket());
475
486
  }
476
487
  if (startDiskSeeded && this._diskBaseTicket === startDiskTicket) {
477
488
  this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
@@ -498,16 +509,9 @@ class LiveSync {
498
509
  // today) counts as now.
499
510
  const ticket = this._saveTicket || this._ticket();
500
511
  this._saveTicket = 0;
512
+ if (ticket > this._acceptedSaveTicket) this._acceptedSaveTicket = ticket;
501
513
  const bytes = getLastSavedBytes();
502
514
  if (bytes != null) this._setDiskBase(bytes, ticket);
503
- // A save this tab made is the one act that acknowledges a lost
504
- // conflict: the person wrote the page as it stands. Only the page as
505
- // it stood at the capture: a conflict from a frame that applied after
506
- // it is not in these bytes, and it keeps the page dirty.
507
- this.unresolvedConflicts = this.unresolvedConflicts.filter(
508
- (c) => this._conflictTickets.get(c) > ticket
509
- );
510
- if (this.unresolvedConflicts.length) gateMarkDirty();
511
515
  this._relayCommit();
512
516
  };
513
517
  document.addEventListener('clay:save-saved', this._saveSavedHandler);
@@ -522,7 +526,6 @@ class LiveSync {
522
526
  queueMicrotask(() => {
523
527
  if (this.isDestroyed) return;
524
528
  if (refusedBy !== lastSeenEtag()) return;
525
- if (this.unresolvedConflicts.length) return;
526
529
  conflictResolvedBySync(refusedBy);
527
530
  this._saveAfterMerge(pageMaybeDirty(), false);
528
531
  });
@@ -836,7 +839,7 @@ class LiveSync {
836
839
  if (etag && html === this.lastHtml) {
837
840
  this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
838
841
  recordEtag(etag);
839
- if (isSaveConflicted() && this.unresolvedConflicts.length === 0) {
842
+ if (isSaveConflicted()) {
840
843
  conflictResolvedBySync(etag);
841
844
  this._saveAfterMerge(pageMaybeDirty(), false);
842
845
  }
@@ -891,7 +894,7 @@ class LiveSync {
891
894
  // The save that follows this capture stores these bytes, so this is the
892
895
  // content its stamp will describe. Held for _relayCommit, and overwritten
893
896
  // by the next capture, so it always names the save currently in flight.
894
- this._savedSnapshot = { html, identityMap };
897
+ this._savedSnapshot = { html, identityMap, ticket: this._saveTicket };
895
898
  };
896
899
 
897
900
  document.addEventListener('clay:snapshot-ready', this._snapshotHandler);
@@ -953,22 +956,29 @@ class LiveSync {
953
956
  // stored, and §10 is explicit that a stamp must never travel on its own.
954
957
  if (!pending || typeof pending.html !== 'string') return;
955
958
 
956
- this._enqueueSend(pending.html, pending.identityMap, etag || null);
957
- }
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;
958
964
 
959
- _postUpdate(html, identityMap, etag = null, attempt = 0) {
960
- // Skip only an unstamped repeat. A landed save is news even when its bytes
961
- // equal the last relay: "Keep mine" can restore exactly those bytes after
962
- // the peers moved on, and they must hear it.
963
- 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) {
964
972
  this._log('Skipping send - HTML unchanged');
965
973
  return;
966
974
  }
975
+ this._enqueueSend(pending.html, pending.identityMap, etag || null);
976
+ }
967
977
 
968
- 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})`);
969
980
 
970
981
  this._sendInFlight = true;
971
- const gen = this._applyGen;
972
982
  let retry = false;
973
983
 
974
984
  // Absolute against the real origin, so a <base href> in the page cannot
@@ -987,18 +997,7 @@ class LiveSync {
987
997
  ...(etag ? { etag } : {})
988
998
  })
989
999
  }).then(response => {
990
- if (response.ok) {
991
- // A frame that applied while this POST was on the wire has already
992
- // advanced lastHtml past this snapshot; assigning would rewind the
993
- // diff base to pre-frame state and misclassify the frame's content
994
- // as local edits on the next dirty apply.
995
- if (this._applyGen === gen) {
996
- this.lastHtml = html;
997
- this._lastIdentityMap = identityMap || null;
998
- } else {
999
- this._log('Skipping lastHtml advance: a frame applied during the POST');
1000
- }
1001
- } else {
1000
+ if (!response.ok) {
1002
1001
  console.warn('[LiveSync] Save returned status:', response.status);
1003
1002
  retry = response.status >= 500 || response.status === 408 || response.status === 429;
1004
1003
  }
@@ -1284,6 +1283,22 @@ class LiveSync {
1284
1283
  this._diskBaseTicket = ticket;
1285
1284
  }
1286
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
+
1287
1302
  _requestFrame(cb) {
1288
1303
  if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
1289
1304
  return window.requestAnimationFrame(cb);
@@ -1323,7 +1338,7 @@ class LiveSync {
1323
1338
  * @param {boolean} lane.synthetic - whether this tab's synthetic ids apply
1324
1339
  * @param {object} [lane.extra] - extra mergeDocument options (the disk lane's beforeApply)
1325
1340
  */
1326
- 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 = {} }) {
1327
1342
  const store = this.identity;
1328
1343
  // A frame may neither write these onto our root nor, by not carrying them,
1329
1344
  // take ours away. Returning false is hyper-morph's veto for both directions.
@@ -1363,6 +1378,7 @@ class LiveSync {
1363
1378
  remoteWins: (el) => el.matches(REMOTE_WINS_SELECTOR),
1364
1379
  ignoreAttribute: isRootLocalAttr,
1365
1380
  protectFocusedValue: true,
1381
+ keepLiveOnly: source === 'peer',
1366
1382
  scripts: { mergeTags: mergeTagRecognizers },
1367
1383
  hooks: { beforeAttributeUpdated },
1368
1384
  ...extra,
@@ -1377,10 +1393,13 @@ class LiveSync {
1377
1393
  // loaded and run. Resuming mutations earlier would let late scripts' DOM
1378
1394
  // changes look like user edits and echo back out.
1379
1395
  let pending;
1396
+ let localRoot = null;
1397
+ let applyId = null;
1380
1398
  if (base == null) {
1381
1399
  pending = HyperMorph.mergeDocument({
1382
1400
  ...common,
1383
1401
  base: null,
1402
+ local: { root: captureLocal(), toLive: originalSnapshotNode },
1384
1403
  identity: { local: localIdentity, remote: sideIdentity(identityMap) },
1385
1404
  });
1386
1405
  } else if (!dirty) {
@@ -1399,6 +1418,7 @@ class LiveSync {
1399
1418
  ...common,
1400
1419
  base: baseDoc,
1401
1420
  local: { root: localRoot, toLive: originalSnapshotNode },
1421
+ fastPath: true,
1402
1422
  identity: {
1403
1423
  base: localIdentity,
1404
1424
  local: localIdentity,
@@ -1406,41 +1426,47 @@ class LiveSync {
1406
1426
  },
1407
1427
  });
1408
1428
  } else {
1409
- pending = HyperMorph.mergeDocument({
1410
- ...common,
1411
- base,
1412
- local: { root: captureLocal(), toLive: originalSnapshotNode },
1413
- identity: {
1414
- base: sideIdentity(baseIdentityMap),
1415
- local: localIdentity,
1416
- remote: sideIdentity(identityMap),
1417
- },
1429
+ localRoot = captureLocal();
1430
+ applyId = beginApply({
1431
+ source, seq, etag, root: localRoot,
1432
+ domain: source === 'disk' ? 'save' : 'sync',
1418
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
+ }
1419
1449
  }
1420
1450
  // The apply itself ran synchronously inside mergeDocument; only resource
1421
1451
  // loads are still pending. This is the moment the frame's bytes were true
1422
1452
  // of the document, so the lane dates its base here, not after the await:
1423
1453
  // a save that captures during the wait is newer than this frame.
1424
1454
  const ticket = this._ticket();
1425
- 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
+ }
1426
1463
  const typedDuringWait = gateCaptureToken().gen !== token.gen;
1427
1464
 
1428
- // A conflict the frame won leaves this tab's text in report.conflicts and
1429
- // nowhere else. It is kept until this tab's next successful save, so the
1430
- // page stays dirty (the close warning holds) however many clean frames
1431
- // follow, and nothing writes the page out from under it automatically.
1432
- if (dirty && report.conflicts.length) {
1433
- for (const c of report.conflicts) this._conflictTickets.set(c, ticket);
1434
- this.unresolvedConflicts.push(...report.conflicts);
1435
- }
1436
- // Clean only when nothing of this tab's survived, nothing of it was lost,
1437
- // and nothing lost earlier is still waiting on a save.
1438
- if (
1439
- dirty &&
1440
- !report.localDiverged &&
1441
- report.conflicts.length === 0 &&
1442
- this.unresolvedConflicts.length === 0
1443
- ) {
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) {
1444
1470
  gateClearIfUnchanged(token);
1445
1471
  }
1446
1472
  // Synthetic ids converge: every live element the frame named takes the
@@ -1451,20 +1477,18 @@ class LiveSync {
1451
1477
  const synthetic = mapIds.has(id) ? id : syntheticByAuthored.get(id);
1452
1478
  if (synthetic) store.adopt(el, synthetic);
1453
1479
  }
1454
- return { report, ticket, typedDuringWait };
1480
+ return { report, ticket, typedDuringWait, conflictIds };
1455
1481
  }
1456
1482
 
1457
1483
  /**
1458
1484
  * The automatic save a merge may owe: the merge kept this tab's edits
1459
1485
  * (convergence), or the person typed during the awaited apply (the mutation
1460
1486
  * feed was paused, so nothing else will schedule it). Only where autosave is
1461
- * on, and never over an unresolved conflict: that save would write the page
1462
- * with the lost text gone and clear the one signal that says so.
1487
+ * on.
1463
1488
  */
1464
1489
  _saveAfterMerge(diverged, typedDuringWait) {
1465
1490
  if (!(diverged || typedDuringWait)) return;
1466
1491
  if (!autosaveActive()) return;
1467
- if (this.unresolvedConflicts.length) return;
1468
1492
  savePageThrottled();
1469
1493
  }
1470
1494
 
@@ -1538,11 +1562,14 @@ class LiveSync {
1538
1562
  // knowingly missing what disk holds, and a stamp there would let its next
1539
1563
  // save replace that change with nobody told.
1540
1564
 
1541
- const { report, typedDuringWait: typed } = await this._mergeIncoming(html, identityMap, {
1565
+ const { report, ticket, typedDuringWait: typed, conflictIds } = await this._mergeIncoming(html, identityMap, {
1542
1566
  base: this.lastHtml,
1543
1567
  baseIdentityMap: this._lastIdentityMap,
1544
1568
  captureLocal: () => captureSnapshot({ flushUndo: false }),
1545
1569
  synthetic: true,
1570
+ source: 'peer',
1571
+ seq,
1572
+ etag,
1546
1573
  });
1547
1574
  diverged = report.localDiverged;
1548
1575
  typedDuringWait = typed;
@@ -1561,11 +1588,9 @@ class LiveSync {
1561
1588
  // two of a burst would read the protected section as clean and clobber
1562
1589
  // it) and could dedupe away the convergence send. Convergence is driven
1563
1590
  // by the explicit save below instead.
1564
- this.lastHtml = html;
1565
- this._lastIdentityMap =
1566
- identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)
1567
- ? identityMap
1568
- : 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);
1569
1594
  this._applyGen++;
1570
1595
 
1571
1596
  // The stamp of section 6, taken now that the bytes it describes are the
@@ -1573,7 +1598,7 @@ class LiveSync {
1573
1598
  // above, and deliberately the same moment: the two claims a page makes
1574
1599
  // when it adopts a stamp are "the host stores this version" and "I hold
1575
1600
  // it", and only the second one is this tab's to make.
1576
- if (typeof etag === 'string' && etag) {
1601
+ if (!(this._acceptedSaveTicket > ticket) && typeof etag === 'string' && etag) {
1577
1602
  recordEtag(etag);
1578
1603
  stamped = true;
1579
1604
  }
@@ -1598,13 +1623,10 @@ class LiveSync {
1598
1623
  // gate or a diverged merge leaves the base where it was, and the
1599
1624
  // convergence save moves it once the merge is on disk.
1600
1625
  //
1601
- // Unresolved conflicts keep every saved baseline where it is too: the
1602
- // page is dirty by definition until this tab saves.
1603
1626
  if (
1604
1627
  this.lane === 'live' &&
1605
1628
  !diverged &&
1606
- !pageMaybeDirty() &&
1607
- this.unresolvedConflicts.length === 0
1629
+ !pageMaybeDirty()
1608
1630
  ) {
1609
1631
  const { forSave, forComparison, forDirty } = captureForSaveAndComparison({
1610
1632
  emitForSync: false,
@@ -1631,7 +1653,7 @@ class LiveSync {
1631
1653
  // returns above without reaching here, so nothing can name an author for a
1632
1654
  // change this tab never took. Null on every frame the host did not stamp.
1633
1655
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1634
- detail: { seq, source: 'peer', by: by || null, report }
1656
+ detail: { seq, source: 'peer', by: by || null, report, unresolved: this.unresolvedConflicts, conflictIds }
1635
1657
  }));
1636
1658
  } finally {
1637
1659
  this._log('applyUpdate - morph complete, resuming mutations');
@@ -1650,9 +1672,7 @@ class LiveSync {
1650
1672
  // isPaused is lifted so the convergence save's relay reaches peers.
1651
1673
  // Only where autosave is on: a manual-save page must never auto-write, so
1652
1674
  // there the merge stays local, still dirty, until the person saves.
1653
- // Not over a lost conflict: the hold and its bar stay up as the signal that
1654
- // this tab lost text, until the person saves.
1655
- if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
1675
+ if (stamped) conflictResolvedBySync(etag);
1656
1676
  this._saveAfterMerge(diverged, typedDuringWait);
1657
1677
  }
1658
1678
 
@@ -1717,10 +1737,13 @@ class LiveSync {
1717
1737
  // file. The merged document is activated for edit mode before it is
1718
1738
  // applied, exactly as boot would have, so every node, local or incoming,
1719
1739
  // goes live from its own saved form.
1720
- const { report, ticket, typedDuringWait: typed } = await this._mergeIncoming(html, null, {
1740
+ const { report, ticket, typedDuringWait: typed, conflictIds } = await this._mergeIncoming(html, null, {
1721
1741
  base: this._diskBase,
1722
1742
  captureLocal: () => captureForMerge().saveClone,
1723
1743
  synthetic: false,
1744
+ source: 'disk',
1745
+ seq,
1746
+ etag,
1724
1747
  extra: { beforeApply: (doc) => activateIncomingDoc(doc.documentElement) },
1725
1748
  });
1726
1749
  diverged = report.localDiverged;
@@ -1748,8 +1771,7 @@ class LiveSync {
1748
1771
  if (
1749
1772
  this.lane === 'live' &&
1750
1773
  !diverged &&
1751
- !pageMaybeDirty() &&
1752
- this.unresolvedConflicts.length === 0
1774
+ !pageMaybeDirty()
1753
1775
  ) {
1754
1776
  // Clean apply: the DOM now IS the disk state, so a local comparison
1755
1777
  // capture of it is the truthful baseline. The next no-op save skips,
@@ -1765,8 +1787,7 @@ class LiveSync {
1765
1787
  // newer peer frame. Rebuild it exactly the way the send pipeline
1766
1788
  // does, so it stays in the snapshot domain.
1767
1789
  const clone = captureSnapshot({ flushUndo: false });
1768
- this.lastHtml = serializeForSync(clone);
1769
- this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
1790
+ this._setPeerBase(serializeForSync(clone), this.identity.exportMap(clone, originalSnapshotNode), ticket);
1770
1791
  this._applyGen++;
1771
1792
  }
1772
1793
 
@@ -1780,6 +1801,8 @@ class LiveSync {
1780
1801
  // so this tab's next save copies from the file somebody else just wrote.
1781
1802
  html,
1782
1803
  report,
1804
+ conflictIds,
1805
+ unresolved: this.unresolvedConflicts,
1783
1806
  }
1784
1807
  }));
1785
1808
  } finally {
@@ -1794,9 +1817,7 @@ class LiveSync {
1794
1817
  // (our edits + their bytes). The baseline was left pre-external, so the
1795
1818
  // save sees both as changes and writes the merge back. Only where
1796
1819
  // autosave is on, as in the peer lane.
1797
- // Not over a lost conflict: the hold and its bar stay up as the signal that
1798
- // this tab lost text, until the person saves.
1799
- if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
1820
+ if (stamped) conflictResolvedBySync(etag);
1800
1821
  this._saveAfterMerge(diverged, typedDuringWait);
1801
1822
  }
1802
1823
 
@@ -1860,5 +1881,5 @@ if (typeof window !== 'undefined') {
1860
1881
  // Export for the clayjs module system. The class itself is exported so
1861
1882
  // tests can create fresh instances without driving the singleton's
1862
1883
  // EventSource/snapshot wiring.
1863
- export { liveSync, LiveSync, morph, WIRE_PROFILES };
1884
+ export { liveSync, LiveSync, morph, WIRE_PROFILES, conflicts, SYNC_IGNORE_SELECTOR, REMOTE_WINS_SELECTOR };
1864
1885
  export default liveSync;