@panphora/clayjs 0.1.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/clay.js +21 -0
  4. package/package.json +27 -0
  5. package/src/attrs/onaftersave.js +38 -0
  6. package/src/attrs/refetch-on-save.js +36 -0
  7. package/src/attrs/save-freeze.js +102 -0
  8. package/src/core/admin-attrs.js +22 -0
  9. package/src/core/admin-contenteditable.js +47 -0
  10. package/src/core/admin-inputs.js +48 -0
  11. package/src/core/admin-onclick.js +50 -0
  12. package/src/core/admin-resources.js +49 -0
  13. package/src/core/autosave.js +61 -0
  14. package/src/core/edit-mode.js +38 -0
  15. package/src/core/is-edit-mode.js +30 -0
  16. package/src/core/persist.js +103 -0
  17. package/src/core/save-core.js +385 -0
  18. package/src/core/save.js +475 -0
  19. package/src/core/snapshot.js +282 -0
  20. package/src/core/unsaved-warning.js +38 -0
  21. package/src/lib/autosave-debug.js +223 -0
  22. package/src/lib/cache-bust.js +12 -0
  23. package/src/lib/cookie.js +37 -0
  24. package/src/lib/dom-ready.js +9 -0
  25. package/src/lib/extension-noise.js +63 -0
  26. package/src/lib/load-vendor-script.js +57 -0
  27. package/src/lib/mutation.js +719 -0
  28. package/src/lib/query.js +3 -0
  29. package/src/lib/region-policy.js +220 -0
  30. package/src/lib/throttle.js +41 -0
  31. package/src/lib/user-gesture.js +126 -0
  32. package/src/loader-logic.js +62 -0
  33. package/src/loader.js +123 -0
  34. package/src/plugins/indicator.js +51 -0
  35. package/src/plugins/sortable.js +119 -0
  36. package/src/plugins/undo.js +23 -0
  37. package/src/sync/live-sync.js +752 -0
  38. package/src/vendor/Sortable.vendor.js +2 -0
  39. package/src/vendor/control-serialize.vendor.js +88 -0
  40. package/src/vendor/hyper-morph.vendor.js +22 -0
  41. package/src/vendor/hyper-undo.vendor.js +11 -0
  42. package/src/vendor/hypercms.vendor.js +1751 -0
  43. package/src/vendor/richclay.vendor.js +456 -0
