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

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.
@@ -29,10 +29,9 @@
29
29
  // suppress the card chrome and let the artboard size to its content:
30
30
  // <DCArtboard chromeless><IOSDevice>…</IOSDevice></DCArtboard>
31
31
  //
32
- // On mobile (UA-detected) the canvas is browse-only: one-finger drag pans
33
- // (starting on artboards too), two-finger pinch zooms, the zoom control is
34
- // hidden, and drag-reorder / inline-rename are disabled. Tap on an artboard
35
- // label still opens the focus overlay.
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.
36
35
  /* END USAGE */
37
36
 
38
37
  const DC = {
@@ -45,10 +44,11 @@ const DC = {
45
44
  font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
46
45
  };
47
46
 
48
- // ── 移动端判定:复刻主仓 @apaas-ai/global-states isMobile() 的 UA 语义
49
- // (跨域产物 iframe 无法 import 该包,同 deck-stage 的先例)。刻意不用视口
50
- // 断点——桌面工作台的预览 iframe 本身就窄,视口宽度会把桌面预览误判成移动端。
51
- // 启动时一次性判定,不随 resize 抖动。
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
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
53
  const dcIsMobile = typeof navigator !== 'undefined' &&
54
54
  (DC_MOBILE_UA_RE.test(navigator.userAgent) ||
@@ -112,14 +112,14 @@ if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
112
112
  // Mobile is browse-only: kill text selection + iOS long-press callout
113
113
  // inside the canvas. iOS interprets a drag over selectable TEXT as a
114
114
  // selection gesture and steals the pointer (pointercancel) — touch-action
115
- // only suppresses native pan/zoom, not selection — so text-dense
116
- // artboards became un-pannable while image-heavy ones panned fine.
115
+ // only suppresses native pan/zoom, not selection, so a drag
116
+ // starting on selectable text never reaches the pan.
117
117
  '.dc-mobile, .dc-mobile *{-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}',
118
118
  // Prototype content routinely carries inner scrollers (the device/window
119
119
  // frames all have overflow:auto bodies). On iOS a touch starting inside a
120
120
  // scrollable element is consumed by THAT scroller (native scroll attempt
121
- // + pointercancel), so pans could never start on those artboards while
122
- // plain-HTML cards panned fine. Mobile canvas is browse-only: kill native
121
+ // + pointercancel), so a pan could
122
+ // never start there. Mobile canvas is browse-only: kill native
123
123
  // touch handling on everything inside the cards so gestures always reach
124
124
  // the viewport state machine. Desktop (no .dc-mobile) keeps inner scroll.
125
125
  '.dc-mobile [data-dc-slot] *{touch-action:none !important}',
@@ -740,9 +740,9 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
740
740
  const touches = new Map(); // pointerId -> last {x, y}
741
741
  let mobMode = null; // null | 'tap' | 'pan' | 'pinch'
742
742
  let tapStart = null; // down point for slop arbitration
743
- let pinchDist = 0; // last-applied finger distance (rAF 帧粒度)
743
+ let pinchDist = 0; // finger distance as of the last applied frame
744
744
  let pinchMid = null; // last-applied midpoint
745
- let panApplied = null; // 单指 pan 最近一次已应用的触点位置
745
+ let panApplied = null; // pointer position as of the last applied pan frame
746
746
  const MOB_SLOP = 9; // px of travel before a tap becomes a pan
747
747
 
748
748
  // First two insertion-ordered touches define the pinch; a third finger is
@@ -758,7 +758,7 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
758
758
  mobMode = 'tap';
759
759
  tapStart = { x: e.clientX, y: e.clientY };
760
760
  } else if (touches.size === 2) {
761
- mobFlushNow(); // 单指 pan 有待应用的帧则先落账,再切 pinch 基准
761
+ mobFlushNow(); // apply any pending pan frame before rebasing as a pinch
762
762
  mobMode = 'pinch';
763
763
  const g = pinchGeom();
764
764
  pinchDist = g.d;
@@ -766,11 +766,13 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
766
766
  try { vp.setPointerCapture(e.pointerId); } catch {}
767
767
  }
768
768
  };
769
- // ── rAF 合帧:move 事件只更新触点坐标,几何应用每帧至多一次。
770
- // 高刷屏/快速捏合时 pointermove 频率可远超渲染帧率,逐事件全量跑
771
- // zoomAt/apply(布局读写 + 样式失效 + postMessage)会以超过引擎回收
772
- // 节奏的速度制造 WebKit 堆垃圾——真机快速捏合表现为内存持续攀升
773
- // (vmmap 定位在 WebKit Malloc;帧率化应用后实测完全回收)。
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.
774
776
  let mobRaf = 0;
