Merge branch 'master' into xtr/identified-immutable-messages
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-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)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。主题服务管理浏览器偏好;这里没有任何内容进入模型请求。
|
||||
|
||||
85
packages/client/ui-theme/src/styles/scrollbar.css
Normal file
85
packages/client/ui-theme/src/styles/scrollbar.css
Normal 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;
|
||||
}
|
||||
506
packages/client/ui-theme/tests/scrollbar-styles.spec.ts
Normal file
506
packages/client/ui-theme/tests/scrollbar-styles.spec.ts
Normal 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([])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
48
packages/client/ui-workspace/tests/browser-styles.spec.ts
Normal file
48
packages/client/ui-workspace/tests/browser-styles.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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';
|
||||
|
||||
|
||||
58
packages/client/web/tests/base-styles.spec.ts
Normal file
58
packages/client/web/tests/base-styles.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user