dsh-wisp 1.30.0 → 1.31.0

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.
@@ -51,7 +51,7 @@ window.__ModuleLoader__.load({
51
51
  var exports = module.exports
52
52
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
53
53
 
54
- const VERSION = '1.30.0'
54
+ const VERSION = '1.31.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -71,6 +71,7 @@ const WHATS_NEW = {
71
71
  '1.28.0': '菜单分成折叠分组,另外多了「关于她」和「动作一览」两个弹窗。',
72
72
  '1.29.0': '夜深了她会劝你去睡;你走开再回来她记得;连续来了几天她也记着。',
73
73
  '1.30.0': '会陪你专注计时了;页面藏起来时她会安静下来,不再空转。',
74
+ '1.31.0': '菜单重做:状态一目了然,开关是开关,大小能直接拖。',
74
75
  }
75
76
 
76
77
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -638,10 +639,33 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
638
639
  .wisp-root[data-mood="attn"] .wisp-body{animation-name:wisp-nudge;animation-duration:1.5s;animation-iteration-count:4}
639
640
  @keyframes wisp-nudge{0%,100%{transform:translate3d(0,0,0)}25%{transform:translate3d(-7px,0,0)}75%{transform:translate3d(7px,0,0)}}
640
641
  @keyframes wisp-hop{0%,100%{transform:translate3d(0,0,0)}45%{transform:translate3d(0,-16px,0)}}
641
- .wisp-menu{position:absolute;min-width:150px;padding:5px;border-radius:11px;font-size:12px;line-height:1.2;color:var(--dsw-alias-label-primary,var(--wisp-say-fg));background:var(--dsw-alias-bg-overlay,var(--wisp-say-bg));border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));box-shadow:0 10px 30px rgba(0,0,0,.28);-webkit-backdrop-filter:blur(14px) saturate(160%);backdrop-filter:blur(14px) saturate(160%);pointer-events:auto;animation:wisp-menu .14s ease}
642
+ .wisp-menu{position:absolute;min-width:198px;max-width:240px;padding:6px;border-radius:12px;font-size:12px;line-height:1.25;color:var(--dsw-alias-label-primary,var(--wisp-say-fg));background:var(--dsw-alias-bg-overlay,var(--wisp-say-bg));border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));box-shadow:0 12px 34px rgba(0,0,0,.3);-webkit-backdrop-filter:blur(14px) saturate(160%);backdrop-filter:blur(14px) saturate(160%);pointer-events:auto;animation:wisp-menu .14s ease}
642
643
  .wisp-menu-item{display:block;width:100%;padding:7px 10px;border:0;border-radius:7px;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;white-space:nowrap}
643
644
  .wisp-menu-item:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.22))}
