dsh-wisp 1.31.0 → 1.32.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.
package/README.md CHANGED
@@ -448,6 +448,34 @@ node tools/audit-log.mjs --confirm <manifest> # 生成后:并入代理登
448
448
 
449
449
  ## 变更
450
450
 
451
+ ### 1.32.0
452
+
453
+ **分组改成悬停展开、移开收起**(点一下仍然是"钉住")。
454
+
455
+ 这一条本来就是标准交互,上一版只做了"点开/点关",属于设计盲区。补上之后,
456
+ 真正要小心的地方不在于"加个 mouseenter",而在于下面这几条 —— 少一条都比不做更糟:
457
+
458
+ | 细节 | 为什么必须 |
459
+ |---|---|
460
+ | **展开有延迟**(140 ms) | 鼠标只是**路过**那一行就展开 = 抖动。这就是 hover intent。 |
461
+ | **移开有宽限**(260 ms) | 斜着往子项移动时,中途不能收。 |
462
+ | **子项与标题在同一个悬停区**(`div.wisp-menu-group`) | 从标题移到子项**不该算移开**。这是这类交互最常见的翻车点。 |
463
+ | **点击 = 钉住** | 悬停是预览、点击是决定。点开的那一组鼠标移开也不关,再点一次才收起。 |
464
+ | **触屏不触发悬停** | 判 `pointerType`。不判的话手指一碰就会莫名其妙展开。 |
465
+ | **焦点在那一组里时不关** | 否则键盘用户正操作着,鼠标一动组就没了。 |
466
+ | **手风琴** | 悬停到另一组会解除上一组的钉住 —— 同时开两组看不清。 |
467
+
468
+ **顺手抓到一个真 bug**:焦点保护第一版写的是"焦点还在菜单里就不许关" ——
469
+ 而菜单**一打开就会把焦点放到第一项**,所以那条件**永远为真**,
470
+ 悬停收起等于**永远不会触发**。改成只看"焦点在不在**这一组**里"。
471
+ 第一版还用了一个"组名 → 元素"的映射,实测和重画后的 DOM 对不上(组照样关);
472
+ 现在改成从焦点**向上走**、遇到带 `data-group-block` 的祖先就算命中 —— 不依赖任何外部映射。
473
+
474
+ 预检 502 → **524 项**:延迟、宽限、路过不展开、子项在同一悬停区、
475
+ 触屏不悬停但点击可用、点击钉住、再点收起、手风琴、焦点保护、焦点离开后规则恢复。
476
+
477
+ ### 1.31.0
478
+
451
479
  ### 1.31.0
452
480
 
453
481
  **菜单重做。** 上一版只是"把 19 个条目收进三个分组" —— 那是分类,不是交互设计。
package/lib/client.js CHANGED
@@ -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.31.0'
54
+ const VERSION = '1.32.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -72,6 +72,7 @@ const WHATS_NEW = {
72
72
  '1.29.0': '夜深了她会劝你去睡;你走开再回来她记得;连续来了几天她也记着。',
73
73
  '1.30.0': '会陪你专注计时了;页面藏起来时她会安静下来,不再空转。',
74
74
  '1.31.0': '菜单重做:状态一目了然,开关是开关,大小能直接拖。',
75
+ '1.32.0': '分组改成鼠标悬停就展开、移开就收起(点一下则是钉住)。',
75
76
  }
76
77
 
77
78
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -2013,12 +2014,67 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2013
2014
  /* 折叠分组要"在原地重开",所以记住这次的落点 */
2014
2015
  let menuAtX = 0
2015
2016
  let menuAtY = 0
