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