# Agent Note: 悬浮弹层的指针宽限期

Status: implemented

[English](2026-07-30-hover-popup-pointer-grace.md) | 中文

## 问题

工作区浏览器行弹出的两种弹层都处于指针无法抵达的位置。`HoverCard` 在指针离开锚点的第一个 `pointerleave` 上就关闭，其卡片还设置了 `pointer-events: none`；但卡片位于锚点右边缘外 8px 处，因此通往卡片的每条路径都要穿过既不属于锚点也不属于卡片的区域，卡片在指针抵达之前就已被销毁——它本应展示的完整工作区路径和会话标题只能匆匆一瞥。行操作菜单传入了 `closeOnPointerLeave`，而其处理器挂在传送后的列表上：把指针移回打开该列表的 `...` 触发按钮会关闭列表，越过列表边缘的任何一次抖动同样如此，且没有任何折返窗口。

## 决策

`usePointerGrace`（[packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts)）持有唯一一个可取消的延迟关闭，由两个原子组件共享，`POINTER_GRACE_MS` 为 200。离开会启动关闭，折返则取消它。因此指针可以安全穿越锚点与弹层之间的间隙，而真正移开的指针仍会关闭弹层。

`HoverCard` 在离开时启动宽限期而不再立即关闭，其卡片也不再设置 `pointer-events: none`，因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭，而不重启停留计时，从而避免指针穿越间隙时卡片闪烁。在卡片上按下指针用于开始文本选择，不会关闭卡片；只有锚点区域内的按下和所有者将 `disabled` 置真，才会抢在宽限期之前立即关闭卡片。

`Menu` 把指针离开关闭的处理从传送后的列表移到包裹 span 上。React 的 enter/leave 遍历基于 React 树进行，因此触发按钮与传送后的列表在这里属于同一区域：穿越两者之间 4px 的间隙、或把指针移回触发按钮，都不再算作离开。只有在列表打开时才会启动离开关闭；由所有者驱动的关闭（选择、Escape、外部点击）会在一个仅以 `open` 为依赖的 effect 中解除待执行的宽限关闭——若把它折叠进外部点击的 effect，则每次重新渲染都会取消宽限期，因为所有者每次都传入新的 `onClose` 闭包。

## 考虑过的替代方案

**仅通过外部点击和 Escape 关闭这两种弹层。** 之所以否决：两者都由悬停唤起，且没有可见的关闭标识；在指针已移到其他行之后仍让它们停留，会把卡片遗留在无关内容之上。

**扩大锚点的命中区域，使其与弹层相接。** 之所以否决：8px 与 4px 的偏移来自设计稿，而一个不可见的桥接元素还必须跟随这两个固定定位弹层已经在滚动和缩放时执行的每一次重新定位。

**保留悬浮卡片的 `pointer-events: none`，只加入宽限期。** 之所以否决：那样指针停在卡片上时命中的是卡片背后的元素，宽限期仍会到期，并关闭用户刚刚够到的卡片。

**让两个原子组件各自持有计时器。** 之所以否决：这两处关闭是同一种行为、同一套调参；共享 hook 可以防止它们各自漂移。

## 后果

悬浮卡片现在可被命中，显示期间会遮挡其覆盖区域的 244px——这是可抵达性的代价；它依然只在指针位于行或卡片上时存在。行菜单现在能承受触发按钮与列表之间的往返，而因自身原因关闭的菜单也不会被残留的待执行关闭重新关掉。未设置 `closeOnPointerLeave` 的菜单不受影响——只有设置该属性时才会挂上包裹层处理器。

## 测试

`packages/client/ui-primitives/tests/hover-card.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭，以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上，以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证，因为它们依赖 jsdom 无法建模的命中测试与布局。
