@lark-apaas/coding-registry-buildless 0.1.20 → 0.1.22

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.
@@ -20,9 +20,82 @@
20
20
  // `progress` via the useSprite() hook. Use Easing + interpolate()/animate()
21
21
  // for tweens; TextSprite / ImageSprite / RectSprite have built-in entry/exit.
22
22
  // Build YOUR scenes by composing Sprites inside a Stage.
23
+ //
24
+ // window.__miaodaMirror (defined below) is Magic-Share sync plumbing between
25
+ // this component and the miaoda host — internal; do not call or remove it.
23
26
  /* END USAGE */
24
27
  // ─────────────────────────────────────────────────────────────────────────────
25
28
 
29
+ // ── Mirror sync singleton ──────────────────────────────────────────────────
30
+ // Shared by every buildless script on the page (first one to load defines
31
+ // it). Inlined per script rather than shipped as its own registry file:
32
+ // call sites use the global bare, so inlining is what guarantees
33
+ // definition-before-use — a separate copy-in file would add a whole "SDK
34
+ // copied but <script> tag missing" failure mode. Keep this block identical
35
+ // across deck-stage.js / design-canvas.jsx / animations.jsx — same
36
+ // convention as the mobile-UA regex. Two halves of
37
+ // the 妙享 (Magic Share) mirror protocol:
38
+ // emit(msg, opts) — stamps msg.__mirror so the host relay treats the
39
+ // message as a broadcastable UI-state snapshot;
40
+ // unstamped messages are never mirrored. opts.key is
41
+ // the sync channel (maps to the share SDK's
42
+ // actionType): late joiners receive only the LATEST
43
+ // snapshot per key, so states that overwrite each
44
+ // other must share one key. opts.throttle is the
45
+ // suggested host-side coalescing interval in ms —
46
+ // snapshots make dropped intermediate frames
47
+ // lossless, so hint it on any continuous gesture.
48
+ // onApply(type, fn) — replay entry for a message type this script itself
49
+ // emits. The host sends {type:'miaoda:mirror:apply',
50
+ // uuid, message, latencyMs} and needs
51
+ // {type:'miaoda:mirror:apply-result', uuid, result}
52
+ // back to drive the SDK's retry: result.type
53
+ // 'failed' means retry (use it for transient
54
+ // not-ready states), 'ignored' is terminal (not
55
+ // applicable / bad payload), 'applied' is success.
56
+ // Unregistered types stay silent on purpose —
57
+ // another script on the page may own them, and the
58
+ // host's timeout is the backstop.
59
+ if (!window.__miaodaMirror) {
60
+ const appliers = new Map(); // message.type -> Set<fn>
61
+ window.__miaodaMirror = {
62
+ emit(msg, opts) {
63
+ try { window.parent.postMessage(Object.assign({}, msg, { __mirror: Object.assign({ v: 1 }, opts) }), '*'); } catch (e) {}
64
+ },
65
+ onApply(type, fn) {
66
+ if (!appliers.has(type)) appliers.set(type, new Set());
67
+ appliers.get(type).add(fn);
68
+ return () => { const s = appliers.get(type); if (s) s.delete(fn); };
69
+ },
70
+ };
71
+ window.addEventListener('message', async (e) => {
72
+ // Replays are accepted from the host frame only — generated pages embed
73
+ // arbitrary third-party iframes, any of which could otherwise forge an
74
+ // apply and drive state (plus its persisted side effects).
75
+ if (e.source !== window.parent) return;
76
+ const d = e.data;
77
+ if (!d || d.type !== 'miaoda:mirror:apply' || !d.message || !d.message.type) return;
78
+ const fns = appliers.get(d.message.type);
79
+ if (!fns || !fns.size) return;
80
+ // Merge across handlers (multi-instance case): one success is enough.
81
+ // Instances share a channel, so a sibling that can't apply yet (a lazily
82
+ // slotted second deck returning 'failed') must not poison an applied
83
+ // result into an SDK retry storm of no-ops — 'failed' only stands when
84
+ // nobody applied. Every handler still runs (no short-circuit) so each
85
+ // sibling instance gets the state.
86
+ let result = { type: 'ignored', reason: 'no applier result' };
87
+ for (const fn of Array.from(fns)) {
88
+ let r;
89
+ try { r = await fn(d.message, { latencyMs: d.latencyMs, uuid: d.uuid }); }
90
+ catch (err) { r = { type: 'failed', reason: String((err && err.message) || err) }; }
91
+ if (!r) continue;
92
+ if (r.type === 'applied') result = r;
93
+ else if (result.type !== 'applied' && (r.type === 'failed' || result.type !== 'failed')) result = r;
94
+ }
95
+ try { window.parent.postMessage({ type: 'miaoda:mirror:apply-result', uuid: d.uuid, result }, '*'); } catch (e2) {}
96
+ });
97
+ }
98
+
26
99
  // ── Easing functions (hand-rolled, Popmotion-style) ─────────────────────────
27
100
  // All easings take t ∈ [0,1] and return eased t ∈ [0,1] (may overshoot for back/elastic).
