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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user