Merge branch 'master' into xtr/identified-immutable-messages

This commit is contained in:
Tianyi Cui
2026-07-28 18:55:50 +08:00
committed by GitHub
28 changed files with 1362 additions and 10 deletions

View File

@@ -15,6 +15,10 @@
min-width: 220px;
max-height: 320px;
overflow-y: auto;
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
(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);
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-menu);

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

@@ -11,6 +11,13 @@
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 14px;
background: var(--dsw-specific-tip);
/* Elevated surface: `--dsw-specific-tip` is the same dark rung as the menu
surface, and `.list` scrolls inside this card, so the thumb takes the l2
elevation tokens. Declared here because the elevation belongs to the
surface, and the custom properties inherit down to `.list` (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);
}
.body {

View File

@@ -79,6 +79,13 @@
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
color: var(--dsw-alias-label-primary);
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens.
Declared here rather than on the scrolling `.groups` child so the
elevation choice sits with the surface; 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);
}
.status,

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

@@ -76,6 +76,13 @@
overflow: hidden;
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv3);
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens.
Declared on the panel rather than the scrolling `.options` child so the
elevation choice sits with the surface; the custom properties inherit
down to whichever descendant 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);
}
/* Nav rail (figma .Setting-nav 501:29958): 188 wide, pad (12,22,12,0),

View File

@@ -13,6 +13,10 @@
max-width: 537px;
max-height: 320px;
overflow-y: auto;
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens
(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);
padding: 4px;
display: flex;
flex-direction: column;

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 packages/client/ui-theme/README.md
README.md: 1227df357cb93241fcf28da9b74d7ba15207e9c5
README.zh.md: cd87ede7264c8d47dd780acaa11128e83d7862f9
README.md: a1ff7d840dae86f5da98de1208ecda3b8b62026b
README.zh.md: 49b52bcb1e07527e98c602086404228c5513091a

View File

@@ -4,6 +4,12 @@ English | [中文](README.zh.md)
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). Contract: api-contracts v3 §8.
`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 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
None, as the theme service manages a browser preference; nothing here reaches a model request.

View File

@@ -4,6 +4,12 @@
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好`light``dark``system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOMui-layout 的呈现器会应用解析后的快照(`html { color-scheme }``body[data-ds-dark-theme]`,以及主题的别名 token 内联变量。契约api-contracts v3 §8。
`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两条渲染路径都读取这一组变量。抬升表面菜单、浮层、对话框在自己的容器上设置 `--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)。
## 模型体验
无。主题服务管理浏览器偏好;这里没有任何内容进入模型请求。

View File

@@ -0,0 +1,85 @@
/* Scrollbar skin: the sole consumer of the four --dsw-alias-scrollbar-*
* tokens. Without it every scrolling region renders the UA scrollbar, which
* ignores the theme — a light native bar over the dark palette.
*
* 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`.
*
* 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
* 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);
}
/* 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.
`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;
}
::-webkit-scrollbar-thumb {
border-radius: 4px;
background: var(--dsh-scrollbar-thumb);
}
::-webkit-scrollbar-thumb:hover {
background: var(--dsh-scrollbar-thumb-hover);
}
/* Both scrollbars meeting in a corner: no separate token, so the corner
matches the transparent track rather than the UA's opaque default. */
::-webkit-scrollbar-corner {
background: transparent;
}

View File

