@lark-apaas/coding-registry-buildless 0.1.4 → 0.1.5-alpha.20260717111512

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.
@@ -28,6 +28,10 @@
28
28
  // When placing a device frame inside a DCArtboard, pass chromeless to
29
29
  // suppress the card chrome and let the artboard size to its content:
30
30
  // <DCArtboard chromeless><IOSDevice>…</IOSDevice></DCArtboard>
31
+ //
32
+ // On mobile devices the canvas automatically becomes a browse-only surface
33
+ // (touch pan/zoom, editing disabled). This needs NO authoring changes:
34
+ // never add custom touch handling or mobile-specific layouts.
31
35
  /* END USAGE */
32
36
 
33
37
  const DC = {
@@ -40,6 +44,16 @@ const DC = {
40
44
  font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
41
45
  };
42
46
 
47
+ // Mirrors the host platform's isMobile() (UA regex + iPad maxTouchPoints);
48
+ // the product iframe is cross-origin, so the shared helper can't be imported.
49
+ // Deliberately NOT viewport-based: the desktop workbench shows canvases in a
50
+ // narrow preview iframe that a width breakpoint would misread as mobile.
51
+ // Evaluated once at load. Keep the regex in sync with deck-stage.js's copy.
52
+ const DC_MOBILE_UA_RE = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i;
53
+ const dcIsMobile = typeof navigator !== 'undefined' &&
54
+ (DC_MOBILE_UA_RE.test(navigator.userAgent) ||
55
+ (/iPad|Tab|Tablet/i.test(navigator.userAgent) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 1));
56
+
43
57
  // One-time CSS injection (classes are dc-prefixed so they don't collide with
44
58
  // the hosted design's own styles).
45
59
  if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
@@ -92,6 +106,39 @@ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
92
106
  // can't be reveal-on-hover and tends to stick visible after a tap — hide it
93
107
  // entirely on mobile. The grip + label stay for per-screen context.
94
108
  '@media (hover: none){.dc-btns{display:none}}',
109
+ // Mobile (UA-detected, .dc-mobile on the viewport root) is browse-only:
110
+ // drag-reorder is disabled there, so the grip is dead weight — hide it.
111
+ '.dc-mobile .dc-grip{display:none}',
112
+ // Mobile is browse-only: kill text selection + iOS long-press callout
113
+ // inside the canvas. iOS interprets a drag over selectable TEXT as a
114
+ // selection gesture and steals the pointer (pointercancel) — touch-action
115
+ // only suppresses native pan/zoom, not selection, so a drag
116
+ // starting on selectable text never reaches the pan.
117
+ '.dc-mobile, .dc-mobile *{-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}',
118
+ // Prototype content routinely carries inner scrollers (the device/window
119
+ // frames all have overflow:auto bodies). On iOS a touch starting inside a
120
+ // scrollable element is consumed by THAT scroller (native scroll attempt
121
+ // + pointercancel), so a pan could
122
+ // never start there. Mobile canvas is browse-only: kill native
123
+ // touch handling on everything inside the cards so gestures always reach
124
+ // the viewport state machine. Desktop (no .dc-mobile) keeps inner scroll.
125
+ '.dc-mobile [data-dc-slot] *{touch-action:none !important}',
126
+ // Pinch updates --dc-inv-zoom per frame (PC semantics: chrome size AND
127
+ // section spacing stay constant). The per-frame relayout that implies
128
+ // white-flashed on WKWebView when the whole world was one paint layer —
129
+ // every head resize invalidated and re-rasterized ALL content. Promote
130
+ // each card (and head) to its own compositing layer: relayout then only
131
+ // MOVES card layers (compositor, no repaint) and repaints just the tiny
132
+ // head layers. contain:paint bounds invalidation to the card box.
133
+ // contain:paint must live on .dc-card, NOT the slot: .dc-header is an
134
+ // abs-positioned slot child sitting entirely ABOVE the slot box
135
+ // (bottom:100%) — paint containment on the slot would clip it out of
136
+ // rendering AND hit-testing, killing the label tap that is mobile's only
137
+ // focus-overlay entry. will-change (the layer-promotion half of the
138
+ // white-flash fix) stays on the slot so header + card share one layer.
139
+ '.dc-mobile [data-dc-slot]{will-change:transform}',
140
+ '.dc-mobile .dc-card{contain:paint}',
141
+ '.dc-mobile .dc-sectionhead{will-change:transform}',
95
142
  '[data-dc-slot]:hover .dc-card.chromeless>*, [data-dc-slot]:hover .dc-card:not(.chromeless){box-shadow:0 4px 8px -8px rgba(0, 0, 0, 0.06), 0 6px 12px 0 rgba(0, 0, 0, 0.04), 0 8px 24px 8px rgba(0, 0, 0, 0.04)}',
96
143
  '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:6px;border:none;cursor:pointer;padding:0;',
97
144
  ' background:transparent;color:#646A73;display:flex;align-items:center;justify-content:center;',
@@ -127,7 +174,13 @@ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
127
174
  // space gap and overlap the artboard headers at low zoom.
128
175
  '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
129
176
  ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left;will-change:transform}',
130
- '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
177
+ // Section heads counter-scale via calc()'d font metrics (see DCSection),
178
+ // NOT CSS zoom: zoom was only standardized in 2024 and legacy WebKit
179
+ // builds (older iOS WKWebViews) mis-apply it — text renders unscaled
180
+ // while layout inflates, tearing the title/subtitle apart at small
181
+ // canvas scales. calc(px * var()) lengths are interop-safe everywhere
182
+ // and keep the same in-flow layout semantics (box grows with the text,
183
+ // space below stays reserved).
131
184
  // On-canvas zoom control (bottom-right pill). Stays screen-fixed because
132
185
  // it renders outside worldRef. z-index 50 keeps it above world content but
133
186
  // below the focus overlay (z-index 100). The dropdown reuses .dc-menu and
@@ -439,7 +492,10 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
439
492
  el.style.setProperty('--dc-inv-zoom', String(1 / scale));
440
493
  // Keep the on-canvas zoom control's % readout in sync. React bails out on
441
494
  // an unchanged rounded value, so pan ticks (scale unchanged) don't render.
442
- setScalePct(Math.round(scale * 100));
495
+ // Mobile renders no zoom control — a per-frame setState during pinch
496
+ // would re-render the whole subtree every frame (visible repaint flicker
497
+ // on phones) for a readout nobody consumes, so skip it there entirely.
498
+ if (!dcIsMobile) setScalePct(Math.round(scale * 100));
443
499
  // Keep the host toolbar's % readout in sync with the canvas scale. Pan
444
500
  // ticks leave scale unchanged — skip the cross-frame post for those.