@@ -0,0 +1,752 @@
1
+ /**
2
+ * live-sync.js — Real-time sync between admin users
3
+ *
4
+ * HOW IT WORKS:
5
+ *
6
+ * ┌─────────────────────────────────────────────────────────┐
7
+ * │ 1. LISTEN snapshot-ready event from save │
8
+ * │ (full document with form values) │
9
+ * └─────────────────────────────────────────────────────────┘
10
+ * │
11
+ * ▼
12
+ * ┌─────────────────────────────────────────────────────────┐
13
+ * │ 2. SEND POST html to /_/live-sync/save │
14
+ * │ (debounced, skip if unchanged) │
15
+ * └─────────────────────────────────────────────────────────┘
16
+ * │
17
+ * ▼
18
+ * ┌─────────────────────────────────────────────────────────┐
19
+ * │ 3. RECEIVE SSE stream from server │
20
+ * │ (other clients' changes) │
21
+ * └─────────────────────────────────────────────────────────┘
22
+ * │
23
+ * ▼
24
+ * ┌─────────────────────────────────────────────────────────┐
25
+ * │ 4. MORPH HyperMorph full documentElement │
26
+ * │ (preserves focus, input values) │
27
+ * └─────────────────────────────────────────────────────────┘
28
+ *
29
+ * LANES: edit-mode tabs ride the 'live' lane (steps 1-4: they broadcast
30
+ * pre-strip snapshots to peers and receive theirs). View-mode tabs ride the
31
+ * 'saved' lane: receive-only (steps 3-4), fed post-strip on-disk HTML by the
32
+ * server whenever the file is persisted (browser save, code editor, device
33
+ * sync, template/restore, local external edits).
34
+ *
35
+ * INTEGRATES WITH: snapshot.js (receives snapshot-ready events)
36
+ */
37
+
38
+ import { HyperMorph, morph } from "../vendor/hyper-morph.vendor.js";
39
+ import Mutation from "../lib/mutation.js";
40
+ import { isSnapshotRemoved } from "../lib/region-policy.js";
41
+ import { isEditMode } from "../core/is-edit-mode.js";
42
+
43
+ class LiveSync {
44
+ constructor() {
45
+ this.sse = null;
46
+ this.currentFile = null;
47
+ this.lastHtml = null;
48
+ this.clientId = this.generateClientId();
49
+ this.debounceMs = 150;
50
+ this.debounceTimer = null;
51
+ this.isPaused = false;
52
+ this.isDestroyed = false;
53
+ this.debug = false;
54
+
55
+ // Lane on the shared per-file SSE channel. Edit mode: 'live' (owner-gated,
56
+ // pre-strip peer snapshots + notifications). View mode: 'saved' (receive-
57
+ // only post-strip on-disk HTML). Overridable per-instance for tests.
58
+ this.lane = isEditMode ? 'live' : 'saved';
59
+
60
+ // Store handler reference for cleanup
61
+ this._snapshotHandler = null;
62
+
63
+ // High-water mark of server seqs we've seen on this channel (own echoes
64
+ // count too). Server-broadcast payloads carry a monotonic seq
65
+ // (Date.now()-based). We drop anything <= this to guard against rare
66
+ // cases where a stale message lands after a newer one, e.g. buffered
67
+ // replay, alt backend after reconnect, or an own-save echo arriving
68
+ // after a peer's newer broadcast.
69
+ this.lastSeenSeq = 0;
70
+
71
+ // rAF-paced single-flight queue. Incoming updates overwrite a pending
72
+ // slot; on each animation frame, if a slot is set and no morph is in
73
+ // flight, run one morph against the latest pending payload. Burst
74
+ // arrivals collapse to one morph per frame, keeping the receiver from
75
+ // falling behind under load. A morph with external scripts returns a
76
+ // Promise, so the in-flight flag prevents overlap.
77
+ this._pendingHtml = null;
78
+ this._pendingSeq = null;
79
+ this._pendingIdentityMap = null;
80
+ this._morphInFlight = false;
81
+ this._rafHandle = null;
82
+
83
+ // Identity tracking for content-based morphing across live-sync updates.
84
+ // Synthetic IDs (`<clientId>:<counter>`) live here only — never written to
85
+ // the DOM, never serialized into saved HTML. The WeakMap holds them
86
+ // against the live elements so that the next save can produce the same
87
+ // identityMap, and afterNodeMorphed transfers IDs from incoming parsed
88
+ // elements onto the live elements they morphed into.
89
+ this.idCounter = this._loadIdCounter();
90
+ this.liveWeakMap = new WeakMap();
91
+
92
+ // Callbacks
93
+ this.onConnect = null;
94
+ this.onDisconnect = null;
95
+ this.onUpdate = null;
96
+ this.onError = null;
97
+ this.onNotification = null;
98
+ }
99
+
100
+ _log(message, data = null) {
101
+ if (!this.debug) return;
102
+ const prefix = `[LiveSync ${new Date().toISOString()}]`;
103
+ if (data !== null) {
104
+ console.log(prefix, message, data);
105
+ } else {
106
+ console.log(prefix, message);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Generate or retrieve a tab-specific client ID
112
+ * Uses sessionStorage (unique per tab) not localStorage (shared across tabs)
113
+ */
114
+ generateClientId() {
115
+ let id = null;
116
+
117
+ try {
118
+ id = sessionStorage.getItem('livesync-client-id');
119
+ } catch (e) {
120
+ // sessionStorage might not be available
121
+ }
122
+
123
+ if (!id) {
124
+ id = Math.random().toString(36).slice(2, 11) + Date.now().toString(36);
125
+ try {
126
+ sessionStorage.setItem('livesync-client-id', id);
127
+ } catch (e) {
128
+ // That's okay
129
+ }
130
+ }
131
+
132
+ return id;
133
+ }
134
+
135
+ /**
136
+ * Start the LiveSync system
137
+ * Can be called after stop() to restart with a new file
138
+ */
139
+ start(file = null) {
140
+ // Reset destroyed flag to allow restart after stop()
141
+ this.isDestroyed = false;
142
+
143
+ // Prevent double-connect: clean up existing connection first
144
+ if (this.sse || this._snapshotHandler) {
145
+ this.cleanup();
146
+ }
147
+
148
+ this.currentFile = file || this.detectCurrentFile();
149
+
150
+ if (!this.currentFile) {
151
+ console.warn('[LiveSync] No file detected');
152
+ return;
153
+ }
154
+
155
+ // Reset state for new connection
156
+ this.lastHtml = null;
157
+ this.lastSeenSeq = 0;
158
+
159
+ console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
160
+ this.connect();
161
+ // View-mode tabs are receive-only: saves are edit-gated upstream, so a
162
+ // snapshot listener would never fire — skip registering it.
163
+ if (this.lane === 'live') {
164
+ this.listenForSnapshots();
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Clean up resources without marking as destroyed
170
+ * Used internally by start() to prevent double-connect
171
+ */
172
+ cleanup() {
173
+ if (this.sse) {
174
+ this.sse.close();
175
+ this.sse = null;
176
+ }
177
+
178
+ if (this._snapshotHandler) {
179
+ document.removeEventListener('clay:snapshot-ready', this._snapshotHandler);
180
+ this._snapshotHandler = null;
181
+ }
182
+
183
+ clearTimeout(this.debounceTimer);
184
+
185
+ // Cancel any pending frame and clear the queue. A morph already in
186
+ // flight cannot be aborted; the isDestroyed check in _runPending guards
187
+ // its post-morph rescheduling so the queue stops cleanly.
188
+ if (this._rafHandle != null) {
189
+ this._cancelFrame(this._rafHandle);
190
+ this._rafHandle = null;
191
+ }
192
+ this._pendingHtml = null;
193
+ this._pendingSeq = null;
194
+ this._pendingIdentityMap = null;
195
+ }
196
+
197
+ _loadIdCounter() {
198
+ try {
199
+ const raw = sessionStorage.getItem('livesync-id-counter');
200
+ const n = parseInt(raw || '0', 10);
201
+ return Number.isFinite(n) && n > 0 ? n : 0;
202
+ } catch (e) {
203
+ return 0;
204
+ }
205
+ }
206
+
207
+ _persistIdCounter() {
208
+ try {
209
+ sessionStorage.setItem('livesync-id-counter', String(this.idCounter));
210
+ } catch (e) {
211
+ // Storage full / sandboxed — fall back to in-memory only.
212
+ }
213
+ }
214
+
215
+ _mintId() {
216
+ this.idCounter++;
217
+ this._persistIdCounter();
218
+ return `${this.clientId}:${this.idCounter}`;
219
+ }
220
+
221
+ /**
222
+ * Walk the live DOM and the snapshot clone in lockstep. Path keys come
223
+ * from the clone (= what the receiver will see, after [snapshot-remove]
224
+ * and snapshotHooks). WeakMap lookup happens against the live element so
225
+ * synthetic IDs persist across saves.
226
+ *
227
+ * The live walk filters [snapshot-remove] to mirror the clone's earlier
228
+ * strip in captureSnapshot. If child counts diverge anywhere (an
229
+ * onbeforesnapshot handler added/removed siblings on the clone), the
230
+ * subtree is skipped — better to fall back to content scoring there than
231
+ * emit misaligned IDs.
232
+ *
233
+ * @param {Element} liveRoot
234
+ * @param {Element} cloneRoot
235
+ * @returns {Object} identityMap keyed by dot-path
236
+ */
237
+ _buildIdentityMap(liveRoot, cloneRoot) {
238
+ const map = {};
239
+ if (!liveRoot || !cloneRoot) return map;
240
+
241
+ const visit = (live, clone, path) => {
242
+ let id = this.liveWeakMap.get(live);
243
+ if (!id) {
244
+ id = this._mintId();
245
+ this.liveWeakMap.set(live, id);
246
+ }
247
+ map[path] = id;
248
+
249
+ const liveKids = [];
250
+ for (const c of live.children) {
251
+ if (!isSnapshotRemoved(c)) liveKids.push(c);
252
+ }
253
+ const cloneKids = clone.children;
254
+
255
+ if (liveKids.length !== cloneKids.length) {
256
+ this._log(
257
+ `identity map: subtree skipped at "${path}" (live=${liveKids.length}, clone=${cloneKids.length})`
258
+ );
259
+ return;
260
+ }
261
+
262
+ for (let i = 0; i < liveKids.length; i++) {
263
+ visit(liveKids[i], cloneKids[i], path === '' ? String(i) : `${path}.${i}`);
264
+ }
265
+ };
266
+
267
+ visit(liveRoot, cloneRoot, '');
268
+ return map;
269
+ }
270
+
271
+ /**
272
+ * Walk a single parsed tree, invoking cb(element, path) at each Element.
273
+ * Paths use the same dot-segment scheme as _buildIdentityMap so the
274
+ * receiver can look up IDs by the path the sender emitted.
275
+ */
276
+ _walkParsedTree(root, cb) {
277
+ if (!root) return;
278
+ const visit = (el, path) => {
279
+ cb(el, path);
280
+ const kids = el.children;
281
+ for (let i = 0; i < kids.length; i++) {
282
+ visit(kids[i], path === '' ? String(i) : `${path}.${i}`);
283
+ }
284
+ };
285
+ visit(root, '');
286
+ }
287
+
288
+ /**
289
+ * Fill liveWeakMap entries for live elements that the matcher's
290
+ * afterNodeMorphed didn't reach. createNode's no-id-children
291
+ * optimization (hyper-morph importNode path) inserts a clone of the
292
+ * parsed element without invoking morphNode, so afterNodeMorphed never
293
+ * fires for those subtrees and their synthetic IDs would be lost. On
294
+ * the receiver's next save, _buildIdentityMap would mint fresh IDs for
295
+ * the same logical elements, breaking convergence for newly-added
296
+ * ambiguous siblings — exactly the case identity-map exists to fix.
297
+ *
298
+ * Walks live and parsed in lockstep using the same path scheme as
299
+ * _buildIdentityMap. Filters [snapshot-remove] from the live side to
300
+ * stay aligned with the sender's clone view. Aborts a subtree on
301
+ * child-count divergence (e.g. local save-ignore additions) — those
302
+ * elements fall through to content scoring on the next round, which
303
+ * is the same fallback as a sender-side lockstep skip.
304
+ *
305
+ * @param {Element} liveRoot - post-morph live tree root
306
+ * @param {Element} parsedRoot - parsed-tree root (still has identityMap WeakMap entries)
307
+ * @param {Object} identityMap - path → id map from the SSE payload
308
+ */
309
+ _fillInIdsAfterMorph(liveRoot, parsedRoot, identityMap) {
310
+ if (!liveRoot || !parsedRoot || !identityMap) return;
311
+ const visit = (live, parsed, path) => {
312
+ const id = identityMap[path];
313
+ if (id && !this.liveWeakMap.has(live)) {
314
+ this.liveWeakMap.set(live, id);
315
+ }
316
+ const liveKids = [];
317
+ for (const c of live.children) {
318
+ if (!isSnapshotRemoved(c)) liveKids.push(c);
319
+ }
320
+ const parsedKids = parsed.children;
321
+ if (liveKids.length !== parsedKids.length) return;
322
+ for (let i = 0; i < liveKids.length; i++) {
323
+ visit(liveKids[i], parsedKids[i], path === '' ? String(i) : `${path}.${i}`);
324
+ }
325
+ };
326
+ visit(liveRoot, parsedRoot, '');
327
+ }
328
+
329
+ /**
330
+ * Auto-detect the current site file path from the URL
331
+ * Returns the path including extension (e.g., card-canvas.html)
332
+ *
333
+ * Handles:
334
+ * - / -> index.html
335
+ * - /about.html -> about.html
336
+ * - /about.htmlclay -> about.htmlclay
337
+ * - /about.html/dashboard -> about.html (SPA route stripped)
338
+ * - /blog/app.htmlclay/settings -> blog/app.htmlclay (SPA route stripped)
339
+ */
340
+ detectCurrentFile() {
341
+ let pathname = window.location.pathname;
342
+
343
+ if (pathname === '/') {
344
+ return 'index.html';
345
+ }
346
+
347
+ pathname = pathname.replace(/^\//, '');
348
+
349
+ const htmlMatch = pathname.match(/^(.*?\.html(?:clay)?)/);
350
+ if (htmlMatch) return htmlMatch[1];
351
+
352
+ return pathname;
353
+ }
354
+
355
+ /**
356
+ * Connect to the SSE endpoint
357
+ * Uses native EventSource reconnection behavior
358
+ */
359
+ connect() {
360
+ if (this.isDestroyed) return;
361
+
362
+ const pageUrl = encodeURIComponent(window.location.href);
363
+ const url = `/_/live-sync/stream?page-url=${pageUrl}&lane=${this.lane}`;
364
+ this.sse = new EventSource(url);
365
+
366
+ this.sse.onopen = () => {
367
+ console.log('[LiveSync] Connected');
368
+ if (this.onConnect) this.onConnect();
369
+ };
370
+
371
+ this.sse.onmessage = (event) => {
372
+ const data = JSON.parse(event.data);
373
+
374
+ // Handle notifications (show toast, don't morph)
375
+ if (data.type === "notification") {
376
+ this.handleNotification(data);
377
+ return;
378
+ }
379
+
380
+ // Handle error events from server
381
+ if (data.error) {
382
+ console.error('[LiveSync] Server error:', data.error);
383
+ if (this.onError) this.onError(new Error(data.error));
384
+ return;
385
+ }
386
+
387
+ const { html, sender, seq, identityMap } = data;
388
+
389
+ // Staleness check runs FIRST — compared against the high-water mark of
390
+ // seqs we've seen (own echoes count too, see below). `seq` is optional
391
+ // for back-compat with older server builds that don't stamp it.
392
+ if (typeof seq === 'number' && seq <= this.lastSeenSeq) {
393
+ this._log(`Dropping stale message: seq=${seq}, lastSeen=${this.lastSeenSeq}`);
394
+ return;
395
+ }
396
+
397
+ // Advance the watermark before the sender filter so that our own save
398
+ // echoes count toward "seen". Without this, a later buffered/replayed
399
+ // peer message with a smaller seq could rewind us past our local edit.
400
+ if (typeof seq === 'number') {
401
+ this.lastSeenSeq = seq;
402
+ }
403
+
404
+ // Ignore own changes — already reflected in the DOM, nothing to morph
405
+ if (sender === this.clientId) {
406
+ this._log('Ignoring own message (sender matches clientId)');
407
+ return;
408
+ }
409
+
410
+ // Guard against invalid html
411
+ if (typeof html !== 'string') {
412
+ console.error('[LiveSync] Received invalid html, ignoring');
413
+ return;
414
+ }
415
+
416
+ this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
417
+ this.applyUpdate(html, seq, identityMap);
418
+ if (this.onUpdate) this.onUpdate({ html, sender, seq, identityMap });
419
+ };
420
+
421
+ // Native EventSource auto-reconnects on transient errors
422
+ // We just surface the status via callbacks
423
+ this.sse.onerror = () => {
424
+ if (this.sse.readyState === EventSource.CONNECTING) {
425
+ console.log('[LiveSync] Reconnecting...');
426
+ } else if (this.sse.readyState === EventSource.CLOSED) {
427
+ console.log('[LiveSync] Connection closed');
428
+ if (this.onError) this.onError(new Error('Connection closed'));
429
+ }
430
+ if (this.onDisconnect) this.onDisconnect();
431
+ };
432
+ }
433
+
434
+ /**
435
+ * Listen for snapshot-ready events from the save system.
436
+ * Receives the full cloned documentElement and sends it.
437
+ */
438
+ listenForSnapshots() {
439
+ this._snapshotHandler = (event) => {
440
+ if (this.isPaused) {
441
+ this._log('snapshot-ready received but isPaused, skipping');
442
+ return;
443
+ }
444
+
445
+ const { documentElement: clone } = event.detail;
446
+ if (!clone) return;
447
+
448
+ // Capture both synchronously inside the event handler — captureSnapshot's
449
+ // caller continues to mutate the clone (strip [save-remove], run hooks)
450
+ // after dispatchEvent returns, so reading outerHTML and walking children
451
+ // must happen now.
452
+ this._log('snapshot-ready received, preparing to send');
453
+ const html = clone.outerHTML;
454
+ const identityMap = this._buildIdentityMap(document.documentElement, clone);
455
+ this.sendUpdate(html, identityMap);
456
+ };
457
+
458
+ document.addEventListener('clay:snapshot-ready', this._snapshotHandler);
459
+ }
460
+
461
+ /**
462
+ * Send full HTML to the server (debounced)
463
+ * Only updates lastHtml after successful save
464
+ */
465
+ sendUpdate(html, identityMap) {
466
+ clearTimeout(this.debounceTimer);
467
+
468
+ this.debounceTimer = setTimeout(() => {
469
+ // Skip if unchanged
470
+ if (html === this.lastHtml) {
471
+ this._log('Skipping send - HTML unchanged');
472
+ return;
473
+ }
474
+
475
+ this._log(`Sending update (HTML length: ${html.length}, lastHtml length: ${this.lastHtml?.length || 0})`);
476
+
477
+ fetch('/_/live-sync/save', {
478
+ method: 'POST',
479
+ headers: { 'Content-Type': 'application/json', 'Page-URL': window.location.href },
480
+ body: JSON.stringify({
481
+ html: html,
482
+ sender: this.clientId,
483
+ identityMap: identityMap
484
+ })
485
+ }).then(response => {
486
+ if (response.ok) {
487
+ this.lastHtml = html;
488
+ } else {
489
+ console.warn('[LiveSync] Save returned status:', response.status);
490
+ }
491
+ }).catch(err => {
492
+ console.error('[LiveSync] Save failed:', err);
493
+ if (this.onError) this.onError(err);
494
+ });
495
+ }, this.debounceMs);
496
+ }
497
+
498
+ /**
499
+ * Apply an update received from the server. Morphs the entire document.
500
+ *
501
+ * Updates land in a single pending slot. On each animation frame, the
502
+ * latest pending payload is morphed once; intermediate updates that
503
+ * arrived between frames are skipped because they would be replaced
504
+ * milliseconds later anyway. Burst arrivals collapse to roughly one morph
505
+ * per frame, so the receiver always shows current state instead of
506
+ * playing back a backlog of stale snapshots.
507
+ *
508
+ * @param {string} html - Full document HTML
509
+ * @param {number} [seq] - Optional monotonic seq from the server
510
+ * @param {Object} [identityMap] - Optional element-identity map from sender
511
+ */
512
+ applyUpdate(html, seq, identityMap) {
513
+ if (this.isDestroyed) return;
514
+ this._pendingHtml = html;
515
+ this._pendingSeq = seq;
516
+ this._pendingIdentityMap = identityMap;
517
+ this._scheduleNextFrame();
518
+ }
519
+
520
+ /**
521
+ * Schedule the next-frame morph if one isn't already pending. Skipped when
522
+ * a morph is in flight; the morph's post-completion check will reschedule
523
+ * if a newer payload arrived during it.
524
+ */
525
+ _scheduleNextFrame() {
526
+ if (this.isDestroyed) return;
527
+ if (this._rafHandle != null) return;
528
+ if (this._morphInFlight) return;
529
+ this._rafHandle = this._requestFrame(() => this._runPending());
530
+ }
531
+
532
+ /**
533
+ * Drain the pending slot once. Errors are caught and logged so a single
534
+ * failed morph does not stop the queue.
535
+ */
536
+ async _runPending() {
537
+ this._rafHandle = null;
538
+ if (this.isDestroyed) return;
539
+
540
+ const html = this._pendingHtml;
541
+ const seq = this._pendingSeq;
542
+ const identityMap = this._pendingIdentityMap;
543
+ this._pendingHtml = null;
544
+ this._pendingSeq = null;
545
+ this._pendingIdentityMap = null;
546
+ if (html == null) return;
547
+
548
+ this._morphInFlight = true;
549
+ try {
550
+ await this._doApplyUpdate(html, seq, identityMap);
551
+ } catch (err) {
552
+ console.error('[LiveSync] applyUpdate failed:', err);
553
+ } finally {
554
+ this._morphInFlight = false;
555
+ }
556
+
557
+ // A newer payload may have arrived during the morph. Schedule another
558
+ // frame to drain it. Without this, late-arriving updates would sit
559
+ // forever until the next applyUpdate call.
560
+ if (!this.isDestroyed && this._pendingHtml != null) {
561
+ this._scheduleNextFrame();
562
+ }
563
+ }
564
+
565
+ _requestFrame(cb) {
566
+ if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
567
+ return window.requestAnimationFrame(cb);
568
+ }
569
+ return setTimeout(cb, 16);
570
+ }
571
+
572
+ _cancelFrame(handle) {
573
+ if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
574
+ window.cancelAnimationFrame(handle);
575
+ return;
576
+ }
577
+ clearTimeout(handle);
578
+ }
579
+
580
+ /**
581
+ * Actual morph work. Do not call directly. Use applyUpdate() so calls
582
+ * pass through the rAF queue and don't overlap.
583
+ * @param {string} html
584
+ * @param {number} [seq]
585
+ * @param {Object} [identityMap]
586
+ * @returns {Promise<void>}
587
+ */
588
+ async _doApplyUpdate(html, seq, identityMap) {
589
+ this._log('applyUpdate - pausing mutations and morphing');
590
+ this.isPaused = true;
591
+
592
+ // Preserve scroll position: a remote edit that inserts or removes content
593
+ // above the viewport would otherwise cause a visible jump. Capturing here
594
+ // and restoring after the morph keeps the viewport stable. Browser
595
+ // scroll-clamping handles the case where the document is now shorter.
596
+ const scrollX = window.scrollX;
597
+ const scrollY = window.scrollY;
598
+
599
+ // Pause mutation observer so morph doesn't trigger autosave
600
+ Mutation.pause();
601
+
602
+ // Parse as full document
603
+ const parser = new DOMParser();
604
+ const newDoc = parser.parseFromString(html, 'text/html');
605
+
606
+ // Build the parsed-tree WeakMap from the incoming identityMap. The
607
+ // sender emitted paths off its clone, which is exactly what we just
608
+ // parsed, so the same path scheme indexes into both trees.
609
+ const parsedWeakMap = new WeakMap();
610
+ if (identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)) {
611
+ this._walkParsedTree(newDoc.documentElement, (el, path) => {
612
+ const id = identityMap[path];
613
+ if (id) parsedWeakMap.set(el, id);
614
+ });
615
+ }
616
+
617
+ const liveWeakMap = this.liveWeakMap;
618
+ // Priority: synthetic IDs win when present (they're updated after every
619
+ // morph via afterNodeMorphed). data-id / id is the durable fallback that
620
+ // covers the bootstrap window and any element that hasn't been paired yet.
621
+ const key = (el) =>
622
+ liveWeakMap.get(el) ||
623
+ parsedWeakMap.get(el) ||
624
+ (el.getAttribute && el.getAttribute('data-id')) ||
625
+ (el.getAttribute && el.getAttribute('id')) ||
626
+ null;
627
+ const afterNodeMorphed = (oldEl, newEl) => {
628
+ const id = parsedWeakMap.get(newEl);
629
+ if (id) liveWeakMap.set(oldEl, id);
630
+ };
631
+
632
+ try {
633
+ // Morph entire document. We MUST await — HyperMorph.morph returns a
634
+ // Promise when `scripts: { handle: true }` needs to wait for external
635
+ // scripts to load. If we don't await, Mutation.resume() fires before
636
+ // late-loading scripts execute, and any DOM mutations they trigger look
637
+ // like user edits → the receiving tab rebroadcasts them (feedback loop).
638
+ await HyperMorph.morph(document.documentElement, newDoc.documentElement, {
639
+ morphStyle: 'outerHTML',
640
+ ignoreActiveValue: true,
641
+ head: { style: 'merge' },
642
+ scripts: { handle: true, matchMode: 'smart' },
643
+ key,
644
+ callbacks: { afterNodeMorphed }
645
+ });
646
+
647
+ // Restore viewport. Done after morph so layout has settled.
648
+ window.scrollTo(scrollX, scrollY);
649
+
650
+ // Fill in any IDs the matcher's afterNodeMorphed missed. Brand-new
651
+ // elements come in via hyper-morph's importNode optimization, which
652
+ // skips morphNode and thus afterNodeMorphed; their parsedWeakMap IDs
653
+ // never make it onto liveWeakMap. Without this pass, the receiver
654
+ // would mint fresh IDs on its next save for those elements,
655
+ // breaking convergence exactly for newly-added ambiguous siblings.
656
+ if (identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)) {
657
+ this._fillInIdsAfterMorph(document.documentElement, newDoc.documentElement, identityMap);
658
+ }
659
+
660
+ // Only mark lastHtml after a successful morph so that a failed apply
661
+ // doesn't desync our state and cause the next outbound save to be
662
+ // mistakenly skipped as "unchanged". Note: lastSeenSeq is advanced at
663
+ // receive time (in onmessage) so the staleness check covers own-save
664
+ // echoes even when they don't reach this point.
665
+ this.lastHtml = html;
666
+
667
+ // Announce that a remote morph just landed, so document-level listeners
668
+ // that are deaf to Mutation.pause (e.g. the hypercms form panel) can
669
+ // re-sync. Fires only on a successful apply, and only for genuine remote
670
+ // morphs — own-sender and stale-seq echoes are filtered upstream in
671
+ // onmessage before applyUpdate is ever called. Covers every SSE morph
672
+ // source (peer edit, version restore, body-swap) since they all funnel
673
+ // through this single choke point.
674
+ document.dispatchEvent(new CustomEvent('clay:sync-applied', {
675
+ detail: { seq }
676
+ }));
677
+ // vendor-compat: hypercms's form panel listens for the legacy name.
678
+ document.dispatchEvent(new CustomEvent('hyperclay:livesync-applied', {
679
+ detail: { seq }
680
+ }));
681
+ } finally {
682
+ this._log('applyUpdate - morph complete, resuming mutations');
683
+ Mutation.resume();
684
+ // Defer past microtask boundary — MutationObserver callbacks fire before
685
+ // this, so isPaused catches any stray snapshots from the morph itself.
686
+ await new Promise((resolve) => setTimeout(resolve, 0));
687
+ this.isPaused = false;
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Handle a notification message from the server
693
+ * Shows a toast and emits an event for custom handling
694
+ * @param {Object} data - { msgType, msg, action?, persistent? }
695
+ */
696
+ handleNotification({ msgType, msg, action, persistent, data }) {
697
+ this._log(`Notification received: ${msgType} - ${msg}`);
698
+
699
+ // The data-loss guard rides this channel but is NOT a toast — the panel
700
+ // module handles it via the clay:notification event below.
701
+ const isDataLoss = msgType === 'data-loss';
702
+
703
+ // Show toast if available
704
+ if (!isDataLoss) {
705
+ if (persistent && window.toastPersistent) {
706
+ window.toastPersistent(msg, msgType);
707
+ } else if (window.toast) {
708
+ window.toast(msg, msgType);
709
+ } else {
710
+ console.log(`[LiveSync] Notification: ${msg}`);
711
+ }
712
+ }
713
+
714
+ // Emit event for custom handling (e.g., reload button, data-loss chip)
715
+ document.dispatchEvent(new CustomEvent('clay:notification', {
716
+ detail: { msgType, msg, action, persistent, data }
717
+ }));
718
+
719
+ // Call notification callback if set
720
+ if (this.onNotification) {
721
+ this.onNotification({ msgType, msg, action, persistent, data });
722
+ }
723
+ }
724
+
725
+ /**
726
+ * Stop LiveSync and clean up resources
727
+ * Can call start() again to restart
728
+ */
729
+ stop() {
730
+ this.cleanup();
731
+ this.isDestroyed = true;
732
+ console.log('[LiveSync] Stopped');
733
+ }
734
+ }
735
+
736
+ // Singleton instance
737
+ const liveSync = new LiveSync();
738
+
739
+ // Auto-initialize when DOM is ready
740
+ if (typeof window !== 'undefined') {
741
+ if (document.readyState === 'loading') {
742
+ document.addEventListener('DOMContentLoaded', () => liveSync.start());
743
+ } else {
744
+ liveSync.start();
745
+ }
746
+ }
747
+
748
+ // Export for the clayjs module system. The class itself is exported so
749
+ // tests can create fresh instances without driving the singleton's
750
+ // EventSource/snapshot wiring.
751
+ export { liveSync, LiveSync, morph };
752
+ export default liveSync;