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); }); });