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:
@@ -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