@panphora/sapjs 0.2.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.
package/src/pass.js ADDED
@@ -0,0 +1,472 @@
1
+ // One full pass: rebuild state from the DOM, compute calc: in dependency order,
2
+ // paint write-if-changed, run effects, apply validation. Nothing is retained
3
+ // between passes except the compile cache.
4
+
5
+ import {
6
+ walkOwned, rowsOf, parseStateDecl, parseTyped, serializeTyped, ensureId, readTransient,
7
+ } from "./dom.js";
8
+ import { compile, run, topoSort } from "./compile.js";
9
+ import { carrierFor } from "./carrier.js";
10
+ import { applyFormat } from "./helpers.js";
11
+ import { NATIVE_BOOLEANS, setBeacon, clearBeacon } from "./errors.js";
12
+ import { withDomMutationPaused } from "./mutation-bridge.js";
13
+
14
+ const NATIVE_BOOLEAN_SET = new Set(NATIVE_BOOLEANS);
15
+
16
+ export function runPass(app, opts = {}) {
17
+ if (opts.breaker) {
18
+ app.diag.error("E26", app.root, {
19
+ problem: "recompute loop halted after 50 passes; recent triggers: " + (app._breakerRing || []).join(", "),
20
+ });
21
+ setBeacon(app.root, "loop");
22
+ return;
23
+ }
24
+ // The whole pass runs inside one DOM-mutation pause: every engine-derived write
25
+ // (intake strips, id stamps, default materialization, paints, effects, validity,
26
+ // beacons) is vacuumed away from the mutation bridge so it never schedules another
27
+ // pass, and away from undo/autosave so a single user edit collapses to one entry.
28
+ // No-op standalone with no hub/observer present. See mutation-bridge.js.
29
+ withDomMutationPaused(() => runPassBody(app, opts));
30
+ }
31
+
32
+ function runPassBody(app, opts) {
33
+ const root = app.root;
34
+ const rootObj = {};
35
+ const calcs = [];
36
+ const paints = [];
37
+ const effects = [];
38
+ const invalids = [];
39
+ const pending = []; // declared defaults missing from the DOM (candidates to materialize)
40
+ const whenFields = new Set(); // field names a show-when/option visibility verb reads
41
+ app.diag.errors = [];
42
+
43
+ const rootCtx = { root: rootObj, state: rootObj, item: null, ownerEl: root, projection: false };
44
+
45
+ // Read a declared field's value, falling back to its default. When the bare
46
+ // attribute is absent, record the default so it can be materialized to the DOM
47
+ // after intake — but only if a visibility verb references the field, so the CSS
48
+ // floor and show-when can match it before any write. Non-visibility fields are
49
+ // never materialized, keeping this purely additive for existing apps.
50
+ function resolveDefault(el, d) {
51
+ let raw = el.getAttribute(d.name);
52
+ if (raw == null) {
53
+ raw = d.default != null ? d.default : d.type === "num" ? "0" : d.type === "bool" ? "false" : "";
54
+ pending.push({ el, name: d.name, value: serializeTyped(parseTyped(raw, d.type), d.type) });
55
+ }
56
+ return parseTyped(raw, d.type);
57
+ }
58
+
59
+ function readState(el, obj) {
60
+ const decls = parseStateDecl(el.getAttribute("state") || "");
61
+ el._sapStateDecls = decls;
62
+ for (const d of decls) {
63
+ if (d.type === "string" && NATIVE_BOOLEAN_SET.has(d.name) && typeof el[d.name] === "boolean") {
64
+ obj[d.name] = el.hasAttribute(d.name); // presence semantics (e.g. <details open>)
65
+ continue;
66
+ }
67
+ if (d.transient) {
68
+ obj[d.name] = readTransient(el, d); // runtime-only; never serialized
69
+ continue;
70
+ }
71
+ obj[d.name] = resolveDefault(el, d);
72
+ }
73
+ }
74
+
75
+ function readBindInto(el, ctx) {
76
+ if (!el.hasAttribute("bind")) return;
77
+ const field = el.getAttribute("bind");
78
+ const owner = ctx.item || ctx.state;
79
+ const c = carrierFor(el);
80
+ if (c.kind === "radio") {
81
+ if (!(field in owner)) owner[field] = "";
82
+ if (el.checked) owner[field] = el.value;
83
+ } else {
84
+ owner[field] = c.read();
85
+ }
86
+ if (el.hasAttribute("transient")) {
87
+ el.removeAttribute("value"); // the live property carries it; the file never does
88
+ el.removeAttribute("checked");
89
+ }
90
+ }
91
+
92
+ function collectEl(el, ctx) {
93
+ for (const attr of el.attributes) {
94
+ const name = attr.name;
95
+ const val = attr.value;
96
+ if (name.startsWith("calc:")) {
97
+ calcs.push({ owner: ctx.item || ctx.state, ownerEl: ctx.ownerEl, name: name.slice(5), entry: compile(val), el, ctx });
98
+ } else if (name === "text" || name.startsWith("text:")) {
99
+ paints.push({ el, kind: "text", fmt: name.includes(":") ? name.slice(5) : null, entry: compile(val), ctx });
100
+ } else if (name === "show") {
101
+ paints.push({ el, kind: "show", entry: compile(val), ctx });
102
+ } else if (name.startsWith("attr:")) {
103
+ paints.push({ el, kind: "attr", arg: name.slice(5), entry: compile(val), ctx });
104
+ } else if (name.startsWith("class:")) {
105
+ paints.push({ el, kind: "class", arg: name.slice(6), entry: compile(val), ctx });
106
+ } else if (name.startsWith("css:")) {
107
+ paints.push({ el, kind: "css", arg: name.slice(4), entry: compile(val), ctx });
108
+ } else if (name === "effect") {
109
+ effects.push({ el, entry: compile(val, true), ctx });
110
+ } else if (name === "invalid") {
111
+ invalids.push({ el, entry: compile(val), ctx });
112
+ } else if (name.startsWith("show-when:") || name.startsWith("option:")) {
113
+ const field = name.slice(name.indexOf(":") + 1);
114
+ whenFields.add(field);
115
+ paints.push({ el, kind: "when", match: "show", field, values: val.split("|"), ctx });
116
+ } else if (name.startsWith("hide-when:")) {
117
+ const field = name.slice(name.indexOf(":") + 1);
118
+ whenFields.add(field);
119
+ paints.push({ el, kind: "when", match: "hide", field, values: val.split("|"), ctx });
120
+ } else if (name.startsWith("option-not:")) {
121
+ const field = name.slice(name.indexOf(":") + 1);
122
+ whenFields.add(field);
123
+ paints.push({ el, kind: "when", match: "show-not", field, values: val.split("|"), ctx });
124
+ }
125
+ }
126
+ if (ctx.projection && el.hasAttribute("bind")) {
127
+ paints.push({ el, kind: "projection", field: el.getAttribute("bind"), ctx });
128
+ }
129
+ }
130
+
131
+ function defineRowMeta(rowObj, rowEl, i) {
132
+ ensureId(rowEl, "row");
133
+ Object.defineProperty(rowObj, "$key", { value: rowEl.id, enumerable: false });
134
+ Object.defineProperty(rowObj, "$index", { value: i, enumerable: false });
135
+ Object.defineProperty(rowObj, "$el", { value: rowEl, enumerable: false });
136
+ }
137
+
138
+ function enterScope(el, obj, ctx) {
139
+ readState(el, obj);
140
+ el._sapScope = obj;
141
+ if (!ctx.projection && el.hasAttribute("bind")) readBindInto(el, ctx);
142
+ collectEl(el, ctx);
143
+ if (el.hasAttribute("items")) handleItems(el, obj, ctx);
144
+ else walkScope(el, obj, ctx);
145
+ }
146
+
147
+ function walkScope(scopeEl, scopeObj, ctx) {
148
+ walkOwned(scopeEl, (el, boundary) => {
149
+ if (boundary) {
150
+ if (el.hasAttribute("scope")) handleScope(el, scopeObj, ctx);
151
+ else if (el.hasAttribute("items")) {
152
+ collectEl(el, ctx);
153
+ handleItems(el, scopeObj, ctx);
154
+ } else if (el.hasAttribute("detail")) handleDetail(el, scopeObj, ctx);
155
+ } else {
156
+ if (!ctx.projection) readBindInto(el, ctx);
157
+ collectEl(el, ctx);
158
+ }
159
+ });
160
+ }
161
+
162
+ function handleScope(el, parentObj, ctx) {
163
+ const name = el.getAttribute("scope");
164
+ const childObj = {};
165
+ parentObj[name] = childObj;
166
+ const cctx = { root: ctx.root, state: childObj, item: null, ownerEl: el, projection: ctx.projection };
167
+ enterScope(el, childObj, cctx);
168
+ }
169
+
170
+ function handleItems(listEl, parentObj, ctx) {
171
+ const name = listEl.getAttribute("items");
172
+ const arr = [];
173
+ parentObj[name] = arr;
174
+ listEl._sapList = arr;
175
+ rowsOf(listEl).forEach((rowEl, i) => {
176
+ const rowObj = {};
177
+ defineRowMeta(rowObj, rowEl, i);
178
+ const rctx = { root: ctx.root, state: ctx.state, item: rowObj, ownerEl: rowEl, projection: ctx.projection };
179
+ enterScope(rowEl, rowObj, rctx);
180
+ arr.push(rowObj);
181
+ });
182
+ }
183
+
184
+ function resolvePathValue(path, ctx) {
185
+ const segs = String(path).split(".");
186
+ let o = ctx.state;
187
+ for (const s of segs) o = o == null ? undefined : o[s];
188
+ if (o === undefined) {
189
+ o = ctx.root;
190
+ for (const s of segs) o = o == null ? undefined : o[s];
191
+ }
192
+ return o;
193
+ }
194
+
195
+ function handleDetail(el, parentObj, ctx) {
196
+ // A detail's own state= declarations name the key field; it lives on the
197
+ // enclosing scope so the row's set: and the panel's key expr see one field.
198
+ for (const d of parseStateDecl(el.getAttribute("state") || "")) {
199
+ if (!(d.name in parentObj)) parentObj[d.name] = resolveDefault(el, d);
200
+ }
201
+ el._sapScope = parentObj;
202
+ const spec = el.getAttribute("detail") || "";
203
+ const m = /^([\w.]+)\s+by\s+(.+)$/.exec(spec);
204
+ if (!m) {
205
+ el.hidden = true;
206
+ el._sapDetailRow = null;
207
+ return;
208
+ }
209
+ const listPath = m[1];
210
+ const keyExpr = m[2].trim();
211
+ const arr = resolvePathValue(listPath, ctx);
212
+ let keyVal;
213
+ try {
214
+ keyVal = run(compile(keyExpr), { state: parentObj, item: ctx.item, el, root: ctx.root });
215
+ } catch {
216
+ keyVal = undefined;
217
+ }
218
+ const row = Array.isArray(arr) ? arr.find((r) => r && String(r.$key) === String(keyVal)) : null;
219
+ el._sapDetailRow = row ? row.$el : null;
220
+ if (!row) {
221
+ if (!el.hidden) el.hidden = true;
222
+ return;
223
+ }
224
+ if (el.hidden) el.hidden = false;
225
+ const dctx = { root: ctx.root, state: parentObj, item: row, ownerEl: row.$el, projection: true };
226
+ collectEl(el, dctx);
227
+ walkScope(el, row, dctx);
228
+ }
229
+
230
+ // --- intake ---
231
+ enterScope(root, rootObj, rootCtx);
232
+
233
+ // --- materialize defaults a visibility verb references, so the CSS floor
234
+ // (hyperclayjs optionVisibility) and our own show-when can match them before any
235
+ // write. Not counted as paints: this completes the file, it is not a user edit.
236
+ // Only whenFields are touched, so apps without show-when/option see no DOM change.
237
+ // (The whole-pass mutation pause keeps these writes off undo/autosave.) ---
238
+ if (pending.length && whenFields.size) {
239
+ for (const w of pending) {
240
+ if (whenFields.has(w.name) && w.el.getAttribute(w.name) == null) {
241
+ w.el.setAttribute(w.name, w.value);
242
+ }
243
+ }
244
+ }
245
+
246
+ // --- compute calc: deepest scopes first, topo within each scope ---
247
+ const groups = new Map();
248
+ for (const c of calcs) {
249
+ let g = groups.get(c.owner);
250
+ if (!g) {
251
+ g = { ownerEl: c.ownerEl, list: [] };
252
+ groups.set(c.owner, g);
253
+ }
254
+ g.list.push(c);
255
+ }
256
+ const ordered = [...groups.values()].sort((a, b) => depth(b.ownerEl) - depth(a.ownerEl));
257
+ for (const g of ordered) {
258
+ let sorted;
259
+ try {
260
+ sorted = topoSort(g.list);
261
+ } catch (err) {
262
+ app.diag.error("E07", g.list[0].el, { problem: err.message });
263
+ sorted = g.list;
264
+ }
265
+ for (const c of sorted) {
266
+ try {
267
+ c.owner[c.name] = run(c.entry, { state: c.ctx.state, item: c.ctx.item, el: c.el, root: c.ctx.root });
268
+ clearBeacon(c.el);
269
+ } catch (err) {
270
+ c.owner[c.name] = undefined;
271
+ app.diag.error("E24", c.el, { attr: "calc:" + c.name, expr: c.entry.src, problem: String(err.message) });
272
+ setBeacon(c.el, err.sapCode || "E24");
273
+ }
274
+ }
275
+ }
276
+
277
+ // --- paint ---
278
+ // Engine-derived output (text/hidden/attr/class/css), effects, and validity
279
+ // complete the file from current state; they are not user edits. The whole-pass
280
+ // mutation pause (runPass) keeps these writes off the bridge, undo, and autosave,
281
+ // so one user edit does not spawn N undoable/dirtying derived writes: undo restores
282
+ // the source attribute and we re-derive on undo/redo (platform.js), so a single
283
+ // undo reverts the action and its derived effects together.
284
+ let writes = 0;
285
+ for (const p of paints) writes += paintOne(p, app);
286
+ for (const e of effects) runEffect(e, app);
287
+ for (const iv of invalids) runInvalid(iv);
288
+
289
+ app._state = rootObj;
290
+ app._stats = {
291
+ fields: Object.keys(rootObj).filter((k) => typeof rootObj[k] !== "function").length,
292
+ calcs: calcs.length,
293
+ paints: paints.length,
294
+ lists: groups.size,
295
+ writes,
296
+ };
297
+ app._lastPass = { trigger: opts.trigger || "refresh", writes };
298
+ if (app._debug) {
299
+ console.log(`sap pass · trigger: ${app._lastPass.trigger} · writes ${writes}/${paints.length}`);
300
+ }
301
+ }
302
+
303
+ function paintAttrName(p) {
304
+ if (p.kind === "text") return p.fmt ? "text:" + p.fmt : "text";
305
+ if (p.kind === "attr") return "attr:" + p.arg;
306
+ if (p.kind === "class") return "class:" + p.arg;
307
+ if (p.kind === "css") return "css:" + p.arg;
308
+ if (p.kind === "when") return (p.match === "hide" ? "hide-when:" : p.match === "show-not" ? "option-not:" : "show-when:") + p.field;
309
+ return p.kind;
310
+ }
311
+
312
+ // Walk self + ancestors for an element carrying `field`, matching optionVisibility's
313
+ // "inside a matching scope" semantics. notMode flips to "present but not a value".
314
+ function nearestMode(el, field, values, notMode) {
315
+ let cur = el;
316
+ while (cur && cur.nodeType === 1) {
317
+ if (cur.hasAttribute && cur.hasAttribute(field)) {
318
+ const inList = values.includes(cur.getAttribute(field));
319
+ if (notMode ? !inList : inList) return true;
320
+ }
321
+ cur = cur.parentElement;
322
+ }
323
+ return false;
324
+ }
325
+
326
+ function applyProjection(el, v) {
327
+ if (el === el.ownerDocument.activeElement) return 0; // user is typing here
328
+ const c = carrierFor(el);
329
+ const cur = c.read();
330
+ const nv = v == null ? "" : v;
331
+ if (String(cur) !== String(nv)) {
332
+ c.write(nv, { silent: true });
333
+ return 1;
334
+ }
335
+ return 0;
336
+ }
337
+
338
+ function paintOne(p, app) {
339
+ if (p.kind === "projection") {
340
+ return applyProjection(p.el, p.ctx.item ? p.ctx.item[p.field] : "");
341
+ }
342
+ if (p.kind === "when") {
343
+ return applyPaint(p, null); // visibility is read from the DOM, not an expression
344
+ }
345
+ const ctx = { state: p.ctx.state, item: p.ctx.item, el: p.el, root: p.ctx.root };
346
+ try {
347
+ const value = run(p.entry, ctx);
348
+ const n = applyPaint(p, value);
349
+ clearBeacon(p.el);
350
+ return n;
351
+ } catch (err) {
352
+ app.diag.error(err.sapCode === "E22" ? "E22" : "E24", p.el, {
353
+ attr: paintAttrName(p),
354
+ expr: p.entry && p.entry.src,
355
+ problem: String(err.message),
356
+ });
357
+ setBeacon(p.el, err.sapCode || "E24");
358
+ return 0;
359
+ }
360
+ }
361
+
362
+ function applyPaint(p, value) {
363
+ const el = p.el;
364
+ switch (p.kind) {
365
+ case "text": {
366
+ const s = p.fmt != null ? applyFormat(p.fmt, value) : value == null ? "" : String(value);
367
+ if (el.textContent !== s) {
368
+ el.textContent = s;
369
+ return 1;
370
+ }
371
+ return 0;
372
+ }
373
+ case "show": {
374
+ const hide = !value;
375
+ if (el.hidden !== hide) {
376
+ el.hidden = hide;
377
+ return 1;
378
+ }
379
+ return 0;
380
+ }
381
+ case "when": {
382
+ const ov = typeof window !== "undefined" && window.hyperclay && window.hyperclay.optionVisibility;
383
+ if (ov && ov._started) {
384
+ // The CSS floor owns visibility now; clear any hidden we set pre-floor.
385
+ if (el._sapWhen && el.hidden) { el.hidden = false; el._sapWhen = false; return 1; }
386
+ return 0;
387
+ }
388
+ let visible;
389
+ if (p.match === "hide") visible = !nearestMode(el, p.field, p.values, false);
390
+ else if (p.match === "show-not") visible = nearestMode(el, p.field, p.values, true);
391
+ else visible = nearestMode(el, p.field, p.values, false);
392
+ el._sapWhen = true;
393
+ const hide = !visible;
394
+ if (el.hidden !== hide) {
395
+ el.hidden = hide;
396
+ return 1;
397
+ }
398
+ return 0;
399
+ }
400
+ case "attr": {
401
+ const a = p.arg;
402
+ if (NATIVE_BOOLEAN_SET.has(a)) {
403
+ const has = el.hasAttribute(a);
404
+ if (value && !has) {
405
+ el.setAttribute(a, "");
406
+ return 1;
407
+ }
408
+ if (!value && has) {
409
+ el.removeAttribute(a);
410
+ return 1;
411
+ }
412
+ return 0;
413
+ }
414
+ const s = typeof value === "boolean" ? String(value) : value == null ? "" : String(value);
415
+ if (el.getAttribute(a) !== s) {
416
+ el.setAttribute(a, s);
417
+ return 1;
418
+ }
419
+ return 0;
420
+ }
421
+ case "class": {
422
+ const on = !!value;
423
+ if (el.classList.contains(p.arg) !== on) {
424
+ el.classList.toggle(p.arg, on);
425
+ return 1;
426
+ }
427
+ return 0;
428
+ }
429
+ case "css": {
430
+ const s = value == null ? "" : String(value);
431
+ if (el.style.getPropertyValue("--" + p.arg) !== s) {
432
+ el.style.setProperty("--" + p.arg, s);
433
+ return 1;
434
+ }
435
+ return 0;
436
+ }
437
+ default:
438
+ return 0;
439
+ }
440
+ }
441
+
442
+ function runEffect(e, app) {
443
+ try {
444
+ run(e.entry, { state: e.ctx.state, item: e.ctx.item, el: e.el, root: e.ctx.root });
445
+ clearBeacon(e.el);
446
+ } catch (err) {
447
+ app.diag.error("E24", e.el, { attr: "effect", expr: e.entry && e.entry.src, problem: String(err.message) });
448
+ setBeacon(e.el, "E24");
449
+ }
450
+ }
451
+
452
+ function runInvalid(iv) {
453
+ let msg = "";
454
+ try {
455
+ const v = run(iv.entry, { state: iv.ctx.state, item: iv.ctx.item, el: iv.el, root: iv.ctx.root });
456
+ msg = typeof v === "string" ? v : "";
457
+ } catch {
458
+ msg = "";
459
+ }
460
+ if (typeof iv.el.setCustomValidity === "function") iv.el.setCustomValidity(msg);
461
+ }
462
+
463
+ function depth(el) {
464
+ let d = 0;
465
+ let c = el;
466
+ while (c) {
467
+ if (c.hasAttribute && c.hasAttribute("sap")) break;
468
+ c = c.parentElement;
469
+ d++;
470
+ }
471
+ return d;
472
+ }
@@ -0,0 +1,78 @@
1
+ // Optional Hyperclay integration. Everything here degrades to a no-op when
2
+ // window.hyperclay is absent, so Sap runs standalone in any HTML file. When the
3
+ // platform is present, Sap rides its live-sync, undo, and autosave for free.
4
+
5
+ // Region-aware save guard. When the platform's region model is present, a control
6
+ // inside a no-save (or frozen) region must not write save-bytes the platform would
7
+ // strip anyway. The control stays fully live and reactive; only the serialization
8
+ // mirror is skipped. Standalone (no region API) returns false, so Sap mirrors as
9
+ // before — same "use the platform feature if present, degrade otherwise" pattern
10
+ // as the undo and optionVisibility handshakes.
11
+ export function regionSkipsSave(el) {
12
+ const region = typeof window !== "undefined" && window.hyperclay && window.hyperclay.region;
13
+ if (!region || typeof region.resolveRegionPolicy !== "function" || typeof region.skipForPolicy !== "function") {
14
+ return false;
15
+ }
16
+ return region.skipForPolicy(region.resolveRegionPolicy(el), undefined, ["no-save", "freeze"]);
17
+ }
18
+
19
+ // Themed confirmation dialog. When the platform's consent() is present (the
20
+ // `everything` preset bundles both dialogs and sap), a confirm-gated action uses
21
+ // it instead of the native window.confirm box. consent(msg, yesCallback) fires the
22
+ // callback synchronously on confirm and rejects on cancel, so Sap passes its action
23
+ // as the callback and never goes async. Returns null standalone, so Sap falls back
24
+ // to window.confirm — same degrade-otherwise pattern as the region/undo handshakes.
25
+ export function platformConsent() {
26
+ const h = typeof window !== "undefined" && window.hyperclay;
27
+ return h && typeof h.consent === "function" ? h.consent : null;
28
+ }
29
+
30
+ export function batch(label, fn) {
31
+ if (typeof fn !== "function") throw new Error("Sap.batch(label, fn): fn must be a function");
32
+ const u = typeof window !== "undefined" && window.hyperclay && window.hyperclay.undo;
33
+ if (u && u.flush) u.flush();
34
+ const r = fn();
35
+ if (r && typeof r.then === "function") throw new Error("Sap.batch fn must be synchronous (it returned a promise)");
36
+ if (u && u.commitCaptured) u.commitCaptured(label);
37
+ return r;
38
+ }
39
+
40
+ // Wire live-sync and undo to a synchronous refresh, and re-mount if a morph
41
+ // replaces the [sap] element wholesale (delegated listeners would die silently).
42
+ export function installBridges(runtime) {
43
+ if (typeof window === "undefined" || typeof document === "undefined") return;
44
+
45
+ const refreshConnected = () => {
46
+ let disconnected = false;
47
+ for (const app of runtime.apps()) {
48
+ if (app.root.isConnected) runtime.runNow(app, "platform");
49
+ else disconnected = true;
50
+ }
51
+ // A morph may have replaced a root wholesale: drop the dead app records (so they
52
+ // don't pile up in order) and re-mount to pick up the replacement.
53
+ if (disconnected) {
54
+ runtime.pruneDisconnected();
55
+ runtime.remountIfPresent();
56
+ }
57
+ };
58
+
59
+ // M2: livesync-applied must refresh synchronously inside the pause window.
60
+ document.addEventListener("hyperclay:livesync-applied", refreshConnected);
61
+
62
+ // Undo/redo replay attributes with no events; re-derive after.
63
+ const undo = window.hyperclay && window.hyperclay.undo;
64
+ if (undo && typeof undo.on === "function") {
65
+ undo.on("undo", refreshConnected);
66
+ undo.on("redo", refreshConnected);
67
+ } else {
68
+ // Lazy handshake: the platform may load after us. The hub dispatches
69
+ // hyperclay:mutation-ready non-bubbling on `document`, so listen there (not window).
70
+ document.addEventListener("hyperclay:mutation-ready", () => {
71
+ const u = window.hyperclay && window.hyperclay.undo;
72
+ if (u && typeof u.on === "function") {
73
+ u.on("undo", refreshConnected);
74
+ u.on("redo", refreshConnected);
75
+ }
76
+ });
77
+ }
78
+ }