@panphora/clayjs 0.2.0 → 0.4.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/README.md +99 -7
  2. package/clay-internals.js +19 -0
  3. package/clay.js +18 -2
  4. package/package.json +19 -3
  5. package/sap.js +5 -5
  6. package/src/attrs/onaftersave.js +12 -1
  7. package/src/attrs/refetch-on-save.js +4 -4
  8. package/src/attrs/save-freeze.js +47 -19
  9. package/src/core/admin-contenteditable.js +2 -2
  10. package/src/core/admin-inputs.js +2 -2
  11. package/src/core/admin-onclick.js +2 -2
  12. package/src/core/admin-resources.js +23 -14
  13. package/src/core/autosave.js +11 -1
  14. package/src/core/edit-mode.js +2 -2
  15. package/src/core/host-attrs.js +46 -0
  16. package/src/core/is-edit-mode.js +14 -6
  17. package/src/core/persist.js +8 -1
  18. package/src/core/save-core.js +199 -290
  19. package/src/core/save.js +124 -84
  20. package/src/core/snapshot.js +99 -43
  21. package/src/core/unsaved-warning.js +8 -2
  22. package/src/dom/all.js +4 -29
  23. package/src/dom/form-data.js +1 -1
  24. package/src/dom/nearest.js +11 -9
  25. package/src/internals/index.js +64 -0
  26. package/src/lib/cache-bust.js +10 -0
  27. package/src/lib/cookie.js +50 -15
  28. package/src/lib/extension-noise.js +5 -1
  29. package/src/lib/mutation.js +65 -18
  30. package/src/lib/region-policy.js +17 -2
  31. package/src/lib/root-attrs.js +45 -0
  32. package/src/lib/throttle.js +25 -13
  33. package/src/loader.js +18 -24
  34. package/src/options/options.js +1 -3
  35. package/src/plugins/demo.js +7 -1
  36. package/src/sync/live-sync.js +121 -89
  37. package/src/ui/dialogs.js +10 -1
  38. package/src/ui/index.js +2 -11
  39. package/src/ui/toast.js +8 -2
  40. package/src/utils/debounce.js +24 -7
  41. package/src/vendor/hyper-undo.vendor.js +1 -1
  42. package/src/vendor/hypercms.vendor.js +29 -16
  43. package/src/vendor/richclay.vendor.js +29 -22
