dsh-wisp 1.32.0 → 1.33.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wisp",
3
- "version": "1.32.0",
3
+ "version": "1.33.1",
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,6 +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
+
261
262
  check(menu !== null && menu.items.length === 8, 'the menu lists every entry',
262
263
  `${menu?.items.length} items at the top level`)
263
264
 
@@ -580,6 +581,62 @@ check(skinSwap.layersDuringFade === 1, 'the cross-fade layer is gone once it set
580
581
  // 每个页面都要装下这份包与解码后的精灵图(单张就是 naturalWidth*naturalHeight*4 字节),
581
582
  // 多开一个页面 = 多一份;之前正是这样把浏览器推崩的,表现为随机的
582
583
  // "Target page, context or browser has been closed",极难定位。
584
+ /* 级联子菜单的几何检查:必须在**页面还活着**的时候跑(它要量真实布局),
585
+ 也必须在键盘段之后(它会开关菜单、动焦点)。 */
586
+ /* 级联子菜单的几何检查放在**最后**:它会开关菜单、动焦点,放在中间会扰动后面的键盘段。 */
587
+ /* 级联子菜单的**几何**:它在旁边,不在上面。
588
+ 这一条替身测不到(假 DOM 没有布局),而"子菜单遮住主菜单"那个 bug 正好出在这里 ——
589
+ 混用了视口坐标(getBoundingClientRect)与图层坐标(菜单的 left/top)。 */
590
+ const flyoutGeo = await page.evaluate(async () => {
591
+ const body = document.querySelector('.wisp-body')
592
+ const box = document.querySelector('.wisp-root')
593
+ if (!body || !box) return { noBody: true }
594
+ const r0 = box.getBoundingClientRect()
595
+ body.dispatchEvent(new MouseEvent('contextmenu', {
596
+ bubbles: true, cancelable: true, clientX: r0.left + r0.width / 2, clientY: r0.top + r0.height / 2,
597
+ }))
598
+ const menu = document.querySelector('.wisp-menu')
599
+ if (!menu) return { noMenu: true }
600
+ const row = [...menu.querySelectorAll('.wisp-menu-item')].find((el) => el.dataset && el.dataset.group)
601
+ if (!row) return { noRow: true }
602
+ row.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' }))
603
+ await new Promise((resolve) => setTimeout(resolve, 350))
604
+ const panel = document.querySelector('.wisp-submenu')
605
+ if (!panel) return { noPanel: true }
606
+ const mr = menu.getBoundingClientRect()
607
+ const pr = panel.getBoundingClientRect()
608
+ const rr = row.getBoundingClientRect()
609
+ const layer = menu.parentNode
610
+ const lr = layer.getBoundingClientRect ? layer.getBoundingClientRect() : { left: -999, top: -999, width: 0, height: 0 }
611
+ return {
612
+ menu: { l: Math.round(mr.left), r: Math.round(mr.right), w: Math.round(mr.width), styleL: menu.style.left, styleT: menu.style.top },
613
+ panel: { l: Math.round(pr.left), r: Math.round(pr.right), t: Math.round(pr.top), w: Math.round(pr.width), styleL: panel.style.left, styleT: panel.style.top },
614
+ row: { t: Math.round(rr.top), h: Math.round(rr.height) },
615
+ layer: { l: Math.round(lr.left), t: Math.round(lr.top), w: Math.round(lr.width), h: Math.round(lr.height), pos: getComputedStyle(layer).position },
616
+ view: { w: window.innerWidth, h: window.innerHeight },
617
+ rows: panel.querySelectorAll('.wisp-menu-item').length,
618
+ }
619
+ })
620
+ check(flyoutGeo.panel !== undefined && flyoutGeo.rows > 0,
621
+ 'hovering a group row pops a submenu panel', JSON.stringify(flyoutGeo))
622
+ check(flyoutGeo.panel !== undefined
623
+ && (flyoutGeo.panel.l >= flyoutGeo.menu.r - 6 || flyoutGeo.panel.r <= flyoutGeo.menu.l + 6),
624
+ 'the submenu sits BESIDE the menu, not on top of it — window coords and layer coords must not be mixed',
625
+ flyoutGeo.panel ? `menu=${flyoutGeo.menu.l}..${flyoutGeo.menu.r} panel=${flyoutGeo.panel.l}..${flyoutGeo.panel.r}` : 'n/a')
626
+ check(flyoutGeo.panel !== undefined
627
+ && flyoutGeo.panel.l >= 0 && flyoutGeo.panel.r <= flyoutGeo.view.w
628
+ && flyoutGeo.panel.t >= 0,
629
+ 'and it fits on screen', flyoutGeo.panel ? JSON.stringify(flyoutGeo.panel) : 'n/a')
630
+ check(flyoutGeo.panel !== undefined && Math.abs(flyoutGeo.panel.t - flyoutGeo.row.t) < 40,
631
+ 'and lines up with the row it belongs to',
632
+ flyoutGeo.panel ? `row.t=${flyoutGeo.row.t} panel.t=${flyoutGeo.panel.t}` : 'n/a')
633
+ await page.evaluate(() => {
634
+ /* 菜单的键盘监听挂在 **window** 上,不是 document —— 发错目标菜单就不会关,
635
+ 后面的键盘段会在"一个还没关掉的菜单"上重开,焦点状态全乱(就是这么挂掉三条的)。 */
636
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
637
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
638
+ })
639
+
583
640
  await browser.close()
584
641
 
585
642
  /* ------------------------------------------------------ 8. 自己踱步 ------- */
@@ -645,6 +702,7 @@ await wanderBrowser.close()
645
702
 
646
703
 
647
704
  console.log(`\n${fail === 0 ? '=== SMOKE PASSED ===' : '=== SMOKE FAILED ==='}`)
705
+
648
706
  console.log(` ${pass} passed, ${fail} failed (real Chromium, ${headed ? 'headed' : 'headless'})`)
649
707
  console.log(` playwright: ${playwright.from}`)
650
708
  if (executablePath) console.log(` browser: ${executablePath}`)
package/verify-wisp.mjs CHANGED
@@ -2441,7 +2441,8 @@ if (clientSrc !== null) {
2441
2441
 
2442
2442
  /* ---- 位置:2×2 的方位控件 ---- */
2443
2443
  check(expand('位置'), 'the position group opens')
2444
- const gridButtons = menuOf().querySelectorAll('.wisp-corner')
2444
+ /* 角落按钮现在在「位置」的子面板里 —— 面板是菜单的兄弟节点,所以查询落在 layer 上 */
2445
+ const gridButtons = menuHost().querySelectorAll('.wisp-corner').filter((el) => el.removed !== true)
2445
2446
  check(gridButtons.length === 4, 'the four corners are a 2×2 compass, not four text rows',
2446
2447
  String(gridButtons.length) + ' 个')
2447
2448
  /* 她此刻并不在任何角落(位置是拖出来的),所以先点一个角落归位,再看标记。 */
@@ -2449,118 +2450,133 @@ if (clientSrc !== null) {
2449
2450
  toTopLeft.dispatch('click', itemEvent())
2450
2451
  body5.dispatch('contextmenu', rightClickOnHer().ev)
2451
2452
  check(expand('位置'), 'reopen the position group')
2452
- const marked = menuOf().querySelectorAll('.wisp-corner').filter((b) => b.dataset && b.dataset.current === 'true')
2453
+ const marked = menuHost().querySelectorAll('.wisp-corner')
2454
+ .filter((b) => b.removed !== true && b.dataset && b.dataset.current === 'true')
2453
2455
  check(marked.length === 1 && marked[0].dataset.corner === '左上角',
2454
2456
  'and the corner she is actually in is marked',
2455
- menuOf().querySelectorAll('.wisp-corner').map((b) => b.textContent + (b.dataset.current ? '*' : '')).join(' '))
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 })
2457
+ menuHost().querySelectorAll('.wisp-corner').filter((b) => b.removed !== true)
2458
+ .map((b) => b.textContent + (b.dataset.current ? '*' : '')).join(' '))
2459
+
2460
+ /* ---- 级联子菜单:悬停展开、移开收起 --------------------------------------
2461
+ 这是**菜单栏**的形状:悬停一行,在它**旁边**开一个独立面板,而不是把下面的内容推下去。
2462
+ 替身里没有真鼠标,但它有事件派发 + 虚拟时钟,所以状态机能被完整测到:
2463
+ 派 pointerenter/pointerleave、推进时钟、看面板有没有出现、出现在哪一侧。
2464
+ 测不到的是浏览器的命中测试本身("指针真的在这一行上")—— 那由冒烟里的真 Chromium 兜着。 */
2465
+ const flyoutOf = (name) => menuHost().querySelectorAll('.wisp-submenu')
2466
+ .find((el) => el.dataset && el.dataset.submenu === name && el.removed !== true) ?? null
2467
+ const rowOf = (name) => groupRow(name)
2468
+ const hoverIn = (name, pointerType) => rowOf(name).dispatch('pointerenter', { pointerType })
2469
+ const hoverOut = (name, pointerType) => rowOf(name).dispatch('pointerleave', { pointerType })
2466
2470
  const isOpen = (name) => {
2467
- const row = groupRow(name)
2471
+ const row = rowOf(name)
2468
2472
  return row !== undefined && row.dataset.open === 'true'
2469
2473
  }