775
777
  const mobFlushFrame = () => {
776
778
  mobRaf = 0;
@@ -811,7 +813,7 @@ function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
811
813
  if (mobMode === 'pan' || mobMode === 'pinch') mobSchedule();
812
814
  };
813
815
  const onTouchUp = (e) => {
814
- mobFlushNow(); // 抬指前把最后一个待应用帧落账,避免尾部位移丢失
816
+ mobFlushNow(); // apply the pending frame first so the gesture tail isn't lost
815
817
  if (!touches.delete(e.pointerId)) return;
816
818
  try { vp.releasePointerCapture(e.pointerId); } catch {}
817
819
  if (touches.size === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-registry-buildless",
3
- "version": "0.1.5-alpha.20260717102042",
3
+ "version": "0.1.5",
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,9 +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 (desktop / presenting mode; mobile browse mode scrolls
16
- * instead — see (g)) — taps on links, buttons and other interactive
17
- * slide 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.
18
17
  * (c) press R to reset to slide 0.
19
18
  * (d) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
20
19
  * scaled with `transform: scale()` to fit the viewport, letterboxed.
@@ -35,18 +34,10 @@
35
34
  * (≤640px), and via the `no-rail` attribute. Rail mutations dispatch
36
35
  * a `deckchange`
37
36
  * CustomEvent on the element: detail = {action, from, to, slide}.
38
- * (g) mobile — on mobile UA (mirrors the host platform's isMobile()
39
- * semantics: UA regex + iPad maxTouchPoints, never viewport width),
40
- * browse mode renders slides as a vertical card stream (12px margin,
41
- * 8px gap, 8px radius) with native scrolling; a per-card page badge
42
- * fades in while scrolling (16|20×12, ink 20% bg, fixed 8px digits)
43
- * and fades out 2s after scrolling stops. Presenting mode
44
- * (miaoda:deck:presenting) rotates the stage 90° (CSS pseudo-
45
- * landscape, no orientation.lock): tap left/right third to navigate,
46
- * tap center to toggle the controls overlay (back button +
47
- * filmstrip; auto-hides after 3s). The back button exits presenting
48
- * and posts miaoda:deck:presenting-dismissed to the parent.
49
- * Desktop behaviour is unchanged.
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.
50
41
  *
51
42
  * Slides are HIDDEN, not unmounted. Non-active slides stay in the DOM with
52
43
  * `visibility: hidden` + `opacity: 0`, so their state (videos, iframes,
@@ -129,20 +120,21 @@
129
120
  // Slide-authored controls that should keep a tap instead of it navigating.
130
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])';
131
122
 
132
- // ── 移动端(复刻主仓 @apaas-ai/global-states isMobile() 的 UA 语义;
133
- // 跨域产物 iframe 无法 import 该包)。刻意不用视口断点:桌面工作台的
134
- // 预览 iframe 本身就窄,视口宽度会把桌面预览误判成移动端。
135
- // mode=desktop 逃生阀只作用于壳层自身 location、不透传到本 iframe,不实现。
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.
136
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;
137
129
  const isMobileUA = () =>
138
130
  MOBILE_UA_RE.test(navigator.userAgent) ||
139
131
  (/iPad|Tab|Tablet/i.test(navigator.userAgent) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 1);
140
- // 移动端视觉常量(Figma node 2965:32138 / 2965:32047 / 2965:34386 已确认)
141
- const MOB_MARGIN = 12; // 竖排卡片页边距
142
- const MOB_GAP = 8; // 卡片间距
143
- const MOB_RADIUS = 8; // 卡片圆角
144
- const MOB_BADGE_IDLE_MS = 2000; // 滚动停止 → 徽标淡出(UX 确认 2s)
145
- const MOB_UI_HIDE_MS = 3000; // 播放控件层自动收起
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
146
138
 
147
139
  const pad2 = (n) => String(n).padStart(2, '0');
148
140
 
@@ -408,7 +400,7 @@
408
400
  .rail[data-presenting] + .rail-resize,
409
401
  .rail[data-user-hidden] + .rail-resize { display: none; }
410
402
 
411
- /* ── 移动端竖排浏览 ─────────────────────────────────────── */
403
+ /* ── Mobile browse: vertical card stream ── */
412
404
  :host([data-mobile-list]) .stage {
413
405
  display: block;
414
406
  overflow-y: auto;
@@ -435,11 +427,12 @@
435
427
  (The equivalent rule further down lives inside @media print only.) */
436
428
  :host([data-mobile-list]) ::slotted([data-deck-skip]) { display: none !important; }
437
429
 
438
- /* ── 移动端播放:CSS 旋转伪横屏(不依赖 orientation.lock)────
439
- 只在设备物理竖屏时旋转;用户顺势把手机转成横屏(系统自动旋转)
440
- 后,视口本身已是横屏,再叠 90° 会让画面侧躺——此时退化为常规
441
- full-bleed 适配,_fitMobilePresent/_mobPresentZone 同步按
442
- orientation 分支换轴。 */
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. */
443
436
  :host([data-mobile-present]) .stage { background: #000; }
444
437
  @media (orientation: portrait) {
445
438
  :host([data-mobile-present]) .stage {
@@ -452,7 +445,7 @@
452
445
  }
453
446
  }
454
447
 
455
- /* ── 移动端页码徽标(规格:Figma 2965:32047)────────────── */
448
+ /* ── Mobile page badge ── */
456
449
  .mob-badges { display: none; }
457
450
  :host([data-mobile-list]) .mob-badges {
458
451
  display: block;
@@ -478,7 +471,7 @@
478
471
  }
479
472
  :host([data-mob-scrolling]) .mob-badge { opacity: 1; }
480
473
 
481
- /* ── 移动端播放控件层(规格:Figma 2965:34386)──────────── */
474
+ /* ── Mobile presenting controls (back button + filmstrip) ── */
482
475
  .mob-ui { display: none; }
483
476
  :host([data-mobile-present][data-mob-ui]) .mob-ui {
484
477
  display: block;
@@ -514,7 +507,8 @@
514
507
  display: flex;
515
508
  gap: 8px;
516
509
  padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
517
- /* 透明浮在 slide 上(验收对齐设计稿)——缩略图自带白底+描边已足够区分 */
510
+ /* Transparent: thumbnails carry their own white frames, and a solid
511
+ strip would blanket the slide behind the overlay. */
518
512
  background: transparent;
519
513
  overflow-x: auto;
520
514
  }
@@ -540,7 +534,7 @@
540
534
  font-size: 12px;
541
535
  line-height: 20px;
542
536
  font-weight: 500;
543
- /* 条带透明、底衬通常是播放态黑底/slide 内容——页码用白色(对齐设计稿) */
537
+ /* The strip floats over the (dark) presenting stage — numbers must be light. */
544
538
  color: rgba(255, 255, 255, 0.9);
545
539
  }
546
540
  .mob-strip .mthumb[data-current] .mnum { color: #336DF4; }
@@ -1044,7 +1038,7 @@
1044
1038
  this._onMobScroll = this._onMobScroll.bind(this);
1045
1039
  stage.addEventListener('scroll', this._onMobScroll, { passive: true });
1046
1040
 
1047
- // 移动竖排的逐页定位规则(::slotted(:nth-child(i))),_fitMobileList 重建。
1041
+ // Per-slide placement rules for the mobile card stream; rebuilt by _fitMobileList().
1048
1042
  this._mobStyle = document.createElement('style');
1049
1043
 
1050
1044
  this._mobBadges = document.createElement('div');
@@ -1472,8 +1466,9 @@
1472
1466
  this._prevIndex = curr;
1473
1467
  }
1474
1468
 
1475
- // 移动端两种呈现互斥:竖排浏览(list)/ 旋转横屏播放(present)。
1476
- // noscale / _snthumb(导出与演讲者缩略图捕获)必须保持桌面几何。
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.
1477
1472
  _syncMobileMode() {
1478
1473
  const eligible = this._mobile && !this.hasAttribute('noscale') && !this._snthumb;
1479
1474
  this.toggleAttribute('data-mobile-list', eligible && !this._presenting);
@@ -1483,9 +1478,9 @@
1483
1478
  _fitMobileList() {
1484
1479
  const dw = this.designWidth, dh = this.designHeight;
1485
1480
  const vw = window.innerWidth;
1486
- this._mobLastVw = vw; // _onResize 高度变化跳过的基线
1481
+ this._mobLastVw = vw; // baseline for _onResize's height-only skip
1487
1482
  const stage = this._canvas.parentElement;
1488
- // 清掉桌面 _fit 写过的 inset 内联值
1483
+ // Clear the stage inset the desktop _fit() wrote inline.
1489
1484
  if (stage) { stage.style.left = '0'; stage.style.right = '0'; stage.style.top = '0'; stage.style.bottom = '0'; }
1490
1485
  this.removeAttribute('data-windowed');
1491
1486
  const cardW = vw - 2 * MOB_MARGIN;
@@ -1504,15 +1499,17 @@
1504
1499
  ' inset: auto !important; top: 0 !important; left: 0 !important;' +
1505
1500
  ' width: ' + dw + 'px !important; height: ' + dh + 'px !important;' +
1506
1501
  ' transform: translate(' + MOB_MARGIN + 'px, ' + y + 'px) scale(' + s + ') !important;' +
1507
- // 圆角/描边随 scale 反向放大,落地后视觉为 8px / 0.5px
1502
+ // Radius/border are pre-divided by the scale so the slide's transform
1503
+ // lands them at 8px / 0.5px on screen.
1508
1504
  ' border-radius: ' + (MOB_RADIUS / s) + 'px; overflow: hidden !important;' +
1509
1505
  ' border: ' + (0.5 / s) + 'px solid #DEE0E3; }'
1510
1506
  );
1511
1507
  cards.push({ slide, i, y, h: cardH });
1512
1508
  y += cardH + MOB_GAP;
1513
1509
  });
1514
- // 竖排规则只作用于屏幕媒体——print 的 ::slotted 分页规则特异性更低,
1515
- // 否则每页会带着列表位移/缩放进入打印流。
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.
1516
1513
  this._mobStyle.textContent = '@media screen {\n' + rules.join('\n') + '\n}';
1517
1514
  this._canvas.style.transform = 'none';
1518
1515
  this._canvas.style.width = vw + 'px';
@@ -1520,8 +1517,8 @@
1520
1517
  this._mobCards = cards;
1521
1518
  this._mobScale = s;
1522
1519
 
1523
- // 徽标:每卡片左下角内 6px;宽度按位数 16/16/20。布局是确定性的,
1524
- // 直接按 _mobCards 几何摆放,无需 IntersectionObserver。
1520
+ // Badge geometry is fully determined by _mobCards, so badges are laid
1521
+ // out directly — no IntersectionObserver needed.
1525
1522
  this._mobBadges.textContent = '';
1526
1523
  cards.forEach((c, ordinal) => {
1527
1524
  const b = document.createElement('div');
@@ -1540,7 +1537,8 @@
1540
1537
  _onMobScroll() {
1541
1538
  if (!this.hasAttribute('data-mobile-list') || !this._mobCards || !this._mobCards.length) return;
1542
1539
 
1543
- // 徽标显隐:程序化滚动也算"滚动",两种来源都给可见性反馈。
1540
+ // Programmatic scrolls count as scrolling too — either source means
1541
+ // "the position is changing", which is what the badge signals.
1544
1542
  this.setAttribute('data-mob-scrolling', '');
1545
1543
  clearTimeout(this._mobScrollIdleTimer);
1546
1544
  this._mobScrollIdleTimer = setTimeout(
@@ -1552,16 +1550,18 @@
1552
1550
  const stage = this._canvas.parentElement;
1553
1551
  const top = stage.scrollTop;
1554
1552
  const moved = this._mobLastTop == null ? Infinity : Math.abs(top - this._mobLastTop);
1555
- this._mobLastTop = top; // 抑制期间也持续跟踪位置
1553
+ this._mobLastTop = top; // keep tracking even while suppressed
1556
1554
  if (this._mobProgUntil && performance.now() < this._mobProgUntil) {
1557
- // 程序化滚动事件流仍在到达:续期窗口,动画多长都盖得住;
1558
- // 事件停止 120ms 后窗口自然过期,之后才恢复用户滚动反推。
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.
1559
1558
  this._mobProgUntil = Math.max(this._mobProgUntil, performance.now() + 120);
1560
1559
  return;
1561
1560
  }
1562
- if (moved < 1) return; // 无真实位移(合成事件/终点回调)不反推
1563
- // 纯视口中心公式在卡片高 < 半视口时到不了首/末页(卡片高 ~= 0.5625 * 宽,
1564
- // 竖屏视口高通常远大于宽的一半),两端改用 clamp,中段维持视口中心语义。
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
1565
  const max = stage.scrollHeight - stage.clientHeight;
1566
1566
  let cur;
1567
1567
  if (max <= 0 || stage.scrollTop <= 1) {
@@ -1577,8 +1577,8 @@
1577
1577
  }
1578
1578
  if (cur.i !== this._index) {
1579
1579
  this._index = cur.i;
1580
- // 广播走现有内核:hash 同步 + slide-changed + slidechange 事件。
1581
- // data-deck-active 的切换在竖排 CSS 下无视觉副作用。
1580
+ // Broadcast through the existing core (hash + slide-changed +
1581
+ // slidechange). data-deck-active toggling is visually inert here.
1582
1582
  this._applyIndex({ broadcast: true, reason: 'api' });
1583
1583
  }
1584
1584
  });
@@ -1589,14 +1589,14 @@
1589
1589
  if (!card) return;
1590
1590
  const stage = this._canvas.parentElement;
1591
1591
  const top = Math.max(0, card.y - Math.max(0, (stage.clientHeight - card.h) / 2));
1592
- // 程序化滚动期间抑制 _onMobScroll 的反推——goto/deep-link 已直接设定
1593
- // _index,多张边缘卡片的居中目标会被 clamp 到同一个 scrollTop,
1594
- // 反推无法区分它们对应哪一页,故导航直接落 index、反推只处理真实
1595
- // 用户位移。
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
1596
  this._mobProgUntil = performance.now() + (smooth ? 800 : 200);
1597
- // 目标位置立为基线:即便这次 scrollTo 因目标已等于当前位置(如居中
1598
- // 目标 clamp 到 0)而不产生真实 scroll 事件,_mobLastTop 也不会停留
1599
- // 在 null——避免之后任意一次无位移事件被当成"首次滚动"误判有位移。
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
1600
  this._mobLastTop = top;
1601
1601
  stage.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
1602
1602
  }
@@ -1607,9 +1607,9 @@
1607
1607
  this.removeAttribute('data-windowed');
1608
1608
  this._canvas.style.width = this.designWidth + 'px';
1609
1609
  this._canvas.style.height = this.designHeight + 'px';
1610
- // 物理竖屏:CSS 旋转 90°,stage 逻辑尺寸 = (innerHeight × innerWidth);
1611
- // 物理横屏(用户顺势转了手机):不旋转,按真实视口常规适配。
1612
- // CSS 侧由 @media (orientation) 同步分支。
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
1613
  const portrait = window.innerHeight >= window.innerWidth;
1614
1614
  const lw = portrait ? window.innerHeight : window.innerWidth;
1615
1615
  const lh = portrait ? window.innerWidth : window.innerHeight;
@@ -1669,10 +1669,10 @@
1669
1669
  }
1670
1670
 
1671
1671
  _onResize() {
1672
- // 竖排列表几何只依赖视口宽度。移动浏览器地址栏收起/展开只改高度,
1673
- // 若照常全量 refit(重建规则/徽标 + 无条件回中当前页),用户滚动中
1674
- // 会被可见地"拽"一下——纯高度变化直接跳过。宽度变化(旋转/分屏)
1675
- // 照常走完整 refit。
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
1676
  if (this.hasAttribute('data-mobile-list')) {
1677
1677
  if (this._mobLastVw === window.innerWidth) return;
1678
1678
  this._mobLastVw = window.innerWidth;
@@ -1774,7 +1774,7 @@
1774
1774
  if (n === this._stage) break;
1775
1775
  if (n.matches && n.matches(INTERACTIVE_SEL)) return;
1776
1776
  }
1777
- // 竖排浏览是滚动列表,tap 不翻页(spec 决策 #5)
1777
+ // Mobile browse is a scroll list — taps must not navigate.
1778
1778
  if (this.hasAttribute('data-mobile-list')) return;
1779
1779
  e.preventDefault();
1780
1780
  if (this.hasAttribute('data-mobile-present')) {
@@ -1791,12 +1791,13 @@
1791
1791
  this._advance(e.clientX < mid ? -1 : 1, 'tap');
1792
1792
  }
1793
1793
 
1794
- // rotate(90deg) 后视觉横屏的 x 轴对应视口 y 轴。方向以真机/仿真实测为准:
1795
- // 若实测发现 prev/next 颠倒,翻转此处的比较符(保持单点修改)。
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).
1796
1797
  _mobPresentZone(e) {
1797
1798
  const rect = this._stage.getBoundingClientRect();
1798
- // 物理竖屏 = stage 被 CSS 旋转 90°,视觉横屏 x 轴对应视口 y 轴;
1799
- // 物理横屏 = 无旋转,直接用视口 x 轴。与 _fitMobilePresent 同分支。
1799
+ // Portrait = stage is rotated, visual x comes from viewport y;
1800
+ // landscape = no rotation, viewport x directly. Same branch as the fit.
1800
1801
  const portrait = window.innerHeight >= window.innerWidth;
1801
1802
  const x = portrait
1802
1803
  ? (e.clientY - rect.top) / Math.max(1, rect.height)
@@ -1810,9 +1811,11 @@
1810
1811
  this.toggleAttribute('data-mob-ui', on);
1811
1812
  clearTimeout(this._mobUiTimer);
1812
1813
  if (!on) return;
1813
- // 从隐藏态唤出才全量重建(吸收直编/重排/skip 变化——strip 是静态
1814
- // 快照、不接 _liveObserver 刷新管线);已展开时的调用(缩略图点击/
1815
- // 翻页重置计时)只同步高亮,避免高频路径 O(页数) 重建。
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.
1816
1819
  if (!wasOpen || !this._mobUi) {
1817
1820
  if (this._mobUi) { this._mobUi.remove(); this._mobUi = null; }
1818
1821
  this._buildMobUi();
@@ -1822,9 +1825,10 @@
1822
1825
  }
1823
1826
 
1824
1827
  _buildMobUi() {
1825
- // 移动 viewer 语境 host 可能从不发 rail-enabled,author-CSS 快照
1826
- // (_enableRail 才建)不存在时缩略图克隆会裸渲染——此处兜底建一次。
1827
- // _snapshotAuthorCss 可重入且带 generation guard。
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.
1828
1832
  if (!this._adoptedSheet && this._authorCss == null) this._snapshotAuthorCss();
1829
1833
  const ui = document.createElement('div');
1830
1834
  ui.className = 'mob-ui export-hidden';
@@ -1860,19 +1864,19 @@
1860
1864
  t.addEventListener('click', (ev) => {
1861
1865
  ev.stopPropagation();
1862
1866
  this._go(e2.i, 'click');
1863
- this._toggleMobUi(true); // 重置自动收起计时,保持控件层可见
1867
+ this._toggleMobUi(true); // reset the auto-hide timer, keep the overlay up
1864
1868
  });
1865
1869
  strip.appendChild(t);
1866
- // 复用 _materialize 的克隆管线(media 熄火、canvas 快照、custom
1867
- // element 中性化):它会自建 host/shadow 并直接挂进 entry.frame,
1868
- // 所以这里不需要再手工搭 host——只需在其后把统一走 _thumbScale
1869
- // (rail 宽度)算出的缩放,改写成本条带 132px 专属的缩放。
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.
1870
1874
  const entry = { slide: e2.slide, frame, host: null, clone: null };
1871
1875
  this._materialize(entry);
1872
1876
  if (entry.clone) entry.clone.style.transform = 'scale(' + (132 / this.designWidth) + ')';
1873
1877
  return { el: t, i: e2.i };
1874
1878
  });
1875
- // 控件层内任何点击不冒泡成翻页 tap
1879
+ // Clicks inside the overlay must not bubble into tap navigation.
1876
1880
  ui.addEventListener('click', (e) => e.stopPropagation());
1877
1881
  ui.append(back, strip);
1878
1882
  this._stage.appendChild(ui);
@@ -1926,13 +1930,14 @@
1926
1930
  _go(i, reason = 'api') {
1927
1931
  if (!this._slides.length) return;
1928
1932
  const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
1929
- // 竖排浏览:导航直接落 index(与桌面语义一致),滚动只是视觉跟随;
1930
- // 不再依赖 _onMobScroll 从滚动位置反推——多张边缘卡片的居中滚动目标
1931
- // 会被 clamp 到同一个 scrollTop,反推无法区分它们是哪一页。
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.
1932
1937
  if (this.hasAttribute('data-mobile-list')) {
1933
1938
  const changed = clamped !== this._index;
1934
1939
  this._index = clamped;
1935
- // 同页 goto 不重复广播(与桌面分支语义一致),但仍需重新居中滚动。
1940
+ // Same-page goto doesn't rebroadcast (desktop parity) but still recentres.
1936
1941
  if (changed) this._applyIndex({ broadcast: true, reason });
1937
1942
  this._mobScrollTo(clamped, true);
1938
1943
  return;