diff --git a/openless-all/app/scripts/aura-skin-contract.test.mjs b/openless-all/app/scripts/aura-skin-contract.test.mjs index 015d9c3e..5228a23e 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('
{item.rawTranscript || t('history.rawEmpty')}
{item.finalText} diff --git a/openless-all/app/src/pages/LessComputerPanel.tsx b/openless-all/app/src/pages/LessComputerPanel.tsx index 206c0e85..fe729b5e 100644 --- a/openless-all/app/src/pages/LessComputerPanel.tsx +++ b/openless-all/app/src/pages/LessComputerPanel.tsx @@ -414,11 +414,11 @@ const shellStyle: CSSProperties = { height: '100vh', display: 'flex', flexDirection: 'column', - borderRadius: 14, + borderRadius: 'var(--ol-bubble-radius)', overflow: 'hidden', - border: '0.5px solid rgba(0, 0, 0, 0.12)', - background: 'rgba(246, 247, 250, 0.88)', - boxShadow: '0 18px 44px -18px rgba(15,17,22,.28), 0 0 0 0.5px rgba(255,255,255,.7) inset', + border: '0.5px solid var(--ol-line-strong)', + background: 'var(--ol-panel-bg)', + boxShadow: 'var(--ol-shadow-lg)', fontFamily: 'var(--ol-font-sans)', color: 'var(--ol-ink)', isolation: 'isolate', @@ -430,9 +430,8 @@ const toolbarStyle: CSSProperties = { alignItems: 'center', padding: '0 8px', borderBottom: '0.5px solid rgba(0, 0, 0, 0.08)', - background: - 'linear-gradient(180deg, rgba(255,255,255,0.74), rgba(238,240,245,0.58))', - boxShadow: '0 1px 0 rgba(255,255,255,.55) inset', + background: 'var(--ol-panel-bg)', + boxShadow: 'var(--ol-shadow-sm)', backdropFilter: 'blur(18px) saturate(150%)', WebkitBackdropFilter: 'blur(18px) saturate(150%)', flexShrink: 0, @@ -444,7 +443,7 @@ const closeBtnStyle: CSSProperties = { width: 22, height: 22, border: 0, - borderRadius: 6, + borderRadius: 'var(--ol-r-sm)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -479,10 +478,10 @@ const roleLabelStyle: CSSProperties = { const userBubbleStyle: CSSProperties = { maxWidth: '85%', padding: '8px 12px', - borderRadius: 14, + borderRadius: 'var(--ol-bubble-radius)', borderBottomRightRadius: 4, - background: 'var(--ol-blue)', - color: '#fff', + background: 'var(--ol-accent-solid-bg)', + color: 'var(--ol-accent-solid-ink)', fontSize: 13, lineHeight: 1.55, wordBreak: 'break-word', @@ -491,7 +490,7 @@ const userBubbleStyle: CSSProperties = { const assistantBubbleStyle: CSSProperties = { maxWidth: '92%', padding: '8px 12px', - borderRadius: 14, + borderRadius: 'var(--ol-bubble-radius)', borderBottomLeftRadius: 4, background: 'rgba(0,0,0,0.04)', fontSize: 13, @@ -519,7 +518,7 @@ const toolChipStyle: CSSProperties = { color: 'var(--ol-ink-2)', background: 'rgba(0,0,0,0.045)', border: '0.5px solid rgba(0,0,0,0.06)', - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', padding: '4px 8px', }; @@ -543,7 +542,7 @@ const approvalCardStyle: CSSProperties = { flexDirection: 'column', gap: 6, padding: '10px 12px', - borderRadius: 12, + borderRadius: 'var(--ol-r-lg)', background: 'rgba(220,38,38,0.05)', border: '0.5px solid rgba(220,38,38,0.20)', }; @@ -553,7 +552,7 @@ const approvalCmdStyle: CSSProperties = { fontSize: 11.5, color: 'var(--ol-ink)', background: 'rgba(0,0,0,0.05)', - borderRadius: 6, + borderRadius: 'var(--ol-r-sm)', padding: '5px 8px', wordBreak: 'break-all', }; @@ -564,25 +563,25 @@ const approvalRerunWarningStyle: CSSProperties = { color: 'rgb(180,83,9)', background: 'rgba(245,158,11,0.10)', border: '0.5px solid rgba(245,158,11,0.25)', - borderRadius: 6, + borderRadius: 'var(--ol-r-sm)', padding: '5px 8px', }; const approveBtnStyle: CSSProperties = { flex: 1, border: 0, - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', padding: '6px 10px', fontSize: 12, fontWeight: 600, cursor: 'default', - background: 'var(--ol-blue)', - color: '#fff', + background: 'var(--ol-accent-solid-bg)', + color: 'var(--ol-accent-solid-ink)', }; const denyBtnStyle: CSSProperties = { flex: 1, - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', padding: '6px 10px', fontSize: 12, fontWeight: 600, @@ -594,7 +593,7 @@ const denyBtnStyle: CSSProperties = { const errorRowStyle: CSSProperties = { padding: '8px 12px', - borderRadius: 10, + borderRadius: 'var(--ol-r-md)', background: 'rgba(220,38,38,0.06)', border: '0.5px solid rgba(220,38,38,0.18)', }; @@ -633,7 +632,7 @@ const globalCss = ` padding: 1px 5px; border-radius: 4px; background: rgba(0,0,0,0.05); } .lc-answer pre { margin: 0 0 6px; padding: 8px 10px; - border-radius: 8px; background: rgba(0,0,0,0.05); + border-radius: var(--ol-control-radius); background: rgba(0,0,0,0.05); overflow-x: auto; } .lc-answer pre code { padding: 0; background: transparent; } .lc-answer a { color: var(--ol-blue); text-decoration: none; } diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index 5c5624a8..62589498 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -1842,7 +1842,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { style={{ fontSize: 13, padding: "6px 10px", - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', border: "0.5px solid rgba(0,0,0,0.12)", background: "var(--ol-surface)", color: "var(--ol-ink)", @@ -1903,7 +1903,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { style={{ fontSize: 13, padding: "6px 10px", - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', border: "0.5px solid rgba(0,0,0,0.12)", background: "var(--ol-surface)", color: "var(--ol-ink)", @@ -1954,7 +1954,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { style={{ fontSize: 13, padding: "6px 10px", - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', border: "0.5px solid rgba(0,0,0,0.12)", background: "var(--ol-surface)", color: "var(--ol-ink)", @@ -2266,7 +2266,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { fontSize: 13, height: 31, padding: "0 10px", - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', border: "0.5px solid rgba(0,0,0,0.12)", background: "var(--ol-surface)", color: "var(--ol-ink)", @@ -2301,7 +2301,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { fontSize: 13, height: 31, padding: "0 10px", - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', border: "0.5px solid rgba(0,0,0,0.12)", background: "var(--ol-surface)", color: "var(--ol-ink)", @@ -2334,7 +2334,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { fontSize: 13, height: 31, padding: "0 10px", - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', border: "0.5px solid rgba(0,0,0,0.12)", background: "var(--ol-surface)", color: "var(--ol-ink)", @@ -2613,7 +2613,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { style={{ fontSize: 13, padding: "6px 10px", - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', border: "0.5px solid rgba(0,0,0,0.12)", background: "var(--ol-surface)", color: "var(--ol-ink)", @@ -2741,7 +2741,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { style={{ fontSize: 13, padding: "6px 10px", - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', border: "0.5px solid rgba(0,0,0,0.12)", background: "var(--ol-surface)", color: "var(--ol-ink)", @@ -2905,7 +2905,7 @@ function FoundryPrepareProgressBlock({