dsh-m 0.9.59 → 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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,21 @@ 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
+
18
+ ### 0.9.60 修复:截图灯箱窗口态无导航/无法退出——createPortal 逃出面板包含块 + 控件常驻
19
+
20
+ - **根因**:灯箱 `position:fixed` 是 `.dshm-panel`(`backdrop-filter` + `overflow:hidden`)的后代,面板盒成了它的包含块——图片按视口单位放大(旧 94vw/80vh),在 ≤680px 高的窗口态面板里必然溢出,in-flow 的 ‹›/圆点被裁出屏外,又没有 ✕;全屏态面板恰为视口才「碰巧」可见(即「只有全屏才有导航按钮,窗口态图片占满小屏且无法退出」)。
21
+ - **v2 修复(`b89af18`)**:① 灯箱 `createPortal` 挂 `document.body`——逃出包含块,窗口/全屏/窄屏任何状态都按真实视口定位(SSR/无 document 回退原树,冒烟测试照常渲染);② 控件全部绝对定位贴边,构造上不可能被图片挤出屏——右上 ✕ 常驻、两侧 ‹› 大热区、底部「计数+圆点+操作提示」pill,图片降为 74vh 给控件留位;③ 退出路径四条:点图即关(zoom-out 光标)、点遮罩关、Esc 关、✕ 关——键盘 ←→/Esc 语义不变(DetailModal 文档级监听不受 portal 影响);④ 单图不渲染 ‹›/计数/圆点(无死控件),圆点热区 `background-clip:content-box` 扩到 18px,开灯箱即聚焦 ✕(对齐 DetailModal U8 先例)。
22
+ - **配套**:环形步进抽纯函数 `lbStep`(`lightbox.js`,组件与键盘共用,单测覆盖回绕/单图/非有限输入);新增 `client-lightbox.test.mjs` 结构守卫(portal/✕/点图关/单图收敛/CSS 绝对定位/内联取模退役),SSR 冒烟补灯箱多图/单图结构断言。
23
+ - **评审吸收轮(独立 subagent 评审,发布前落码)**:R1 CSS 级联——`.dsvm-btn` 基础规则源顺序靠后,实测反杀 ✕/‹› 的 `padding:0/font-size:26px/radius:10px`(‹› 字形实渲 13px),改复合选择器 `.dsvm-btn.dsvm-lb*`(特异性 0,2,0,顺序免疫);R2 Windows Desktop 拖拽带——灯箱 `top:var(--dsh-windows-titlebar-height,0px)` 让带(0.9.4 先例,Web 无变量回落 0px 行为不变),✕ 不再落进壳顶 `-webkit-app-region:drag` 吞点击带;R3 焦点还原——关闭灯箱把焦点归还触发的截图缩略图(捕获 effect 声明于聚焦 ✕ 之前,`isConnected` 防已卸载元素);R4 灯箱圆点与缩略图条 key 改序号(URL 重复不撞车);R5 无头探针固化为 `scripts/verify-lightbox.mjs` 入库(手动跑,需 playwright-core),结构断言收敛为 includes 级锚(逐字正则红灯语义是「文本变了」非「行为变了」),SSR 冒烟补 `role=dialog/aria-modal` 与重复 URL 圆点断言。
24
+ - **验证**:1236 项测试全绿、typecheck 零错误;`scripts/verify-lightbox.mjs` 复刻 `.dshm-overlay > .dshm-panel` 真实语境 19/19 断言——面板内 fixed 探针盒=面板盒(包含块实证)、灯箱盒=全视口、✕/‹›/pill 全程在视口内、**计算样式级联正确(R1 回归门)**、›/圆点/‹/←→ 导航与 Esc/点图关闭正确、**关闭后焦点还原缩略图(R3)**、**拖拽带 40px 避让(R2)**、全屏态回归、500px 矮窗口控件仍常驻(旧版此处必裁)。
25
+
11
26
  ### 0.9.59 优化:分类行左缘内缩 8px + 全屏下空转 ⌃ 收起钮按行数派生隐藏
12
27
 
13
28
  - **分类行内缩(`f42e5be`)**:分类 chips 行与搜索框/卡片左缘齐平,小颗粒胶囊贴着大元素左缘视觉憋仄;分类行整体右移 8px 留出呼吸空间。仅作用分类行(ZoneChips),上方分区 chips 行不随动。`offsetLeft` 与 absolute 定位同以 `.dshm-chips` 的 padding 盒为原点 → `+N` 跟随、右侧筛选钉位、`offsetTop` 裁剪几何均不受影响。