445
501
  if (lastPostedScale.current !== scale) {
@@ -490,7 +546,9 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
490
546
  const slots = world.querySelectorAll('[data-dc-slot]');
491
547
  if (!slots.length) return; // content not mounted yet — wait for the ready commit
492
548
  const heads = world.querySelectorAll('.dc-sectionhead');
493
- const PAD = 64;
549
+ // Fit margin from the screen edges. Mobile screens are narrow — 64px a
550
+ // side eats a third of the width, so fit flush-ish at 16px there.
551
+ const PAD = dcIsMobile ? 16 : 64;
494
552
 
495
553
  // Content bounding box in WORLD space at the currently applied transform.
496
554
  // X extent from cards only (section-head blocks are full width and would
@@ -556,8 +614,8 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
556
614
  const t = tf.current;
557
615
  const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
558
616
  const k = next / t.scale;
559
- // --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's
560
- // marginBottom) reflow on every scale change, vertically shifting the
617
+ // --dc-inv-zoom consumers (.dc-sectionhead's calc()'d font metrics,
618
+ // each section's marginBottom) reflow on every scale change, vertically shifting the
561
619
  // world layout — so a world point mathematically pinned under the cursor
562
620
  // drifts as you zoom (content creeps up on zoom-in, down on zoom-out).
563
621
  // Anchor the DOM element under the cursor instead: record its screen Y,
@@ -630,11 +688,18 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
630
688
  // better feel there. No-ops on other browsers. Safari also fires
631
689
  // ctrlKey wheel events during the same pinch — isGesturing makes
632
690
  // onWheel drop those entirely so they neither zoom nor pan.
691
+ //
692
+ // On mobile the pointer-based two-finger pinch below is the single zoom
693
+ // source. iOS fires BOTH pointer events and these WebKit gesture* events
694
+ // for the same two fingers — zooming from both would double-apply, so on
695
+ // mobile gesture* degrades to preventDefault only (still suppressing the
696
+ // native page zoom).
633
697
  let gsBase = 1;
634
698
  let isGesturing = false;
635
699
  const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
636
700
  const onGestureChange = (e) => {
637
701
  e.preventDefault();
702
+ if (dcIsMobile) return;
638
703
  zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
639
704
  };
640
705
  const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
@@ -664,6 +729,110 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
664
729
  vp.style.cursor = '';
665
730
  };
666
731
 
732
+ // ── Mobile touch gestures (replaces the desktop pointer mapping above):
733
+ // one-finger drag pans — starting on artboards too, since cards fill a
734
+ // phone viewport and background is scarce; two-finger pinch zooms about
735
+ // the midpoint while the midpoint's own travel pans (Figma semantics, one
736
+ // gesture frames the view). A tap/pan slop keeps label taps working: no
737
+ // capture happens within the slop, so a clean tap still clicks through to
738
+ // focus; past it the viewport captures the pointer and the click retargets
739
+ // harmlessly. No fling/inertia — release stops the canvas (spec §8).
740
+ const touches = new Map(); // pointerId -> last {x, y}
741
+ let mobMode = null; // null | 'tap' | 'pan' | 'pinch'
742
+ let tapStart = null; // down point for slop arbitration
743
+ let pinchDist = 0; // finger distance as of the last applied frame
744
+ let pinchMid = null; // last-applied midpoint
745
+ let panApplied = null; // pointer position as of the last applied pan frame
746
+ const MOB_SLOP = 9; // px of travel before a tap becomes a pan
747
+
748
+ // First two insertion-ordered touches define the pinch; a third finger is
749
+ // tracked but inert (its moves shift neither midpoint nor distance).
750
+ const pinchGeom = () => {
751
+ const [a, b] = [...touches.values()];
752
+ return { d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
753
+ };
754
+
755
+ const onTouchDown = (e) => {
756
+ touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
757
+ if (touches.size === 1) {
758
+ mobMode = 'tap';
759
+ tapStart = { x: e.clientX, y: e.clientY };
760
+ } else if (touches.size === 2) {
761
+ mobFlushNow(); // apply any pending pan frame before rebasing as a pinch
762
+ mobMode = 'pinch';
763
+ const g = pinchGeom();
764
+ pinchDist = g.d;
765
+ pinchMid = { x: g.mx, y: g.my };
766
+ try { vp.setPointerCapture(e.pointerId); } catch {}
767
+ }
768
+ };
769
+ // rAF-coalesced application: move handlers only record pointer
770
+ // positions; geometry is applied at most once per frame. pointermove can
771
+ // far outrun the render rate (120Hz touch sampling, fast pinches), and
772
+ // running the full zoomAt/apply per event creates engine-heap garbage
773
+ // faster than mobile WebKit reclaims it — which reads as unbounded
774
+ // memory growth on device. Only the last position per frame can ever
775
+ // be painted anyway.
776
+ let mobRaf = 0;
777
+ const mobFlushFrame = () => {
778
+ mobRaf = 0;
779
+ if (mobMode === 'pan') {
780
+ const [t] = [...touches.values()];
781
+ if (!t) return;
782
+ tf.current.x += t.x - panApplied.x;
783
+ tf.current.y += t.y - panApplied.y;
784
+ panApplied = { x: t.x, y: t.y };
785
+ apply();
786
+ } else if (mobMode === 'pinch' && touches.size >= 2) {
787
+ const g = pinchGeom();
788
+ tf.current.x += g.mx - pinchMid.x;
789
+ tf.current.y += g.my - pinchMid.y;
790
+ pinchMid = { x: g.mx, y: g.my };
791
+ // zoomAt applies the pan mutation above too; degenerate distances
792
+ // (fingers together) skip the zoom but still need the pan applied.
793
+ // Same full zoomAt as the desktop trackpad-pinch path (gesturechange):
794
+ // per-frame --dc-inv-zoom keeps chrome size AND section spacing
795
+ // constant throughout the gesture, with the same drift compensation.
796
+ if (pinchDist > 0 && g.d > 0) zoomAt(g.mx, g.my, g.d / pinchDist);
797
+ else apply();
798
+ pinchDist = g.d;
799
+ }
800
+ };
801
+ const mobSchedule = () => { if (!mobRaf) mobRaf = requestAnimationFrame(mobFlushFrame); };
802
+ const mobFlushNow = () => { if (mobRaf) { cancelAnimationFrame(mobRaf); mobFlushFrame(); } };
803
+
804
+ const onTouchMove = (e) => {
805
+ const t = touches.get(e.pointerId);
806
+ if (!t) return;
807
+ t.x = e.clientX; t.y = e.clientY;
808
+ if (mobMode === 'tap' && Math.hypot(t.x - tapStart.x, t.y - tapStart.y) > MOB_SLOP) {
809
+ mobMode = 'pan';
810
+ panApplied = { x: t.x, y: t.y };
811
+ try { vp.setPointerCapture(e.pointerId); } catch {}
812
+ }
813
+ if (mobMode === 'pan' || mobMode === 'pinch') mobSchedule();
814
+ };
815
+ const onTouchUp = (e) => {
816
+ mobFlushNow(); // apply the pending frame first so the gesture tail isn't lost
817
+ if (!touches.delete(e.pointerId)) return;
818
+ try { vp.releasePointerCapture(e.pointerId); } catch {}
819
+ if (touches.size === 0) {
820
+ mobMode = null;
821
+ } else if (touches.size === 1) {
822
+ // Pinch collapsing to one finger hands off to pan with the surviving
823
+ // finger as the new baseline — no jump.
824
+ mobMode = 'pan';
825
+ const [t] = [...touches.values()];
826
+ panApplied = { x: t.x, y: t.y };
827
+ } else if (mobMode === 'pinch') {
828
+ // 3+ fingers losing one: re-baseline so the new leading pair doesn't
829
+ // read as a sudden midpoint/distance delta.
830
+ const g = pinchGeom();
831
+ pinchDist = g.d;
832
+ pinchMid = { x: g.mx, y: g.my };
833
+ }
834
+ };
835
+
667
836
  // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
668
837
  // visible midpoint stays fixed — matching the host's iframe-zoom feel.
669
838
  const onHostMsg = (e) => {
@@ -694,31 +863,38 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
694
863
  lastPostedScale.current = undefined;
695
864
  apply();
696
865
 
866
+ // Mobile swaps the desktop pointer mapping (bg-only pan) for the touch
867
+ // state machine; wheel/gesture listeners stay on both (harmless without
868
+ // the hardware, and a bluetooth mouse on Android still pans via wheel).
869
+ const pd = dcIsMobile ? onTouchDown : onPointerDown;
870
+ const pm = dcIsMobile ? onTouchMove : onPointerMove;
871
+ const pu = dcIsMobile ? onTouchUp : onPointerUp;
697
872
  vp.addEventListener('wheel', onWheel, { passive: false });
698
873
  vp.addEventListener('gesturestart', onGestureStart, { passive: false });
699
874
  vp.addEventListener('gesturechange', onGestureChange, { passive: false });
700
875
  vp.addEventListener('gestureend', onGestureEnd, { passive: false });
701
- vp.addEventListener('pointerdown', onPointerDown);
702
- vp.addEventListener('pointermove', onPointerMove);
703
- vp.addEventListener('pointerup', onPointerUp);
704
- vp.addEventListener('pointercancel', onPointerUp);
876
+ vp.addEventListener('pointerdown', pd);
877
+ vp.addEventListener('pointermove', pm);
878
+ vp.addEventListener('pointerup', pu);
879
+ vp.addEventListener('pointercancel', pu);
705
880
  return () => {
706
881
  window.removeEventListener('message', onHostMsg);
707
882
  vp.removeEventListener('wheel', onWheel);
708
883
  vp.removeEventListener('gesturestart', onGestureStart);
709
884
  vp.removeEventListener('gesturechange', onGestureChange);
710
885
  vp.removeEventListener('gestureend', onGestureEnd);
711
- vp.removeEventListener('pointerdown', onPointerDown);
712
- vp.removeEventListener('pointermove', onPointerMove);
713
- vp.removeEventListener('pointerup', onPointerUp);
714
- vp.removeEventListener('pointercancel', onPointerUp);
886
+ vp.removeEventListener('pointerdown', pd);
887
+ vp.removeEventListener('pointermove', pm);
888
+ vp.removeEventListener('pointerup', pu);
889
+ vp.removeEventListener('pointercancel', pu);
890
+ if (mobRaf) cancelAnimationFrame(mobRaf);
715
891
  };
716
892
  }, [apply, minScale, maxScale]);
