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
parent
4a90f3e688
commit
232a36c159
@ -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…
Reference in new issue