@@ -438,6 +453,21 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
438
453
 
439
454
  ## English
440
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
+
463
+ ### Fixed in 0.9.60 — screenshot lightbox unusable when windowed: portal out of the panel containing block + always-visible controls
464
+
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").
466
+ - **v2 fix (`b89af18`)**: ① portal the lightbox to `document.body` — it escapes the containing block and is positioned against the real viewport in every panel state (SSR/no-document falls back to the plain tree so the smoke test still renders it); ② every control is absolutely positioned to the edges and can never be pushed off-screen by an image — persistent top-right ✕, large side ‹ › arrows, a bottom counter+dots+hint pill, and the image capped at 74vh; ③ four ways out: click the image (zoom-out cursor), click the backdrop, Esc, or ✕ — keyboard ←→/Esc semantics unchanged (DetailModal document-level listeners are unaffected by the portal); ④ single-image shots hide ‹›/counter/dots (no dead controls), dot hit areas grow to 18px via `background-clip:content-box`, and the close button is focused on open (same pattern as DetailModal U8).
467
+ - **Supporting changes**: the ring step is extracted as the pure `lbStep` helper (`lightbox.js`, shared by the component and the keyboard handler; unit-tested for wrap-around, single image and non-finite input); new `client-lightbox.test.mjs` structural guards (portal/✕/click-image-close/single-image/CSS absolutes/inline-modulo retired); SSR smoke extended with multi- and single-image lightbox assertions.
468
+ - **Review absorption round (independent subagent review, landed pre-release)**: R1 CSS cascade — the base `.dsvm-btn` rule sits later in source order and measurably overrode the ✕/‹› `padding:0/font-size:26px/radius:10px` (arrows actually rendered a 13px glyph); switched to compound selectors `.dsvm-btn.dsvm-lb*` (specificity 0,2,0, order-immune). R2 Windows Desktop drag band — the lightbox now yields the top band via `top:var(--dsh-windows-titlebar-height,0px)` (the 0.9.4 precedent; web falls back to 0px with zero drift) so ✕ never lands in the shell's `-webkit-app-region:drag` strip that swallows clicks. R3 focus restore — closing the lightbox returns focus to the triggering screenshot thumbnail (capture effect declared before the focus-✕ effect; `isConnected` guard). R4 lightbox dots and the thumbnail strip now key by index (duplicate URLs no longer collide). R5 the headless probe is persisted as `scripts/verify-lightbox.mjs` (manual run, needs playwright-core); structural assertions converged to includes-level anchors (verbatim regex red lights mean "text changed", not "behavior changed"); SSR smoke gains `role=dialog/aria-modal` and duplicate-URL dots assertions.
469
+ - **Verification**: 1236 tests pass and typecheck is clean; `scripts/verify-lightbox.mjs` reproducing the real `.dshm-overlay > .dshm-panel` context passed 19/19 assertions — a fixed probe inside the panel equals the panel box (containing block proven), the lightbox box equals the viewport, ✕/‹›/pill stay within the viewport at all times, **computed cascade styles correct (R1 regression gate)**, ›/dot/‹/←→ navigation plus Esc/click-image close behave correctly, **focus restored to the thumbnail on close (R3)**, **40px titlebar band avoided (R2)**, fullscreen regresses nothing, and controls remain present in a 500px-tall window (always clipped before).
470
+
441
471
  ### Improved in 0.9.59 — 8px category-row inset and a derived visibility for the no-op collapse toggle
442
472
 
443
473
  - **Category-row inset (`f42e5be`)**: the category chip row sat flush with the search box and card grid, and small pills hugging large elements felt cramped; the row now starts 8px further right for breathing room. Only the category row (ZoneChips) moves — the zone chip row above stays put. `offsetLeft` and absolute positioning share the `.dshm-chips` padding-box origin, so `+N` tracking, the pinned Filter trigger and `offsetTop`-based clip geometry are all unaffected.
package/lib/client.js CHANGED
@@ -463,6 +463,81 @@ var init_backdrop = __esm({
463
463
  }
464
464
  });
465
465
 
