dsh-m 0.9.64 → 0.9.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,20 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
8
8
 
9
9
  ## 中文
10
10
 
11
+ ### 0.9.66 修复:触屏滑动方向与移动端惯例相反(用户实机反馈)
12
+
13
+ - **根因**:0.9.65 触屏滑动把映射锚在了键盘光标隐喻上(`ArrowRight=next` ⇒ 手指右拖=下一张),而触屏的正确隐喻是**内容拖拽**——手指拖着图像条走,**往左拖露出右边那张=下一张**(相册/微信/车龙全部如此)。`swipeDir` 返回的手指位移方向被原样喂给了 `lbStep`,致真机上「左滑出上一张、右滑出下一张」,与全部肌肉记忆相反。
14
+ - **修复**:调用点取反映射 `lbStep(index, -dir, …)`;`swipeDir` 纯函数语义不变(仍报手指方向),文档注释明确「返回值是手指方向而非翻页方向,调用点必须取反」。键盘 ←→ 不动(桌面光标惯例:←=上一张、→=下一张,两者隐喻各自成立互不干扰)。
15
+ - **探针同批修正**:`verify-lightbox.mjs` A31a/A31b 断言按正确惯例重写(修正后旧断言转红即方向确已翻转的实证);附带修正探针编号撞车——尾部既有 Primary 场景 A26–A29 保留,新特性场景编为 A30(预取命中)/A31(滑动)/A32(焦点圈闭);A19 重写为时序确定性版双断占位(当轮验证附带产出:疑似回归经消融+worktree 对比+确定性时序三重取证定谳为「预取预热了场景前提图」,非回归,取证记录见探针内注释)。
16
+ - **验证**:1289 项测试全绿、typecheck 零错误;浏览器探针 50/50(A31 五断言含方向翻转实证 + A32 圈闭六断言 + A30 预取命中 + A19/A20–A25 既有锚全数保持)。
17
+
18
+ ### 0.9.65 优化:灯箱邻图预取 + 触屏滑动切换 + 焦点圈闭
19
+
20
+ - **邻图预取**:当前图加载完成(`useImgChain` settled)后静默预热左右邻——URL 用与灯箱真实请求**逐字节同形**的 `weservUrl(w=1600)`,翻页直接命中浏览器缓存近零等待。纯 best-effort:不碰赢家记忆/链状态,预取失败对真实链零影响(导航时照常走 weserv→原图双兜底);`prefetchedRef` Set 去重防来回翻页重复发起,Set 随灯箱挂载期生命周期(重开重新预热);单图由 `lbNeighbors` 空表自然短路。
21
+ - **触屏滑动切换**:touch 指针横向滑过阈值即翻页(`swipeDir` 纯函数判向:|dx| ≥ 48px 且 |dx| ≥ 2|dy|;不设时长门槛,慢拖也算;竖滑/斜滑归零——纵向手势与 pinch 缩放可达性留给原生,`touch-action:pan-y pinch-zoom`)。只认 `pointerType==="touch"`——鼠标拖拽维持原生语义(图=原生拖拽、遮罩=按下+点击才关,0.9.27 防拖拽误关守卫不破);滑动终结后 500ms 内根上 capture 吞掉合成 click(双保险:即便滑动止于 ✕/遮罩也不触发其点击语义)。判向/邻图两枚纯函数入 `lightbox.js`(`swipeDir`/`lbNeighbors`,Node 单测覆盖)。
22
+ - **焦点圈闭**:灯箱开着时 Tab/Shift+Tab 只在灯箱内可聚焦控件(按钮/链接:✕/‹›/失败态重试+打开原图)间回绕;焦点已逃出灯箱(如点击遮罩落在 body)时拉回首/尾。portal 挂 body 后 Tab 本会走到背景面板,此处收口;DetailModal 自身 U8 债(无圈闭)维持不变,Esc 仍只关最上层弹层。
23
+ - **验证**:1289 项测试全绿、typecheck 零错误;`client-lightbox.test.mjs` 新增 6 项(`lbNeighbors`/`swipeDir` 纯逻辑单测 + 预取/滑动/圈闭/touch-action 结构锚),0.9.60/0.9.62 批次既有锚全数保持。
24
+
11
25
  ### 0.9.64 优化:设置页双清单并排重排 + 操作记录整体搬迁设置 tab
