Files
openless/openless-all/app/scripts/aura-skin-contract.test.mjs
T
HKLHaoBinandCursor 356e8ad480 Fix Style page dark mode readability with style tokens.
Replace hardcoded light card backgrounds with --ol-style-* tokens and strengthen dark subtle/input contrast.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 00:47:53 +08:00

488 lines
16 KiB
JavaScript

import { readFile, readdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import assert from 'node:assert/strict';
const root = new URL('../', import.meta.url);
async function read(relPath) {
return readFile(new URL(relPath, root), 'utf8');
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractCssBlock(css, selector) {
const escapedSelector = escapeRegExp(selector);
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\n\\}`));
assert.ok(match, `tokens.css must contain ${selector} block`);
return match[1];
}
function parseCustomProperties(block) {
const tokens = new Map();
const re = /^\s*(--[\w-]+)\s*:\s*([^;]+);/gm;
let match;
while ((match = re.exec(block)) !== null) {
tokens.set(match[1], match[2].trim());
}
return tokens;
}
function resolveTokenValue(name, tokens, stack = new Set()) {
const raw = tokens.get(name);
assert.ok(raw, `missing token ${name}`);
if (!raw.startsWith('var(')) {
return raw;
}
const inner = raw.slice(4, -1).trim();
const refName = inner.split(',')[0].trim();
assert.ok(refName.startsWith('--'), `unsupported var() reference in ${name}: ${raw}`);
if (stack.has(refName)) {
throw new Error(`circular var() reference: ${[...stack, refName].join(' -> ')}`);
}
stack.add(refName);
return resolveTokenValue(refName, tokens, stack);
}
function parseCssColor(value) {
const hexMatch = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hexMatch) {
let hex = hexMatch[1];
if (hex.length === 3) {
hex = hex
.split('')
.map((ch) => ch + ch)
.join('');
}
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
a: 1,
};
}
const rgbaMatch = value.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i);
if (rgbaMatch) {
return {
r: Number(rgbaMatch[1]),
g: Number(rgbaMatch[2]),
b: Number(rgbaMatch[3]),
a: rgbaMatch[4] === undefined ? 1 : Number(rgbaMatch[4]),
};
}
throw new Error(`unsupported color format: ${value}`);
}
function srgbChannel(value) {
const normalized = value / 255;
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
}
function relativeLuminance({ r, g, b }) {
const R = srgbChannel(r);
const G = srgbChannel(g);
const B = srgbChannel(b);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
function contrastRatio(foreground, background) {
const fg = parseCssColor(foreground);
const bg = parseCssColor(background);
assert.equal(fg.a, 1, `foreground must be opaque for contrast checks: ${foreground}`);
assert.equal(bg.a, 1, `background must be opaque for contrast checks: ${background}`);
const lighter = Math.max(relativeLuminance(fg), relativeLuminance(bg));
const darker = Math.min(relativeLuminance(fg), relativeLuminance(bg));
return (lighter + 0.05) / (darker + 0.05);
}
function compositeOverBackground(fgValue, bgValue) {
const fg = parseCssColor(fgValue);
const bg = parseCssColor(bgValue);
if (fg.a === 1) {
return fgValue;
}
const alpha = fg.a;
const r = Math.round(fg.r * alpha + bg.r * (1 - alpha));
const g = Math.round(fg.g * alpha + bg.g * (1 - alpha));
const b = Math.round(fg.b * alpha + bg.b * (1 - alpha));
return `rgb(${r}, ${g}, ${b})`;
}
function contrastRatioOverBackground(foreground, background) {
const effectiveFg = compositeOverBackground(foreground, background);
return contrastRatio(effectiveFg, background);
}
function assertSolidContrast(tokens, label, bgToken, inkToken, minRatio = 4.5) {
const bg = resolveTokenValue(bgToken, tokens);
const ink = resolveTokenValue(inkToken, tokens);
const ratio = contrastRatio(ink, bg);
assert.ok(
ratio >= minRatio,
`${label}: ${inkToken} on ${bgToken} must meet WCAG AA (${ratio.toFixed(2)}:1 < ${minRatio}:1)`,
);
return ratio;
}
function assertMutedContrast(tokens, label, bgToken, inkToken, minRatio = 4.5) {
const bg = resolveTokenValue(bgToken, tokens);
const ink = resolveTokenValue(inkToken, tokens);
const ratio = contrastRatioOverBackground(ink, bg);
assert.ok(
ratio >= minRatio,
`${label}: ${inkToken} on ${bgToken} must meet WCAG AA (${ratio.toFixed(2)}:1 < ${minRatio}:1)`,
);
return ratio;
}
function assertUsesClassName(source, className, message) {
const escapedClassName = escapeRegExp(className);
const patterns = [
new RegExp(`className\\s*=\\s*(?:\\{\\s*)?"(?:[^"]*\\s)?${escapedClassName}(?:\\s[^"]*)?"(?:\\s*\\})?`),
new RegExp(`className\\s*=\\s*(?:\\{\\s*)?'(?:[^']*\\s)?${escapedClassName}(?:\\s[^']*)?'(?:\\s*\\})?`),
new RegExp(`className\\s*=\\s*(?:\\{\\s*)?\`(?:[^\`]*\\s)?${escapedClassName}(?:\\s[^\`]*)?\`(?:\\s*\\})?`),
];
assert.ok(patterns.some((pattern) => pattern.test(source)), message);
}
const srcRoot = fileURLToPath(new URL('src/', root));
async function walkSourceFiles(dirPath, files = []) {
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
await walkSourceFiles(entryPath, files);
continue;
}
if (/\.(tsx?|css)$/.test(entry.name)) {
files.push(path.relative(srcRoot, entryPath).replace(/\\/g, '/'));
}
}
return files;
}
assert.throws(
() => assertUsesClassName('<div>ol-app-shell-bg</div>', 'ol-app-shell-bg', 'sample must require className usage'),
/sample must require className usage/,
);
assert.throws(
() =>
assertUsesClassName(
'<div className="foo-ol-app-shell-bg-bar" />',
'ol-app-shell-bg',
'sample must require an exact class token',
),
/sample must require an exact class token/,
);
assertUsesClassName(
'<div className="foo ol-app-shell-bg bar" />',
'ol-app-shell-bg',
'sample should accept className usage',
);
const [tokens, globalCss, shell, settingsModal, overview, settingsTabs, themeMode, stylePage, sourceFiles, remoteStyle] = await Promise.all([
read('src/styles/tokens.css'),
read('src/styles/global.css'),
read('src/components/FloatingShell.tsx'),
read('src/components/SettingsModal.tsx'),
read('src/pages/Overview.tsx'),
read('src/pages/settings/tabs.tsx'),
read('src/lib/themeMode.ts'),
read('src/pages/Style.tsx'),
walkSourceFiles(srcRoot),
read('src-tauri/src/remote_server/assets/style.css'),
]);
assert.match(tokens, /--ol-shell-radius:/, 'tokens.css must define --ol-shell-radius');
assert.match(tokens, /--ol-panel-radius:/, 'tokens.css must define --ol-panel-radius');
assert.match(tokens, /--ol-aura-shadow:/, 'tokens.css must define --ol-aura-shadow');
assert.match(tokens, /--ol-font-display:/, 'tokens.css must define --ol-font-display');
assert.match(tokens, /--ol-on-accent:/, 'tokens.css must define --ol-on-accent');
assert.match(tokens, /--ol-primary-solid-bg:/, 'tokens.css must define --ol-primary-solid-bg');
assert.match(tokens, /--ol-primary-solid-ink:/, 'tokens.css must define --ol-primary-solid-ink');
assert.match(tokens, /--ol-control-radius:/, 'tokens.css must define --ol-control-radius');
assert.match(tokens, /--ol-accent-solid-bg:/, 'tokens.css must define --ol-accent-solid-bg');
assert.match(tokens, /--ol-accent-solid-bg-hover:/, 'tokens.css must define --ol-accent-solid-bg-hover');
assert.match(tokens, /--ol-accent-solid-ink:/, 'tokens.css must define --ol-accent-solid-ink');
assert.match(tokens, /--ol-danger-solid-bg:/, 'tokens.css must define --ol-danger-solid-bg');
assert.match(tokens, /--ol-danger-solid-ink:/, 'tokens.css must define --ol-danger-solid-ink');
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-accent-solid-bg:/,
'tokens.css must define --ol-accent-solid-bg in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-accent-solid-bg-hover:/,
'tokens.css must define --ol-accent-solid-bg-hover in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-accent-solid-ink:/,
'tokens.css must define --ol-accent-solid-ink in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-danger-solid-bg:/,
'tokens.css must define --ol-danger-solid-bg in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-danger-solid-ink:/,
'tokens.css must define --ol-danger-solid-ink in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-primary-solid-bg:/,
'tokens.css must define --ol-primary-solid-bg in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-primary-solid-ink:/,
'tokens.css must define --ol-primary-solid-ink in dark theme',
);
const lightTokens = parseCustomProperties(extractCssBlock(tokens, ':root'));
const darkTokens = parseCustomProperties(extractCssBlock(tokens, "[data-ol-theme='dark']"));
const contrastPairs = [
{
label: 'light accent-solid',
tokens: lightTokens,
bg: '--ol-accent-solid-bg',
ink: '--ol-accent-solid-ink',
},
{
label: 'light primary-solid',
tokens: lightTokens,
bg: '--ol-primary-solid-bg',
ink: '--ol-primary-solid-ink',
},
{
label: 'dark accent-solid',
tokens: darkTokens,
bg: '--ol-accent-solid-bg',
ink: '--ol-accent-solid-ink',
},
{
label: 'dark primary-solid',
tokens: darkTokens,
bg: '--ol-primary-solid-bg',
ink: '--ol-primary-solid-ink',
},
{
label: 'light danger-solid',
tokens: lightTokens,
bg: '--ol-danger-solid-bg',
ink: '--ol-danger-solid-ink',
},
{
label: 'dark danger-solid',
tokens: darkTokens,
bg: '--ol-danger-solid-bg',
ink: '--ol-danger-solid-ink',
},
];
const contrastRatios = {};
for (const pair of contrastPairs) {
contrastRatios[pair.label] = assertSolidContrast(pair.tokens, pair.label, pair.bg, pair.ink);
}
const mutedContrastPairs = [
{
label: 'light ink-4 on surface',
tokens: lightTokens,
bg: '--ol-surface',
ink: '--ol-ink-4',
},
{
label: 'light ink-4 on surface-2',
tokens: lightTokens,
bg: '--ol-surface-2',
ink: '--ol-ink-4',
},
];
const mutedContrastRatios = {};
for (const pair of mutedContrastPairs) {
mutedContrastRatios[pair.label] = assertMutedContrast(pair.tokens, pair.label, pair.bg, pair.ink);
}
const remoteTokens = parseCustomProperties(extractCssBlock(remoteStyle, ':root'));
const remoteMutedContrastPairs = [
{
label: 'remote ink-4 on surface',
tokens: remoteTokens,
bg: '--surface',
ink: '--ink-4',
},
{
label: 'remote ink-4 on surface-2',
tokens: remoteTokens,
bg: '--surface-2',
ink: '--ink-4',
},
];
const remoteMutedContrastRatios = {};
for (const pair of remoteMutedContrastPairs) {
remoteMutedContrastRatios[pair.label] = assertMutedContrast(pair.tokens, pair.label, pair.bg, pair.ink);
}
const olFrostBlock = extractCssBlock(globalCss, '.ol-frost');
assert.doesNotMatch(
olFrostBlock,
/rgba\(\s*255\s*,\s*255\s*,\s*255/i,
'.ol-frost in global.css must not hardcode white rgba background gradients',
);
assert.match(globalCss, /background:\s*var\(--ol-frost-bg\)/, '.ol-frost must use --ol-frost-bg token');
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-input-ink:/,
'tokens.css must define --ol-input-ink in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-capsule-confirm-bg:/,
'tokens.css must define --ol-capsule-confirm-bg in dark theme',
);
assert.match(
tokens,
/\[data-ol-theme='dark'\][\s\S]*--ol-capsule-confirm-ink:/,
'tokens.css must define --ol-capsule-confirm-ink in dark theme',
);
assert.match(globalCss, /\.ol-app-shell-bg\b/, 'global.css must expose .ol-app-shell-bg');
assert.match(globalCss, /\.ol-aura-panel\b/, 'global.css must expose .ol-aura-panel');
assert.doesNotMatch(globalCss, /@keyframes ol-aura-halo/, 'global.css must not add an animated halo');
assert.match(globalCss, /\.ol-aura-card\b/, 'global.css must expose .ol-aura-card');
assert.match(
globalCss,
/\.ol-aura-settings\[data-ol-mobile="true"\]/,
'global.css must scope mobile settings radius to the settings element itself',
);
assertUsesClassName(shell, 'ol-app-shell-bg', 'FloatingShell must use the app shell background class');
assertUsesClassName(shell, 'ol-aura-sidebar', 'FloatingShell must expose an Aura sidebar hook');
assertUsesClassName(shell, 'ol-aura-panel', 'FloatingShell must expose an Aura panel hook');
assertUsesClassName(settingsModal, 'ol-aura-settings', 'SettingsModal must expose an Aura settings wrapper');
assertUsesClassName(overview, 'ol-overview-hero', 'Overview must expose a high-visibility overview surface hook');
assert.match(
settingsTabs,
/import\s+\{[^}]*ThemeSection[^}]*\}\s+from\s+['"]\.\/ThemeSection['"]/,
'tabs.tsx GeneralTab must import ThemeSection',
);
assert.match(settingsTabs, /<ThemeSection\s*\/?>/, 'tabs.tsx GeneralTab must render ThemeSection');
assert.match(
themeMode,
/prefers-color-scheme:\s*dark/,
'themeMode.ts must listen for prefers-color-scheme: dark',
);
assert.match(
themeMode,
/data-ol-theme|olTheme|dataset\.olTheme/,
'themeMode.ts must apply theme via data-ol-theme / dataset.olTheme',
);
const forbiddenStyleCardLightBackgrounds = [
/rgba\(\s*255\s*,\s*255\s*,\s*255/i,
/rgba\(\s*248\s*,\s*250\s*,\s*252/i,
/rgba\(\s*239\s*,\s*246\s*,\s*255/i,
];
for (const pattern of forbiddenStyleCardLightBackgrounds) {
assert.doesNotMatch(
stylePage,
pattern,
'Style.tsx must not hardcode light style-card backgrounds (use --ol-style-* tokens)',
);
}
assert.match(
stylePage,
/--ol-style-card-bg/,
'Style.tsx must reference --ol-style-card-bg for style pack surfaces',
);
assert.match(
stylePage,
/--ol-style-card-ink/,
'Style.tsx must reference --ol-style-card-ink for style pack text',
);
assert.match(
stylePage,
/--ol-style-subtle-bg/,
'Style.tsx must reference --ol-style-subtle-bg for editor subtle surfaces',
);
const illegalCssStringPatterns = [
/color:\s*'var\([^)]+\)';/,
/background:\s*'var\([^)]+\)';/,
];
const forbiddenInlineInkBackground = /background:\s*'var\(--ol-ink\)'/;
const forbiddenBlueOnAccentCombo =
/background:[\s\S]{0,200}var\(--ol-blue\)[\s\S]{0,500}?color:[\s\S]{0,200}var\(--ol-on-accent\)|color:[\s\S]{0,200}var\(--ol-on-accent\)[\s\S]{0,500}?background:[\s\S]{0,200}var\(--ol-blue\)/;
const forbiddenErrOnAccentCombo =
/background:[\s\S]{0,200}var\(--ol-err[^)]*\)[\s\S]{0,500}?color:[\s\S]{0,200}var\(--ol-on-accent\)|background:[\s\S]{0,200}var\(--ol-err[^)]*\)[\s\S]{0,500}?color:[\s\S]{0,200}var\(--ol-accent-solid-ink\)|color:[\s\S]{0,200}var\(--ol-on-accent\)[\s\S]{0,500}?background:[\s\S]{0,200}var\(--ol-err[^)]*\)|color:[\s\S]{0,200}var\(--ol-accent-solid-ink\)[\s\S]{0,500}?background:[\s\S]{0,200}var\(--ol-err[^)]*\)/;
for (const relPath of sourceFiles) {
const source = await read(`src/${relPath}`);
for (const pattern of illegalCssStringPatterns) {
assert.doesNotMatch(
source,
pattern,
`src/${relPath} must not use quoted CSS custom properties inside injected CSS strings`,
);
}
if (relPath.endsWith('.tsx')) {
assert.doesNotMatch(
source,
forbiddenInlineInkBackground,
`src/${relPath} must not use --ol-ink as a button/solid background`,
);
assert.doesNotMatch(
source,
forbiddenBlueOnAccentCombo,
`src/${relPath} must not pair background var(--ol-blue) with color var(--ol-on-accent) (use --ol-accent-solid-* tokens)`,
);
assert.doesNotMatch(
source,
forbiddenErrOnAccentCombo,
`src/${relPath} must not pair background var(--ol-err) with color var(--ol-on-accent) or var(--ol-accent-solid-ink) (use --ol-danger-solid-* tokens)`,
);
}
}
console.log('Aura skin contract OK');
console.log(
'Solid contrast ratios:',
Object.fromEntries(
Object.entries(contrastRatios).map(([label, ratio]) => [label, `${ratio.toFixed(2)}:1`]),
),
);
console.log(
'Muted contrast ratios:',
Object.fromEntries(
Object.entries({ ...mutedContrastRatios, ...remoteMutedContrastRatios }).map(([label, ratio]) => [
label,
`${ratio.toFixed(2)}:1`,
]),
),
);