466
+ // src/client/lightbox.js
467
+ var lightbox_exports = {};
468
+ __export(lightbox_exports, {
469
+ lbStep: () => lbStep
470
+ });
471
+ function lbStep(index, step, length) {
472
+ if (!Number.isFinite(index) || !Number.isFinite(step) || !Number.isInteger(length) || length <= 1) return index;
473
+ return ((index + step) % length + length) % length;
474
+ }
475
+ var init_lightbox = __esm({
476
+ "src/client/lightbox.js"() {
477
+ "use strict";
478
+ }
479
+ });
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
+
466
541
  // src/client/markdown.js
467
542
  var markdown_exports = {};
468
543
  __export(markdown_exports, {
@@ -1576,6 +1651,8 @@ var API = "/dshm";
1576
1651
  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));
1577
1652
  var { readMarketSnapshot: readMarketSnapshot2, writeMarketSnapshot: writeMarketSnapshot2, isDefaultFirstPageQuery: isDefaultFirstPageQuery2 } = (init_market_snapshot(), __toCommonJS(market_snapshot_exports));
1578
1653
  var { backdropCloseHandlers: backdropCloseHandlers2 } = (init_backdrop(), __toCommonJS(backdrop_exports));
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));
1579
1656
  var { createMarkdown: createMarkdown2 } = (init_markdown(), __toCommonJS(markdown_exports));
1580
1657
  var { ExtLink, MdImg, renderMarkdown } = createMarkdown2(h);
1581
1658
  var { installedViewModel: installedViewModel2, registrySourceKey: registrySourceKey2 } = (init_installed_view(), __toCommonJS(installed_view_exports));