2470
2474
 
2471
- ensureCollapsed('外观'); ensureCollapsed('行为'); ensureCollapsed('位置')
2472
- check(!isOpen('外观') && !isOpen('行为'), 'every group starts collapsed')
2475
+ groupRow('外观').dispatch('click', itemEvent()); groupRow('外观').dispatch('click', itemEvent())
2476
+ check(!isOpen('外观') && flyoutOf('外观') === null, 'no submenu is open at the start')
2473
2477
 
2474
- /* 悬停要**有延迟**:鼠标只是路过就展开会抖 —— 这是 hover intent,不是可有可无的润色 */
2478
+ /* 悬停要**有延迟**:鼠标只是路过就弹出面板会抖 —— 这是 hover intent */
2475
2479
  hoverIn('外观', 'mouse')
2476
- check(!isOpen('外观'), 'hovering does not open it instantly (a passing mouse would twitch it)')
2480
+ check(flyoutOf('外观') === null && !isOpen('外观'),
2481
+ 'hovering does not pop a submenu instantly (a passing mouse would twitch it)')
2477
2482
  h.advance(200, 40)
2478
- check(isOpen('外观'), 'after the intent delay it opens')
2479
-
2480
- /* 移开也要有宽限:斜着往子项移动时,中途不能收 */
2483
+ check(flyoutOf('外观') !== null && isOpen('外观'), 'after the intent delay the submenu appears')
2484
+ check(rowOf('外观').getAttribute('aria-haspopup') === 'true'
2485
+ && rowOf('外观').getAttribute('aria-expanded') === 'true',
2486
+ 'and the row says it has a submenu and that it is open',
2487
+ `haspopup=${rowOf('外观').getAttribute('aria-haspopup')} expanded=${rowOf('外观').getAttribute('aria-expanded')}`)
2488
+
2489
+ /* 关键:面板是**菜单的兄弟**(浮在旁边),不是菜单里把内容推下去的块 */
2490
+ const panel = flyoutOf('外观')
2491
+ check(panel.parentNode === menuHost(),
2492
+ 'the submenu is a sibling of the menu, floating beside it')
2493
+ check(String(panel.style.left) !== '' && String(panel.style.top) !== '',
2494
+ 'and it is positioned', `left=${panel.style.left} top=${panel.style.top}`)
2495
+ check(String(panel.style.left) !== String(menuOf().style.left),
2496
+ 'not stacked on top of the menu', `panel=${panel.style.left} menu=${menuOf().style.left}`)
2497
+
2498
+ /* 移开:延迟后收起 */
2481
2499
  hoverOut('外观', 'mouse')