2017
+ /* 悬停展开的延迟:鼠标路过一行就展开会抖(hover intent),
2018
+ 关闭的宽限更长一点 —— 斜着往子项移动时才不会中途收起。 */
2019
+ const HOVER_OPEN_MS = 140
2020
+ const HOVER_CLOSE_MS = 260
2021
+ /* 点开的组会被"钉住":鼠标移开也不关。悬停是预览,点击是决定。 */
2022
+ let pinnedGroup = ''
2023
+ let groupOpenTimer = null
2024
+ let groupCloseTimer = null
2025
+ /* 判断"焦点是否在这一组里"不能笼统地看"焦点是否在菜单里" —— 菜单一打开焦点就在
2026
+ 第一项上,那样写等于悬停收起永远不会触发。 */
2027
+
2028
+ const clearGroupTimers = () => {
2029
+ if (groupOpenTimer) { clock.cancel(groupOpenTimer); groupOpenTimer = null }
2030
+ if (groupCloseTimer) { clock.cancel(groupCloseTimer); groupCloseTimer = null }
2031
+ }
2032
+ /* 焦点是否在这**一组**里 —— 键盘用户正操作这一组时,鼠标移开不该把它收掉。
2033
+ 焦点在别的行、或压根不在菜单里,都不该阻止收起。
2034
+ 判法:从焦点向上走,遇到带 data-group-block 的祖先就算命中(不靠外部映射)。 */
2035
+ const focusInsideGroup = (name) => {
2036
+ let el = null
2037
+ try { el = document.activeElement } catch (e) { el = null }
2038
+ let hops = 0
2039
+ while (el && hops < 24) {
2040
+ if (el.dataset && el.dataset.groupBlock === name) return true
2041
+ el = el.parentNode || null
2042
+ hops++
2043
+ }
2044
+ return false
2045
+ }
2046
+ /* 悬停到一组:延迟后展开它,并解除别组的钉住(手风琴 —— 同时开两组会看不清)。 */
2047
+ const scheduleGroupOpen = (name) => {
2048
+ clearGroupTimers()
2049
+ if (activeGroup === name) return
2050
+ groupOpenTimer = clock.after(HOVER_OPEN_MS, () => {
2051
+ groupOpenTimer = null
2052
+ if (destroyed || menuEl === null) return
2053
+ pinnedGroup = ''
2054
+ activeGroup = name
2055
+ openMenu(menuAtX, menuAtY, true)
2056
+ })
2057
+ }
2058
+ const scheduleGroupClose = (name) => {
2059
+ clearGroupTimers()
2060
+ if (activeGroup !== name) return
2061
+ if (pinnedGroup === name) return // 点开的不随鼠标移开关闭
2062
+ groupCloseTimer = clock.after(HOVER_CLOSE_MS, () => {
2063
+ groupCloseTimer = null
2064
+ if (destroyed || menuEl === null) return
2065
+ if (pinnedGroup === name) return
2066
+ if (focusInsideGroup(name)) return // 键盘用户还在这一组里,别关
2067
+ activeGroup = ''
2068
+ openMenu(menuAtX, menuAtY, true)
2069
+ })
2070
+ }
2016
2071
 
2017
2072
  const closeMenu = () => {
2018
2073
  if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl)
2019
2074
  menuEl = null
2020
2075
  menuButtons = []
2021
2076
  menuFocusAt = -1
2077
+ clearGroupTimers()
2022
2078
  if (menuOff) { menuOff(); menuOff = null }
2023
2079
  }
2024
2080
 
@@ -2327,7 +2383,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2327
2383
  const openMenu = (x, y, keepGroup) => {
2328
2384
  menuAtX = x
2329
2385
  menuAtY = y
2330
- if (keepGroup !== true) activeGroup = ''
2386
+ if (keepGroup !== true) { activeGroup = ''; pinnedGroup = '' }
2331
2387
  closeMenu()
2332
2388
  stopGlide()
2333
2389
  const items = menuItems()
@@ -2541,8 +2597,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2541
2597
  continue
2542
2598
  }