@@ -1752,6 +1829,13 @@ var ZH = {
1752
1829
  "pager.jump.ph": "\u9875\u53F7",
1753
1830
  "panel.fullscreen": "\u5168\u5C4F",
1754
1831
  "panel.restore": "\u8FD8\u539F",
1832
+ "lb.title": "\u63D2\u4EF6\u622A\u56FE\u9884\u89C8",
1833
+ "lb.prev": "\u4E0A\u4E00\u5F20\uFF08\u2190\uFF09",
1834
+ "lb.next": "\u4E0B\u4E00\u5F20\uFF08\u2192\uFF09",
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",
1755
1839
  "filter.title": "\u7B5B\u9009",
1756
1840
  "filter.sortfield": "\u6392\u5E8F\u5B57\u6BB5",
1757
1841
  "filter.sortdir": "\u6392\u5217\u65B9\u5411",
@@ -2026,6 +2110,13 @@ var EN = {
2026
2110
  "pager.jump.ph": "Page",
2027
2111
  "panel.fullscreen": "Fullscreen",
2028
2112
  "panel.restore": "Restore",
2113
+ "lb.title": "Plugin screenshot preview",
2114
+ "lb.prev": "Previous (\u2190)",
2115
+ "lb.next": "Next (\u2192)",
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",
2029
2120
  "filter.title": "Filter",
2030
2121
  "filter.sortfield": "Sort by",
2031
2122
  "filter.sortdir": "Direction",
@@ -2260,13 +2351,31 @@ var CSS = `
2260
2351
  .dsvm-shotrow{display:flex;gap:6px;overflow-x:auto;padding:2px 0}
2261
2352
  .dsvm-shotbox{min-width:120px;min-height:84px;display:flex;align-items:center;justify-content:center;background:rgba(127,127,127,.08);border-radius:6px;cursor:zoom-in;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.25))}
2262
2353
  .dsvm-shot{max-width:220px;max-height:130px;border-radius:6px;display:block}
2263
- .dsvm-lightbox{position:fixed;inset:0;z-index:2147483200;background:rgba(0,0,0,.88);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px}
2264
- .dsvm-lightbox img{max-width:94vw;max-height:80vh;border-radius:8px}
2265
- .dsvm-lbnav{display:flex;align-items:center;gap:10px}
2266
- .dsvm-lbcount{color:rgba(255,255,255,.75);font-size:12px}
2267
- .dsvm-lbdots{display:flex;gap:6px}
2268
- .dsvm-lbdot{width:8px;height:8px;border-radius:50%;background:rgba(255,255,255,.3);cursor:pointer}
2269
- .dsvm-lbdot.on{background:#fff}
2354
+ /* \u706F\u7BB1 v2\uFF080.9.60\uFF09\uFF1A\u7EC4\u4EF6 createPortal \u6302 body\uFF08\u9003\u51FA\u9762\u677F backdrop-filter \u5305\u542B\u5757\uFF09\uFF0C
2355
+ \u63A7\u4EF6\u5168\u90E8\u7EDD\u5BF9\u5B9A\u4F4D\u8D34\u89C6\u53E3\u8FB9\u2014\u2014\u2715 \u5E38\u9A7B\u53F3\u4E0A\u3001\u2039\u203A \u4E24\u4FA7\u5C45\u4E2D\u3001\u5E95\u90E8\u8BA1\u6570+\u5706\u70B9 pill\uFF0C
2356
+ \u4EFB\u4F55\u9762\u677F\u72B6\u6001/\u4EFB\u610F\u56FE\u7247\u9AD8\u5EA6\u4E0B\u90FD\u4E0D\u4F1A\u88AB\u6324\u51FA\u5C4F\u5E55\uFF1B\u56FE\u7247\u964D\u5230 74vh \u7ED9\u5E95\u90E8\u63A7\u4EF6\u7559\u4F4D\u3002
2357
+ \u8BC4\u5BA1 R1\uFF1A\u2715/\u2039\u203A \u7528 .dsvm-btn.dsvm-lb* \u590D\u5408\u9009\u62E9\u5668\u2014\u2014\u57FA\u7840 .dsvm-btn \u89C4\u5219\u6E90\u987A\u5E8F\u9760\u540E
2358
+ \uFF08padding:5px 14px/font-size:13px/radius:8px\uFF09\uFF0C\u540C\u7279\u5F02\u6027\u4E0B\u4F1A\u53CD\u6740\u5355\u7C7B\u89C4\u5219\uFF08\u5B9E\u6D4B\u5B57\u5F62 13px\u226026px\uFF09\u3002
2359
+ \u8BC4\u5BA1 R2\uFF1Atop \u8BA9\u51FA Windows Desktop \u62D6\u62FD\u5E26 var(--dsh-windows-titlebar-height)\uFF080.9.4 \u5148\u4F8B\uFF0C
2360
+ \u58F3\u9876 -webkit-app-region:drag \u541E\u70B9\u51FB\u3001\u65E0\u89C6 z-index\uFF09\uFF1BWeb \u65E0\u6B64\u53D8\u91CF\u56DE\u843D 0px \u884C\u4E3A\u4E0D\u53D8\u3002 */
2361
+ .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);display:flex;align-items:center;justify-content:center}
2362
+ .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}
2363
+ .dsvm-btn:focus-visible{outline:2px solid rgba(255,255,255,.75);outline-offset:2px}
2364
+ .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}
2365
+ .dsvm-btn.dsvm-lbarrow{position:absolute;top:50%;transform:translateY(-50%);width:46px;height:60px;padding:0;font-size:26px;line-height:1;border-radius:10px}
2366
+ .dsvm-lbarrow.prev{left:12px}
2367
+ .dsvm-lbarrow.next{right:12px}
2368
+ .dsvm-lbbar{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);display:flex;align-items:center;gap:12px;max-width:94vw;background:rgba(15,23,42,.62);border:1px solid rgba(255,255,255,.16);border-radius:999px;padding:7px 16px;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
2369
+ .dsvm-lbcount{color:rgba(255,255,255,.82);font-size:12px;font-variant-numeric:tabular-nums;white-space:nowrap}
2370
+ .dsvm-lbhint{color:rgba(255,255,255,.55);font-size:11px;white-space:nowrap}
2371
+ .dsvm-lbdots{display:flex;gap:2px}
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}
2373
+ .dsvm-lbdot.on{background:#fff;background-clip:content-box}
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}
2270
2379
  .dsvm-card-clickable, .dshm-card{cursor:pointer}
2271
2380
  .dshm-card:hover{border-color:var(--dsw-alias-interactive-bg-selected,#4f46e5)}
2272
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}
@@ -2525,18 +2634,20 @@ function useMarketData(zone = "community") {
2525
2634
  return { query, data, loading, error, reload, updateQuery };
2526
2635
  }
2527
2636
  function Icon({ entry }) {
2528
- const [broken, setBroken] = useState(false);
2529
2637
  const letter = String(entry.name || entry.id || "?").charAt(0).toUpperCase();
2530
2638
  const url = entry.icon || (entry.github ? `https://github.com/${entry.github.split("/")[0]}.png?size=64` : null);
2531
- if (!url || broken) {
2639
+ const chain = useImgChain(url || "", { h: 96 });
2640
+ if (!url || chain.failed) {
2532
2641
  return h("div", { className: "dshm-icon", "aria-hidden": "true" }, letter);
2533
2642
  }
2534
2643
  return h("img", {
2535
2644
  className: "dshm-icon",
2536
- src: url,
2645
+ src: chain.src,
2537
2646
  alt: "",
2538
- onError: () => setBroken(true),
2539
- referrerPolicy: "no-referrer"
2647
+ onError: chain.onError,
2648
+ onLoad: chain.onLoad,
2649
+ referrerPolicy: "no-referrer",
2650
+ decoding: "async"
2540
2651
  });
2541
2652
  }
2542
2653
  function Spin() {
@@ -2924,7 +3035,7 @@ function compactCount(n) {
2924
3035
  }
2925
3036
  return String(n);
2926
3037
  }
2927
- function Shot({ src, onClick }) {
3038
+ function Shot({ src, onClick, onBroken }) {
2928
3039
  const [show, setShow] = useState(false);
2929
3040
  const ref = useRef(null);
2930
3041
  useEffect(() => {
@@ -2945,6 +3056,10 @@ function Shot({ src, onClick }) {
2945
3056
  io.observe(el);
2946
3057
  return () => io.disconnect();
2947
3058
  }, []);
3059
+ const chain = useImgChain(src, { h: 300, active: show });
3060
+ useEffect(() => {
3061
+ if (chain.failed && onBroken) onBroken(src);
3062
+ }, [chain.failed, onBroken, src]);
2948
3063
  return h(
2949
3064
  "div",
2950
3065
  {
@@ -2961,27 +3076,90 @@ function Shot({ src, onClick }) {
2961
3076
  }
2962
3077
  }
2963
3078
  },
2964
- 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
2965
3080
  );
2966
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
+ }
2967
3125
  function Lightbox({ shots, index, onNav, onClose }) {
2968
- return h(
3126
+ const single = shots.length <= 1;
3127
+ const closeRef = useRef(null);
3128
+ const prevFocusRef = useRef(null);
3129
+ useEffect(() => {
3130
+ prevFocusRef.current = document.activeElement;
3131
+ return () => {
3132
+ const el = prevFocusRef.current;
3133
+ if (el && el.isConnected) el.focus();
3134
+ };
3135
+ }, []);
3136
+ useEffect(() => {
3137
+ if (closeRef.current) closeRef.current.focus();
3138
+ }, []);
3139
+ const chain = useImgChain(shots[index], { w: 1600 });
3140
+ const node = h(
2969
3141
  "div",
2970
- { className: "dsvm-lightbox", ...backdropCloseHandlers2(onClose) },
2971
- h("img", { src: shots[index], alt: "", onClick: (e) => e.stopPropagation(), referrerPolicy: "no-referrer" }),
2972
- h(
3142
+ { className: "dsvm-lightbox", role: "dialog", "aria-modal": "true", "aria-label": lookup("lb.title"), ...backdropCloseHandlers2(onClose) },
3143
+ chain.failed ? h(
2973
3144
  "div",
2974
- { className: "dsvm-lbnav", onClick: (e) => e.stopPropagation() },
2975
- h("button", { className: "dsvm-btn", onClick: () => onNav((index - 1 + shots.length) % shots.length) }, "\u2039"),
2976
- h("span", { className: "dsvm-lbcount" }, `${index + 1} / ${shots.length}`),
2977
- h("button", { className: "dsvm-btn", onClick: () => onNav((index + 1) % shots.length) }, "\u203A")
2978
- ),
2979
- h(
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 }),
3150
+ h("button", { ref: closeRef, className: "dsvm-btn dsvm-lbclose", "aria-label": lookup("common.close"), title: lookup("common.close"), onClick: onClose }, h(XIcon)),
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"),
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"),
3153
+ single ? null : h(
2980
3154
  "div",
2981
- { className: "dsvm-lbdots", onClick: (e) => e.stopPropagation() },
2982
- ...shots.map((s, i) => h("span", { key: s, className: `dsvm-lbdot${i === index ? " on" : ""}`, onClick: () => onNav(i) }))
3155
+ { className: "dsvm-lbbar" },
3156
+ h("span", { className: "dsvm-lbcount" }, `${index + 1} / ${shots.length}`),
3157
+ // 评审 R4:key 用序号而非 URL——safeScreenshots 不去重,同一 URL 两次出现即 key 撞车
3158
+ h("div", { className: "dsvm-lbdots" }, ...shots.map((s, i) => h("span", { key: i, className: `dsvm-lbdot${i === index ? " on" : ""}`, onClick: () => onNav(i) }))),
3159
+ h("span", { className: "dsvm-lbhint" }, lookup("lb.hint"))
2983
3160
  )
2984
3161
  );
3162
+ return typeof document !== "undefined" && document.body && typeof rd.createPortal === "function" ? rd.createPortal(node, document.body) : node;
2985
3163
  }
2986
3164
  var dsvmModalDepth = 0;
2987
3165
  function useModalDepth(active) {
@@ -2996,6 +3174,9 @@ function useModalDepth(active) {
2996
3174
  function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeBusy, upgradeRec, profileKind, installRec, installNote }) {
2997
3175
  useModalDepth(true);
2998
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));
2999
3180
  const [lb, setLb] = useState(null);
3000
3181
  const [copied, setCopied] = useState(false);
3001
3182
  const [rmOpen, setRmOpen] = useState(false);
@@ -3009,14 +3190,14 @@ function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeB
3009
3190
  if (lb !== null) setLb(null);
3010
3191
  else onClose();
3011
3192
  }
3012
- if (lb !== null && shots.length > 1) {
3013
- if (e.key === "ArrowLeft") setLb((i) => (i - 1 + shots.length) % shots.length);
3014
- if (e.key === "ArrowRight") setLb((i) => (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));
3015
3196
  }
3016
3197
  };
3017
3198
  document.addEventListener("keydown", onKey);
3018
3199
  return () => document.removeEventListener("keydown", onKey);
3019
- }, [lb, shots.length, onClose]);
3200
+ }, [lb, visible.length, onClose]);
3020
3201
  const catLabel = labels && labels[it.category] || (["essentials", "cui-picks", "self-dev", "tencent-lighthouse", "watchlist"].includes(it.category) ? lookup("cat." + it.category) : it.category);