2482
2500
  h.advance(80, 40)
2483
- check(isOpen('外观'), 'leaving does not close it instantly either')
2501
+ check(flyoutOf('外观') !== null, 'leaving the row does not close it instantly')
2484
2502
  h.advance(400, 40)
2485
- check(!isOpen('外观'), 'after the grace period it closes')
2503
+ check(flyoutOf('外观') === null && !isOpen('外观'), 'after the grace period it closes')
2504
+
2505
+ /* 指针移进**面板**本身:算同一区域,不能关 */
2506
+ hoverIn('外观', 'mouse')
2507
+ h.advance(200, 40)
2508
+ const panel2 = flyoutOf('外观')
2509
+ /* 顺序要照真实浏览器:指针从行移到面板,先触发行的 leave、再触发面板的 enter。
2510
+ 写反了的话"面板 enter 取消关闭"就被后面的 leave 又覆盖掉了。 */
2511
+ hoverOut('外观', 'mouse')
2512
+ panel2.dispatch('pointerenter', { pointerType: 'mouse' })
2513
+ h.advance(600, 40)
2514
+ check(flyoutOf('外观') !== null,
2515
+ 'moving the pointer onto the panel keeps it open (row and panel are one region)')
2516
+ panel2.dispatch('pointerleave', { pointerType: 'mouse' })
2517
+ h.advance(600, 40)
2518
+ check(flyoutOf('外观') === null, 'leaving the panel closes it')
2486
2519
 
