feat(color): APCA-driven on-solid contrast pick in render-css (RFC Phase 1)

First consumer of uix.color. The theme generator's on-solid text pick
(white vs dark) now decides by APCA (|Lc| >= 60) instead of WCAG 2
(< 3:1), with a WCAG 2 ratio kept as a conservative cross-check — white
must clear BOTH or the contrast slot flips to onSolidContrast. APCA is
accurate in the mid-tones where WCAG 2 mis-estimates (the risk=orange
case). Reproduces the documented base behavior (only risk flips) via a
better metric; generated/base.css unchanged (the pick lives in the
runtime theme block).

- render-css: import apcaLc / oklchToGammaRgb / safeParseColor /
  wcagContrastRatio from $color; replace the local WCAG pick; drop the
  now-orphaned local wcagRelativeLuminance + wcagContrastRatio.
- color: add safeParseColor (null instead of throw for var()/color-mix
  theme values the engine can't introspect).
- wire $color alias (vite.config.ts + svelte.config.js + CLAUDE.md).

Verified: color 20/20, eidos 162/165 (3 pre-existing words failures),
active-eidos-config contrast asserts pass, npm run check 0 new errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent fc6f53a52e
commit 8e3221f158

@ -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` |

@ -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', () => {

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

@ -12,7 +12,8 @@ export {
oklchToGammaRgb,
oklchToHex,
oklchToOklab,
parseColor
parseColor,
safeParseColor
} from './convert';
export { apcaLc, wcagContrastRatio } from './apca';

@ -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<ColorRoleSlot, string> = {
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<ColorAlphaStep, string> = {
'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`.

@ -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'),

@ -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'),

Loading…
Cancel
Save

Powered by TurnKey Linux.