Files
apes-sidebar/tests/theme.spec.ts
T
Menghuan1918 421fd3fde7 fix(tasks): 小条的状态词与活动行统一字体(修掉 1px 基线错位)
用户反馈「运行中和正在分析请求没对齐」。实测是字体度量差异,不是布局:
两者行盒完全一致(11px/14px、align-items: center 下同一位置),但 11px 下
sans 字族的 fontBoundingBox ascent/descent 是 11/2,mono 字族是 10/3 ——
基线低 1px。中文在 mono 栈里本来就走同一个 CJK 回退(PingFang SC),
mono 只改到句中的拉丁片段,等于半句等宽 + 整体低一像素。

修法:.barActivity 不再声明 font-family,两段文字共用小条自己的字族;
机器文本的等宽语气保留在 meta 行 / 后台任务行 / 终端输出井。
theme.spec 新增守护(.barActivity 不得声明 font-family)。

真机证据:真实运行中卡片的小条(3× DPR 截图 17-bar-real.png)两段同处一行,
两个 span 的 font-family 计算值完全相同;此前同一小条 3× 截图里蓝字明显偏低。

验证:typecheck / lint ✅;pnpm test 1257 passed / 9 skipped(新增 1 例)。

(cherry picked from commit 6816af76828a436eaf201205e385c1aa4f77dcd5)
2026-09-27 22:18:57 +08:00