@@ -0,0 +1,506 @@
/**
* 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
* elevated surfaces it paints, 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 contains which.
*/
interface SheetSurfaces {
/** Elevated surface tokens this sheet paints anywhere. */
elevated: 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']
/**
* Token families that name a SURFACE — a background an element is drawn on, and
* so something a scrollbar can sit against. `--dsw-alias-button-*`,
* `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same dark
* elevation rungs while naming a control or an inline span, which no scroll
* container renders its bar against (ChatView's floating `.toBottom` pill,
* CodeBlock's banner). Family, not geometry: a floating button legitimately
* carries a radius, a shadow, and a fixed size, so shape cannot separate them.
*/
const SURFACE_TOKEN_PATTERN = /^--dsw-(?:alias-bg-|specific-)/
/**
* The palette's own dark elevation ladder, resolved from `design-platform.css`:
* `bg-layer-2` and `bg-layer-3` are the rungs above the base surfaces, and the
* l1/l2 scrollbar split encodes exactly that step. Reading it from the palette
* rather than from the sheets that happen to rebind is what lets the check flag
* a surface NOBODY has rebound yet.
* @returns surface tokens whose dark value sits on an elevated rung.
*/
function elevatedRungs(): Set<string> {
const definitions = new Map<string, string>()
for (const rule of platformRules) {
// Dark declarations come later in the sheet and overwrite the light ones,
// which is the palette this distinction exists in.
for (const [property, value] of rule.declarations) definitions.set(property, value)
}
const resolve = (name: string): string => {
const seen = new Set<string>()
let current = name
while (definitions.has(current) && !seen.has(current)) {
seen.add(current)
const value = definitions.get(current)!
const [reference] = varReferences(value)
if (reference === undefined) return value
current = reference
}
return current
}
const rungs = new Set([resolve('--dsw-alias-bg-layer-2'), resolve('--dsw-alias-bg-layer-3')])
const tokens = new Set<string>()
for (const name of definitions.keys()) {
if (SURFACE_TOKEN_PATTERN.test(name) && rungs.has(resolve(name))) tokens.add(name)
}
return tokens
}
const elevatedSurfaces = elevatedRungs()
for (const file of packageStylesheets()) {
const rules = parseRules(readFileSync(file, 'utf8'))
const surfaces: SheetSurfaces = { elevated: 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) {
if (elevatedSurfaces.has(token)) surfaces.elevated.add(token)
}
if (rebinds) {
rebindRules.push({ file, rule })
surfaces.rebinds = true
}
}
sheetSurfaces.set(file, surfaces)
}
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('resolves the elevated surface set from the palette ladder', () => {
// The set has to come from the palette, not from the sheets that happen to
// rebind: derived from rebinds it can only confirm what someone already
// remembered, and a surface nobody has rebound yet — the case the check
// exists for — would define itself as unelevated. Anchoring it here means a
// new palette token on an elevated rung is in scope the moment it is
// defined. `--dsw-specific-tip` is the regression that proved the point: it
// resolves to the same dark rung as the menu surface, and the Todo panel
// scrolled on it unrebound while a rebind-derived set stayed green.
expect(elevatedSurfaces).toContain('--dsw-alias-bg-layer-2')
expect(elevatedSurfaces).toContain('--dsw-alias-bg-layer-3')
expect(elevatedSurfaces).toContain('--dsw-specific-menu')
expect(elevatedSurfaces).toContain('--dsw-specific-input-major')
expect(elevatedSurfaces).toContain('--dsw-specific-tip')
// Base surfaces stay out, or every scroll container would be in scope and
// the check would say nothing.
expect(elevatedSurfaces).not.toContain('--dsw-alias-bg-base')
expect(elevatedSurfaces).not.toContain('--dsw-alias-bg-layer-1')
})
it('every sheet that scrolls on an 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 both in
// review and in a light-palette screenshot. Four sheets shipped that way
// (ui-primitives Menu, InputBar, QuestionComposer, TodoPanel) 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. What keeps that from over-reporting is the token FAMILY: only
// `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface, so a floating
// button or an inline code span reaching the same rung is out of scope
// (ChatView's `.toBottom`, CodeBlock's banner). Geometry cannot make that
// call — a floating button carries a radius, a shadow, and a fixed size.
for (const [file, surfaces] of sheetSurfaces) {
if (!surfaces.scrolls || surfaces.rebinds) continue
expect([...surfaces.elevated], `${file} scrolls on an elevated surface without rebinding`).toEqual([])
}
})
})

View File

