fix(client): theme the scrollbars and reserve the workspace list gutter

design-platform.css declared four --dsw-alias-scrollbar-* tokens in both
palettes that no rule read, so every scrolling region rendered the user
agent's own scrollbar and the dark theme showed a light native bar against
dark surfaces.

The symptom that surfaced the gap was in the sidebar: the workspace
browser's session list is its only scrolling region, and each row's
trailing content (the relative timestamp, and the hover action buttons
that replace it) is `flex: none` flush against the row's 8px right
padding, so an overlaid scrollbar painted on top of the timestamp.

ui-theme/styles/scrollbar.css becomes the sole consumer of the four
tokens, imported by the web shell's base.css after design-platform.css
because it reads that sheet's tokens. The rules sit on `body`, not
`html`: the alias tokens are declared on `body`, custom properties
inherit only downward, and from `html` they resolve to the
guaranteed-invalid value with scrollbar-color computing to `auto`.
scrollbar-width and scrollbar-color are declared on `body, body *` rather
than inherited, because inheritance would carry the color already
substituted at `body` and an elevated surface could not retint its own
thumb; scrollbar-width does not inherit at all.

Both the standard properties and the ::-webkit-scrollbar pseudo-elements
read one indirection pair bound to the l1 tokens, so an elevated surface
rebinds that pair to the l2 tokens once and retints both renderings. The
command popup, slash menu, model-select panel, and settings panel do so,
which gives the l2 tokens their first consumers.

WorkspaceBrowser's `.list` declares scrollbar-gutter: stable, keeping the
bar beside the rows. `stable` rather than `auto` so the reservation holds
when the list is short enough not to scroll: expanding a workspace group
would otherwise shift every row sideways at the moment it starts
scrolling.
This commit is contained in:
Chinesezjc
2026-07-28 11:02:11 +08:00
parent c49c0ba497
commit 662089dd76
17 changed files with 848 additions and 4 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

@@ -77,6 +77,13 @@
background: var(--dsw-specific-input-major);
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

@@ -74,6 +74,13 @@
overflow: hidden;
background: var(--dsw-alias-bg-layer-1);
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: 9bf232d506c599a6302c04d5769b43993d84dbf6
README.zh.md: 84dba38d751b74c13f4af42c40484995900dfc12

View File

@@ -4,6 +4,10 @@ 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 the standard `scrollbar-color` and the `::-webkit-scrollbar-thumb` rules 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 both renderings. 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,10 @@
主题插件:基于 --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标准属性 `scrollbar-color``::-webkit-scrollbar-thumb` 规则都读取这一组变量。抬升表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)``--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为两种渲染同时换色。推理过程与实测计算值见[滚动条 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。
## 模型体验
无。主题服务管理浏览器偏好;这里没有任何内容进入模型请求。

View File

@@ -0,0 +1,66 @@
/* 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 rule sits 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`.
*
* `scrollbar-color` is an inherited property, so binding it once on `body`
* reaches every scroll container without enumerating module class names.
* `scrollbar-width` is NOT inherited, so it is applied to all elements.
* The WebKit pseudo-elements are not inherited either, hence the unscoped
* `::-webkit-scrollbar` rules.
*
* 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
* the standard properties and the WebKit pseudo-elements read the
* indirection, so one rebind reaches both renderings. */
body {
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1);
}
/* `scrollbar-color` and `scrollbar-width` are 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.
Track stays transparent so the thumb reads against whatever surface
scrolls under it; only the thumb carries a token colour. */
body,
body * {
scrollbar-width: thin;
scrollbar-color: var(--dsh-scrollbar-thumb) transparent;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-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,311 @@
/**
* 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
}
/**
* 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 }[] = []
for (const file of packageStylesheets()) {
const rules = parseRules(readFileSync(file, 'utf8'))
for (const rule of rules) {
let rebinds = false
for (const [property, value] of rule.declarations) {
if (property.startsWith(INDIRECTION_PREFIX) && file !== fileURLToPath(new URL('scrollbar.css', STYLES))) rebinds = true
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 })
}
}
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('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$/)
}
}
}
})
})

View File

@@ -207,6 +207,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)
})
})