dsh-m 0.9.58 → 0.9.60

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.60 修复:截图灯箱窗口态无导航/无法退出——createPortal 逃出面板包含块 + 控件常驻
12
+
13
+ - **根因**:灯箱 `position:fixed` 是 `.dshm-panel`(`backdrop-filter` + `overflow:hidden`)的后代,面板盒成了它的包含块——图片按视口单位放大(旧 94vw/80vh),在 ≤680px 高的窗口态面板里必然溢出,in-flow 的 ‹›/圆点被裁出屏外,又没有 ✕;全屏态面板恰为视口才「碰巧」可见(即「只有全屏才有导航按钮,窗口态图片占满小屏且无法退出」)。
14
+ - **v2 修复(`b89af18`)**:① 灯箱 `createPortal` 挂 `document.body`——逃出包含块,窗口/全屏/窄屏任何状态都按真实视口定位(SSR/无 document 回退原树,冒烟测试照常渲染);② 控件全部绝对定位贴边,构造上不可能被图片挤出屏——右上 ✕ 常驻、两侧 ‹› 大热区、底部「计数+圆点+操作提示」pill,图片降为 74vh 给控件留位;③ 退出路径四条:点图即关(zoom-out 光标)、点遮罩关、Esc 关、✕ 关——键盘 ←→/Esc 语义不变(DetailModal 文档级监听不受 portal 影响);④ 单图不渲染 ‹›/计数/圆点(无死控件),圆点热区 `background-clip:content-box` 扩到 18px,开灯箱即聚焦 ✕(对齐 DetailModal U8 先例)。
15
+ - **配套**:环形步进抽纯函数 `lbStep`(`lightbox.js`,组件与键盘共用,单测覆盖回绕/单图/非有限输入);新增 `client-lightbox.test.mjs` 结构守卫(portal/✕/点图关/单图收敛/CSS 绝对定位/内联取模退役),SSR 冒烟补灯箱多图/单图结构断言。
16
+ - **评审吸收轮(独立 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 圆点断言。
17
+ - **验证**:1236 项测试全绿、typecheck 零错误;`scripts/verify-lightbox.mjs` 复刻 `.dshm-overlay > .dshm-panel` 真实语境 19/19 断言——面板内 fixed 探针盒=面板盒(包含块实证)、灯箱盒=全视口、✕/‹›/pill 全程在视口内、**计算样式级联正确(R1 回归门)**、›/圆点/‹/←→ 导航与 Esc/点图关闭正确、**关闭后焦点还原缩略图(R3)**、**拖拽带 40px 避让(R2)**、全屏态回归、500px 矮窗口控件仍常驻(旧版此处必裁)。
18
+
19
+ ### 0.9.59 优化:分类行左缘内缩 8px + 全屏下空转 ⌃ 收起钮按行数派生隐藏
20
+
21
+ - **分类行内缩(`f42e5be`)**:分类 chips 行与搜索框/卡片左缘齐平,小颗粒胶囊贴着大元素左缘视觉憋仄;分类行整体右移 8px 留出呼吸空间。仅作用分类行(ZoneChips),上方分区 chips 行不随动。`offsetLeft` 与 absolute 定位同以 `.dshm-chips` 的 padding 盒为原点 → `+N` 跟随、右侧筛选钉位、`offsetTop` 裁剪几何均不受影响。
22
+ - **⌃ 空转修复(`7cbdf11`)**:全屏等宽容器下分类 chips 可能排进 ≤ maxRows(2)行,展开态 `⌃` 收起钮点击后布局零变化(`hiddenCount=0` 连 `+N` 都不会出现),成死按钮。新增展开态行数测量 `expRows`(复用 `chipRows` 纯函数,measure 展开态分支只写 `expRows` 不动 `geom`),`⌃` 仅当展开行数 > maxRows 时渲染;ResizeObserver 在展开态也挂载——全屏↔窗口切换的宽度变化正是重测触发源,全屏下 `⌃` 自动隐藏、退回窗口自动恢复,行数含 `⌃` 自身故显隐收敛不振荡。
23
+ - **验证**:1224 项测试全绿;真实 DSH Web 实测窗口/全屏两态 `⌃` 显隐与收起行为、分类行 8px 内缩生效。
24
+
11
25
  ### 0.9.58 优化:+N 动态紧跟末可见分类,并与其框高一致
12
26
 
13
27
  - **位置(`4ade54f`)**:在 0.9.57 的独立安全槽位中,根据末可见分类 chip 的实测右缘设置 `+N` 左缘 = 右缘 + 6px;槽位由 52px 扩为 60px,窄宽时仍不与分类或筛选重叠。精选区没有筛选时不额外预留筛选 gutter;展开态 `⌃` 仍位于列表尾部。
@@ -432,6 +446,20 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
432
446
 
433
447
  ## English
434
448
 
449
+ ### Fixed in 0.9.60 — screenshot lightbox unusable when windowed: portal out of the panel containing block + always-visible controls
450
+
451
+ - **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").
452
+ - **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).
453
+ - **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.
454
+ - **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.
455
+ - **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).
456
+
457
+ ### Improved in 0.9.59 — 8px category-row inset and a derived visibility for the no-op collapse toggle
458
+
459
+ - **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.
460
+ - **No-op `⌃` fix (`7cbdf11`)**: at fullscreen width all category chips can fit within maxRows (2) rows, where clicking the expanded-state `⌃` collapse toggle changed nothing visually (`hiddenCount=0`, not even a `+N` appears) — a dead button. Added an expanded-state row measurement `expRows` (reusing the pure `chipRows` helper; the expanded measure branch only writes `expRows`, never `geom`), and `⌃` renders only when expanded rows exceed maxRows. The ResizeObserver now also observes while expanded — the width change of fullscreen↔window switches is exactly the remeasure trigger — so `⌃` hides itself in fullscreen and returns back in the window; counting includes `⌃` itself, so visibility converges without oscillation.
461
+ - **Verification**: 1224 tests pass; verified against real DSH Web for `⌃` visibility and collapse behavior in both window and fullscreen, and for the 8px category-row inset.
462
+
435
463
  ### Improved in 0.9.58 — the +N toggle follows the last visible category at matching height