@@ -208,6 +208,13 @@
min-height: 0;
overflow-y: auto;
padding-bottom: 12px;
/* Row trailing content (the relative time, and the hover action buttons
that replace it) sits flush against the row's 8px right padding, so an
overlaid scrollbar covers it. Reserving the gutter keeps the bar beside
the rows instead of on top of them; `stable` holds the reservation when
the list is short enough not to scroll, so expanding a group does not
shift every row left. */
scrollbar-gutter: stable;
}
/* One workspace section: header row + expanded session run. Rows inside

View File

@@ -0,0 +1,48 @@
/**
* WorkspaceBrowser scroll-region style contract, asserted against the CSS text
* on disk: the session list reserves its scrollbar gutter so the scrollbar
* cannot overlay row trailing content, and reserves it whether or not the list
* currently overflows so expanding a group does not shift rows sideways.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
/**
* Declarations of one class rule, keyed by property with whitespace collapsed.
* Declaration order and trailing semicolons are normalized away.
* @param className - local class name, without the leading dot.
* @returns the rule's declarations, or undefined when no such rule exists.
*/
function declarations(className: string): Map<string, string> | undefined {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
const match = new RegExp(String.raw`(^|[\s,}])\.${className}\s*\{([^{}]*)\}`).exec(withoutComments)
if (match === null) return undefined
const found = new Map<string, string>()
// The body group is unconditional in the pattern; the fallback only satisfies
// noUncheckedIndexedAccess.
for (const part of (match[2] ?? '').split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
}
return found
}
describe('WorkspaceBrowser.module.css list', () => {
const list = declarations('list')
it('is the scrolling region', () => {
expect(list).toBeDefined()
expect(list!.get('overflow-y')).toBe('auto')
})
it('reserves the scrollbar gutter unconditionally', () => {
// Row trailing content sits flush against the row's right padding, so an
// overlay scrollbar covers it. `stable` keeps the reservation when the list
// is short enough not to scroll, so expanding a group does not shift rows.
expect(list!.get('scrollbar-gutter')).toBe('stable')
})
})

View File

@@ -1,8 +1,10 @@
/* Shell-owned global base: full-height mount plus the theme token sheets.
* The four ui-theme sheets are the sole token source (--dsw-*); the shell
* links them here so tokens exist before any plugin CSS lands. */
* The five ui-theme sheets are the sole token source (--dsw-*); the shell
* links them here so tokens exist before any plugin CSS lands. scrollbar.css
* follows design-platform.css because it reads that sheet's tokens. */
@import '@deepseek-ai/dsh-client-ui-theme/styles/base.css';
@import '@deepseek-ai/dsh-client-ui-theme/styles/design-platform.css';
@import '@deepseek-ai/dsh-client-ui-theme/styles/scrollbar.css';
@import '@deepseek-ai/dsh-client-ui-theme/styles/gradient-shadow-text.css';
@import '@deepseek-ai/dsh-client-ui-theme/styles/shiki.css';

View File

@@ -0,0 +1,58 @@
/**
* Shell base sheet contract, asserted against the CSS text on disk: base.css is
* where the ui-theme token sheets enter the bundle, every sheet it names exists,
* and scrollbar.css follows design-platform.css because it reads that sheet's
* tokens.
*/
import { existsSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const THEME_PACKAGE = '@deepseek-ai/dsh-client-ui-theme'
const baseCss = readFileSync(fileURLToPath(new URL('../src/base.css', import.meta.url)), 'utf8')
/**
* Import specifiers of the sheet, in source order. Quote style and surrounding
* whitespace are normalized away.
* @param css - stylesheet text.
* @returns each `@import` target in the order the sheet lists it.
*/
function importOrder(css: string): string[] {
// The destructuring default only satisfies noUncheckedIndexedAccess; the
// group is unconditional in the pattern.
return [...css.matchAll(/@import\s+['"]([^'"]+)['"]/g)].map(([, specifier = '']) => specifier)
}
/**
* Resolve a `<package>/styles/<file>` specifier to its path in the workspace.
* The theme package maps `./styles/*` to `./src/styles/*`, so the sheets stay
* on the source plane rather than needing a build.
* @param specifier - import specifier from base.css.
* @returns absolute path of the file the specifier names.
*/
function resolveThemeSheet(specifier: string): string {
const name = specifier.slice(`${THEME_PACKAGE}/styles/`.length)
return fileURLToPath(new URL(`../../ui-theme/src/styles/${name}`, import.meta.url))
}
const imports = importOrder(baseCss)
describe('web shell base.css', () => {
it('imports every sheet from the theme package and each one exists', () => {
expect(imports.length).toBeGreaterThan(0)
for (const specifier of imports) {
expect(specifier.startsWith(`${THEME_PACKAGE}/styles/`), specifier).toBe(true)
expect(existsSync(resolveThemeSheet(specifier)), specifier).toBe(true)
}
})
it('imports the scrollbar sheet after the token sheet it reads', () => {
// Both sheets bind on `body`, so with scrollbar.css first the alias tokens
// would still resolve; the order encodes the dependency direction so a
// later specificity or selector change cannot silently invert it.
const platform = imports.indexOf(`${THEME_PACKAGE}/styles/design-platform.css`)
const scrollbar = imports.indexOf(`${THEME_PACKAGE}/styles/scrollbar.css`)
expect(platform).toBeGreaterThanOrEqual(0)
expect(scrollbar).toBeGreaterThan(platform)
})
})