dsh-m 0.9.64 → 0.9.65

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,13 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
8
8
 
9
9
  ## 中文
10
10
 
11
+ ### 0.9.65 优化:灯箱邻图预取 + 触屏滑动切换 + 焦点圈闭
12
+
13
+ - **邻图预取**:当前图加载完成(`useImgChain` settled)后静默预热左右邻——URL 用与灯箱真实请求**逐字节同形**的 `weservUrl(w=1600)`,翻页直接命中浏览器缓存近零等待。纯 best-effort:不碰赢家记忆/链状态,预取失败对真实链零影响(导航时照常走 weserv→原图双兜底);`prefetchedRef` Set 去重防来回翻页重复发起,Set 随灯箱挂载期生命周期(重开重新预热);单图由 `lbNeighbors` 空表自然短路。
14
+ - **触屏滑动切换**: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 单测覆盖)。
15
+ - **焦点圈闭**:灯箱开着时 Tab/Shift+Tab 只在灯箱内可聚焦控件(按钮/链接:✕/‹›/失败态重试+打开原图)间回绕;焦点已逃出灯箱(如点击遮罩落在 body)时拉回首/尾。portal 挂 body 后 Tab 本会走到背景面板,此处收口;DetailModal 自身 U8 债(无圈闭)维持不变,Esc 仍只关最上层弹层。
16
+ - **验证**:1289 项测试全绿、typecheck 零错误;`client-lightbox.test.mjs` 新增 6 项(`lbNeighbors`/`swipeDir` 纯逻辑单测 + 预取/滑动/圈闭/touch-action 结构锚),0.9.60/0.9.62 批次既有锚全数保持。
17
+
11
18
  ### 0.9.64 优化:设置页双清单并排重排 + 操作记录整体搬迁设置 tab
12
19
 
13
20
  - **设置页网格(mock 驱动终审)**:社区清单/精选清单两卡并排 **4:7**(社区卡只读五项 ~250px 足矣,宽度让给交互更多的精选卡);全屏态「关于」升入三卡行(`4fr 7fr 3fr`,不封顶——对齐市场「加列不加宽」哲学,设置页恰有三个卡片单元可用);视口 ≤760px 单列回落;关于卡常态通栏、操作记录恒通栏(`nth-child` 定位,与 `Section()` 签名解耦)。
@@ -475,6 +482,13 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
475
482
 
476
483
  ## English
477
484
 
485
+ ### Added in 0.9.65 — lightbox: neighbor prefetch + touch swipe navigation + focus trap
486
+
487
+ - **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.
488
+ - **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).
489
+ - **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.
490
+ - **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.
491
+
478
492
  ### Changed in 0.9.64 — settings page: dual-catalog side-by-side layout + operations history moved into the settings tab
479
493
 
480
494
  - **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.65",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",