@lark-apaas/coding-registry-buildless 0.1.25 → 0.1.26

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.
@@ -31,9 +31,9 @@
31
31
  // it). Inlined per script rather than shipped as its own registry file:
32
32
  // call sites use the global bare, so inlining is what guarantees
33
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
34
+ // copied but <script> tag missing" failure mode. Keep every inlined copy
35
+ // identical (find them by this block's heading) — same convention as the
36
+ // mobile-UA regex. Two halves of
37
37
  // the 妙享 (Magic Share) mirror protocol:
38
38
  // emit(msg, opts) — stamps msg.__mirror so the host relay treats the
39
39
  // message as a broadcastable UI-state snapshot;
@@ -101,9 +101,9 @@ function dcFitTransformToBounds(bounds, vp, minScale, maxScale) {
101
101
  // it). Inlined per script rather than shipped as its own registry file:
102
102
  // call sites use the global bare, so inlining is what guarantees
103
103
  // definition-before-use — a separate copy-in file would add a whole "SDK
104
- // copied but <script> tag missing" failure mode. Keep this block identical
105
- // across deck-stage.js / design-canvas.jsx / animations.jsx — same
106
- // convention as the mobile-UA regex. Two halves of
104
+ // copied but <script> tag missing" failure mode. Keep every inlined copy
105
+ // identical (find them by this block's heading) — same convention as the
106
+ // mobile-UA regex. Two halves of
107
107
  // the 妙享 (Magic Share) mirror protocol:
108
108
  // emit(msg, opts) — stamps msg.__mirror so the host relay treats the
109
109
  // message as a broadcastable UI-state snapshot;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-registry-buildless",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "Miaoda registry (buildless runtime) — shadcn-style copy-in SDKs (globals + CDN-pinned, browser Babel) for buildless static HTML stacks",
5
5
  "type": "module",
6
6
  "files": [
@@ -80,6 +80,76 @@
80
80
  /* END USAGE */
81
81
 
82
82
  (() => {
83
+ // ── Mirror sync singleton ──────────────────────────────────────────────────
84
+ // Shared by every buildless script on the page (first one to load defines
85
+ // it). Inlined per script rather than shipped as its own registry file:
86
+ // call sites use the global bare, so inlining is what guarantees
87
+ // definition-before-use — a separate copy-in file would add a whole "SDK
88
+ // copied but <script> tag missing" failure mode. Keep every inlined copy
89
+ // identical (find them by this block's heading) — same convention as the
90
+ // mobile-UA regex. Two halves of
91
+ // the 妙享 (Magic Share) mirror protocol:
92
+ // emit(msg, opts) — stamps msg.__mirror so the host relay treats the
93
+ // message as a broadcastable UI-state snapshot;
94
+ // unstamped messages are never mirrored. opts.key is
95
+ // the sync channel (maps to the share SDK's
96
+ // actionType): late joiners receive only the LATEST
97
+ // snapshot per key, so states that overwrite each
98
+ // other must share one key. opts.throttle is the
99
+ // suggested host-side coalescing interval in ms —
100
+ // snapshots make dropped intermediate frames
101
+ // lossless, so hint it on any continuous gesture.
102
+ // onApply(type, fn) — replay entry for a message type this script itself
103
+ // emits. The host sends {type:'miaoda:mirror:apply',
104
+ // uuid, message, latencyMs} and needs
105
+ // {type:'miaoda:mirror:apply-result', uuid, result}
106
+ // back to drive the SDK's retry: result.type
107
+ // 'failed' means retry (use it for transient
108
+ // not-ready states), 'ignored' is terminal (not
109
+ // applicable / bad payload), 'applied' is success.
110
+ // Unregistered types stay silent on purpose —
111
+ // another script on the page may own them, and the
112
+ // host's timeout is the backstop.
113
+ if (!window.__miaodaMirror) {
114
+ const appliers = new Map(); // message.type -> Set<fn>
115
+ window.__miaodaMirror = {
116
+ emit(msg, opts) {
117
+ try { window.parent.postMessage(Object.assign({}, msg, { __mirror: Object.assign({ v: 1 }, opts) }), '*'); } catch (e) {}
118
+ },
119
+ onApply(type, fn) {
120
+ if (!appliers.has(type)) appliers.set(type, new Set());
121
+ appliers.get(type).add(fn);
122
+ return () => { const s = appliers.get(type); if (s) s.delete(fn); };
123
+ },
124
+ };
125
+ window.addEventListener('message', async (e) => {
126
+ // Replays are accepted from the host frame only — generated pages embed
127
+ // arbitrary third-party iframes, any of which could otherwise forge an
128
+ // apply and drive state (plus its persisted side effects).
129
+ if (e.source !== window.parent) return;
130
+ const d = e.data;
131
+ if (!d || d.type !== 'miaoda:mirror:apply' || !d.message || !d.message.type) return;
132
+ const fns = appliers.get(d.message.type);
133
+ if (!fns || !fns.size) return;
134
+ // Merge across handlers (multi-instance case): one success is enough.
135
+ // Instances share a channel, so a sibling that can't apply yet (a lazily
136
+ // slotted second deck returning 'failed') must not poison an applied
137
+ // result into an SDK retry storm of no-ops — 'failed' only stands when
138
+ // nobody applied. Every handler still runs (no short-circuit) so each
139
+ // sibling instance gets the state.
140
+ let result = { type: 'ignored', reason: 'no applier result' };
141
+ for (const fn of Array.from(fns)) {
142
+ let r;
143
+ try { r = await fn(d.message, { latencyMs: d.latencyMs, uuid: d.uuid }); }
144
+ catch (err) { r = { type: 'failed', reason: String((err && err.message) || err) }; }
145
+ if (!r) continue;
146
+ if (r.type === 'applied') result = r;
147
+ else if (result.type !== 'applied' && (r.type === 'failed' || result.type !== 'failed')) result = r;
148
+ }
149
+ try { window.parent.postMessage({ type: 'miaoda:mirror:apply-result', uuid: d.uuid, result }, '*'); } catch (e2) {}
150
+ });
151
+ }
152
+
83
153
  /** Shorthands for the common frames; anything else is computed by parseRatio — this is not an allowlist */
84
154
  const RATIOS = {
85
155
  '3:4': [1080, 1440],
@@ -414,13 +484,20 @@
414
484
  (paging / zoom / copy / download).
415
485
  overflow:auto is what lets you scroll a card that has been zoomed past the viewport,
416
486
  and the holder centres with margin:auto — flex centring clips the top once the content
417
- outgrows its container. */
487
+ outgrows its container.
488
+ The enlarged card must stay commentable while everything around it must not: the host
489
+ exempts a whole event path once any ancestor carries the chrome mark (csChrome), so
490
+ the mark cannot sit on .cs-focus. The dark ground is a separate scrim sibling carrying the mark,
491
+ and the holder is exactly the card's size — every empty pixel then belongs to the
492
+ scrim. pointer-events cannot carve that out instead: comment mode forces
493
+ pointer-events:auto on every element. */
418
494
  .cs-focus {
419
495
  position: fixed; inset: 0; z-index: 100;
420
- background: rgba(16,17,20,.92);
421
496
  display: grid; overflow: auto;
497
+ padding: 56px 24px 104px; box-sizing: border-box;
422
498
  }
423
- .cs-focus .cs-holder { margin: auto; padding: 56px 24px 104px; }
499
+ .cs-focus-scrim { position: fixed; inset: 0; background: rgba(16,17,20,.92); }
500
+ .cs-focus .cs-holder { position: relative; margin: auto; }
424
501
  .cs-focus .cs-holder > section { outline: none !important; box-shadow: none !important; }
425
502
  .cs-focus-close {
426
503
  position: fixed; top: 20px; right: 24px; z-index: 2;
@@ -1239,6 +1316,9 @@
1239
1316
  connectedCallback() {
1240
1317
  if (this.dataset.ready) return;
1241
1318
  this.dataset.ready = '1';
1319
+ // Before init, not after: a late joiner's replay can land while init still awaits the
1320
+ // sidecar, and only a registered applier can answer 'failed' to make the SDK retry
1321
+ this.wireMirror();
1242
1322
  // The script is usually loaded synchronously from <head>, at which point the child
1243
1323
  // <section>s are not parsed yet — wait for the document before initialising
1244
1324
  if (document.readyState === 'loading') {
@@ -1364,10 +1444,15 @@
1364
1444
  // themselves; the "fit" button and the 0 key do not count as framing, since following
1365
1445
  // the viewport is exactly what they ask for.
1366
1446
  addEventListener('resize', () => {
1447
+ if (this._remoteBounds) { this.fitBounds(this._remoteBounds); return; }
1367
1448
  if (this.userFramed) { this.placeHeads(); return; }
1368
1449
  this.fit();
1369
1450
  });
1370
1451
  }
1452
+ this._ready = true;
1453
+ // Baseline for late joiners (the viewport baseline already went out through fit)
1454
+ this.emitStructure();
1455
+ this.emitFocus();
1371
1456
  ready();
1372
1457
  void this.reportOverflow();
1373
1458
  }
@@ -1572,6 +1657,7 @@
1572
1657
  section.dataset.label = label.textContent.trim();
1573
1658
  this._labelOverrides[section.dataset.cardKey] = section.dataset.label;
1574
1659
  this.saveState();
1660
+ this.emitStructure();
1575
1661
  });
1576
1662
  label.addEventListener('keydown', (e) => {
1577
1663
  if (e.key === 'Enter') { e.preventDefault(); label.blur(); }
@@ -1679,20 +1765,27 @@
1679
1765
  deleteCard(n) {
1680
1766
  const section = this.cardAt(n);
1681
1767
  if (!section) return;
1682
- if (this.focusIndex === n) this.closeFocus();
1683
1768
  const key = section.dataset.cardKey;
1769
+ this.dropCard(section);
1770
+ if (key && !this._hiddenKeys.includes(key)) this._hiddenKeys.push(key);
1771
+ this.saveState();
1772
+ this.emitStructure();
1773
+ this.toast('已删除');
1774
+ }
1775
+
1776
+ /** Takes a card off the page — shared by a local delete and a replayed one, which must not persist */
1777
+ dropCard(section) {
1778
+ if (this.focusIndex === Number(section.dataset.cardIndex)) this.closeFocus();
1684
1779
  const head = this.headFor && this.headFor.get(section);
1685
1780
  if (head) head.remove();
1686
1781
  if (this.headFor) this.headFor.delete(section);
1687
1782
  section.remove();
1688
- if (key && !this._hiddenKeys.includes(key)) this._hiddenKeys.push(key);
1689
- this.saveState();
1690
1783
 
1691
1784
  const cards = this.cards();
1692
1785
  cards.forEach((s, i) => { s.dataset.cardIndex = String(i + 1); });
1693
- this.heads = cards.map((s) => this.headFor.get(s));
1786
+ if (this.headFor) this.heads = cards.map((s) => this.headFor.get(s));
1694
1787
  this.placeHeads();
1695
- this.toast('已删除');
1788
+ if (csIsMobile) this.goToPage(this.page, false);
1696
1789
  }
1697
1790
 
1698
1791
  /**
@@ -1811,10 +1904,18 @@
1811
1904
 
1812
1905
  // A click on a card opens focus mode (but not when the click lands on a link, button or
1813
1906
  // the label)
1907
+ // Releasing a text-selection drag still dispatches a click on the card. Opening focus
1908
+ // there would bury the selection under the overlay before the host's selection-comment
1909
+ // toolbar can use it, so a press that moved or left a selection is not a click.
1910
+ let downX = 0, downY = 0;
1911
+ this.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
1814
1912
  this.addEventListener('click', (e) => {
1815
1913
  const section = e.target.closest('card-stage > section');
1816
1914
  if (!section) return;
1817
1915
  if (e.target.closest('a, button, input, textarea, [contenteditable]')) return;
1916
+ if (Math.hypot(e.clientX - downX, e.clientY - downY) > 4) return;
1917
+ const sel = getSelection();
1918
+ if (sel && !sel.isCollapsed && section.contains(sel.anchorNode)) return;
1818
1919
  this.openFocus(Number(section.dataset.cardIndex));
1819
1920
  });
1820
1921
 
@@ -1849,6 +1950,9 @@
1849
1950
  this.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
1850
1951
  this.syncZoomBar();
1851
1952
  this.placeHeads();
1953
+ // Any local framing takes the view back from the presenter, resize included
1954
+ if (!this._applyingMirror) this._remoteBounds = null;
1955
+ this.emitViewport();
1852
1956
  }
1853
1957
 
1854
1958
  zoomAt(cx, cy, factor) {
@@ -1938,6 +2042,7 @@
1938
2042
  this._slideTimer = setTimeout(() => { this.style.transition = 'none'; }, 300);
1939
2043
  }
1940
2044
  this.syncDots();
2045
+ this.emitFocus();
1941
2046
  }
1942
2047
 
1943
2048
  wireSwipe() {
@@ -2250,6 +2355,9 @@
2250
2355
  this.focusZoom = this.focusFit;
2251
2356
  this._focusFrame = frame;
2252
2357
  this._focusClone = clone;
2358
+ // Mirrored by key, not index: indices shift under deletes, and the two ends may have
2359
+ // applied a different set of them when a snapshot crosses
2360
+ this._focusKey = src.dataset.cardKey;
2253
2361
 
2254
2362
  const close = document.createElement('button');
2255
2363
  close.className = 'cs-focus-close';
@@ -2299,9 +2407,20 @@
2299
2407
  };
2300
2408
  bar.append(prev, counter, next, sep(), zoomOut, this._focusPct, zoomIn, sep(), copy, download);
2301
2409
 
2302
- overlay.append(holder, close, bar);
2410
+ const scrim = document.createElement('div');
2411
+ scrim.className = 'cs-focus-scrim';
2412
+ // The scrim is fixed, so its containing block is the viewport and a wheel over it would
2413
+ // chain to the document rather than the overlay — scrolling a zoomed-in card from the
2414
+ // dark margin is forwarded by hand. Ctrl/⌘ is left to bubble to the zoom handler below.
2415
+ scrim.addEventListener('wheel', (e) => {
2416
+ if (e.ctrlKey || e.metaKey) return;
2417
+ e.preventDefault();
2418
+ overlay.scrollBy(e.deltaX, e.deltaY);
2419
+ }, { passive: false });
2420
+
2421
+ overlay.append(csChrome(scrim), holder, csChrome(close), csChrome(bar));
2303
2422
  overlay.addEventListener('click', (e) => {
2304
- if (e.target === overlay || e.target === holder) this.closeFocus();
2423
+ if (e.target === overlay || e.target === scrim) this.closeFocus();
2305
2424
  });
2306
2425
  // Focus mode's right-click shares the canvas menu
2307
2426
  clone.addEventListener('contextmenu', (e) => {
@@ -2315,7 +2434,9 @@
2315
2434
  this.setFocusZoom(this.focusZoom * Math.exp(-e.deltaY / 300));
2316
2435
  }, { passive: false });
2317
2436
 
2318
- document.body.appendChild(csChrome(overlay));
2437
+ overlay.addEventListener('scroll', () => this.emitFocus(), { passive: true });
2438
+
2439
+ document.body.appendChild(overlay);
2319
2440
  this.overlay = overlay;
2320
2441
  this.applyFocusZoom();
2321
2442
  }
@@ -2331,16 +2452,188 @@
2331
2452
  this._focusFrame.style.height = this.H * this.focusZoom + 'px';
2332
2453
  this._focusClone.style.transform = `scale(${this.focusZoom})`;
2333
2454
  if (this._focusPct) this._focusPct.textContent = Math.round(this.focusZoom * 100) + '%';
2455
+ this.emitFocus();
2334
2456
  }
2335
2457
 
2336
2458
  closeFocus() {
2337
2459
  this.closeContextMenu();
2460
+ const wasOpen = !!this.overlay;
2338
2461
  if (this.overlay) this.overlay.remove();
2339
2462
  this.overlay = null;
2340
2463
  this.focusIndex = null;
2464
+ this._focusKey = null;
2341
2465
  this._focusFrame = null;
2342
2466
  this._focusClone = null;
2343
2467
  this._focusPct = null;
2468
+ if (wasOpen) this.emitFocus();
2469
+ }
2470
+
2471
+ // —— Magic Share (妙享) ——
2472
+ // Three keys, because a late joiner receives only the latest snapshot per key: canvas
2473
+ // framing, the focused card, and deletes / renames never overwrite one another.
2474
+ // Every applier answers 'failed' until init has laid the cards out — that is transient and
2475
+ // only 'failed' makes the SDK retry.
2476
+ wireMirror() {
2477
+ // Capture iframes (?thumbnail= product screenshots, ?export=1) sit out in both directions
2478
+ // — same rule as design-canvas's dcCaptureMode
2479
+ if (/[?&](_snthumb|thumbnail)=|[?&]export=1(&|$)/.test(location.search || '')) return;
2480
+ this._mirrorOn = true;
2481
+ // The applier registry is page-global and outlives this element: once it is taken off the
2482
+ // page it must not answer for (or draw overlays on behalf of) the stage that replaced it
2483
+ const on = (type, fn) => window.__miaodaMirror.onApply(type, (m) =>
2484
+ this.isConnected ? fn(m) : { type: 'ignored', reason: 'stage detached' });
2485
+ on('miaoda:cards:viewport-changed', (m) => this.applyMirrorViewport(m));
2486
+ on('miaoda:cards:focus-changed', (m) => this.applyMirrorFocus(m));
2487
+ on('miaoda:cards:structure-changed', (m) => this.applyMirrorStructure(m));
2488
+ }
2489
+
2490
+ /** Runs a replay so that the emits it triggers are not echoed back as this end's own */
2491
+ asMirror(fn) {
2492
+ this._applyingMirror = true;
2493
+ try { fn(); } finally { this._applyingMirror = false; }
2494
+ }
2495
+
2496
+ // The transform is local screen geometry and cannot be replayed on another window size, so
2497
+ // the world rectangle in view is mirrored instead and each end fits it into its own window
2498
+ // — same scheme as design-canvas's viewport v2.
2499
+ emitViewport() {
2500
+ if (!this._mirrorOn || this._applyingMirror || csIsMobile || this._viewportT) return;
2501
+ // Trailing 120ms: applyTransform runs at pointer rate; reading at fire time loses nothing
2502
+ this._viewportT = setTimeout(() => {
2503
+ this._viewportT = 0;
2504
+ if (!this.isConnected) return;
2505
+ const z = this.zoom;
2506
+ window.__miaodaMirror.emit(
2507
+ { type: 'miaoda:cards:viewport-changed', bounds: { x: -this.panX / z, y: -this.panY / z, width: innerWidth / z, height: innerHeight / z } },
2508
+ { key: 'cards:viewport', throttle: 60 },
2509
+ );
2510
+ }, 120);
2511
+ }
2512
+
2513
+ applyMirrorViewport(m) {
2514
+ // Mobile pages card by card; it follows cards:focus instead
2515
+ if (csIsMobile) return { type: 'ignored', reason: 'mobile follows the focused card' };
2516
+ if (!this._ready) return { type: 'failed', reason: 'not laid out yet' };
2517
+ const b = m.bounds;
2518
+ if (!b || ![b.x, b.y, b.width, b.height].every(Number.isFinite) || !(b.width > 0) || !(b.height > 0)) {
2519
+ return { type: 'ignored', reason: 'bad bounds' };
2520
+ }
2521
+ // A timer armed by a local gesture would fire with the presenter's view and echo it back
2522
+ clearTimeout(this._viewportT);
2523
+ this._viewportT = 0;
2524
+ this.fitBounds(b);
2525
+ return { type: 'applied' };
2526
+ }
2527
+
2528
+ fitBounds(b) {
2529
+ const z = Math.min(MAX_PCT / 100, Math.max(MIN_PCT / 100, Math.min(innerWidth / b.width, innerHeight / b.height)));
2530
+ this.asMirror(() => {
2531
+ this.zoom = z;
2532
+ this.panX = innerWidth / 2 - (b.x + b.width / 2) * z;
2533
+ // Top-anchored: a contain fit's spare height would otherwise become a per-device offset
2534
+ this.panY = -b.y * z;
2535
+ this.applyTransform();
2536
+ });
2537
+ // Kept so a resize on this end refits the presenter's view rather than auto-fitting
2538
+ this._remoteBounds = b;
2539
+ }
2540
+
2541
+ focusSnapshot() {
2542
+ const r3 = (v) => Math.round(v * 1000) / 1000;
2543
+ if (csIsMobile) {
2544
+ const s = this.cardAt(this.page);
2545
+ return { key: s ? s.dataset.cardKey : null, zoom: null, sx: 0, sy: 0 };
2546
+ }
2547
+ const ov = this.overlay;
2548
+ if (!ov || !this._focusKey) return { key: null, zoom: null, sx: 0, sy: 0 };
2549
+ const frac = (pos, range) => (range > 0 ? r3(pos / range) : 0);
2550
+ return {
2551
+ key: this._focusKey,
2552
+ // Relative to the entry fit, not the absolute %: the fit follows each end's window, and
2553
+ // what has to match is how much of the card is in view
2554
+ zoom: r3(this.focusZoom / this.focusFit),
2555
+ sx: frac(ov.scrollLeft, ov.scrollWidth - ov.clientWidth),
2556
+ sy: frac(ov.scrollTop, ov.scrollHeight - ov.clientHeight),
2557
+ };
2558
+ }
2559
+
2560
+ emitFocus() {
2561
+ if (!this._mirrorOn || this._applyingMirror || this._focusT) return;
2562
+ this._focusT = setTimeout(() => {
2563
+ this._focusT = 0;
2564
+ if (!this.isConnected) return;
2565
+ const snap = this.focusSnapshot();
2566
+ // Scroll events from a replay arrive after the replay returns, so the flag cannot catch
2567
+ // them; an unchanged snapshot is that echo
2568
+ const sig = JSON.stringify(snap);
2569
+ if (sig === this._focusSig) return;
2570
+ this._focusSig = sig;
2571
+ window.__miaodaMirror.emit({ type: 'miaoda:cards:focus-changed', ...snap }, { key: 'cards:focus', throttle: 60 });
2572
+ }, 120);
2573
+ }
2574
+
2575
+ applyMirrorFocus(m) {
2576
+ if (!this._ready) return { type: 'failed', reason: 'not laid out yet' };
2577
+ if (m.key !== null && typeof m.key !== 'string') return { type: 'ignored', reason: 'bad key' };
2578
+ const section = m.key === null ? null : this.cards().find((s) => s.dataset.cardKey === m.key);
2579
+ if (m.key !== null && !section) return { type: 'ignored', reason: 'card not present' };
2580
+ clearTimeout(this._focusT);
2581
+ this._focusT = 0;
2582
+ this.asMirror(() => {
2583
+ const n = section ? Number(section.dataset.cardIndex) : null;
2584
+ if (csIsMobile) {
2585
+ if (n) this.goToPage(n);
2586
+ return;
2587
+ }
2588
+ if (!n) { this.closeFocus(); return; }
2589
+ if (this.focusIndex !== n) this.openFocus(n);
2590
+ if (Number.isFinite(m.zoom) && m.zoom > 0) this.setFocusZoom(this.focusFit * m.zoom);
2591
+ const ov = this.overlay;
2592
+ if (Number.isFinite(m.sx)) ov.scrollLeft = m.sx * (ov.scrollWidth - ov.clientWidth);
2593
+ if (Number.isFinite(m.sy)) ov.scrollTop = m.sy * (ov.scrollHeight - ov.clientHeight);
2594
+ });
2595
+ this._focusSig = JSON.stringify(this.focusSnapshot());
2596
+ return { type: 'applied' };
2597
+ }
2598
+
2599
+ emitStructure() {
2600
+ if (!this._mirrorOn || this._applyingMirror) return;
2601
+ window.__miaodaMirror.emit(
2602
+ { type: 'miaoda:cards:structure-changed', srcKey: this.srcKey, hidden: this._hiddenKeys.slice(), labels: { ...this._labelOverrides } },
2603
+ { key: 'cards:structure' },
2604
+ );
2605
+ }
2606
+
2607
+ applyMirrorStructure(m) {
2608
+ if (!this._ready) return { type: 'failed', reason: 'not laid out yet' };
2609
+ if (typeof m.srcKey !== 'string' || !Array.isArray(m.hidden) || !m.labels || typeof m.labels !== 'object') {
2610
+ return { type: 'ignored', reason: 'malformed structure' };
2611
+ }
2612
+ // Built from another version of the source (an agent edit one end has not reloaded): card
2613
+ // keys no longer line up, same rule the sidecar follows
2614
+ if (m.srcKey !== this.srcKey) return { type: 'ignored', reason: 'source mismatch' };
2615
+ // Replayed state is the presenter's, never persisted through this end's write bridge — but
2616
+ // it is folded into this end's state, or this end's next emit (say, a rename) would carry
2617
+ // the pre-replay hidden list and overwrite the latest snapshot late joiners receive.
2618
+ // Hidden is a union: a dropped section cannot come back.
2619
+ for (const key of m.hidden) {
2620
+ if (typeof key === 'string' && !this._hiddenKeys.includes(key)) this._hiddenKeys.push(key);
2621
+ }
2622
+ for (const [key, label] of Object.entries(m.labels)) {
2623
+ if (typeof label === 'string') this._labelOverrides[key] = label;
2624
+ }
2625
+ this.asMirror(() => {
2626
+ for (const section of this.cards()) {
2627
+ const key = section.dataset.cardKey;
2628
+ if (this._hiddenKeys.includes(key)) { this.dropCard(section); continue; }
2629
+ const label = m.labels[key];
2630
+ if (typeof label !== 'string' || label === section.dataset.label) continue;
2631
+ section.dataset.label = label;
2632
+ const head = this.headFor && this.headFor.get(section);
2633
+ if (head) head.querySelector('.cs-label').textContent = label;
2634
+ }
2635
+ });
2636
+ return { type: 'applied' };
2344
2637
  }
2345
2638
 
2346
2639
  }
@@ -139,9 +139,9 @@
139
139
  // it). Inlined per script rather than shipped as its own registry file:
140
140
  // call sites use the global bare, so inlining is what guarantees
141
141
  // definition-before-use — a separate copy-in file would add a whole "SDK
142
- // copied but <script> tag missing" failure mode. Keep this block identical
143
- // across deck-stage.js / design-canvas.jsx / animations.jsx — same
144
- // convention as the mobile-UA regex. Two halves of
142
+ // copied but <script> tag missing" failure mode. Keep every inlined copy
143
+ // identical (find them by this block's heading) — same convention as the
144
+ // mobile-UA regex. Two halves of
145
145
  // the 妙享 (Magic Share) mirror protocol:
146
146
  // emit(msg, opts) — stamps msg.__mirror so the host relay treats the
147
147
  // message as a broadcastable UI-state snapshot;