用户反馈「运行中和正在分析请求没对齐」。实测是字体度量差异,不是布局:
两者行盒完全一致(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)
232 lines
11 KiB
TypeScript
232 lines
11 KiB
TypeScript
/**
|
||
* 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/)
|
||
}
|
||
})
|
||
})
|