fix(ui-theme): gate the standard scrollbar properties behind the missing WebKit pseudo-element

A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari
discard every `::-webkit-scrollbar*` rule for that element, including
`::-webkit-scrollbar-thumb:hover`. Declaring both unconditionally left the
hover tokens rendering nowhere: the engines implementing the hover
pseudo-element are exactly the ones the standard properties silence, and
Firefox has no hover pseudo-element to fall back on. Both hover tokens and all
four elevated surfaces' hover rebinds were therefore dead code.

Measured in chromium on probe elements with `scrollbar-gutter: stable`: an 8px
`::-webkit-scrollbar` alone reserved a 30px band, and adding
`scrollbar-width: thin` dropped it to the 10px `thin` reserves.

The standard properties now sit inside `@supports not
selector(::-webkit-scrollbar)`, so Firefox takes them and WebKit-based engines
take the pseudo-elements. The WebKit rules stay ungated: an engine without
those pseudo-elements drops them as unknown selectors, and gating them would
hide them from an engine that implements them without `selector()` — the
pre-16.4 Safari the ungated form serves correctly.

Three unit assertions pin the split by source offset, which the existing
at-rule-flattening parser cannot see. The web e2e now reads the path chromium
actually takes: the `auto` standard properties as the gate's signature, the
pseudo-element sizing and track, the indirection variables resolved per
throwaway probe, and the hover declaration as cascade rule text — chromium
folds the `:hover` rule into `getComputedStyle(el,
'::-webkit-scrollbar-thumb')`, so no computed query separates the states.
This commit is contained in:
Chinesezjc
2026-07-28 15:02:20 +08:00
parent a6b0cd6f8d
commit c3e9690b2e
9 changed files with 251 additions and 80 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 packages/client/ui-theme/README.md
README.md: 9bf232d506c599a6302c04d5769b43993d84dbf6
README.zh.md: 84dba38d751b74c13f4af42c40484995900dfc12
README.md: a1ff7d840dae86f5da98de1208ecda3b8b62026b
README.zh.md: 49b52bcb1e07527e98c602086404228c5513091a

View File

@@ -6,7 +6,9 @@ Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both the standard `scrollbar-color` and the `::-webkit-scrollbar-thumb` rules read that pair. An elevated surface (menu, popover, dialog) 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; one rebind retints both renderings. Reasoning and the measured computed values: [the scrollbar Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md).
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) 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; one rebind retints whichever path the engine took.
The two paths are mutually exclusive by construction. `scrollbar-width`/`scrollbar-color` sit inside `@supports not selector(::-webkit-scrollbar)` because a non-`auto` value of either makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included — declaring both unconditionally leaves `--dsh-scrollbar-thumb-hover` with no rendering anywhere. Firefox therefore takes the standard properties and WebKit-based engines take the pseudo-elements, so the hover token only ever renders through the pseudo-element path. Reasoning and the measured computed values: [the scrollbar Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md).
## Model Experience

View File

