diff --git a/openless-all/app/scripts/aura-skin-contract.test.mjs b/openless-all/app/scripts/aura-skin-contract.test.mjs index 015d9c3e..22b5baab 100644 --- a/openless-all/app/scripts/aura-skin-contract.test.mjs +++ b/openless-all/app/scripts/aura-skin-contract.test.mjs @@ -1,4 +1,6 @@ -import { readFile } from 'node:fs/promises'; +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); @@ -11,6 +13,132 @@ 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 = [ @@ -22,6 +150,23 @@ function assertUsesClassName(source, className, message) { 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('