232 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Live theme-token access tests — issue #90's guard.
*
* `effectiveTokenValue` must treat visually inert values (CSS reset
* keywords, `transparent`, and any color below the opacity floor) as UNSET
* so callers' `|| fallback` chains fire. Skin systems set global tokens like
* `--dsw-alias-bg-base` to `transparent` or translucent glass values (the
* dsh-web-ui skins use rgba 0.16–0.7); without this guard a
* truthy-but-inert value would leave the terminal/editor see-through over
* the skin's backdrop. Effectively opaque values (>= 0.9 alpha, including a
* skin's scoped 0.96 porcelain) pass through so the skin still controls the
* surface.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { readFileSync, readdirSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import { colorAlpha, effectiveTokenValue, tokenValue } from '../src/client/theme.ts'
afterEach(() => {
document.body.removeAttribute('style')
})
describe('colorAlpha', () => {
it('parses the hex family', () => {
expect(colorAlpha('#fff')).toBe(1)
expect(colorAlpha('#fff8')).toBeCloseTo(0x88 / 255)
expect(colorAlpha('#112233')).toBe(1)
expect(colorAlpha('#11223344')).toBeCloseTo(0x44 / 255)
})
it('parses rgb()/rgba() in comma and space syntax', () => {
expect(colorAlpha('rgb(1, 2, 3)')).toBe(1)
expect(colorAlpha('rgba(255, 255, 255, 0.45)')).toBeCloseTo(0.45)
expect(colorAlpha('rgb(255 255 255 / 0.7)')).toBeCloseTo(0.7)
})
it('parses hsl()/hsla()', () => {
expect(colorAlpha('hsl(200, 50%, 50%)')).toBe(1)
expect(colorAlpha('hsla(200, 50%, 50%, 0.3)')).toBeCloseTo(0.3)
})
it('treats unparseable formats as opaque', () => {
expect(colorAlpha('white')).toBeNull()
expect(colorAlpha('')).toBeNull()
})
})
describe('effectiveTokenValue', () => {
it('passes through real opaque paint values verbatim', () => {
document.body.style.setProperty('--probe', '#112233')
expect(effectiveTokenValue('--probe')).toBe('#112233')
// Effectively opaque translucency (a skin's scoped 0.96 porcelain glass)
// is a deliberate surface choice — it passes through.
document.body.style.setProperty('--probe', 'rgba(10, 22, 54, 0.96)')
expect(effectiveTokenValue('--probe')).toBe('rgba(10, 22, 54, 0.96)')
})
it('treats transparent and CSS reset keywords as unset', () => {
for (const inert of ['transparent', 'initial', 'inherit', 'unset']) {
document.body.style.setProperty('--probe', inert)
expect(effectiveTokenValue('--probe'), inert).toBe('')
}
})
it('treats translucent glass values below the opacity floor as unset', () => {
// The dsh-web-ui skins set bg-base to rgba 0.16–0.7 for glass panels;
// the terminal must fall back to an opaque background there.
for (const glass of ['rgba(255, 255, 255, 0.45)', 'rgba(20, 26, 46, 0.7)', '#11223344']) {
document.body.style.setProperty('--probe', glass)
expect(effectiveTokenValue('--probe'), glass).toBe('')
}
})
it('treats a missing token as unset', () => {
expect(effectiveTokenValue('--never-defined')).toBe('')
})
it('tokenValue still returns the raw value', () => {
document.body.style.setProperty('--probe', 'transparent')
expect(tokenValue('--probe')).toBe('transparent')
expect(effectiveTokenValue('--probe')).toBe('')
})
})
/**
* Skin contract (guide §12): every visual value rides a `--dsw-alias-*` /
* `--dsw-font-*` / `--ds-*` token, and the plugin draws no color of its own.
*
* File icons are no exception to check any more: the built-in set IS DSH's
* `FileTypeIcon` artwork from a platform module (the host owns those pixels
* and its own palette), and everything the plugin renders around them —
* including the colored tab glyphs — rides theme tokens. So the guard is
* simply that no plugin module carries a color literal, that every module
* stylesheet paints `color` from a token, and that no icon dataset sneaked
* back in as a chunk.
*/
// jsdom has no file:// import.meta.url; vitest runs from the repo root.
const ROOT = process.cwd()
/** `color` as a property — never `background-color` / `scrollbar-color` / `-webkit-text-fill-color`. */
const COLOR_PROPERTY = /(?:^|[;{\s])color\s*:\s*([^;}]+)/g
/** Values that name no paint of their own and are therefore exempt. */
const INERT_COLOR = /^(?:inherit|currentcolor|transparent)$/i
/** A token allowed by the contract: the plugin's own `--dsw-*` or the host's `--ds-*`. */
function isThemeToken(name: string): boolean {
return name.startsWith('dsw-') || name.startsWith('ds-')
}
/** Every `*.module.css` sheet under `src/client/`, as repo-relative POSIX paths. */
function moduleStylesheets(): string[] {
return readdirSync(resolve(ROOT, 'src/client'), { recursive: true, encoding: 'utf8' })
.filter(name => name.endsWith('.module.css'))
.map(name => `src/client/${name.split(sep).join('/')}`)
}
describe('skin contract: the plugin owns no color of its own', () => {
it('the icon modules carry no color literals', () => {
for (const file of ['src/client/file-icons.tsx', 'src/client/icons.tsx']) {
const source = readFileSync(resolve(ROOT, file), 'utf8')
expect(source, file).not.toMatch(/#[0-9a-fA-F]{3,8}\b/)
expect(source, file).not.toMatch(/\brgba?\(/)
}
})
it('the colored tab glyphs take their color from theme tokens, never from a literal', () => {
const module = readFileSync(resolve(ROOT, 'src/client/builtins/tab-icons.tsx'), 'utf8')
expect(module).not.toMatch(/#[0-9a-fA-F]{3,8}\b/)
expect(module).not.toMatch(/\brgba?\(/)
// Each glyph is wrapped in a themed class, which is how the color arrives.
const styles = readFileSync(resolve(ROOT, 'src/client/builtins/tab-icons.module.css'), 'utf8')
const classes = [...module.matchAll(/css\.([a-zA-Z]+)/g)].map(m => m[1])
expect(classes.length).toBeGreaterThan(0)
for (const name of new Set(classes)) expect(styles, name).toContain(`.${name}`)
// Every declaration that paints a color resolves to a token.
for (const declaration of styles.matchAll(/color:\s*([^;]+);/g)) {
expect(declaration[1], declaration[0]).toContain('var(--dsw-')
}
})
it('every module stylesheet paints `color` from a theme token', () => {
const sheets = moduleStylesheets()
// Guard the scan itself: a glob that silently matches nothing would make
// this contract vacuous.
expect(sheets.length).toBeGreaterThan(5)
for (const file of sheets) {
const styles = readFileSync(resolve(ROOT, file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
for (const declaration of styles.matchAll(COLOR_PROPERTY)) {
const value = declaration[1]!.trim()
// `color: inherit` / `currentcolor` / `transparent` is the plugin
// deliberately taking the surrounding color, not painting one.
if (INERT_COLOR.test(value)) continue
// Dereference every var() — `color-mix()` chains are fine as long as
// the whole expression bottoms out in theme tokens.
const tokens = [...value.matchAll(/var\(--([a-z0-9-]+)/gi)].map(match => match[1]!)
expect(tokens.length, `${file}: color: ${value}`).toBeGreaterThan(0)
for (const name of tokens) expect(isThemeToken(name), `${file}: color: ${value}`).toBe(true)
}
}
})
it('the running bar sweeps the FULL width (a band either side, not a partial blob)', () => {
// Reader report: the first cut moved a 45%-wide band with
// `background-position`, so the bar was only ever partially lit. The
// corrected rule is a bar-wide band whose TRANSFORM travels one full width
// either side of the bar — entered at the left edge, covering the bar
// mid-flight, gone past the right edge.
const styles = readFileSync(resolve(ROOT, 'src/client/tasks-graph.module.css'), 'utf8')
const block = /\.cardBar\[data-running='true'\]::after \{([\s\S]*?)\n\}/.exec(styles)?.[1]
if (block === undefined) throw new Error('the running bar sweep rule must exist')
expect(block).toContain('width: 100%')
expect(block).toContain('transform: translateX(-100%)')
// No residual background-position travel (the partial-sweep mechanism).
expect(block).not.toContain('background-position')
expect(block).not.toContain('background-size')
const frames = /@keyframes dsh-tasks-bar-sweep \{([\s\S]*?)\n\}/.exec(styles)?.[1]
if (frames === undefined) throw new Error('the sweep keyframes must exist')
expect(frames).toContain('transform: translateX(-100%)')
expect(frames).toContain('transform: translateX(100%)')
})
it('the running bar keeps its state word and activity on ONE font stack', () => {
// Reader report: 运行中 and 正在分析请求 sat at different heights. The
// cause is metric, not layout — at 11px the sans stack is ascent/descent
// 11/2 against the mono stack's 10/3, so a mono activity line rests 1px
// lower. Both texts must therefore inherit the bar's own font.
const styles = readFileSync(resolve(ROOT, 'src/client/tasks-graph.module.css'), 'utf8')
const block = /\.barActivity \{([\s\S]*?)\n\}/.exec(styles)?.[1]
if (block === undefined) throw new Error('the .barActivity rule must exist')
expect(block).not.toContain('font-family')
expect(block).not.toContain('--ds-font-family-code')
})
it('the empty-pane welcome capsule follows the host guide recipe', () => {
// The card is the same surface as DSH's own guide capsule ("pick what
// opens here"), so this pins the shared geometry and the token-only
// paint. A literal here would drift away from the host under every skin.
const styles = readFileSync(resolve(ROOT, 'src/client/sidebar.module.css'), 'utf8')
const block = /\.paneCard \{([\s\S]*?)\n\}/.exec(styles)?.[1]
if (block === undefined) throw new Error('the .paneCard rule must exist in src/client/sidebar.module.css')
expect(block).toContain('border: 0.5px solid var(--dsw-alias-border-l4)')
expect(block).toContain('border-radius: 24px')
expect(block).toContain('background: var(--dsw-alias-bg-layer-1)')
expect(block).toContain('min-height: 56px')
expect(block).toContain('padding: 14px 20px')
// No paint of its own: every color-ish declaration resolves to a token,
// and the upstream-only spellings this repo must not copy are absent.
for (const declaration of block.matchAll(/(?:^|\n)\s*(?:background|border|color|font):\s*([^;]*);/g)) {
const value = declaration[1] ?? ''
// A literal here (hex, rgba, a named colour) would drift from the host
// under every skin, so any keyword whose value is not a token fails.
expect(
value === '' || value.includes('var(--dsw-'),
`${declaration[0].trim()} must paint through a --dsw-* token (got: ${value})`,
).toBe(true)
}
expect(block).not.toContain('--dsw-alias-bg-l1')
expect(block).not.toContain('--dsw-alias-bg-l2')
})
it('no icon dataset is shipped as a lazy chunk', () => {
const chunkDir = resolve(ROOT, 'src/client/chunks')
const chunks = readdirSync(chunkDir).filter(name => /\.tsx?$/.test(name))
for (const name of chunks) {
const source = readFileSync(resolve(chunkDir, name), 'utf8')
expect(source, name).not.toMatch(/#[0-9a-fA-F]{6}\b/)
}
})
})