Files
Genarrative/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts
T
kdletters e072c66ce9
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
修复 AGC 模板库筛选与卡片状态的表现缺陷
- 模板库筛选区改用共享筛选条与标签 chip:选中为实心品牌填充 + 反白文字,未选为浅底描边
- 未选中悬停不再借用品牌色(品牌色专属已选中),状态阶梯固定为静止 / 悬停 / 按下 / 选中
- 新增 getPlatformCategoryChipClassName 收敛筛选 chip 类名口径,模板库、资源画布、参考图弹窗与平台 Web 端共用
- PlatformSegmentedTabs 增加 accent 选中口径,与筛选 chip 共用 --platform-chip-* 语义色
- 修复卡片文字区按 grid auto 行排版导致的标题 / 简介 / 标签被裁:行高契约拆为分项常量,文字区固定 174
- 修复忙状态下动作行塞入第三个元素导致的按钮文案换行顶出卡片:忙状态写在触发它的按钮上
- 修复经典滚动条占宽导致的卡片区横向滚动条:按竖滚动条预留列宽后再算列宽行高
- 焦点环由 15% 透明度改为实心色并用 outline 绘制,选中项聚焦不再被状态投影盖掉
- 封面加载失败改为隐藏图片留中性底色,不再出现浏览器裂图图标
- 补充状态对比度、行高契约、滚动条预留、封面兜底与忙状态文案的回归测试
- 同步技术方案、踩坑记录与决策记录的口径
2026-09-23 16:51:15 +08:00