2543
2599
  if (spec.group !== undefined) {
2544
- /* 折叠分组:标题行带上当前状态摘要,点一下在原地展开/收起。 */
2600
+ /* 折叠分组:标题行 + (展开时的)子项,**包在同一个悬停区里**。
2601
+ 这是悬停展开能做对的前提 —— 从标题移到子项不该算"移开",
2602
+ 否则鼠标一动子菜单就没了(那是这类交互最常见的翻车点)。 */
2545
2603
  const open = activeGroup === spec.group
2604
+ const block = document.createElement('div')
2605
+ block.className = 'wisp-menu-group'
2606
+ block.dataset.groupBlock = spec.group
2546
2607
  const btn = document.createElement('button')
2547
2608
  btn.className = 'wisp-menu-item wisp-menu-row'
2548
2609
  btn.setAttribute('type', 'button')
@@ -2564,16 +2625,37 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2564
2625
  btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2565
2626
  btn.addEventListener('click', (ev) => {
2566
2627
  ev.preventDefault(); ev.stopPropagation()
2567
- activeGroup = open ? '' : spec.group
2628
+ if (activeGroup === spec.group && pinnedGroup === spec.group) {
2629
+ /* 已经点开过:再点一次收起 */
2630
+ pinnedGroup = ''
2631
+ activeGroup = ''
2632
+ } else {
2633
+ /* 点开 = 钉住:之后鼠标移开也不关(键盘、触屏、以及"我就想它开着"都靠这条) */
2634
+ pinnedGroup = spec.group
2635
+ activeGroup = spec.group
2636
+ }
2568
2637
  openMenu(menuAtX, menuAtY, true)
2569
2638
  })
2570
- menu.appendChild(btn)
2639
+ /* 悬停展开 / 移开收起。pointerType 不为 mouse 的一律不管 ——
2640
+ 触屏没有悬停,不判这个的话手指一碰就会莫名其妙展开。 */
2641
+ const isMouse = (ev) => !(ev && ev.pointerType && ev.pointerType !== 'mouse')
2642
+ block.addEventListener('pointerenter', (ev) => {
2643
+ if (!isMouse(ev)) return
2644
+ scheduleGroupOpen(spec.group)
2645
+ })
2646
+ block.addEventListener('pointerleave', (ev) => {
2647
+ if (!isMouse(ev)) return
2648
+ scheduleGroupClose(spec.group)
2649
+ })
2650
+ block.appendChild(btn)
2651
+ menu.appendChild(block)
2571
2652
  menuButtons.push(btn)
2572
2653
  if (!open) continue
2573
2654
  const wrap = document.createElement('div')
2574
2655
  wrap.className = 'wisp-menu-sub'
2575
2656
  renderChildren(spec.children, wrap)
2576
- menu.appendChild(wrap)
2657
+ /* 子项进 block(悬停区),不是直接进 menu */
2658
+ block.appendChild(wrap)
2577
2659
  continue
2578
2660
  }
2579
2661
  actionRow(spec, menu)
@@ -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.31.0'
54
+ const VERSION = '1.32.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -72,6 +72,7 @@ const WHATS_NEW = {
72
72
  '1.29.0': '夜深了她会劝你去睡;你走开再回来她记得;连续来了几天她也记着。',
73
73
  '1.30.0': '会陪你专注计时了;页面藏起来时她会安静下来,不再空转。',
74
74
  '1.31.0': '菜单重做:状态一目了然,开关是开关,大小能直接拖。',
75
+ '1.32.0': '分组改成鼠标悬停就展开、移开就收起(点一下则是钉住)。',
75
76
  }
76
77
 
77
78
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -1962,12 +1963,67 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1962
1963
  /* 折叠分组要"在原地重开",所以记住这次的落点 */
1963
1964
  let menuAtX = 0
1964
1965
  let menuAtY = 0
1966
+ /* 悬停展开的延迟:鼠标路过一行就展开会抖(hover intent),
1967
+ 关闭的宽限更长一点 —— 斜着往子项移动时才不会中途收起。 */
1968
+ const HOVER_OPEN_MS = 140
1969
+ const HOVER_CLOSE_MS = 260
1970
+ /* 点开的组会被"钉住":鼠标移开也不关。悬停是预览,点击是决定。 */
1971
+ let pinnedGroup = ''
1972
+ let groupOpenTimer = null
1973
+ let groupCloseTimer = null
1974
+ /* 判断"焦点是否在这一组里"不能笼统地看"焦点是否在菜单里" —— 菜单一打开焦点就在
1975
+ 第一项上,那样写等于悬停收起永远不会触发。 */
1976
+
1977
+ const clearGroupTimers = () => {
1978
+ if (groupOpenTimer) { clock.cancel(groupOpenTimer); groupOpenTimer = null }
1979
+ if (groupCloseTimer) { clock.cancel(groupCloseTimer); groupCloseTimer = null }
1980
+ }
1981
+ /* 焦点是否在这**一组**里 —— 键盘用户正操作这一组时,鼠标移开不该把它收掉。
1982
+ 焦点在别的行、或压根不在菜单里,都不该阻止收起。
1983
+ 判法:从焦点向上走,遇到带 data-group-block 的祖先就算命中(不靠外部映射)。 */
1984
+ const focusInsideGroup = (name) => {
1985
+ let el = null
1986
+ try { el = document.activeElement } catch (e) { el = null }
1987
+ let hops = 0
1988
+ while (el && hops < 24) {
1989
+ if (el.dataset && el.dataset.groupBlock === name) return true
1990
+ el = el.parentNode || null
1991
+ hops++
1992
+ }
1993
+ return false
1994
+ }
1995
+ /* 悬停到一组:延迟后展开它,并解除别组的钉住(手风琴 —— 同时开两组会看不清)。 */
1996
+ const scheduleGroupOpen = (name) => {
1997
+ clearGroupTimers()
1998
+ if (activeGroup === name) return
1999
+ groupOpenTimer = clock.after(HOVER_OPEN_MS, () => {
2000
+ groupOpenTimer = null
2001
+ if (destroyed || menuEl === null) return
2002
+ pinnedGroup = ''
2003
+ activeGroup = name
2004
+ openMenu(menuAtX, menuAtY, true)
2005
+ })
2006
+ }
2007
+ const scheduleGroupClose = (name) => {
2008
+ clearGroupTimers()
2009
+ if (activeGroup !== name) return
2010
+ if (pinnedGroup === name) return // 点开的不随鼠标移开关闭
2011
+ groupCloseTimer = clock.after(HOVER_CLOSE_MS, () => {
2012
+ groupCloseTimer = null
2013
+ if (destroyed || menuEl === null) return
2014
+ if (pinnedGroup === name) return
2015
+ if (focusInsideGroup(name)) return // 键盘用户还在这一组里,别关
2016
+ activeGroup = ''
2017
+ openMenu(menuAtX, menuAtY, true)
2018
+ })
2019
+ }
1965
2020
 
