feat(uix): EngineColor token resolution + uix.color surface

Add `ActiveEidos.resolveToken(token)` / `resolveTokens(tokens)` — resolve a
colour token (`--scale-{name}-{step}` / `--primitive-{role}-{step}`) to a
concrete sRGB hex purely in JS (eidos config + the `$color` engine), with NO
DOM read. This replaces the `getComputedStyle(probe)` round-trip consumers
used to reach a token's value, which forces a synchronous reflow. Role
primitives honour an applied `applyColorScheme` override (override-first);
palette scales resolve theme-scoped with the primitive palette as fallback.

Expose the `$color` engine at runtime as `uix.color` (`EngineColor` — stateless,
so `Engine*` not `Active*`) — a discoverable accessor next to `uix.motion` /
`uix.timers`. eidos keeps importing `$color` directly for build/SSR.

- src/uix/eidos/lib/resolve-token.ts — pure parseColorToken + normalizeToHex
- ActiveEidos.resolveToken/resolveTokens + ParsedColorToken export
- ActiveUix.color getter + EngineColor type
- arts/color README documents the uix.color surface
- tests: 16 (pure + integration against the base config)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 4a90f3e688
commit 232a36c159

@ -18,6 +18,13 @@ its runtime (`ActiveEidos`). Living in `arts/` (below the UIX layers), like
`uix.motion`, lets both consume it with no cross-layer dependency. Per the arts
convention it imports **no other art** and touches **no DOM**.
Exposed at runtime as **`uix.color`** — a stateless accessor on `ActiveUix` that
returns this namespace, so the engine sits next to `uix.motion` / `uix.timers` and
is discoverable. eidos keeps importing `$color` **directly** (it runs at build /
SSR, before any `uix` exists); `uix.color` is the additive runtime surface so a
consumer reaches the engine (e.g. `uix.color.oklchToHex`) instead of round-tripping
a colour through the DOM with `getComputedStyle`.
## Pipeline
```

@ -37,11 +37,12 @@ import {
} from '$adom';
import { EngineSemantic } from '$uix/sema';
import { createEngineMotion, type EngineMotion } from '$motion';
import * as colorEngine from '$color';
import type { LangNode, SupportedLocale } from '$libs/langs';
import { commonLangs, componentLangs } from '$uix/langs';
import { UIX_REQUIRED_SERVICES, type UixRequiredService } from './services';
import type { ActiveUix, ActiveUixOptions, AttachActiveUixOptions } from './types';
import type { ActiveUix, ActiveUixOptions, AttachActiveUixOptions, EngineColor } from './types';
import { connectLangsToPrefs, createLocaleSourceFromPrefs } from './prefs';
import {
ActiveUixDomDisabledError,
@ -386,6 +387,16 @@ class ActiveUixImpl implements ActiveUix {
return this.init.motion;
}
/**
* Colour math engine (`arts/color`). Pure + stateless, so it's the same in both
* boot modes — returns the `$color` namespace (`oklchToHex`, `parseColor`,
* `apcaLc`, `deriveScheme`, …). The discoverable surface for the engine eidos
* already consumes; reach for it instead of round-tripping colour through the DOM.
*/
get color(): EngineColor {
return colorEngine;
}
// ── Core ───────────────────────────────────────────────────────
get logger(): EngineLogger {
return this.init.mode === 'standalone' ? this.init.logger : this.init.app.logger;

@ -85,6 +85,13 @@ export interface AttachActiveUixOptions {
readonly registerDefaultLangs?: boolean;
}
/**
* The colour math engine namespace (`arts/color` / `$color`) — pure, stateless.
* `Engine*` (not `Active*`) per the framework convention: `Active*` carries state,
* `Engine*` is a stateless processor. Exposed on {@link ActiveUix.color}.
*/
export type EngineColor = typeof import('$color');
/**
* The UIX layer's view of the composed runtime. Components consume
* this; they do NOT consume `ActiveApp` directly.
@ -111,6 +118,14 @@ export interface ActiveUix {
*/
readonly motion: EngineMotion;
/**
* Colour math engine (`arts/color` / `$color`). Pure + stateless — OKLCH⇄sRGB,
* APCA contrast, scale generation, scheme derivation. The discoverable runtime
* surface for the engine eidos consumes at build + runtime; reach for it (e.g.
* `uix.color.oklchToHex`) instead of probing the DOM for a colour value.
*/
readonly color: EngineColor;
// ── Core (always present, regardless of boot mode) ─────────────────
readonly logger: EngineLogger;
/**

@ -49,6 +49,7 @@ import {
type BuildSchemeOptions,
type BuildSchemeResult
} from './lib/build-scheme';
import { parseColorToken, normalizeToHex } from './lib/resolve-token';
import {
buildTypeScale,
type TypeScaleSeed,
@ -71,6 +72,7 @@ import type { EidosConfigPatch } from './lib/options';
import type {
ColorRole,
ColorScale,
ColorScaleStep,
ColorScales,
EidosCssContract,
EidosCssVariableMap,
@ -85,6 +87,7 @@ import { DEFAULT_SCALING } from './lib/config-types';
import { ActiveEidosConfigError, ActiveEidosNoContextError } from './errors';
export { ActiveEidosConfigError, ActiveEidosNoContextError } from './errors';
export type { ParsedColorToken } from './lib/resolve-token';
export interface ActiveEidosThemeContext {
readonly theme: string;
@ -474,6 +477,66 @@ export class ActiveEidos {
return getEidosColorRoleScale(this.#config, role, themeId);
}
/**
* Resolve a colour token (CSS custom-property name) to a concrete sRGB hex,
* PURELY in JS — config (palette / role scales) + the `$color` engine, NO DOM
* read. Recognises `--scale-{name}-{step}` (theme-independent palette) and
* `--primitive-{role}-{step}` (role primitive; an applied {@link applyColorScheme}
* override wins over the theme's authored value, mirroring the cascade). Returns
* `null` for tokens that aren't a resolvable colour-scale step (semantic slots,
* unknown vars).
*
* Replaces the `getComputedStyle(probe)` round-trip consumers used to reach a
* token's value — that read forces a synchronous reflow; this never touches DOM.
*/
resolveToken(token: string): string | null {
const parsed = parseColorToken(token);
if (!parsed) return null;
let raw: string | undefined;
if (parsed.family === 'scale') {
// Palette scales are theme-scoped in the config (light / dark differ), with
// the universal primitive palette as fallback — same order as a role's scale.
const themeScale = this.getTheme(this.getThemeId())?.color?.scales?.[parsed.name];
raw = (themeScale ?? this.getColorScale(parsed.name))?.[parsed.step];
} else {
// Role primitive: an applied runtime scheme overrides the theme's authored
// role scale, so it wins — the scheme block is written after the theme block
// in `apply()`, and resolution must follow the same precedence.
raw =
this.#resolveSchemeRoleStep(parsed.name, parsed.step) ??
this.getColorRoleScale(parsed.name as ColorRole, this.getThemeId())?.[parsed.step];
}
return raw ? normalizeToHex(raw) : null;
}
/**
* Batch {@link resolveToken} — resolve many tokens at once, skipping any that
* don't resolve. Handy for a swatch grid that needs a token→hex map.
*/
resolveTokens(tokens: readonly string[]): Record<string, string> {
const out: Record<string, string> = {};
for (const token of tokens) {
const hex = this.resolveToken(token);
if (hex !== null) out[token] = hex;
}
return out;
}
#resolveSchemeRoleStep(role: string, step: ColorScaleStep): string | undefined {
if (!this.#schemeSpec) return undefined;
let result: BuildSchemeResult;
try {
result = this.#buildSchemeResult();
} catch {
return undefined;
}
const roleResult = result.roles.find((entry) => entry.role === role);
if (!roleResult) return undefined;
return roleResult.steps[Number(step) - 1];
}
renderStaticCss(): string {
this.assertValid();
// Thread the runtime-configured breakpoints (developer-set on ActiveDom) into

@ -121,7 +121,8 @@ export type {
ApplySpacingOptions,
ThemeSeed,
ApplyThemeOptions,
ApplyThemeResult
ApplyThemeResult,
ParsedColorToken
} from './active-eidos.svelte';
export type {
EidosConfigDocument,

@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import { parseColorToken, normalizeToHex } from './resolve-token'
import { createActiveEidos } from '..'
describe('parseColorToken', () => {
it('parses a palette scale token', () => {
expect(parseColorToken('--scale-purple-9')).toEqual({
family: 'scale',
name: 'purple',
step: '9'
})
})
it('parses a role primitive token', () => {
expect(parseColorToken('--primitive-primary-9')).toEqual({
family: 'role',
name: 'primary',
step: '9'
})
})
it('parses an intent role at the top step', () => {
expect(parseColorToken('--primitive-affirm-12')).toEqual({
family: 'role',
name: 'affirm',
step: '12'
})
})
it('keeps a hyphenated scale name intact', () => {
expect(parseColorToken('--scale-slate-blue-9')).toEqual({
family: 'scale',
name: 'slate-blue',
step: '9'
})
})
it('trims surrounding whitespace', () => {
expect(parseColorToken(' --scale-teal-1 ')).toEqual({
family: 'scale',
name: 'teal',
step: '1'
})
})
it('rejects a semantic slot (non-numeric step)', () => {
expect(parseColorToken('--color-primary-track')).toBeNull()
})
it('rejects an alpha step', () => {
expect(parseColorToken('--scale-purple-a9')).toBeNull()
})
it('rejects an out-of-range step', () => {
expect(parseColorToken('--scale-purple-13')).toBeNull()
expect(parseColorToken('--scale-purple-0')).toBeNull()
})
it('rejects an unknown prefix or shape', () => {
expect(parseColorToken('--color-primary-9')).toBeNull()
expect(parseColorToken('--space-4')).toBeNull()
expect(parseColorToken('not-a-token')).toBeNull()
})
})
describe('normalizeToHex', () => {
it('returns an already-hex value unchanged (no oklch round-trip)', () => {
expect(normalizeToHex('#8e4ec6')).toBe('#8e4ec6')
expect(normalizeToHex(' #FFF ')).toBe('#FFF')
})
it('coerces an oklch() string to a concrete sRGB hex', () => {
expect(normalizeToHex('oklch(0.7 0.15 200)')).toMatch(/^#[0-9a-f]{6}$/i)
})
it('returns null for an unparseable value', () => {
expect(normalizeToHex('definitely not a color')).toBeNull()
})
})
describe('ActiveEidos.resolveToken (against the base config)', () => {
const eidos = createActiveEidos({ applyDom: false, themeSource: 'config' })
it('resolves a role primitive to a concrete sRGB hex', () => {
expect(eidos.resolveToken('--primitive-primary-9')).toMatch(/^#[0-9a-f]{3,8}$/i)
})
it('resolves a palette scale step to a concrete sRGB hex', () => {
// The base palette is theme-scoped; pick a real scale name from the active theme.
const scales = eidos.getTheme(eidos.getThemeId())?.color?.scales ?? {}
const scale = Object.keys(scales)[0]
expect(scale, 'expected the base theme to declare colour scales').toBeTruthy()
expect(eidos.resolveToken(`--scale-${scale}-9`)).toMatch(/^#[0-9a-f]{3,8}$/i)
})
it('returns null for a semantic slot and for unknown tokens', () => {
expect(eidos.resolveToken('--color-primary-track')).toBeNull()
expect(eidos.resolveToken('not-a-token')).toBeNull()
})
it('resolveTokens skips unresolvable tokens', () => {
const map = eidos.resolveTokens(['--primitive-primary-9', '--color-primary-track', 'garbage'])
expect(map['--primitive-primary-9']).toMatch(/^#[0-9a-f]{3,8}$/i)
expect(map['--color-primary-track']).toBeUndefined()
expect(map['garbage']).toBeUndefined()
})
})

@ -0,0 +1,63 @@
// Pure colour-token resolution — the JS bridge from an eidos colour token (a CSS
// custom-property name) to a concrete value, WITHOUT touching the DOM. Reading a
// token's value used to mean `getComputedStyle(probe)`, which forces a synchronous
// reflow; this resolves the same value from the config + the `$color` engine.
//
// Parsing lives here (pure, testable); the lookup against the live config/scheme
// lives on `ActiveEidos.resolveToken` (it needs the instance's palette + theme).
import { safeParseColor, oklchToHex } from '$color'
import { COLOR_SCALE_STEPS, type ColorScaleStep } from './config-types'
export interface ParsedColorToken {
/**
* `scale` → a palette scale step (`--scale-{name}-{step}`), theme-independent.
* `role` → a role primitive (`--primitive-{role}-{step}`); an applied
* `applyColorScheme` override wins over the theme's authored value.
*/
readonly family: 'scale' | 'role'
/** Scale name (e.g. `purple`) or role name (e.g. `primary`, `affirm`). */
readonly name: string
/** Step `1`..`12`. */
readonly step: ColorScaleStep
}
const STEP_SET: ReadonlySet<string> = new Set(COLOR_SCALE_STEPS)
const TOKEN_RE = /^--(scale|primitive)-(.+)-(\d{1,2})$/
const HEX_RE = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
/**
* Parse a colour token (CSS custom-property name) into family / name / step.
* Recognises the two numeric shapes the palette emits:
*
* --scale-{name}-{step} → { family: 'scale', name, step }
* --primitive-{role}-{step} → { family: 'role', name: role, step }
*
* Returns `null` for anything else — semantic slots (`--color-{role}-{slot}`),
* alpha steps (`--scale-{name}-a9`), out-of-range steps, arbitrary vars. The
* caller treats `null` as "not a resolvable colour-scale token".
*/
export function parseColorToken(token: string): ParsedColorToken | null {
const match = TOKEN_RE.exec(token.trim())
if (!match) return null
const step = match[3]
if (!STEP_SET.has(step)) return null
return {
family: match[1] === 'scale' ? 'scale' : 'role',
name: match[2],
step: step as ColorScaleStep
}
}
/**
* Coerce any authored colour string (`#hex` / `oklch(...)` / parseable form) to a
* concrete sRGB hex, gamut-mapped via the `$color` engine. Already-hex values
* short-circuit so an in-gamut value never drifts through an OKLCH round-trip.
* `null` when the value can't be parsed.
*/
export function normalizeToHex(value: string): string | null {
const trimmed = value.trim()
if (HEX_RE.test(trimmed)) return trimmed
const oklch = safeParseColor(trimmed)
return oklch ? oklchToHex(oklch) : null
}
Loading…
Cancel
Save

Powered by TurnKey Linux.