- 新 chunk `file-icons`(`lib/client-file-icons.js`,255 kB / gzip 90 kB):把数据表 注册成 `registerFileIcon` 描述符——按 glyph+color 归并,563 条规则 → 约 250 条注册; 目录规则只走 `folderNames`(只认领列出的目录,未列出的仍用内置 glyph)。 - `src/client/file-icon-theme.ts`:pref → loadChunk → 注册/注销,generation 守卫 丢弃「切换中途落地」的加载;chunk 拉取失败只 log,保持内置 glyph。 - pref `fileIconTheme`(默认 `'builtin'`,未知值按 builtin 处理)+ 设置页 「文件 → 文件图标」下拉(内置单色 / 彩色品牌图标)。 - chunk 名同步四处(chunk-loader / bundle-route / tsdown / manifest-consistency)+ `bundle-route.spec`;tsdown 为该 chunk 加 react-icons ESM alias——否则整包打进 6.4 MB,alias 后 255 kB。 - i18n:6 个新 key 镜像到全部 19 份词典(zh/en 之外),locale 键集守护通过。 - 皮肤契约:指南 §12 增豁免条款(品牌色是内容非 chrome,仅限该懒加载 chunk), AGENTS §5 同步;`tests/theme.spec.ts` 新增边界用例(核心图标模块零颜色字面量、 核心不静态 import 该 chunk)。 - 测试:`tests/file-icon-theme.spec.tsx` 7 例(loader 六态 + 真实数据三表注册); prefs 新增默认值/解析用例;builtins / prefs / plugin-shape / smoke 夹具补字段。 - 全量 `pnpm test` 失败集与 main 基线逐条一致(5 文件 / 26 例预存环境失败), 新增用例全绿;typecheck / lint / build / check:consumer-types 全通过。
121 lines
4.9 KiB
TypeScript
121 lines
4.9 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 } 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. The ONE documented exception is the
|
||
* optional colored icon theme: brand colors are identity, not chrome, so
|
||
* they are hardcoded — but only inside the lazy `file-icons` chunk, which
|
||
* the core bundle must never import. This guards that boundary.
|
||
*/
|
||
// jsdom has no file:// import.meta.url; vitest runs from the repo root.
|
||
const ROOT = process.cwd()
|
||
|
||
describe('skin contract boundary for hardcoded icon colors', () => {
|
||
const coreIconModules = ['src/client/file-icons.tsx', 'src/client/file-icon-theme.ts']
|
||
|
||
it('the core icon modules carry no color literals', () => {
|
||
for (const file of coreIconModules) {
|
||
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('only the lazy chunk may carry the colored dataset, and the core never imports it statically', () => {
|
||
const chunk = readFileSync(resolve(ROOT, 'src/client/chunks/file-icons.tsx'), 'utf8')
|
||
expect(chunk).toMatch(/#[0-9a-fA-F]{6}/)
|
||
const clientDir = resolve(ROOT, 'src/client')
|
||
const offenders: string[] = []
|
||
for (const entry of readdirSync(clientDir, { recursive: true, withFileTypes: true })) {
|
||
if (!entry.isFile() || !/\.tsx?$/.test(entry.name)) continue
|
||
const path = resolve(entry.parentPath, entry.name)
|
||
if (path.includes('/chunks/')) continue
|
||
if (/from '[^']*chunks\/file-icons/.test(readFileSync(path, 'utf8'))) offenders.push(path)
|
||
}
|
||
expect(offenders).toEqual([])
|
||
})
|
||
})
|