12
26
 
13
27
  - **设置页网格(mock 驱动终审)**:社区清单/精选清单两卡并排 **4:7**(社区卡只读五项 ~250px 足矣,宽度让给交互更多的精选卡);全屏态「关于」升入三卡行(`4fr 7fr 3fr`,不封顶——对齐市场「加列不加宽」哲学,设置页恰有三个卡片单元可用);视口 ≤760px 单列回落;关于卡常态通栏、操作记录恒通栏(`nth-child` 定位,与 `Section()` 签名解耦)。
@@ -475,6 +489,20 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
475
489
 
476
490
  ## English
477
491
 
492
+ ### Fixed in 0.9.66 — touch swipe direction inverted vs mobile convention (user report from a real phone)
493
+
494
+ - **Root cause**: 0.9.65 anchored the swipe mapping on the keyboard cursor metaphor (`ArrowRight=next` ⇒ finger-right = next image), while touch follows the **content-drag** metaphor - the finger drags the filmstrip, so **swiping left reveals the image on the right = next** (photo libraries, chat apps, every carousel). `swipeDir`'s finger-direction result was fed straight into `lbStep`, producing "swipe left → previous, swipe right → next" on real devices - inverted against all muscle memory.
495
+ - **Fix**: the call site flips the mapping (`lbStep(index, -dir, …)`); `swipeDir` semantics unchanged (still reports the finger direction), now documented as "the return value is the finger direction, not the page direction - callers must negate". Keyboard ←→ untouched (desktop cursor convention: ←=previous, →=next - the two metaphors each hold in their own domain).
496
+ - **Probe fixes in the same batch**: `verify-lightbox.mjs` A31a/A31b rewritten to the correct convention (the old assertion going red after the fix is direct proof the direction flipped); probe numbering collision fixed - the pre-existing Primary scenarios keep A26-A29, the new-feature scenarios become A30 (prefetch warm-hit) / A31 (swipe) / A32 (focus trap); A19 rewritten as a timing-deterministic double-fail placeholder scenario (byproduct of this verification round: the suspected regression was triply disproven - ablation probe, worktree bisect, deterministic timing - the prefetch had legitimately pre-warmed the scenario's premise image; forensics live in the probe comments).
497
+ - **Verification**: 1289 tests green, typecheck clean; browser probe 50/50 (A31 five assertions incl. the flip proof + A32 six trap assertions + A30 prefetch warm-hit + A19/A20-A25 legacy anchors all held).
498
+
499
+ ### Added in 0.9.65 — lightbox: neighbor prefetch + touch swipe navigation + focus trap
500
+
501
+ - **Neighbor prefetch**: once the current image settles (`useImgChain` settled), the left/right neighbors are warmed silently - using a `weservUrl(w=1600)` URL **byte-identical** to the lightbox's real request, so a swipe/arrow navigation hits the browser cache with near-zero wait. Strictly best-effort: no winner-memory or chain-state mutation, a failed prefetch leaves the real chain untouched (navigation still runs the weserv→original dual fallback); a `prefetchedRef` Set dedupes repeated crossings (no re-issuing when bouncing back), the Set lives for the lightbox mount lifetime (a fresh open warms again); single-image boxes short-circuit naturally via `lbNeighbors` returning an empty list.
502
+ - **Touch swipe navigation**: a horizontal touch drag past the threshold flips images (`swipeDir` pure predicate: |dx| ≥ 48px and |dx| ≥ 2|dy|; no time gate - slow deliberate drags count; vertical/diagonal drags return 0 - vertical gestures and pinch-zoom accessibility stay native, via `touch-action:pan-y pinch-zoom`). Only `pointerType==="touch"` is recognized - mouse drags keep their native semantics (image = native drag, backdrop = press+click to close, the 0.9.27 drag-misfire guard intact); for 500ms after a recognized swipe the root's capture phase swallows the synthetic click (double belt: a swipe ending on ✕ or the backdrop triggers neither).
503
+ - **Focus trap**: while the lightbox is open, Tab/Shift+Tab cycles only among focusable controls inside it (buttons/links: ✕/‹›/failed-state retry + open-original); if focus has escaped (e.g. a backdrop click left it on body), the next Tab pulls it back to the first/last control. After the portal-to-body change, Tab used to walk into the background panel - this closes that gap; DetailModal's own U8 debt (no trap) is unchanged and Esc still closes only the topmost layer.
504
+ - **Verification**: 1289 tests green, typecheck clean; `client-lightbox.test.mjs` gains 6 items (`lbNeighbors`/`swipeDir` pure-logic units + prefetch/swipe/trap/touch-action structural anchors); all 0.9.60/0.9.62-era anchors retained.
505
+
478
506
  ### Changed in 0.9.64 — settings page: dual-catalog side-by-side layout + operations history moved into the settings tab
479
507
 
480
508
  - **Settings grid (mock-driven final review)**: the Community and Curated cards now sit side by side at **4:7** (the read-only community card needs ~250px; width goes to the more interactive registry card); in fullscreen the About card joins a three-card row (`4fr 7fr 3fr`, uncapped — the same "add columns, don't stretch units" philosophy as the market tab); below a 760px viewport it falls back to a single column; About spans full width in normal mode, the operations card is always full width.
package/lib/client.js CHANGED
@@ -466,12 +466,28 @@ var init_backdrop = __esm({
466
466
  // src/client/lightbox.js
467
467
  var lightbox_exports = {};
468
468
  __export(lightbox_exports, {
469
- lbStep: () => lbStep
469
+ lbNeighbors: () => lbNeighbors,
470
+ lbStep: () => lbStep,
471
+ swipeDir: () => swipeDir
470
472
  });
471
473
  function lbStep(index, step, length) {
472
474
  if (!Number.isFinite(index) || !Number.isFinite(step) || !Number.isInteger(length) || length <= 1) return index;
473
475
  return ((index + step) % length + length) % length;
474
476
  }
477
+ function lbNeighbors(index, length) {
478
+ if (!Number.isFinite(index) || !Number.isInteger(length) || length <= 1) return [];
479
+ const out = [];
480
+ for (const n of [lbStep(index, 1, length), lbStep(index, -1, length)]) {
481
+ if (Number.isFinite(n) && !out.includes(n)) out.push(n);
482
+ }
483
+ return out;
484
+ }
485
+ function swipeDir(dx, dy, { threshold = 48, ratio = 2 } = {}) {
486
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) return 0;
487
+ const ax = Math.abs(dx);
488
+ if (ax < threshold || ax < ratio * Math.abs(dy)) return 0;
489
+ return dx > 0 ? 1 : -1;
490
+ }
475
491
  var init_lightbox = __esm({
476
492
  "src/client/lightbox.js"() {
477
493
  "use strict";
@@ -1651,7 +1667,7 @@ var API = "/dshm";
1651
1667
  var { DEFAULT_PAGE_SIZE: DEFAULT_PAGE_SIZE2, MARKET_PAGE_SIZES: MARKET_PAGE_SIZES2, pageItems: pageItems2, createZoneState: createZoneState2, normalizeMarketQuery: normalizeMarketQuery2, resetPageOnFilterChange: resetPageOnFilterChange2, normalizeMarketResponse: normalizeMarketResponse2, registryNotice: registryNotice2, zoneChips: zoneChips2, marketNotice: marketNotice2, searchSourceOf: searchSourceOf2, mergeLatestFields: mergeLatestFields2, chipRows: chipRows2, countBeyondRows: countBeyondRows2, clipTopOf: clipTopOf2, visibleRowTop: visibleRowTop2, autoExpandDecision: autoExpandDecision2 } = (init_market_state(), __toCommonJS(market_state_exports));
1652
1668
  var { readMarketSnapshot: readMarketSnapshot2, writeMarketSnapshot: writeMarketSnapshot2, isDefaultFirstPageQuery: isDefaultFirstPageQuery2 } = (init_market_snapshot(), __toCommonJS(market_snapshot_exports));
1653
1669
  var { backdropCloseHandlers: backdropCloseHandlers2 } = (init_backdrop(), __toCommonJS(backdrop_exports));
1654
- var { lbStep: lbStep2 } = (init_lightbox(), __toCommonJS(lightbox_exports));
1670
+ var { lbStep: lbStep2, lbNeighbors: lbNeighbors2, swipeDir: swipeDir2 } = (init_lightbox(), __toCommonJS(lightbox_exports));
1655
1671
  var { WESERV_TIMEOUT_MS: WESERV_TIMEOUT_MS2, weservUrl: weservUrl2, serviceBucketOf: serviceBucketOf2, tierOrder: tierOrder2, nextTier: nextTier2, needsTimeout: needsTimeout2, preferredTier: preferredTier2, rememberSuccess: rememberSuccess2 } = (init_img_chain(), __toCommonJS(img_chain_exports));
1656
1672
  var { createMarkdown: createMarkdown2 } = (init_markdown(), __toCommonJS(markdown_exports));
1657
1673
  var { ExtLink, MdImg, renderMarkdown } = createMarkdown2(h);
@@ -2374,8 +2390,10 @@ var CSS = `
2374
2390
  \u5728\u6D45\u5E95\u4E0A\u4E0D\u53EF\u89C1\uFF08\u7528\u6237\u53CC\u6A21\u5F0F\u5B9E\u62CD\uFF09\u3002\u53CC\u4FA7\u4FEE\u590D\u2014\u2014\u76AE\u80A4\u4FA7 :not(.dsvm-lightbox) \u8C41\u514D
2375
2391
  \uFF08dsh-skins 1.5.1 \u8D77\uFF09\uFF1B\u672C\u4FA7 background !important \u81EA\u6301\uFF1Amarketplace \u793E\u533A\u76AE\u80A4\u4E0D\u53EF
2376
2392
  \u679A\u4E3E\uFF0C\u5F71\u9662\u4E0D\u53D8\u91CF\u5FC5\u987B\u7531\u7EC4\u4EF6\u81EA\u5DF1\u515C\u5E95\u3002!important \u6E05\u5355\u5B9A\u957F\uFF08\u6CE8\u91CA\u63D0\u53CA\xD72 + \u672C\u89C4\u5219
2377
- + sidebar footer \u517C\u5BB9\u89C4\u5219\uFF09\uFF0C\u65B0\u589E\u987B\u6539 client-lightbox \u65AD\u8A00\u663E\u5F0F\u767B\u8BB0\uFF0C\u6CE8\u91CA\u5373\u5B88\u536B\u3002 */
2378
- .dsvm-lightbox{position:fixed;top:var(--dsh-windows-titlebar-height,0px);left:0;right:0;bottom:0;z-index:2147483200;background:rgba(0,0,0,.88)!important;display:flex;align-items:center;justify-content:center}
2393
+ + sidebar footer \u517C\u5BB9\u89C4\u5219\uFF09\uFF0C\u65B0\u589E\u987B\u6539 client-lightbox \u65AD\u8A00\u663E\u5F0F\u767B\u8BB0\uFF0C\u6CE8\u91CA\u5373\u5B88\u536B\u3002
2394
+ 0.9.65\uFF1Atouch-action:pan-y pinch-zoom\u2014\u2014\u6A2A\u6ED1\u5224\u5411\u4EA4\u7ED9\u706F\u7BB1\u6ED1\u52A8\u5207\u6362\uFF0C\u7EB5\u5411\u539F\u751F
2395
+ \u624B\u52BF\u4E0E pinch \u7F29\u653E\uFF08\u4F4E\u89C6\u529B\u53EF\u8FBE\u6027\uFF09\u4E0D\u88AB\u52AB\u6301\u3002 */
2396
+ .dsvm-lightbox{position:fixed;top:var(--dsh-windows-titlebar-height,0px);left:0;right:0;bottom:0;z-index:2147483200;background:rgba(0,0,0,.88)!important;display:flex;align-items:center;justify-content:center;touch-action:pan-y pinch-zoom}
2379
2397
  .dsvm-lightbox img{max-width:min(92vw,1500px);max-height:74vh;border-radius:8px;cursor:zoom-out;box-shadow:0 10px 44px rgba(0,0,0,.55);display:block}
2380
2398
  .dsvm-btn:focus-visible{outline:2px solid rgba(255,255,255,.75);outline-offset:2px}
2381
2399
  .dsvm-btn.dsvm-lbclose{position:absolute;top:12px;right:12px;width:38px;height:38px;padding:0;border-radius:10px;display:flex;align-items:center;justify-content:center}
@@ -3191,9 +3209,40 @@ function Lightbox({ shots, index, onNav, onClose }) {
3191
3209
  if (closeRef.current) closeRef.current.focus();
3192
3210
  }, []);
3193
3211
  const chain = useImgChain(shots[index], { w: 1600 });
3212
+ const prefetchedRef = useRef(null);
3213
+ if (prefetchedRef.current === null) prefetchedRef.current = /* @__PURE__ */ new Set();
3214
+ useEffect(() => {
3215
+ if (typeof Image === "undefined") return;
3216
+ if (!chain.settled || chain.failed) return;
3217
+ for (const n of lbNeighbors2(index, shots.length)) {
3218
+ const url = shots[n];
3219
+ if (typeof url !== "string" || url === "" || prefetchedRef.current.has(url)) continue;
3220
+ prefetchedRef.current.add(url);
3221
+ const img = new Image();
3222
+ img.decoding = "async";
3223
+ img.src = weservUrl2(url, { w: 1600 });
3224
+ }
3225
+ }, [chain.settled, chain.failed, index, shots]);
3226
+ const swipeRef = useRef(null);
3227
+ const swipedAtRef = useRef(0);
3228
+ const onPointerDown = (e) => {
3229
+ if (e.pointerType !== "touch") return;
3230
+ swipeRef.current = { x: e.clientX, y: e.clientY };
3231
+ };
3232
+ const onPointerUp = (e) => {
3233
+ if (e.pointerType !== "touch" || !swipeRef.current) return;
3234
+ const dir = swipeDir2(e.clientX - swipeRef.current.x, e.clientY - swipeRef.current.y);
3235
+ swipeRef.current = null;
3236
+ if (!dir || single) return;
3237
+ swipedAtRef.current = Date.now();
3238
+ onNav(lbStep2(index, -dir, shots.length));
3239
+ };
3240
+ const onClickCapture = (e) => {
3241
+ if (Date.now() - swipedAtRef.current < 500) e.stopPropagation();
3242
+ };
3194
3243
  const node = h(
3195
3244
  "div",
3196
- { className: "dsvm-lightbox", role: "dialog", "aria-modal": "true", "aria-label": lookup("lb.title"), ...backdropCloseHandlers2(onClose) },
3245
+ { className: "dsvm-lightbox", role: "dialog", "aria-modal": "true", "aria-label": lookup("lb.title"), onPointerDown, onPointerUp, onClickCapture, ...backdropCloseHandlers2(onClose) },
3197
3246
  chain.failed ? h(
3198
3247
  "div",
3199
3248
  { className: "dsvm-lbfail", onClick: (e) => e.stopPropagation() },
@@ -3283,6 +3332,19 @@ function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeB
3283
3332
  if (e.key === "ArrowLeft") setLb((i) => lbStep2(i, -1, visible.length));
3284
3333
  if (e.key === "ArrowRight") setLb((i) => lbStep2(i, 1, visible.length));
3285
3334
  }
3335
+ if (e.key === "Tab" && lb !== null) {
3336
+ const box = typeof document !== "undefined" ? document.querySelector(".dsvm-lightbox") : null;
3337
+ if (!box) return;
3338
+ const focusables = Array.from(box.querySelectorAll("button, a[href]")).filter((el) => el.offsetWidth > 0 && el.offsetHeight > 0);
3339
+ if (!focusables.length) return;
3340
+ const first = focusables[0];
3341
+ const last = focusables[focusables.length - 1];
3342
+ const active = document.activeElement;
3343
+ if (!box.contains(active) || (e.shiftKey ? active === first : active === last)) {
3344
+ e.preventDefault();
3345
+ (e.shiftKey ? last : first).focus();
3346
+ }
3347
+ }
3286
3348
  };
3287
3349
  document.addEventListener("keydown", onKey);
3288
3350
  return () => document.removeEventListener("keydown", onKey);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.64",
3
+ "version": "0.9.66",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",