3021
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}`;
3022
3203
  const copyCmd = async () => {
@@ -3085,10 +3266,10 @@ function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeB
3085
3266
  h("summary", null, lookup("readme.show")),
3086
3267
  rmOpen ? h(ReadmeBlock, { pkg: it.npm, repo: it.github }) : h("div", { className: "dshm-hint" }, lookup("readme.loading"))
3087
3268
  ) : null,
3088
- shots.length ? h(
3269
+ visible.length ? h(
3089
3270
  "div",
3090
3271
  { className: "dsvm-shotrow" },
3091
- ...shots.map((src, i) => h(Shot, { key: src, src, onClick: () => setLb(i) }))
3272
+ ...shots.map((src, i) => h(Shot, { key: `${i}:${src}`, src, onClick: () => setLb(visible.indexOf(src)), onBroken: breakShot }))
3092
3273
  ) : null,
3093
3274
  it.community === true ? h(
3094
3275
  "details",
@@ -3131,7 +3312,8 @@ function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeB
3131
3312
  ) : h("button", { className: "dshm-btn primary", disabled: busy, onClick: () => onInstall(it) }, busy ? h(Spin) : lookup("action.install"))
3132
3313
  )
3133
3314
  ),
3134
- 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
3135
3317
  );
3136
3318
  }
3137
3319
  var ZONE_TABS = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.59",
3
+ "version": "0.9.61",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/registry.json CHANGED
@@ -66,7 +66,8 @@
66
66
  "id": "dsh-context",
67
67
  "name": "DSH Context",
68
68
  "description": "上下文透视:Context 仪表盘、侧栏面板与 /context 命令,查看上下文组成、演进趋势与压缩剪枝等事件,支持跨会话用量统计。",
69
- "category": "cui-picks",
69
+ "category": "essentials",
70
+ "alsoCategories": ["cui-picks"],
70
71
  "tags": ["上下文", "Token 统计", "可视化", "仪表盘"],
71
72
  "source": "npm",
72
73
  "npm": "dsh-context",