@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,719 @@
1
+ /**
2
+ *
3
+ * Lightweight wrapper around MutationObserver that provides methods to watch DOM changes.
4
+ * Uses a single observer instance internally to improve performance.
5
+ *
6
+ * // Watch for any DOM changes (additions, removals, attribute changes)
7
+ * Mutation.onAnyChange({ debounce: 200 }, changes => {
8
+ * changes.forEach(change => {
9
+ * if (change.type === 'add') {
10
+ * console.log('Added:', change.element);
11
+ * console.log('To parent:', change.parent);
12
+ * console.log('Between:', change.previousSibling, change.nextSibling);
13
+ * }
14
+ * if (change.type === 'remove') {
15
+ * console.log('Removed:', change.element);
16
+ * console.log('From parent:', change.parent);
17
+ * }
18
+ * if (change.type === 'attribute') {
19
+ * console.log('Changed:', change.attribute);
20
+ * console.log('From:', change.oldValue, 'to:', change.newValue);
21
+ * }
22
+ * });
23
+ * });
24
+ *
25
+ * // Watch for element additions or removals
26
+ * Mutation.onAddOrRemove({ debounce: 200 }, changes => {
27
+ * changes.forEach(change => {
28
+ * const action = change.type === 'add' ? 'added to' : 'removed from';
29
+ * console.log(`${change.element.tagName} ${action} ${change.parent.tagName}`);
30
+ * });
31
+ * });
32
+ *
33
+ * // Watch for element additions
34
+ * Mutation.onAddElement({ debounce: 200 }, changes => {
35
+ * changes.forEach(({ element, parent }) => {
36
+ * console.log(`${element.tagName} added to ${parent.tagName}`);
37
+ * });
38
+ * });
39
+ *
40
+ * // Watch for element removals with location info
41
+ * Mutation.onRemoveElement({ debounce: 200 }, changes => {
42
+ * changes.forEach(({ element, parent, previousSibling, nextSibling }) => {
43
+ * console.log(`${element.tagName} removed from ${parent.tagName}`);
44
+ * console.log('Was between:', previousSibling?.tagName, nextSibling?.tagName);
45
+ * });
46
+ * });
47
+ *
48
+ * // Watch for attribute changes
49
+ * Mutation.onAttribute({ debounce: 200 }, changes => {
50
+ * // Debounce collects multiple changes into an array
51
+ * changes.forEach(({ element, attribute, oldValue, newValue: newValue }) => {
52
+ * console.log(`${element.tagName} ${attribute} changed from ${oldValue} to ${newValue}`);
53
+ * });
54
+ * });
55
+ *
56
+ * // External reactive consumers (e.g. sapjs, which re-derives state on ANY DOM change)
57
+ * // subscribe through the region-aware, pause-gated lane:
58
+ * //
59
+ * // Mutation.onAnyChange({ require: 'observed' }, changes => { ... })
60
+ * //
61
+ * // Leaving `pausable` at its default (true) is load-bearing: by wrapping its own write
62
+ * // phase in Mutation.pause()/resume(), such a consumer's derived writes are vacuumed on
63
+ * // resume and routed only to non-pausable consumers, so they never loop back to it.
64
+ * // The raw lane (subscribeRaw/createObserver) is internal — reserved for undo/region
65
+ * // plumbing — and is NOT part of the public contract for external consumers.
66
+ *
67
+ */
68
+
69
+ import { EXTENSION_ATTR_PATTERN } from './extension-noise.js';
70
+ import { resolveRegionPolicy, isInert, strictestPolicy, skipForPolicy } from './region-policy.js';
71
+ import { isUserDrivenNow, markUserDriven } from './user-gesture.js';
72
+
73
+ const dummyElem = document.createElement("div");
74
+
75
+ const Mutation = {
76
+ _callbacks: {
77
+ anyChange: [],
78
+ addOrRemove: [],
79
+ addElement: [],
80
+ removeElement: [],
81
+ attribute: []
82
+ },
83
+
84
+ _observing: false,
85
+ _pauseDepth: 0,
86
+ _hasNonPausable: false,
87
+ debug: false,
88
+
89
+ // Raw lane: subscribers that need the untransformed MutationRecords (the undo
90
+ // recorder). Each gets a private buffer so a global takeRecords() drain by one
91
+ // subscriber can't steal records owed to another. See _drainBrowserQueue.
92
+ _rawSubscribers: [],
93
+ // Change-lane records pulled by a raw subscriber's drain, deferred to a
94
+ // microtask so a debounce:0 mutating subscriber (autosize) never fires
95
+ // synchronously inside undo's commit boundary.
96
+ _deferredChangeRecords: null,
97
+ _deferredChangeScheduled: false,
98
+
99
+ /**
100
+ * Pause mutation observation.
101
+ * Use this when making programmatic DOM changes that shouldn't trigger callbacks.
102
+ * Always pair with resume() in a try/finally block.
103
+ */
104
+ pause() {
105
+ // Reference-counted so nested pauses (e.g. a programmatic pause wrapping a
106
+ // live-sync morph that also pauses) only resume on the outermost release.
107
+ this._pauseDepth++;
108
+ // Bridge: pause undo recorder too. Live-sync calls Mutation.pause()
109
+ // before morphing remote HTML in, and we want those mutations excluded
110
+ // from the local undo stack as well. undo.pause() is itself reference-counted.
111
+ if (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo && window.hyperclay.undo.pause) {
112
+ window.hyperclay.undo.pause();
113
+ }
114
+ this._log('Paused', this._pauseDepth);
115
+ },
116
+
117
+ /**
118
+ * Resume mutation observation after a pause.
119
+ */
120
+ resume() {
121
+ if (this._pauseDepth === 0) return; // underflow guard
122
+ this._pauseDepth--;
123
+ // Drain pending mutation records only on the outermost release — observer
124
+ // stays connected during pause, so morph mutations are recorded and would
125
+ // fire async once the depth returns to zero. Route through the one drain
126
+ // funnel: non-pausable callbacks (pure enhancers like sortable /
127
+ // optionVisibility) get that boundary batch, and raw subscribers (undo) get
128
+ // it into their buffer so the morph-boundary records aren't lost (then
129
+ // undo.resume()'s own drain-discard excludes the morph, matching today).
130
+ if (this._pauseDepth === 0 && this._observer) {
131
+ this._drainBrowserQueue(this);
132
+ }
133
+ if (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo && window.hyperclay.undo.resume) {
134
+ window.hyperclay.undo.resume();
135
+ }
136
+ this._log('Resumed', this._pauseDepth);
137
+ },
138
+
139
+ _log(message, data = null, type = 'log') {
140
+ if (!this.debug) return;
141
+
142
+ const timestamp = new Date().toISOString();
143
+ const prefix = `[Mutation ${timestamp}]`;
144
+
145
+ if (data) {
146
+ console[type](prefix, message, data);
147
+ } else {
148
+ console[type](prefix, message);
149
+ }
150
+ },
151
+
152
+ _notify(type, changes, onlyNonPausable = false) {
153
+ this._log(`Notifying ${this._callbacks[type].length} callbacks of type "${type}"`, { changes });
154
+
155
+ for (const callback of this._callbacks[type]) {
156
+ // While paused, only non-pausable callbacks (pure enhancers) run.
157
+ if (onlyNonPausable && callback.pausable !== false) continue;
158
+
159
+ const { fn, debounce = 0, selectorFilter, omitChangeDetails, require, skip } = callback;
160
+
161
+ // Per-consumer region policy: drop changes whose region this consumer
162
+ // doesn't participate in (no-save / no-trigger-autosave / no-undo / freeze
163
+ // and their legacy equivalents), resolved against the callback's `require`.
164
+ let filteredChanges = changes.filter(
165
+ change => !skipForPolicy(this._policyForChange(change), require, skip)
166
+ );
167
+ if (!filteredChanges.length) {
168
+ this._log('No changes passed the region policy, skipping callback');
169
+ continue;
170
+ }
171
+
172
+ // Apply filtering if there's a selector filter
173
+ if (selectorFilter) {
174
+ this._log('Applying selector filter', { selectorFilter });
175
+ filteredChanges = filteredChanges.filter(change => {
176
+ if (typeof selectorFilter === 'string') {
177
+ const matches = change.element.matches?.(selectorFilter) || false;
178
+ this._log(`Selector "${selectorFilter}" match:`, { element: change.element, matches });
179
+ return matches;
180
+ }
181
+ if (typeof selectorFilter === 'function') {
182
+ const matches = selectorFilter(change.element);
183
+ this._log('Custom filter match:', { element: change.element, matches });
184
+ return matches;
185
+ }
186
+ return false;
187
+ });
188
+ this._log('Changes after filtering:', { filteredChanges });
189
+ }
190
+
191
+ // Skip if nothing remains after filtering
192
+ if (!filteredChanges.length) {
193
+ this._log('No changes passed the filter, skipping callback');
194
+ continue;
195
+ }
196
+
197
+ // Handle debouncing and callback execution
198
+ if (debounce === 0) {
199
+ // No debounce, execute immediately
200
+ try {
201
+ if (omitChangeDetails) {
202
+ fn();
203
+ } else {
204
+ fn(filteredChanges);
205
+ }
206
+ } catch (e) {
207
+ this._log('Error in callback execution:', e, 'error');
208
+ console.error('Error in Mutation callback:', e);
209
+ }
210
+ } else {
211
+ // Clear any existing timeout
212
+ if (callback.timeout) {
213
+ clearTimeout(callback.timeout);
214
+ callback.timeout = null;
215
+ }
216
+
217
+ if (omitChangeDetails) {
218
+ // For omitChangeDetails, just reset the timer
219
+ callback.timeout = setTimeout(() => {
220
+ callback.timeout = null;
221
+ try {
222
+ this._log('Executing debounced callback (no details)');
223
+ fn();
224
+ } catch (e) {
225
+ this._log('Error in callback execution:', e, 'error');
226
+ console.error('Error in Mutation callback:', e);
227
+ }
228
+ }, debounce);
229
+ } else {
230
+ // For callbacks with change details, accumulate changes
231
+ if (!callback.pendingChanges) {
232
+ callback.pendingChanges = [];
233
+ }
234
+ callback.pendingChanges.push(...filteredChanges);
235
+
236
+ // Reset the timer
237
+ callback.timeout = setTimeout(() => {
238
+ const changes = callback.pendingChanges;
239
+ callback.pendingChanges = null; // Reset to null, not empty array
240
+ callback.timeout = null; // Clear the timeout reference
241
+ try {
242
+ this._log('Executing debounced callback with changes:', { changes });
243
+ if (changes && changes.length > 0) {
244
+ fn(changes);
245
+ }
246
+ } catch (e) {
247
+ this._log('Error in callback execution:', e, 'error');
248
+ console.error('Error in Mutation callback:', e);
249
+ }
250
+ }, debounce);
251
+ }
252
+ }
253
+ }
254
+ },
255
+
256
+ // Resolve a change's region policy once per batch (memoized on the change).
257
+ // Removed/detached elements carry their region markers on the still-attached
258
+ // parent (the region they were removed FROM), so merge it in for those.
259
+ _policyForChange(change) {
260
+ if (change.__policy) return change.__policy;
261
+ const el = change.element;
262
+ let policy = resolveRegionPolicy(el);
263
+ if (change.parent && el && el.isConnected === false) {
264
+ policy = strictestPolicy(policy, resolveRegionPolicy(change.parent));
265
+ }
266
+ change.__policy = policy;
267
+ return policy;
268
+ },
269
+
270
+ // The real MutationObserver's callback target. Two lanes, in this order:
271
+ // 1. raw fan-out FIRST, unconditionally (even while paused / on the fast
272
+ // path) so the raw lane's push timing matches a real MutationObserver and
273
+ // undo's own paused-drop keeps working.
274
+ // 2. the change lane (pause-gated, as before).
275
+ _onRecords(records) {
276
+ this._fanOutRaw(records);
277
+ this._handleMutations(records);
278
+ },
279
+
280
+ // Push the untransformed records to every raw subscriber's callback. No
281
+ // filtering and no pause gating: raw means raw (undo runs its own filter and
282
+ // its own pause). Captured BEFORE the change lane's isInert / extension-attr
283
+ // intake drops, which the raw subscriber must not inherit.
284
+ _fanOutRaw(records) {
285
+ if (!this._rawSubscribers.length || !records.length) return;
286
+ for (const sub of this._rawSubscribers) {
287
+ try {
288
+ sub.cb(records);
289
+ } catch (e) {
290
+ this._log('Error in raw subscriber callback:', e, 'error');
291
+ console.error('Error in Mutation raw subscriber:', e);
292
+ }
293
+ }
294
+ },
295
+
296
+ // The ONLY caller of this._observer.takeRecords(). Pulls the browser's pending
297
+ // (undelivered) records and routes them three ways so nobody loses a record
298
+ // the global takeRecords() just emptied:
299
+ // (a) every OTHER raw subscriber: into its private buffer + a microtask flush
300
+ // (b) the change lane: deferred to a microtask when the requester is a raw
301
+ // subscriber (so a debounce:0 mutating subscriber can't fire inside
302
+ // undo's synchronous commit); synchronous-to-non-pausables when the hub
303
+ // itself is the requester (the resume() boundary, matching today)
304
+ // (c) the requester: returns the pulled records merged with its own buffer,
305
+ // then clears the buffer.
306
+ _drainBrowserQueue(requester) {
307
+ const pulled = this._observer ? this._observer.takeRecords() : [];
308
+ const requesterIsRaw = !!requester && this._rawSubscribers.indexOf(requester) !== -1;
309
+
310
+ if (pulled.length) {
311
+ // (a) records owed to other raw subscribers (the global queue is now empty
312
+ // for them too, so hand them their copy via their buffer).
313
+ for (const sub of this._rawSubscribers) {
314
+ if (sub === requester) continue;
315
+ sub.buffer.push(...pulled);
316
+ this._scheduleBufferFlush(sub);
317
+ }
318
+
319
+ // (b) change lane.
320
+ if (requesterIsRaw) {
321
+ this._deferChangeLane(pulled);
322
+ } else if (this._hasNonPausable) {
323
+ this._processMutations(pulled, true);
324
+ }
325
+ }
326
+
327
+ // (c) hand the requester its records (own buffer first = chronological).
328
+ if (requesterIsRaw) {
329
+ const own = requester.buffer;
330
+ requester.buffer = [];
331
+ return own.length ? own.concat(pulled) : pulled;
332
+ }
333
+ return pulled;
334
+ },
335
+
336
+ // Deliver a raw subscriber's buffered records on a microtask, reading the
337
+ // CURRENT buffer at flush time so a synchronous drain() in between makes this
338
+ // a no-op (the buffer is consume-and-clear; a record is delivered exactly once
339
+ // — via flush OR via drain, never both).
340
+ _scheduleBufferFlush(sub) {
341
+ if (sub._flushScheduled) return;
342
+ sub._flushScheduled = true;
343
+ queueMicrotask(() => {
344
+ sub._flushScheduled = false;
345
+ if (!sub.buffer.length) return;
346
+ const batch = sub.buffer;
347
+ sub.buffer = [];
348
+ try {
349
+ sub.cb(batch);
350
+ } catch (e) {
351
+ this._log('Error in raw subscriber flush:', e, 'error');
352
+ console.error('Error in Mutation raw subscriber:', e);
353
+ }
354
+ });
355
+ },
356
+
357
+ // Queue change-lane records pulled by a raw subscriber's drain for a single
358
+ // microtask-deferred pass through _handleMutations (pause rules apply at
359
+ // processing time, not capture time).
360
+ _deferChangeLane(records) {
361
+ if (!this._deferredChangeRecords) this._deferredChangeRecords = [];
362
+ this._deferredChangeRecords.push(...records);
363
+ if (this._deferredChangeScheduled) return;
364
+ this._deferredChangeScheduled = true;
365
+ queueMicrotask(() => {
366
+ this._deferredChangeScheduled = false;
367
+ const batch = this._deferredChangeRecords;
368
+ this._deferredChangeRecords = null;
369
+ if (batch && batch.length) this._handleMutations(batch);
370
+ });
371
+ },
372
+
373
+ // Raw lane primitive: an explicit push+pull subscription to the untransformed
374
+ // MutationRecords. Internal plumbing for the vendored undo recorder (so the
375
+ // page runs ONE MutationObserver). Not public API yet.
376
+ subscribeRaw(cb) {
377
+ const sub = { cb, buffer: [], _flushScheduled: false };
378
+ this._rawSubscribers.push(sub);
379
+ // A raw subscriber may register before any change subscriber, and the hub
380
+ // only starts observing on first subscription (the lazy-start trap), so
381
+ // force it on or undo records nothing when it starts first.
382
+ this._startObserving();
383
+ return {
384
+ drain: () => this._drainBrowserQueue(sub),
385
+ unsubscribe: () => {
386
+ const i = this._rawSubscribers.indexOf(sub);
387
+ if (i !== -1) this._rawSubscribers.splice(i, 1);
388
+ },
389
+ };
390
+ },
391
+
392
+ // Thin MutationObserver-shaped adapter over subscribeRaw, so undo's scope.js
393
+ // changes one construction line and keeps its observe/disconnect/takeRecords
394
+ // calls. Only document.body is supported (the singleton's scope); created
395
+ // shadow scopes keep a real MutationObserver. Options are accepted and ignored
396
+ // — the hub already observes with options identical to undo's.
397
+ createObserver(cb) {
398
+ let subscription = null;
399
+ return {
400
+ observe: (target /*, options */) => {
401
+ if (target !== document.body) {
402
+ throw new Error('Mutation.createObserver only supports observing document.body');
403
+ }
404
+ this._startObserving();
405
+ if (!subscription) subscription = this.subscribeRaw(cb);
406
+ },
407
+ disconnect: () => {
408
+ if (subscription) { subscription.unsubscribe(); subscription = null; }
409
+ },
410
+ takeRecords: () => (subscription ? subscription.drain() : []),
411
+ };
412
+ },
413
+
414
+ _handleMutations(mutations) {
415
+ if (this._pauseDepth > 0) {
416
+ // While paused (e.g. during a live-sync morph), only non-pausable
417
+ // callbacks should run. With none registered, take the fast path.
418
+ if (!this._hasNonPausable) {
419
+ this._log(`Skipping ${mutations.length} mutations (paused)`);
420
+ return;
421
+ }
422
+ this._processMutations(mutations, true);
423
+ return;
424
+ }
425
+ this._processMutations(mutations, false);
426
+ },
427
+
428
+ _processMutations(mutations, onlyNonPausable = false) {
429
+ const changes = [];
430
+ const changesByType = {
431
+ add: [],
432
+ remove: [],
433
+ attribute: [],
434
+ characterData: []
435
+ };
436
+
437
+ for (const mutation of mutations) {
438
+ // Intake drop: a no-watch / mutations-ignore subtree (or extension noise)
439
+ // is invisible to every consumer, so skip it without walking. All other
440
+ // region attributes are resolved per-consumer in _notify.
441
+ if (isInert(mutation.target)) {
442
+ continue;
443
+ }
444
+
445
+ // Ignore extension marker attributes (e.g. password-manager field tags) stamped onto real elements.
446
+ if (mutation.type === 'attributes' && mutation.attributeName &&
447
+ EXTENSION_ATTR_PATTERN.test(mutation.attributeName.toLowerCase())) {
448
+ continue;
449
+ }
450
+
451
+ if (mutation.type === 'characterData') {
452
+ this._log('Processing characterData mutation', {
453
+ element: mutation.target.parentElement,
454
+ oldValue: mutation.oldValue,
455
+ newValue: mutation.target.textContent
456
+ });
457
+
458
+ const change = {
459
+ type: 'characterData',
460
+ element: mutation.target.parentElement ?? dummyElem, // hacky, but ensures we always pass an element in the callback
461
+ oldValue: mutation.oldValue,
462
+ newValue: mutation.target.textContent
463
+ };
464
+ changes.push(change);
465
+ changesByType.characterData.push(change);
466
+ }
467
+
468
+ if (mutation.type === 'childList') {
469
+ this._log('Processing childList mutation', {
470
+ addedNodes: mutation.addedNodes,
471
+ removedNodes: mutation.removedNodes
472
+ });
473
+
474
+ for (const node of mutation.addedNodes) {
475
+ if (node.nodeType === 1 && !isInert(node)) {
476
+ const addedNodes = [node, ...node.querySelectorAll('*')];
477
+ this._log(`Processing ${addedNodes.length} added nodes`, { addedNodes });
478
+
479
+ for (const element of addedNodes) {
480
+ const change = {
481
+ type: 'add',
482
+ element,
483
+ parent: mutation.target,
484
+ previousSibling: mutation.previousSibling,
485
+ nextSibling: mutation.nextSibling
486
+ };
487
+ changes.push(change);
488
+ changesByType.add.push(change);
489
+ }
490
+ }
491
+ }
492
+
493
+ for (const node of mutation.removedNodes) {
494
+ if (node.nodeType === 1 && !isInert(node)) {
495
+ const removedNodes = [node, ...node.querySelectorAll('*')];
496
+ this._log(`Processing ${removedNodes.length} removed nodes`, { removedNodes });
497
+
498
+ for (const element of removedNodes) {
499
+ const change = {
500
+ type: 'remove',
501
+ element,
502
+ parent: mutation.target,
503
+ previousSibling: mutation.previousSibling,
504
+ nextSibling: mutation.nextSibling
505
+ };
506
+ changes.push(change);
507
+ changesByType.remove.push(change);
508
+ }
509
+ }
510
+ }
511
+
512
+ // Bubble text-node child changes (e.g. `el.textContent = 'foo'`) up
513
+ // to the parent element so onAnyChange fires. Typed callbacks
514
+ // (addElement, removeElement) stay element-only by design.
515
+ let hasTextNodeChanges = false;
516
+ for (const node of mutation.addedNodes) {
517
+ if (node.nodeType === 3) { hasTextNodeChanges = true; break; }
518
+ }
519
+ if (!hasTextNodeChanges) {
520
+ for (const node of mutation.removedNodes) {
521
+ if (node.nodeType === 3) { hasTextNodeChanges = true; break; }
522
+ }
523
+ }
524
+ if (hasTextNodeChanges && mutation.target.nodeType === 1) {
525
+ const change = {
526
+ type: 'characterData',
527
+ element: mutation.target,
528
+ oldValue: undefined,
529
+ newValue: mutation.target.textContent
530
+ };
531
+ changes.push(change);
532
+ changesByType.characterData.push(change);
533
+ }
534
+ }
535
+
536
+ if (mutation.type === 'attributes') {
537
+ this._log('Processing attribute mutation', {
538
+ element: mutation.target,
539
+ attribute: mutation.attributeName,
540
+ oldValue: mutation.oldValue,
541
+ newValue: mutation.target.getAttribute(mutation.attributeName)
542
+ });
543
+
544
+ const change = {
545
+ type: 'attribute',
546
+ element: mutation.target,
547
+ attribute: mutation.attributeName,
548
+ oldValue: mutation.oldValue,
549
+ newValue: mutation.target.getAttribute(mutation.attributeName)
550
+ };
551
+ changes.push(change);
552
+ changesByType.attribute.push(change);
553
+ }
554
+ }
555
+
556
+ if (changes.length) {
557
+ this._log('Processing collected changes', {
558
+ total: changes.length,
559
+ byType: {
560
+ add: changesByType.add.length,
561
+ remove: changesByType.remove.length,
562
+ attribute: changesByType.attribute.length
563
+ }
564
+ });
565
+
566
+ // Data-guard provenance: if a trusted gesture drove this turn (or one
567
+ // happened within the recency window) and any change is autosave-relevant
568
+ // (matches the save's region scope), mark the pending save user-driven.
569
+ // Runs synchronously in the MO callback; skipped during paused-morph drains.
570
+ if (!onlyNonPausable && isUserDrivenNow()) {
571
+ for (const change of changes) {
572
+ if (!skipForPolicy(this._policyForChange(change), 'autosave')) {
573
+ markUserDriven();
574
+ break;
575
+ }
576
+ }
577
+ }
578
+
579
+ this._notify('anyChange', changes, onlyNonPausable);
580
+
581
+ const addOrRemove = [...changesByType.add, ...changesByType.remove];
582
+ if (addOrRemove.length) {
583
+ this._notify('addOrRemove', addOrRemove, onlyNonPausable);
584
+ }
585
+
586
+ if (changesByType.add.length) {
587
+ this._notify('addElement', changesByType.add, onlyNonPausable);
588
+ }
589
+ if (changesByType.remove.length) {
590
+ this._notify('removeElement', changesByType.remove, onlyNonPausable);
591
+ }
592
+ if (changesByType.attribute.length) {
593
+ this._notify('attribute', changesByType.attribute, onlyNonPausable);
594
+ }
595
+ }
596
+ },
597
+
598
+ _observer: null,
599
+
600
+ _initializeObserver() {
601
+ if (!this._observer) {
602
+ this._log('Initializing MutationObserver');
603
+ this._observer = new MutationObserver(this._onRecords.bind(this));
604
+ }
605
+ },
606
+
607
+ _addCallback(type, options = {}, callback) {
608
+ this._log('Adding callback', { type, options });
609
+
610
+ if (options.debug) {
611
+ this.debug = true;
612
+ }
613
+
614
+ const cb = {
615
+ fn: callback,
616
+ debounce: options.debounce || 0,
617
+ selectorFilter: options.selectorFilter,
618
+ omitChangeDetails: options.omitChangeDetails,
619
+ // Region policy: axis this consumer needs ('observed' | 'autosave' | 'undo')
620
+ // or a literal attribute escape-hatch. Unset => legacy four-marker skip.
621
+ require: options.require,
622
+ skip: options.skip,
623
+ // pausable:false keeps the callback firing during Mutation.pause() (pure
624
+ // enhancers that never save/record/rebroadcast). Default true.
625
+ pausable: options.pausable !== false,
626
+ timeout: null,
627
+ pendingChanges: null
628
+ };
629
+
630
+ this._callbacks[type].push(cb);
631
+ this._recomputeHasNonPausable();
632
+ this._log(`Added callback to ${type}. Total callbacks:`, {
633
+ [type]: this._callbacks[type].length
634
+ });
635
+
636
+ this._startObserving();
637
+
638
+ return () => {
639
+ this._log('Removing callback', { type });
640
+ const index = this._callbacks[type].indexOf(cb);
641
+ if (index !== -1) {
642
+ clearTimeout(cb.timeout);
643
+ cb.pendingChanges = null;
644
+ this._callbacks[type].splice(index, 1);
645
+ this._recomputeHasNonPausable();
646
+ this._log(`Removed callback from ${type}. Remaining callbacks:`, {
647
+ [type]: this._callbacks[type].length
648
+ });
649
+ }
650
+ };
651
+ },
652
+
653
+ _recomputeHasNonPausable() {
654
+ this._hasNonPausable = Object.values(this._callbacks).some(
655
+ list => list.some(cb => cb.pausable === false)
656
+ );
657
+ },
658
+
659
+ _startObserving() {
660
+ if (this._observing) {
661
+ this._log('Already observing, skipping initialization');
662
+ return;
663
+ }
664
+
665
+ this._log('Starting observation');
666
+ this._initializeObserver();
667
+ this._observer.observe(document.body, {
668
+ childList: true,
669
+ attributes: true,
670
+ subtree: true,
671
+ characterData: true,
672
+ attributeOldValue: true,
673
+ characterDataOldValue: true
674
+ });
675
+ this._observing = true;
676
+ this._log('Observation started');
677
+ },
678
+
679
+ onAnyChange(options = {}, callback) {
680
+ return this._addCallback('anyChange', options, callback);
681
+ },
682
+
683
+ onAddOrRemove(options = {}, callback) {
684
+ return this._addCallback('addOrRemove', options, callback);
685
+ },
686
+
687
+ onAddElement(options = {}, callback) {
688
+ return this._addCallback('addElement', options, callback);
689
+ },
690
+
691
+ onRemoveElement(options = {}, callback) {
692
+ return this._addCallback('removeElement', options, callback);
693
+ },
694
+
695
+ onAttribute(options = {}, callback) {
696
+ return this._addCallback('attribute', options, callback);
697
+ },
698
+
699
+ /**
700
+ * Start the singleton observer without registering a callback. The data-clobber
701
+ * chip (data-loss-panel) calls this so user-driven attribution runs wherever the
702
+ * chip ships, not only when another module (e.g. option-visibility) happens to
703
+ * subscribe. Idempotent — the _observing guard makes repeat calls a no-op.
704
+ */
705
+ ensureObserving() {
706
+ this._startObserving();
707
+ }
708
+ };
709
+
710
+ // Signal consumers (e.g. hypercms ?cms=true auto-open) that Mutation is ready,
711
+ // so they can react instead of polling. Wrapped so a dispatch failure can never
712
+ // break the install.
713
+ try {
714
+ document.dispatchEvent(new CustomEvent('clay:mutation-ready', { detail: { Mutation } }));
715
+ // vendor-compat: hypercms's readiness fast path listens for the legacy name
716
+ document.dispatchEvent(new CustomEvent('hyperclay:mutation-ready', { detail: { Mutation } }));
717
+ } catch {}
718
+
719
+ export default Mutation;