dsh-m 0.9.60 → 0.9.61

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/lib/client.js +146 -15
  3. package/package.json +1 -1
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.61 修复:大陆浏览器拉不到插件截图/图标——图片加载链 weserv 优先双兜底
12
+
13
+ - **根因(2026-10-09 实测)**:截图(raw.githubusercontent.com)与图标(github.com 头像)由用户浏览器直连拉取——服务器侧 200/0.2s,大陆浏览器路径时断时通,当日故障样本 dsh-wallpaper-engine 五图(22.5MB,含 10.4MB GIF)缩略图条与灯箱全空;0.9.60 已排除灯箱 v2 回归,图标「能显示」实为本地字母兜底。对标 dsh-market 1.66.14:缩略图无条件 weserv(其源码实测大陆 1.39s/23KB vs 原图 41KB)但灯箱直连零兜底——大陆点开大图同款黑洞。
14
+ - **图片加载链(ADR-0014)**:三类消费方(缩略图 h=300 / 灯箱 w=1600 / 图标 h=96)共用 `useImgChain`——weserv 层(`fit=inside&we=1&output=webp&q=80` 服务端缩放转码)优先,失败或 8s 人工超时(仅此层;直连层零人工超时防误杀慢速合法下载)换原图直连,再败走终态:缩略图剔除(全败隐藏整条)、灯箱占位「⚠ 失败 + 重试 + 打开原图 ↗」、图标字母渐变兜底。赢家记忆按 raw/avatar 两桶记最近成功层,**链启动时快照次序**——同批多图竞态下兄弟图不因偏好翻转跳层(执行期实证:缩略图 1 经直连成功翻转偏好后 2/3 全灭的竞态);链内前进序感知(偏好为 direct 时 direct 败仍按序试 weserv,固定阶梯会跳过兜底直判死)。四处 shots 基准同步换 visible(整条门控/截图条 map/灯箱门控/键盘导航+effect 依赖),列表保持全量 map + `${i}:${src}` 稳定 key,灯箱 index 钳制防越界。
15
+ - **配套**:纯逻辑入 `src/client/img-chain.js`(URL 构造/桶分类/序感知 tier 机/赢家记忆,`WESERV_BASE` 单常量——未来设置项只改一处);GLOSSARY 增「图片加载链(Image Chain)」一条;探针扩展 A16-A24(断直连/断 weserv/双断占位+重试/weserv 挂起 8s 守卫/赢家记忆/缩略图剔除/direct-first 换层 Probe 场景)+ `--live` 真网压缩实证;执行期探针 harness 三大确定性根基:**每场景全新 page(独立 context,消灭路由复挂竞态与跨场景解码复用)**、代次化 URL(凡需失败的 URL 全页生命周期唯一)、层序断言走 reqLog(page.on('request') 不依赖路由拦截)。
16
+ - **验证**:1256 项测试全绿(基线 1236 + 新增 20)、typecheck 零错误;探针 31/31 三连跑稳定;`--live` 压缩实证 mascot-drawer.png 直连 2859KB vs weserv 151KB(≈18.9×)。评审吸收轮(完整多轮独立 subagent 评审):R1-1【阻断】8s 守卫在成功加载后不清除——成功图 8s 后被强制换层重载(大陆场景缩略图误剔除/图标字母化/灯箱变占位),修以 settled 语义(onLoad 置真、链启动/翻页重置、timer 四条件守卫含空 url),并新增探针 A25(成功驻留 >8.5s 行为钉子,旧代码必红)与计时门控源锚;R1-2 A22 补负向断言(导航后零 weserv 形态请求)、A24 断言首请求形态(赢家记忆翻转获得证伪力);R1-3 freshState 清 reqLog(场景封闭);R1-5 url 变化改渲染期 derived-state 重置(消除中间帧幽灵请求);R1-6 --live 双侧校验 r.ok + image/*(错误页不得假 PASS);R2-3 advance 防御性 settled 重置。第二轮终局裁决:共识 approve。遗留后续项:中间帧消除的行为级探针(现由源锚防回归)。
17
+
11
18
  ### 0.9.60 修复:截图灯箱窗口态无导航/无法退出——createPortal 逃出面板包含块 + 控件常驻
12
19
 
13
20
  - **根因**:灯箱 `position:fixed` 是 `.dshm-panel`(`backdrop-filter` + `overflow:hidden`)的后代,面板盒成了它的包含块——图片按视口单位放大(旧 94vw/80vh),在 ≤680px 高的窗口态面板里必然溢出,in-flow 的 ‹›/圆点被裁出屏外,又没有 ✕;全屏态面板恰为视口才「碰巧」可见(即「只有全屏才有导航按钮,窗口态图片占满小屏且无法退出」)。
@@ -446,6 +453,13 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
446
453
 
447
454
  ## English
448
455
 
456
+ ### Fixed in 0.9.61 — screenshots/icons unreachable from mainland browsers: weserv-first dual-fallback image chain
457
+
458
+ - **Root cause (measured 2026-10-09)**: screenshots (raw.githubusercontent.com) and icons (github.com avatars) load browser-direct - fine from the server (200/0.2s), intermittently dead from mainland browsers. That day's failure sample dsh-wallpaper-engine (5 images, 22.5MB incl. a 10.4MB GIF) rendered an empty strip and a black lightbox; 0.9.60 ruled out a lightbox-v2 regression, and "working" icons were the local letter fallback. Benchmark dsh-market 1.66.14: thumbnails unconditionally via weserv (their in-source mainland measurement: 1.39s/23KB vs 41KB original) but the lightbox loads direct with zero fallback - the same mainland black hole.
459
+ - **Image chain (ADR-0014)**: three consumers (thumbnail h=300 / lightbox w=1600 / icon h=96) share `useImgChain` - weserv tier first (`fit=inside&we=1&output=webp&q=80` server-side resize/recode), on failure or an 8s guard (that tier only; the direct tier gets no artificial timeout so slow-but-legal downloads are never killed) fall back to the original URL, then to per-consumer finals: thumbnails dropped (whole strip hidden when all fail), lightbox placeholder "failed + retry + open original", icon letter fallback. Winner memory per service bucket (raw/avatar) remembers the last successful tier, and **each chain snapshots the order at start** - sibling images racing in the same batch never skip a tier because a preference flipped mid-flight (found in execution: thumbnail 1 succeeding via direct flipped the preference and killed thumbnails 2/3); advancement is order-aware (with direct preferred, a direct failure still tries weserv - a fixed ladder would skip the fallback entirely). All four shots references switch to visible (strip gating / strip map / lightbox gating / keyboard nav + effect deps), the strip keeps a full-list map with stable `${i}:${src}` keys, and the lightbox index is clamped against shrinkage.
460
+ - **Supporting**: pure logic in `src/client/img-chain.js` (URL builder / bucket classifier / order-aware tier machine / winner memory; `WESERV_BASE` single constant - a future setting changes one line); one GLOSSARY term (Image Chain); probe extended A16-A24 (direct-blocked / weserv-blocked / dual-block placeholder+retry / weserv-hang 8s guard / winner memory / strip removal / direct-first Probe scenario) plus `--live` real-network compression; three probe-harness determinism pillars learned during execution: **a fresh page (fresh context) per scenario** (kills route-remount races and cross-scenario decoded-image reuse), generation URLs (`?g=N` - any URL that must fail is unique for the page lifetime), and tier-order assertions via reqLog (`page.on('request')` survives route interception quirks).
461
+ - **Verification**: 1256 tests green (1236 baseline + 20 new), typecheck clean; probe 31/31 stable across three runs; `--live` compression: mascot-drawer.png direct 2859KB vs weserv 151KB (~18.9x). Review absorption round (full multi-round independent subagent review): R1-1 [blocker] the 8s guard survived a successful load - a displayed image was force-advanced 8s later (mainland: thumbnails wrongly dropped, icons degraded, lightbox mutating into the placeholder); fixed with settled semantics (set by onLoad, reset on chain start/navigation, four-condition timer guard incl. empty url), pinned by new probe A25 (dwell >8.5s; the old code cannot pass it) plus timer-gating source anchors. R1-2 A22 gains the negative assertion (zero weserv-form requests after navigation) and A24 asserts the first request's form (winner-memory flip now falsifiable). R1-3 freshState clears reqLog (scenario closure). R1-5 url change resets via render-phase derived state (the one-frame ghost request is gone). R1-6 --live validates r.ok + image/* on both fetches. R2-3 defensive settled reset in advance. Round-2 verdict: consensus approve. Deferred: a behavioral probe for the middle-frame elimination (currently pinned by source anchors).
462
+
449
463
  ### Fixed in 0.9.60 — screenshot lightbox unusable when windowed: portal out of the panel containing block + always-visible controls
450
464
 
451
465
  - **Root cause**: the lightbox was a `position:fixed` descendant of `.dshm-panel` (`backdrop-filter` + `overflow:hidden`), so the panel box became its containing block. The image sized in viewport units (old 94vw/80vh) inevitably overflows the ≤680px-tall windowed panel and the in-flow ‹›/dots row gets clipped off-screen, with no close button at all; fullscreen only worked because the panel then equals the viewport (the reported "nav buttons appear only in fullscreen; windowed fills the small screen and cannot exit").
package/lib/client.js CHANGED
@@ -478,6 +478,66 @@ var init_lightbox = __esm({
478
478
  }
479
479
  });
480
480
 
481
+ // src/client/img-chain.js
482
+ var img_chain_exports = {};
483
+ __export(img_chain_exports, {
484
+ WESERV_BASE: () => WESERV_BASE,
485
+ WESERV_TIMEOUT_MS: () => WESERV_TIMEOUT_MS,
486
+ needsTimeout: () => needsTimeout,
487
+ nextTier: () => nextTier,
488
+ preferredTier: () => preferredTier,
489
+ rememberSuccess: () => rememberSuccess,
490
+ resetTierPreferences: () => resetTierPreferences,
491
+ serviceBucketOf: () => serviceBucketOf,
492
+ tierOrder: () => tierOrder,
493
+ weservUrl: () => weservUrl
494
+ });
495
+ function weservUrl(src, { w, h: h2 } = {}) {
496
+ const bare = String(src).replace(/^https?:\/\//, "");
497
+ const size = Number.isFinite(w) && w > 0 ? `w=${Math.floor(w)}` : Number.isFinite(h2) && h2 > 0 ? `h=${Math.floor(h2)}` : "";
498
+ const sizePart = size ? `&${size}` : "";
499
+ return `${WESERV_BASE}url=${encodeURIComponent(bare)}${sizePart}&fit=inside&we=1&output=webp&q=80`;
500
+ }
501
+ function serviceBucketOf(url) {
502
+ try {
503
+ return new URL(String(url)).hostname === "github.com" ? "avatar" : "raw";
504
+ } catch {
505
+ return "raw";
506
+ }
507
+ }
508
+ function tierOrder(preferred) {
509
+ return preferred === "direct" ? ["direct", "weserv"] : ["weserv", "direct"];
510
+ }
511
+ function nextTier(current, preferred = "weserv") {
512
+ const order = tierOrder(preferred);
513
+ const idx = order.indexOf(current);
514
+ if (idx === -1 || idx === order.length - 1) return "failed";
515
+ return order[idx + 1];
516
+ }
517
+ function needsTimeout(tier) {
518
+ return tier === "weserv";
519
+ }
520
+ function preferredTier(bucket) {
521
+ return preferredByBucket[bucket === "avatar" ? "avatar" : "raw"];
522
+ }
523
+ function rememberSuccess(bucket, tier) {
524
+ if (tier !== "weserv" && tier !== "direct") return;
525
+ preferredByBucket[bucket === "avatar" ? "avatar" : "raw"] = tier;
526
+ }
527
+ function resetTierPreferences() {
528
+ preferredByBucket.raw = "weserv";
529
+ preferredByBucket.avatar = "weserv";
530
+ }
531
+ var WESERV_BASE, WESERV_TIMEOUT_MS, preferredByBucket;
532
+ var init_img_chain = __esm({
533
+ "src/client/img-chain.js"() {
534
+ "use strict";
535
+ WESERV_BASE = "https://images.weserv.nl/?";
536
+ WESERV_TIMEOUT_MS = 8e3;
537
+ preferredByBucket = { raw: "weserv", avatar: "weserv" };
538
+ }
539
+ });
540
+
481
541
  // src/client/markdown.js
482
542
  var markdown_exports = {};
483
543
  __export(markdown_exports, {
@@ -1592,6 +1652,7 @@ var { DEFAULT_PAGE_SIZE: DEFAULT_PAGE_SIZE2, MARKET_PAGE_SIZES: MARKET_PAGE_SIZE
1592
1652
  var { readMarketSnapshot: readMarketSnapshot2, writeMarketSnapshot: writeMarketSnapshot2, isDefaultFirstPageQuery: isDefaultFirstPageQuery2 } = (init_market_snapshot(), __toCommonJS(market_snapshot_exports));
1593
1653
  var { backdropCloseHandlers: backdropCloseHandlers2 } = (init_backdrop(), __toCommonJS(backdrop_exports));
1594
1654
  var { lbStep: lbStep2 } = (init_lightbox(), __toCommonJS(lightbox_exports));
1655
+ 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));
1595
1656
  var { createMarkdown: createMarkdown2 } = (init_markdown(), __toCommonJS(markdown_exports));
1596
1657
  var { ExtLink, MdImg, renderMarkdown } = createMarkdown2(h);
1597
1658
  var { installedViewModel: installedViewModel2, registrySourceKey: registrySourceKey2 } = (init_installed_view(), __toCommonJS(installed_view_exports));
@@ -1772,6 +1833,9 @@ var ZH = {
1772
1833
  "lb.prev": "\u4E0A\u4E00\u5F20\uFF08\u2190\uFF09",
1773
1834
  "lb.next": "\u4E0B\u4E00\u5F20\uFF08\u2192\uFF09",
1774
1835
  "lb.hint": "\u70B9\u56FE\u6216\u7A7A\u767D\u5904\u5173\u95ED \xB7 \u2190\u2192 \u5207\u6362",
1836
+ "lb.fail": "\u56FE\u7247\u52A0\u8F7D\u5931\u8D25",
1837
+ "lb.retry": "\u91CD\u8BD5",
1838
+ "lb.open": "\u6253\u5F00\u539F\u56FE",
1775
1839
  "filter.title": "\u7B5B\u9009",
1776
1840
  "filter.sortfield": "\u6392\u5E8F\u5B57\u6BB5",
1777
1841
  "filter.sortdir": "\u6392\u5217\u65B9\u5411",
@@ -2050,6 +2114,9 @@ var EN = {
2050
2114
  "lb.prev": "Previous (\u2190)",
2051
2115
  "lb.next": "Next (\u2192)",
2052
2116
  "lb.hint": "Click image or backdrop to close \xB7 \u2190\u2192 to switch",
2117
+ "lb.fail": "Failed to load image",
2118
+ "lb.retry": "Retry",
2119
+ "lb.open": "Open original",
2053
2120
  "filter.title": "Filter",
2054
2121
  "filter.sortfield": "Sort by",
2055
2122
  "filter.sortdir": "Direction",
@@ -2305,6 +2372,10 @@ var CSS = `
2305
2372
  .dsvm-lbdot{box-sizing:content-box;width:8px;height:8px;padding:5px;border-radius:50%;background:rgba(255,255,255,.32);background-clip:content-box;cursor:pointer}
2306
2373
  .dsvm-lbdot.on{background:#fff;background-clip:content-box}
2307
2374
  @media (max-width:640px){.dsvm-lbhint{display:none}.dsvm-btn.dsvm-lbarrow{width:40px;height:54px}}
2375
+ /* 0.9.61 \u53CC\u8D25\u7EC8\u6001\u5360\u4F4D\uFF1A\u5C45\u4E2D\u5217\u2014\u2014\u26A0 \u6587\u6848 + \u91CD\u8BD5\uFF08\u590D\u7528 dsvm-btn\uFF09+ \u6253\u5F00\u539F\u56FE\uFF08ExtLink \u65B0\u6807\u7B7E\uFF09 */
2376
+ .dsvm-lbfail{display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 32px;background:rgba(15,23,42,.55);border:1px solid rgba(255,255,255,.14);border-radius:12px}
2377
+ .dsvm-lbfailmsg{color:rgba(255,255,255,.85);font-size:14px}
2378
+ .dsvm-lbfail a{color:rgba(255,255,255,.75);font-size:13px;text-decoration:underline}
2308
2379
  .dsvm-card-clickable, .dshm-card{cursor:pointer}