271 lines
9.2 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { repoPath } from './repoPath';
type Rgba = readonly [number, number, number, number];
const themePath = repoPath('packages/shared/src/theme.css');
function getCssBlock(source: string, selector: string) {
const selectorIndex = source.indexOf(selector);
expect(selectorIndex, `${selector} should exist`).toBeGreaterThanOrEqual(0);
const openBraceIndex = source.indexOf('{', selectorIndex);
let depth = 0;
for (let index = openBraceIndex; index < source.length; index += 1) {
const char = source[index];
if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(openBraceIndex + 1, index);
}
}
}
throw new Error(`${selector} block is not closed`);
}
function getCssVariable(block: string, variableName: string) {
const match = block.match(new RegExp(`${variableName}:\\s*([^;]+);`));
expect(match, `${variableName} should exist`).not.toBeNull();
return match![1].trim();
}
function parseCssColor(source: string): Rgba {
const value = source.trim();
const hex = value.match(/^#([\da-f]{6})$/i);
if (hex) {
return [
Number.parseInt(hex[1].slice(0, 2), 16),
Number.parseInt(hex[1].slice(2, 4), 16),
Number.parseInt(hex[1].slice(4, 6), 16),
1,
];
}
const functional = value.match(/^rgba?\((.*)\)$/i);
if (!functional) {
throw new Error(`unsupported color: ${value}`);
}
const parts = functional[1]
.replace('/', ' ')
.split(/[,\s]+/)
.filter(Boolean);
return [
Number(parts[0]),
Number(parts[1]),
Number(parts[2]),
parts[3] === undefined ? 1 : Number(parts[3]),
];
}
function compositeColor(foreground: Rgba, background: Rgba): Rgba {
const alpha = foreground[3] + background[3] * (1 - foreground[3]);
return [
(foreground[0] * foreground[3] +
background[0] * background[3] * (1 - foreground[3])) /
alpha,
(foreground[1] * foreground[3] +
background[1] * background[3] * (1 - foreground[3])) /
alpha,
(foreground[2] * foreground[3] +
background[2] * background[3] * (1 - foreground[3])) /
alpha,
alpha,
];
}
function contrastRatio(first: Rgba, second: Rgba) {
const luminance = ([red, green, blue]: Rgba) => {
const channels = [red, green, blue].map((channel) => {
const value = channel / 255;
return value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4;
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
};
const firstLuminance = luminance(first);
const secondLuminance = luminance(second);
return (
(Math.max(firstLuminance, secondLuminance) + 0.05) /
(Math.min(firstLuminance, secondLuminance) + 0.05)
);
}
/** 取渐变里的色标(`linear-gradient(135deg, #b3542f, #8f3f22)` → 两个颜色)。 */
function parseGradientStops(source: string): Rgba[] {
const body = source.slice(source.indexOf('(') + 1, source.lastIndexOf(')'));
return body
.split(',')
.map((part) => part.trim())
.filter((part) => part.startsWith('#') || part.startsWith('rgb'))
.map((part) => parseCssColor(part.split(/\s+/)[0] ?? part));
}
/** 页面背景(`--platform-body-fill`)里的不透明色标:chip 实际落在这层之上。 */
function parseBodyFillUnderlays(source: string): Rgba[] {
return Array.from(source.matchAll(/#[\da-f]{6}/gi)).map((match) =>
parseCssColor(match[0]),
);
}
describe('workbench theme contrast', () => {
/**
* 筛选 chip 的两态对比:用户反馈「选中和没选中的颜色看不出差别」,根因是选中态
* 只换了低透明度的暖色底(两态对比 1.09:1)。这里把「选中 = 实心填充」这条口径
* 钉死:反白文字在渐变两端都要过 AA,且与未选底色至少差 3:1。
*/
it('keeps the chip selected state legible and distinct in both themes', () => {
const css = readFileSync(themePath, 'utf8');
const themes = [
{
name: 'light',
block: getCssBlock(css, '.platform-theme--light'),
// 浅色主题下 chip 落在页面底色上,用页面渐变的最亮与最暗色标夹住两种情况。
idleUnderlays: parseBodyFillUnderlays(
getCssVariable(
getCssBlock(css, '.platform-theme--light'),
'--platform-body-fill',
),
),
},
{
name: 'dark',
block: getCssBlock(css, '.platform-theme--dark'),
idleUnderlays: parseBodyFillUnderlays(
getCssVariable(
getCssBlock(css, '.platform-theme--dark'),
'--platform-body-fill',
),
),
},
];
expect(
parseGradientStops('linear-gradient(135deg, #b3542f, #8f3f22)'),
).toEqual([
[179, 84, 47, 1],
[143, 63, 34, 1],
]);
for (const theme of themes) {
const activeFill = parseGradientStops(
getCssVariable(theme.block, '--platform-chip-active-fill'),
);
const activeText = parseCssColor(
getCssVariable(theme.block, '--platform-chip-active-text'),
);
const idleFill = parseCssColor(
getCssVariable(theme.block, '--platform-chip-idle-fill'),
);
expect(activeFill, `${theme.name} active gradient stops`).toHaveLength(2);
expect(
theme.idleUnderlays.length,
`${theme.name} body fill stops`,
).toBeGreaterThan(0);
// 反白文字:渐变两端都要过 AA,不能只保证深的那一端。
for (const stop of activeFill) {
expect(
contrastRatio(activeText, stop),
`${theme.name} label on fill ${stop.slice(0, 3).join(',')}`,
).toBeGreaterThanOrEqual(4.5);
}
// 两态可分辨:选中填充与任意页面底色上的未选 chip 至少差 3:1。
for (const underlay of theme.idleUnderlays) {
const idleChip = compositeColor(idleFill, underlay);
for (const stop of activeFill) {
expect(
contrastRatio(stop, idleChip),
`${theme.name} selected vs idle over ${underlay.slice(0, 3).join(',')}`,
).toBeGreaterThanOrEqual(3);
}
}
}
});
/**
* 焦点环可见性:键盘用户靠它找焦点。旧口径是 15% 透明度的暖色,合成到页面底色只有
* 1.17:1——等于没有焦点提示。这里按 WCAG 非文本对比 3:1 钉住两套皮肤。
*/
it('keeps the keyboard focus ring visible in both themes', () => {
const css = readFileSync(themePath, 'utf8');
for (const selector of [
'.platform-theme--light',
'.platform-theme--dark',
]) {
const block = getCssBlock(css, selector);
const ring = parseCssColor(
getCssVariable(block, '--platform-input-focus-ring'),
);
const underlays = parseBodyFillUnderlays(
getCssVariable(block, '--platform-body-fill'),
);
expect(underlays.length, `${selector} body fill stops`).toBeGreaterThan(
0,
);
for (const underlay of underlays) {
expect(
contrastRatio(ring, underlay),
`${selector} focus ring over ${underlay.slice(0, 3).join(',')}`,
).toBeGreaterThanOrEqual(3);
}
}
});
it('keeps warm user bubbles above WCAG AA text contrast', () => {
const css = readFileSync(themePath, 'utf8');
const light = getCssBlock(css, '.platform-theme--light');
const dark = getCssBlock(css, '.platform-theme--dark');
const bubbleBackground = (theme: string, underlay: Rgba) =>
compositeColor(
parseCssColor(getCssVariable(theme, '--platform-warm-bg')),
compositeColor(
parseCssColor(getCssVariable(theme, '--platform-input-fill')),
underlay,
),
);
// Pure black is darker than any real light-theme workbench underlay.
const lightContrast = contrastRatio(
parseCssColor(getCssVariable(light, '--platform-text-base')),
bubbleBackground(light, [0, 0, 0, 1]),
);
const darkBodyFill = getCssVariable(dark, '--platform-body-fill');
const darkPanelFill = getCssVariable(dark, '--platform-desktop-panel-fill');
expect(darkBodyFill).toContain('rgba(129, 140, 248, 0.2)');
expect(darkBodyFill).toContain('rgba(59, 130, 246, 0.12)');
expect(darkBodyFill).toContain('rgba(34, 211, 238, 0.08)');
expect(darkBodyFill).toContain('#13151c');
expect(darkPanelFill).toContain('rgba(255, 255, 255, 0.05)');
// Conservative upper bound: the brightest body stop, every radial peak,
// and the brightest desktop-panel overlay are composed at full strength.
const darkBrightestUnderlay = compositeColor(
[255, 255, 255, 0.05],
compositeColor(
[129, 140, 248, 0.2],
compositeColor(
[59, 130, 246, 0.12],
compositeColor([34, 211, 238, 0.08], [19, 21, 28, 1]),
),
),
);
const darkContrast = contrastRatio(
parseCssColor(getCssVariable(dark, '--platform-text-base')),
bubbleBackground(dark, darkBrightestUnderlay),
);
expect(contrastRatio([0, 0, 0, 1], [255, 255, 255, 1])).toBeCloseTo(21);
expect(lightContrast).toBeGreaterThanOrEqual(4.5);
expect(darkContrast).toBeGreaterThanOrEqual(4.5);
});
});