644
- .wisp-menu-head{padding:7px 10px 3px;font-size:11px;opacity:.62;letter-spacing:.04em}
645
+ .wisp-menu-head{padding:5px 9px 7px}
646
+ .wisp-menu-title{font-size:12.5px;font-weight:600}
647
+ .wisp-menu-sub{font-size:10.5px;opacity:.6;margin-top:1px}
648
+ .wisp-menu-sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l1,var(--wisp-say-line));opacity:.45}
649
+ .wisp-menu-hint{padding:5px 9px 3px;font-size:10.5px;opacity:.45}
650
+ .wisp-menu-row{display:flex;align-items:center;justify-content:space-between;gap:10px}
651
+ .wisp-menu-sum{flex:0 1 auto;min-width:0;font-size:11px;opacity:.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
652
+ .wisp-menu-item[data-accent="true"]{font-weight:600;color:var(--dsw-alias-brand-primary,inherit)}
653
+ .wisp-menu-item[data-muted="true"]{opacity:.78}
654
+ .wisp-switch{flex:0 0 auto;width:26px;height:14px;border-radius:8px;background:rgba(140,160,190,.35);position:relative;transition:background .15s ease}
655
+ .wisp-switch[data-on="true"]{background:var(--dsw-alias-brand-primary,#4a7cf7)}
656
+ .wisp-switch i{position:absolute;top:2px;left:2px;width:10px;height:10px;border-radius:50%;background:#fff;transition:transform .15s ease}
657
+ .wisp-switch[data-on="true"] i{transform:translateX(12px)}
658
+ .wisp-slider-row{padding:6px 10px 8px}
659
+ .wisp-slider-top{display:flex;justify-content:space-between;font-size:11px;opacity:.8;margin-bottom:3px}
660
+ .wisp-slider{position:relative;height:16px;cursor:pointer;outline:none;touch-action:none}
661
+ .wisp-slider-track{position:absolute;left:0;right:0;top:7px;height:3px;border-radius:2px;background:rgba(140,160,190,.35)}
662
+ .wisp-slider-fill{position:absolute;left:0;top:7px;height:3px;border-radius:2px;background:var(--dsw-alias-brand-primary,#4a7cf7)}
663
+ .wisp-slider-knob{position:absolute;top:3px;width:11px;height:11px;margin-left:-5px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.35)}
664
+ .wisp-slider:focus-visible .wisp-slider-knob{outline:2px solid var(--dsw-alias-brand-primary,#4a7cf7);outline-offset:1px}
665
+ .wisp-grid2{display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:2px 8px 4px}
666
+ .wisp-grid2 button,.wisp-corner{padding:6px 4px;border:1px solid transparent;border-radius:7px;background:none;color:inherit;font:inherit;font-size:11px;cursor:pointer}
667
+ .wisp-grid2 button:hover,.wisp-corner:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.22))}
668
+ .wisp-grid2 button[data-current="true"],.wisp-corner[data-current="true"]{border-color:var(--dsw-alias-brand-primary,#4a7cf7);font-weight:600}
645
669
  .wisp-root[data-hidden="true"]{display:none}
646
670
  .wisp-tab{position:absolute;width:56px;height:56px;padding:0;display:flex;align-items:center;justify-content:center;overflow:hidden;border-radius:16px;border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));background:var(--dsw-alias-bg-overlay,var(--wisp-say-bg));box-shadow:0 6px 18px rgba(0,0,0,.22);cursor:pointer;pointer-events:auto;animation:wisp-menu .16s ease;transition:transform .16s ease}
647
671
  .wisp-tab:hover{transform:translateY(-2px)}
@@ -664,6 +688,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
664
688
  .wisp-cell:hover{border-color:var(--dsw-alias-border-l1,var(--wisp-say-line));background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.18))}
665
689
  .wisp-cell img{width:100%;height:52px;object-fit:contain;object-position:bottom center;pointer-events:none}
666
690
  .wisp-credits{font-size:11px;opacity:.8;margin:6px 0 0}
691
+ .wisp-dialog-action{display:block;width:100%;margin:4px 0 2px;padding:7px 10px;border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));border-radius:8px;background:none;color:inherit;font:inherit;cursor:pointer}
692
+ .wisp-dialog-action:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.2))}
667
693
  @media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}}`
668
694
 
669
695
  const installStyles = (styles) => {
@@ -2124,6 +2150,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2124
2150
 
2125
2151
  const aboutDialog = () => {
2126
2152
  const note = WHATS_NEW[VERSION]
2153
+ /* 菜单里那条「版本与更新」已经收掉了(版本号现在直接显示在菜单头部),
2154
+ 所以检查更新必须有新的入口 —— 放在这里,上下文正合适。 */
2155
+ const checkBtn = document.createElement('button')
2156
+ checkBtn.className = 'wisp-dialog-action'
2157
+ checkBtn.setAttribute('type', 'button')
2158
+ checkBtn.textContent = '检查更新'
2159
+ checkBtn.addEventListener('click', (ev) => {
2160
+ ev.preventDefault()
2161
+ closeDialog()
2162
+ showUpdate()
2163
+ })
2127
2164
  return openDialog('about', '关于她', [
2128
2165
  para('DeepSeek娘 —— DSH 界面上的浮动桌宠。她会跟着你的状态换表情:你打字她在忙,你闲着她在晃,你跑完一轮她替你高兴。'),
2129
2166
  para('她的样子照社区规范来:深蓝色渐变长直发、头顶一个大圆环呆毛、两片鲸鳍耳、女仆装。'),
@@ -2131,16 +2168,39 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2131
2168
  para('她还能陪你专注计时:菜单里开一段 25 或 45 分钟,期间她保持"干活中",戳她一下是问还剩多久,到点她替你收工。'),
2132
2169
  para('这一版:' + (note || VERSION)),
2133
2170
  para('形象与设定参考社区项目 Neko3000/deepseek-whalechan(设定文档 CC BY-NC-SA 4.0,原作者 B站 ZipZipPipe、上善无形)。'),
2171
+ checkBtn,
2134
2172
  dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
2135
2173
  ])
2136
2174
  }
2137
2175
 
2138
- /* 顶层只放"做什么",外观/行为/位置收进可折叠的分组 —— 19 个同级条目等于没有结构。 */
2176
+ /* 菜单的结构 ------------------------------------------------------------
2177
+ 三段:**动作在上**(专注是最常用的,置顶)、**设置在中间**(三个可折叠分组 +
2178
+ 大小滑块)、**信息在下**(动作一览 / 关于她)。每段之间有空行。
2179
+ 分组的标题行直接显示当前状态(皮肤名 / 几个开关开着 / 哪个角落),
2180
+ 所以收起时也知道里面是什么 —— 这是它和"一个写着外观的按钮"的区别。 */
2181
+ const onToggleCount = () => TOGGLES.filter((t) => cfg[t.key] === true).length
2182
+ const currentCorner = () => {
2183
+ const bw = boxW()
2184
+ const bh = boxH()
2185
+ const hit = CORNERS.find((c) => {
2186
+ const want = c.at(vw(), vh(), bw, bh, cfg)
2187
+ return Math.abs(want.x - coords.x) < 2 && Math.abs(want.y - coords.y) < 2
2188
+ })
2189
+ return hit ? hit.label : '自由'
2190
+ }
2139
2191
  const menuItems = () => [
2140
- { label: '关于她…', run: () => aboutDialog() },
2141
- { label: '动作一览…', run: () => actionsDialog() },
2192
+ { kind: 'head' },
2193
+ { kind: 'sep' },
2194
+ /* 专注:最常用的动作,放最上面;进行中时这一行本身就是状态 + 结束 */
2195
+ ...(focusUntil > 0
2196
+ ? [{ label: '专注中 · 还剩 ' + Math.max(1, Math.ceil(focusLeftMs() / 60000)) + ' 分钟', run: () => stopFocus(false), accent: true }]
2197
+ : [
2198
+ { label: '专注 ' + cfg.focusMinutes + ' 分钟', run: () => startFocus(cfg.focusMinutes), accent: true },
2199
+ { label: '专注 45 分钟', run: () => startFocus(45) },
2200
+ ]),
2201
+ { kind: 'sep' },
2142
2202
  {
2143
- group: '外观',
2203
+ group: '外观', summary: skinLabel(activeSkin),
2144
2204
  children: [
2145
2205
  ...SKIN_IDS.map((id) => ({
2146
2206
  label: (id === activeSkin ? '✓ ' : ' ') + skinLabel(id),
@@ -2157,26 +2217,27 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2157
2217
  },
2158
2218
  {
2159
2219
  group: '行为',
2220
+ summary: onToggleCount() + '/' + TOGGLES.length + ' 开',
2160
2221
  children: [
2161
2222
  { label: '说句话', run: () => { wake(true); say(pick(LINES.click)) } },
2162
2223
  { label: '睡一会儿', run: () => setMood('sleep') },
2163
- { label: '专注 ' + cfg.focusMinutes + ' 分钟', run: () => startFocus(cfg.focusMinutes) },
2164
- { label: '专注 45 分钟', run: () => startFocus(45) },
2165
- /* 只有专注中才出现"结束专注" —— 平时的菜单不该多一个按不动的条目 */
2166
- ...(focusUntil > 0 ? [{ label: '结束专注', run: () => stopFocus(false) }] : []),
2167
- ...TOGGLES.map((t) => ({
2168
- label: (cfg[t.key] === true ? '✓ ' : ' ') + t.label,
2169
- run: () => applyConfig({ [t.key]: cfg[t.key] !== true }),
2170
- })),
2224
+ /* 开关是**真正的开关行**:带 aria-checked,点完菜单不关(就在原地更新) */
2225
+ ...TOGGLES.map((t) => ({ kind: 'switch', key: t.key, label: t.label })),
2171
2226
  ],
2172
2227
  },
2173
2228
  {
2174
2229
  group: '位置',
2175
- children: CORNERS.map((c) => ({ label: c.label, run: () => goCorner(c.label) })),
2230
+ summary: currentCorner(),
2231
+ children: [{ kind: 'corners' }],
2176
2232
  },
2177
- { label: '版本与更新', run: () => showUpdate() },
2233
+ { kind: 'slider' },
2234
+ { kind: 'sep' },
2235
+ { label: '动作一览…', run: () => actionsDialog() },
2236
+ { label: '关于她…', run: () => aboutDialog() },
2237
+ { kind: 'sep' },
2178
2238
  /* 一个开关,不是一个单向动作:从迷你按钮右键打开菜单时,它必须能把她叫回来。 */
2179
- { label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden) },
2239
+ { label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden), muted: true },
2240
+ { kind: 'hint' },
2180
2241
  ]
2181
2242
 
2182
2243
  /* ---- 菜单的键盘驱动 ----
@@ -2224,13 +2285,16 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2224
2285
  menu.className = 'wisp-menu'
2225
2286
  menu.setAttribute('role', 'menu')
2226
2287
  menu.style.position = 'absolute'
2227
- let heads = 0
2228
- const addItem = (spec, sub) => {
2288
+ let extras = 0
2289
+ /* 动作行:点了就执行、然后关菜单。 */
2290
+ const actionRow = (spec, into) => {
2229
2291
  const btn = document.createElement('button')
2230
2292
  btn.className = 'wisp-menu-item'
2231
2293
  btn.setAttribute('type', 'button')
2232
2294
  btn.setAttribute('role', 'menuitem')
2233
2295
  btn.setAttribute('tabindex', '-1')
2296
+ if (spec.accent === true) btn.dataset.accent = 'true'
2297
+ if (spec.muted === true) btn.dataset.muted = 'true'
2234
2298
  btn.textContent = spec.label
2235
2299
  // 菜单自己吞掉 pointerdown,否则会被下面那个"点外面就关"的监听判成外部点击
2236
2300
  btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
@@ -2239,26 +2303,197 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2239
2303
  closeMenu()
2240
2304
  try { spec.run() } catch (err) { /* 单个动作失败不该带塌整个菜单 */ }
2241
2305
  })
2242
- menu.appendChild(btn)
2306
+ into.appendChild(btn)
2307
+ menuButtons.push(btn)
2308
+ return btn
2309
+ }
2310
+
2311
+ /* 开关行:真开关的样子 + role=menuitemcheckbox + aria-checked。
2312
+ 点完**不关菜单** —— 拨开关时菜单消失是最烦人的交互之一。 */
2313
+ const switchRow = (spec, into) => {
2314
+ const on = cfg[spec.key] === true
2315
+ const btn = document.createElement('button')
2316
+ btn.className = 'wisp-menu-item wisp-menu-row'
2317
+ btn.setAttribute('type', 'button')
2318
+ btn.setAttribute('role', 'menuitemcheckbox')
2319
+ btn.setAttribute('aria-checked', on ? 'true' : 'false')
2320
+ btn.setAttribute('tabindex', '-1')
2321
+ btn.dataset.switchKey = spec.key
2322
+ const label = document.createElement('span')
2323
+ label.textContent = spec.label
2324
+ const toggle = document.createElement('span')
2325
+ toggle.className = 'wisp-switch'
2326
+ toggle.dataset.on = on ? 'true' : 'false'
2327
+ toggle.appendChild(document.createElement('i'))
2328
+ btn.appendChild(label)
2329
+ btn.appendChild(toggle)
2330
+ btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2331
+ btn.addEventListener('click', (ev) => {
2332
+ ev.preventDefault(); ev.stopPropagation()
2333
+ try { applyConfig({ [spec.key]: cfg[spec.key] !== true }) } catch (err) { /* ignore */ }
2334
+ /* 原地重画:开着的那一组保持开着,菜单不跳走 */
2335
+ openMenu(menuAtX, menuAtY, true)
2336
+ })
2337
+ into.appendChild(btn)
2243
2338
  menuButtons.push(btn)
2244
2339
  return btn
2245
2340
  }
2341
+
2342
+ /* 大小滑块:连续量就该直接拖,而不是一次次点"放大一点"。
2343
+ 键盘也能调(←/→ 各 0.25 档)—— 所以它自己是可聚焦的。
2344
+ 替身里没有布局,getBoundingClientRect 全是 0,所以宽度为 0 时用固定跨度兜底;
2345
+ 测试一律走方向键那条路,不依赖像素。 */
2346
+ const sliderRow = (into) => {
2347
+ const SPEC = CONFIG_SPEC.size
2348
+ const row = document.createElement('div')
2349
+ row.className = 'wisp-slider-row'
2350
+ const top = document.createElement('div')
2351
+ top.className = 'wisp-slider-top'
2352
+ const name = document.createElement('span')
2353
+ name.textContent = '大小'
2354
+ const value = document.createElement('span')
2355
+ value.className = 'wisp-slider-value'
2356
+ value.dataset.sliderValue = '1'
2357
+ value.textContent = Math.round(cfg.size * 100) + '%'
2358
+ top.appendChild(name)
2359
+ top.appendChild(value)
2360
+ row.appendChild(top)
2361
+ const track = document.createElement('div')
2362
+ track.className = 'wisp-slider'
2363
+ track.setAttribute('role', 'slider')
2364
+ track.setAttribute('tabindex', '-1')
2365
+ track.setAttribute('aria-label', '大小')
2366
+ track.dataset.slider = '1'
2367
+ track.setAttribute('aria-valuemin', String(SPEC.lo))
2368
+ track.setAttribute('aria-valuemax', String(SPEC.hi))
2369
+ track.setAttribute('aria-valuenow', String(cfg.size))
2370
+ const bg = document.createElement('div')
2371
+ bg.className = 'wisp-slider-track'
2372
+ const fill = document.createElement('div')
2373
+ fill.className = 'wisp-slider-fill'
2374
+ const knob = document.createElement('div')
2375
+ knob.className = 'wisp-slider-knob'
2376
+ track.appendChild(bg)
2377
+ track.appendChild(fill)
2378
+ track.appendChild(knob)
2379
+ const paintSlider = () => {
2380
+ const ratio = clamp((cfg.size - SPEC.lo) / (SPEC.hi - SPEC.lo), 0, 1)
2381
+ fill.style.width = Math.round(ratio * 100) + '%'
2382
+ knob.style.left = Math.round(ratio * 100) + '%'
2383
+ value.textContent = Math.round(cfg.size * 100) + '%'
2384
+ track.setAttribute('aria-valuenow', String(cfg.size))
2385
+ }
2386
+ paintSlider()
2387
+ const setFromClientX = (clientX) => {
2388
+ let rect = { left: 0, width: 0 }
2389
+ try { rect = track.getBoundingClientRect() } catch (e) { rect = { left: 0, width: 0 } }
2390
+ const span = rect.width > 0 ? rect.width : 120
2391
+ const ratio = clamp((clientX - rect.left) / span, 0, 1)
2392
+ const next = Math.round((SPEC.lo + ratio * (SPEC.hi - SPEC.lo)) * 4) / 4
2393
+ applyConfig({ size: next })
2394
+ paintSlider()
2395
+ }
2396
+ track.addEventListener('pointerdown', (ev) => {
2397
+ ev.preventDefault(); ev.stopPropagation()
2398
+ setFromClientX(ev.clientX)
2399
+ const move = (e) => setFromClientX(e.clientX)
2400
+ const up = () => {
2401
+ window.removeEventListener('pointermove', move)
2402
+ window.removeEventListener('pointerup', up)
2403
+ }
2404
+ window.addEventListener('pointermove', move)
2405
+ window.addEventListener('pointerup', up)
2406
+ })
2407
+ row.appendChild(track)
2408
+ into.appendChild(row)
2409
+ menuButtons.push(track)
2410
+ extras++
2411
+ return track
2412
+ }
2413
+
2414
+ /* 四个角落用 2×2 的方位控件,而不是四个文字条目 —— 空间的事用空间的形式表达。 */
2415
+ const cornerGrid = (into) => {
2416
+ const grid = document.createElement('div')
2417
+ grid.className = 'wisp-grid2'
2418
+ for (const corner of CORNERS) {
2419
+ const b = document.createElement('button')
2420
+ b.className = 'wisp-corner'
2421
+ b.setAttribute('type', 'button')
2422
+ b.setAttribute('tabindex', '-1')
2423
+ b.dataset.corner = corner.label
2424
+ b.textContent = corner.label
2425
+ if (corner.label === currentCorner()) b.dataset.current = 'true'
2426
+ b.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2427
+ b.addEventListener('click', (ev) => {
2428
+ ev.preventDefault(); ev.stopPropagation()
2429
+ closeMenu()
2430
+ try { goCorner(corner.label) } catch (err) { /* ignore */ }
2431
+ })
2432
+ grid.appendChild(b)
2433
+ menuButtons.push(b)
2434
+ }
2435
+ into.appendChild(grid)
2436
+ extras++
2437
+ }
2438
+
2439
+ const renderChildren = (children, into) => {
2440
+ for (const child of children) {
2441
+ if (child.kind === 'switch') { switchRow(child, into); continue }
2442
+ if (child.kind === 'corners') { cornerGrid(into); continue }
2443
+ actionRow(child, into)
2444
+ }
2445
+ }
2446
+
2246
2447
  for (let i = 0; i < items.length; i++) {
2247
2448
  const spec = items[i]
2449
+ if (spec.kind === 'head') {
2450
+ /* 头部:她是谁、哪一版、现在什么状态。不可聚焦 —— 它不是动作。 */
2451
+ const head = document.createElement('div')
2452
+ head.className = 'wisp-menu-head'
2453
+ const title = document.createElement('div')
2454
+ title.className = 'wisp-menu-title'
2455
+ title.textContent = 'DeepSeek娘'
2456
+ const subline = document.createElement('div')
2457
+ subline.className = 'wisp-menu-sub'
2458
+ const bits = ['v' + VERSION, skinLabel(activeSkin)]
2459
+ if (focusUntil > 0) bits.push('专注中')
2460
+ subline.textContent = bits.join(' · ')
2461
+ head.appendChild(title)
2462
+ head.appendChild(subline)
2463
+ menu.appendChild(head)
2464
+ extras++
2465
+ continue
2466
+ }
2467
+ if (spec.kind === 'sep') {
2468
+ const sep = document.createElement('div')
2469
+ sep.className = 'wisp-menu-sep'
2470
+ menu.appendChild(sep)
2471
+ extras++
2472
+ continue
2473
+ }
2474
+ if (spec.kind === 'hint') {
2475
+ const hint = document.createElement('div')
2476
+ hint.className = 'wisp-menu-hint'
2477
+ hint.textContent = '↑↓ 选择 · ←→ 调大小 · Esc 关闭'
2478
+ menu.appendChild(hint)
2479
+ extras++
2480
+ continue
2481
+ }
2482
+ if (spec.kind === 'slider') { sliderRow(menu); continue }
2248
2483
  if (spec.header !== undefined) {
2249
2484
  // 小标题是分组标签,不是菜单项 —— 不能被聚焦,否则键盘用户会停在一个按不动的东西上
2250
2485
  const head = document.createElement('div')
2251
2486
  head.className = 'wisp-menu-head'
2252
2487
  head.textContent = spec.header
2253
2488
  menu.appendChild(head)
2254
- heads++
2489
+ extras++
2255
2490
  continue
2256
2491
  }
2257
2492
  if (spec.group !== undefined) {
2258
- /* 折叠分组:一行标题,点一下在原地展开/收起(重开同一个坐标,不是新开一个菜单)。 */
2493
+ /* 折叠分组:标题行带上当前状态摘要,点一下在原地展开/收起。 */
2259
2494
  const open = activeGroup === spec.group
2260
2495
  const btn = document.createElement('button')
2261
- btn.className = 'wisp-menu-item'
2496
+ btn.className = 'wisp-menu-item wisp-menu-row'
2262
2497
  btn.setAttribute('type', 'button')
2263
2498
  btn.setAttribute('role', 'menuitem')
2264
2499
  btn.setAttribute('tabindex', '-1')
@@ -2268,6 +2503,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2268
2503
  /* 文字直接写在按钮上,不拆成子 span:真 DOM 会汇总子节点的 textContent,
2269
2504
  但简化 DOM(预检的替身)不会 —— 依赖那种汇总会让分组名在替身里变成空串。 */
2270
2505
  btn.textContent = (open ? '▾ ' : '▸ ') + spec.group
2506
+ if (spec.summary !== undefined) {
2507
+ const sum = document.createElement('span')
2508
+ sum.className = 'wisp-menu-sum'
2509
+ sum.dataset.summary = '1'
2510
+ sum.textContent = spec.summary
2511
+ btn.appendChild(sum)
2512
+ }
2271
2513
  btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2272
2514
  btn.addEventListener('click', (ev) => {
2273
2515
  ev.preventDefault(); ev.stopPropagation()
@@ -2279,33 +2521,19 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2279
2521
  if (!open) continue
2280
2522
  const wrap = document.createElement('div')
2281
2523
  wrap.className = 'wisp-menu-sub'
2282
- for (const child of spec.children) {
2283
- const cb = document.createElement('button')
2284
- cb.className = 'wisp-menu-item'
2285
- cb.setAttribute('type', 'button')
2286
- cb.setAttribute('role', 'menuitem')
2287
- cb.setAttribute('tabindex', '-1')
2288
- cb.textContent = child.label
2289
- cb.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2290
- cb.addEventListener('click', (ev) => {
2291
- ev.preventDefault(); ev.stopPropagation()
2292
- closeMenu()
2293
- try { child.run() } catch (err) { /* 同上 */ }
2294
- })
2295
- wrap.appendChild(cb)
2296
- menuButtons.push(cb)
2297
- }
2524
+ renderChildren(spec.children, wrap)
2298
2525
  menu.appendChild(wrap)
2299
2526
  continue
2300
2527
  }
2301
- addItem(spec, false)
2528
+ actionRow(spec, menu)
2302
2529
  }
2530
+
2303
2531
  // 先挂上去再定位:定位要用最终尺寸,而尺寸只有渲染后才知道
2304
2532
  layer.appendChild(menu)
2305
2533
  menuEl = menu
2306
2534
  const w = MENU_W
2307
2535
  // 高度只用于夹取落点,所以按**实际渲染出来的行数**估算即可(假 DOM 里没有布局可测)
2308
- const hs = MENU_PAD + heads * 22 + menuButtons.length * MENU_ITEM_H
2536
+ const hs = MENU_PAD + extras * 24 + menuButtons.length * MENU_ITEM_H
2309
2537
  menu.style.left = Math.round(clamp(x, 8, Math.max(8, vw() - w - 8))) + 'px'
2310
2538
  menu.style.top = Math.round(clamp(y, 8, Math.max(8, vh() - hs - 8))) + 'px'
2311
2539
 
@@ -2317,6 +2545,28 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2317
2545
  return
2318
2546
  }
2319
2547
  if (menuButtons.length === 0) return
2548
+ /* 焦点在滑块上:←/→ 调大小,不移动焦点(这是滑块的常规键盘交互)。 */
2549
+ const focused = menuFocusAt >= 0 ? menuButtons[menuFocusAt] : null
2550
+ if (focused && focused.dataset && focused.dataset.slider === '1'
2551
+ && (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight')) {
2552
+ if (typeof ev.preventDefault === 'function') ev.preventDefault()
2553
+ const step = ev.shiftKey ? 1 : 0.25
2554
+ applyConfig({ size: cfg.size + (ev.key === 'ArrowRight' ? step : -step) })
2555
+ /* 只更新滑块自身,不重画整张菜单 —— 焦点和展开状态都不动 */
2556
+ try {
2557
+ const SPEC = CONFIG_SPEC.size
2558
+ const ratio = clamp((cfg.size - SPEC.lo) / (SPEC.hi - SPEC.lo), 0, 1)
2559
+ const knob = focused.querySelector('.wisp-slider-knob')
2560
+ const fill = focused.querySelector('.wisp-slider-fill')
2561
+ if (knob) knob.style.left = Math.round(ratio * 100) + '%'
2562
+ if (fill) fill.style.width = Math.round(ratio * 100) + '%'
2563
+ focused.setAttribute('aria-valuenow', String(cfg.size))
2564
+ const row = focused.parentNode
2565
+ const label = row && row.querySelector ? row.querySelector('[data-slider-value]') : null
2566
+ if (label) label.textContent = Math.round(cfg.size * 100) + '%'
2567
+ } catch (err) { /* 画不出来也不该挡住改大小 */ }
2568
+ return
2569
+ }
2320
2570
  let at = menuFocusAt
2321
2571
  if (ev.key === 'ArrowDown') at = at < 0 ? 0 : at + 1
2322
2572
  else if (ev.key === 'ArrowUp') at = at < 0 ? menuButtons.length - 1 : at - 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wisp",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "description": "DeepSeek娘 — an unofficial floating desktop companion for the DeepSeek Harness UI. Zero dependencies; sprites inlined as data URIs; schedules on the Client timer service instead of the trapped browser timer globals.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/tools/smoke.mjs CHANGED
@@ -258,7 +258,7 @@ const skinCount = await page.evaluate(() => {
258
258
  const s = window.__wisp.skins
259
259
  return Array.isArray(s) ? s.length : Object.keys(s ?? {}).length
260
260
  })
261
- check(menu !== null && menu.items.length === 7, 'the menu lists every entry',
261
+ check(menu !== null && menu.items.length === 8, 'the menu lists every entry',
262
262
  `${menu?.items.length} items at the top level`)
263
263
 
264
264
  // 键盘:打开即聚焦第一项,方向键移动,Esc 关闭并把焦点还给她