717
893
 
718
894
  return (
719
895
  <div
720
896
  ref={vpRef}
721
- className="design-canvas"
897
+ className={dcIsMobile ? 'design-canvas dc-mobile' : 'design-canvas'}
722
898
  style={{
723
899
  height: '100vh', width: '100vw',
724
900
  background: DC.bg,
@@ -744,12 +920,16 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
744
920
  >
745
921
  {children}
746
922
  </div>
747
- <DCZoomControl
748
- scalePct={scalePct}
749
- minScale={minScale}
750
- maxScale={maxScale}
751
- onZoomTo={(pct) => zoomCenteredRef.current?.(pct / 100)}
752
- />
923
+ {/* Mobile zooms by pinch — the control is not rendered at all (host
924
+ set-zoom messages still work; the protocol is independent of it). */}
925
+ {!dcIsMobile && (
926
+ <DCZoomControl
927
+ scalePct={scalePct}
928
+ minScale={minScale}
929
+ maxScale={maxScale}
930
+ onZoomTo={(pct) => zoomCenteredRef.current?.(pct / 100)}
931
+ />
932
+ )}
753
933
  </div>
754
934
  );
755
935
  }
@@ -788,11 +968,16 @@ function DCSection({ id, title, subtitle, children, gap = 48 }) {
788
968
  <div data-dc-section={sid}
789
969
  style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
790
970
  <div style={{ padding: '0 60px' }}>
791
- <div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
971
+ <div className="dc-sectionhead" style={{ paddingBottom: 'calc(36px * var(--dc-inv-zoom, 1))' }}>
792
972
  <DCEditable tag="div" value={sec.title ?? title}
793
973
  onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
794
- style={{ fontSize: 20, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 2, display: 'inline-block', lineHeight: '28px' }} />
795
- {subtitle && <div style={{ fontSize: 14, color: DC.subtitle, lineHeight: '22px' }}>{subtitle}</div>}
974
+ style={{
975
+ fontSize: 'calc(20px * var(--dc-inv-zoom, 1))', fontWeight: 600, color: DC.title,
976
+ letterSpacing: 'calc(-0.4px * var(--dc-inv-zoom, 1))',
977
+ marginBottom: 'calc(2px * var(--dc-inv-zoom, 1))',
978
+ display: 'inline-block', lineHeight: 'calc(28px * var(--dc-inv-zoom, 1))',
979
+ }} />
980
+ {subtitle && <div style={{ fontSize: 'calc(14px * var(--dc-inv-zoom, 1))', color: DC.subtitle, lineHeight: 'calc(22px * var(--dc-inv-zoom, 1))' }}>{subtitle}</div>}
796
981
  </div>
797
982
  </div>
798
983
  <div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
@@ -1035,14 +1220,17 @@ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorde
1035
1220
 
1036
1221
  return (
1037
1222
  <div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
1038
- <div className="dc-header" data-miaoda-chrome="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
1223
+ {/* Desktop stops pointerdown so header interactions never start a
1224
+ bg-pan; mobile pans FROM chrome too (slop arbitration keeps label
1225
+ taps working), so the event must bubble to the viewport there. */}
1226
+ <div className="dc-header" data-miaoda-chrome="" style={{ color: DC.label }} onPointerDown={dcIsMobile ? undefined : (e) => e.stopPropagation()}>
1039
1227
  <div className="dc-labelrow">
1040
- <div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
1228
+ <div className="dc-grip" onPointerDown={dcIsMobile ? undefined : onGripDown} title="Drag to reorder">
1041
1229
  <svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
1042
1230
  </div>
1043
1231
  <div className="dc-labeltext" onClick={onFocus} title="Click to focus">
1044
1232
  <DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
1045
- style={{ fontSize: 14, fontWeight: 300, color: DC.label, lineHeight: '22px' }} />
1233
+ style={{ fontSize: 14, fontWeight: 400, color: DC.label, lineHeight: '22px' }} />
1046
1234
  </div>
1047
1235
  </div>
1048
1236
  <div className="dc-btns">
@@ -1086,6 +1274,13 @@ function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorde
1086
1274
  // Inline rename — commits on blur or Enter.
1087
1275
  function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
1088
1276
  const T = tag;
1277
+ // Mobile is browse-only: render plain text — no contentEditable (a tap
1278
+ // would pop the soft keyboard), no stopPropagation (pans start on labels
1279
+ // too), and no onClick swallow (desktop uses it to keep an edit-click from
1280
+ // bubbling; on mobile the tap should bubble so a label tap opens focus).
1281
+ if (dcIsMobile) {
1282
+ return <T className="dc-editable" style={style}>{value}</T>;
1283
+ }
1089
1284
  return (
1090
1285
  <T className="dc-editable" contentEditable suppressContentEditableWarning
1091
1286
  onClick={onClick}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-registry-buildless",
3
- "version": "0.1.4",
3
+ "version": "0.1.5-alpha.20260717111512",
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": [
@@ -12,8 +12,8 @@
12
12
  * hidden while presenting / in preview / on narrow / noscale / no-rail.
13
13
  * (b) keyboard navigation — ←/→, PgUp/PgDn, Space, Home/End, number keys.
14
14
  * On touch devices, tapping the left/right half of the stage goes
15
- * prev/next — taps on links, buttons and other interactive slide
16
- * content are left alone.
15
+ * prev/next (not in mobile browse mode — see (g)) — taps on links,
16
+ * buttons and other interactive slide content are left alone.
17
17
  * (c) press R to reset to slide 0.
18
18
  * (d) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
19
19
  * scaled with `transform: scale()` to fit the viewport, letterboxed.
@@ -34,6 +34,10 @@
34
34
  * (≤640px), and via the `no-rail` attribute. Rail mutations dispatch
35
35
  * a `deckchange`
36
36
  * CustomEvent on the element: detail = {action, from, to, slide}.
37
+ * (g) mobile — on mobile devices the same deck automatically renders as
38
+ * a scrollable vertical card list (browse) and a rotated fullscreen
39
+ * presenting mode. This needs NO authoring changes: never hand-roll
40
+ * mobile/responsive slide layouts. Desktop behaviour is unchanged.
37
41
  *
38
42
  * Slides are HIDDEN, not unmounted. Non-active slides stay in the DOM with
39
43
  * `visibility: hidden` + `opacity: 0`, so their state (videos, iframes,
@@ -116,6 +120,22 @@
116
120
  // Slide-authored controls that should keep a tap instead of it navigating.
117
121
  const INTERACTIVE_SEL = 'a[href], button, input, select, textarea, summary, label, video[controls], audio[controls], [role="button"], [onclick], [tabindex]:not([tabindex^="-"]), [contenteditable]:not([contenteditable="false" i])';
118
122
 
123
+ // Mirrors the host platform's isMobile() (UA regex + iPad maxTouchPoints);
124
+ // the product iframe is cross-origin, so the shared helper can't be
125
+ // imported. Deliberately NOT viewport-based: the desktop workbench renders
126
+ // decks in a narrow preview iframe that a width breakpoint would misread
127
+ // as mobile. Keep the regex in sync with design-canvas.jsx's copy.
128
+ const MOBILE_UA_RE = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i;
129
+ const isMobileUA = () =>
130
+ MOBILE_UA_RE.test(navigator.userAgent) ||
131
+ (/iPad|Tab|Tablet/i.test(navigator.userAgent) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 1);
132
+ // Mobile visual metrics (values from the platform's mobile design spec).
133
+ const MOB_MARGIN = 12; // card-stream edge margin
134
+ const MOB_GAP = 8; // gap between cards
135
+ const MOB_RADIUS = 8; // card corner radius
136
+ const MOB_BADGE_IDLE_MS = 2000; // scroll idle before the page badge fades
137
+ const MOB_UI_HIDE_MS = 3000; // presenting controls auto-hide
138
+
119
139
  const pad2 = (n) => String(n).padStart(2, '0');
120
140
 
121
141
  // Label precedence: data-label → data-screen-label (number stripped) → first heading → "Slide".
@@ -380,6 +400,145 @@
380
400
  .rail[data-presenting] + .rail-resize,
381
401
  .rail[data-user-hidden] + .rail-resize { display: none; }
382
402
 
403
+ /* ── Mobile browse: vertical card stream ── */
404
+ :host([data-mobile-list]) .stage {
405
+ display: block;
406
+ overflow-y: auto;
407
+ -webkit-overflow-scrolling: touch;
408
+ background: #F8F9FA;
409
+ }
410
+ :host([data-mobile-list]) .canvas {
411
+ transform: none !important;
412
+ background: transparent;
413
+ will-change: auto;
414
+ }
415
+ :host([data-mobile-list][data-windowed]) .canvas { border: none; box-shadow: none; }
416
+ :host([data-mobile-list]) .rail, :host([data-mobile-list]) .rail-resize { display: none; }
417
+ :host([data-mobile-list]) ::slotted(*) {
418
+ opacity: 1;
419
+ visibility: visible;
420
+ pointer-events: auto;
421
+ transform-origin: 0 0;
422
+ box-sizing: border-box;
423
+ }
424
+ /* Skipped slides get no nth-child placement rule from _fitMobileList —
425
+ without this they'd fall back to the base inset:0 stacking rule and,
426
+ now force-visible, paint over the whole list canvas and swallow taps.
427
+ (The equivalent rule further down lives inside @media print only.) */
428
+ :host([data-mobile-list]) ::slotted([data-deck-skip]) { display: none !important; }
429
+
430
+ /* ── Mobile presenting: CSS pseudo-landscape (no orientation.lock) ──
431
+ Rotate only while the device is physically portrait. Once the user
432
+ turns the phone (system auto-rotate) the viewport itself is landscape,
433
+ and stacking another 90° would render slides sideways — so landscape
434
+ falls back to a plain full-bleed fit. _fitMobilePresent and
435
+ _mobPresentZone branch on the same orientation test. */
436
+ :host([data-mobile-present]) .stage { background: #000; }
437
+ @media (orientation: portrait) {
438
+ :host([data-mobile-present]) .stage {
439
+ inset: auto;
440
+ left: 50%;
441
+ top: 50%;
442
+ width: 100vh;
443
+ height: 100vw;
444
+ transform: translate(-50%, -50%) rotate(90deg);
445
+ }
446
+ }
447
+
448
+ /* ── Mobile page badge ── */
449
+ .mob-badges { display: none; }
450
+ :host([data-mobile-list]) .mob-badges {
451
+ display: block;
452
+ position: absolute;
453
+ inset: 0;
454
+ pointer-events: none;
455
+ z-index: 10;
456
+ }
457
+ .mob-badge {
458
+ position: absolute;
459
+ height: 12px;
460
+ box-sizing: border-box;
461
+ padding: 0 2px;
462
+ border-radius: 3px;
463
+ background: rgba(31, 35, 41, 0.2);
464
+ color: #fff;
465
+ font-family: 'PingFang SC', -apple-system, system-ui, sans-serif;
466
+ font-size: 8px;
467
+ line-height: 12px;
468
+ text-align: center;
469
+ opacity: 0;
470
+ transition: opacity 0.1s linear;
471
+ }
472
+ :host([data-mob-scrolling]) .mob-badge { opacity: 1; }
473
+
474
+ /* ── Mobile presenting controls (back button + filmstrip) ── */
475
+ .mob-ui { display: none; }
476
+ :host([data-mobile-present][data-mob-ui]) .mob-ui {
477
+ display: block;
478
+ position: absolute;
479
+ inset: 0;
480
+ pointer-events: none;
481
+ z-index: 20;
482
+ }
483
+ .mob-back {
484
+ position: absolute;
485
+ left: max(16px, env(safe-area-inset-left));
486
+ top: 12px;
487
+ pointer-events: auto;
488
+ display: flex;
489
+ align-items: center;
490
+ gap: 6px;
491
+ border: none;
492
+ border-radius: 999px;
493
+ padding: 6px 14px;
494
+ background: rgba(0, 0, 0, 0.4);
495
+ color: #fff;
496
+ font-family: 'PingFang SC', -apple-system, system-ui, sans-serif;
497
+ font-size: 15px;
498
+ font-weight: 500;
499
+ cursor: pointer;
500
+ }
501
+ .mob-strip {
502
+ position: absolute;
503
+ left: 0;
504
+ right: 0;
505
+ bottom: 0;
506
+ pointer-events: auto;
507
+ display: flex;
508
+ gap: 8px;
509
+ padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
510
+ /* Transparent: thumbnails carry their own white frames, and a solid
511
+ strip would blanket the slide behind the overlay. */
512
+ background: transparent;
513
+ overflow-x: auto;
514
+ }
515
+ .mob-strip .mthumb { flex: 0 0 132px; cursor: pointer; }
516
+ .mob-strip .mframe {
517
+ width: 132px;
518
+ height: 72px;
519
+ border-radius: 8px;
520
+ border: 0.5px solid #DEE0E3;
521
+ overflow: hidden;
522
+ position: relative;
523
+ background: #fff;
524
+ }
525
+ .mob-strip .mthumb[data-current] .mframe {
526
+ outline: 1px solid #336DF4;
527
+ outline-offset: 2px;
528
+ border-radius: 10px;
529
+ }
530
+ .mob-strip .mnum {
531
+ margin-top: 2px;
532
+ text-align: center;
533
+ font-family: 'PingFang SC', -apple-system, system-ui, sans-serif;
534
+ font-size: 12px;
535
+ line-height: 20px;
536
+ font-weight: 500;
537
+ /* The strip floats over the (dark) presenting stage — numbers must be light. */
538
+ color: rgba(255, 255, 255, 0.9);
539
+ }
540
+ .mob-strip .mthumb[data-current] .mnum { color: #336DF4; }
541
+
383
542
  /* ── Speaker-notes dock ──────────────────────────────────────────────
384
543
  Bottom panel spanning the non-rail width in edit mode: a resize grip
385
544
  (drag to set height, persisted to localStorage) + an editable textarea
@@ -538,6 +697,8 @@
538
697
  // windowed margin/shadow, see _fit. A bare load with no #N hash starts at
539
698
  // slide 1, so ?thumbnail=1 alone yields a clean first-slide frame.
540
699
  this._snthumb = /[?&](_snthumb|thumbnail)=/.test(location.search);
700
+ this._mobile = isMobileUA();
701
+ this._syncMobileMode();
541
702
  if (this._snthumb) this.setAttribute('no-rail', '');
542
703
  this._render();
543
704
  this._loadNotes();
@@ -830,6 +991,9 @@
830
991
  if (this._tweakTimer) clearTimeout(this._tweakTimer);
831
992
  if (this._railAnimTimer) clearTimeout(this._railAnimTimer);
832
993
  if (this._notesPersistTimer) clearTimeout(this._notesPersistTimer);
994
+ clearTimeout(this._mobUiTimer);
995
+ clearTimeout(this._mobScrollIdleTimer);
996
+ if (this._mobScrollRaf) { cancelAnimationFrame(this._mobScrollRaf); this._mobScrollRaf = null; }
833
997
  if (this._scaleRaf) cancelAnimationFrame(this._scaleRaf);
834
998
  if (this._liveObserver) this._liveObserver.disconnect();
835
999
  if (this._railObserver) this._railObserver.disconnect();
@@ -845,6 +1009,7 @@
845
1009
  if (this._rail) {
846
1010
  this._rail.style.setProperty('--deck-aspect', this.designWidth + '/' + this.designHeight);
847
1011
  }
1012
+ this._syncMobileMode();
848
1013
  this._fit();
849
1014
  this._scaleThumbs();
850
1015
  this._syncPrintPageRule();
@@ -870,6 +1035,17 @@
870
1035
  canvas.appendChild(slot);
871
1036
  stage.appendChild(canvas);
872
1037
 
1038
+ this._onMobScroll = this._onMobScroll.bind(this);
1039
+ stage.addEventListener('scroll', this._onMobScroll, { passive: true });
1040
+
1041
+ // Per-slide placement rules for the mobile card stream; rebuilt by _fitMobileList().
1042
+ this._mobStyle = document.createElement('style');
1043
+
1044
+ this._mobBadges = document.createElement('div');
1045
+ this._mobBadges.className = 'mob-badges export-hidden';
1046
+ this._mobBadges.setAttribute('data-miaoda-chrome', '');
1047
+ canvas.appendChild(this._mobBadges);
1048
+
873
1049
  // Thumbnail rail + context menu. Thumbnails are populated in
874
1050
  // _renderRail() after _collectSlides().
875
1051
  const rail = document.createElement('div');
@@ -1010,7 +1186,7 @@
1010
1186
  // the note instead of navigating slides (same trick as the thumb keydown).
1011
1187
  notesBody.addEventListener('keydown', (e) => e.stopPropagation());
1012
1188
 
1013
- this._root.append(style, rail, resize, stage, notes, menu);
1189
+ this._root.append(style, this._mobStyle, rail, resize, stage, notes, menu);
1014
1190
  this._canvas = canvas;
1015
1191
  this._stage = stage;
1016
1192
  this._slot = slot;
@@ -1285,9 +1461,162 @@
1285
1461
  }));
1286
1462
  }
1287
1463
 
1464
+ if (this.hasAttribute('data-mob-ui')) this._syncMobStrip();
1465
+
1288
1466
  this._prevIndex = curr;
1289
1467
  }
1290
1468
 
1469
+ // The two mobile presentations are mutually exclusive. noscale and
1470
+ // _snthumb are capture modes whose output must keep desktop geometry,
1471
+ // so they opt out of both.
1472
+ _syncMobileMode() {
1473
+ const eligible = this._mobile && !this.hasAttribute('noscale') && !this._snthumb;
1474
+ this.toggleAttribute('data-mobile-list', eligible && !this._presenting);
1475
+ this.toggleAttribute('data-mobile-present', eligible && !!this._presenting);
1476
+ }
1477
+
1478
+ _fitMobileList() {
1479
+ const dw = this.designWidth, dh = this.designHeight;
1480
+ const vw = window.innerWidth;
1481
+ this._mobLastVw = vw; // baseline for _onResize's height-only skip
1482
+ const stage = this._canvas.parentElement;
1483
+ // Clear the stage inset the desktop _fit() wrote inline.
1484
+ if (stage) { stage.style.left = '0'; stage.style.right = '0'; stage.style.top = '0'; stage.style.bottom = '0'; }
1485
+ this.removeAttribute('data-windowed');
1486
+ const cardW = vw - 2 * MOB_MARGIN;
1487
+ const s = cardW / dw;
1488
+ const cardH = dh * s;
1489
+ const kids = Array.prototype.slice.call(this.children);
1490
+ const rules = [];
1491
+ const cards = [];
1492
+ let y = MOB_MARGIN;
1493
+ this._slides.forEach((slide, i) => {
1494
+ if (slide.hasAttribute('data-deck-skip')) return;
1495
+ const nth = kids.indexOf(slide) + 1;
1496
+ if (nth <= 0) return;
1497
+ rules.push(
1498
+ ':host([data-mobile-list]) ::slotted(:nth-child(' + nth + ')) {' +
1499
+ ' inset: auto !important; top: 0 !important; left: 0 !important;' +
1500
+ ' width: ' + dw + 'px !important; height: ' + dh + 'px !important;' +
1501
+ ' transform: translate(' + MOB_MARGIN + 'px, ' + y + 'px) scale(' + s + ') !important;' +
1502
+ // Radius/border are pre-divided by the scale so the slide's transform
1503
+ // lands them at 8px / 0.5px on screen.
1504
+ ' border-radius: ' + (MOB_RADIUS / s) + 'px; overflow: hidden !important;' +
1505
+ ' border: ' + (0.5 / s) + 'px solid #DEE0E3; }'
1506
+ );
1507
+ cards.push({ slide, i, y, h: cardH });
1508
+ y += cardH + MOB_GAP;
1509
+ });
1510
+ // Screen-only: these per-slide rules out-specify the print block's
1511
+ // ::slotted reset, so unwrapped they would drag the card-stream
1512
+ // transforms into the paginated print output.
1513
+ this._mobStyle.textContent = '@media screen {\n' + rules.join('\n') + '\n}';
1514
+ this._canvas.style.transform = 'none';
1515
+ this._canvas.style.width = vw + 'px';
1516
+ this._canvas.style.height = Math.max(y - MOB_GAP + MOB_MARGIN, 0) + 'px';
1517
+ this._mobCards = cards;
1518
+ this._mobScale = s;
1519
+
1520
+ // Badge geometry is fully determined by _mobCards, so badges are laid
1521
+ // out directly — no IntersectionObserver needed.
1522
+ this._mobBadges.textContent = '';
1523
+ cards.forEach((c, ordinal) => {
1524
+ const b = document.createElement('div');
1525
+ b.className = 'mob-badge';
1526
+ const n = ordinal + 1;
1527
+ b.textContent = String(n);
1528
+ b.style.width = (String(n).length >= 3 ? 20 : 16) + 'px';
1529
+ b.style.left = (MOB_MARGIN + 6) + 'px';
1530
+ b.style.top = (c.y + c.h - 6 - 12) + 'px';
1531
+ this._mobBadges.appendChild(b);
1532
+ });
1533
+
1534
+ this._mobScrollTo(this._index, false);
1535
+ }
1536
+
1537
+ _onMobScroll() {
1538
+ if (!this.hasAttribute('data-mobile-list') || !this._mobCards || !this._mobCards.length) return;
1539
+
1540
+ // Programmatic scrolls count as scrolling too — either source means
1541
+ // "the position is changing", which is what the badge signals.
1542
+ this.setAttribute('data-mob-scrolling', '');
1543
+ clearTimeout(this._mobScrollIdleTimer);
1544
+ this._mobScrollIdleTimer = setTimeout(
1545
+ () => this.removeAttribute('data-mob-scrolling'), MOB_BADGE_IDLE_MS);
1546
+
1547
+ if (this._mobScrollRaf) return;
1548
+ this._mobScrollRaf = requestAnimationFrame(() => {
1549
+ this._mobScrollRaf = null;
1550
+ const stage = this._canvas.parentElement;
1551
+ const top = stage.scrollTop;
1552
+ const moved = this._mobLastTop == null ? Infinity : Math.abs(top - this._mobLastTop);
1553
+ this._mobLastTop = top; // keep tracking even while suppressed
1554
+ if (this._mobProgUntil && performance.now() < this._mobProgUntil) {
1555
+ // Events are still streaming from a programmatic scroll: extend
1556
+ // the window so any animation length is covered. It expires 120ms
1557
+ // after the stream stops; user scrolls then resume inference.
1558
+ this._mobProgUntil = Math.max(this._mobProgUntil, performance.now() + 120);
1559
+ return;
1560
+ }
1561
+ if (moved < 1) return; // no real displacement (synthetic/terminal event)
1562
+ // A pure viewport-centre rule can never select the first/last page:
1563
+ // 16:9 cards are far shorter than half a portrait viewport, so their
1564
+ // centring targets clamp. Clamp at the extremes, centre in between.
1565
+ const max = stage.scrollHeight - stage.clientHeight;
1566
+ let cur;
1567
+ if (max <= 0 || stage.scrollTop <= 1) {
1568
+ cur = this._mobCards[0];
1569
+ } else if (stage.scrollTop >= max - 1) {
1570
+ cur = this._mobCards[this._mobCards.length - 1];
1571
+ } else {
1572
+ const center = stage.scrollTop + stage.clientHeight / 2;
1573
+ cur = this._mobCards[0];
1574
+ for (const c of this._mobCards) {
1575
+ if (center >= c.y) cur = c; else break;
1576
+ }
1577
+ }
1578
+ if (cur.i !== this._index) {
1579
+ this._index = cur.i;
1580
+ // Broadcast through the existing core (hash + slide-changed +
1581
+ // slidechange). data-deck-active toggling is visually inert here.
1582
+ this._applyIndex({ broadcast: true, reason: 'api' });
1583
+ }
1584
+ });
1585
+ }
1586
+
1587
+ _mobScrollTo(i, smooth) {
1588
+ const card = (this._mobCards || []).find((c) => c.i === i);
1589
+ if (!card) return;
1590
+ const stage = this._canvas.parentElement;
1591
+ const top = Math.max(0, card.y - Math.max(0, (stage.clientHeight - card.h) / 2));
1592
+ // Suppress scroll-position inference while a programmatic scroll is
1593
+ // in flight: goto/deep-link already set _index directly, and the
1594
+ // centring targets of several edge cards clamp to the same scrollTop,
1595
+ // so the position alone cannot tell those pages apart.
1596
+ this._mobProgUntil = performance.now() + (smooth ? 800 : 200);
1597
+ // Seed the baseline with the target: a scrollTo that lands where the
1598
+ // stage already is fires no scroll event, and a null baseline would
1599
+ // make the next no-op event read as a real first displacement.
1600
+ this._mobLastTop = top;
1601
+ stage.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
1602
+ }
1603
+
1604
+ _fitMobilePresent() {
1605
+ const stage = this._canvas.parentElement;
1606
+ if (stage) { stage.style.left = ''; stage.style.right = ''; stage.style.top = ''; stage.style.bottom = ''; }
1607
+ this.removeAttribute('data-windowed');
1608
+ this._canvas.style.width = this.designWidth + 'px';
1609
+ this._canvas.style.height = this.designHeight + 'px';
1610
+ // Portrait: the stage is CSS-rotated, so its logical size is the
1611
+ // swapped viewport. Landscape (user physically rotated): no rotation,
1612
+ // plain fit. The CSS branches on the same orientation query.
1613
+ const portrait = window.innerHeight >= window.innerWidth;
1614
+ const lw = portrait ? window.innerHeight : window.innerWidth;
1615
+ const lh = portrait ? window.innerWidth : window.innerHeight;
1616
+ const s = Math.min(lw / this.designWidth, lh / this.designHeight);
1617
+ this._canvas.style.transform = 'scale(' + s + ')';
1618
+ }
1619
+
1291
1620
  _railWidth() {
1292
1621
  // State-based, no offsetWidth: the first _fit() can run before the
1293
1622
  // rail has had layout on some load paths, and a 0 there paints the
@@ -1301,6 +1630,8 @@
1301
1630
 
1302
1631
  _fit() {
1303
1632
  if (!this._canvas) return;
1633
+ if (this.hasAttribute('data-mobile-list')) return this._fitMobileList();
1634
+ if (this.hasAttribute('data-mobile-present')) return this._fitMobilePresent();
1304
1635
  const stage = this._canvas.parentElement;
1305
1636
  // PPTX export sets noscale so the DOM capture sees authored-size
1306
1637
  // geometry — the scaled canvas is in shadow DOM, so the exporter's
@@ -1311,6 +1642,8 @@
1311
1642
  if (stage) { stage.style.left = '0'; stage.style.right = '0'; stage.style.top = '0'; stage.style.bottom = '0'; }
1312
1643
  return;
1313
1644
  }
1645
+ this._canvas.style.width = this.designWidth + 'px';
1646
+ this._canvas.style.height = this.designHeight + 'px';
1314
1647
  const rw = this._railWidth();
1315
1648
  const nh = this._notesHeight();
1316
1649
  // Windowed (20px margin + card shadow) in edit/preview; full-bleed while
@@ -1336,6 +1669,14 @@
1336
1669
  }
1337
1670
 
1338
1671
  _onResize() {
1672
+ // Card-stream geometry depends only on viewport width, and mobile
1673
+ // browser URL bars fire height-only resizes as they collapse/expand —
1674
+ // a full refit would rebuild everything and yank the list back to the
1675
+ // current page mid-scroll. Width changes (rotate/split) refit as usual.
1676
+ if (this.hasAttribute('data-mobile-list')) {
1677
+ if (this._mobLastVw === window.innerWidth) return;
1678
+ this._mobLastVw = window.innerWidth;
1679
+ }
1339
1680
  this._fit();
1340
1681
  // Crossing the narrow-viewport breakpoint reveals the rail — rerun the
1341
1682
  // thumbnail scale the same way _setRailWidth does.
@@ -1351,6 +1692,8 @@
1351
1692
  const d = e.data;
1352
1693
  if (d && d.type === 'miaoda:deck:presenting' && typeof d.on === 'boolean') {
1353
1694
  this._presenting = d.on;
1695
+ this._syncMobileMode();
1696
+ if (!d.on) this._toggleMobUi(false);
1354
1697
  this._syncRailHidden();
1355
1698
  this._syncNotesHidden();
1356
1699
  this._closeMenu();
@@ -1431,12 +1774,122 @@
1431
1774
  if (n === this._stage) break;
1432
1775
  if (n.matches && n.matches(INTERACTIVE_SEL)) return;
1433
1776
  }
1777
+ // Mobile browse is a scroll list — taps must not navigate.
1778
+ if (this.hasAttribute('data-mobile-list')) return;
1434
1779
  e.preventDefault();
1780
+ if (this.hasAttribute('data-mobile-present')) {
1781
+ const zone = this._mobPresentZone(e);
1782
+ if (zone === 'menu') this._toggleMobUi();
1783
+ else {
1784
+ this._advance(zone === 'prev' ? -1 : 1, 'tap');
1785
+ if (this.hasAttribute('data-mob-ui')) this._toggleMobUi(true);
1786
+ }
1787
+ return;
1788
+ }
1435
1789
  const rw = this._railWidth();
1436
1790
  const mid = rw + (window.innerWidth - rw) / 2;
1437
1791
  this._advance(e.clientX < mid ? -1 : 1, 'tap');
1438
1792
  }
1439
1793
 
1794
+ // After rotate(90deg) the visual-landscape x axis maps onto viewport y
1795
+ // (direction confirmed on device; if prev/next ever read reversed, flip
1796
+ // these comparisons — single point of change).
1797
+ _mobPresentZone(e) {
1798
+ const rect = this._stage.getBoundingClientRect();
1799
+ // Portrait = stage is rotated, visual x comes from viewport y;
1800
+ // landscape = no rotation, viewport x directly. Same branch as the fit.
1801
+ const portrait = window.innerHeight >= window.innerWidth;
1802
+ const x = portrait
1803
+ ? (e.clientY - rect.top) / Math.max(1, rect.height)
1804
+ : (e.clientX - rect.left) / Math.max(1, rect.width);
1805
+ return x < 1 / 3 ? 'prev' : x > 2 / 3 ? 'next' : 'menu';
1806
+ }
1807
+
1808
+ _toggleMobUi(force) {
1809
+ const wasOpen = this.hasAttribute('data-mob-ui');
1810
+ const on = force != null ? !!force : !wasOpen;
1811
+ this.toggleAttribute('data-mob-ui', on);
1812
+ clearTimeout(this._mobUiTimer);
1813
+ if (!on) return;
1814
+ // Rebuild only when opening from hidden: the strip is a static
1815
+ // snapshot outside _liveObserver's refresh pipeline, so reopening is
1816
+ // what absorbs slide edits/reorders/skips. Calls while already open
1817
+ // (thumbnail taps resetting the timer) only re-highlight — rebuilding
1818
+ // on that hot path would clone every slide per tap.
1819
+ if (!wasOpen || !this._mobUi) {
1820
+ if (this._mobUi) { this._mobUi.remove(); this._mobUi = null; }
1821
+ this._buildMobUi();
1822
+ }
1823
+ this._syncMobStrip();
1824
+ this._mobUiTimer = setTimeout(() => this._toggleMobUi(false), MOB_UI_HIDE_MS);
1825
+ }
1826
+
1827
+ _buildMobUi() {
1828
+ // A pure-viewer host may never send rail-enabled, so the author-CSS
1829
+ // snapshot (normally built by _enableRail) can be missing and the
1830
+ // clones would render unstyled — build it here as a fallback.
1831
+ // _snapshotAuthorCss is re-entrant and generation-guarded.
1832
+ if (!this._adoptedSheet && this._authorCss == null) this._snapshotAuthorCss();
1833
+ const ui = document.createElement('div');
1834
+ ui.className = 'mob-ui export-hidden';
1835
+ ui.setAttribute('data-miaoda-chrome', '');
1836
+ const back = document.createElement('button');
1837
+ back.className = 'mob-back';
1838
+ back.type = 'button';
1839
+ back.textContent = '‹ 演示模式';
1840
+ back.addEventListener('click', (e) => {
1841
+ e.stopPropagation();
1842
+ this._presenting = false;
1843
+ this._syncMobileMode();
1844
+ this._syncRailHidden();
1845
+ this._syncNotesHidden();
1846
+ this._fit();
1847
+ this._toggleMobUi(false);
1848
+ try { window.parent.postMessage({ type: 'miaoda:deck:presenting-dismissed' }, '*'); } catch (err) {}
1849
+ });
1850
+ const strip = document.createElement('div');
1851
+ strip.className = 'mob-strip';
1852
+ this._mobStripEntries = this._slides
1853
+ .map((slide, i) => ({ slide, i }))
1854
+ .filter((e2) => !e2.slide.hasAttribute('data-deck-skip'))
1855
+ .map((e2, ordinal) => {
1856
+ const t = document.createElement('div');
1857
+ t.className = 'mthumb';
1858
+ const frame = document.createElement('div');
1859
+ frame.className = 'mframe';
1860
+ const num = document.createElement('div');
1861
+ num.className = 'mnum';
1862
+ num.textContent = String(ordinal + 1);
1863
+ t.append(frame, num);
1864
+ t.addEventListener('click', (ev) => {
1865
+ ev.stopPropagation();
1866
+ this._go(e2.i, 'click');
1867
+ this._toggleMobUi(true); // reset the auto-hide timer, keep the overlay up
1868
+ });
1869
+ strip.appendChild(t);
1870
+ // _materialize provides the full clone pipeline (media neutralised,
1871
+ // canvas snapshots) and mounts a host into entry.frame itself — but
1872
+ // it scales clones by the rail's _thumbScale, so rewrite the
1873
+ // transform for this strip's fixed 132px frames.
1874
+ const entry = { slide: e2.slide, frame, host: null, clone: null };
1875
+ this._materialize(entry);
1876
+ if (entry.clone) entry.clone.style.transform = 'scale(' + (132 / this.designWidth) + ')';
1877
+ return { el: t, i: e2.i };
1878
+ });
1879
+ // Clicks inside the overlay must not bubble into tap navigation.
1880
+ ui.addEventListener('click', (e) => e.stopPropagation());
1881
+ ui.append(back, strip);
1882
+ this._stage.appendChild(ui);
1883
+ this._mobUi = ui;
1884
+ }
1885
+
1886
+ _syncMobStrip() {
1887
+ (this._mobStripEntries || []).forEach((en) => {
1888
+ en.el.toggleAttribute('data-current', en.i === this._index);
1889
+ if (en.i === this._index) en.el.scrollIntoView({ block: 'nearest', inline: 'center' });
1890
+ });
1891
+ }
1892
+
1440
1893
  _onKey(e) {
1441
1894
  // Ignore when the user is typing.
1442
1895
  const t = e.target;
@@ -1477,6 +1930,18 @@
1477
1930
  _go(i, reason = 'api') {
1478
1931
  if (!this._slides.length) return;
1479
1932
  const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
1933
+ // Mobile browse: navigation sets _index directly (desktop parity) and
1934
+ // the scroll is only a visual follow. Inferring the page back from the
1935
+ // scroll position is unreliable here — edge cards' centring targets
1936
+ // clamp to the same scrollTop.
1937
+ if (this.hasAttribute('data-mobile-list')) {
1938
+ const changed = clamped !== this._index;
1939
+ this._index = clamped;
1940
+ // Same-page goto doesn't rebroadcast (desktop parity) but still recentres.
1941
+ if (changed) this._applyIndex({ broadcast: true, reason });
1942
+ this._mobScrollTo(clamped, true);
1943
+ return;
1944
+ }
1480
1945
  if (clamped === this._index) return;
1481
1946
  this._index = clamped;
1482
1947
  this._applyIndex({ broadcast: true, reason });
@@ -1714,9 +2179,12 @@
1714
2179
  if (entry.host) return;
1715
2180
  const dw = this.designWidth, dh = this.designHeight;
1716
2181
  let clone = entry.slide.cloneNode(true);
1717
- clone.removeAttribute('id');
2182
+ // Keep ids: the clone lives in its own shadow root, so duplicate ids
2183
+ // can't clash with the document (getElementById doesn't pierce shadow
2184
+ // roots), while id-keyed author styles (#slide-01 { background: … })
2185
+ // MUST keep matching — stripping ids rendered such decks as blank
2186
+ // white thumbnails (styles all failed to match).
1718
2187
  clone.removeAttribute('data-deck-active');
1719
- clone.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
1720
2188
  // Neuter heavy media; replace <video> with its poster so the box
1721
2189
  // keeps a visual. <iframe>/<audio> become empty placeholders.
1722
2190
  clone.querySelectorAll('iframe, audio, object, embed').forEach((el) => {