mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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.
450 lines
20 KiB
TypeScript
450 lines
20 KiB
TypeScript
/**
|
|
* Scrollbar stylesheet contract, asserted against the CSS text on disk: every
|
|
* --dsw-alias-scrollbar-* token design-platform.css defines has a consumer,
|
|
* scrollbar.css binds the base-surface pair through the rebindable
|
|
* indirection, and elevated surfaces rebind that indirection in complete
|
|
* pairs. The expected token set is scanned out of design-platform.css, so
|
|
* adding, renaming, or dropping a scrollbar token moves these assertions with
|
|
* it.
|
|
*/
|
|
import { readdirSync, readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
/** One flattened CSS rule: its comma-separated selector parts and its declarations in source order. */
|
|
interface CssRule {
|
|
selectors: string[]
|
|
declarations: [property: string, value: string][]
|
|
}
|
|
|
|
const STYLES = new URL('../src/styles/', import.meta.url)
|
|
const PACKAGES_DIR = fileURLToPath(new URL('../../../', import.meta.url))
|
|
const read = (name: string): string => readFileSync(fileURLToPath(new URL(name, STYLES)), 'utf8')
|
|
|
|
const platformCss = read('design-platform.css')
|
|
const scrollbarCss = read('scrollbar.css')
|
|
|
|
/** Body attribute selecting the dark palette; ui-layout's ThemePresenter sets it. */
|
|
const DARK_ATTRIBUTE = '[data-ds-dark-theme]'
|
|
/** Alias tokens under test: the prefix the elevation pairs share. */
|
|
const TOKEN_PREFIX = '--dsw-alias-scrollbar-'
|
|
/** Prefix of the rebindable indirection scrollbar.css owns. */
|
|
const INDIRECTION_PREFIX = '--dsh-scrollbar-'
|
|
|
|
/**
|
|
* Flatten a stylesheet into rules. Whitespace, declaration order, and trailing
|
|
* semicolons are normalized away; nesting and at-rules are not handled, which
|
|
* no sheet under test uses for scrollbar declarations.
|
|
* @param css - stylesheet text.
|
|
* @returns one entry per rule, in source order.
|
|
*/
|
|
function parseRules(css: string): CssRule[] {
|
|
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
const rules: CssRule[] = []
|
|
// Destructuring defaults only satisfy noUncheckedIndexedAccess; both groups
|
|
// are unconditional in the pattern.
|
|
for (const [, selector = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
const declarations = body
|
|
.split(';')
|
|
.map(part => part.trim())
|
|
.filter(part => part.includes(':'))
|
|
.map((part): [string, string] => {
|
|
const colon = part.indexOf(':')
|
|
return [part.slice(0, colon).trim(), part.slice(colon + 1).trim()]
|
|
})
|
|
rules.push({ selectors: selector.split(',').map(part => part.trim()), declarations })
|
|
}
|
|
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.
|
|
* @returns every referenced custom-property name, in source order.
|
|
*/
|
|
function varReferences(value: string): string[] {
|
|
return [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map(([, name = '']) => name)
|
|
}
|
|
|
|
/**
|
|
* Every CSS file shipped as package source, excluding build output and
|
|
* installed dependencies.
|
|
* @returns absolute paths of the stylesheets under packages/.
|
|
*/
|
|
function packageStylesheets(): string[] {
|
|
const found: string[] = []
|
|
const walk = (dir: string): void => {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
if (entry.name !== 'node_modules' && entry.name !== 'lib' && entry.name !== 'dist') walk(path)
|
|
} else if (entry.name.endsWith('.css')) found.push(path)
|
|
}
|
|
}
|
|
walk(PACKAGES_DIR)
|
|
return found
|
|
}
|
|
|
|
/**
|
|
* Tokens a stylesheet reads through its rendering declarations, following its
|
|
* own custom-property definitions transitively so a token reached only through
|
|
* an indirection counts. The walk starts from the standard-property
|
|
* declarations, so a defined-but-unread indirection contributes nothing.
|
|
* @param rules - parsed rules of one stylesheet.
|
|
* @returns every `--dsw-*` token the sheet's rendering declarations depend on.
|
|
*/
|
|
function tokensRendered(rules: CssRule[]): Set<string> {
|
|
const definitions = new Map<string, string>()
|
|
const pending: string[] = []
|
|
for (const rule of rules) {
|
|
for (const [property, value] of rule.declarations) {
|
|
if (property.startsWith('--')) definitions.set(property, value)
|
|
else pending.push(value)
|
|
}
|
|
}
|
|
const reached = new Set<string>()
|
|
const visited = new Set<string>()
|
|
while (pending.length > 0) {
|
|
for (const name of varReferences(pending.pop()!)) {
|
|
if (name.startsWith('--dsw-')) reached.add(name)
|
|
if (visited.has(name)) continue
|
|
visited.add(name)
|
|
const definition = definitions.get(name)
|
|
if (definition !== undefined) pending.push(definition)
|
|
}
|
|
}
|
|
return reached
|
|
}
|
|
|
|
const platformRules = parseRules(platformCss)
|
|
const scrollbarRules = parseRules(scrollbarCss)
|
|
const sorted = (names: Iterable<string>): string[] => [...names].sort()
|
|
|
|
/**
|
|
* Scrollbar tokens defined by the rules whose selectors carry (or do not
|
|
* carry) the dark palette attribute.
|
|
* @param dark - true to scan the dark blocks, false to scan the light blocks.
|
|
* @returns the scrollbar token names defined there.
|
|
*/
|
|
function definedTokens(dark: boolean): Set<string> {
|
|
const names = new Set<string>()
|
|
for (const rule of platformRules) {
|
|
if (rule.selectors.every(selector => selector.includes(DARK_ATTRIBUTE)) !== dark) continue
|
|
for (const [property] of rule.declarations) {
|
|
if (property.startsWith(TOKEN_PREFIX)) names.add(property)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
const lightTokens = definedTokens(false)
|
|
const darkTokens = definedTokens(true)
|
|
const allTokens = new Set([...lightTokens, ...darkTokens])
|
|
|
|
/** Every scrollbar token any package stylesheet references, mapped to the files referencing it. */
|
|
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])
|
|
}
|
|
}
|
|
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
|
|
// under the dark palette, since the dark block only overrides.
|
|
expect(allTokens.size).toBeGreaterThan(0)
|
|
expect(sorted(lightTokens)).toEqual(sorted(allTokens))
|
|
expect(sorted(darkTokens)).toEqual(sorted(allTokens))
|
|
})
|
|
|
|
it('resolves every scrollbar token to a static scale value, not to another alias', () => {
|
|
// The alias layer is the only indirection in the token sheet: an alias
|
|
// pointing at a second alias makes the dark override order-dependent.
|
|
for (const rule of platformRules) {
|
|
for (const [property, value] of rule.declarations) {
|
|
if (!property.startsWith(TOKEN_PREFIX)) continue
|
|
for (const reference of varReferences(value)) {
|
|
expect(reference, `${property}: ${value}`).toMatch(/^--dsw-static-/)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('scrollbar token consumers', () => {
|
|
it('every defined scrollbar token is referenced by some package stylesheet', () => {
|
|
// Before scrollbar.css existed these tokens had no consumer at all and
|
|
// every scroll container rendered the unthemed UA bar. A fifth token, or a
|
|
// rename on one side only, leaves the new name unreferenced here.
|
|
expect(sorted(referencedTokens.keys())).toEqual(sorted(allTokens))
|
|
})
|
|
|
|
it('every referenced scrollbar token is defined in design-platform.css', () => {
|
|
// A dangling var() renders the UA default instead of failing loudly, so a
|
|
// rename has to move the reference and the definition together.
|
|
for (const [token, files] of referencedTokens) {
|
|
expect(allTokens, files.join(', ')).toContain(token)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('scrollbar.css base-surface binding', () => {
|
|
const rendered = tokensRendered(scrollbarRules)
|
|
|
|
it('renders the l1 pair through the rebindable indirection', () => {
|
|
// l1 is the base-surface default the indirection resolves to; the
|
|
// indirection only counts as bound when a rendering declaration reads it.
|
|
expect(rendered).toContain(`${TOKEN_PREFIX}bg-l1`)
|
|
expect(rendered).toContain(`${TOKEN_PREFIX}hover-l1`)
|
|
})
|
|
|
|
it('routes the standard property and the WebKit thumb through the same indirection', () => {
|
|
// A rebind on an elevated container has to move the Firefox and the WebKit
|
|
// rendering together, which only holds while both read the same variable.
|
|
const declaration = (property: string, selectorPart: string): string | undefined => scrollbarRules
|
|
.filter(rule => rule.selectors.includes(selectorPart))
|
|
.flatMap(rule => rule.declarations)
|
|
.findLast(([name]) => name === property)?.[1]
|
|
const thumbColor = declaration('scrollbar-color', 'body')
|
|
expect(thumbColor).toBeDefined()
|
|
const indirection = varReferences(thumbColor!)[0]
|
|
expect(indirection).toBe(`${INDIRECTION_PREFIX}thumb`)
|
|
expect(varReferences(declaration('background', '::-webkit-scrollbar-thumb')!)).toEqual([indirection])
|
|
})
|
|
})
|
|
|
|
describe('scrollbar.css selectors', () => {
|
|
const scrollbarColorSelectors = scrollbarRules
|
|
.filter(rule => rule.declarations.some(([property]) => property === 'scrollbar-color'))
|
|
.flatMap(rule => rule.selectors)
|
|
|
|
it('declares scrollbar-color only where the body-scoped tokens are visible', () => {
|
|
// design-platform.css defines the alias tokens on `body`, and custom
|
|
// properties inherit downward only: the same declaration on `html` or
|
|
// `:root` resolves to the guaranteed-invalid value, which computes
|
|
// scrollbar-color to `auto` and drops the theming entirely.
|
|
expect(scrollbarColorSelectors.length).toBeGreaterThan(0)
|
|
for (const selector of scrollbarColorSelectors) {
|
|
expect(selector, selector).toMatch(/^body\b/)
|
|
}
|
|
})
|
|
|
|
it('defines the indirection where the alias tokens are visible', () => {
|
|
const definesIndirection = ([property, value]: [string, string]): boolean =>
|
|
property.startsWith(INDIRECTION_PREFIX) && value.includes(TOKEN_PREFIX)
|
|
const hosts = scrollbarRules
|
|
.filter(rule => rule.declarations.some(definesIndirection))
|
|
.flatMap(rule => rule.selectors)
|
|
expect(hosts.length).toBeGreaterThan(0)
|
|
for (const selector of hosts) expect(selector, selector).toMatch(/^body\b/)
|
|
})
|
|
|
|
it('re-declares the scrollbar properties per element rather than inheriting them', () => {
|
|
// scrollbar-width is not an inherited property, and an inherited
|
|
// scrollbar-color carries the colour already substituted at `body`, which
|
|
// a descendant rebinding the indirection could no longer change.
|
|
expect(scrollbarColorSelectors).toContain('body *')
|
|
const widthSelectors = scrollbarRules
|
|
.filter(rule => rule.declarations.some(([property]) => property === 'scrollbar-width'))
|
|
.flatMap(rule => rule.selectors)
|
|
expect(widthSelectors).toContain('body *')
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
it('each rebinding rule sets the thumb and the hover variable together', () => {
|
|
// A surface rebinding only the resting colour keeps the l1 hover colour,
|
|
// so the elevation is wrong only while the pointer is over the thumb.
|
|
for (const { file, rule } of rebindRules) {
|
|
const properties = rule.declarations.map(([property]) => property).filter(property => property.startsWith(INDIRECTION_PREFIX))
|
|
expect(sorted(properties), `${file} ${rule.selectors.join(', ')}`).toEqual([
|
|
`${INDIRECTION_PREFIX}thumb-hover`, `${INDIRECTION_PREFIX}thumb`,
|
|
].sort())
|
|
}
|
|
})
|
|
|
|
it('each rebinding rule binds the indirection names scrollbar.css renders', () => {
|
|
// A misspelled property name declares an unused variable, and the surface
|
|
// silently keeps the base-surface colour.
|
|
const rendered = new Set(
|
|
scrollbarRules
|
|
.flatMap(rule => rule.declarations)
|
|
.filter(([property]) => !property.startsWith('--'))
|
|
.flatMap(([, value]) => varReferences(value))
|
|
.filter(name => name.startsWith(INDIRECTION_PREFIX)),
|
|
)
|
|
for (const { file, rule } of rebindRules) {
|
|
for (const [property] of rule.declarations) {
|
|
if (property.startsWith(INDIRECTION_PREFIX)) expect(rendered, `${file}: ${property}`).toContain(property)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('every rebind targets the l2 elevation pair', () => {
|
|
for (const { file, rule } of rebindRules) {
|
|
for (const [property, value] of rule.declarations) {
|
|
if (!property.startsWith(INDIRECTION_PREFIX)) continue
|
|
for (const token of varReferences(value)) {
|
|
expect(token, `${file}: ${property}`).toMatch(/-l2$/)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
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([])
|
|
}
|
|
})
|
|
})
|