1966
2021
  const closeMenu = () => {
1967
2022
  if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl)
1968
2023
  menuEl = null
1969
2024
  menuButtons = []
1970
2025
  menuFocusAt = -1
2026
+ clearGroupTimers()
1971
2027
  if (menuOff) { menuOff(); menuOff = null }
1972
2028
  }
1973
2029
 
@@ -2276,7 +2332,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2276
2332
  const openMenu = (x, y, keepGroup) => {
2277
2333
  menuAtX = x
2278
2334
  menuAtY = y
2279
- if (keepGroup !== true) activeGroup = ''
2335
+ if (keepGroup !== true) { activeGroup = ''; pinnedGroup = '' }
2280
2336
  closeMenu()
2281
2337
  stopGlide()
2282
2338
  const items = menuItems()
@@ -2490,8 +2546,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2490
2546
  continue
2491
2547
  }
2492
2548
  if (spec.group !== undefined) {
2493
- /* 折叠分组:标题行带上当前状态摘要,点一下在原地展开/收起。 */
2549
+ /* 折叠分组:标题行 + (展开时的)子项,**包在同一个悬停区里**。
2550
+ 这是悬停展开能做对的前提 —— 从标题移到子项不该算"移开",
2551
+ 否则鼠标一动子菜单就没了(那是这类交互最常见的翻车点)。 */
2494
2552
  const open = activeGroup === spec.group
2553
+ const block = document.createElement('div')
2554
+ block.className = 'wisp-menu-group'
2555
+ block.dataset.groupBlock = spec.group
2495
2556
  const btn = document.createElement('button')
2496
2557
  btn.className = 'wisp-menu-item wisp-menu-row'
2497
2558
  btn.setAttribute('type', 'button')
@@ -2513,16 +2574,37 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2513
2574
  btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2514
2575
  btn.addEventListener('click', (ev) => {
2515
2576
  ev.preventDefault(); ev.stopPropagation()
2516
- activeGroup = open ? '' : spec.group
2577
+ if (activeGroup === spec.group && pinnedGroup === spec.group) {
2578
+ /* 已经点开过:再点一次收起 */
2579
+ pinnedGroup = ''
2580
+ activeGroup = ''
2581
+ } else {
2582
+ /* 点开 = 钉住:之后鼠标移开也不关(键盘、触屏、以及"我就想它开着"都靠这条) */
2583
+ pinnedGroup = spec.group
2584
+ activeGroup = spec.group
2585
+ }
2517
2586
  openMenu(menuAtX, menuAtY, true)
2518
2587
  })
2519
- menu.appendChild(btn)
2588
+ /* 悬停展开 / 移开收起。pointerType 不为 mouse 的一律不管 ——
2589
+ 触屏没有悬停,不判这个的话手指一碰就会莫名其妙展开。 */
2590
+ const isMouse = (ev) => !(ev && ev.pointerType && ev.pointerType !== 'mouse')
2591
+ block.addEventListener('pointerenter', (ev) => {
2592
+ if (!isMouse(ev)) return
2593
+ scheduleGroupOpen(spec.group)
2594
+ })
2595
+ block.addEventListener('pointerleave', (ev) => {
2596
+ if (!isMouse(ev)) return
2597
+ scheduleGroupClose(spec.group)
2598
+ })
2599
+ block.appendChild(btn)
2600
+ menu.appendChild(block)
2520
2601
  menuButtons.push(btn)
2521
2602
  if (!open) continue
2522
2603
  const wrap = document.createElement('div')
2523
2604
  wrap.className = 'wisp-menu-sub'
2524
2605
  renderChildren(spec.children, wrap)
2525
- menu.appendChild(wrap)
2606
+ /* 子项进 block(悬停区),不是直接进 menu */
2607
+ block.appendChild(wrap)
2526
2608
  continue
2527
2609
  }
