@panphora/clayjs 1.1.0 → 1.3.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 +4 -1
  2. package/THIRD-PARTY-NOTICES.md +10 -0
  3. package/dist/clay.standalone.js +21751 -13942
  4. package/entries/clay-data.js +1 -1
  5. package/package.json +7 -2
  6. package/packed-contract.json +17 -0
  7. package/src/attrs/save-freeze.js +10 -18
  8. package/src/core/admin-contenteditable.js +5 -6
  9. package/src/core/etag.js +17 -4
  10. package/src/core/host-attrs.js +52 -2
  11. package/src/core/is-edit-mode.js +14 -5
  12. package/src/core/persist.js +5 -10
  13. package/src/core/save-conflict-notice.js +22 -27
  14. package/src/core/save-core.js +326 -26
  15. package/src/core/save.js +19 -4
  16. package/src/core/snapshot.js +148 -15
  17. package/src/core/source-map.js +817 -0
  18. package/src/core/stale-host-notice.js +95 -0
  19. package/src/core/unsaved-warning.js +3 -0
  20. package/src/dom/dom-helpers.js +5 -1
  21. package/src/lib/content-dom.js +108 -0
  22. package/src/lib/hostile-css.js +49 -0
  23. package/src/lib/mutation.js +26 -3
  24. package/src/lib/region-capabilities.js +69 -0
  25. package/src/lib/region-policy.js +18 -13
  26. package/src/lib/root-attrs.js +52 -13
  27. package/src/loader-logic.js +25 -4
  28. package/src/loader.js +4 -0
  29. package/src/plugins/demo.js +3 -0
  30. package/src/plugins/sortable.js +6 -1
  31. package/src/plugins/source.js +326 -0
  32. package/src/plugins/wire.js +248 -47
  33. package/src/sync/live-sync.js +275 -63
  34. package/src/sync/presence.js +303 -0
  35. package/src/sync/section-notice.js +230 -0
  36. package/src/sync/splice-merge.js +7 -10
  37. package/src/sync/stream.js +190 -0
  38. package/src/vendor/hyper-morph.vendor.js +2 -2
  39. package/src/vendor/hyper-undo.vendor.js +1 -1
  40. package/src/vendor/hypercms.vendor.js +438 -45
  41. package/src/vendor/parse5.vendor.js +3 -0
  42. package/src/vendor/quickcrop.vendor.js +1 -1
  43. package/src/vendor/richclay.vendor.js +22 -15
