The scrollbar tokens get their consumer, and the workspace list reserves its gutter
滚动条 token 有了消费方,工作区列表预留出滚动条空位
`design-platform.css` declares four `--dsw-alias-scrollbar-*` tokens (`bg-l1`, `bg-l2`, `hover-l1`, `hover-l2`) in both palettes, and no rule anywhere in the client read them. A defined token with no consumer is not a theme: every scrolling region rendered the user agent's own scrollbar, which knows nothing about the palette, so the dark theme showed a light native bar against dark surfaces. The visible symptom that
English
Problem
design-platform.css declares four --dsw-alias-scrollbar-* tokens (bg-l1, bg-l2, hover-l1, hover-l2) in both palettes, and no rule anywhere in the client read them. A defined token with no consumer is not a theme: every scrolling region rendered the user agent's own scrollbar, which knows nothing about the palette, so the dark theme showed a light native bar against dark surfaces.
The visible symptom that surfaced the gap was elsewhere. The workspace browser's session list (.list in WorkspaceBrowser.module.css) is the sidebar's only scrolling region, and each row's trailing content sits flush against the row's 8px right padding — .time in rows/Rows.module.css is flex: none, as are the action buttons that replace it on hover. An overlaid scrollbar therefore painted on top of the relative timestamp. Reserving space in that one list would have left the bar itself unthemed, so the two halves are one change.
Decision
packages/client/ui-theme/src/styles/scrollbar.css is the sole consumer of the four tokens and the third global sheet imported by ui-theme's dynamic client entry. It follows design-platform.css there because it reads that sheet's tokens; both compile into ui-theme's plugin-owned client bundle.
The rules sit on body, not html. design-platform.css declares the --dsw-alias-* tokens on body, with the dark overrides on body[data-ds-dark-theme], and custom properties inherit only downward; an html rule resolves them to the guaranteed-invalid value, at which point scrollbar-color computes to auto and no theming happens at all.
scrollbar-width and scrollbar-color are declared on body, body * rather than once at the top. Inheritance would pass down the color already substituted at body, so a descendant rebinding the indirection could not change its own scrollbar; re-declaring makes each element substitute the variable as it sees it. scrollbar-width is not an inherited property in the first place, so it needs the per-element declaration regardless. The ::-webkit-scrollbar* pseudo-elements are likewise not inherited and are matched unscoped.
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. transparent is the pair's other legal target, added when the sidebar's bars started following the pointer; the gate below admits those two and nothing else. The mechanically discoverable subset is owned by packages/client/ui-theme/tests/scrollbar-styles.client.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 initially missed, which is why the per-sheet rebinding contract is checked mechanically rather than by inspection.
The elevated set is resolved from the palette's own dark elevation ladder — the surface tokens whose dark value lands on bg-layer-2 or bg-layer-3, which is the step the l1/l2 split encodes. Deriving it instead from the sheets that already rebind was the first attempt and is unsound: such a set can only confirm what someone already remembered, and a surface nobody has rebound yet — exactly the case the check exists for — defines itself as unelevated. --dsw-specific-tip proved it, resolving to the menu surface's rung while the todo panel scrolled on it unrebound and the derived check stayed green.
Scope is by token family, not by geometry: only --dsw-alias-bg-* and --dsw-specific-* name a surface. --dsw-alias-button-*, --dsw-alias-interactive-*, and --dsw-alias-markdown-* reach the same rungs while naming a control or an inline span that no scroll container renders its bar against. Shape cannot make that call, since a floating button legitimately carries a radius, a shadow, and a fixed size. The check is per sheet rather than per rule because the card and the descendant that scrolls are separate rules. That approximation cannot detect a scrolling component embedded in an elevated card painted by another package's stylesheet, as DirectoryBrowser inside Modal demonstrated; cross-sheet composition remains a review and assembled-UI responsibility.
The track and the corner stay transparent, so the thumb reads against whatever surface scrolls under it; only the thumb and its hover state carry a token color.
.list declares scrollbar-gutter: stable, which keeps the bar beside the rows instead of on top of them. stable rather than auto because auto reserves the gutter only while the list actually overflows: expanding a workspace group would then shift every row horizontally at the moment it starts scrolling. The reservation is unconditional and the rows never move.
The gutter and the sheet's ::-webkit-scrollbar width are jointly necessary against an overlay scrollbar, which is the configuration where the symptom exists at all. Measured on the running app by deleting each from the live cascade with the other left in force: either deletion alone takes the list's band from 8 to 0. The gutter states that space be reserved, and the pseudo-element width is what makes chromium treat the bar as occupying layout space rather than floating over the content. Neither half of this change is therefore optional for the reported bug, which is a second reason the two halves ship together.
Alternatives considered
Per-module ::-webkit-scrollbar rules in each scrolling component sheet. Rejected: the client has thirteen scrolling containers across nine packages, every one would carry the same block, and the fourteenth would ship unthemed with nothing failing. A skin driven by design tokens belongs in the package that owns the tokens.
An opt-in utility class that each scroll container adds. Same duplication removed, but the failure mode stays: a new scroll container is themed only if its author remembers the class, and the omission is invisible in review. The body, body * form has no opt-in step to forget; a container that genuinely wants a different bar overrides the indirection, which is the same mechanism elevated surfaces use.
Bind the properties on html. The natural place for a document-wide skin, and it fails measurably: with the rule on html a scroll container computes scrollbar-color: auto in chromium, because the alias tokens are not in scope there.
Declare the properties once and let them inherit. Fewer matched elements, and it breaks the rebinding contract — inheritance carries the substituted color, not the variable reference, so an elevated surface could not retint its own scrollbar. It is also incomplete on its own terms, since scrollbar-width does not inherit.
Declare the standard properties and the pseudo-elements unconditionally, without the @supports gate. Measured in chromium on probe elements with scrollbar-gutter: stable so the band is observable: an 8px ::-webkit-scrollbar alone reserved a 30px band (the sheet's width plus the UA's buttons), and adding scrollbar-width: thin to the same element dropped it to the 10px thin reserves — the pseudo-element rules were being discarded, not merged. Every ::-webkit-scrollbar-thumb:hover rule went with them, so both hover tokens and all four elevated surfaces' hover rebinds were dead code on the engine most users run.
Gate the WebKit rules too, behind @supports selector(::-webkit-scrollbar). Symmetrical to read, and wrong in one direction: it would hide the rules from an engine that implements the pseudo-elements but not selector(), which is the pre-16.4 Safari the ungated form serves correctly. Unknown selectors are already dropped, so the gate adds no protection to pay for that.
Pad the rows instead of reserving the gutter (extra right padding on .list, or moving .time inward). Rejected: padding applies whether or not a bar is present, so it costs horizontal room in the common short-list case, and it fixes exactly one container while leaving every other scrolling region's content under its bar.
scrollbar-gutter: auto on .list. The reservation appears when the list overflows, which is when the bar exists. Rejected because the sidebar's lists grow and shrink as groups expand, so the reservation would appear and disappear under the user's cursor and shift the rows with it.
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, andrgb(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 throughscrollbar-colorand 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 ofscrollbar-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.- The workspace list is permanently narrower by the reserved band, at every list length. That is the trade the fix buys: stable row geometry instead of a timestamp that is legible only while the list is short.
- There is no track token in the palette, so a design that later wants an opaque track needs a new alias token rather than a literal color in this sheet.
Testing
Three unit specs read the CSS text on disk. ui-theme/tests/scrollbar-styles.spec.ts scans the scrollbar token set out of design-platform.css rather than hardcoding it, so adding, renaming, or dropping a token moves the assertions with it, and checks that every token has a consumer and that each elevated surface rebinds a complete pair. It also pins the path split by source offset: the standard properties inside the gate block, the ::-webkit-scrollbar* rules and every read of the hover indirection outside it. That split needs an offset assertion because the spec's rule parser flattens through at-rules, so a gate deleted or a declaration moved across it leaves every other assertion in the file green.
apps/web/tests/sidebar-scrollbar.e2e.ts covers the facts only a real engine reports: the reserved band width, and which rendering path the engine took. It needs no model calls — the list only has to overflow — so it seeds cold sessions from an existing committed fixture read-only.
That scenario also commits a golden, snapshots/sidebar-scrollbar/geometry.expected.md, holding the resolved scrollbar style and geometry in both palettes. The aria goldens the other web scenarios commit cannot carry a CSS-only change: it alters no DOM and no accessible name, so their normalized trees are byte-identical with and without it. Recording the resolved values instead makes an unintended shift in thumb colour, band width, or rendering path a reviewable diff rather than a threshold someone has to reason about. Absolute coordinates are deliberately excluded — timeRight and the two edges depend on the sidebar's laid-out width and on font metrics, so committing them would produce a fixture that has to be re-recorded per platform and would document the platform rather than the change. What is recorded is the band, the overlap, and two orderings, each a difference or a comparison that survives any layout preserving the reservation.
Confirmed in headless chromium on the built client by reading computed values, which is what distinguishes a working token chain from a syntactically valid one: a scroll container computes the l1 thumb color in each palette, and a container that rebinds the indirection computes the l2 color, proving the rebind reaches the computed value rather than only the custom property. Firefox was verified the same way for the standard path, including the l1-to-l2 rebind on scrollbar-color; headless Firefox reports scrollbar-width: none on every element, styled or not, which is a headless artifact rather than an effect of the sheet.
Two chromium measurement limits shape what the e2e can assert. The gate makes chromium report scrollbar-width and scrollbar-color as auto, so the substituted scrollbar-color is no longer the observable — the e2e asserts the auto reading deliberately, since a concrete value there would mean the gate leaked and silenced the pseudo-elements. And getComputedStyle(el, '::-webkit-scrollbar-thumb') folds in the ::-webkit-scrollbar-thumb:hover rule, so it reports the hover color at rest and pins neither state; proven by deleting the hover rule through CSSStyleSheet.deleteRule in the live page, which flipped that same query from the hover color to the resting one. The e2e therefore reads the resting and hover colors as the indirection variables resolve on the list — one throwaway probe element per variable, because getComputedStyle returns a live declaration and a reused probe reports only the last value read — and reads the hover declaration out of the cascade as rule text.
The gate itself has a negative control at the level it operates on: removing the @supports wrapper from the sheet, rebuilding build:web, and rerunning the e2e turns the scrollbar-width: auto assertion red with thin, which is the suppression the gate exists to prevent.
Headless chromium draws overlay scrollbars, and that is the configuration in which the reported symptom exists, so the e2e reproduces the bug rather than approximating it: against clean master the list's band is 0 and the bar covers 7px of the relative time. A reserved gutter there does not shrink clientWidth, so an assertion comparing the time element's right edge against the client-area edge holds with and without the reservation and would pass or fail on the platform's scrollbar style rather than on the declaration under test. The two signals that do separate the states are the offsetWidth - clientWidth band and timeCoveredBy, the overlap measured against the bar's own width.
Both are asserted because each catches a different regression, established by mutating one declaration at a time with the other assertions in that test silenced. Removing only the gutter leaves timeCoveredBy at 0 — the bar is then 8px and the row's right padding is also 8px, so it abuts the timestamp without covering it — and the band assertion is what fails. Removing the pseudo-element width as well, which is the actual master state, produces the overlap, and timeCoveredBy fails at 7. A headed run under xvfb cannot show the symptom in either state, because chromium paints a classic space-consuming bar there and clientWidth already excludes it.
Verifying browser-visible plugin CSS needs a rebuild pnpm run build:web does not perform. WorkspaceBrowser.module.css never reaches apps/web/dist: ui-workspace loads as a runtime plugin and its CSS is inlined into packages/client/ui-workspace/lib/client.js, built by that package's own bundle script. A negative control that reruns only build:web therefore exercises a stale bundle and passes with the declaration removed, which reads as a vacuous test rather than as an invalid control. Rebuild with pnpm --filter @deepseek-ai/dsh-client-ui-workspace run bundle, confirm the artifact by grepping lib/client.js for the declaration, then build:web.
test:web ran build:web alone, so every scroll-region or plugin-CSS change hit that trap; it now runs build first, which covers packages/*/* and so rebuilds the plugin bundles. check-all already ordered build before build:web, so CI was never exposed — only the local script was, which is exactly where a stale-bundle pass is most likely to be believed.
中文
问题
design-platform.css 在亮色与暗色两套调色板中都声明了四个 --dsw-alias-scrollbar-* token(bg-l1、bg-l2、hover-l1、hover-l2),而客户端里没有任何一条规则读取它们。定义了却无人消费的 token 构不成主题:所有滚动区域渲染的都是浏览器自带的滚动条,它对调色板一无所知,因此暗色主题下暗色表面上出现的是一条亮色的原生滚动条。
暴露这一缺口的可见症状出在别处。工作区浏览器的会话列表(WorkspaceBrowser.module.css 中的 .list)是侧边栏里唯一的滚动区域,而每一行的尾部内容都紧贴该行 8px 的右内边距——rows/Rows.module.css 中的 .time 取 flex: none,hover 时取代它的操作按钮也是如此。于是覆盖式滚动条会画在相对时间戳之上。只在这一个列表里预留空间,滚动条本身仍然没有主题,因此两部分合为一次变更。
决策
packages/client/ui-theme/src/styles/scrollbar.css 是这四个 token 的唯一消费方,也是 ui-theme 动态客户端 entry 导入的第三张全局样式表。它排在 design-platform.css 之后,因为它读取那张样式表的 token;两者都会编译进 ui-theme 持有的客户端 bundle。
规则挂在 body 上,而非 html。design-platform.css 在 body 上声明 --dsw-alias-* token,暗色覆盖挂在 body[data-ds-dark-theme] 上,而自定义属性只向下继承;挂在 html 上的规则会把它们解析为 guaranteed-invalid 值,此时 scrollbar-color 计算为 auto,主题完全不起作用。
scrollbar-width 与 scrollbar-color 声明在 body, body * 上,而不是只在顶层声明一次。继承传下去的是已经在 body 处代入完成的颜色值,因此后代元素重新绑定这层间接变量也无法改变自己的滚动条;逐元素重新声明使每个元素按它自己看到的取值代入变量。scrollbar-width 本身就不是可继承属性,无论如何都需要逐元素声明。::-webkit-scrollbar* 伪元素同样不继承,因此以不加限定的选择器匹配。
两种渲染互斥,而这种互斥是被强制的,不是假定的。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 上。这组变量另一个合法的目标是 transparent,它随侧边栏滚动条改为跟随指针一并引入;下文的门禁只接受这两种目标。可由机械检查发现的子集归 packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。
Menu、InputBar、QuestionComposer 与 TodoPanel 这四个表面最初被漏掉,因此逐样式表的重新绑定约定由机械检查而非人工审阅把关。
抬升表面集合是从调色板自身的暗色抬升阶梯解析出来的——暗色取值落在 bg-layer-2 或 bg-layer-3 上的那些表面 token,而这一档正是 l1/l2 之分所编码的层级差。最初的做法是从已经做了重新绑定的样式表反向推导,那是不成立的:这样得到的集合只能确认别人已经记得的部分,而尚无人重新绑定的表面——恰恰就是这项检查存在的理由——会把自己定义成「非抬升」。--dsw-specific-tip 证明了这一点:它解析到与菜单表面相同的那一档,待办面板在它上面滚动却没有重新绑定,而推导式的检查依然是绿的。
判定范围依据 token 家族而非几何形状:只有 --dsw-alias-bg-* 与 --dsw-specific-* 表述的是表面。--dsw-alias-button-*、--dsw-alias-interactive-* 与 --dsw-alias-markdown-* 会落到相同档位,但它们表述的是控件或行内片段,没有任何滚动容器会把滚动条画在它们之上。形状无法做这个判断,因为悬浮按钮本来就会带圆角、阴影和固定尺寸。这项检查以样式表为粒度而非以规则为粒度,因为卡片与真正滚动的后代元素是两条不同的规则。这种近似检查无法检测嵌在由另一个包的样式表绘制的抬升卡片中的滚动组件,Modal 内的 DirectoryBrowser 就证明了这一点;跨样式表的组合仍需在评审和组装后 UI 层面把关。
轨道与两条滚动条相交的角落保持透明,因此滑块是以其下滚动的任何表面为背景被看到;只有滑块及其 hover 状态带 token 颜色。
.list 声明 scrollbar-gutter: stable,使滚动条位于行的旁边而非行的上方。取 stable 而非 auto,因为 auto 只在列表确实溢出时才预留空位:那样展开一个工作区分组时,所有行会在列表开始滚动的那一刻发生水平位移。stable 的预留是无条件的,行不会移动。
面对覆盖式滚动条——也就是这个症状唯一存在的那种形态——空位声明与样式表里的 ::-webkit-scrollbar 宽度是共同必要的。在运行中的应用上实测:保留其中一条、从活的层叠中删掉另一条,任意一次单独删除都会让列表的条带从 8 降到 0。空位声明表述的是「要预留空间」,而伪元素宽度才是让 chromium 把滚动条视为占据布局空间、而不是浮在内容之上的原因。因此对这个 bug 而言,本次变更的两半都不是可选项,这也是两半必须一起交付的第二个理由。
曾考虑的替代方案
在每个滚动组件的样式表里各写一份 ::-webkit-scrollbar 规则。 之所以否决:客户端共有分布在九个包中的十三个滚动容器,每一个都要带上同一段规则,而第十四个会在没有任何门禁报错的情况下漏掉主题。由设计 token 驱动的皮肤应当归属于拥有这些 token 的包。
提供一个工具类,由各滚动容器自行加上。 重复同样被消除,但失败方式依旧存在:新的滚动容器只有在作者记得加类名时才有主题,而遗漏在评审中看不出来。body, body * 这种写法没有需要记住的启用步骤;确实想要不同滚动条的容器可以覆盖间接变量,这与抬升表面使用的机制相同。
把这两个属性绑定在 html 上。 这是文档级皮肤最自然的落点,而它的失败是可测量的:规则挂在 html 上时,chromium 中滚动容器计算出的 scrollbar-color 为 auto,因为别名 token 在那个作用域内不存在。
只声明一次,靠继承下传。 匹配的元素更少,但它破坏重新绑定约定——继承携带的是代入后的颜色,而不是变量引用,因此抬升表面无法给自己的滚动条换色。它本身也不完整,因为 scrollbar-width 不继承。
不加 @supports 门禁,无条件同时声明标准属性与伪元素。 在 chromium 中于带 scrollbar-gutter: stable(使条带可观测)的探针元素上实测:单独一条 8px 的 ::-webkit-scrollbar 预留出 30px 条带(样式表指定的宽度加上浏览器自带的按钮),而给同一元素加上 scrollbar-width: thin 后降到 thin 所预留的 10px——说明伪元素规则是被丢弃,而不是被合并。全部 ::-webkit-scrollbar-thumb:hover 规则随之失效,因此两个 hover token 与四处抬升表面的 hover 重新绑定,在多数用户实际使用的引擎上都是死代码。
给 WebKit 规则也加门禁,写成 @supports selector(::-webkit-scrollbar)。 读起来对称,但在一个方向上是错的:它会对「实现了伪元素但不支持 selector()」的引擎隐藏这些规则,而那正是不加门禁时能被正确服务的 16.4 之前的 Safari。未知选择器本就会被丢弃,因此这道门禁不提供任何能抵偿该代价的保护。
改用内边距而不是预留空位(给 .list 加右内边距,或把 .time 向内移)。 之所以否决:内边距无论滚动条是否存在都生效,因此在常见的短列表情形下白白占用横向空间;而且它只修好一个容器,其余每个滚动区域的内容仍然压在滚动条之下。
给 .list 用 scrollbar-gutter: auto。 空位在列表溢出时出现,也就是滚动条存在的时候。之所以否决:侧边栏的列表会随分组展开与收起而伸缩,因此空位会在用户光标之下出现又消失,并带动行一起位移。
后果
- 客户端的每个滚动容器都绘制带主题的滑块:亮色基础表面为
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 *匹配所有元素,涉及的两个属性其效果本就被浏览器限制在实际会滚动的元素上。代价是一个覆盖面很宽的选择器;另一种选择是一个不生效的重新绑定约定。- 工作区列表在任何列表长度下都永久少了预留空位那一条宽度。这正是该修复换来的代价:以稳定的行几何,换掉只在列表较短时才可读的时间戳。
- 调色板中没有轨道 token,因此日后若设计需要不透明轨道,要新增一个别名 token,而不是在这张样式表里写字面颜色。
测试
三份单元测试读取磁盘上的 CSS 文本。ui-theme/tests/scrollbar-styles.spec.ts 从 design-platform.css 中扫描出滚动条 token 集合,而不是把它写死,因此新增、重命名或删除 token 时断言会随之变化;它检查每个 token 都有消费方,且每处抬升表面重新绑定的都是完整的一对。它还以源码偏移量锁定两条路径的划分:标准属性在门禁块之内,::-webkit-scrollbar* 规则与每一处对 hover 间接变量的读取都在门禁块之外。这个划分必须用偏移量断言,因为该测试文件的规则解析器会把 at-rule 拉平,所以删掉门禁或把某条声明移到门禁另一侧,文件里其余全部断言仍然是绿的。
apps/web/tests/sidebar-scrollbar.e2e.ts 覆盖只有真实渲染引擎才能报告的事实:预留条带的宽度,以及引擎实际走的是哪条渲染路径。它不需要任何模型调用——列表只要溢出即可——因此以只读方式复用一份既有的已提交 fixture(测试前置数据)来铺入冷会话。
这个场景还提交了一份 golden(期望产物)snapshots/sidebar-scrollbar/geometry.expected.md,记录两套调色板下解析后的滚动条样式与几何。其余 web 场景提交的 aria golden 承载不了纯 CSS 的改动:它不改变任何 DOM、也不改变任何无障碍名称,因此有无这次改动,它们规范化后的树都是逐字节相同的。改为记录解析后的取值,就让滑块颜色、条带宽度或渲染路径的意外变化成为可评审的 diff,而不是一条需要人去推敲的阈值断言。绝对坐标被特意排除——timeRight 与两条边缘取决于侧边栏排版后的宽度和字体度量,把它们提交进去会得到一份需要按平台重新录制的 fixture,那记录的是平台而不是这次改动。真正记录下来的是条带、重叠量与两个先后关系,每一项都是差值或比较,因此只要预留仍然成立,任何排版下都不变。
在构建产物客户端上于 headless chromium 中读取计算值确认,这正是区分「token 链真正生效」与「语法合法」的手段:滚动容器在两套调色板下分别计算出 l1 的滑块颜色,而重新绑定间接变量的容器计算出 l2 的颜色,证明重新绑定作用到了计算值,而不只是作用到自定义属性上。Firefox 的标准属性路径以同样方式做了验证,包含 scrollbar-color 上从 l1 到 l2 的重新绑定;headless Firefox 对任何元素(无论是否被样式命中)都报告 scrollbar-width: none,这是 headless 的产物,不是这张样式表造成的。
chromium 上有两处测量限制决定了 e2e 能断言什么。门禁使 chromium 报告的 scrollbar-width 与 scrollbar-color 都是 auto,因此代入后的 scrollbar-color 不再是可观测量——e2e 特意断言这个 auto 读数,因为此处出现具体值就意味着门禁泄漏、伪元素被静音。另外,getComputedStyle(el, '::-webkit-scrollbar-thumb') 会把 ::-webkit-scrollbar-thumb:hover 规则一并折算进去,因此它在静止态就报告 hover 颜色,两种状态都锁不住;这一点由在运行中的页面里用 CSSStyleSheet.deleteRule 删掉 hover 规则得证——同一查询随之从 hover 颜色翻转为静止态颜色。因此 e2e 改为读取那组间接变量在列表上代入后的静止态与 hover 颜色(每个变量用一个一次性探针元素,因为 getComputedStyle 返回的是活的声明对象,复用探针只会报告最后一次读到的值),并把 hover 声明当作规则文本从层叠中读出。
门禁本身在它起作用的层面有反向对照:把样式表中的 @supports 包裹去掉、重新 build:web、再跑 e2e,scrollbar-width: auto 那条断言会以 thin 变红,而这正是门禁存在所要阻止的那种静音。
headless chromium 绘制的是覆盖式滚动条,而这恰好就是被报告症状存在的那种形态,因此这个 e2e 复现的是这个 bug 本身,而不是它的近似:在干净的 master 上,列表条带为 0,滚动条盖住相对时间 7px。其中预留空位不会缩小 clientWidth,因此把时间元素右边缘与内容区右边缘做比较的断言在有无预留的两种状态下都成立,它的通过或失败取决于平台的滚动条样式,而不是取决于被测的那条声明。真正能区分两种状态的两个量是 offsetWidth - clientWidth 条带,以及以滚动条自身宽度为基准量出的重叠量 timeCoveredBy。
两者都要断言,因为各自捕捉的是不同的回归;这一点通过每次只改动一条声明、并把同一个测试里的其余断言静音来确定。只删掉空位声明时 timeCoveredBy 仍为 0——此时滚动条是 8px,而行的右内边距也是 8px,于是它紧贴时间戳但并未盖住——失败的是条带那条断言。再把伪元素宽度也删掉(这才是 master 的真实状态)才会产生重叠,此时 timeCoveredBy 以 7 变红。在 xvfb 下的有头运行无论哪种状态都看不到这个症状,因为 chromium 在那里画的是经典占位滚动条,clientWidth 本来就已经把它排除了。
验证浏览器可见的插件 CSS 需要一次 pnpm run build:web 并不执行的重建。WorkspaceBrowser.module.css 从不进入 apps/web/dist:ui-workspace 以运行时插件方式加载,其 CSS 内联进 packages/client/ui-workspace/lib/client.js,由该包自己的 bundle 脚本构建。因此只重跑 build:web 的反向对照实际测的是旧产物,去掉声明后仍会通过,看起来像测试无效,实际是对照无效。正确做法是先 pnpm --filter @deepseek-ai/dsh-client-ui-workspace run bundle,用 grep 在 lib/client.js 中确认该声明确实存在或消失,然后再 build:web。
test:web 原先只运行 build:web,因此任何滚动区域或插件 CSS 的改动都会碰到这个陷阱;现在它先运行 build,而 build 覆盖 packages/*/*,从而会重建各插件产物。check-all 本来就把 build 排在 build:web 之前,所以 CI 从未受影响——受影响的只有本地脚本,而这恰恰是「产物过期却通过」最容易被当真的地方。