补强项目总控气泡回归合同
显式锁定执行过程卡占满消息区宽度 验证执行过程卡保持为消息列表直接子项 新增浅色与深色主题气泡 WCAG 对比度测试
This commit is contained in:
@@ -6278,6 +6278,12 @@ iframe.preview-frame {
|
||||
color: var(--platform-text-base);
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-supervisor-message-list
|
||||
> .project-supervisor-process-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.game-workbench-chat .agent-runtime-status {
|
||||
max-height: clamp(120px, 24dvh, 240px);
|
||||
min-height: 0;
|
||||
|
||||
@@ -3499,6 +3499,12 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
styles.match(
|
||||
/\.game-workbench-chat \.project-supervisor-message-list \.message--user\s*\{([^}]*)\}/s,
|
||||
)?.[1] ?? '';
|
||||
const processCardRules = Array.from(
|
||||
styles.matchAll(
|
||||
/\.game-workbench-chat\s+\.project-supervisor-message-list\s*>\s*\.project-supervisor-process-card\s*\{([^}]*)\}/gs,
|
||||
),
|
||||
(match) => match[1],
|
||||
);
|
||||
|
||||
expect(messageListRule).not.toBe('');
|
||||
expect(messageListRule).not.toContain('display: flex;');
|
||||
@@ -3511,6 +3517,8 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(userMessageRule).toContain('border-radius: 14px 14px 4px;');
|
||||
expect(userMessageRule).toContain('background: var(--platform-warm-bg);');
|
||||
expect(userMessageRule).toContain('color: var(--platform-text-base);');
|
||||
expect(processCardRules).toHaveLength(1);
|
||||
expect(processCardRules[0]).toContain('width: 100%;');
|
||||
});
|
||||
|
||||
it('enables the run presentation and renders registered images in the resource viewer', async () => {
|
||||
@@ -9310,6 +9318,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
});
|
||||
const waitingProcessCard =
|
||||
within(directMessageList).getByLabelText('陶泥儿执行过程');
|
||||
expect(waitingProcessCard.parentElement).toBe(directMessageList);
|
||||
expect(
|
||||
within(waitingProcessCard).getByText('已发送消息,正在等待陶泥儿回复'),
|
||||
).not.toBeNull();
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
type Rgba = readonly [number, number, number, number];
|
||||
|
||||
const themePath = resolve(process.cwd(), '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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user