2528
2610
  actionRow(spec, menu)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wisp",
3
- "version": "1.31.0",
3
+ "version": "1.32.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/verify-wisp.mjs CHANGED
@@ -2454,6 +2454,116 @@ if (clientSrc !== null) {
2454
2454
  'and the corner she is actually in is marked',
2455
2455
  menuOf().querySelectorAll('.wisp-corner').map((b) => b.textContent + (b.dataset.current ? '*' : '')).join(' '))
2456
2456
 
2457
+ /* ---- 悬停展开 / 移开收起 ------------------------------------------------
2458
+ 这是"指针类"交互。替身里没有真鼠标,但它有**事件派发 + 虚拟时钟**,
2459
+ 所以这套逻辑能被完整测到:派 pointerenter、推进时钟、看菜单结构变没变。
2460
+ 测不到的是浏览器的命中测试本身("指针真的在这一行上")—— 那部分由冒烟里的
2461
+ 真 Chromium 兜着,这里测的是**状态机**:延迟、宽限、钉住、触屏、焦点保护。 */
2462
+ const hoverBlock = (name) => menuHost().querySelectorAll('.wisp-menu-group')
2463
+ .find((el) => el.dataset && el.dataset.groupBlock === name && el.removed !== true)
2464
+ const hoverIn = (name, pointerType) => hoverBlock(name).dispatch('pointerenter', { pointerType })
2465
+ const hoverOut = (name, pointerType) => hoverBlock(name).dispatch('pointerleave', { pointerType })
2466
+ const isOpen = (name) => {
2467
+ const row = groupRow(name)
2468
+ return row !== undefined && row.dataset.open === 'true'
2469
+ }
2470
+
2471
+ ensureCollapsed('外观'); ensureCollapsed('行为'); ensureCollapsed('位置')
2472
+ check(!isOpen('外观') && !isOpen('行为'), 'every group starts collapsed')
2473
+
2474
+ /* 悬停要**有延迟**:鼠标只是路过就展开会抖 —— 这是 hover intent,不是可有可无的润色 */
2475
+ hoverIn('外观', 'mouse')
2476
+ check(!isOpen('外观'), 'hovering does not open it instantly (a passing mouse would twitch it)')
2477
+ h.advance(200, 40)
2478
+ check(isOpen('外观'), 'after the intent delay it opens')
2479
+
2480
+ /* 移开也要有宽限:斜着往子项移动时,中途不能收 */
2481
+ hoverOut('外观', 'mouse')
2482
+ h.advance(80, 40)
2483
+ check(isOpen('外观'), 'leaving does not close it instantly either')
2484
+ h.advance(400, 40)
2485
+ check(!isOpen('外观'), 'after the grace period it closes')
2486
+
2487
+ /* 路过不展开:进去又马上出来,推进很久也不该开过 */
2488
+ hoverIn('行为', 'mouse')
2489
+ hoverOut('行为', 'mouse')
2490
+ h.advance(800, 40)
2491
+ check(!isOpen('行为'), 'a mouse that just passes over never opens a group')
2492
+
2493
+ /* 子项在**同一个悬停区**里 —— 这是"从标题移到子项不会关"的实现方式 */
2494
+ hoverIn('外观', 'mouse')
2495
+ h.advance(200, 40)
2496
+ const blockEl = hoverBlock('外观')
2497
+ const kids = blockEl.querySelector ? blockEl.querySelector('.wisp-menu-sub') : null
2498
+ check(kids !== null && kids.parentNode === blockEl,
2499
+ 'the children sit inside the same hover area, so moving into them is not leaving',
2500
+ kids === null ? '(没有子项容器)' : String(kids.parentNode === blockEl))
2501
+
2502
+ /* 触屏没有悬停:不判 pointerType 的话,手指一碰就会莫名其妙展开 */
2503
+ hoverOut('外观', 'mouse')
2504
+ h.advance(400, 40)
2505
+ hoverIn('外观', 'touch')
2506
+ h.advance(600, 40)
2507
+ check(!isOpen('外观'), 'a touch pointer never opens a group by hovering')
2508
+ groupRow('外观').dispatch('click', itemEvent())
2509
+ check(isOpen('外观'), 'but tapping it still works')
2510
+
2511
+ /* 点击 = 钉住:鼠标移开也不关(悬停是预览,点击是决定) */
2512
+ hoverOut('外观', 'mouse')
2513
+ h.advance(800, 40)
2514
+ check(isOpen('外观'), 'a group you clicked stays open when the mouse leaves')
2515
+ groupRow('外观').dispatch('click', itemEvent())
2516
+ check(!isOpen('外观'), 'clicking it again collapses it')
2517
+
2518
+ /* 手风琴:悬停到另一组时,被钉住的那一组会让位 */
2519
+ groupRow('外观').dispatch('click', itemEvent())
2520
+ check(isOpen('外观'), 'pin the appearance group again')
2521
+ hoverIn('行为', 'mouse')
2522
+ h.advance(200, 40)
2523
+ check(isOpen('行为') && !isOpen('外观'),
2524
+ 'hovering another group opens it and lets the pinned one go',
2525
+ `外观=${isOpen('外观')} 行为=${isOpen('行为')}`)
2526
+ hoverOut('行为', 'mouse')
2527
+ h.advance(600, 40)
2528
+ check(!isOpen('行为'), 'and it closes again after the grace')
2529
+
2530
+ /* 焦点在这一组里时,鼠标移开不该把它收掉(否则键盘用户正操作着,鼠标一动就没了) */
2531
+ hoverIn('外观', 'mouse')
2532
+ h.advance(200, 40)
2533
+ check(isOpen('外观'), 'hover it open again (not pinned this time)')
2534
+ const focusGroupRow = () => {
2535
+ pressKey('Home')
2536
+ for (let i = 0; i < 16; i++) {
2537
+ const el = h.document.activeElement
2538
+ if (el && el.dataset && el.dataset.group === '外观') return true
2539
+ pressKey('ArrowDown')
2540
+ }
2541
+ return false
2542
+ }
2543
+ check(focusGroupRow(), 'keyboard focus can land on the group row')
2544
+ const focusOnGroupRow = () => {
2545
+ const el = h.document.activeElement
2546
+ return Boolean(el && el.dataset && el.dataset.group === '外观')
2547
+ }
2548
+ check(focusOnGroupRow(), 'the focus really is on that row')
2549
+ hoverOut('外观', 'mouse')
2550
+ h.advance(300, 40)
2551
+ check(focusOnGroupRow(), 'hovering out does not move the focus')
2552
+ h.advance(500, 40)
2553
+ const activeNow = h.document.activeElement
2554
+ check(isOpen('外观'), 'while focus is inside that group, leaving with the mouse does not close it',
2555
+ `focus=${activeNow ? String(activeNow.className) + '/' + JSON.stringify(activeNow.dataset) : '(无)'}`)
2556
+ /* 焦点挪走之后,它就该按规则收起了 */
2557
+ pressKey('Home')
2558
+ hoverOut('外观', 'mouse')
2559
+ h.advance(800, 40)
2560
+ check(!isOpen('外观'), 'once focus leaves, the hover rules apply again')
2561
+ pressKey('Escape')
2562
+ check(menuOf() === null, 'Escape still closes the menu after all that hovering')
2563
+ /* 后面的检查要看菜单,重新打开 */
2564
+ body5.dispatch('contextmenu', rightClickOnHer().ev)
2565
+ check(menuOf() !== null, 'the menu reopens for the remaining structure checks')
2566
+
2457
2567
  /* ---- 菜单里不该再有"版本与更新":版本号在头部,检查更新在弹窗里 ---- */
2458
2568
  check(!itemsOf().some((i) => i.textContent.includes('版本与更新')),
2459
2569
  'the version entry is gone from the menu (the header shows it now)')