436
464
 
437
465
  - **Position (`4ade54f`)**: within the 0.9.57 reserved, overlap-free slot, position `+N` from the measured right edge of the last visible category chip plus 6px. The slot grows from 52px to 60px to preserve clearance at narrow widths. Curated rows without a Filter trigger still reserve no unnecessary Filter gutter; expanded `⌃` remains at list end.
package/lib/client.js CHANGED
@@ -463,6 +463,21 @@ 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
+
466
481
  // src/client/markdown.js
467
482
  var markdown_exports = {};
468
483
  __export(markdown_exports, {
@@ -1573,9 +1588,10 @@ var h = React.createElement;
1573
1588
  var { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } = React;
1574
1589
  var PLUGIN_ID = "dsh-m";
1575
1590
  var API = "/dshm";
1576
- 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, countBeyondRows: countBeyondRows2, clipTopOf: clipTopOf2, visibleRowTop: visibleRowTop2, autoExpandDecision: autoExpandDecision2 } = (init_market_state(), __toCommonJS(market_state_exports));
1591
+ 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
1592
  var { readMarketSnapshot: readMarketSnapshot2, writeMarketSnapshot: writeMarketSnapshot2, isDefaultFirstPageQuery: isDefaultFirstPageQuery2 } = (init_market_snapshot(), __toCommonJS(market_snapshot_exports));
1578
1593
  var { backdropCloseHandlers: backdropCloseHandlers2 } = (init_backdrop(), __toCommonJS(backdrop_exports));
1594
+ var { lbStep: lbStep2 } = (init_lightbox(), __toCommonJS(lightbox_exports));
1579
1595
  var { createMarkdown: createMarkdown2 } = (init_markdown(), __toCommonJS(markdown_exports));
1580
1596
  var { ExtLink, MdImg, renderMarkdown } = createMarkdown2(h);
1581
1597
  var { installedViewModel: installedViewModel2, registrySourceKey: registrySourceKey2 } = (init_installed_view(), __toCommonJS(installed_view_exports));
@@ -1752,6 +1768,10 @@ var ZH = {
1752
1768
  "pager.jump.ph": "\u9875\u53F7",
1753
1769
  "panel.fullscreen": "\u5168\u5C4F",
1754
1770
  "panel.restore": "\u8FD8\u539F",
1771
+ "lb.title": "\u63D2\u4EF6\u622A\u56FE\u9884\u89C8",
1772
+ "lb.prev": "\u4E0A\u4E00\u5F20\uFF08\u2190\uFF09",
1773
+ "lb.next": "\u4E0B\u4E00\u5F20\uFF08\u2192\uFF09",
1774
+ "lb.hint": "\u70B9\u56FE\u6216\u7A7A\u767D\u5904\u5173\u95ED \xB7 \u2190\u2192 \u5207\u6362",
1755
1775
  "filter.title": "\u7B5B\u9009",
1756
1776
  "filter.sortfield": "\u6392\u5E8F\u5B57\u6BB5",
1757
1777
  "filter.sortdir": "\u6392\u5217\u65B9\u5411",
@@ -2026,6 +2046,10 @@ var EN = {
2026
2046
  "pager.jump.ph": "Page",
2027
2047
  "panel.fullscreen": "Fullscreen",
2028
2048
  "panel.restore": "Restore",
2049
+ "lb.title": "Plugin screenshot preview",
2050
+ "lb.prev": "Previous (\u2190)",
2051
+ "lb.next": "Next (\u2192)",
2052
+ "lb.hint": "Click image or backdrop to close \xB7 \u2190\u2192 to switch",
2029
2053
  "filter.title": "Filter",
2030
2054
  "filter.sortfield": "Sort by",
2031
2055
  "filter.sortdir": "Direction",
@@ -2230,6 +2254,10 @@ var CSS = `
2230
2254
  .dshm-chip:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(38,49,72,.06))}
2231
2255
  .dshm-chip.on{background:var(--dsw-specific-sidebar-nav-item-active,rgba(38,49,72,.08));border-color:transparent;color:var(--dsw-alias-label-primary,inherit);font-weight:500}
2232
2256
  .dsvm-chipswrap{position:sticky;top:-14px;z-index:5;background:var(--dsw-alias-bg-base,#fff);padding:8px 0;margin:-8px 0;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.14))}
2257
+ /* 0.9.59 \u5206\u7C7B\u884C\u5DE6\u7F18\u5185\u7F29 8px\uFF1A\u4E0E\u641C\u7D22\u6846/\u5361\u7247\u5DE6\u7F18\u9F50\u5E73\u65F6\u80F6\u56CA\u884C\u89C6\u89C9\u618B\u4EC4\uFF0C\u6574\u4F53\u53F3\u79FB\u7559\u51FA\u547C\u5438\u7A7A\u95F4\u3002
2258
+ \u4EC5\u4F5C\u7528\u5206\u7C7B\u884C\uFF08ZoneChips\uFF09\uFF0C\u4E0A\u65B9\u5206\u533A chips \u884C\u4E0D\u968F\u52A8\uFF1BoffsetLeft \u4E0E absolute \u5B9A\u4F4D\u540C\u4EE5
2259
+ .dshm-chips \u7684 padding \u76D2\u4E3A\u539F\u70B9 \u2192 +N \u8DDF\u968F\u3001\u53F3\u4FA7\u7B5B\u9009\u9489\u4F4D\u3001offsetTop \u88C1\u526A\u51E0\u4F55\u5747\u4E0D\u53D7\u5F71\u54CD\u3002 */
2260
+ .dsvm-chipswrap .dshm-chips{padding-left:8px}
2233
2261
  .dsvm-pagejump{display:inline-flex;align-items:center;gap:4px;margin-left:8px}
2234
2262
  .dsvm-pagejump-input{width:56px;height:24px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.28));background:transparent;color:var(--dsw-alias-label-secondary,#4b5563);border-radius:999px;padding:0 10px;font:inherit;font-size:12px;outline:none;text-align:center;transition:border-color .15s,color .15s}
2235
2263
  .dsvm-pagejump-input:hover{color:var(--dsw-alias-label-primary,inherit)}
@@ -2256,13 +2284,27 @@ var CSS = `
2256
2284
  .dsvm-shotrow{display:flex;gap:6px;overflow-x:auto;padding:2px 0}
2257
2285
  .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))}
