Files
Genarrative/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts
T
suzmii 2a2e4c1cea 按 review 修:动画在途拦截、样式守卫扩到三面板、窄屏差异显式放行、用例不再依赖 cwd
- 在途拦截补齐生成动画:提交记录的「属于哪张资源」从 draftKey 拆成 resourcePath,
  `resourceCanvasResourceEditSubmissionBlocksResource` 被快速编辑与生成动画共用;
  此前动画这条键对不上、拦不住,重开面板再提交会复用同一 operationId 卡在原生锁上,
  或(改了提示词)直接多出一笔付费生成
- 样式缺口守卫扩到三块面板(动画面板 / 快速编辑面板 / 生图面板),判据改为按
  「选择器分支 + 声明」逐条比对;两个面板的实测缺口一并照抄补上(快速编辑面板的
  `overflow: auto → visible` 与窄屏底栏两条按宿主差异显式登记,不静默漂移)
- 窄屏只搬动画面板那一支:网页端 ≤760px 会把快速编辑浮层也变成整宽底栏,AGC 的
  快速编辑是「居中贴资源卡」的绝对定位,跟随它会压住栏目工具栏与任务侧栏
- 用例路径不再依赖 cwd:新增 tests/repoPath.ts(按 import.meta.url 反推仓库根),
  19 个用例文件的 `resolve(process.cwd(), …)` 统一改用它;顺手修掉
  `HOST_STYLE_SHEETS.filter(existsSync)` 把仓库相对路径交给 cwd 解析的假红
- 清掉两处死代码:不可达的 `quickEditPanel.status === 'generating'` 判据与
  未被使用的 prune 导出
2026-09-21 01:23:35 +08:00

150 lines
4.8 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)
);
}
describe('workbench theme contrast', () => {
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);
});
});