@@ -0,0 +1,303 @@
1
+ /**
2
+ * presence.js — who else is on this document.
3
+ *
4
+ * A fixed-corner stack of circles, one per participant the host chose to name,
5
+ * plus a count of everyone it did not. The host decides who is named, per
6
+ * recipient, from that recipient's own access; nothing here asks for a name and
7
+ * nothing here can widen what arrived.
8
+ *
9
+ * THE RULE THIS FILE EXISTS TO GET RIGHT.
10
+ *
11
+ * captureForSaveAndComparison() clones the document, hands that clone to peers
12
+ * on `clay:snapshot-ready`, and only THEN strips the save-only regions. So
13
+ * `no-save` alone is not enough: it keeps names out of the file while
14
+ * broadcasting them to every peer on the document, including a visitor the host
15
+ * deliberately answered with a count and no names. `no-snapshot` is the token
16
+ * that runs BEFORE the clone is emitted (captureSnapshot), so every runtime root
17
+ * here carries all three, tooltip included:
18
+ *
19
+ * no-save not written to the saved file
20
+ * no-watch invisible to the mutation system, so a roster change is not an edit
21
+ * no-snapshot absent from every snapshot, including the one peers receive
22
+ *
23
+ * The roster lives in this module's memory. It is never written to a DOM data
24
+ * store, an attribute or anything else a serializer walks — the names on screen
25
+ * are text nodes inside a subtree that no snapshot contains, and the hover label
26
+ * is held in a closure rather than in `title`. Styling is inline and hostile-css
27
+ * !important throughout, so there is no injected <style> tag to mark either.
28
+ *
29
+ * Gated on the host, not on the first frame: a host that does not announce
30
+ * `presence` draws nothing at all rather than an empty stack. That is what
31
+ * hyperclay-local, HTML Clay and makerclay do today and it stays that way until
32
+ * they adopt it.
33
+ *
34
+ * `people[].id` is an opaque keyed pseudonym. It picks the colour and nothing
35
+ * else: it is not a database id, it names no account, and it is never logged.
36
+ */
37
+
38
+ import { hostSupports } from '../core/host-meta.js';
39
+ import { make, set } from '../lib/hostile-css.js';
40
+
41
+ // Runtime-only chrome, in one string so no root can carry two of the three.
42
+ const RUNTIME_ONLY = 'no-save no-watch no-snapshot';
43
+
44
+ // Two initials at 12px need about 15px of the circle, so the overlap has to
45
+ // leave that much clear or the neighbouring circle eats the second letter.
46
+ const SIZE = 30;
47
+ const OVERLAP = 6;
48
+ const FONT = "12px/1 system-ui,-apple-system,'Segoe UI',sans-serif";
49
+ const LABEL_FONT = "12px/1.4 system-ui,-apple-system,'Segoe UI',sans-serif";
50
+
51
+ // The chrome around the circles is themeable like every other clayjs surface.
52
+ // The circles themselves are not: their colour is computed per participant, so
53
+ // there is no one value a page could override.
54
+ const INK = 'var(--clay-presence-ink,#1f2023)';
55
+ const CHIP_BG = 'var(--clay-presence-bg,#fff)';
56
+ const CHIP_EDGE = 'var(--clay-presence-edge,rgba(0,0,0,.14))';
57
+ const TIP_BG = 'var(--clay-presence-tip-bg,#222)';
58
+ const TIP_INK = 'var(--clay-presence-tip-ink,#fff)';
59
+ const FACE = '#ffffff';
60
+
61
+ // A fixed set rather than a computed hue: every one of these was looked at
62
+ // against white initials, and a hue wheel puts neighbouring pseudonyms on two
63
+ // blues nobody can tell apart.
64
+ const COLORS = [
65
+ '#b91c1c', '#c2410c', '#a16207', '#15803d', '#0f766e',
66
+ '#0369a1', '#1d4ed8', '#6d28d9', '#a21caf', '#9f1239',
67
+ ];
68
+
69
+ /** Every element this module creates, marked the same way. */
70
+ function runtimeRoot(el) {
71
+ el.setAttribute('clay', RUNTIME_ONLY);
72
+ return el;
73
+ }
74
+
75
+ function hashOf(id) {
76
+ let h = 5381;
77
+ for (let i = 0; i < id.length; i++) {
78
+ h = (((h << 5) + h) ^ id.charCodeAt(i)) >>> 0;
79
+ }
80
+ return h;
81
+ }
82
+
83
+ // The pseudonym picks the colour and nothing else.
84
+ function colorFor(id) {
85
+ return COLORS[hashOf(id) % COLORS.length];
86
+ }
87
+
88
+ // Array.from, not [0]: a name starting outside the BMP would otherwise show half
89
+ // a character.
90
+ function initialsOf(name) {
91
+ const words = String(name).trim().split(/\s+/).filter(Boolean);
92
+ if (!words.length) return '?';
93
+ const first = (word) => Array.from(word)[0].toUpperCase();
94
+ return words.length === 1 ? first(words[0]) : first(words[0]) + first(words[1]);
95
+ }
96
+
97
+ /**
98
+ * Read the roster frame defensively. Everything here arrived over a wire, and a
99
+ * frame this function cannot understand must draw nothing rather than draw a
100
+ * guess: `[object Object]` in a circle is worse than an empty corner.
101
+ */
102
+ function normalize(data) {
103
+ const list = data && Array.isArray(data.people) ? data.people : [];
104
+ const people = [];
105
+ for (const person of list) {
106
+ if (!person || typeof person.id !== 'string' || !person.id) continue;
107
+ const name = typeof person.name === 'string' && person.name.trim() ? person.name.trim() : 'Someone';
108
+ people.push({
109
+ id: person.id,
110
+ name,
111
+ canEdit: person.canEdit === true,
112
+ you: person.you === true,
113
+ });
114
+ }
115
+ const count = data && data.anonymous;
116
+ const anonymous = typeof count === 'number' && Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
117
+ return { people, anonymous };
118
+ }
119
+
120
+ class Presence {
121
+ constructor() {
122
+ this.roster = { people: [], anonymous: 0 };
123
+ this.root = null;
124
+ this.stack = null;
125
+ this.count = null;
126
+ this.tip = null;
127
+ this._gate = null;
128
+ }
129
+
130
+ /**
131
+ * Does this host run presence at all? Asked once, of /_/meta, and never
132
+ * inferred from a frame arriving: the answer decides whether the chrome is
133
+ * built, so a host that says nothing draws nothing forever.
134
+ */
135
+ supported() {
136
+ if (!this._gate) this._gate = hostSupports('presence').then((ok) => ok === true, () => false);
137
+ return this._gate;
138
+ }
139
+
140
+ /**
141
+ * Take a roster frame. Held in memory first so a frame that arrives before
142
+ * discovery answers is not lost, and drawn only if the host announced the
143
+ * capability.
144
+ *
145
+ * @param {{people: Array, anonymous: number}} data
146
+ * @returns {Promise<void>}
147
+ */
148
+ async update(data) {
149
+ this.roster = normalize(data);
150
+ if (!(await this.supported())) return;
151
+ this.render();
152
+ }
153
+
154
+ /** The stream went away, so the roster is no longer true about anything. */
155
+ clear() {
156
+ this.roster = { people: [], anonymous: 0 };
157
+ if (!this.root) return;
158
+ this.root.remove();
159
+ this.root = null;
160
+ this.stack = null;
161
+ this.count = null;
162
+ this.tip = null;
163
+ }
164
+
165
+ render() {
166
+ const { people, anonymous } = this.roster;
167
+
168
+ // A person alone on a document is told nothing at all. The count is the
169
+ // total either way: a recipient the host would not name gets people: [] and
170
+ // everyone in `anonymous`, so this sum is the same participant count in both
171
+ // directions.
172
+ if (people.length + anonymous <= 1) {
173
+ this.hide();
174
+ return;
175
+ }
176
+ if (!this.build()) return;
177
+
178
+ this.hideTip();
179
+ this.stack.textContent = '';
180
+ for (const person of people) {
181
+ this.stack.appendChild(this.avatar(person));
182
+ }
183
+ // The overlap belongs between circles, so the leftmost one does not pull
184
+ // itself into the count beside it.
185
+ if (this.stack.firstElementChild) set(this.stack.firstElementChild, 'margin-left', '0');
186
+
187
+ if (anonymous > 0) {
188
+ this.count.textContent = `+${anonymous} viewing`;
189
+ set(this.count, 'display', 'inline-block');
190
+ } else {
191
+ this.count.textContent = '';
192
+ set(this.count, 'display', 'none');
193
+ }
194
+ set(this.root, 'display', 'flex');
195
+ }
196
+
197
+ hide() {
198
+ if (!this.root) return;
199
+ this.hideTip();
200
+ this.stack.textContent = '';
201
+ this.count.textContent = '';
202
+ set(this.root, 'display', 'none');
203
+ }
204
+
205
+ build() {
206
+ if (this.root) return true;
207
+ if (typeof document === 'undefined' || !document.body) return false;
208
+
209
+ this.root = runtimeRoot(make('div', [
210
+ 'position:fixed',
211
+ 'top:calc(12px + env(safe-area-inset-top,0px))',
212
+ 'right:calc(12px + env(safe-area-inset-right,0px))',
213
+ 'z-index:2147483000',
214
+ 'display:flex', 'align-items:center', 'gap:8px',
215
+ 'max-width:calc(100vw - 24px)',
216
+ `font:${LABEL_FONT}`, `color:${INK}`,
217
+ // Only the circles take a pointer. A fixed corner that swallowed clicks
218
+ // would take a bite out of whatever the page put underneath it.
219
+ 'pointer-events:none',
220
+ ]));
221
+ this.root.setAttribute('data-clay-presence', '');
222
+
223
+ this.count = runtimeRoot(make('span', [
224
+ 'box-sizing:border-box', 'flex:none', 'white-space:nowrap',
225
+ 'padding:4px 8px', 'border-radius:999px',
226
+ `background-color:${CHIP_BG}`, 'background-image:none', `color:${INK}`,
227
+ 'border-width:1px', 'border-style:solid', `border-color:${CHIP_EDGE}`,
228
+ `font:${LABEL_FONT}`,
229
+ 'box-shadow:0 1px 3px rgba(0,0,0,.2)',
230
+ 'display:none',
231
+ ]));
232
+ this.count.setAttribute('data-clay-presence-count', '');
233
+
234
+ this.stack = runtimeRoot(make('div', [
235
+ 'display:flex', 'align-items:center', 'flex:none',
236
+ ]));
237
+
238
+ // Inside the root rather than appended to <body>, so the one element that
239
+ // holds a name at rest cannot outlive the subtree that keeps it out of a
240
+ // snapshot. It carries the marking of its own too.
241
+ this.tip = runtimeRoot(make('span', [
242
+ 'position:absolute', 'top:100%', 'right:0', 'margin-top:6px',
243
+ 'box-sizing:border-box', 'white-space:nowrap', 'pointer-events:none',
244
+ 'padding:4px 8px', 'border-radius:6px',
245
+ `background-color:${TIP_BG}`, 'background-image:none',
246
+ `color:${TIP_INK}`, `font:${LABEL_FONT}`,
247
+ 'box-shadow:0 4px 14px rgba(0,0,0,.28)',
248
+ 'display:none',
249
+ ]));
250
+ this.tip.setAttribute('data-clay-presence-tip', '');
251
+
252
+ this.root.append(this.stack, this.count, this.tip);
253
+ document.body.appendChild(this.root);
254
+ return true;
255
+ }
256
+
257
+ avatar(person) {
258
+ const color = colorFor(person.id);
259
+ // Solid says this person can change the document; hollow says they are
260
+ // reading it. The two are the same three colours inverted, so a stack of
261
+ // both reads as one set rather than as two kinds of thing.
262
+ const skin = person.canEdit
263
+ ? [`background-color:${color}`, `color:${FACE}`, `border-color:${FACE}`]
264
+ : [`background-color:${FACE}`, `color:${color}`, `border-color:${color}`];
265
+
266
+ const el = runtimeRoot(make('div', [
267
+ 'box-sizing:border-box', 'flex:none',
268
+ `width:${SIZE}px`, `height:${SIZE}px`, 'border-radius:50%',
269
+ 'display:flex', 'align-items:center', 'justify-content:center',
270
+ `font:${FONT}`, 'font-weight:600',
271
+ `margin-left:-${OVERLAP}px`,
272
+ 'border-width:2px', 'border-style:solid', 'background-image:none',
273
+ 'box-shadow:0 1px 3px rgba(0,0,0,.28)',
274
+ 'user-select:none', 'cursor:default', 'pointer-events:auto',
275
+ ...skin,
276
+ ], initialsOf(person.name)));
277
+ el.setAttribute('data-clay-presence-avatar', '');
278
+
279
+ // The name lives in this closure, never in an attribute on the element, and
280
+ // it reaches the DOM only while a pointer is actually on the circle.
281
+ const label = person.you ? `${person.name} (you)` : person.name;
282
+ el.addEventListener('mouseenter', () => this.showTip(label));
283
+ el.addEventListener('mouseleave', () => this.hideTip());
284
+ return el;
285
+ }
286
+
287
+ showTip(text) {
288
+ if (!this.tip) return;
289
+ this.tip.textContent = text;
290
+ set(this.tip, 'display', 'block');
291
+ }
292
+
293
+ hideTip() {
294
+ if (!this.tip) return;
295
+ this.tip.textContent = '';
296
+ set(this.tip, 'display', 'none');
297
+ }
298
+ }
299
+
300
+ const presence = new Presence();
301
+
302
+ export { presence, Presence };
303
+ export default presence;
@@ -0,0 +1,230 @@
1
+ /**
2
+ * section-notice.js — "<name> changed this section".
3
+ *
4
+ * One dismissible line, shown when a live-sync frame from somebody else
5
+ * actually changed the editable region this reader was working in.
6
+ *
7
+ * HOW "THIS SECTION CHANGED" IS DECIDED, AND WHY BOTH OBVIOUS READINGS ARE WRONG.
8
+ *
9
+ * Not "an ancestor was morphed". Every frame morphs `document.documentElement`,
10
+ * so `body` is an ancestor of every region there is and that test reports every
11
+ * unrelated edit anybody makes anywhere on the page.
12
+ *
13
+ * Not "an ancestor was replaced". hyper-morph matches and mutates IN PLACE
14
+ * (morphNode / morphAttributes), so nothing is replaced when a peer retypes a
15
+ * sentence — which is the common case this notice exists for.
16
+ *
17
+ * So the region's own content is compared across the morph. The baseline is
18
+ * recorded when the reader focuses the region and refreshed while they type, so
19
+ * their own keystrokes are never read as somebody else's edit, and the
20
+ * comparison runs on `clay:sync-applied`, which fires after the awaited morph.
21
+ * It is re-recorded on EVERY applied frame, named or not: a change nobody is
22
+ * named for would otherwise leave the baseline behind the screen, and the next
23
+ * named frame — which may have touched nothing here — would inherit it and put
24
+ * the wrong person's name on somebody else's edit.
25
+ *
26
+ * The one thing that gets past it: a page script that rewrites the region
27
+ * without an `input` event. That drift is charged to whichever named frame lands
28
+ * next. Typing, pasting and every other user edit of a control or a
29
+ * contenteditable subtree do fire one, which is the case this is for.
30
+ *
31
+ * The region is kept AFTER focus leaves, deliberately. hyper-morph's
32
+ * `ignoreActiveValue` skips both the value sync and the child morph for
33
+ * `document.activeElement`, so nothing can change under the caret while it is
34
+ * still there; the frame worth reporting is the one that lands on the paragraph
35
+ * the reader just left.
36
+ *
37
+ * DISMISS ONLY. An undo here would mean recovering displaced local work, which
38
+ * nothing in this library builds. Unsaved local work is protected before the
39
+ * morph by protectPeerDoc, and a refused save is still the conflict bar's job.
40
+ *
41
+ * Silent with no `by` on the frame, which is the whole gate: the server stamps
42
+ * an author on live-lane frames alone, so a saved-lane reader — who must never
43
+ * be told who wrote the public page they are reading — is silent by
44
+ * construction rather than by a second rule that could disagree.
45
+ *
46
+ * Every root carries all three runtime tokens, for the reason presence.js's
47
+ * header sets out: captureForSaveAndComparison() hands its clone to peers on
48
+ * `clay:snapshot-ready` and strips the save-only regions only AFTER that, so
49
+ * `no-save` alone keeps an author's name out of the file while broadcasting it
50
+ * to every peer on the document.
51
+ *
52
+ * no-save not written to the saved file
53
+ * no-watch invisible to the mutation system, so a notice is not an edit
54
+ * no-snapshot absent from every snapshot, including the one peers receive
55
+ *
56
+ * The name reaches the DOM in one text node inside that subtree and is held
57
+ * nowhere else — no attribute, no data store, nothing a serializer walks.
58
+ */
59
+
60
+ import { make, set } from '../lib/hostile-css.js';
61
+
62
+ // Runtime-only chrome, in one string so no root can carry two of the three.
63
+ const RUNTIME_ONLY = 'no-save no-watch no-snapshot';
64
+
65
+ const BG = 'var(--clay-section-bg,#222)';
66
+ const INK = 'var(--clay-section-ink,#fff)';
67
+ const EDGE = 'var(--clay-section-edge,rgba(255,255,255,.28))';
68
+ const FONT = "14px/1.45 system-ui,-apple-system,'Segoe UI',sans-serif";
69
+
70
+ // A control's work is its live value, which its markup does not carry. An
71
+ // editable subtree's is its markup, and `outerHTML` covers the region's own
72
+ // attributes too, so an in-place attribute change on it counts as a change.
73
+ const CONTROLS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
74
+
75
+ // The nearest element that DECLARES the attribute, which is the host of the
76
+ // editable subtree. `isContentEditable` is inherited, so it answers true for
77
+ // every descendant text node's parent and names no region at all.
78
+ const EDITABLE = '[contenteditable]:not([contenteditable="false"])';
79
+
80
+ /** Every element this module creates, marked the same way. */
81
+ function runtimeRoot(el) {
82
+ el.setAttribute('clay', RUNTIME_ONLY);
83
+ return el;
84
+ }
85
+
86
+ function regionOf(target) {
87
+ if (!target || target.nodeType !== 1 || typeof target.closest !== 'function') return null;
88
+ if (CONTROLS.has(target.tagName)) return target;
89
+ return target.closest(EDITABLE);
90
+ }
91
+
92
+ // Null for a region the frame removed outright, which differs from every string
93
+ // a live region can produce and so reads as the change it is.
94
+ function contentOf(el) {
95
+ if (!el || !el.isConnected) return null;
96
+ return CONTROLS.has(el.tagName) ? String(el.value) : el.outerHTML;
97
+ }
98
+
99
+ // The stamp arrived over a wire. The server promises a name on every live-lane
100
+ // frame, and this still checks, because a frame that cannot be understood must
101
+ // draw nothing rather than draw "undefined changed this section".
102
+ function nameOf(by) {
103
+ if (!by || typeof by !== 'object') return null;
104
+ const name = typeof by.name === 'string' ? by.name.trim() : '';
105
+ return name || null;
106
+ }
107
+
108
+ class SectionNotice {
109
+ constructor() {
110
+ this.region = null;
111
+ this.content = null;
112
+ this.root = null;
113
+ this.line = null;
114
+ this._wired = false;
115
+ this._onFocusIn = (event) => this.remember(event.target);
116
+ this._onEdit = (event) => this.refresh(event.target);
117
+ this._onApplied = (event) => this.applied(event && event.detail);
118
+ }
119
+
120
+ /** Idempotent, so importing this module twice cannot double-report a frame. */
121
+ init() {
122
+ if (this._wired || typeof document === 'undefined') return;
123
+ this._wired = true;
124
+ document.addEventListener('focusin', this._onFocusIn);
125
+ // `input` alone: a <select> fires it before its `change`, and every other
126
+ // user edit of a control or an editable subtree fires it too.
127
+ document.addEventListener('input', this._onEdit);
128
+ document.addEventListener('clay:sync-applied', this._onApplied);
129
+ }
130
+
131
+ destroy() {
132
+ if (!this._wired) return;
133
+ this._wired = false;
134
+ document.removeEventListener('focusin', this._onFocusIn);
135
+ document.removeEventListener('input', this._onEdit);
136
+ document.removeEventListener('clay:sync-applied', this._onApplied);
137
+ this.region = null;
138
+ this.content = null;
139
+ if (this.root) this.root.remove();
140
+ this.root = null;
141
+ this.line = null;
142
+ }
143
+
144
+ /** The section the reader is working in. Focus on anything else leaves it alone. */
145
+ remember(target) {
146
+ const region = regionOf(target);
147
+ if (!region) return;
148
+ this.region = region;
149
+ this.content = contentOf(region);
150
+ }
151
+
152
+ /** Their own typing is not somebody else's edit. */
153
+ refresh(target) {
154
+ const region = regionOf(target);
155
+ if (!region || region !== this.region) return;
156
+ this.content = contentOf(region);
157
+ }
158
+
159
+ applied(detail) {
160
+ if (!this.region) return;
161
+ const now = contentOf(this.region);
162
+ const changed = now !== this.content;
163
+ this.content = now;
164
+ if (!changed) return;
165
+ const name = nameOf(detail && detail.by);
166
+ if (!name) return;
167
+ this.show(name);
168
+ }
169
+
170
+ show(name) {
171
+ if (!this.build()) return;
172
+ this.line.textContent = `${name} changed this section`;
173
+ set(this.root, 'display', 'flex');
174
+ }
175
+
176
+ hide() {
177
+ if (!this.root) return;
178
+ this.line.textContent = '';
179
+ set(this.root, 'display', 'none');
180
+ }
181
+
182
+ build() {
183
+ if (this.root && this.root.isConnected) return true;
184
+ if (typeof document === 'undefined' || !document.body) return false;
185
+
186
+ this.root = runtimeRoot(make('div', [
187
+ 'position:fixed',
188
+ 'left:calc(12px + env(safe-area-inset-left,0px))',
189
+ // Clear of the conflict bar, which is bottom-anchored and can span almost
190
+ // the whole width of a phone. That bar is the more urgent of the two, so
191
+ // this one moves rather than covering it.
192
+ 'bottom:calc(72px + env(safe-area-inset-bottom,0px))',
193
+ 'z-index:2147483000',
194
+ 'display:none', 'align-items:center', 'gap:10px',
195
+ 'box-sizing:border-box', 'max-width:calc(100vw - 24px)',
196
+ 'padding:9px 12px', 'border-radius:10px',
197
+ `background-color:${BG}`, 'background-image:none', `color:${INK}`,
198
+ 'border-width:1px', 'border-style:solid', `border-color:${EDGE}`,
199
+ 'box-shadow:0 6px 24px rgba(0,0,0,.32),0 1px 2px rgba(0,0,0,.24)',
200
+ `font:${FONT}`, 'text-align:left',
201
+ ]));
202
+ this.root.setAttribute('data-clay-section-notice', '');
203
+ this.root.setAttribute('role', 'status');
204
+
205
+ this.line = runtimeRoot(make('span', ['flex:1 1 auto']));
206
+ this.line.setAttribute('data-clay-section-notice-line', '');
207
+
208
+ // all:initial first, because a page restyling every button is the normal
209
+ // case rather than the adversarial one; everything it needs is restated.
210
+ const dismiss = runtimeRoot(make('button', [
211
+ 'all:initial', 'box-sizing:border-box', 'cursor:pointer', `font:${FONT}`,
212
+ 'font-weight:500', 'border-radius:6px', 'padding:4px 8px',
213
+ 'white-space:nowrap', 'flex:none', `color:${INK}`, 'opacity:.72',
214
+ ], 'Dismiss'));
215
+ dismiss.type = 'button';
216
+ dismiss.setAttribute('data-clay-section-notice-dismiss', '');
217
+ dismiss.addEventListener('click', () => this.hide());
218
+
219
+ this.root.append(this.line, dismiss);
220
+ document.body.appendChild(this.root);
221
+ return true;
222
+ }
223
+ }
224
+
225
+ const sectionNotice = new SectionNotice();
226
+
227
+ if (typeof document !== 'undefined') sectionNotice.init();
228
+
229
+ export { sectionNotice, SectionNotice };
230
+ export default sectionNotice;
@@ -32,7 +32,7 @@
32
32
  */
