scrollbar-styles.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. /**
  2. * Scrollbar stylesheet contract, asserted against the CSS text on disk: every
  3. * --dsw-alias-scrollbar-* token design-platform.css defines has a consumer,
  4. * scrollbar.css binds the base-surface pair through the rebindable
  5. * indirection, and elevated surfaces rebind that indirection in complete
  6. * pairs. The expected token set is scanned out of design-platform.css, so
  7. * adding, renaming, or dropping a scrollbar token moves these assertions with
  8. * it.
  9. */
  10. import { readdirSync, readFileSync } from 'node:fs'
  11. import { join } from 'node:path'
  12. import { fileURLToPath } from 'node:url'
  13. import { describe, expect, it } from 'vitest'
  14. /** One flattened CSS rule: its comma-separated selector parts and its declarations in source order. */
  15. interface CssRule {
  16. selectors: string[]
  17. declarations: [property: string, value: string][]
  18. }
  19. const STYLES = new URL('../src/styles/', import.meta.url)
  20. const PACKAGES_DIR = fileURLToPath(new URL('../../../', import.meta.url))
  21. const read = (name: string): string => readFileSync(fileURLToPath(new URL(name, STYLES)), 'utf8')
  22. const platformCss = read('design-platform.css')
  23. const scrollbarCss = read('scrollbar.css')
  24. /** Body attribute selecting the dark palette; ui-layout's ThemePresenter sets it. */
  25. const DARK_ATTRIBUTE = '[data-ds-dark-theme]'
  26. /** Alias tokens under test: the prefix the elevation pairs share. */
  27. const TOKEN_PREFIX = '--dsw-alias-scrollbar-'
  28. /** Prefix of the rebindable indirection scrollbar.css owns. */
  29. const INDIRECTION_PREFIX = '--dsh-scrollbar-'
  30. /**
  31. * Flatten a stylesheet into rules. Whitespace, declaration order, and trailing
  32. * semicolons are normalized away; nesting and at-rules are not handled, which
  33. * no sheet under test uses for scrollbar declarations.
  34. * @param css - stylesheet text.
  35. * @returns one entry per rule, in source order.
  36. */
  37. function parseRules(css: string): CssRule[] {
  38. const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
  39. const rules: CssRule[] = []
  40. // Destructuring defaults only satisfy noUncheckedIndexedAccess; both groups
  41. // are unconditional in the pattern.
  42. for (const [, selector = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
  43. const declarations = body
  44. .split(';')
  45. .map(part => part.trim())
  46. .filter(part => part.includes(':'))
  47. .map((part): [string, string] => {
  48. const colon = part.indexOf(':')
  49. return [part.slice(0, colon).trim(), part.slice(colon + 1).trim()]
  50. })
  51. rules.push({ selectors: selector.split(',').map(part => part.trim()), declarations })
  52. }
  53. return rules
  54. }
  55. /**
  56. * Half-open source span of one at-rule's block, excluding its prelude.
  57. * @param css - stylesheet text.
  58. * @param prelude - exact at-rule prelude to locate, without the opening brace.
  59. * @returns the block's brace offsets, or undefined when the prelude is absent.
  60. */
  61. function atRuleBlock(css: string, prelude: string): { start: number; end: number } | undefined {
  62. const opening = css.indexOf(`${prelude} {`)
  63. if (opening === -1) return undefined
  64. const start = css.indexOf('{', opening)
  65. let depth = 0
  66. for (let index = start; index < css.length; index += 1) {
  67. if (css[index] === '{') depth += 1
  68. else if (css[index] === '}') {
  69. depth -= 1
  70. if (depth === 0) return { start, end: index }
  71. }
  72. }
  73. throw new Error(`unbalanced braces after ${prelude}`)
  74. }
  75. /**
  76. * Custom-property names a value reads.
  77. * @param value - declaration value, possibly with nested var() calls.
  78. * @returns every referenced custom-property name, in source order.
  79. */
  80. function varReferences(value: string): string[] {
  81. return [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map(([, name = '']) => name)
  82. }
  83. /**
  84. * Every CSS file shipped as package source, excluding build output and
  85. * installed dependencies.
  86. * @returns absolute paths of the stylesheets under packages/.
  87. */
  88. function packageStylesheets(): string[] {
  89. const found: string[] = []
  90. const walk = (dir: string): void => {
  91. for (const entry of readdirSync(dir, { withFileTypes: true })) {
  92. const path = join(dir, entry.name)
  93. if (entry.isDirectory()) {
  94. if (entry.name !== 'node_modules' && entry.name !== 'lib' && entry.name !== 'dist') walk(path)
  95. } else if (entry.name.endsWith('.css')) found.push(path)
  96. }
  97. }
  98. walk(PACKAGES_DIR)
  99. return found
  100. }
  101. /**
  102. * Tokens a stylesheet reads through its rendering declarations, following its
  103. * own custom-property definitions transitively so a token reached only through
  104. * an indirection counts. The walk starts from the standard-property
  105. * declarations, so a defined-but-unread indirection contributes nothing.
  106. * @param rules - parsed rules of one stylesheet.
  107. * @returns every `--dsw-*` token the sheet's rendering declarations depend on.
  108. */
  109. function tokensRendered(rules: CssRule[]): Set<string> {
  110. const definitions = new Map<string, string>()
  111. const pending: string[] = []
  112. for (const rule of rules) {
  113. for (const [property, value] of rule.declarations) {
  114. if (property.startsWith('--')) definitions.set(property, value)
  115. else pending.push(value)
  116. }
  117. }
  118. const reached = new Set<string>()
  119. const visited = new Set<string>()
  120. while (pending.length > 0) {
  121. for (const name of varReferences(pending.pop()!)) {
  122. if (name.startsWith('--dsw-')) reached.add(name)
  123. if (visited.has(name)) continue
  124. visited.add(name)
  125. const definition = definitions.get(name)
  126. if (definition !== undefined) pending.push(definition)
  127. }
  128. }
  129. return reached
  130. }
  131. const platformRules = parseRules(platformCss)
  132. const scrollbarRules = parseRules(scrollbarCss)
  133. const sorted = (names: Iterable<string>): string[] => [...names].sort()
  134. /**
  135. * Scrollbar tokens defined by the rules whose selectors carry (or do not
  136. * carry) the dark palette attribute.
  137. * @param dark - true to scan the dark blocks, false to scan the light blocks.
  138. * @returns the scrollbar token names defined there.
  139. */
  140. function definedTokens(dark: boolean): Set<string> {
  141. const names = new Set<string>()
  142. for (const rule of platformRules) {
  143. if (rule.selectors.every(selector => selector.includes(DARK_ATTRIBUTE)) !== dark) continue
  144. for (const [property] of rule.declarations) {
  145. if (property.startsWith(TOKEN_PREFIX)) names.add(property)
  146. }
  147. }
  148. return names
  149. }
  150. const lightTokens = definedTokens(false)
  151. const darkTokens = definedTokens(true)
  152. const allTokens = new Set([...lightTokens, ...darkTokens])
  153. /** Every scrollbar token any package stylesheet references, mapped to the files referencing it. */
  154. const referencedTokens = new Map<string, string[]>()
  155. /** Every indirection property any package stylesheet outside ui-theme declares, mapped to its declaring rules. */
  156. const rebindRules: { file: string; rule: CssRule }[] = []
  157. /**
  158. * What one stylesheet contributes to the elevated-surface question: which
  159. * elevated surfaces it paints, whether any rule scrolls, and whether it
  160. * rebinds. Kept per file rather than per rule because the elevated card and the
  161. * descendant that actually scrolls are separate rules in the same sheet, and
  162. * CSS text does not express which contains which.
  163. */
  164. interface SheetSurfaces {
  165. /** Elevated surface tokens this sheet paints anywhere. */
  166. elevated: Set<string>
  167. /** True when some rule declares `overflow*: auto|scroll`. */
  168. scrolls: boolean
  169. /** True when some rule rebinds the indirection. */
  170. rebinds: boolean
  171. }
  172. const sheetSurfaces = new Map<string, SheetSurfaces>()
  173. /** Properties whose `auto`/`scroll` value makes a rule a scroll container. */
  174. const OVERFLOW_PROPERTIES = ['overflow', 'overflow-x', 'overflow-y']
  175. /** Properties that paint a surface, and so identify the elevation a rule sits on. */
  176. const SURFACE_PROPERTIES = ['background', 'background-color']
  177. /**
  178. * Token families that name a SURFACE — a background an element is drawn on, and
  179. * so something a scrollbar can sit against. `--dsw-alias-button-*`,
  180. * `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same dark
  181. * elevation rungs while naming a control or an inline span, which no scroll
  182. * container renders its bar against (ChatView's floating `.toBottom` pill,
  183. * CodeBlock's banner). Family, not geometry: a floating button legitimately
  184. * carries a radius, a shadow, and a fixed size, so shape cannot separate them.
  185. */
  186. const SURFACE_TOKEN_PATTERN = /^--dsw-(?:alias-bg-|specific-)/
  187. /**
  188. * The palette's own dark elevation ladder, resolved from `design-platform.css`:
  189. * `bg-layer-2` and `bg-layer-3` are the rungs above the base surfaces, and the
  190. * l1/l2 scrollbar split encodes exactly that step. Reading it from the palette
  191. * rather than from the sheets that happen to rebind is what lets the check flag
  192. * a surface NOBODY has rebound yet.
  193. * @returns surface tokens whose dark value sits on an elevated rung.
  194. */
  195. function elevatedRungs(): Set<string> {
  196. const definitions = new Map<string, string>()
  197. for (const rule of platformRules) {
  198. // Dark declarations come later in the sheet and overwrite the light ones,
  199. // which is the palette this distinction exists in.
  200. for (const [property, value] of rule.declarations) definitions.set(property, value)
  201. }
  202. const resolve = (name: string): string => {
  203. const seen = new Set<string>()
  204. let current = name
  205. while (definitions.has(current) && !seen.has(current)) {
  206. seen.add(current)
  207. const value = definitions.get(current)!
  208. const [reference] = varReferences(value)
  209. if (reference === undefined) return value
  210. current = reference
  211. }
  212. return current
  213. }
  214. const rungs = new Set([resolve('--dsw-alias-bg-layer-2'), resolve('--dsw-alias-bg-layer-3')])
  215. const tokens = new Set<string>()
  216. for (const name of definitions.keys()) {
  217. if (SURFACE_TOKEN_PATTERN.test(name) && rungs.has(resolve(name))) tokens.add(name)
  218. }
  219. return tokens
  220. }
  221. const elevatedSurfaces = elevatedRungs()
  222. for (const file of packageStylesheets()) {
  223. const rules = parseRules(readFileSync(file, 'utf8'))
  224. const surfaces: SheetSurfaces = { elevated: new Set(), scrolls: false, rebinds: false }
  225. for (const rule of rules) {
  226. let rebinds = false
  227. const ruleSurfaces: string[] = []
  228. for (const [property, value] of rule.declarations) {
  229. if (property.startsWith(INDIRECTION_PREFIX) && file !== fileURLToPath(new URL('scrollbar.css', STYLES))) rebinds = true
  230. if (OVERFLOW_PROPERTIES.includes(property) && /\b(?:auto|scroll)\b/.test(value)) surfaces.scrolls = true
  231. if (SURFACE_PROPERTIES.includes(property)) ruleSurfaces.push(...varReferences(value))
  232. for (const token of varReferences(value)) {
  233. if (!token.startsWith(TOKEN_PREFIX)) continue
  234. referencedTokens.set(token, [...referencedTokens.get(token) ?? [], file])
  235. }
  236. }
  237. for (const token of ruleSurfaces) {
  238. if (elevatedSurfaces.has(token)) surfaces.elevated.add(token)
  239. }
  240. if (rebinds) {
  241. rebindRules.push({ file, rule })
  242. surfaces.rebinds = true
  243. }
  244. }
  245. sheetSurfaces.set(file, surfaces)
  246. }
  247. describe('design-platform.css scrollbar tokens', () => {
  248. it('defines the same scrollbar token set in the light and the dark block', () => {
  249. // A token present only in the light block silently keeps its light value
  250. // under the dark palette, since the dark block only overrides.
  251. expect(allTokens.size).toBeGreaterThan(0)
  252. expect(sorted(lightTokens)).toEqual(sorted(allTokens))
  253. expect(sorted(darkTokens)).toEqual(sorted(allTokens))
  254. })
  255. it('resolves every scrollbar token to a static scale value, not to another alias', () => {
  256. // The alias layer is the only indirection in the token sheet: an alias
  257. // pointing at a second alias makes the dark override order-dependent.
  258. for (const rule of platformRules) {
  259. for (const [property, value] of rule.declarations) {
  260. if (!property.startsWith(TOKEN_PREFIX)) continue
  261. for (const reference of varReferences(value)) {
  262. expect(reference, `${property}: ${value}`).toMatch(/^--dsw-static-/)
  263. }
  264. }
  265. }
  266. })
  267. })
  268. describe('scrollbar token consumers', () => {
  269. it('every defined scrollbar token is referenced by some package stylesheet', () => {
  270. // Before scrollbar.css existed these tokens had no consumer at all and
  271. // every scroll container rendered the unthemed UA bar. A fifth token, or a
  272. // rename on one side only, leaves the new name unreferenced here.
  273. expect(sorted(referencedTokens.keys())).toEqual(sorted(allTokens))
  274. })
  275. it('every referenced scrollbar token is defined in design-platform.css', () => {
  276. // A dangling var() renders the UA default instead of failing loudly, so a
  277. // rename has to move the reference and the definition together.
  278. for (const [token, files] of referencedTokens) {
  279. expect(allTokens, files.join(', ')).toContain(token)
  280. }
  281. })
  282. })
  283. describe('scrollbar.css base-surface binding', () => {
  284. const rendered = tokensRendered(scrollbarRules)
  285. it('renders the l1 pair through the rebindable indirection', () => {
  286. // l1 is the base-surface default the indirection resolves to; the
  287. // indirection only counts as bound when a rendering declaration reads it.
  288. expect(rendered).toContain(`${TOKEN_PREFIX}bg-l1`)
  289. expect(rendered).toContain(`${TOKEN_PREFIX}hover-l1`)
  290. })
  291. it('routes the standard property and the WebKit thumb through the same indirection', () => {
  292. // A rebind on an elevated container has to move the Firefox and the WebKit
  293. // rendering together, which only holds while both read the same variable.
  294. const declaration = (property: string, selectorPart: string): string | undefined => scrollbarRules
  295. .filter(rule => rule.selectors.includes(selectorPart))
  296. .flatMap(rule => rule.declarations)
  297. .findLast(([name]) => name === property)?.[1]
  298. const thumbColor = declaration('scrollbar-color', 'body')
  299. expect(thumbColor).toBeDefined()
  300. const indirection = varReferences(thumbColor!)[0]
  301. expect(indirection).toBe(`${INDIRECTION_PREFIX}thumb`)
  302. expect(varReferences(declaration('background', '::-webkit-scrollbar-thumb')!)).toEqual([indirection])
  303. })
  304. })
  305. describe('scrollbar.css selectors', () => {
  306. const scrollbarColorSelectors = scrollbarRules
  307. .filter(rule => rule.declarations.some(([property]) => property === 'scrollbar-color'))
  308. .flatMap(rule => rule.selectors)
  309. it('declares scrollbar-color only where the body-scoped tokens are visible', () => {
  310. // design-platform.css defines the alias tokens on `body`, and custom
  311. // properties inherit downward only: the same declaration on `html` or
  312. // `:root` resolves to the guaranteed-invalid value, which computes
  313. // scrollbar-color to `auto` and drops the theming entirely.
  314. expect(scrollbarColorSelectors.length).toBeGreaterThan(0)
  315. for (const selector of scrollbarColorSelectors) {
  316. expect(selector, selector).toMatch(/^body\b/)
  317. }
  318. })
  319. it('defines the indirection where the alias tokens are visible', () => {
  320. const definesIndirection = ([property, value]: [string, string]): boolean =>
  321. property.startsWith(INDIRECTION_PREFIX) && value.includes(TOKEN_PREFIX)
  322. const hosts = scrollbarRules
  323. .filter(rule => rule.declarations.some(definesIndirection))
  324. .flatMap(rule => rule.selectors)
  325. expect(hosts.length).toBeGreaterThan(0)
  326. for (const selector of hosts) expect(selector, selector).toMatch(/^body\b/)
  327. })
  328. it('re-declares the scrollbar properties per element rather than inheriting them', () => {
  329. // scrollbar-width is not an inherited property, and an inherited
  330. // scrollbar-color carries the colour already substituted at `body`, which
  331. // a descendant rebinding the indirection could no longer change.
  332. expect(scrollbarColorSelectors).toContain('body *')
  333. const widthSelectors = scrollbarRules
  334. .filter(rule => rule.declarations.some(([property]) => property === 'scrollbar-width'))
  335. .flatMap(rule => rule.selectors)
  336. expect(widthSelectors).toContain('body *')
  337. })
  338. })
  339. describe('scrollbar.css rendering paths', () => {
  340. /** The gate prelude, spelled exactly as the sheet must spell it for the split to exist. */
  341. const GATE = '@supports not selector(::-webkit-scrollbar)'
  342. const withoutComments = scrollbarCss.replace(/\/\*[\s\S]*?\*\//g, ' ')
  343. const gate = atRuleBlock(withoutComments, GATE)
  344. /** Standard scrollbar properties, the ones whose non-`auto` values suppress the pseudo-elements. */
  345. const STANDARD_PROPERTIES = ['scrollbar-width', 'scrollbar-color']
  346. it('gates the standard properties behind the absence of the WebKit pseudo-element', () => {
  347. // A non-`auto` scrollbar-width or scrollbar-color makes Chromium and
  348. // Safari discard every ::-webkit-scrollbar* rule for that element,
  349. // ::-webkit-scrollbar-thumb:hover included. Declaring both paths
  350. // unconditionally therefore renders the hover token nowhere: the engines
  351. // implementing the hover pseudo-element are exactly the ones the standard
  352. // properties silence, and Firefox has no hover pseudo-element at all.
  353. expect(gate, GATE).toBeDefined()
  354. for (const property of STANDARD_PROPERTIES) {
  355. const offsets = [...withoutComments.matchAll(new RegExp(String.raw`(^|[;{\s])${property}\s*:`, 'g'))]
  356. .map(match => match.index)
  357. expect(offsets.length, property).toBeGreaterThan(0)
  358. for (const offset of offsets) {
  359. expect(offset, `${property} outside ${GATE}`).toBeGreaterThan(gate!.start)
  360. expect(offset, `${property} outside ${GATE}`).toBeLessThan(gate!.end)
  361. }
  362. }
  363. })
  364. it('leaves the WebKit pseudo-element rules outside the gate', () => {
  365. // Gating these in turn would only restate selector matching: an engine
  366. // without the pseudo-elements drops the rules as unknown selectors. Inside
  367. // the gate they would be dropped by the engines that do implement them,
  368. // which is every engine that can render them.
  369. const offsets = [...withoutComments.matchAll(/::-webkit-scrollbar/g)]
  370. .map(match => match.index)
  371. .filter(offset => withoutComments.slice(offset).search(/^[\w:-]*\s*[,{]/) === 0)
  372. expect(offsets.length).toBeGreaterThan(0)
  373. for (const offset of offsets) {
  374. expect(offset > gate!.start && offset < gate!.end, `::-webkit-scrollbar rule inside ${GATE}`).toBe(false)
  375. }
  376. })
  377. it('renders the hover token only through the pseudo-element path', () => {
  378. // The standard path has no hover counterpart — scrollbar-color states one
  379. // thumb colour and the engine derives its own hover treatment — so the
  380. // hover indirection has to be read outside the gate or it renders nowhere.
  381. const hoverOffsets = [...withoutComments.matchAll(new RegExp(String.raw`var\(\s*${INDIRECTION_PREFIX}thumb-hover`, 'g'))]
  382. .map(match => match.index)
  383. expect(hoverOffsets.length).toBeGreaterThan(0)
  384. for (const offset of hoverOffsets) {
  385. expect(offset > gate!.start && offset < gate!.end, 'hover indirection read inside the gate').toBe(false)
  386. }
  387. })
  388. })
  389. describe('elevated surface rebinds', () => {
  390. it('at least one surface rebinds the indirection', () => {
  391. expect(rebindRules.length).toBeGreaterThan(0)
  392. })
  393. it('each rebinding rule sets the thumb and the hover variable together', () => {
  394. // A surface rebinding only the resting colour keeps the l1 hover colour,
  395. // so the elevation is wrong only while the pointer is over the thumb.
  396. for (const { file, rule } of rebindRules) {
  397. const properties = rule.declarations.map(([property]) => property).filter(property => property.startsWith(INDIRECTION_PREFIX))
  398. expect(sorted(properties), `${file} ${rule.selectors.join(', ')}`).toEqual([
  399. `${INDIRECTION_PREFIX}thumb-hover`, `${INDIRECTION_PREFIX}thumb`,
  400. ].sort())
  401. }
  402. })
  403. it('each rebinding rule binds the indirection names scrollbar.css renders', () => {
  404. // A misspelled property name declares an unused variable, and the surface
  405. // silently keeps the base-surface colour.
  406. const rendered = new Set(
  407. scrollbarRules
  408. .flatMap(rule => rule.declarations)
  409. .filter(([property]) => !property.startsWith('--'))
  410. .flatMap(([, value]) => varReferences(value))
  411. .filter(name => name.startsWith(INDIRECTION_PREFIX)),
  412. )
  413. for (const { file, rule } of rebindRules) {
  414. for (const [property] of rule.declarations) {
  415. if (property.startsWith(INDIRECTION_PREFIX)) expect(rendered, `${file}: ${property}`).toContain(property)
  416. }
  417. }
  418. })
  419. it('every rebind targets the l2 elevation pair', () => {
  420. for (const { file, rule } of rebindRules) {
  421. for (const [property, value] of rule.declarations) {
  422. if (!property.startsWith(INDIRECTION_PREFIX)) continue
  423. for (const token of varReferences(value)) {
  424. expect(token, `${file}: ${property}`).toMatch(/-l2$/)
  425. }
  426. }
  427. }
  428. })
  429. it('resolves the elevated surface set from the palette ladder', () => {
  430. // The set has to come from the palette, not from the sheets that happen to
  431. // rebind: derived from rebinds it can only confirm what someone already
  432. // remembered, and a surface nobody has rebound yet — the case the check
  433. // exists for — would define itself as unelevated. Anchoring it here means a
  434. // new palette token on an elevated rung is in scope the moment it is
  435. // defined. `--dsw-specific-tip` is the regression that proved the point: it
  436. // resolves to the same dark rung as the menu surface, and the Todo panel
  437. // scrolled on it unrebound while a rebind-derived set stayed green.
  438. expect(elevatedSurfaces).toContain('--dsw-alias-bg-layer-2')
  439. expect(elevatedSurfaces).toContain('--dsw-alias-bg-layer-3')
  440. expect(elevatedSurfaces).toContain('--dsw-specific-menu')
  441. expect(elevatedSurfaces).toContain('--dsw-specific-input-major')
  442. expect(elevatedSurfaces).toContain('--dsw-specific-tip')
  443. // Base surfaces stay out, or every scroll container would be in scope and
  444. // the check would say nothing.
  445. expect(elevatedSurfaces).not.toContain('--dsw-alias-bg-base')
  446. expect(elevatedSurfaces).not.toContain('--dsw-alias-bg-layer-1')
  447. })
  448. it('every sheet that scrolls on an elevated surface rebinds', () => {
  449. // The failure this closes: a scroll container on an elevated surface that
  450. // nobody remembered to rebind renders the l1 thumb, which differs from l2
  451. // only in the dark palette and only for that one surface — invisible both in
  452. // review and in a light-palette screenshot. Four sheets shipped that way
  453. // (ui-primitives Menu, InputBar, QuestionComposer, TodoPanel) and review
  454. // caught them by hand, which is what this replaces.
  455. //
  456. // Surface-level, not element-level: the elevated card and the descendant
  457. // that scrolls are separate rules, and CSS text does not say which contains
  458. // which. What keeps that from over-reporting is the token FAMILY: only
  459. // `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface, so a floating
  460. // button or an inline code span reaching the same rung is out of scope
  461. // (ChatView's `.toBottom`, CodeBlock's banner). Geometry cannot make that
  462. // call — a floating button carries a radius, a shadow, and a fixed size.
  463. for (const [file, surfaces] of sheetSurfaces) {
  464. if (!surfaces.scrolls || surfaces.rebinds) continue
  465. expect([...surfaces.elevated], `${file} scrolls on an elevated surface without rebinding`).toEqual([])
  466. }
  467. })
  468. })