fix(client): rebind the scrollbar indirection on three missed elevated surfaces

Review found three scroll containers sitting on surfaces the rebinding
contract covers, none of which rebound: ui-primitives' shared Menu card
on --dsw-specific-menu (the surface PopupSelectView already rebinds for),
and the composer input and question composer cards, both on
--dsw-specific-input-major. Each rendered the l1 thumb, which differs
from l2 only in the dark palette and only on that surface, so a
light-palette screenshot and a code read both look correct.

Adds the mechanical check that would have caught them instead of leaving
it to inspection: a sheet that scrolls somewhere and paints a known
elevated surface somewhere must rebind. The elevated set is derived from
the sheets that already rebind, since a rebinding rule paints the surface
whose elevation it declares, so a new elevated surface joins the set by
rebinding rather than by anyone updating a list. Surface-level rather than
element-level because the card and the descendant that scrolls are
separate rules and CSS text does not say which contains which. Verified by
reverting each of the three fixes in turn: the check names the sheet and
the surface every time.

Also commits snapshots/sidebar-scrollbar/geometry.expected.md, the
resolved scrollbar style and geometry in both palettes. The aria goldens
the other web scenarios commit cannot carry a CSS-only change, since it
alters no DOM and no accessible name and leaves their trees
byte-identical. Absolute coordinates stay out: they track font metrics and
the laid-out sidebar width, so committing them would document the platform
and force a per-platform re-record.
This commit is contained in:
Chinesezjc
2026-07-28 17:42:57 +08:00
parent 29b9261470
commit 4265ac876c
9 changed files with 203 additions and 6 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: c52440bb057c5202ee90bcf53518fe62ba1379b7
2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: e234106c47eeaf418aac4a09ced0dd6c854a439c
2026-07-28-themed-scrollbars-and-reserved-gutter.md: dfa2dc69d46ea8ce9cf0ad621334b8999957e830
2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 19381f5c1541d41359ba6f02e9448fe8342ee44c

View File

@@ -20,7 +20,9 @@ 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. Four surfaces rebind today: the command popup, the slash menu, the model-select panel, and the settings panel. The last two declare it on the elevated panel rather than on the scrolling descendant, because the elevation is a property of the surface and custom properties inherit down 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. Seven surfaces rebind today: the command popup, the slash menu, the model-select panel, the settings panel, the shared `ui-primitives` menu card, the composer input card, and the question composer card. Most declare it on the elevated card rather than on the scrolling descendant, because the elevation is a property of the surface and custom properties inherit down to whichever child actually scrolls.
The last three were missed in the first implementation and found in review, which is why the rebinding contract is now checked mechanically rather than by inspection: a sheet that scrolls somewhere and paints a known elevated surface somewhere must rebind. The elevated set is derived from the sheets that already rebind — a rebinding rule paints the surface whose elevation it declares — so it is self-maintaining rather than a list to update. The check is surface-level rather than element-level because the card and the descendant that scrolls are separate rules, and CSS text does not express which contains which.
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.
@@ -61,6 +63,8 @@ Three unit specs read the CSS text on disk. `ui-theme/tests/scrollbar-styles.spe
`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.

View File

@@ -20,7 +20,9 @@ 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 上。目前有处抬升表面做了重新绑定:命令浮层、斜杠菜单、模型选择面板设置面板。后两者把声明写在抬升面板上而非滚动的后代元素上,因为抬升层级是这个表面的属性,而自定义属性会继承到真正滚动的那个子元素。
两条路径都读取同一组间接变量 `--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 上。目前有处抬升表面做了重新绑定:命令浮层、斜杠菜单、模型选择面板设置面板`ui-primitives` 共用菜单卡片、输入条卡片与提问组件卡片。多数把声明写在抬升卡片上而非滚动的后代元素上,因为抬升层级是这个表面的属性,而自定义属性会继承到真正滚动的那个子元素。
后三处在最初的实现里被漏掉、由评审发现,因此重新绑定契约现在由机械检查把关,而不再依赖人工审阅:一张样式表只要在某处滚动、又在某处绘制已知的抬升表面,就必须重新绑定。抬升表面集合从已经做了重新绑定的样式表推导得出——重新绑定的那条规则正好绘制着它所声明抬升层级的那个表面——因此该集合自我维护,不是一份需要人去更新的清单。这项检查以表面为粒度而非以元素为粒度,因为卡片与真正滚动的后代元素是两条不同的规则,而 CSS 文本无法表达谁包含谁。
轨道与两条滚动条相交的角落保持透明,因此滑块是以其下滚动的任何表面为背景被看到;只有滑块及其 hover 状态带 token 颜色。
@@ -61,6 +63,8 @@ Status: implemented
`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 声明当作规则文本从层叠中读出。

View File