33
33
 
34
34
  import { findChangedRoots, spliceProtected } from '../vendor/hyper-morph.vendor.js';
35
- import { captureSnapshot, captureForMerge } from '../core/snapshot.js';
35
+ import { captureSnapshot, captureForMerge, originalSnapshotNode } from '../core/snapshot.js';
36
36
  // save.js is edit-only in the loader waves but safe to reach from here: its
37
37
  // module body guards every init on isEditMode, and the disk lane that needs
38
38
  // this state only ever runs in edit-mode tabs.
@@ -109,17 +109,14 @@ function walkPairs(a, b, cb) {
109
109
  * whose child counts diverge (extension noise beside the clone's strip) is
110
110
  * skipped — those elements fall back to data-id / id matching.
111
111
  */
112
- function fillCloneIds(liveEl, cloneEl, liveWeakMap, idOf) {
113
- const id = liveWeakMap.get(liveEl);
112
+ function fillCloneIds(liveEl, cloneEl, liveWeakMap, idOf, root = true) {
113
+ const live = originalSnapshotNode(cloneEl) || (root ? liveEl : null);
114
+ const id = liveWeakMap.get(live);
114
115
  if (id) idOf.set(cloneEl, id);
115
- const liveKids = [];
116
- for (const c of liveEl.children) {
117
- if (!isSnapshotRemoved(c)) liveKids.push(c);
118
- }
119
116
  const cloneKids = cloneEl.children;
120
- if (liveKids.length !== cloneKids.length) return;
121
- for (let i = 0; i < liveKids.length; i++) {
122
- fillCloneIds(liveKids[i], cloneKids[i], liveWeakMap, idOf);
117
+ for (let i = 0; i < cloneKids.length; i++) {
118
+ const childLive = originalSnapshotNode(cloneKids[i]);
119
+ fillCloneIds(childLive, cloneKids[i], liveWeakMap, idOf, false);
123
120
  }
124
121
  }
125
122