28
101
  const Easing = {
@@ -346,6 +419,13 @@ function Stage({
346
419
  // ~1s as a fallback — paused, with the playback bar hidden, so the product
347
420
  // thumbnail is a deterministic frame of the first composed scene.
348
421
  const captureMode = typeof location !== 'undefined' && /[?&]thumbnail=/.test(location.search || '');
422
+ // Mirror silence additionally covers _snthumb frames (deck's presenter
423
+ // popup pins LIVE product iframes per slide — a stage embedded in such a
424
+ // slide is on the presenter's side, so the role gate can't drop its
425
+ // stamped emits). Kept separate from captureMode on purpose: the poster
426
+ // freeze stays keyed to ?thumbnail= only, so _snthumb thumbnails still
427
+ // look like the live slide.
428
+ const captureFrame = captureMode || (typeof location !== 'undefined' && /[?&]_snthumb=/.test(location.search || ''));
349
429
 
350
430
  const [time, setTime] = React.useState(() => {
351
431
  if (captureMode) return clamp(poster == null ? 1 : poster, 0, duration);
@@ -362,7 +442,11 @@ function Stage({
362
442
  const rafRef = React.useRef(null);
363
443
  const lastTsRef = React.useRef(null);
364
444
 
365
- // Persist playhead
445
+ // Persist playhead. A Magic-Share-replayed time persists too, on purpose:
446
+ // unlike the rail/viewport preferences this IS the stage's current
447
+ // position, and while playing the next local rAF tick would re-persist it
448
+ // anyway (a guard would only reach the paused case, whose landing spot is
449
+ // equally this stage's real position).
366
450
  React.useEffect(() => {
367
451
  try { localStorage.setItem(persistKey + ':t', String(time)); } catch {}
368
452
  }, [time, persistKey]);
@@ -402,7 +486,51 @@ function Stage({
402
486
  // The playback bar is a *sibling* of this node, so it never lands in a capture
403
487
  // and needs no hiding.
404
488
  const bridgeStateRef = React.useRef(null);
405
- bridgeStateRef.current = { time, duration, fps, width, height, setTime, setPlaying };
489
+ bridgeStateRef.current = { time, duration, fps, width, height, playing, loop, setTime, setPlaying };
490
+
491
+ // Magic Share: playhead sync fires only from explicit interactions (playbar,
492
+ // keyboard) plus the coarse keepalive below — never from the rAF tick, which
493
+ // would broadcast at frame rate for nothing (each message is already a full
494
+ // snapshot). Scrub-drags still call this per mousemove, so the actual post
495
+ // is trailing-throttled locally (same rule as design-canvas's viewport:
496
+ // no per-event cross-frame posts). Capture iframes are frozen stills and
497
+ // the host export drives the bridge programmatically, so captureMode
498
+ // stays silent. ts rides along for the HOST, which derives from it the
499
+ // latencyMs it hands back to replays.
500
+ const emitTRef = React.useRef(0);
501
+ React.useEffect(() => () => clearTimeout(emitTRef.current), []);
502
+ const emitPlayhead = () => {
503
+ if (captureFrame || emitTRef.current) return;
504
+ emitTRef.current = setTimeout(() => {
505
+ emitTRef.current = 0;
506
+ // Read at fire time, not call time — by 80ms the interaction's state
507
+ // has committed and bridgeStateRef is fresh (same live-ref pattern as
508
+ // design-canvas's tf trailing timer).
509
+ const cur = bridgeStateRef.current;
510
+ window.__miaodaMirror.emit(
511
+ { type: 'miaoda:anim:playhead-changed', t: cur.time, playing: cur.playing, ts: Date.now() },
512
+ { key: 'anim:playhead', throttle: 60 },
513
+ );
514
+ }, 80);
515
+ };
516
+
517
+ // Coarse drift correction: followers free-run their own rAF between
518
+ // messages, so while playing re-anchor them every few seconds. 3s is
519
+ // coarse enough to be free and tight enough that free-run drift stays
520
+ // imperceptible. Quiesced while replays are flowing in: a replayed
521
+ // playing:true arms this effect on followers too, and their competing
522
+ // snapshots are otherwise only stopped by the host's presenter role gate —
523
+ // don't lean on a single layer across presenter-handoff windows.
524
+ const lastApplyTsRef = React.useRef(0);
525
+ React.useEffect(() => {
526
+ if (!playing || captureFrame) return;
527
+ const iv = setInterval(() => {
528
+ if (Date.now() - lastApplyTsRef.current < 3000) return;
529
+ emitPlayhead();
530
+ }, 3000);
531
+ return () => clearInterval(iv);
532
+ }, [playing]);
533
+
406
534
  React.useEffect(() => {
407
535
  const s = bridgeStateRef;
408
536
  const bridge = {
@@ -420,7 +548,42 @@ function Stage({
420
548
  // Announce to the host that this product IS an animation scene, so it can
421
549
  // reveal the export entry (mirrors deck-stage's miaoda:deck:available).
422
550
  try { window.parent.postMessage({ type: 'miaoda:anim:available' }, '*'); } catch (e) {}
423
- return () => { if (window.__animStage === bridge) delete window.__animStage; };
551
+ // Magic Share replay — a thin adapter over the same setTime/setPlaying the
552
+ // export bridge exposes. The setters themselves never emit, but
553
+ // setPlaying(true) does arm the keepalive effect, whose re-emit — like
554
+ // every follower emit — is dropped host-side by the presenter role gate.
555
+ const offApply = window.__miaodaMirror.onApply('miaoda:anim:playhead-changed', (m, ctx) => {
556
+ // Capture iframes are frozen stills — a forwarded replay's playing:true
557
+ // would unfreeze the poster mid-screenshot. The emit side is already
558
+ // silent under captureMode; keep the apply side symmetric.
559
+ if (captureFrame) return { type: 'ignored', reason: 'capture iframe' };
560
+ // isFinite, not typeof: NaN would sail through clamp() (Math.min/max
561
+ // propagate it) into setTime and persisted state.
562
+ if (!Number.isFinite(m.t)) return { type: 'ignored', reason: 'no t' };
563
+ // Kill any pending local-emit timer — it reads state at fire time and
564
+ // would re-broadcast the just-replayed values as this end's own (same
565
+ // rule as design-canvas's viewport applier).
566
+ clearTimeout(emitTRef.current);
567
+ emitTRef.current = 0;
568
+ const st = s.current;
569
+ // While playing, advance the replayed time by the transport delay so
570
+ // both playheads aim at the same wall-clock frame. A looping stage
571
+ // wraps rather than clamps — a snapshot sent near the seam would
572
+ // otherwise pin the follower on the last frame until its own rAF
573
+ // wraps.
574
+ const lat = m.playing && ctx && Number.isFinite(ctx.latencyMs) ? ctx.latencyMs / 1000 : 0;
575
+ const nt = m.t + lat;
576
+ // Double-mod wrap: JS % keeps the sign, and a (forged/skewed) negative
577
+ // t would otherwise render a negative playhead.
578
+ st.setTime(m.playing && st.loop && st.duration > 0 ? ((nt % st.duration) + st.duration) % st.duration : clamp(nt, 0, st.duration));
579
+ st.setPlaying(!!m.playing);
580
+ lastApplyTsRef.current = Date.now();
581
+ return { type: 'applied' };
582
+ });
583
+ return () => {
584
+ offApply();
585
+ if (window.__animStage === bridge) delete window.__animStage;
586
+ };
424
587
  }, []);
425
588
 
426
589
  // Animation loop
@@ -457,12 +620,16 @@ function Stage({
457
620
  if (e.code === 'Space') {
458
621
  e.preventDefault();
459
622
  setPlaying(p => !p);
623
+ emitPlayhead();
460
624
  } else if (e.code === 'ArrowLeft') {
461
625
  setTime(t => clamp(t - (e.shiftKey ? 1 : 0.1), 0, duration));
626
+ emitPlayhead();
462
627
  } else if (e.code === 'ArrowRight') {
463
628
  setTime(t => clamp(t + (e.shiftKey ? 1 : 0.1), 0, duration));
629
+ emitPlayhead();
464
630
  } else if (e.key === '0' || e.code === 'Home') {
465
631
  setTime(0);
632
+ emitPlayhead();
466
633
  }
467
634
  };
468
635
  window.addEventListener('keydown', onKey);
@@ -519,9 +686,9 @@ function Stage({
519
686
  time={time}
520
687
  duration={duration}
521
688
  playing={playing}
522
- onPlayPause={() => setPlaying(p => !p)}
523
- onReset={() => { setTime(0); }}
524
- onSeek={(t) => setTime(t)}
689
+ onPlayPause={() => { setPlaying(p => !p); emitPlayhead(); }}
690
+ onReset={() => { setTime(0); emitPlayhead(); }}
691
+ onSeek={(t) => { setTime(t); emitPlayhead(); }}
525
692
  />
526
693
  )}
527
694
  </div>
@@ -32,6 +32,9 @@
32
32
  // On mobile devices the canvas automatically becomes a browse-only surface
33
33
  // (touch pan/zoom, editing disabled). This needs NO authoring changes:
34
34
  // never add custom touch handling or mobile-specific layouts.
35
+ //
36
+ // window.__miaodaMirror (defined below) is Magic-Share sync plumbing between
37
+ // this component and the miaoda host — internal; do not call or remove it.
35
38
  /* END USAGE */
36
39
 
37
40
  const DC = {
@@ -54,6 +57,82 @@ const dcIsMobile = typeof navigator !== 'undefined' &&
54
57
  (DC_MOBILE_UA_RE.test(navigator.userAgent) ||
55
58
  (/iPad|Tab|Tablet/i.test(navigator.userAgent) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 1));
56
59
 
60
+ // Capture iframes (?thumbnail= product screenshots) sit out Magic Share in
61
+ // BOTH directions — same rule as deck-stage's _snthumb and animations'
62
+ // captureMode: their emits would carry a throwaway instance's state, and a
63
+ // (mis)forwarded replay could reframe the canvas mid-shot.
64
+ const dcCaptureMode = typeof location !== 'undefined' && /[?&](_snthumb|thumbnail)=/.test(location.search || '');
65
+
66
+ // ── Mirror sync singleton ──────────────────────────────────────────────────
67
+ // Shared by every buildless script on the page (first one to load defines
68
+ // it). Inlined per script rather than shipped as its own registry file:
69
+ // call sites use the global bare, so inlining is what guarantees
70
+ // definition-before-use — a separate copy-in file would add a whole "SDK
71
+ // copied but <script> tag missing" failure mode. Keep this block identical
72
+ // across deck-stage.js / design-canvas.jsx / animations.jsx — same
73
+ // convention as the mobile-UA regex. Two halves of
74
+ // the 妙享 (Magic Share) mirror protocol:
75
+ // emit(msg, opts) — stamps msg.__mirror so the host relay treats the
76
+ // message as a broadcastable UI-state snapshot;
77
+ // unstamped messages are never mirrored. opts.key is
78
+ // the sync channel (maps to the share SDK's
79
+ // actionType): late joiners receive only the LATEST
80
+ // snapshot per key, so states that overwrite each
81
+ // other must share one key. opts.throttle is the
82
+ // suggested host-side coalescing interval in ms —
83
+ // snapshots make dropped intermediate frames
84
+ // lossless, so hint it on any continuous gesture.
85
+ // onApply(type, fn) — replay entry for a message type this script itself
86
+ // emits. The host sends {type:'miaoda:mirror:apply',
87
+ // uuid, message, latencyMs} and needs
88
+ // {type:'miaoda:mirror:apply-result', uuid, result}
89
+ // back to drive the SDK's retry: result.type
90
+ // 'failed' means retry (use it for transient
91
+ // not-ready states), 'ignored' is terminal (not
92
+ // applicable / bad payload), 'applied' is success.
93
+ // Unregistered types stay silent on purpose —
94
+ // another script on the page may own them, and the
95
+ // host's timeout is the backstop.
96
+ if (!window.__miaodaMirror) {
97
+ const appliers = new Map(); // message.type -> Set<fn>
98
+ window.__miaodaMirror = {
99
+ emit(msg, opts) {
100
+ try { window.parent.postMessage(Object.assign({}, msg, { __mirror: Object.assign({ v: 1 }, opts) }), '*'); } catch (e) {}
101
+ },
102
+ onApply(type, fn) {
103
+ if (!appliers.has(type)) appliers.set(type, new Set());
104
+ appliers.get(type).add(fn);
105
+ return () => { const s = appliers.get(type); if (s) s.delete(fn); };
106
+ },
107
+ };
108
+ window.addEventListener('message', async (e) => {
109
+ // Replays are accepted from the host frame only — generated pages embed
110
+ // arbitrary third-party iframes, any of which could otherwise forge an
111
+ // apply and drive state (plus its persisted side effects).
112
+ if (e.source !== window.parent) return;
113
+ const d = e.data;
114
+ if (!d || d.type !== 'miaoda:mirror:apply' || !d.message || !d.message.type) return;
115
+ const fns = appliers.get(d.message.type);
116
+ if (!fns || !fns.size) return;
117
+ // Merge across handlers (multi-instance case): one success is enough.
118
+ // Instances share a channel, so a sibling that can't apply yet (a lazily
119
+ // slotted second deck returning 'failed') must not poison an applied
120
+ // result into an SDK retry storm of no-ops — 'failed' only stands when
121
+ // nobody applied. Every handler still runs (no short-circuit) so each
122
+ // sibling instance gets the state.
123
+ let result = { type: 'ignored', reason: 'no applier result' };
124
+ for (const fn of Array.from(fns)) {
125
+ let r;
126
+ try { r = await fn(d.message, { latencyMs: d.latencyMs, uuid: d.uuid }); }
127
+ catch (err) { r = { type: 'failed', reason: String((err && err.message) || err) }; }
128
+ if (!r) continue;
129
+ if (r.type === 'applied') result = r;
130
+ else if (result.type !== 'applied' && (r.type === 'failed' || result.type !== 'failed')) result = r;
131
+ }
132
+ try { window.parent.postMessage({ type: 'miaoda:mirror:apply-result', uuid: d.uuid, result }, '*'); } catch (e2) {}
133
+ });
134
+ }
135
+
57
136
  // One-time CSS injection (classes are dc-prefixed so they don't collide with
58
137
  // the hosted design's own styles).
59
138
  if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
@@ -228,10 +307,23 @@ function dcFlatten(children) {
228
307
  // ─────────────────────────────────────────────────────────────
229
308
  const DC_STATE_FILE = '.design-canvas.state.json';
230
309
 
231
- // Persist a sidecar file back to the host.
232
- function miaodaWriteFile(path, content) {
310
+ // Persist a sidecar file back to the host. writeContext is an OWNERSHIP
311
+ // declaration for the host's release pipeline (not a sync receipt — the
312
+ // product can't know whether a Magic Share sync actually reached anyone):
313
+ // {kind:'mirror-managed', key} says this file's full content is the same
314
+ // state a mirror channel carries, so the host may skip reloading followers
315
+ // in FOLLOW mode for a release made purely of such writes — they already
316
+ // have the state via mirror, and channel guarantees (retry / per-key latest
317
+ // on rejoin / re-seed after reload / the file itself on next cold load)
318
+ // cover every miss. Persistence and deploy proceed unchanged — this is NOT
319
+ // a notify:false. Only declare it where emit and write are fed by the very
320
+ // same snapshot — an untagged write reloads as before, which is the
321
+ // fail-safe for states that are NOT mirrored.
322
+ function miaodaWriteFile(path, content, writeContext) {
233
323
  try {
234
- window.parent.postMessage({ type: 'miaoda:bridge:write-file', path: path, content: content }, '*');
324
+ const msg = { type: 'miaoda:bridge:write-file', path: path, content: content };
325
+ if (writeContext) msg.writeContext = writeContext;
326
+ window.parent.postMessage(msg, '*');
235
327
  } catch (e) {
236
328
  /* no parent — ignore */
237
329
  }
@@ -248,13 +340,30 @@ function DesignCanvas({ children, minScale, maxScale, style }) {
248
340
  const [ready, setReady] = React.useState(false);
249
341
  const didRead = React.useRef(false);
250
342
  const skipNextWrite = React.useRef(false);
343
+ // Magic Share echo guards, one per mirrored state: a remote apply must land
344
+ // locally without re-broadcasting as if the user did it here.
345
+ const skipNextMirrorEmit = React.useRef(false);
346
+ const skipNextFocusEmit = React.useRef(false);
347
+ // Current focus, readable synchronously by the applier — an apply carrying
348
+ // the value we already hold must NOT set the skip flag: same-value setState
349
+ // re-renders but the [state.focus] effect bails out (primitive dep), so the
350
+ // flag would strand and swallow the next real focus broadcast.
351
+ const focusRef = React.useRef(state.focus);
352
+ focusRef.current = state.focus;
353
+
354
+ // Set once a Magic Share structure replay has landed. A late joiner's
355
+ // replay races the sidecar fetch below — the replay is the fresher truth
356
+ // (it IS the presenter's current arrangement, and it was already answered
357
+ // with 'applied' so the SDK won't resend), so a slower fetch must not
358
+ // clobber it with this end's stale sidecar.
359
+ const mirrorSeeded = React.useRef(false);
251
360
 
252
361
  React.useEffect(() => {
253
362
  let off = false;
254
363
  fetch('./' + DC_STATE_FILE)
255
364
  .then((r) => (r.ok ? r.json() : null))
256
365
  .then((saved) => {
257
- if (off || !saved || !saved.sections) return;
366
+ if (off || mirrorSeeded.current || !saved || !saved.sections) return;
258
367
  skipNextWrite.current = true;
259
368
  setState((s) => ({ ...s, sections: saved.sections }));
260
369
  })
@@ -266,13 +375,78 @@ function DesignCanvas({ children, minScale, maxScale, style }) {
266
375
 
267
376
  React.useEffect(() => {
268
377
  if (!didRead.current) return;
378
+ // Magic Share: snapshot the arrangement whenever it settles — including
379
+ // the sidecar hydration (the skipNextWrite path), which is exactly the
380
+ // baseline a late-joining follower needs. Only a remote apply must not
381
+ // re-broadcast.
382
+ if (skipNextMirrorEmit.current) skipNextMirrorEmit.current = false;
383
+ else if (!dcCaptureMode) window.__miaodaMirror.emit({ type: 'miaoda:canvas:structure-changed', sections: state.sections }, { key: 'canvas:structure' });
269
384
  if (skipNextWrite.current) { skipNextWrite.current = false; return; }
270
385
  const t = setTimeout(() => {
271
- miaodaWriteFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
386
+ // mirror-managed precondition: this write persists the same snapshot
387
+ // the emit above carried on canvas:structure — keep both fed by the
388
+ // same state if either moves.
389
+ miaodaWriteFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections }),
390
+ { kind: 'mirror-managed', key: 'canvas:structure' }).catch(() => {});
272
391
  }, 250);
273
392
  return () => clearTimeout(t);
274
393
  }, [state.sections]);
275
394
 
395
+ // Magic Share: focus is ephemeral view state — mirror it as its own key so
396
+ // a follower enters/exits the focus overlay in step with the presenter.
397
+ React.useEffect(() => {
398
+ if (dcCaptureMode) return;
399
+ if (skipNextFocusEmit.current) { skipNextFocusEmit.current = false; return; }
400
+ window.__miaodaMirror.emit({ type: 'miaoda:canvas:focus-changed', focus: state.focus }, { key: 'canvas:focus' });
401
+ }, [state.focus]);
402
+
403
+ // Magic Share replay entries — consume the same message types this
404
+ // component emits, so the emit↔apply contract stays in this one file.
405
+ React.useEffect(() => {
406
+ if (dcCaptureMode) return;
407
+ const offs = [
408
+ window.__miaodaMirror.onApply('miaoda:canvas:structure-changed', (m) => {
409
+ // Per-field shape check, not just typeof: a malformed snapshot (e.g.
410
+ // order that isn't an array) would pass setState here, be answered
411
+ // 'applied', and only then blow up the whole React tree in render —
412
+ // an unrecoverable failure the retry protocol can't see.
413
+ if (!m.sections || typeof m.sections !== 'object') return { type: 'ignored', reason: 'no sections' };
414
+ for (const sec of Object.values(m.sections)) {
415
+ if (!sec || typeof sec !== 'object' ||
416
+ (sec.order !== undefined && !Array.isArray(sec.order)) ||
417
+ (sec.hidden !== undefined && !Array.isArray(sec.hidden)) ||
418
+ (sec.labels !== undefined && (typeof sec.labels !== 'object' || sec.labels === null))) {
419
+ return { type: 'ignored', reason: 'malformed sections' };
420
+ }
421
+ }
422
+ skipNextMirrorEmit.current = true;
423
+ // A replayed arrangement is the presenter's state, not a local edit —
424
+ // never persist it back through this end's write bridge.
425
+ skipNextWrite.current = true;
426
+ // Marking didRead lets the sections effect run NOW and consume both
427
+ // flags — before hydration settles they would otherwise strand on the
428
+ // effect's !didRead early-return and swallow a later real emit/write.
429
+ mirrorSeeded.current = true;
430
+ didRead.current = true;
431
+ setState((s) => ({ ...s, sections: m.sections }));
432
+ return { type: 'applied' };
433
+ }),
434
+ window.__miaodaMirror.onApply('miaoda:canvas:focus-changed', (m) => {
435
+ const next = typeof m.focus === 'string' ? m.focus : null;
436
+ // Same value → nothing will change and the [state.focus] effect won't
437
+ // re-run; setting the skip flag would strand it (see focusRef note).
438
+ if (next === focusRef.current) return { type: 'applied' };
439
+ // Sync the ref now, not at next render — a second apply landing in
440
+ // the same tick would otherwise compare against the stale value.
441
+ focusRef.current = next;
442
+ skipNextFocusEmit.current = true;
443
+ setState((s) => ({ ...s, focus: next }));
444
+ return { type: 'applied' };
445
+ }),
446
+ ];
447
+ return () => offs.forEach((off) => off());
448
+ }, []);
449
+
276
450
  // Build registries synchronously from children so FocusOverlay can read
277
451
  // them in the same render. Fragments are flattened; wrapping in other
278
452
  // elements still opts out of focus/reorder.
@@ -466,6 +640,16 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
466
640
  const saveT = React.useRef(0);
467
641
 
468
642
  const lastPostedScale = React.useRef();
643
+ // Magic Share: trailing-throttle timer for the viewport snapshot, plus a
644
+ // guard so a replayed viewport doesn't schedule its own re-broadcast.
645
+ const mirrorEmitT = React.useRef(0);
646
+ const applyingRemoteView = React.useRef(false);
647
+ // True while tf holds a REMOTE framing (last write came from a replay,
648
+ // no local gesture since). Gates every localStorage persist path — the
649
+ // 200ms saveT and the pagehide/unmount flush both read tf at fire time,
650
+ // so guarding only the scheduling call would still leak the presenter's
651
+ // framing into this viewer's saved viewport.
652
+ const viewIsRemote = React.useRef(false);
469
653
  // Imperative handle for the on-canvas zoom control to zoom around the
470
654
  // viewport centre (same path as the host's set-zoom message). Assigned in
471
655
  // the pointer/wheel effect below where zoomAt is in scope.
@@ -500,12 +684,36 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
500
684
  // ticks leave scale unchanged — skip the cross-frame post for those.
501
685
  if (lastPostedScale.current !== scale) {
502
686
  lastPostedScale.current = scale;
503
- window.parent.postMessage({ type: 'miaoda:canvas:zoom', scale }, '*');
687
+ try { window.parent.postMessage({ type: 'miaoda:canvas:zoom', scale }, '*'); } catch (e) { /* no parent — ignore */ }
688
+ }
689
+ // A replayed viewport is the presenter's framing, not this viewer's — it
690
+ // must not overwrite the viewport this end will restore when opening the
691
+ // canvas on its own later. Any local gesture re-owns the view (and its
692
+ // persistence); apply() is the single fan-out for both, so this branch
693
+ // is the one place that distinction needs to live.
694
+ if (applyingRemoteView.current) {
695
+ viewIsRemote.current = true;
696
+ } else {
697
+ viewIsRemote.current = false;
698
+ clearTimeout(saveT.current);
699
+ saveT.current = setTimeout(() => {
700
+ try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
701
+ }, 200);
702
+ }
703
+ // Magic Share: mirror the full viewport (scale AND pan) as a snapshot.
704
+ // Trailing-throttled at 120ms rather than per-apply — apply() runs at
705
+ // pointer rate, and this file deliberately avoids per-frame cross-frame
706
+ // posts (see the zoom guard above); 120ms keeps a follower tracking a
707
+ // continuous pan without that cost.
708
+ if (!dcCaptureMode && !applyingRemoteView.current && !mirrorEmitT.current) {
709
+ mirrorEmitT.current = setTimeout(() => {
710
+ mirrorEmitT.current = 0;
711
+ window.__miaodaMirror.emit(
712
+ { type: 'miaoda:canvas:viewport-changed', x: tf.current.x, y: tf.current.y, scale: tf.current.scale },
713
+ { key: 'canvas:viewport', throttle: 60 },
714
+ );
715
+ }, 120);
504
716
  }
505
- clearTimeout(saveT.current);
506
- saveT.current = setTimeout(() => {
507
- try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
508
- }, 200);
509
717
  }, [tfKey]);
510
718
 
511
719
  // Whether the mount-time restore found a saved viewport — when it did, the
@@ -516,6 +724,9 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
516
724
  React.useLayoutEffect(() => {
517
725
  const flush = () => {
518
726
  clearTimeout(saveT.current);
727
+ // Unload/unmount with a replayed framing on screen: persist nothing —
728
+ // the saved viewport must stay this viewer's own.
729
+ if (viewIsRemote.current) return;
519
730
  try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
520
731
  };
521
732
  try {
@@ -846,16 +1057,48 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
846
1057
  // images/fonts is after our mount-time announce, so re-announce.
847
1058
  // Clear the pan-tick guard so apply() re-posts the current scale
848
1059
  // even if it's unchanged — the host just reset dcScale to 1.
849
- window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*');
1060
+ try { window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*'); } catch (e) { /* no parent — ignore */ }
850
1061
  // Re-announce the upgrade capability too — the host's readyGen reset
851
1062
  // (fires on iframe 'load', which for image/font-heavy canvases lands
852
1063
  // after the mount-time announce) drops prior state, so re-declare here.
853
- window.parent.postMessage({ type: 'miaoda:upgrade:available', kind: 'design-canvas' }, '*');
1064
+ try { window.parent.postMessage({ type: 'miaoda:upgrade:available', kind: 'design-canvas' }, '*'); } catch (e) { /* no parent — ignore */ }
854
1065
  lastPostedScale.current = undefined;
1066
+ // The re-announce apply() must not change who owns the view: a
1067
+ // viewport seed can land BEFORE the iframe's native 'load' fires this
1068
+ // probe, and a plain local apply() would flip viewIsRemote and
1069
+ // persist the presenter's framing — the exact leak viewIsRemote
1070
+ // exists to stop. The zoom % re-post is ungated either way.
1071
+ applyingRemoteView.current = viewIsRemote.current;
855
1072
  apply();
1073
+ applyingRemoteView.current = false;
856
1074
  }
857
1075
  };
858
1076
  window.addEventListener('message', onHostMsg);
1077
+ // Magic Share replay entry for the viewport — reuses the local apply()
1078
+ // pipeline (DOM transform, --dc-inv-zoom, host % readout); the
1079
+ // applyingRemoteView guard keeps the replay from re-broadcasting or
1080
+ // persisting the presenter's framing.
1081
+ const offViewportApply = dcCaptureMode ? () => {} : window.__miaodaMirror.onApply('miaoda:canvas:viewport-changed', (m) => {
1082
+ if (![m.x, m.y, m.scale].every(Number.isFinite)) return { type: 'ignored', reason: 'bad snapshot' };
1083
+ // Kill any pending local-emit timer: it was armed before this replay
1084
+ // and would otherwise fire 120ms later with tf already holding the
1085
+ // REMOTE state — echoing the presenter's viewport back as our own.
1086
+ clearTimeout(mirrorEmitT.current);
1087
+ mirrorEmitT.current = 0;
1088
+ // Same staleness for the persist timer: scheduled by a local gesture,
1089
+ // but it reads tf at fire time and would save the remote framing.
1090
+ clearTimeout(saveT.current);
1091
+ // A replayed framing IS this canvas's framing now — mark the first-visit
1092
+ // fit as satisfied, or its layout effect (armed whenever a fresh viewer
1093
+ // has no saved viewport) would re-measure and override the presenter's
1094
+ // viewport as soon as content mounts.
1095
+ didFit.current = true;
1096
+ applyingRemoteView.current = true;
1097
+ tf.current = { x: m.x, y: m.y, scale: Math.min(maxScale, Math.max(minScale, m.scale)) };
1098
+ apply();
1099
+ applyingRemoteView.current = false;
1100
+ return { type: 'applied' };
1101
+ });
859
1102
  // Announce canvas mode so the host toolbar proxies its % control here
860
1103
  // instead of scaling the iframe element (which would just shrink the
861
1104
  // viewport window of an infinite canvas). The apply() that follows emits
@@ -863,13 +1106,13 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
863
1106
  // lastPostedScale reset mirrors the miaoda:canvas:probe handler: the layout
864
1107
  // effect's restore-path apply() may already have posted the restored
865
1108
  // scale (before miaoda:canvas:present), so clear the guard to re-post it in order.
866
- window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*');
1109
+ try { window.parent.postMessage({ type: 'miaoda:canvas:present' }, '*'); } catch (e) { /* no parent — ignore */ }
867
1110
  // Generic "upgrade" capability signal — announce that this artifact offers
868
1111
  // an upgrade entry so the host can show/hide its upgrade button. Decoupled
869
1112
  // from the canvas zoom-proxy protocol above; any product may post the same
870
1113
  // message. Listener (onHostMsg) is already registered above → announce-after-
871
1114
  // register order holds. No client-side behavior here.
872
- window.parent.postMessage({ type: 'miaoda:upgrade:available', kind: 'design-canvas' }, '*');
1115
+ try { window.parent.postMessage({ type: 'miaoda:upgrade:available', kind: 'design-canvas' }, '*'); } catch (e) { /* no parent — ignore */ }
873
1116
  lastPostedScale.current = undefined;
874
1117
  apply();
875
1118
 
@@ -889,6 +1132,9 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
889
1132
  vp.addEventListener('pointercancel', pu);
890
1133
  return () => {
891
1134
  window.removeEventListener('message', onHostMsg);
1135
+ offViewportApply();
1136
+ clearTimeout(mirrorEmitT.current);
1137
+ mirrorEmitT.current = 0;
892
1138
  vp.removeEventListener('wheel', onWheel);
893
1139
  vp.removeEventListener('gesturestart', onGestureStart);
894
1140
  vp.removeEventListener('gesturechange', onGestureChange);
@@ -190,7 +190,7 @@ function useTweaks(defaults) {
190
190
  const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
191
191
  ? keyOrEdits : { [keyOrEdits]: val };
192
192
  setValues((prev) => ({ ...prev, ...edits }));
193
- window.parent.postMessage({ type: 'miaoda:tweaks:set-keys', edits }, '*');
193
+ try { window.parent.postMessage({ type: 'miaoda:tweaks:set-keys', edits }, '*'); } catch (e) { /* no parent — ignore */ }
194
194
  // Same-window signal so in-page listeners (deck-stage rail thumbnails)
195
195
  // can react — the parent message only reaches the host, not peers.
196
196
  window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
@@ -248,13 +248,13 @@ function TweaksPanel({ title = 'Tweaks', children }) {
248
248
  else if (t === 'miaoda:tweaks:deactivate') setOpen(false);
249
249
  };
250
250
  window.addEventListener('message', onMsg);
251
- window.parent.postMessage({ type: 'miaoda:tweaks:available' }, '*');
251
+ try { window.parent.postMessage({ type: 'miaoda:tweaks:available' }, '*'); } catch (e) { /* no parent — ignore */ }
252
252
  return () => window.removeEventListener('message', onMsg);
253
253
  }, []);
254
254
 
255
255
  const dismiss = () => {
256
256
  setOpen(false);
257
- window.parent.postMessage({ type: 'miaoda:tweaks:dismissed' }, '*');
257
+ try { window.parent.postMessage({ type: 'miaoda:tweaks:dismissed' }, '*'); } catch (e) { /* no parent — ignore */ }
258
258
  };
259
259
 
260
260
  const onDragStart = (e) => {
@@ -586,8 +586,11 @@ Object.assign(window, {
586
586
  document.head.appendChild(s);
587
587
  })();
588
588
 
589
- const __twkSendChat = (text) =>
590
- window.parent.postMessage({ type: 'miaoda:tweaks:chat', text }, '*');
589
+ const __twkSendChat = (text) => {
590
+ // Posting to the host must never take the component down: the parent frame can be
591
+ // cross-origin or already gone, and postMessage throws in both cases.
592
+ try { window.parent.postMessage({ type: 'miaoda:tweaks:chat', text }, '*'); } catch (e) { /* no parent — ignore */ }
593
+ };
591
594
 
592
595
  const __TWK_SPARK_PATH = 'M18.3658 62.2435L36.7823 51.9165L37.0858 51.012L36.7823 50.5083H35.8716L32.7853 50.3206L22.2616 50.0389L13.1546 49.6634L4.30054 49.194L2.07438 48.7246L0 45.9551L0.202378 44.5938L2.07438 43.3264L4.75589 43.5611L10.6755 43.9836L19.5801 44.5938L26.0056 44.9693L35.568 45.9551H37.0858L37.2882 45.3448L36.7823 44.9693L36.3775 44.5938L27.1693 38.3507L17.2022 31.7789L11.9909 27.9767L9.20822 26.0522L7.79157 24.2684L7.18443 20.3254L9.71416 17.5089L13.1546 17.7436L14.0147 17.9783L17.5057 20.654L24.9431 26.4277L34.6573 33.5627L36.0739 34.7362L36.6444 34.3512L36.7317 34.079L36.0739 32.9994L30.8121 23.4704L25.1961 13.7537L22.6664 9.71675L22.0086 7.32277C21.7539 6.31812 21.6039 5.48695 21.6039 4.45938L24.4878 0.516349L26.1068 0L30.0026 0.516349L31.6216 1.92457L34.0502 7.46359L37.9459 16.1476L44.0173 27.9767L45.7881 31.4973L46.7494 34.7362L47.1036 35.722H47.7107V35.1587L48.2166 28.4931L49.1274 20.3254L50.0381 9.81063L50.3416 6.85336L51.8089 3.28586L54.7434 1.36128L57.0201 2.44092L58.8921 5.11655L58.6391 6.85336L57.5261 14.0822L55.3505 25.395L53.9338 32.9994H54.7434L55.7047 32.0136L59.5498 26.944L65.9753 18.8702L68.8086 15.6782L72.1479 12.1577L74.2729 10.4678H78.3204L81.2549 14.8802L79.9395 19.4335L75.7907 24.6909L72.3503 29.1503L67.4173 35.7593L64.3563 41.0732L64.6308 41.5116L65.3682 41.4487L76.499 39.0548L82.5198 37.9751L89.7042 36.7547L92.9423 38.2568L93.2964 39.8058L92.0316 42.9509L84.3412 44.8285L75.3354 46.6592L61.9245 49.8162L61.776 49.9356L61.9513 50.1956L67.9991 50.743L70.5795 50.8839H76.9038L88.6923 51.7757L91.7786 53.7942L93.6 56.282L93.2964 58.2066L88.5405 60.6006L82.1656 59.0985L67.2402 55.531L62.1302 54.2636H61.4218V54.6861L65.6718 58.8638L73.514 65.9049L83.2787 75.0114L83.7846 77.2646L82.5198 79.0483L81.2043 78.8606L72.6032 72.3827L69.264 69.4724L61.776 63.1354H61.2701V63.7926L62.9903 66.3274L72.1479 80.081L72.6032 84.3057L71.9455 85.667L69.5676 86.5119L66.9872 86.0425L61.5736 78.4851L56.0588 70.0357L51.6065 62.4313L51.0687 62.7708L48.419 91.0652L47.2048 92.5204L44.3715 93.6L41.9935 91.8162L40.7286 88.9059L41.9935 83.1322L43.5114 75.6217L44.7256 69.6602L45.8387 62.2435L46.5185 59.7659L46.4584 59.6001L45.9153 59.6914L40.3239 67.3601L31.824 78.8606L25.0949 86.0425L23.4759 86.6997L20.6932 85.2445L20.9462 82.6628L22.5146 80.3627L31.824 68.5336L37.44 61.1639L41.0595 56.9335L41.0243 56.3216L40.8245 56.3046L16.0891 72.4297L11.6874 72.993L9.76476 71.2092L10.0177 68.2989L10.9284 67.3601L18.3658 62.2435Z';
593
596