2487
- /* 路过不展开:进去又马上出来,推进很久也不该开过 */
2520
+ /* 路过不弹出 */
2488
2521
  hoverIn('行为', 'mouse')
2489
2522
  hoverOut('行为', 'mouse')
2490
2523
  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))
2524
+ check(flyoutOf('行为') === null, 'a mouse that just passes over never pops a submenu')
2501
2525
 
2502
- /* 触屏没有悬停:不判 pointerType 的话,手指一碰就会莫名其妙展开 */
2503
- hoverOut('外观', 'mouse')
2504
- h.advance(400, 40)
2526
+ /* 触屏:不判 pointerType 的话,手指一碰就会弹面板 */
2505
2527
  hoverIn('外观', 'touch')
2506
2528
  h.advance(600, 40)
2507
- check(!isOpen('外观'), 'a touch pointer never opens a group by hovering')
2529
+ check(flyoutOf('外观') === null, 'a touch pointer never opens a submenu by hovering')
2508
2530
  groupRow('外观').dispatch('click', itemEvent())
2509
- check(isOpen('外观'), 'but tapping it still works')
2531
+ check(flyoutOf('外观') !== null, 'but tapping the row still works')
2510
2532
 
2511
- /* 点击 = 钉住:鼠标移开也不关(悬停是预览,点击是决定) */
2533
+ /* 点击 = 钉住:移开也不关;再点一次收起 */
2512
2534
  hoverOut('外观', 'mouse')
2513
2535
  h.advance(800, 40)
2514
- check(isOpen('外观'), 'a group you clicked stays open when the mouse leaves')
2536
+ check(flyoutOf('外观') !== null, 'a submenu you clicked stays open when the mouse leaves')
2515
2537
  groupRow('外观').dispatch('click', itemEvent())
2516
- check(!isOpen('外观'), 'clicking it again collapses it')
2538
+ check(flyoutOf('外观') === null, 'clicking the row again closes it')
2517
2539
 
2518
- /* 手风琴:悬停到另一组时,被钉住的那一组会让位 */
2540
+ /* 手风琴:悬停到另一行时,上一个面板让位 */
2519
2541
  groupRow('外观').dispatch('click', itemEvent())
2520
- check(isOpen('外观'), 'pin the appearance group again')
2542
+ check(flyoutOf('外观') !== null, 'pin one open again')
2521
2543
  hoverIn('行为', 'mouse')