@@ -6,7 +6,9 @@
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css``design-platform.css``scrollbar.css``gradient-shadow-text.css``shiki.css``scrollbar.css``--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
滚动条重新绑定契约:`scrollbar.css``body` 上把 `--dsh-scrollbar-thumb``--dsh-scrollbar-thumb-hover` 绑定到 l1基础表面token标准属性 `scrollbar-color``::-webkit-scrollbar-thumb` 规则都读取这一组变量。抬升表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)``--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为两种渲染同时换色。推理过程与实测计算值见[滚动条 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)
滚动条重新绑定契约:`scrollbar.css``body` 上把 `--dsh-scrollbar-thumb``--dsh-scrollbar-thumb-hover` 绑定到 l1基础表面token两条渲染路径都读取这一组变量。抬升表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)``--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色
两条路径在构造上互斥。`scrollbar-width``scrollbar-color` 写在 `@supports not selector(::-webkit-scrollbar)` 之内,因为这两个属性只要取非 `auto`Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中——若无条件地同时声明,`--dsh-scrollbar-thumb-hover` 在任何引擎上都不会被渲染。因此 Firefox 走标准属性WebKit 系引擎走伪元素hover token 只经由伪元素这条路径渲染。推理过程与实测计算值见[滚动条 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。
## 模型体验

View File

@@ -2,50 +2,69 @@
* tokens. Without it every scrolling region renders the UA scrollbar, which
* ignores the theme — a light native bar over the dark palette.
*
* The rule sits on `body`, not `html`: design-platform.css declares the
* The rules sit on `body`, not `html`: design-platform.css declares the
* --dsw-alias-* tokens on `body` (and the dark overrides on
* `body[data-ds-dark-theme]`), and custom properties only inherit downward,
* so an `html` rule resolves them to the guaranteed-invalid value and
* `scrollbar-color` falls back to `auto`.
*
* `scrollbar-color` is an inherited property, so binding it once on `body`
* reaches every scroll container without enumerating module class names.
* `scrollbar-width` is NOT inherited, so it is applied to all elements.
* The WebKit pseudo-elements are not inherited either, hence the unscoped
* `::-webkit-scrollbar` rules.
*
* Surfaces pick their elevation by rebinding --dsh-scrollbar-thumb{,-hover}:
* the l1 pair here is the base-surface default, and an elevated surface
* (menu, popover, dialog) rebinds to the l2 pair on its own container. Both
* the standard properties and the WebKit pseudo-elements read the
* indirection, so one rebind reaches both renderings. */
* rendering paths below read the indirection, so one rebind reaches whichever
* path the engine took. */
body {
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1);
}
/* `scrollbar-color` and `scrollbar-width` are declared on every element
rather than inherited from `body`. Inheriting would pass down the COLOUR
already substituted at `body`, so a descendant rebinding
--dsh-scrollbar-thumb could not change it; re-declaring makes each element
substitute the variable as it sees it, which is what gives an elevated
surface a working rebind. `scrollbar-width` is not an inherited property
at all, so it needs the per-element declaration regardless.
/* The two paths are mutually exclusive, and the gate is load-bearing rather
than defensive. A non-`auto` `scrollbar-width` or `scrollbar-color` makes
Chromium and Safari drop every `::-webkit-scrollbar*` rule for that
element, including `::-webkit-scrollbar-thumb:hover` — measured in chromium
as an 8px `::-webkit-scrollbar` width taking effect on its own and being
ignored as soon as `scrollbar-width: thin` is added. Declaring both
unconditionally therefore leaves the hover tokens with no rendering at all,
because 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.
Track stays transparent so the thumb reads against whatever surface
scrolls under it; only the thumb carries a token colour. */
body,
body * {
scrollbar-width: thin;
scrollbar-color: var(--dsh-scrollbar-thumb) transparent;
`not selector(::-webkit-scrollbar)` is true only where the pseudo-element
is unimplemented, so Firefox takes the standard path and WebKit-based
engines take the pseudo-element path. 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. */
@supports not selector(::-webkit-scrollbar) {
/* Declared on every element rather than inherited from `body`. Inheriting
would pass down the COLOUR already substituted at `body`, so a descendant
rebinding --dsh-scrollbar-thumb could not change it; re-declaring makes
each element substitute the variable as it sees it, which is what gives
an elevated surface a working rebind. `scrollbar-width` is not an
inherited property at all, so it needs the per-element declaration
regardless.
No hover counterpart exists on this path: `scrollbar-color` states one
thumb colour and the engine derives its own hover treatment. */
body,
body * {
scrollbar-width: thin;
scrollbar-color: var(--dsh-scrollbar-thumb) transparent;
}
}
/* Not gated in turn: an engine that does not implement these pseudo-elements
drops the rules as unknown selectors, so the gate would only restate what
selector matching already does. Not inherited either, hence the unscoped
selectors. */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
/* Track stays transparent so the thumb reads against whatever surface scrolls
under it; only the thumb carries a token colour. */
::-webkit-scrollbar-track {
background: transparent;
}

View File

@@ -58,6 +58,27 @@ function parseRules(css: string): CssRule[] {
return rules
}
/**
* Half-open source span of one at-rule's block, excluding its prelude.
* @param css - stylesheet text.
* @param prelude - exact at-rule prelude to locate, without the opening brace.
* @returns the block's brace offsets, or undefined when the prelude is absent.
*/
function atRuleBlock(css: string, prelude: string): { start: number; end: number } | undefined {
const opening = css.indexOf(`${prelude} {`)
if (opening === -1) return undefined
const start = css.indexOf('{', opening)
let depth = 0
for (let index = start; index < css.length; index += 1) {
if (css[index] === '{') depth += 1
else if (css[index] === '}') {
depth -= 1
if (depth === 0) return { start, end: index }
}
}
throw new Error(`unbalanced braces after ${prelude}`)
}
/**
* Custom-property names a value reads.
* @param value - declaration value, possibly with nested var() calls.
@@ -265,6 +286,60 @@ describe('scrollbar.css selectors', () => {
})
})
describe('scrollbar.css rendering paths', () => {
/** The gate prelude, spelled exactly as the sheet must spell it for the split to exist. */
const GATE = '@supports not selector(::-webkit-scrollbar)'
const withoutComments = scrollbarCss.replace(/\/\*[\s\S]*?\*\//g, ' ')
const gate = atRuleBlock(withoutComments, GATE)
/** Standard scrollbar properties, the ones whose non-`auto` values suppress the pseudo-elements. */
const STANDARD_PROPERTIES = ['scrollbar-width', 'scrollbar-color']
it('gates the standard properties behind the absence of the WebKit pseudo-element', () => {
// 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 paths
// unconditionally therefore renders the hover token nowhere: the engines
// implementing the hover pseudo-element are exactly the ones the standard
// properties silence, and Firefox has no hover pseudo-element at all.
expect(gate, GATE).toBeDefined()
for (const property of STANDARD_PROPERTIES) {
const offsets = [...withoutComments.matchAll(new RegExp(String.raw`(^|[;{\s])${property}\s*:`, 'g'))]
.map(match => match.index)
expect(offsets.length, property).toBeGreaterThan(0)
for (const offset of offsets) {
expect(offset, `${property} outside ${GATE}`).toBeGreaterThan(gate!.start)
expect(offset, `${property} outside ${GATE}`).toBeLessThan(gate!.end)
}
}
})
it('leaves the WebKit pseudo-element rules outside the gate', () => {
// Gating these in turn would only restate selector matching: an engine
// without the pseudo-elements drops the rules as unknown selectors. Inside
// the gate they would be dropped by the engines that do implement them,
// which is every engine that can render them.
const offsets = [...withoutComments.matchAll(/::-webkit-scrollbar/g)]
.map(match => match.index)
.filter(offset => withoutComments.slice(offset).search(/^[\w:-]*\s*[,{]/) === 0)
expect(offsets.length).toBeGreaterThan(0)
for (const offset of offsets) {
expect(offset > gate!.start && offset < gate!.end, `::-webkit-scrollbar rule inside ${GATE}`).toBe(false)
}
})
it('renders the hover token only through the pseudo-element path', () => {
// The standard path has no hover counterpart — scrollbar-color states one
// thumb colour and the engine derives its own hover treatment — so the
// hover indirection has to be read outside the gate or it renders nowhere.
const hoverOffsets = [...withoutComments.matchAll(new RegExp(String.raw`var\(\s*${INDIRECTION_PREFIX}thumb-hover`, 'g'))]
.map(match => match.index)
expect(hoverOffsets.length).toBeGreaterThan(0)
for (const offset of hoverOffsets) {
expect(offset > gate!.start && offset < gate!.end, 'hover indirection read inside the gate').toBe(false)
}
})
})
describe('elevated surface rebinds', () => {
it('at least one surface rebinds the indirection', () => {
expect(rebindRules.length).toBeGreaterThan(0)