open-design/apps/web/tests/styles/plugin-use-menu.test.ts
Marc Chan 619087a6b4
refactor(web): split global CSS by ownership (#2609)
* refactor(web): split global CSS by ownership

* test(web): expand CSS imports in style checks

* fix(web): keep privacy consent banner above modals
2026-05-25 05:48:28 +00:00

106 lines
4 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { readExpandedIndexCss } from '../helpers/read-expanded-css';
const indexCss = readExpandedIndexCss();
const pluginsHomeCss = readFileSync(
new URL('../../src/styles/home/plugins-home.css', import.meta.url),
'utf8',
);
type Specificity = [ids: number, classes: number, types: number];
function cssDeclarations(css: string, selector: string): string {
const blocks: string[] = [];
const rulePattern = /([^{}]+)\{([^}]*)\}/g;
let match: RegExpExecArray | null;
while ((match = rulePattern.exec(css)) !== null) {
const selectors = (match[1] ?? '').split(',').map((item) => item.trim());
if (selectors.includes(selector)) blocks.push(match[2] ?? '');
}
if (blocks.length === 0) throw new Error(`Missing CSS block for ${selector}`);
return blocks.join('\n');
}
function ruleValue(block: string, property: string): string {
const matches = [...block.matchAll(new RegExp(`(?:^|[;\\n])\\s*${property}:\\s*([^;]+);`, 'g'))];
const match = matches.at(-1);
if (!match) throw new Error(`Missing CSS property ${property}`);
return match[1]!.trim();
}
function specificity(selector: string): Specificity {
const ids = selector.match(/#[\w-]+/g)?.length ?? 0;
const classes =
(selector.match(/\.[\w-]+/g)?.length ?? 0) +
(selector.match(/\[[^\]]+\]/g)?.length ?? 0) +
(selector.match(/:(?!:)[\w-]+(?:\([^)]*\))?/g)?.length ?? 0);
const withoutPseudos = selector.replace(/:(?!:)[\w-]+(?:\([^)]*\))?/g, '');
const types = withoutPseudos
.split(/[#.:[\]\s>+~]+/)
.filter((part) => /^[a-z][\w-]*$/i.test(part)).length;
return [ids, classes, types];
}
function compareSpecificity(left: Specificity, right: Specificity): number {
for (let index = 0; index < left.length; index += 1) {
const diff = left[index]! - right[index]!;
if (diff !== 0) return diff;
}
return 0;
}
function hexToRgb(hex: string): [number, number, number] {
const normalized = hex.trim().replace(/^#/, '');
if (!/^(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(normalized)) {
throw new Error(`Expected #rgb or #rrggbb, got ${hex}`);
}
const expanded = normalized.length === 3
? [...normalized].map((char) => `${char}${char}`).join('')
: normalized;
return [
Number.parseInt(expanded.slice(0, 2), 16),
Number.parseInt(expanded.slice(2, 4), 16),
Number.parseInt(expanded.slice(4, 6), 16),
];
}
function luminance([r, g, b]: [number, number, number]): number {
const channel = (value: number) => {
const normalized = value / 255;
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}
function contrastRatio(foreground: string, background: string): number {
const first = luminance(hexToRgb(foreground));
const second = luminance(hexToRgb(background));
const lighter = Math.max(first, second);
const darker = Math.min(first, second);
return (lighter + 0.05) / (darker + 0.05);
}
describe('plugin use menu contrast', () => {
it('keeps option text readable on hover and keyboard focus', () => {
const globalHoverSelector = 'button:hover:not(:disabled)';
const hoverSelector = 'button.plugins-home__use-menu-item:hover:not(:disabled)';
const focusSelector = 'button.plugins-home__use-menu-item:focus-visible';
const globalHover = cssDeclarations(indexCss, globalHoverSelector);
const hover = cssDeclarations(pluginsHomeCss, hoverSelector);
const focus = cssDeclarations(pluginsHomeCss, focusSelector);
expect(ruleValue(globalHover, 'background')).toBe('var(--bg-subtle)');
expect(compareSpecificity(specificity(hoverSelector), specificity(globalHoverSelector))).toBeGreaterThan(0);
for (const block of [hover, focus]) {
const background = ruleValue(block, 'background');
const color = ruleValue(block, 'color');
expect(contrastRatio(color, background)).toBeGreaterThanOrEqual(4.5);
}
});
});