2309
2380
  .dshm-card:hover{border-color:var(--dsw-alias-interactive-bg-selected,#4f46e5)}
2310
2381
  .dsvm-btn{border:1px solid rgba(255,255,255,.35);background:rgba(255,255,255,.14);color:#fff;border-radius:8px;padding:5px 14px;font:inherit;font-size:13px;cursor:pointer}
@@ -2563,18 +2634,20 @@ function useMarketData(zone = "community") {
2563
2634
  return { query, data, loading, error, reload, updateQuery };
2564
2635
  }
2565
2636
  function Icon({ entry }) {
2566
- const [broken, setBroken] = useState(false);
2567
2637
  const letter = String(entry.name || entry.id || "?").charAt(0).toUpperCase();
2568
2638
  const url = entry.icon || (entry.github ? `https://github.com/${entry.github.split("/")[0]}.png?size=64` : null);
2569
- if (!url || broken) {
2639
+ const chain = useImgChain(url || "", { h: 96 });
2640
+ if (!url || chain.failed) {
2570
2641
  return h("div", { className: "dshm-icon", "aria-hidden": "true" }, letter);
2571
2642
  }
2572
2643
  return h("img", {
2573
2644
  className: "dshm-icon",
2574
- src: url,
2645
+ src: chain.src,
2575
2646
  alt: "",
2576
- onError: () => setBroken(true),
2577
- referrerPolicy: "no-referrer"
2647
+ onError: chain.onError,
2648
+ onLoad: chain.onLoad,
2649
+ referrerPolicy: "no-referrer",
2650
+ decoding: "async"
2578
2651
  });
2579
2652
  }
2580
2653
  function Spin() {
@@ -2962,7 +3035,7 @@ function compactCount(n) {
2962
3035
  }
2963
3036
  return String(n);
2964
3037
  }
2965
- function Shot({ src, onClick }) {
3038
+ function Shot({ src, onClick, onBroken }) {
2966
3039
  const [show, setShow] = useState(false);
2967
3040
  const ref = useRef(null);
2968
3041
  useEffect(() => {
@@ -2983,6 +3056,10 @@ function Shot({ src, onClick }) {
2983
3056
  io.observe(el);
2984
3057
  return () => io.disconnect();
2985
3058
  }, []);
3059
+ const chain = useImgChain(src, { h: 300, active: show });
3060
+ useEffect(() => {
3061
+ if (chain.failed && onBroken) onBroken(src);
3062
+ }, [chain.failed, onBroken, src]);
2986
3063
  return h(
2987
3064
  "div",
2988
3065
  {
@@ -2999,9 +3076,52 @@ function Shot({ src, onClick }) {
2999
3076
  }
3000
3077
  }
3001
3078
  },
3002
- show ? h("img", { className: "dsvm-shot", src, alt: "", loading: "lazy", referrerPolicy: "no-referrer", fetchPriority: "low" }) : null
3079
+ show ? chain.failed ? null : h("img", { className: "dsvm-shot", src: chain.src, alt: "", loading: "lazy", referrerPolicy: "no-referrer", fetchPriority: "low", decoding: "async", onError: chain.onError, onLoad: chain.onLoad }) : null
3003
3080
  );
3004
3081
  }
3082
+ function useImgChain(url, opts) {
3083
+ const { w, h: h2, active = true } = opts || {};
3084
+ const bucket = useMemo(() => serviceBucketOf2(url), [url]);
3085
+ const startPrefRef = useRef(null);
3086
+ if (startPrefRef.current === null) startPrefRef.current = preferredTier2(serviceBucketOf2(url));
3087
+ const [tier, setTier] = useState(() => tierOrder2(startPrefRef.current)[0]);
3088
+ const [settled, setSettled] = useState(false);
3089
+ const startChain = useCallback(() => {
3090
+ startPrefRef.current = preferredTier2(serviceBucketOf2(url));
3091
+ setSettled(false);
3092
+ setTier(tierOrder2(startPrefRef.current)[0]);
3093
+ }, [url]);
3094
+ const [prevUrl, setPrevUrl] = useState(url);
3095
+ if (prevUrl !== url) {
3096
+ setPrevUrl(url);
3097
+ startPrefRef.current = preferredTier2(serviceBucketOf2(url));
3098
+ setSettled(false);
3099
+ setTier(tierOrder2(startPrefRef.current)[0]);
3100
+ }
3101
+ const failed = tier === "failed";
3102
+ const src = failed ? "" : tier === "weserv" ? weservUrl2(url, { w, h: h2 }) : url;
3103
+ const advance = () => {
3104
+ setSettled(false);
3105
+ setTier((t) => nextTier2(t, startPrefRef.current));
3106
+ };
3107
+ const onError = advance;
3108
+ const onLoad = useCallback(() => {
3109
+ setSettled(true);
3110
+ rememberSuccess2(bucket, tier);
3111
+ }, [bucket, tier]);
3112
+ const retry = startChain;
3113
+ useEffect(() => {
3114
+ if (!url || !active || settled || !needsTimeout2(tier)) return;
3115
+ const timer = setTimeout(advance, WESERV_TIMEOUT_MS2);
3116
+ return () => clearTimeout(timer);
3117
+ }, [url, active, settled, tier]);
3118
+ return { src, failed, retry, onError, onLoad };
3119
+ }
3120
+ function UseImgChainProbe(props) {
3121
+ const chain = useImgChain(props.url, { w: props.w, h: props.h });
3122
+ if (chain.failed) return h("div", { className: "dsvm-probe-failed" }, "chain:failed");
3123
+ return h("img", { src: chain.src, alt: "", referrerPolicy: "no-referrer", decoding: "async", onError: chain.onError, onLoad: chain.onLoad });
3124
+ }
3005
3125
  function Lightbox({ shots, index, onNav, onClose }) {
3006
3126
  const single = shots.length <= 1;
3007
3127
  const closeRef = useRef(null);
@@ -3016,10 +3136,17 @@ function Lightbox({ shots, index, onNav, onClose }) {
3016
3136
  useEffect(() => {
3017
3137
  if (closeRef.current) closeRef.current.focus();
3018
3138
  }, []);
3139
+ const chain = useImgChain(shots[index], { w: 1600 });
3019
3140
  const node = h(
3020
3141
  "div",
3021
3142
  { className: "dsvm-lightbox", role: "dialog", "aria-modal": "true", "aria-label": lookup("lb.title"), ...backdropCloseHandlers2(onClose) },
3022
- h("img", { src: shots[index], alt: "", onClick: onClose, referrerPolicy: "no-referrer" }),
3143
+ chain.failed ? h(
3144
+ "div",
3145
+ { className: "dsvm-lbfail", onClick: (e) => e.stopPropagation() },
3146
+ h("div", { className: "dsvm-lbfailmsg" }, `\u26A0 ${lookup("lb.fail")}`),
3147
+ h("button", { className: "dsvm-btn", onClick: chain.retry }, lookup("lb.retry")),
3148
+ h(ExtLink, { href: shots[index] }, lookup("lb.open"))
3149
+ ) : h("img", { src: chain.src, alt: "", onClick: onClose, referrerPolicy: "no-referrer", decoding: "async", onError: chain.onError, onLoad: chain.onLoad }),
3023
3150
  h("button", { ref: closeRef, className: "dsvm-btn dsvm-lbclose", "aria-label": lookup("common.close"), title: lookup("common.close"), onClick: onClose }, h(XIcon)),
3024
3151
  single ? null : h("button", { className: "dsvm-btn dsvm-lbarrow prev", "aria-label": lookup("lb.prev"), title: lookup("lb.prev"), onClick: () => onNav(lbStep2(index, -1, shots.length)) }, "\u2039"),
3025
3152
  single ? null : h("button", { className: "dsvm-btn dsvm-lbarrow next", "aria-label": lookup("lb.next"), title: lookup("lb.next"), onClick: () => onNav(lbStep2(index, 1, shots.length)) }, "\u203A"),
@@ -3047,6 +3174,9 @@ function useModalDepth(active) {
3047
3174
  function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeBusy, upgradeRec, profileKind, installRec, installNote }) {
3048
3175
  useModalDepth(true);
3049
3176
  const shots = it.community === true ? safeScreenshots(it) : [];
3177
+ const [brokenShots, setBrokenShots] = useState([]);
3178
+ const breakShot = useCallback((src) => setBrokenShots((prev) => prev.includes(src) ? prev : prev.concat(src)), []);
3179
+ const visible = shots.filter((src) => !brokenShots.includes(src));
3050
3180
  const [lb, setLb] = useState(null);
3051
3181
  const [copied, setCopied] = useState(false);
3052
3182
  const [rmOpen, setRmOpen] = useState(false);
@@ -3060,14 +3190,14 @@ function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeB
3060
3190
  if (lb !== null) setLb(null);
3061
3191
  else onClose();
3062
3192
  }
3063
- if (lb !== null && shots.length > 1) {
3064
- if (e.key === "ArrowLeft") setLb((i) => lbStep2(i, -1, shots.length));
3065
- if (e.key === "ArrowRight") setLb((i) => lbStep2(i, 1, shots.length));
3193
+ if (lb !== null && visible.length > 1) {
3194
+ if (e.key === "ArrowLeft") setLb((i) => lbStep2(i, -1, visible.length));
3195
+ if (e.key === "ArrowRight") setLb((i) => lbStep2(i, 1, visible.length));
3066
3196
  }
3067
3197
  };
3068
3198
  document.addEventListener("keydown", onKey);
3069
3199
  return () => document.removeEventListener("keydown", onKey);
3070
- }, [lb, shots.length, onClose]);
3200
+ }, [lb, visible.length, onClose]);
3071
3201
  const catLabel = labels && labels[it.category] || (["essentials", "cui-picks", "self-dev", "tencent-lighthouse", "watchlist"].includes(it.category) ? lookup("cat." + it.category) : it.category);
3072
3202
  const installCmd = typeof it.install === "string" && it.install !== "" ? it.install : it.source === "npm" ? `dsh plugin --profile web add ${it.npm}` : `dsh plugin --profile web add github:${it.github}`;
3073
3203
  const copyCmd = async () => {
@@ -3136,10 +3266,10 @@ function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeB
3136
3266
  h("summary", null, lookup("readme.show")),
3137
3267
  rmOpen ? h(ReadmeBlock, { pkg: it.npm, repo: it.github }) : h("div", { className: "dshm-hint" }, lookup("readme.loading"))
3138
3268
  ) : null,
3139
- shots.length ? h(
3269
+ visible.length ? h(
3140
3270
  "div",
3141
3271
  { className: "dsvm-shotrow" },
3142
- ...shots.map((src, i) => h(Shot, { key: `${i}:${src}`, src, onClick: () => setLb(i) }))
3272
+ ...shots.map((src, i) => h(Shot, { key: `${i}:${src}`, src, onClick: () => setLb(visible.indexOf(src)), onBroken: breakShot }))
3143
3273
  ) : null,
3144
3274
  it.community === true ? h(
3145
3275
  "details",
@@ -3182,7 +3312,8 @@ function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeB
3182
3312
  ) : h("button", { className: "dshm-btn primary", disabled: busy, onClick: () => onInstall(it) }, busy ? h(Spin) : lookup("action.install"))
3183
3313
  )
3184
3314
  ),
3185
- lb !== null && shots.length ? h(Lightbox, { shots, index: lb, onNav: setLb, onClose: () => setLb(null) }) : null
3315
+ // 0.9.61(评审 F):visible 活引用 + index 钳制(后台剔除收缩防越界)
3316
+ lb !== null && visible.length ? h(Lightbox, { shots: visible, index: Math.min(lb, visible.length - 1), onNav: setLb, onClose: () => setLb(null) }) : null
3186
3317
  );
3187
3318
  }
3188
3319
  var ZONE_TABS = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.60",
3
+ "version": "0.9.61",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",