mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
* refactor(web): split global CSS by ownership * test(web): expand CSS imports in style checks * fix(web): keep privacy consent banner above modals
106 lines
4 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|