feat(web): draw the sidebar's scrollbar only under the pointer

The session list overflows after a handful of sessions, and its scrollbar
was drawn permanently in a column that is at rest most of the time.

SidebarRoot now tracks the pointer over the whole column and rebinds
ui-theme's scrollbar indirection pair to `transparent` while it is
outside, keeping the thumb for 2s after the pointer leaves so it does not
blink out on the way past. Rebinding colour leaves the list's
`scrollbar-gutter: stable` reservation in force, so revealing the bar
moves no row.

ui-theme's gate now states the widened contract: a rebind targets an -l2
token pair or `transparent`, and nothing else.
This commit is contained in:
creatixchu
2026-08-04 15:05:32 +08:00
parent 3c436d781e
commit a030397aca
20 changed files with 430 additions and 28 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md
2026-07-28-themed-scrollbars-and-reserved-gutter.md: b45f70b126d083916c756afb88a8b646a4e9bb85
2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 8afa36429ce7e6e061b014d63dcb20e5a642a84c
2026-07-28-themed-scrollbars-and-reserved-gutter.md: ba3d9d3c94cf9a775c43c14e292187b3a43935fe
2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 51d668f584ee254183d5d1cf709140df2556e8d0

View File

@@ -20,7 +20,7 @@ The rules sit on `body`, not `html`. `design-platform.css` declares the `--dsw-a
The two renderings are mutually exclusive, and the exclusion is enforced rather than assumed. A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included. Declaring both unconditionally therefore leaves the hover token rendering nowhere at all: the engines that implement the hover pseudo-element are exactly the ones the standard properties silence, and Firefox has no hover pseudo-element to fall back on. The standard properties consequently sit inside `@supports not selector(::-webkit-scrollbar)`, which is true only where the pseudo-element is unimplemented, so Firefox takes the standard path and WebKit-based engines take the pseudo-element path. The WebKit rules are not gated in turn: an engine without those pseudo-elements drops them as unknown selectors, so a gate would only restate what selector matching already does. An engine too old for the `selector()` function makes the condition invalid, which evaluates false and selects the pseudo-element path — the correct side for the pre-16.4 Safari that is the realistic case for that reading.
Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls.
Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. `transparent` is the pair's other legal target, added when the sidebar's bars [started following the pointer](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md); the gate below admits those two and nothing else. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls.
Four surfaces — `Menu`, `InputBar`, `QuestionComposer`, and `TodoPanel` — were missed in the first implementation and found in review, which is why the per-sheet rebinding contract is checked mechanically rather than by inspection.
@@ -54,7 +54,7 @@ The gutter and the sheet's `::-webkit-scrollbar` width are jointly necessary aga
## Consequences
- Every scroll container in the client draws the themed thumb: `rgb(229, 229, 229)` on a light base surface, `rgb(60, 60, 61)` on a dark one, and `rgb(84, 85, 87)` for a dark elevated surface that rebinds to the l2 pair.
- Every scroll container in the client draws the themed thumb: `rgb(229, 229, 229)` on a light base surface, `rgb(60, 60, 61)` on a dark one, and `rgb(84, 85, 87)` for a dark elevated surface that rebinds to the l2 pair. The sidebar's regions draw theirs only under the pointer, through the same indirection.
- The two renderings are separately specified, so a change to the thumb's geometry or hover behavior has to be made twice — once in `scrollbar-width`/`scrollbar-color`, once in the pseudo-elements. Routing both through the indirection pair confines that duplication to the properties Firefox and WebKit do not share.
- The hover tokens (`--dsw-alias-scrollbar-hover-l1`/`-l2`) render only on the pseudo-element path. Firefox states one thumb color through `scrollbar-color` and derives its own hover treatment, so a design change to the hover colors is visible in Chromium and Safari and not in Firefox. This is a limit of `scrollbar-color`, not of the sheet.
- `body *` matches every element, for two properties whose effect the user agent already limits to elements that actually scroll. The cost is a broad selector; the alternative was a rebinding contract that does not work.

View File

@@ -20,7 +20,7 @@ Status: implemented
两种渲染互斥,而这种互斥是被强制的,不是假定的。`scrollbar-width``scrollbar-color` 只要取非 `auto`Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中。因此无条件地同时声明会让 hover token 在任何地方都得不到渲染:实现了 hover 伪元素的引擎,恰恰就是被标准属性静音的那些,而 Firefox 没有 hover 伪元素可作退路。于是标准属性写在 `@supports not selector(::-webkit-scrollbar)` 之内,该条件只在伪元素未被实现处为真,因此 Firefox 走标准属性路径WebKit 系引擎走伪元素路径。WebKit 规则不再反向加门禁:不实现这些伪元素的引擎会把它们当作未知选择器丢弃,因此加门禁只是重述选择器匹配本身已经做的事。对于旧到不支持 `selector()` 函数的引擎,该条件无效,从而求值为假并选中伪元素路径——对于这条判断下现实存在的 16.4 之前的 Safari这正是正确的一侧。
两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb``--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1基础表面token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)``--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。
两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb``--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1基础表面token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)``--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。这组变量另一个合法的目标是 `transparent`,它随侧边栏滚动条[改为跟随指针](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md)一并引入;下文的门禁只接受这两种目标。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。
`Menu``InputBar``QuestionComposer``TodoPanel` 这四个表面在最初的实现里被漏掉、由评审发现,因此逐样式表的重新绑定契约由机械检查而非人工审阅把关。
@@ -54,7 +54,7 @@ Status: implemented
## 后果
- 客户端的每个滚动容器都绘制带主题的滑块:亮色基础表面为 `rgb(229, 229, 229)`,暗色基础表面为 `rgb(60, 60, 61)`,重新绑定到 l2 的暗色抬升表面为 `rgb(84, 85, 87)`
- 客户端的每个滚动容器都绘制带主题的滑块:亮色基础表面为 `rgb(229, 229, 229)`,暗色基础表面为 `rgb(60, 60, 61)`,重新绑定到 l2 的暗色抬升表面为 `rgb(84, 85, 87)`侧边栏内的滚动区域经由同一组间接变量,只在指针到达时才绘制滑块。
- 两种渲染分别指定,因此改动滑块的几何或 hover 行为需要改两处:一处在 `scrollbar-width``scrollbar-color`,一处在伪元素。让两者都经由这组间接变量,把这份重复限制在 Firefox 与 WebKit 不共用的那些属性上。
- hover token`--dsw-alias-scrollbar-hover-l1``-l2`只在伪元素路径上渲染。Firefox 通过 `scrollbar-color` 只表述一个滑块颜色,其 hover 表现由引擎自行推导,因此对 hover 颜色的设计改动在 Chromium 与 Safari 上可见,在 Firefox 上不可见。这是 `scrollbar-color` 本身的限制,不是这张样式表的限制。
- `body *` 匹配所有元素,涉及的两个属性其效果本就被浏览器限制在实际会滚动的元素上。代价是一个覆盖面很宽的选择器;另一种选择是一个不生效的重新绑定契约。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md
2026-08-04-pointer-revealed-sidebar-scrollbars.md: fdc68fc0eda0aa68b3ec09d93df4a313f39f6d35
2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md: 8b11aec961a713fe5e07046dee7473fa34471689

View File

@@ -0,0 +1,54 @@
# Agent Note: The sidebar's scrollbars follow the pointer
Status: implemented
English | [中文](2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md)
## Problem
The sidebar's session list overflows after a handful of sessions, and from that point its scrollbar is drawn permanently — in a column that is at rest most of the time, next to rows whose own chrome only appears on hover. It is the one piece of always-on furniture in the sidebar, and nothing about it is actionable until someone reaches for it. The product ask (2026-08-04) is to draw it only while the pointer is over the sidebar, with a short tail so it does not blink out on the way past.
## Decision
`SidebarRoot` tracks the pointer over the whole column and carries a `quietBars` class whenever it is outside. The rule that class selects rebinds ui-theme's indirection pair — `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` — to `transparent`, so every scroll region nested under the column draws no thumb. The session list is the only one today; a future one inherits the behavior rather than opting into it.
The tail is `SCROLLBAR_LINGER_MS = 2000`: `pointerleave` starts a timer, `pointerenter` cancels a pending one, and only the timer firing puts the class back. A pointer that crosses the column's edge and returns — travelling around a portalled menu, or overshooting on the way to a row — never sees the thumb blink.
The pointer surface is the column, not the list. A pointer heading for the bar crosses the logo row, the New Session capsule, and the search field first, so revealing on the list alone would surface the bar only once the pointer was already among the rows.
`transparent` is what makes the reveal free of layout. `scrollbar-gutter: stable` on the list exists so rows never move ([the gutter note](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)); rebinding a colour leaves that reservation in force, so the thumb appears in space the list was already holding for it.
The indirection pair rather than a rule on the list, because that pair is ui-theme's documented rebinding seam: one declaration reaches both rendering paths (the WebKit pseudo-elements and Firefox's `scrollbar-color`), and custom properties inherit, which is what makes the column — rather than each scroll region in it — the thing that owns the state.
That widens the rebinding contract, so its gate states the new shape rather than accepting it by silence: `ui-theme/tests/scrollbar-styles.spec.ts` now admits exactly two rebind targets, an `-l2` token pair or `transparent`, and rejects everything else — an l1 rebind, which only restates the base-surface default under a name that reads as an elevation, and a literal colour, which leaves the palette.
## Alternatives considered
**CSS `:hover` on the column, with no JavaScript state.** The whole mechanism in one rule, and it cannot express the tail: the bar would vanish on the frame the pointer crossed the edge, which is exactly when a pointer is travelling to the conversation or around a portalled menu. The ask names the tail, and a hover-only version reads as flicker.
**Keep it in CSS and get the delay from a transition,** by registering `--dsh-scrollbar-thumb` through `@property` so the custom property becomes animatable and a `transition-delay` could hold the colour. Rejected on cost and on reach: the registration is global to every surface that reads the pair, for one column's timing, and the WebKit scrollbar pseudo-elements this palette actually renders through do not reliably transition — the delay would be specified where it cannot be observed.
**Hide the bar itself**`scrollbar-width: none`, or `display: none` on `::-webkit-scrollbar`. Rejected because it takes the reserved band with it: the bar would reappear by re-taking 8px and shift every row sideways under the pointer that revealed it, which is the regression the gutter reservation was added to fix.
**Draw an overlay thumb in the app** and hide the native bar entirely, which is what a fully custom fade would need. It buys arbitrary styling and costs hit-testing, drag, wheel, momentum, and both palettes' hover states — a large owned surface for a cosmetic gain, in a client whose scrollbars are already themed through tokens.
**Scope the reveal to the scrolling list rather than the column.** Fewer elements involved, and it puts the reveal at the wrong boundary: the pointer reaches the rows last, so the bar would appear after the user is already reading them, and every other scroll region added to the sidebar later would have to opt in by hand.
**Reveal on scroll events too,** so a keyboard- or touch-driven scroll shows the bar. Rejected as drawing an affordance the input that triggered it cannot use; the rows themselves already show that the list moved.
## Consequences
- A list scrolled by keyboard or by a touch drag shows no thumb once the linger passes, since neither leaves a pointer over the column.
- The column starts quiet on a cold load and stays so until the pointer first moves over it. A pointer already parked there when the page loads fires nothing until it moves, which is the browser's rule rather than this shell's.
- An elevated surface nested in the column that rebinds the pair to l2 for its own elevation overrides the quiet state and keeps its bar drawn. Nothing in the sidebar does this today; the settings panel portals out of the column entirely.
- The shell's DOM now carries a state class, so ui-sidebar's shell snapshots pin `quietBars` and a regression in the default state is a snapshot diff rather than something someone has to notice in a screenshot.
## Testing
`packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx` drives the class through the transitions with fake timers: revealed on entry, still revealed one millisecond before the linger closes, quiet one millisecond after, and cancelled by a return within the window. It also unmounts mid-linger and asserts no timer survives — a pending hide firing into a dead component is the failure this shape is prone to. The events are `pointerover`/`pointerout` carrying a `relatedTarget`, because React synthesizes enter and leave from those and ignores the raw ones.
`packages/client/ui-sidebar/tests/scrollbar-quiet-styles.spec.ts` reads the sheet: the rule states both halves of the pair — rebinding the resting thumb alone would leave the hover colour painting the moment the pointer reached the bar — and states no `scrollbar-gutter`, which belongs to the scrolling region.
`apps/web/tests/sidebar-scrollbar.e2e.ts` is where the two halves meet a real engine. It parks the pointer over the list before every colour reading, since a scenario that never moves the mouse would measure the quiet state throughout and read as vacuous green. Its own test then moves the pointer away, asserts the thumb is still drawn on the leave itself, polls until it resolves to `rgba(0, 0, 0, 0)`, and re-measures the geometry there to prove the reservation held while the bar was hidden. The committed golden records the thumb at both pointer positions in both palettes.
The e2e's control is a mutation, and it needs the plugin's own bundle: dropping `quietBars` from the shell, rebuilding `@deepseek-ai/dsh-client-ui-sidebar` and only then `build:web`, turns that test red on the thumb resolving to `rgb(229, 229, 229)` where it expects `rgba(0, 0, 0, 0)`. Rerunning `build:web` alone exercises a stale bundle and passes with the change removed, which is the trap [the gutter note](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) documented.

View File

@@ -0,0 +1,54 @@
# Agent Note侧边栏的滚动条跟随指针
Status: implemented
[English](2026-08-04-pointer-revealed-sidebar-scrollbars.md) | 中文
## Problem
侧边栏的会话列表只要有十来个会话就会溢出从那一刻起它的滚动条就一直画在那里——所处的这一列大部分时间都是静止的而列表行自己的操作按钮只在悬停时才出现。它是侧边栏里唯一始终常驻的构件而在有人真的伸手去拖它之前它不提供任何可操作性。产品诉求2026-08-04是只在指针位于侧边栏内时才绘制它并留一小段拖尾避免指针路过时它一闪而灭。
## Decision
`SidebarRoot` 跟踪整列上的指针,只要指针不在列内就给根元素挂上 `quietBars` 类。该类选中的规则把 ui-theme 的那组间接变量——`--dsh-scrollbar-thumb``--dsh-scrollbar-thumb-hover`——重新绑定为 `transparent`,于是嵌套在这一列下的每个滚动区域都不绘制滑块。今天这样的区域只有会话列表;将来新增的区域会直接继承这一行为,而不需要逐个接入。
拖尾是 `SCROLLBAR_LINGER_MS = 2000``pointerleave` 启动一个定时器,`pointerenter` 取消尚未触发的定时器,只有定时器真正触发才会把类加回去。指针越过列边界又折返时——绕过一个 portal 菜单,或是奔向某一行时冲过了头——不会看到滑块闪动。
承载指针的是整列,而不是列表。奔向滚动条的指针会先经过 logo 行、New Session 胶囊和搜索框,所以只在列表上显示,会让滚动条等到指针已经落在行中间时才出现。
`transparent` 正是让这次显示不触发任何布局的原因。列表上的 `scrollbar-gutter: stable` 存在的意义就是让行永不移动(见[空槽 Agent Noteagent 决策记录)](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md));重新绑定的只是颜色,那份预留始终有效,所以滑块出现在列表本就为它留出的空间里。
选择这组间接变量而不是给列表加规则,是因为这组变量正是 ui-theme 写明的重新绑定接缝一次声明同时作用于两条渲染路径WebKit 伪元素与 Firefox 的 `scrollbar-color`),而自定义属性会继承——这正是让整列、而不是列内每个滚动区域,成为该状态所有者的原因。
这拓宽了重新绑定契约,因此它的门禁把新的形态明写出来,而不是默许通过:`ui-theme/tests/scrollbar-styles.spec.ts` 现在只接受两种重新绑定目标,即一组 `-l2` token 或 `transparent`,其余一律拒绝——绑回 l1 只是用一个听起来像抬升层级的名字重述基础表面的默认值,而字面颜色则脱离了调色板。
## Alternatives considered
**只用列上的 CSS `:hover`,不引入 JavaScript 状态。** 整套机制只需一条规则,但它表达不出拖尾:指针越过边界的那一帧滑块就会消失,而那恰好是指针正奔向对话区或绕行 portal 菜单的时刻。诉求本身点名了拖尾,只有 hover 的版本读起来就是闪烁。
**留在 CSS 里、用过渡拿到这段延迟**,即通过 `@property` 注册 `--dsh-scrollbar-thumb` 让该自定义属性可动画,再用 `transition-delay` 把颜色按住。因代价与作用范围被否决:这项注册对每个读取这组变量的表面都是全局的,却只为一列的时序服务;而且这套调色板实际渲染所走的 WebKit 滚动条伪元素并不可靠地支持过渡——延迟会被声明在观察不到它的地方。
**直接把滚动条藏掉**——`scrollbar-width: none`,或对 `::-webkit-scrollbar``display: none`。被否决,因为这会连带取消那段预留:滚动条重新出现时要重新占走 8px会把每一行都在显示它的那个指针底下横向推移而这正是当初加入空槽预留所修掉的回归。
**在应用内自绘一个覆盖式滑块**,并彻底隐藏原生滚动条,这是完全自定义淡入淡出所需要的做法。它换来任意样式,代价是命中测试、拖拽、滚轮、惯性以及两套调色板下的 hover 状态——在一个滚动条已由 token 统一主题化的客户端里,为观感付出的是一大片自持表面。
**把显示范围收敛到滚动的列表而不是整列。** 涉及的元素更少,却把显示的边界放错了位置:指针最后才到达行,滚动条会等到用户已经在读这些行时才出现;而且日后加入侧边栏的其他滚动区域都得手工接入。
**滚动事件也触发显示**,让键盘或触摸驱动的滚动同样显示滚动条。被否决,因为那是在为触发它的输入方式画一个它用不上的可供性;行本身已经说明列表移动过了。
## Consequences
- 用键盘或触摸拖动滚动的列表,在拖尾结束后不显示滑块,因为这两种方式都不会把指针留在列上。
- 冷启动时该列处于静默状态,直到指针第一次移到它上面为止。页面加载时就停在那里的指针在移动之前不会触发任何事件,这是浏览器的规则,而非这个外壳的。
- 嵌套在列内、为自身抬升层级把这组变量重新绑定到 l2 的抬升表面,会覆盖静默状态并继续绘制自己的滚动条。今天侧边栏内没有这样的表面;设置面板整个 portal 到了列之外。
- 外壳的 DOM 现在带有一个状态类,因此 ui-sidebar 的外壳快照会钉住 `quietBars`,默认状态出现回归时表现为快照 diff而不是需要有人从截图里看出来的东西。
## Testing
`packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx` 用假定时器把这个类走过各次跃迁:进入时显示,拖尾结束前 1 毫秒仍然显示,结束后 1 毫秒转为静默,以及窗口内折返会取消隐藏。它还在拖尾进行中卸载组件并断言没有定时器存活——待触发的隐藏落到已销毁的组件上,正是这种写法容易犯的错。事件用的是带 `relatedTarget``pointerover``pointerout`,因为 React 由它们合成 enter 与 leave而会忽略原生的那两个事件。
`packages/client/ui-sidebar/tests/scrollbar-quiet-styles.spec.ts` 直接读样式表:该规则必须写出这组变量的两半——只重新绑定静止态滑块,会让指针一碰到滚动条就露出 hover 颜色——并且不得出现 `scrollbar-gutter`,那属于滚动区域自己。
`apps/web/tests/sidebar-scrollbar.e2e.ts` 是两半在真实引擎里汇合的地方。它在每次读取颜色前先把指针停在列表上,因为一个从不移动鼠标的场景全程测到的都是静默状态,会变成空洞的绿。随后它自己的用例把指针移开,断言在 leave 当下滑块仍在绘制,轮询直到它解析为 `rgba(0, 0, 0, 0)`,并在该状态下重新测量几何,以证明滚动条隐藏期间那份预留依然生效。提交的 golden 记录了两套调色板下、两个指针位置上的滑块颜色。
这条 e2e 的对照是一次 mutation而它需要插件自己的产物`quietBars` 从外壳中去掉,先重新构建 `@deepseek-ai/dsh-client-ui-sidebar`、之后再跑 `build:web`,该用例会因为滑块解析为 `rgb(229, 229, 229)`、而期望 `rgba(0, 0, 0, 0)` 而变红。只重跑 `build:web` 用的是过期产物,即使改动已被删除也照样通过,这正是[空槽 Agent Noteagent 决策记录)](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)记录过的陷阱。