@@ -59,13 +59,27 @@
// the same query flipped from the hover colour to the resting one).
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-scrollbar', import.meta.url))
/**
* Committed golden of the resolved scrollbar style and geometry, in both
* palettes. The aria goldens the other scenarios commit cannot carry this
* change: it alters no DOM and no accessible name, so their normalized trees are
* byte-identical with and without it. This one records the values instead, which
* makes an unintended shift in thumb colour, band width, or rendering path a
* reviewable diff rather than an assertion someone has to think about.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
/** Enough rows that the list overflows the 800px-tall viewport's sidebar; the scenario asserts the overflow rather than trusting it. */
const SEED_COUNT = 24
@@ -179,6 +193,48 @@ function measureList(page: Page): Promise<ListMetrics> {
})
}
/**
* Render the golden body: the resolved scrollbar style of the list in each
* palette, plus the geometric relations the fix establishes.
*
* Absolute coordinates are deliberately absent. `timeRight`, `clientRight`, and
* `borderRight` depend on the sidebar's laid-out width and on font metrics, so
* committing them would make the golden fail on a machine whose fonts measure
* differently — a fixture that has to be re-recorded per platform documents the
* platform, not the change. What is recorded instead is the band, the overlap,
* and the two orderings, each of which is a difference or a comparison and so
* survives any layout that keeps the reservation.
* @param light - metrics measured under the light palette.
* @param dark - metrics measured under the dark palette.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
const palette = (name: string, metrics: ListMetrics): string[] => [
`## ${name}`,
'',
`- scrollbar-gutter: ${metrics.gutter}`,
`- ::-webkit-scrollbar width: ${metrics.width}`,
`- ::-webkit-scrollbar-track background: ${metrics.track}`,
`- scrollbar-width: ${metrics.standardWidth}`,
`- scrollbar-color: ${metrics.standardColor}`,
`- ::-webkit-scrollbar-thumb:hover declarations: ${metrics.hoverRules.join(' | ')}`,
`- --dsh-scrollbar-thumb: ${metrics.token}`,
`- --dsh-scrollbar-thumb-hover: ${metrics.hoverToken}`,
`- list overflows: ${String(metrics.overflows)}`,
`- reserved band: ${String(metrics.band)}px`,
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
'',
]
return [
'# Sidebar session list scrollbar',
'',
...palette('Light palette', light),
...palette('Dark palette', dark),
].join('\n').trimEnd()
}
/**
* Reveal the seeded rows: every seeded session is unattached, so they all sit
* in the collapsed Ungrouped bucket. Converges on expanded rather than
@@ -296,6 +352,22 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('matches the committed scrollbar geometry golden in both palettes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-golden'))
const light = await measureList(page)
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const dark = await measureList(page)
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(light, dark), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('commits exactly the fixtures it reads', async () => {
// The scenario borrows seeded-history's seed.jsonl rather than committing a
// second copy, so this directory holds the golden alone.
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
})
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])

View File

@@ -0,0 +1,33 @@
# Sidebar session list scrollbar
## Light palette
- scrollbar-gutter: stable
- ::-webkit-scrollbar width: 8px
- ::-webkit-scrollbar-track background: rgba(0, 0, 0, 0)
- scrollbar-width: auto
- scrollbar-color: auto
- ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover)
- --dsh-scrollbar-thumb: rgb(229, 229, 229)
- --dsh-scrollbar-thumb-hover: rgb(212, 212, 212)
- list overflows: true
- reserved band: 8px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true
## Dark palette
- scrollbar-gutter: stable
- ::-webkit-scrollbar width: 8px
- ::-webkit-scrollbar-track background: rgba(0, 0, 0, 0)
- scrollbar-width: auto
- scrollbar-color: auto
- ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover)
- --dsh-scrollbar-thumb: rgb(60, 60, 61)
- --dsh-scrollbar-thumb-hover: rgb(84, 85, 87)
- list overflows: true
- reserved band: 8px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true

View File

@@ -87,6 +87,13 @@
box-shadow: var(--dsw-shadow-lv2);
font-size: 16px;
line-height: 24px;
/* Elevated surface in dark, same as the menus: the textarea inside scrolls
once the composer hits its height cap, so the thumb takes the l2 pair.
Declared on the card because the elevation belongs to the surface, and the
custom properties inherit down to the textarea that actually scrolls (see
ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.accessory {

View File

@@ -17,6 +17,13 @@
border-radius: 12px;
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens. The
declaration sits on the card rather than on `.scrollable .viewport`
because the elevation is a property of this surface, and the custom
properties inherit down to whichever descendant actually scrolls (see
ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Primary card is 218 wide in the design across both hosts. */

View File