2258
2286
  .dsvm-shot{max-width:220px;max-height:130px;border-radius:6px;display:block}
2259
- .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}
2260
- .dsvm-lightbox img{max-width:94vw;max-height:80vh;border-radius:8px}
2261
- .dsvm-lbnav{display:flex;align-items:center;gap:10px}
2262
- .dsvm-lbcount{color:rgba(255,255,255,.75);font-size:12px}
2263
- .dsvm-lbdots{display:flex;gap:6px}
2264
- .dsvm-lbdot{width:8px;height:8px;border-radius:50%;background:rgba(255,255,255,.3);cursor:pointer}
2265
- .dsvm-lbdot.on{background:#fff}
2287
+ /* \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
2288
+ \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
2289
+ \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
2290
+ \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
2291
+ \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
2292
+ \u8BC4\u5BA1 R2\uFF1Atop \u8BA9\u51FA Windows Desktop \u62D6\u62FD\u5E26 var(--dsh-windows-titlebar-height)\uFF080.9.4 \u5148\u4F8B\uFF0C
2293
+ \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 */
2294
+ .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}
2295
+ .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}
2296
+ .dsvm-btn:focus-visible{outline:2px solid rgba(255,255,255,.75);outline-offset:2px}
2297
+ .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}
2298
+ .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}
2299
+ .dsvm-lbarrow.prev{left:12px}
2300
+ .dsvm-lbarrow.next{right:12px}
2301
+ .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)}
2302
+ .dsvm-lbcount{color:rgba(255,255,255,.82);font-size:12px;font-variant-numeric:tabular-nums;white-space:nowrap}
2303
+ .dsvm-lbhint{color:rgba(255,255,255,.55);font-size:11px;white-space:nowrap}
2304
+ .dsvm-lbdots{display:flex;gap:2px}
2305
+ .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
+ .dsvm-lbdot.on{background:#fff;background-clip:content-box}
2307
+ @media (max-width:640px){.dsvm-lbhint{display:none}.dsvm-btn.dsvm-lbarrow{width:40px;height:54px}}
2266
2308
  .dsvm-card-clickable, .dshm-card{cursor:pointer}
2267
2309
  .dshm-card:hover{border-color:var(--dsw-alias-interactive-bg-selected,#4f46e5)}
2268
2310
  .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}
@@ -2732,6 +2774,7 @@ function ZoneChips({ zone, counts, labels, active, onPick, trailing, wrapTitle }
2732
2774
  const chipsSignature = JSON.stringify(chips.map((c) => [c.id, c.labelKey ? lookup(c.labelKey) : c.label, c.count]));
2733
2775
  const [expanded, setExpanded] = useState(false);
2734
2776
  const [stuck, setStuck] = useState(false);
2777
+ const [expRows, setExpRows] = useState(0);
2735
2778
  const [geom, setGeom] = useState(null);
2736
2779
  const [rowHeight, setRowHeight] = useState(0);
2737
2780
  const wrapRef = useRef(null);
@@ -2742,7 +2785,7 @@ function ZoneChips({ zone, counts, labels, active, onPick, trailing, wrapTitle }
2742
2785
  const maxH = maxRows * (rowHeight || 22) + (maxRows - 1) * 6;
2743
2786
  const measure = useCallback(() => {
2744
2787
  const el = wrapRef.current;
2745
- if (!el || expanded) return;
2788
+ if (!el) return;
2746
2789
  const tops = [];
2747
2790
  let rh = 0;
2748
2791
  for (const k of el.children) {
@@ -2751,6 +2794,11 @@ function ZoneChips({ zone, counts, labels, active, onPick, trailing, wrapTitle }
2751
2794
  if (!rh && k.offsetHeight) rh = k.offsetHeight;
2752
2795
  }
2753
2796
  if (!tops.length) return;
2797
+ if (expanded) {
2798
+ const rows = chipRows2(tops);
2799
+ setExpRows((prev) => prev === rows ? prev : rows);
2800
+ return;
2801
+ }
2754
2802
  setGeom((prev) => {
2755
2803
  const clipTopV = clipTopOf2(tops, maxRows);
2756
2804
  const hiddenCount2 = countBeyondRows2(tops, maxRows);
@@ -2784,7 +2832,7 @@ function ZoneChips({ zone, counts, labels, active, onPick, trailing, wrapTitle }
2784
2832
  if (d.expand) setExpanded(true);
2785
2833
  }, [active, activeIdx, expanded, geom]);
2786
2834
  useLayoutEffect(() => {
2787
- if (!expanded) measure();
2835
+ measure();
2788
2836
  }, [chipsSignature, active, expanded, zone, stuck, rowHeight, measure]);
2789
2837
  useEffect(() => {
2790
2838
  if (expanded || !geom || typeof requestAnimationFrame !== "function") return;
@@ -2793,7 +2841,7 @@ function ZoneChips({ zone, counts, labels, active, onPick, trailing, wrapTitle }
2793
2841
  }, [geom, expanded, measure]);
2794
2842
  useEffect(() => {
2795
2843
  const el = wrapRef.current;
2796
- if (!el || expanded || typeof ResizeObserver === "undefined") return;
2844
+ if (!el || typeof ResizeObserver === "undefined") return;
2797
2845
  let raf = 0;
2798
2846
  const ro = new ResizeObserver(() => {
2799
2847
  cancelAnimationFrame(raf);
@@ -2804,7 +2852,7 @@ function ZoneChips({ zone, counts, labels, active, onPick, trailing, wrapTitle }
2804
2852
  ro.disconnect();
2805
2853
  cancelAnimationFrame(raf);
2806
2854
  };
2807
- }, [expanded, measure]);
2855
+ }, [measure]);
2808
2856
  useEffect(() => {
2809
2857
  if (expanded || typeof document === "undefined" || !document.fonts?.ready) return;
2810
2858
  let cancelled = false;
@@ -2878,7 +2926,9 @@ function ZoneChips({ zone, counts, labels, active, onPick, trailing, wrapTitle }
2878
2926
  onClick: () => setExpanded(true)
2879
2927
  }, `+${hiddenCount}`) : null,
2880
2928
  expanded ? [
2881
- h("button", {
2929
+ // 0.9.59:仅当展开布局真实超过 maxRows 行才渲染 ⌃——否则收起是零变化空操作(死按钮)。
2930
+ // 行数含 ⌃ 自身:若它恰好独占一行,隐藏后行数回落仍 ≤ maxRows,显隐收敛不振荡。
2931
+ expRows > maxRows ? h("button", {
2882
2932
  key: "collapse",
2883
2933
  "data-chip": "1",
2884
2934
  className: "dshm-chip",
@@ -2888,7 +2938,7 @@ function ZoneChips({ zone, counts, labels, active, onPick, trailing, wrapTitle }
2888
2938
  if (active) autoRef.current = active;
2889
2939
  setExpanded(false);
2890
2940
  }
2891
- }, "\u2303"),
2941
+ }, "\u2303") : null,
2892
2942
  trailing || null
2893
2943
  ] : null
2894
2944
  ),
@@ -2953,23 +3003,36 @@ function Shot({ src, onClick }) {
2953
3003
  );
2954
3004
  }
2955
3005
  function Lightbox({ shots, index, onNav, onClose }) {
2956
- return h(
3006
+ const single = shots.length <= 1;
3007
+ const closeRef = useRef(null);
3008
+ const prevFocusRef = useRef(null);
3009
+ useEffect(() => {
3010
+ prevFocusRef.current = document.activeElement;
3011
+ return () => {
3012
+ const el = prevFocusRef.current;
3013
+ if (el && el.isConnected) el.focus();
3014
+ };
3015
+ }, []);
3016
+ useEffect(() => {
3017
+ if (closeRef.current) closeRef.current.focus();
3018
+ }, []);
3019
+ const node = h(
2957
3020
  "div",
2958
- { className: "dsvm-lightbox", ...backdropCloseHandlers2(onClose) },
2959
- h("img", { src: shots[index], alt: "", onClick: (e) => e.stopPropagation(), referrerPolicy: "no-referrer" }),
2960
- h(
3021
+ { 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" }),
3023
+ h("button", { ref: closeRef, className: "dsvm-btn dsvm-lbclose", "aria-label": lookup("common.close"), title: lookup("common.close"), onClick: onClose }, h(XIcon)),
3024
+ 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
+ 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"),
3026
+ single ? null : h(
2961
3027
  "div",
2962
- { className: "dsvm-lbnav", onClick: (e) => e.stopPropagation() },
2963
- h("button", { className: "dsvm-btn", onClick: () => onNav((index - 1 + shots.length) % shots.length) }, "\u2039"),
3028
+ { className: "dsvm-lbbar" },
2964
3029
  h("span", { className: "dsvm-lbcount" }, `${index + 1} / ${shots.length}`),
2965
- h("button", { className: "dsvm-btn", onClick: () => onNav((index + 1) % shots.length) }, "\u203A")
2966
- ),
2967
- h(
2968
- "div",
2969
- { className: "dsvm-lbdots", onClick: (e) => e.stopPropagation() },
2970
- ...shots.map((s, i) => h("span", { key: s, className: `dsvm-lbdot${i === index ? " on" : ""}`, onClick: () => onNav(i) }))
3030
+ // 评审 R4:key 用序号而非 URL——safeScreenshots 不去重,同一 URL 两次出现即 key 撞车
3031
+ h("div", { className: "dsvm-lbdots" }, ...shots.map((s, i) => h("span", { key: i, className: `dsvm-lbdot${i === index ? " on" : ""}`, onClick: () => onNav(i) }))),
3032
+ h("span", { className: "dsvm-lbhint" }, lookup("lb.hint"))
2971
3033
  )
2972
3034
  );
3035
+ return typeof document !== "undefined" && document.body && typeof rd.createPortal === "function" ? rd.createPortal(node, document.body) : node;
2973
3036
  }
2974
3037
  var dsvmModalDepth = 0;
2975
3038
  function useModalDepth(active) {
@@ -2998,8 +3061,8 @@ function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeB
2998
3061
  else onClose();
2999
3062
  }
3000
3063
  if (lb !== null && shots.length > 1) {
3001
- if (e.key === "ArrowLeft") setLb((i) => (i - 1 + shots.length) % shots.length);
3002
- if (e.key === "ArrowRight") setLb((i) => (i + 1) % shots.length);
3064
+ if (e.key === "ArrowLeft") setLb((i) => lbStep2(i, -1, shots.length));
3065
+ if (e.key === "ArrowRight") setLb((i) => lbStep2(i, 1, shots.length));
3003
3066
  }
3004
3067
  };
3005
3068
  document.addEventListener("keydown", onKey);
@@ -3076,7 +3139,7 @@ function DetailModal({ it, labels, busy, onClose, onInstall, onUpgrade, upgradeB
3076
3139
  shots.length ? h(
3077
3140
  "div",
3078
3141
  { className: "dsvm-shotrow" },
3079
- ...shots.map((src, i) => h(Shot, { key: src, src, onClick: () => setLb(i) }))
3142
+ ...shots.map((src, i) => h(Shot, { key: `${i}:${src}`, src, onClick: () => setLb(i) }))
3080
3143
  ) : null,
3081
3144
  it.community === true ? h(
3082
3145
  "details",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.58",
3
+ "version": "0.9.60",
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",