2522
2544
  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')
2545
+ check(flyoutOf('行为') !== null && flyoutOf('外观') === null,
2546
+ 'hovering another row opens its submenu and closes the previous one',
2547
+ `外观=${flyoutOf('外观') !== null} 行为=${flyoutOf('行为') !== null}`)
2529
2548
 
2530
- /* 焦点在这一组里时,鼠标移开不该把它收掉(否则键盘用户正操作着,鼠标一动就没了) */
2531
- hoverIn('外观', 'mouse')
2532
- h.advance(200, 40)
2533
- check(isOpen('外观'), 'hover it open again (not pinned this time)')
2534
- const focusGroupRow = () => {
2549
+ /* 键盘:→ 进子菜单、← 回父行、Esc 逐层退出 */
2550
+ const focusedIs = (pred) => {
2551
+ const el = h.document.activeElement
2552
+ return Boolean(el && pred(el))
2553
+ }
2554
+ const focusRow = (name) => {
2535
2555
  pressKey('Home')
2536
2556
  for (let i = 0; i < 16; i++) {
2537
- const el = h.document.activeElement
2538
- if (el && el.dataset && el.dataset.group === '外观') return true
2557
+ if (focusedIs((el) => el.dataset && el.dataset.group === name)) return true
2539
2558
  pressKey('ArrowDown')
2540
2559
  }
2541
2560
  return false
2542
2561
  }
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')
2562
+ check(focusRow('位置'), 'keyboard focus can land on a group row')
2563
+ pressKey('ArrowRight')
2564
+ check(flyoutOf('位置') !== null, 'ArrowRight opens the submenu beside it')
2565
+ check(focusedIs((el) => el.className.indexOf('wisp-corner') >= 0),
2566
+ 'and focus moves into it', String(h.document.activeElement && h.document.activeElement.className))
2567
+ pressKey('ArrowLeft')
2568
+ check(flyoutOf('位置') === null, 'ArrowLeft closes it')
2569
+ check(focusedIs((el) => el.dataset && el.dataset.group === '位置'),
2570
+ 'and hands focus back to the row',
2571
+ String(h.document.activeElement && h.document.activeElement.dataset && h.document.activeElement.dataset.group))
2572
+ pressKey('ArrowRight')
2573
+ check(flyoutOf('位置') !== null, 'open it again for the Escape check')
2574
+ pressKey('Escape')
2575
+ check(flyoutOf('位置') === null && menuOf() !== null,
2576
+ 'Escape closes the submenu first and leaves the menu open (one layer at a time)',
2577
+ `面板=${flyoutOf('位置') === null ? '关了' : '还开着'} 菜单=${menuOf() === null ? '被关了' : '还开着'}`)
2561
2578
  pressKey('Escape')
2562
- check(menuOf() === null, 'Escape still closes the menu after all that hovering')
2563
- /* 后面的检查要看菜单,重新打开 */
2579
+ check(menuOf() === null, 'and a second Escape closes the menu')
2564
2580
  body5.dispatch('contextmenu', rightClickOnHer().ev)
2565
2581
  check(menuOf() !== null, 'the menu reopens for the remaining structure checks')
2566
2582
 
@@ -2622,7 +2638,8 @@ if (clientSrc !== null) {
2622
2638
  body5.dispatch('contextmenu', secondClick.ev)
2623
2639
  check(menuOf() !== null, 'the menu reopens')
2624
2640
  check(expand('位置'), 'the corners live in the position group')
2625
- const homeBtn = menuOf().querySelectorAll('.wisp-corner').find((b) => b.dataset && b.dataset.corner === '右下角')
2641
+ const homeBtn = menuHost().querySelectorAll('.wisp-corner')
2642
+ .find((b) => b.removed !== true && b.dataset && b.dataset.corner === '右下角')
2626
2643
  check(Boolean(homeBtn), 'the corner button is there')
2627
2644
  homeBtn.dispatch('click', itemEvent())
2628
2645
  const homed = fifth.position