Files
apes-sidebar/tests/theme.spec.ts
T
Menghuan1918 7cce50c493 fix(tasks): 扫光改为整条完整扫过 + 折叠/展开按钮分向
用户反馈两条:

1) 扫光只有「部分」:第一版是 45% 宽的窄带 + background-position 位移,
   亮带始终只覆盖小条的一部分(峰值 22.5%→77.5%,两端扫不到),看起来像
   中间游走的一块光斑。改为与小条等宽的渐变带 + transform
   translateX(-100%) → translateX(100%):光带从左外侧进入、中途覆盖整条、
   再向右外侧离开,行程用条宽本身表达(任意条宽成立),末 1/3 周期停在
   右侧外侧留白。theme.spec 新增守护钉住该形状(宽度 100%、transform 行程、
   不再有 background-position/size 位移)。

2) 折叠按钮与展开按钮长得一样:三处控件原本共用向下箭头(控制条的折叠开关
   更是无论折叠还是展开都画同一个清单图标)。现按动作方向区分——
   卡片小条的逐节点按钮 = chevron up(收进聚合),聚合卡片 = chevron down
   (展开),控制条开关随状态在两者之间切换(并复用同一个折叠图标)。
   组件守护见 tasks-page.spec 的「draws COLLAPSE and EXPAND with different
   glyphs」。

真机证据(3080,真实运行中的后台任务):242px 小条上每 90ms 采样 ::after 的
matrix 平移量得到 [-23,32,85,131,163,-139,-94,-46,9,64,114,152,179,188]
(左侧 -139 → 右侧 +188,跨度超过一个条宽,单调推进);三处 svg 比对
expandVsCollapse / clusterMatchesExpand / clusterFlips / clusterCollapseMatchesCard
全部为 true。

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

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

220 lines
10 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 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/)
}
})
})