package/src/dom/all.js CHANGED
@@ -156,36 +156,9 @@ const createMethodHandler = (elements, plugins, methods) => ({
156
156
  // - When a method returns an Element (like cloneNode), it wraps it in a proxy
157
157
  // - When a method returns undefined (like removeAttribute), it chains on the original elements
158
158
  // - For other return values, like strings, it returns the results in an array
159
- // We also handle passing in an all-wrapped proxy object as an argument and loop over all elements in it
160
- // In the method handler's get function, replace the function call handling with:
161
159
  if (typeof value === 'function') {
162
160
  return (...args) => {
163
- // Unwrap any proxy arguments
164
- const unwrappedArgs = args.map(arg => {
165
- if (arg && arg.constructor === Proxy) {
166
- return Array.from(arg);
167
- }
168
- return arg;
169
- });
170
-
171
- const results = elements.map(el => {
172
- // Check if any of the unwrapped arguments are arrays (from proxies)
173
- const hasProxyArgs = unwrappedArgs.some(Array.isArray);
174
-
175
- if (hasProxyArgs) {
176
- // Handle proxy arguments case
177
- const elementResults = unwrappedArgs.map(arg => {
178
- if (Array.isArray(arg)) {
179
- return arg.map(proxyEl => el[prop](proxyEl));
180
- }
181
- return [el[prop](...args)];
182
- }).flat();
183
- return elementResults[elementResults.length - 1];
184
- } else {
185
- // Simple case - just call the method once with original arguments
186
- return el[prop](...args);
187
- }
188
- });
161
+ const results = elements.map(el => el[prop](...args));
189
162
 
190
163
  if (results[0] instanceof Element) {
191
164
  return createElementProxy(results.filter(Boolean), plugins, methods);
@@ -390,7 +363,9 @@ const All = new Proxy(function (selectorOrElements, contextSelector) {
390
363
  if (typeof selectorOrElements === 'string') {
391
364
  const elements = contextElements.flatMap(context => {
392
365
  // Include context itself if it matches the selector
393
- const matches = context.matches(selectorOrElements) ? [context] : [];
366
+ // Document has no matches(): it can never be its own match, only a root
367
+ // to search under.
368
+ const matches = context.matches?.(selectorOrElements) ? [context] : [];
394
369
  // Plus all descendants that match
395
370
  const descendants = Array.from(context.querySelectorAll(selectorOrElements));
396
371
  return [...matches, ...descendants];
@@ -5,7 +5,7 @@ function getDataFromForm(container) {
5
5
  // Helper function to process a single element
6
6
  const processElement = (elem) => {
7
7
  const name = elem.getAttribute('name');
8
- const value = elem.value || elem.getAttribute('value');
8
+ const value = elem.value ?? elem.getAttribute('value');
9
9
 
10
10
  // Skip elements without a name or with a disabled attribute
11
11
  if (!name || elem.disabled) return;
@@ -31,7 +31,9 @@
31
31
  export default function nearest (startElem, selector, elementFoundReturnValue = x => x) {
32
32
  const visited = new Set();
33
33
 
34
- // Check node and its descendants using BFS
34
+ // Returns the matched ELEMENT, not the transformed value: a transform yielding
35
+ // "" or false used to read as not-found here, so the search walked past a real
36
+ // match (an empty input, an unchecked box) and could report a farther element.
35
37
  function checkDeep(root) {
36
38
  if (!root || visited.has(root)) return null;
37
39
 
@@ -46,7 +48,7 @@ export default function nearest (startElem, selector, elementFoundReturnValue =
46
48
  localVisited.add(node);
47
49
 
48
50
  if (node.matches(selector)) {
49
- return elementFoundReturnValue(node);
51
+ return node;
50
52
  }
51
53
 
52
54
  queue.push(...node.children);
@@ -58,8 +60,8 @@ export default function nearest (startElem, selector, elementFoundReturnValue =
58
60
  function checkSiblings(start, direction) {
59
61
  let sibling = start[direction];
60
62
  while (sibling) {
61
- const result = checkDeep(sibling);
62
- if (result) return result;
63
+ const found = checkDeep(sibling);
64
+ if (found) return found;
63
65
  sibling = sibling[direction];
64
66
  }
65
67
  return null;
@@ -80,14 +82,14 @@ export default function nearest (startElem, selector, elementFoundReturnValue =
80
82
 
81
83
  // Check children deeply
82
84
  for (const child of current.children) {
83
- const result = checkDeep(child);
84
- if (result) return result;
85
+ const found = checkDeep(child);
86
+ if (found) return elementFoundReturnValue(found);
85
87
  }
86
88
 
87
89
  // Check siblings deeply
88
- let result = checkSiblings(current, 'previousElementSibling') ||
89
- checkSiblings(current, 'nextElementSibling');
90
- if (result) return result;
90
+ const found = checkSiblings(current, 'previousElementSibling') ||
91
+ checkSiblings(current, 'nextElementSibling');
92
+ if (found) return elementFoundReturnValue(found);
91
93
 
92
94
  // Move up to parent
93
95
  current = current.parentElement;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * internals/index.js — the supported low-level surface.
3
+ *
4
+ * Everything here is reachable by direct module import whether we export it or
5
+ * not, because src/ ships to npm and the CDN. What this file adds is the promise:
6
+ * these names keep working, and anything not named here may change in a patch.
7
+ *
8
+ * Lower level than clay.* deliberately. These are the pieces the library builds
9
+ * itself out of, so they assume you know the save lifecycle. The ergonomics are
10
+ * your problem; the stability is ours.
11
+ */
12
+
13
+ import { captureSnapshot, captureForSave } from "../core/snapshot.js";
14
+ import { saveHtml, replacePageWith, isSaveInProgress } from "../core/save-core.js";
15
+ import {
16
+ addRegionToken,
17
+ resolveRegionPolicy,
18
+ isInert,
19
+ isSnapshotRemoved,
20
+ PERSIST,
21
+ REGION_ATTRS,
22
+ STRIP_FROM_SAVE,
23
+ STRIP_FROM_COMPARISON,
24
+ SNAPSHOT_REMOVE_SELECTOR,
25
+ FREEZE_SELECTOR,
26
+ } from "../lib/region-policy.js";
27
+
28
+ const clay = (window.clay = window.clay || {});
29
+
30
+ clay.internals = {
31
+ // The snapshot pipeline, read side. captureSnapshot gives you the clone before
32
+ // any stripping; captureForSave gives you the bytes a save would send.
33
+ captureSnapshot,
34
+ captureForSave,
35
+
36
+ // Write your own attribute without hardcoding our selectors. Doing it by hand is
37
+ // how a custom attribute quietly stops respecting [no-save] two releases later.
38
+ region: {
39
+ addRegionToken,
40
+ resolveRegionPolicy,
41
+ isInert,
42
+ isSnapshotRemoved,
43
+ PERSIST,
44
+ REGION_ATTRS,
45
+ selectors: {
46
+ stripFromSave: STRIP_FROM_SAVE,
47
+ stripFromComparison: STRIP_FROM_COMPARISON,
48
+ snapshotRemove: SNAPSHOT_REMOVE_SELECTOR,
49
+ freeze: FREEZE_SELECTOR,
50
+ },
51
+ },
52
+
53
+ // The save lane under clay.save. saveHtml sends bytes you supply, so it bypasses
54
+ // the snapshot pipeline entirely: whatever you hand it is what lands in the
55
+ // person's file. Check isSaveInProgress first — firing into an in-flight save is
56
+ // how you lose an edit.
57
+ save: {
58
+ saveHtml,
59
+ replacePageWith,
60
+ isSaveInProgress,
61
+ },
62
+ };
63
+
64
+ export default clay.internals;
@@ -1,12 +1,22 @@
1
1
  // cacheBust.js
2
2
  // Cache-bust an element's href or src attribute by adding/updating a version query param
3
3
 
4
+ import { addRegionToken } from './region-policy.js';
5
+
4
6
  function cacheBust(el) {
5
7
  const attr = el.hasAttribute('href') ? 'href' : 'src';
6
8
  const currentValue = el.getAttribute(attr);
7
9
  const url = new URL(currentValue, location.href);
8
10
  url.searchParams.set('v', Date.now());
9
11
  el.setAttribute(attr, url.href);
12
+
13
+ // This runs from [onaftersave], i.e. after the save baseline was taken, so the
14
+ // rewrite would otherwise read as a user edit and leave the page dirty forever.
15
+ // Marking the element instead of re-reading the whole live DOM afterwards is what
16
+ // lets the baseline stay equal to the bytes actually sent: a post-save re-read
17
+ // cannot tell this rewrite apart from something the user typed mid-save, and used
18
+ // to record the latter as saved without sending it.
19
+ addRegionToken(el, 'no-trigger-autosave');
10
20
  }
11
21
 
12
22
  export default cacheBust;
package/src/lib/cookie.js CHANGED
@@ -1,31 +1,66 @@
1
+ // A sandboxed document (an iframe without allow-same-origin) throws a
2
+ // SecurityError on document.cookie. is-edit-mode.js reads a cookie at module
3
+ // scope, so an unguarded throw does not merely lose the cookie: it escapes the
4
+ // dynamic import the loader is awaiting and the document loses its client
5
+ // entirely. No cookies readable means no cookie, which is the honest answer.
6
+ function readCookies() {
7
+ try {
8
+ return document.cookie;
9
+ } catch (err) {
10
+ return '';
11
+ }
12
+ }
13
+
14
+ // A cookie value that is not valid percent-encoding (a stray '%') makes
15
+ // decodeURIComponent throw URIError. Decode ONCE, outside the JSON try: the catch
16
+ // used to call it a second time on the value that had just thrown, so the second
17
+ // throw escaped — out through is-edit-mode's module-scope read, out through the
18
+ // loader's awaited import, and the document lost its client over a malformed cookie
19
+ // it never even needed.
20
+ function decode(value) {
21
+ try {
22
+ return decodeURIComponent(value);
23
+ } catch (err) {
24
+ return value;
25
+ }
26
+ }
27
+
1
28
  // e.g. Cookie.get("isAdminOfCurrentResource")
2
29
  function get (cookieName) {
3
- const cookies = document.cookie.split('; ');
30
+ const cookies = readCookies().split('; ');
4
31
  const cookie = cookies.find(row => row.startsWith(`${cookieName}=`));
5
32
  if (!cookie) return null;
6
- const cookieValue = cookie.slice(cookieName.length + 1);
33
+ const cookieValue = decode(cookie.slice(cookieName.length + 1));
34
+ try {
35
+ return JSON.parse(cookieValue);
36
+ } catch (err) {
37
+ return cookieValue;
38
+ }
39
+ }
40
+
41
+ // Writing document.cookie throws in a sandbox for the same reason reading it
42
+ // does, and there is nothing to clear there anyway.
43
+ function writeCookie(value) {
7
44
  try {
8
- return JSON.parse(decodeURIComponent(cookieValue));
45
+ document.cookie = value;
9
46
  } catch (err) {
10
- return decodeURIComponent(cookieValue);
47
+ // Sandboxed: no cookie jar to clear.
11
48
  }
12
49
  }
13
50
 
14
51
  // e.g. Cookie.remove("isAdminOfCurrentResource")
15
52
  function remove(name) {
16
53
  // Clear from current path
17
- document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/;`
18
-
19
- // Clear from current domain
20
- document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=${window.location.hostname};`
54
+ writeCookie(`${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/;`)
21
55
 
22
- // Clear from apex domain (e.g., .hyperclay.com or .localhyperclay.com)
23
- const hostname = window.location.hostname;
24
- if (hostname.includes('.')) {
25
- // Get the last two parts for the apex domain (handles .com, .co.uk, etc)
26
- const parts = hostname.split('.');
27
- const apexDomain = '.' + parts.slice(-2).join('.');
28
- document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=${apexDomain};`
56
+ // Clear from every parent domain rather than a guessed apex. Joining the last
57
+ // two labels produces `.co.uk` on editor.example.co.uk, which is a public
58
+ // suffix: the browser rejects it and the real cookie survives. Walking every
59
+ // suffix needs no public-suffix list, because the browser rejects exactly the
60
+ // ones that are public suffixes, harmlessly.
61
+ const labels = window.location.hostname.split('.');
62
+ for (let i = 0; i < labels.length; i++) {
63
+ writeCookie(`${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${labels.slice(i).join('.')};`);
29
64
  }
30
65
  }
31
66
 
@@ -52,7 +52,11 @@ export function stripExtensionNoise(root) {
52
52
  if (!root || !root.querySelectorAll) return
53
53
  try {
54
54
  for (const el of root.querySelectorAll(EXTENSION_NODE_SELECTOR)) el.remove()
55
- for (const el of root.querySelectorAll('*')) {
55
+ // querySelectorAll('*') never returns the root itself, so an extension stamp on
56
+ // <html> used to ride into every saved file. The root is the one element an
57
+ // extension can mark on a page with no other match.
58
+ for (const el of [root, ...root.querySelectorAll('*')]) {
59
+ if (!el.attributes) continue
56
60
  for (const attr of [...el.attributes]) {
57
61
  if (EXTENSION_ATTR_PATTERN.test(attr.name.toLowerCase())) el.removeAttribute(attr.name)
58
62
  }
@@ -68,10 +68,34 @@
68
68
 
69
69
  import { EXTENSION_ATTR_PATTERN } from './extension-noise.js';
70
70
  import { resolveRegionPolicy, isInert, strictestPolicy, skipForPolicy } from './region-policy.js';
71
+ import { ROOT_LIBRARY_ATTRS } from './root-attrs.js';
72
+
71
73
  import { isUserDrivenNow, markUserDriven } from './user-gesture.js';
72
74
 
73
75
  const dummyElem = document.createElement("div");
74
76
 
77
+ // Attributes clayjs itself writes on <html>: savestatus flips on every save,
78
+ // editmode/pageowner on every mode change. They are library state, not page
79
+ // content, and snapshots already normalize them so they never reach a file.
80
+ //
81
+ // This became load-bearing the moment the hub started observing documentElement
82
+ // instead of body. The raw lane is deliberately unfiltered ("raw means raw"), so
83
+ // without this every save would push a savestatus flip into undo's record stream
84
+ // and Cmd+Z after a save would undo that instead of the user's edit.
85
+ const ROOT_LIBRARY_ATTR_SET = new Set(ROOT_LIBRARY_ATTRS);
86
+
87
+ function isLibraryRootAttr(record) {
88
+ return record.type === 'attributes'
89
+ && record.target === document.documentElement
90
+ && ROOT_LIBRARY_ATTR_SET.has(record.attributeName);
91
+ }
92
+
93
+ function withoutLibraryNoise(records) {
94
+ return records.some(isLibraryRootAttr)
95
+ ? records.filter(r => !isLibraryRootAttr(r))
96
+ : records;
97
+ }
98
+
75
99
  const localMutation = {
76
100
  _callbacks: {
77
101
  anyChange: [],
@@ -108,8 +132,8 @@ const localMutation = {
108
132
  // Bridge: pause undo recorder too. Live-sync calls Mutation.pause()
109
133
  // before morphing remote HTML in, and we want those mutations excluded
110
134
  // 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();
135
+ if (typeof window !== 'undefined' && window.clay?.undo?.pause) {
136
+ window.clay.undo.pause();
113
137
  }
114
138
  this._log('Paused', this._pauseDepth);
115
139
  },
@@ -130,8 +154,8 @@ const localMutation = {
130
154
  if (this._pauseDepth === 0 && this._observer) {
131
155
  this._drainBrowserQueue(this);
132
156
  }
133
- if (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo && window.hyperclay.undo.resume) {
134
- window.hyperclay.undo.resume();
157
+ if (typeof window !== 'undefined' && window.clay?.undo?.resume) {
158
+ window.clay.undo.resume();
135
159
  }
136
160
  this._log('Resumed', this._pauseDepth);
137
161
  },
@@ -156,7 +180,7 @@ const localMutation = {
156
180
  // While paused, only non-pausable callbacks (pure enhancers) run.
157
181
  if (onlyNonPausable && callback.pausable !== false) continue;
158
182
 
159
- const { fn, debounce = 0, selectorFilter, omitChangeDetails, require, skip } = callback;
183
+ const { fn, debounce = 0, maxWait = 0, selectorFilter, omitChangeDetails, require, skip } = callback;
160
184
 
161
185
  // Per-consumer region policy: drop changes whose region this consumer
162
186
  // doesn't participate in (no-save / no-trigger-autosave / no-undo / freeze
@@ -214,10 +238,21 @@ const localMutation = {
214
238
  callback.timeout = null;
215
239
  }
216
240
 
241
+ // A plain debounce resets on every mutation, so anything on the page that
242
+ // changes faster than the delay (a clock, a countdown, a polling counter)
243
+ // keeps pushing the callback into the future and it never fires at all.
244
+ // maxWait puts a ceiling on that: however long the churn lasts, the
245
+ // callback runs within maxWait of the FIRST change it was waiting on.
246
+ if (!callback.firstPendingAt) callback.firstPendingAt = Date.now();
247
+ const delay = maxWait > 0
248
+ ? Math.max(0, Math.min(debounce, maxWait - (Date.now() - callback.firstPendingAt)))
249
+ : debounce;
250
+
217
251
  if (omitChangeDetails) {
218
252
  // For omitChangeDetails, just reset the timer
219
253
  callback.timeout = setTimeout(() => {
220
254
  callback.timeout = null;
255
+ callback.firstPendingAt = null;
221
256
  try {
222
257
  this._log('Executing debounced callback (no details)');
223
258
  fn();
@@ -225,7 +260,7 @@ const localMutation = {
225
260
  this._log('Error in callback execution:', e, 'error');
226
261
  console.error('Error in Mutation callback:', e);
227
262
  }
228
- }, debounce);
263
+ }, delay);
229
264
  } else {
230
265
  // For callbacks with change details, accumulate changes
231
266
  if (!callback.pendingChanges) {
@@ -238,6 +273,7 @@ const localMutation = {
238
273
  const changes = callback.pendingChanges;
239
274
  callback.pendingChanges = null; // Reset to null, not empty array
240
275
  callback.timeout = null; // Clear the timeout reference
276
+ callback.firstPendingAt = null;
241
277
  try {
242
278
  this._log('Executing debounced callback with changes:', { changes });
243
279
  if (changes && changes.length > 0) {
@@ -247,7 +283,7 @@ const localMutation = {
247
283
  this._log('Error in callback execution:', e, 'error');
248
284
  console.error('Error in Mutation callback:', e);
249
285
  }
250
- }, debounce);
286
+ }, delay);
251
287
  }
252
288
  }
253
289
  }
@@ -272,7 +308,9 @@ const localMutation = {
272
308
  // path) so the raw lane's push timing matches a real MutationObserver and
273
309
  // undo's own paused-drop keeps working.
274
310
  // 2. the change lane (pause-gated, as before).
275
- _onRecords(records) {
311
+ _onRecords(rawRecords) {
312
+ const records = withoutLibraryNoise(rawRecords);
313
+ if (!records.length) return;
276
314
  this._fanOutRaw(records);
277
315
  this._handleMutations(records);
278
316
  },
@@ -304,7 +342,9 @@ const localMutation = {
304
342
  // (c) the requester: returns the pulled records merged with its own buffer,
305
343
  // then clears the buffer.
306
344
  _drainBrowserQueue(requester) {
307
- const pulled = this._observer ? this._observer.takeRecords() : [];
345
+ // Same filter as _onRecords: takeRecords() bypasses the observer callback, so
346
+ // library-owned root attributes would otherwise reach subscribers this way.
347
+ const pulled = withoutLibraryNoise(this._observer ? this._observer.takeRecords() : []);
308
348
  const requesterIsRaw = !!requester && this._rawSubscribers.indexOf(requester) !== -1;
309
349
 
310
350
  if (pulled.length) {
@@ -614,6 +654,8 @@ const localMutation = {
614
654
  const cb = {
615
655
  fn: callback,
616
656
  debounce: options.debounce || 0,
657
+ // Ceiling on the debounce: without it, continuous churn defers forever.
658
+ maxWait: options.maxWait || 0,
617
659
  selectorFilter: options.selectorFilter,
618
660
  omitChangeDetails: options.omitChangeDetails,
619
661
  // Region policy: axis this consumer needs ('observed' | 'autosave' | 'undo')
@@ -624,7 +666,8 @@ const localMutation = {
624
666
  // enhancers that never save/record/rebroadcast). Default true.
625
667
  pausable: options.pausable !== false,
626
668
  timeout: null,
627
- pendingChanges: null
669
+ pendingChanges: null,
670
+ firstPendingAt: null
628
671
  };
629
672
 
630
673
  this._callbacks[type].push(cb);
@@ -664,7 +707,11 @@ const localMutation = {
664
707
 
665
708
  this._log('Starting observation');
666
709
  this._initializeObserver();
667
- this._observer.observe(document.body, {
710
+ // The root, not body: every capture serializes documentElement, so watching
711
+ // only body meant a <head> edit (document.title, a new <style>) could never
712
+ // trigger an autosave while still counting as a change in the dirty check —
713
+ // the page stayed permanently unsaved and only a manual save cleared it.
714
+ this._observer.observe(document.documentElement, {
668
715
  childList: true,
669
716
  attributes: true,
670
717
  subtree: true,
@@ -718,18 +765,18 @@ const Mutation = existingHub || localMutation;
718
765
 
719
766
  if (typeof window !== 'undefined' && !existingHub) {
720
767
  window.__clayMutation = Mutation;
721
- // Publish the vendor-compat mirror BEFORE the readiness dispatch: sap's mutation
722
- // bridge reads window.hyperclay.Mutation when hyperclay:mutation-ready fires, so
723
- // the hub must already be in place or sap keeps its own native observer.
724
- window.hyperclay = window.hyperclay || {};
725
- window.hyperclay.Mutation = Mutation;
768
+ // Publish the hub BEFORE the readiness dispatch. Consumers (sap's mutation
769
+ // bridge, hypercms's fast path) read clay.Mutation from inside their
770
+ // mutation-ready listener, and the loader's assembleCore does not run until
771
+ // after this module finishes evaluating, so without this the hub is invisible
772
+ // at the exact moment it announces itself.
773
+ window.clay = window.clay || {};
774
+ window.clay.Mutation = Mutation;
726
775
  // Signal consumers (e.g. hypercms ?cms=true auto-open, sap's late-hub listener)
727
776
  // that Mutation is ready, so they can react instead of polling. Wrapped so a
728
777
  // dispatch failure can never break the install.
729
778
  try {
730
779
  document.dispatchEvent(new CustomEvent('clay:mutation-ready', { detail: { Mutation } }));
731
- // vendor-compat: hypercms's readiness fast path listens for the legacy name
732
- document.dispatchEvent(new CustomEvent('hyperclay:mutation-ready', { detail: { Mutation } }));
733
780
  } catch {}
734
781
  }
735
782
 
@@ -52,6 +52,21 @@ function hasRegionToken(el, token) {
52
52
  return !!el.hasAttribute?.(token); // legacy bare attribute
53
53
  }
54
54
 
55
+ /**
56
+ * Add a canonical region token to an element's `clay` attribute, preserving any
57
+ * tokens already there. The one place that knows how the attribute is spelled, so
58
+ * a caller that needs to mark a region cannot invent a second merge.
59
+ *
60
+ * @param {Element} el
61
+ * @param {string} token - a canonical token, e.g. 'no-trigger-autosave'
62
+ */
63
+ export function addRegionToken(el, token) {
64
+ const tokens = new Set((el.getAttribute("clay") || "").trim().split(/\s+/).filter(Boolean));
65
+ if (tokens.has(token)) return;
66
+ tokens.add(token);
67
+ el.setAttribute("clay", Array.from(tokens).join(" "));
68
+ }
69
+
55
70
  // Serializer selectors (recognize the clay-token spelling FIRST, then new + legacy bare).
56
71
  export const STRIP_FROM_SAVE = '[clay~="no-save"], [no-save], [save-remove]';
57
72
  export const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
@@ -205,8 +220,8 @@ export function skipForPolicy(policy, require, skip) {
205
220
  }
206
221
 
207
222
  // The canonical region API the vendored hyper-undo (a separate bundle that can't
208
- // import this module) delegates "is this undoable?" to via window.hyperclay.region,
209
- // so the two can no longer drift. The loader assembles this onto the compat shim.
223
+ // import this module) delegates "is this undoable?" to via clay.region, so the two
224
+ // can no longer drift. The loader assembles this onto clay in assembleCore.
210
225
  export const windowRegionShape = {
211
226
  resolveRegionPolicy,
212
227
  isInert,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * root-attrs.js — the attributes that live on <html>, and who owns them.
3
+ *
4
+ * Three parties write to the root: the host at serve time, this library, and the
5
+ * author. Only the author's belong to the document. The other two belong to one
6
+ * response and one tab, which is why they are named here rather than inside the
7
+ * modules that happen to read them — the observer has to ignore them, and a
8
+ * peer's copy of them must never be applied.
9
+ */
10
+
11
+ // Injected by the host at serve time. Spec §9 bounds a save token to per-file and
12
+ // per-tab and makes the host strip it before writing, so it never reaches disk.
13
+ // But §9 bounds only the save path, and §10 fans a snapshot out to other editors'
14
+ // browsers, which is the hole this module closes.
15
+ export const HOST_TOKEN_ATTRS = ["savetoken", "htmlclaytoken"];
16
+
17
+ // Which save envelope this host's lane takes: a fact about the response, not
18
+ // about the document.
19
+ export const SAVE_TRANSPORT_ATTR = "clay-save-transport";
20
+
21
+ // This library's own root state, and this tab's UI truth.
22
+ export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
23
+
24
+ // Never copied between tabs: not written onto this root by an incoming morph, not
25
+ // sent out on a sync broadcast.
26
+ //
27
+ // Both directions matter, and each was found separately. Accepting a peer's token
28
+ // makes every later save go out as that peer, and keeps working after this tab's
29
+ // own access is revoked, because revocation cannot reach a token minted for
30
+ // somebody else. Accepting a peer's ABSENCE of one is the mirror failure logged in
31
+ // documentid.md §5: a token-stripped broadcast removes this tab's own token and it
32
+ // can no longer save at all.
33
+ export const TAB_LOCAL_ROOT_ATTRS = new Set([
34
+ ...HOST_TOKEN_ATTRS,
35
+ SAVE_TRANSPORT_ATTR,
36
+ ...ROOT_LIBRARY_ATTRS,
37
+ ]);
38
+
39
+ /**
40
+ * True when an incoming morph must be kept away from this attribute.
41
+ * Scoped to the root: the same names anywhere else are the author's business.
42
+ */
43
+ export function isTabLocalRootAttr(name, element) {
44
+ return element === document.documentElement && TAB_LOCAL_ROOT_ATTRS.has(name);
45
+ }
@@ -1,36 +1,48 @@
1
1
  function throttle(callback, delay, executeFirst = true) {
2
2
  let lastCall = executeFirst ? 0 : Date.now();
3
3
  let timeoutId = null;
4
- let pendingResolvers = [];
4
+ let pending = [];
5
+
6
+ // See debounce.js: settle both branches, or a throwing callback strands every
7
+ // caller sharing the window. throttle's caller is the save lane.
8
+ function settle(ctx, args, waiting) {
9
+ let result;
10
+ try {
11
+ result = callback.apply(ctx, args);
12
+ } catch (error) {
13
+ for (const p of waiting) p.reject(error);
14
+ return;
15
+ }
16
+ Promise.resolve(result).then(
17
+ value => { for (const p of waiting) p.resolve(value); },
18
+ error => { for (const p of waiting) p.reject(error); },
19
+ );
20
+ }
5
21
 
6
22
  return function (...args) {
7
23
  const ctx = this;
8
24
  const now = Date.now();
9
25
  const remaining = delay - (now - lastCall);
10
26
 
11
- return new Promise((resolve) => {
27
+ return new Promise((resolve, reject) => {
12
28
  if (remaining <= 0) {
13
29
  clearTimeout(timeoutId);
14
30
  timeoutId = null;
15
31
  lastCall = now;
16
32
 
17
- const resolvers = pendingResolvers.concat(resolve);
18
- pendingResolvers = [];
19
-
20
- Promise.resolve(callback.apply(ctx, args))
21
- .then(value => { for (const r of resolvers) r(value); });
33
+ const waiting = pending.concat({ resolve, reject });
34
+ pending = [];
35
+ settle(ctx, args, waiting);
22
36
  } else {
23
- pendingResolvers.push(resolve);
37
+ pending.push({ resolve, reject });
24
38
 
25
39
  if (!timeoutId) {
26
40
  timeoutId = setTimeout(() => {
27
41
  lastCall = Date.now();
28
42
  timeoutId = null;
29
- const resolvers = pendingResolvers;
30
- pendingResolvers = [];
31
-
32
- Promise.resolve(callback.apply(ctx, args))
33
- .then(value => { for (const r of resolvers) r(value); });
43
+ const waiting = pending;
44
+ pending = [];
45
+ settle(ctx, args, waiting);
34
46
  }, remaining);
35
47
  }
36
48
  }