diff --git a/CLAUDE.md b/CLAUDE.md index e9647d449..660ce7a91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ resolve and the server-test project). | Alias | Target | | --- | --- | | `@` | `src/` | -| `$active-app`, `$adom`, `$auth`, `$bus`, `$cache`, `$clipboard`, `$connection`, `$format`, `$http`, `$langs`, `$logger`, `$motion`, `$orca`, `$perm`, `$prefs`, `$session`, `$sium`, `$storage`, `$timer` | `src/arts/{name}` | +| `$active-app`, `$adom`, `$auth`, `$bus`, `$cache`, `$clipboard`, `$color`, `$connection`, `$format`, `$http`, `$langs`, `$logger`, `$motion`, `$orca`, `$perm`, `$prefs`, `$session`, `$sium`, `$storage`, `$timer` | `src/arts/{name}` | | `$libs`, `$locale`, `$reactive` | `src/libs`, `src/libs/locale`, `src/libs/reactive` | | `$svrs` | `src/svrs/` | | `$uix`, `$active-uix`, `$soma` | `src/uix`, `src/uix/active-uix`, `src/uix/soma` | diff --git a/src/arts/color/color.test.ts b/src/arts/color/color.test.ts index 8b009827b..0ff3c200c 100644 --- a/src/arts/color/color.test.ts +++ b/src/arts/color/color.test.ts @@ -10,6 +10,7 @@ import { parseColor, pickNearestTemplate, pickOnSolid, + safeParseColor, scaleToTemplate, wcagContrastRatio, type Oklch, @@ -99,6 +100,12 @@ describe('convert — round trips & anchors', () => { it('rejects an invalid hex', () => { expect(() => parseColor('#ggg')).toThrow(); }); + + it('safeParseColor returns null for values it cannot introspect', () => { + expect(safeParseColor('var(--x)')).toBeNull(); + expect(safeParseColor('color-mix(in oklab, red, blue)')).toBeNull(); + expect(safeParseColor('#8e4ec6')).not.toBeNull(); + }); }); describe('gamut mapping', () => { diff --git a/src/arts/color/convert.ts b/src/arts/color/convert.ts index 17d27324e..39fbd16fc 100644 --- a/src/arts/color/convert.ts +++ b/src/arts/color/convert.ts @@ -188,3 +188,16 @@ export function parseColor(input: Oklch | string): Oklch { } throw new Error(`color: unsupported color "${str}" (Phase 0 accepts hex, oklch(), or [l,c,h])`); } + +/** + * Like {@link parseColor} but returns `null` instead of throwing — for untrusted + * theme values that may be `var(...)` / `color-mix(...)` / keywords the engine + * cannot introspect. Callers keep their conservative default for those. + */ +export function safeParseColor(input: Oklch | string): Oklch | null { + try { + return parseColor(input); + } catch { + return null; + } +} diff --git a/src/arts/color/index.ts b/src/arts/color/index.ts index efd4cd07e..84b66604a 100644 --- a/src/arts/color/index.ts +++ b/src/arts/color/index.ts @@ -12,7 +12,8 @@ export { oklchToGammaRgb, oklchToHex, oklchToOklab, - parseColor + parseColor, + safeParseColor } from './convert'; export { apcaLc, wcagContrastRatio } from './apca'; diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index 4e7949d12..efffc7cbf 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -51,6 +51,7 @@ import type { KeyframeStops, MotionConfig } from '$motion' +import { apcaLc, oklchToGammaRgb, safeParseColor, wcagContrastRatio } from '$color' import { EidosCssVariableError, EidosThemeNotFoundError } from '../errors' export interface RenderThemeCssOptions { @@ -80,6 +81,15 @@ const DEFAULT_COLOR_ROLE_SLOT_STEPS: Record = { contrast: '12' } +/** + * On-solid legibility floor. The theme's `onSolid` text (white) must clear THIS on a + * role's solid to stay; below it the contrast slot flips to `onSolidContrast` (dark). + * APCA decides (|Lc| ≥ floor, ≈ the WCAG 3:1 UI minimum) with a WCAG 2 ratio as a + * conservative cross-check — white must clear BOTH. (COLOR_ENGINE_RFC §8) + */ +const ON_SOLID_APCA_FLOOR = 60 +const ON_SOLID_WCAG_FLOOR = 3 + const DEFAULT_COLOR_ALPHA_PERCENTAGES: Record = { '1': '3%', '2': '5%', @@ -336,18 +346,22 @@ export function renderThemeCss( // (button / badge / banner text). The contrasting color over a saturated // step-9 fill is the theme's `onSolid` (white) — but on a LIGHT solid // (amber / yellow / orange, step-9 luminance high) white is illegible - // (~2:1). When `onSolid` fails WCAG against this role's solid step, the + // (~2:1). When `onSolid` fails the APCA floor (WCAG cross-checked), the // engine swaps to `onSolidContrast` (dark). Step-12 stays as the var // fallback. An explicit per-role `slots.contrast` override is honored // verbatim, so roles that intentionally invert keep working. (audit P2-2) - const solidHex = color.scales[scaleName]?.['9'] - const onSolidHex = color.content?.onSolid - const parsedSolid = solidHex ? parseHexColor(solidHex) : null - const parsedOnSolid = onSolidHex ? parseHexColor(onSolidHex) : null - const onSolidFailsContrast = - parsedSolid !== null && - parsedOnSolid !== null && - wcagContrastRatio(parsedSolid, parsedOnSolid) < 3 + const solidColor = color.scales[scaleName]?.['9'] + const parsedSolid = solidColor ? safeParseColor(solidColor) : null + const parsedOnSolid = color.content?.onSolid ? safeParseColor(color.content.onSolid) : null + let onSolidFailsContrast = false + if (parsedSolid && parsedOnSolid) { + const solidRgb = oklchToGammaRgb(parsedSolid) + const onSolidRgb = oklchToGammaRgb(parsedOnSolid) + // APCA decides; WCAG 2 is a conservative cross-check — white must clear BOTH. + onSolidFailsContrast = + Math.abs(apcaLc(onSolidRgb, solidRgb)) < ON_SOLID_APCA_FLOOR || + wcagContrastRatio(onSolidRgb, solidRgb) < ON_SOLID_WCAG_FLOOR + } const defaultContrastToken = onSolidFailsContrast ? 'color-content-on-solid-contrast' : 'color-content-on-solid' @@ -1616,27 +1630,6 @@ function srgbLuminance({ r, g, b }: { r: number; g: number; b: number }): number return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 } -/** WCAG 2.x relative luminance (gamma-linearized) — for accurate contrast ratios. */ -function wcagRelativeLuminance({ r, g, b }: { r: number; g: number; b: number }): number { - const lin = (channel: number): number => { - const s = channel / 255 - return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4) - } - return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) -} - -/** WCAG contrast ratio (1..21) between two parsed colors. */ -function wcagContrastRatio( - a: { r: number; g: number; b: number }, - b: { r: number; g: number; b: number } -): number { - const la = wcagRelativeLuminance(a) - const lb = wcagRelativeLuminance(b) - const hi = Math.max(la, lb) - const lo = Math.min(la, lb) - return (hi + 0.05) / (lo + 0.05) -} - /** * Compositing inverse: the translucent color that, painted over a solid * background `bg` (255 = white, 0 = black), reproduces the opaque `solidHex`. diff --git a/svelte.config.js b/svelte.config.js index 1df4fd6e7..a596f5705 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -22,6 +22,7 @@ const config = { '$bus': resolve(__dirname, 'src/arts/bus'), '$cache': resolve(__dirname, 'src/arts/cache'), '$clipboard': resolve(__dirname, 'src/arts/clipboard'), + '$color': resolve(__dirname, 'src/arts/color'), '$connection': resolve(__dirname, 'src/arts/connection'), '$format': resolve(__dirname, 'src/arts/format'), '$http': resolve(__dirname, 'src/arts/http'), diff --git a/vite.config.ts b/vite.config.ts index ebaef4080..d0f5e1e71 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,7 @@ const aliases = { '$bus': resolve(__dirname, 'src/arts/bus'), '$cache': resolve(__dirname, 'src/arts/cache'), '$clipboard': resolve(__dirname, 'src/arts/clipboard'), + '$color': resolve(__dirname, 'src/arts/color'), '$connection': resolve(__dirname, 'src/arts/connection'), '$format': resolve(__dirname, 'src/arts/format'), '$http': resolve(__dirname, 'src/arts/http'),