@@ -19,6 +19,13 @@
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv1-blur);
color: var(--dsw-alias-label-primary);
/* Elevated surface in dark, same as the menus: the option list inside scrolls
once the card hits the cap above, so the thumb takes the l2 pair. Declared
on the card because the elevation belongs to the surface, and the custom
properties inherit down to `.options` (see ui-theme styles/scrollbar.css
for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.card,

View File

@@ -167,22 +167,64 @@ const allTokens = new Set([...lightTokens, ...darkTokens])
const referencedTokens = new Map<string, string[]>()
/** Every indirection property any package stylesheet outside ui-theme declares, mapped to its declaring rules. */
const rebindRules: { file: string; rule: CssRule }[] = []
/**
* What one stylesheet contributes to the elevated-surface question: which
* surface tokens its rules paint, whether any rule scrolls, and whether it
* rebinds. Kept per file rather than per rule because the elevated card and the
* descendant that actually scrolls are separate rules in the same sheet, and
* CSS text does not express which element contains which.
*/
interface SheetSurfaces {
/** Surface tokens named by `background`/`background-color` on a rebinding rule. */
rebound: Set<string>
/** Surface tokens named by `background`/`background-color` anywhere in the sheet. */
painted: Set<string>
/** True when some rule declares `overflow*: auto|scroll`. */
scrolls: boolean
/** True when some rule rebinds the indirection. */
rebinds: boolean
}
const sheetSurfaces = new Map<string, SheetSurfaces>()
/** Properties whose `auto`/`scroll` value makes a rule a scroll container. */
const OVERFLOW_PROPERTIES = ['overflow', 'overflow-x', 'overflow-y']
/** Properties that paint a surface, and so identify the elevation a rule sits on. */
const SURFACE_PROPERTIES = ['background', 'background-color']
for (const file of packageStylesheets()) {
const rules = parseRules(readFileSync(file, 'utf8'))
const surfaces: SheetSurfaces = { rebound: new Set(), painted: new Set(), scrolls: false, rebinds: false }
for (const rule of rules) {
let rebinds = false
const ruleSurfaces: string[] = []
for (const [property, value] of rule.declarations) {
if (property.startsWith(INDIRECTION_PREFIX) && file !== fileURLToPath(new URL('scrollbar.css', STYLES))) rebinds = true
if (OVERFLOW_PROPERTIES.includes(property) && /\b(?:auto|scroll)\b/.test(value)) surfaces.scrolls = true
if (SURFACE_PROPERTIES.includes(property)) ruleSurfaces.push(...varReferences(value))
for (const token of varReferences(value)) {
if (!token.startsWith(TOKEN_PREFIX)) continue
referencedTokens.set(token, [...referencedTokens.get(token) ?? [], file])
}
}
if (rebinds) rebindRules.push({ file, rule })
for (const token of ruleSurfaces) surfaces.painted.add(token)
if (rebinds) {
rebindRules.push({ file, rule })
surfaces.rebinds = true
for (const token of ruleSurfaces) surfaces.rebound.add(token)
}
}
sheetSurfaces.set(file, surfaces)
}
/**
* Surface tokens known to be elevated, derived from the sheets that already
* rebind rather than listed here: a rebinding rule paints the surface whose
* elevation it is declaring. Deriving it means a new elevated surface joins the
* set by rebinding, and cannot be added to the palette without either rebinding
* or failing the check below.
*/
const elevatedSurfaces = new Set([...sheetSurfaces.values()].flatMap(surfaces => [...surfaces.rebound]))
describe('design-platform.css scrollbar tokens', () => {
it('defines the same scrollbar token set in the light and the dark block', () => {
// A token present only in the light block silently keeps its light value
@@ -383,4 +425,25 @@ describe('elevated surface rebinds', () => {
}
}
})
it('every sheet that scrolls on a known elevated surface rebinds', () => {
// The failure this closes: a scroll container on an elevated surface that
// nobody remembered to rebind renders the l1 thumb, which differs from l2
// only in the dark palette and only for that one surface — invisible in
// review and in a light-palette screenshot. Three sheets shipped that way
// (ui-primitives Menu, InputBar, QuestionComposer) and review caught them
// by hand, which is what this replaces.
//
// Surface-level, not element-level: the elevated card and the descendant
// that scrolls are separate rules, and CSS text does not say which contains
// which. A sheet that both scrolls somewhere and paints a known elevated
// surface somewhere must rebind; the elevation would otherwise be a
// coincidence of two unrelated rules, which no sheet under test does.
expect(elevatedSurfaces.size).toBeGreaterThan(0)
for (const [file, surfaces] of sheetSurfaces) {
if (!surfaces.scrolls || surfaces.rebinds) continue
const elevated = [...surfaces.painted].filter(token => elevatedSurfaces.has(token))
expect(elevated, `${file} scrolls on ${elevated.join(', ')} without rebinding`).toEqual([])
}
})
})