You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/active-eidos.svelte.ts

1094 lines
34 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { Context } from 'runed';
import type {
ActiveDom,
ActiveDomStyleHost,
Breakpoint,
DomAttrValue,
ResponsiveProp
} from '$adom';
import type { ActivePrefs } from '$prefs';
import type { Density } from '$libs/density';
import type { ThemeEffective } from '$libs/theme';
import { matches } from '$libs/errs';
import { getActiveUix, type ActiveUix } from '$active-uix';
import type { ActiveLangs } from '$langs';
import type { ActiveFormat } from '$format';
import { UIX_ERR_DOM_DISABLED } from '$active-uix/errors';
import {
assertValidEidosConfig,
createEidosConfigDocumentFromConfig,
getEidosColorRoleScale,
getEidosColorScale,
getEidosCssContract,
getEidosRecipeTokens,
getEidosTheme,
listEidosColorRoles,
listEidosColorScales,
listEidosRecipes,
listEidosThemes,
readEidosConfigFromDocument,
serializeEidosConfig,
snapshotEidosConfig,
validateEidosConfig
} from './lib/config';
import { isEidosConfigDocumentLike, type EidosConfigDocument } from './lib/persistence';
import {
renderContractCss as renderEidosContractCss,
renderCssVariables as renderEidosCssVariables,
renderStaticCss as renderEidosStaticCss,
renderThemeCss as renderEidosThemeCss,
type RenderContractCssOptions,
type RenderCssVariablesOptions,
type RenderThemeCssOptions
} from './lib/render-css';
import { createThemeBaseEidosConfig } from './lib/themes/base';
import {
buildScheme,
schemeDeclarations,
type BuildSchemeOptions,
type BuildSchemeResult
} from './lib/build-scheme';
import {
buildTypeScale,
type TypeScaleSeed,
type BuildTypeScaleResult
} from './lib/build-type-scale';
import { buildDepth, type DepthOverrides, type BuildDepthResult } from './lib/build-depth';
import { buildShape, type ShapeSeed, type BuildShapeResult } from './lib/build-shape';
import {
buildSpaceScale,
type SpaceScaleSeed,
type BuildSpaceScaleResult
} from './lib/build-space-scale';
import { collectFontPreloads, type FontPreload } from './lib/font-preload';
import type { Oklch } from '$color';
import type { EngineMotion } from '$motion';
import type { EidosConfigPatch } from './lib/options';
import type {
ColorRole,
ColorScale,
ColorScales,
EidosCssContract,
EidosCssVariableMap,
EidosValidationReport,
EidosConfig,
EidosConfigSnapshot,
RecipeTokenMap,
ScalingKey,
ThemeDefinition
} from './lib/config-types';
import { DEFAULT_SCALING } from './lib/config-types';
import { ActiveEidosConfigError, ActiveEidosNoContextError } from './errors';
export { ActiveEidosConfigError, ActiveEidosNoContextError } from './errors';
export interface ActiveEidosThemeContext {
readonly theme: string;
readonly mode: ThemeEffective;
readonly density: Density;
}
export type ActiveEidosThemeResolver = (
context: ActiveEidosThemeContext,
active: ActiveEidos
) => string;
export interface ActiveEidosPreferenceSource {
getTheme(): string;
getMode(): ThemeEffective;
getDensity(): Density;
getScaling(): ScalingKey;
onPreferenceChange(handler: () => void): () => void;
}
export interface ActiveEidosValueSource<T> {
get(): T;
onChange(handler: (value: T) => void): () => void;
}
interface ActiveEidosLastAttrs {
readonly target: HTMLElement;
readonly themeId: string;
readonly mode: ThemeEffective;
readonly density: Density;
readonly scaling: ScalingKey;
}
export type ActiveEidosStyleHost = ActiveDomStyleHost;
export type ActiveEidosThemeSource = 'auto' | 'config' | 'css';
export type ActiveEidosCssVariablesOptions = Omit<RenderCssVariablesOptions, 'contract'>;
/**
* Options for {@link ActiveEidos.applyColorScheme} — derive + apply a whole-system
* color scheme from one brand seed. Extends the pure {@link BuildSchemeOptions} but
* the runtime resolves `scales` (donor curves) + `background` from the active theme,
* so they are dropped here and replaced by `theme` / `mode` selectors.
*/
export interface ApplyColorSchemeOptions extends Omit<BuildSchemeOptions, 'scales' | 'background'> {
/** Theme id to source donor scales from. @default the active theme id */
readonly theme?: string;
/** Force light / dark donor + background. @default the active effective mode */
readonly mode?: ThemeEffective;
/** CSS selector the override targets. @default ':root' */
readonly selector?: string;
/** Background for the alpha steps (overrides the mode default). */
readonly background?: string;
}
interface ActiveEidosSchemeSpec {
readonly seed: string | Oklch;
readonly options: ApplyColorSchemeOptions;
}
const DEFAULT_SCHEME_SELECTOR = ':root';
/** Options for {@link ActiveEidos.applyTypeScale}. */
export interface ApplyTypeScaleOptions {
/** CSS selector the override targets. @default ':root' */
readonly selector?: string;
}
interface ActiveEidosTypeScaleSpec {
readonly seed: TypeScaleSeed;
readonly options: ApplyTypeScaleOptions;
}
/** Options for {@link ActiveEidos.applyDepth}. */
export interface ApplyDepthOptions {
/** CSS selector the override targets. @default ':root' */
readonly selector?: string;
}
interface ActiveEidosDepthSpec {
readonly planes: DepthOverrides;
readonly options: ApplyDepthOptions;
}
/** Options for {@link ActiveEidos.applyShape}. */
export interface ApplyShapeOptions {
/** CSS selector the variable override targets. @default ':root' */
readonly selector?: string;
}
interface ActiveEidosShapeSpec {
readonly seed: ShapeSeed;
readonly options: ApplyShapeOptions;
}
/** Options for {@link ActiveEidos.applySpacing}. */
export interface ApplySpacingOptions {
/** CSS selector the override targets. @default ':root' */
readonly selector?: string;
}
interface ActiveEidosSpaceScaleSpec {
readonly seed: SpaceScaleSeed;
readonly options: ApplySpacingOptions;
}
/**
* A whole-system theme seed — one config that composes the five runtime
* builders (color · type · depth · shape · space). Every axis is optional;
* {@link ActiveEidos.applyTheme} sets the axes you provide and reverts the
* ones you omit to the authored foundation (atomic whole-theme semantics).
* For surgical per-axis tweaks use the individual `apply{Color,Type,…}` methods.
*/
export interface ThemeSeed {
/** Brand color seed → whole color scheme (see {@link ActiveEidos.applyColorScheme}). */
readonly color?: string | Oklch;
/** Modular type-scale seed → `--font-size-*` ladder. */
readonly type?: TypeScaleSeed;
/** Per-plane depth cue overrides → `--depth-{plane}-*`. */
readonly depth?: DepthOverrides;
/** Shape channel seed (smoothing / nestGap / families) → `--shape-*`. */
readonly shape?: ShapeSeed;
/** Space scale seed (base unit / growth) → `--space-*`. */
readonly space?: SpaceScaleSeed;
}
/** Options for {@link ActiveEidos.applyTheme}. */
export interface ApplyThemeOptions {
/** CSS selector every axis targets. @default ':root' */
readonly selector?: string;
/** Color-axis options (variant / temper / theme / mode / per-role overrides). */
readonly color?: Omit<ApplyColorSchemeOptions, 'selector'>;
}
/** The composed result of {@link ActiveEidos.applyTheme} — per-axis introspection. */
export interface ApplyThemeResult {
readonly color?: BuildSchemeResult;
readonly type?: BuildTypeScaleResult;
readonly depth?: BuildDepthResult;
readonly shape?: BuildShapeResult;
readonly space?: BuildSpaceScaleResult;
}
export interface ActiveEidosOptions {
readonly config?: EidosConfig | EidosConfigDocument;
readonly themeBase?: EidosConfigPatch;
readonly uix?: ActiveUix;
readonly prefs?: ActivePrefs;
readonly langs?: ActiveLangs;
readonly format?: ActiveFormat;
readonly preferences?: ActiveEidosPreferenceSource;
readonly theme?: string;
readonly mode?: ThemeEffective;
readonly density?: Density;
readonly scaling?: ScalingKey;
readonly modeSource?: ActiveEidosValueSource<ThemeEffective>;
readonly densitySource?: ActiveEidosValueSource<Density>;
readonly scalingSource?: ActiveEidosValueSource<ScalingKey>;
readonly dom?: ActiveDom;
readonly applyDom?: boolean;
readonly styleId?: string;
readonly styleHost?: ActiveEidosStyleHost;
readonly themeSource?: ActiveEidosThemeSource;
readonly cssVariables?: EidosCssVariableMap;
readonly cssVariablesSelector?: string;
readonly cssVariablesStrict?: boolean;
readonly themeResolver?: ActiveEidosThemeResolver;
}
export type ActiveEidosUserOptions = Omit<
ActiveEidosOptions,
'uix' | 'prefs' | 'langs' | 'format' | 'dom'
>;
const DEFAULT_STYLE_ID = 'uix-eidos';
const DEFAULT_THEME = 'base';
const DEFAULT_MODE: ThemeEffective = 'light';
const DEFAULT_DENSITY: Density = 'comfortable';
const EIDOS_THEME_ATTR = 'data-theme';
const EIDOS_MODE_ATTR = 'data-mode';
const EIDOS_DENSITY_ATTR = 'data-density';
const EIDOS_SCALING_ATTR = 'data-scaling';
const _ctx = new Context<ActiveEidos>('ActiveEidos');
export class ActiveEidos {
readonly #config: EidosConfig;
readonly #uix: ActiveUix | undefined;
readonly #prefs: ActivePrefs | undefined;
readonly #langs: ActiveLangs | undefined;
readonly #format: ActiveFormat | undefined;
readonly #preferences: ActiveEidosPreferenceSource;
#dom: ActiveDom | undefined;
readonly #styleId: string;
readonly #styleHost: ActiveEidosStyleHost | undefined;
readonly #themeSource: ActiveEidosThemeSource;
readonly #themeResolver: ActiveEidosThemeResolver;
readonly #applyDom: boolean;
readonly #ownedStyleIds = new Set<string>();
#cssVariables: EidosCssVariableMap | undefined;
#cssVariablesOptions: ActiveEidosCssVariablesOptions;
#schemeSpec: ActiveEidosSchemeSpec | undefined;
#typeScaleSpec: ActiveEidosTypeScaleSpec | undefined;
#depthSpec: ActiveEidosDepthSpec | undefined;
#shapeSpec: ActiveEidosShapeSpec | undefined;
#spaceScaleSpec: ActiveEidosSpaceScaleSpec | undefined;
#unsubscribe: (() => void) | undefined;
#lastAttrs: ActiveEidosLastAttrs | undefined;
#disposed = false;
constructor(options: ActiveEidosOptions) {
this.#config = resolveEidosConfig(options.config, options.themeBase);
this.#uix = options.uix;
this.#prefs = options.prefs ?? options.uix?.prefs;
this.#langs = options.langs ?? options.uix?.langs;
this.#format = options.format ?? options.uix?.format;
this.#preferences = resolvePreferences(options);
this.#dom = options.dom ?? options.uix?.dom;
this.#styleId = options.styleId ?? DEFAULT_STYLE_ID;
this.#styleHost = options.styleHost;
this.#themeSource = options.themeSource ?? 'auto';
this.#themeResolver = options.themeResolver ?? defaultActiveEidosThemeResolver;
this.#applyDom = options.applyDom !== false;
this.#cssVariables = options.cssVariables ? { ...options.cssVariables } : undefined;
this.#cssVariablesOptions = {
selector: options.cssVariablesSelector,
strict: options.cssVariablesStrict
};
if (this.#applyDom && !this.#dom) {
throw new ActiveEidosConfigError('dom service is required when applyDom is enabled');
}
if (this.#cssVariables) this.renderCssVariables(this.#cssVariables, this.#cssVariablesOptions);
// Register this config's motion presets into the shared engine
// (`uix.motion`). soma's `Presence` + eidos wrappers resolve them by name
// there; eidos owns the CSS generation, the engine owns execution.
if (this.#uix && this.#config.motion?.presets) {
for (const [name, preset] of Object.entries(this.#config.motion.presets)) {
this.#uix.motion.register(name, preset);
}
}
if (this.#applyDom) {
this.apply();
this.#unsubscribe = this.#preferences.onPreferenceChange(() => this.apply());
}
}
get disposed(): boolean {
return this.#disposed;
}
get uix(): ActiveUix | undefined {
return this.#uix;
}
get dom(): ActiveDom {
return this.#requireDom();
}
/**
* The motion runtime — the shared `uix.motion` service (`arts/motion`). Eidos
* registers its presets into it at construction; soma's `Presence` resolves
* them by name via `motion.run(node, phase)`. Eidos owns the CSS generation;
* the engine owns execution. See `eidos-motion.md`.
*/
get motion(): EngineMotion {
if (!this.#uix) {
throw new ActiveEidosConfigError('motion runtime requires the uix service');
}
return this.#uix.motion;
}
get langs(): ActiveLangs {
if (!this.#langs) {
throw new ActiveEidosConfigError('langs service is required by ActiveEidos components');
}
return this.#langs;
}
get format(): ActiveFormat | undefined {
return this.#format;
}
get prefs(): ActivePrefs {
if (!this.#prefs) {
throw new ActiveEidosConfigError('prefs service is required by ActiveEidos components');
}
return this.#prefs;
}
getThemeContext(): ActiveEidosThemeContext {
return {
theme: this.#preferences.getTheme(),
mode: this.#preferences.getMode(),
density: this.#preferences.getDensity()
};
}
getThemeId(): string {
return this.#themeResolver(this.getThemeContext(), this);
}
snapshot(): EidosConfigSnapshot {
return snapshotEidosConfig(this.#config);
}
validate(): EidosValidationReport {
return validateEidosConfig(this.#config);
}
assertValid(): void {
assertValidEidosConfig(this.#config);
}
listColorScales(): readonly string[] {
return listEidosColorScales(this.#config);
}
listThemes(): readonly string[] {
return listEidosThemes(this.#config);
}
listRecipes(): readonly string[] {
return listEidosRecipes(this.#config);
}
getTheme(id: string): ThemeDefinition | undefined {
return getEidosTheme(this.#config, id);
}
getRecipeTokens(component: string): RecipeTokenMap | undefined {
return getEidosRecipeTokens(this.#config, component);
}
listColorRoles(): readonly ColorRole[] {
return listEidosColorRoles();
}
getColorScale(name: string): ColorScale | undefined {
return getEidosColorScale(this.#config, name);
}
getColorRoleScale(role: ColorRole, themeId?: string): ColorScale | undefined {
return getEidosColorRoleScale(this.#config, role, themeId);
}
renderStaticCss(): string {
this.assertValid();
return renderEidosStaticCss(this.#config);
}
/**
* `<link rel="preload">` descriptors for the font families flagged `preload: true`.
* Render them in the app's `<svelte:head>` (the engine emits CSS, not head markup).
*/
fontPreloads(): FontPreload[] {
return collectFontPreloads(this.#config.primitives.typography);
}
getCssContract(): EidosCssContract {
return getEidosCssContract(this.#config);
}
toDocument(): EidosConfigDocument {
return createEidosConfigDocumentFromConfig(this.#config);
}
serialize(): string {
return serializeEidosConfig(this.#config);
}
renderContractCss(renderOptions?: RenderContractCssOptions): string {
this.assertValid();
return renderEidosContractCss(this.#config, renderOptions);
}
renderCssVariables(
variables = this.#cssVariables ?? {},
renderOptions: ActiveEidosCssVariablesOptions = this.#cssVariablesOptions
): string {
this.assertValid();
return renderEidosCssVariables(variables, {
contract: this.getCssContract(),
...renderOptions
});
}
renderThemeCss(
themeId = this.getThemeId(),
renderOptions: RenderThemeCssOptions = { selector: ':root' }
): string {
if (this.#themeSource === 'css') return '';
if (this.#themeSource === 'auto' && !this.getTheme(themeId)) return '';
this.assertValid();
return renderEidosThemeCss(this.#config, themeId, renderOptions);
}
renderCss(themeId = this.getThemeId()): string {
const staticCss = this.renderStaticCss();
const themeCss = this.renderThemeCss(themeId);
return themeCss ? `${staticCss}\n\n${themeCss}` : staticCss;
}
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined;
resolve<T>(value: ResponsiveProp<T> | undefined, fallback: T): T;
resolve<T>(value: ResponsiveProp<T> | undefined, fallback?: T): T | undefined {
return this.dom.resolve(value) ?? fallback;
}
breakpoint(name: Breakpoint): number {
return this.dom.breakpoints.current[name];
}
isAtLeast(name: Breakpoint): boolean {
return this.dom.isAtLeast(name);
}
isBelow(name: Breakpoint): boolean {
return !this.dom.isAtLeast(name);
}
setCssVariables(
variables: EidosCssVariableMap,
renderOptions: ActiveEidosCssVariablesOptions = {}
): void {
const nextVariables = { ...variables };
const nextOptions = {
...this.#cssVariablesOptions,
...renderOptions
};
this.renderCssVariables(nextVariables, nextOptions);
this.#cssVariables = nextVariables;
this.#cssVariablesOptions = nextOptions;
this.apply();
}
clearCssVariables(): void {
this.#cssVariables = undefined;
this.apply();
}
/**
* Derive a whole-system color scheme from one brand seed and apply it live.
*
* Composes the `uix.color` engine (Material-3 `deriveScheme` → `generateScale`
* → APCA on-solid → compositing-inverse alpha) into `--primitive-{role}-*`
* overrides written as a managed style block. Overriding the binding layer
* reprojects every `--color-{role}-*` slot (and the neutral-driven surface /
* content / border chrome) downstream — the 31-scale palette stays put.
*
* The donor curves + alpha background are resolved from the active theme, so
* the scheme follows light / dark automatically (it re-derives on mode change).
* The returned {@link BuildSchemeResult} exposes the generated steps / solids /
* on-solid picks for introspection.
*/
applyColorScheme(seed: string | Oklch, options: ApplyColorSchemeOptions = {}): BuildSchemeResult {
this.#schemeSpec = { seed, options };
const result = this.#buildSchemeResult();
this.apply();
return result;
}
/** Remove an applied color scheme, reverting to the theme's own primitives. */
clearColorScheme(): void {
if (!this.#schemeSpec) return;
this.#schemeSpec = undefined;
this.apply();
}
/**
* Derive + apply a whole font-size scale from one modular ratio, written as a managed
* `:root` block that overrides the theme's authored `--font-size-*` at runtime — the
* typography analogue of {@link applyColorScheme}. With `ratioMax` the scale is fluid.
*/
applyTypeScale(seed: TypeScaleSeed, options: ApplyTypeScaleOptions = {}): BuildTypeScaleResult {
this.#typeScaleSpec = { seed, options };
const result = buildTypeScale(seed);
this.apply();
return result;
}
/** Remove the applied type scale, reverting to the theme's authored sizes. */
clearTypeScale(): void {
if (!this.#typeScaleSpec) return;
this.#typeScaleSpec = undefined;
this.apply();
}
/**
* Retune depth planes at runtime — the depth analogue of {@link applyColorScheme} /
* {@link applyTypeScale}. Given per-plane cue overrides (surface / shadow / halo / blur /
* scrim / z) it writes a managed block of `--depth-{plane}-{cue}` overrides that wins over
* the static foundation, so every component on a retuned plane follows. The jaula-abierta
* runtime of the depth channel.
*/
applyDepth(planes: DepthOverrides, options: ApplyDepthOptions = {}): BuildDepthResult {
this.#depthSpec = { planes, options };
const result = buildDepth(planes);
this.apply();
return result;
}
/** Remove the applied depth retune, reverting to the foundation's planes. */
clearDepth(): void {
if (!this.#depthSpec) return;
this.#depthSpec = undefined;
this.apply();
}
/**
* Retune the shape channel at runtime — the shape analogue of {@link applyColorScheme} /
* {@link applyTypeScale} / {@link applyDepth}. Dial `smoothing` (corner continuity / squircle
* intensity) + `nestGap`, or override/add `families`, written as a managed block that wins over
* the foundation. The jaula-abierta runtime of the shape channel.
*/
applyShape(seed: ShapeSeed, options: ApplyShapeOptions = {}): BuildShapeResult {
this.#shapeSpec = { seed, options };
const result = buildShape(seed);
this.apply();
return result;
}
/** Remove the applied shape retune, reverting to the foundation's shape system. */
clearShape(): void {
if (!this.#shapeSpec) return;
this.#shapeSpec = undefined;
this.apply();
}
/**
* Derive + apply a whole space scale from one base unit — the spacing analogue of
* {@link applyTypeScale}. Regenerates `--space-{key}` as a modular ladder (base × N),
* optionally FLUID (`growth > 1` → each step `clamp()`s with the viewport), preserving the
* density × scaling composition. The space *is rhythm* (STRUCTURE_ENGINE_RFC), not a flat
* lookup table. Opt-in over the authored `STATIC_SPACE`.
*/
applySpacing(seed: SpaceScaleSeed = {}, options: ApplySpacingOptions = {}): BuildSpaceScaleResult {
this.#spaceScaleSpec = { seed, options };
const result = buildSpaceScale(seed);
this.apply();
return result;
}
/** Remove the applied space scale, reverting to the theme's authored spacing. */
clearSpacing(): void {
if (!this.#spaceScaleSpec) return;
this.#spaceScaleSpec = undefined;
this.apply();
}
/**
* Apply a whole-system theme from one seed — the capstone of the runtime
* builders. Composes color · type · depth · shape · space in a SINGLE managed
* write (vs five separate `apply*` calls), atomically: the axes you provide are
* set, the ones you omit revert to the authored foundation. The "jaula abierta"
* in one call. Returns a per-axis {@link ApplyThemeResult} for introspection.
*
* For surgical per-axis tweaks (leaving the rest untouched) use the individual
* `apply{Color,Type,Depth,Shape,Spacing}` methods instead.
*/
applyTheme(seed: ThemeSeed, options: ApplyThemeOptions = {}): ApplyThemeResult {
const selector = options.selector ?? DEFAULT_SCHEME_SELECTOR;
this.#schemeSpec =
seed.color !== undefined
? { seed: seed.color, options: { ...options.color, selector } }
: undefined;
this.#typeScaleSpec =
seed.type !== undefined ? { seed: seed.type, options: { selector } } : undefined;
this.#depthSpec =
seed.depth !== undefined ? { planes: seed.depth, options: { selector } } : undefined;
this.#shapeSpec =
seed.shape !== undefined ? { seed: seed.shape, options: { selector } } : undefined;
this.#spaceScaleSpec =
seed.space !== undefined ? { seed: seed.space, options: { selector } } : undefined;
this.apply();
return {
color: this.#schemeSpec ? this.#buildSchemeResult() : undefined,
type: seed.type !== undefined ? buildTypeScale(seed.type) : undefined,
depth: seed.depth !== undefined ? buildDepth(seed.depth) : undefined,
shape: seed.shape !== undefined ? buildShape(seed.shape) : undefined,
space: seed.space !== undefined ? buildSpaceScale(seed.space) : undefined
};
}
/** Remove an applied theme, reverting EVERY axis to the authored foundation. */
clearTheme(): void {
if (
!this.#schemeSpec &&
!this.#typeScaleSpec &&
!this.#depthSpec &&
!this.#shapeSpec &&
!this.#spaceScaleSpec
) {
return;
}
this.#schemeSpec = undefined;
this.#typeScaleSpec = undefined;
this.#depthSpec = undefined;
this.#shapeSpec = undefined;
this.#spaceScaleSpec = undefined;
this.apply();
}
#buildSchemeResult(): BuildSchemeResult {
const spec = this.#schemeSpec;
if (!spec) throw new ActiveEidosConfigError('no color scheme applied');
this.assertValid();
const themeId = spec.options.theme ?? this.getThemeId();
const mode = spec.options.mode ?? this.getThemeContext().mode;
return buildScheme(spec.seed, {
...spec.options,
scales: this.#resolveDonorScales(themeId),
background: spec.options.background ?? (mode === 'dark' ? '#111111' : '#ffffff')
});
}
#resolveDonorScales(themeId: string): ColorScales {
const themeScales = this.getTheme(themeId)?.color?.scales;
if (themeScales && Object.keys(themeScales).length > 0) return themeScales;
// Fallback: assemble from the primitive palette (theme declared no scales).
const out: Record<string, ColorScale> = {};
for (const name of this.listColorScales()) {
const scale = this.getColorScale(name);
if (scale) out[name] = scale;
}
if (Object.keys(out).length === 0) {
throw new ActiveEidosConfigError(
`no color scales available to seed a scheme (theme '${themeId}')`
);
}
return out;
}
apply(): void {
if (this.#disposed || !this.#applyDom) return;
const host = this.#resolveStyleHost();
if (!host) return;
const themeContext = this.getThemeContext();
const themeId = this.#themeResolver(themeContext, this);
const staticCss = this.renderStaticCss();
const themeStyleId = `${this.#styleId}-theme`;
const themeCss = this.renderThemeCss(themeId);
const variablesStyleId = `${this.#styleId}-variables`;
const variablesCss = this.#cssVariables ? this.renderCssVariables() : '';
this.#writeThemeAttrs(
themeId,
themeContext.mode,
themeContext.density,
this.#preferences.getScaling()
);
this.#writeStyle(host, `${this.#styleId}-static`, staticCss);
if (themeCss) {
this.#writeStyle(host, themeStyleId, themeCss);
} else {
this.#removeStyle(host, themeStyleId);
}
if (variablesCss) {
this.#writeStyle(host, variablesStyleId, variablesCss);
} else {
this.#removeStyle(host, variablesStyleId);
}
// The derived scheme (applyColorScheme) is written LAST so its
// `--primitive-{role}-*` overrides win over the theme block at equal
// specificity. Re-derived here on every apply() so it follows mode changes.
const schemeStyleId = `${this.#styleId}-scheme`;
const schemeCss = this.#renderSchemeCss();
if (schemeCss) {
this.#writeStyle(host, schemeStyleId, schemeCss);
} else {
this.#removeStyle(host, schemeStyleId);
}
// The runtime type scale (applyTypeScale) is written last so its `--font-size-*`
// overrides win over the static block's authored sizes at equal specificity.
const typeScaleStyleId = `${this.#styleId}-typescale`;
const typeScaleCss = this.#renderTypeScaleCss();
if (typeScaleCss) {
this.#writeStyle(host, typeScaleStyleId, typeScaleCss);
} else {
this.#removeStyle(host, typeScaleStyleId);
}
// The runtime depth retune (applyDepth) is written last so its `--depth-{plane}-*`
// overrides win over the static foundation's planes at equal specificity.
const depthStyleId = `${this.#styleId}-depth`;
const depthCss = this.#renderDepthCss();
if (depthCss) {
this.#writeStyle(host, depthStyleId, depthCss);
} else {
this.#removeStyle(host, depthStyleId);
}
// The runtime shape retune (applyShape) is written last so its `--shape-*` + family rules
// win over the static foundation's shape system at equal specificity.
const shapeStyleId = `${this.#styleId}-shape`;
const shapeCss = this.#renderShapeCss();
if (shapeCss) {
this.#writeStyle(host, shapeStyleId, shapeCss);
} else {
this.#removeStyle(host, shapeStyleId);
}
// The runtime space scale (applySpacing) is written last so its `--space-*` overrides win
// over the static foundation's authored scale at equal specificity.
const spacingStyleId = `${this.#styleId}-spacing`;
const spacingCss = this.#renderSpacingCss();
if (spacingCss) {
this.#writeStyle(host, spacingStyleId, spacingCss);
} else {
this.#removeStyle(host, spacingStyleId);
}
}
#renderSchemeCss(): string {
if (!this.#schemeSpec) return '';
let result: BuildSchemeResult;
try {
result = this.#buildSchemeResult();
} catch {
return '';
}
const selector = this.#schemeSpec.options.selector ?? DEFAULT_SCHEME_SELECTOR;
// Dual stack per opaque step: hex fallback + oklch() wide-gamut override.
const body = schemeDeclarations(result)
.map((line) => `\t${line}`)
.join('\n');
return body ? `${selector} {\n${body}\n}` : '';
}
#renderTypeScaleCss(): string {
if (!this.#typeScaleSpec) return '';
const result = buildTypeScale(this.#typeScaleSpec.seed);
const selector = this.#typeScaleSpec.options.selector ?? ':root';
const body = result.variables.map((line) => `\t${line}`).join('\n');
return body ? `${selector} {\n${body}\n}` : '';
}
#renderDepthCss(): string {
if (!this.#depthSpec) return '';
const result = buildDepth(this.#depthSpec.planes);
const selector = this.#depthSpec.options.selector ?? ':root';
const body = result.variables.map((line) => `\t${line}`).join('\n');
return body ? `${selector} {\n${body}\n}` : '';
}
#renderShapeCss(): string {
if (!this.#shapeSpec) return '';
const result = buildShape(this.#shapeSpec.seed);
const selector = this.#shapeSpec.options.selector ?? ':root';
const body = result.variables.map((line) => `\t${line}`).join('\n');
const varBlock = body ? `${selector} {\n${body}\n}` : '';
return [varBlock, ...result.families].filter(Boolean).join('\n');
}
#renderSpacingCss(): string {
if (!this.#spaceScaleSpec) return '';
const result = buildSpaceScale(this.#spaceScaleSpec.seed);
const selector = this.#spaceScaleSpec.options.selector ?? ':root';
const body = result.variables.map((line) => `\t${line}`).join('\n');
return body ? `${selector} {\n${body}\n}` : '';
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
this.#unsubscribe?.();
const host = this.#applyDom ? this.#resolveStyleHost() : null;
for (const id of this.#ownedStyleIds) {
this.#dom?.removeStyle(id, host ? { host } : undefined);
}
this.#ownedStyleIds.clear();
if (this.#lastAttrs) {
const { target, themeId, mode, density, scaling } = this.#lastAttrs;
const attrs: Record<string, DomAttrValue> = {};
if (target.getAttribute(EIDOS_THEME_ATTR) === themeId) {
attrs[EIDOS_THEME_ATTR] = undefined;
}
if (target.getAttribute(EIDOS_MODE_ATTR) === mode) attrs[EIDOS_MODE_ATTR] = undefined;
if (target.getAttribute(EIDOS_DENSITY_ATTR) === density) {
attrs[EIDOS_DENSITY_ATTR] = undefined;
}
if (target.getAttribute(EIDOS_SCALING_ATTR) === scaling) {
attrs[EIDOS_SCALING_ATTR] = undefined;
}
this.#dom?.apply({
target,
attrs
});
}
this.#lastAttrs = undefined;
}
#resolveStyleHost(): HTMLElement | null {
if (this.#styleHost) {
return typeof this.#styleHost === 'function' ? this.#styleHost() : this.#styleHost;
}
try {
return this.#requireDom().getDocument().head;
} catch (error) {
if (matches(error, UIX_ERR_DOM_DISABLED)) {
throw new ActiveEidosConfigError(
'dom service is disabled; pass applyDom:false or enable ActiveUix dom',
error
);
}
return null;
}
}
#writeStyle(host: HTMLElement, id: string, css: string): void {
this.#requireDom().writeStyle(id, css, {
host,
attrs: { 'data-uix-eidos': true }
});
this.#ownedStyleIds.add(id);
}
#writeThemeAttrs(
themeId: string,
mode: ThemeEffective,
density: Density,
scaling: ScalingKey
): void {
const dom = this.#requireDom();
let target: HTMLElement;
try {
target = dom.getDocument().documentElement;
} catch (error) {
if (matches(error, UIX_ERR_DOM_DISABLED)) {
throw new ActiveEidosConfigError(
'dom service is disabled; pass applyDom:false or enable ActiveUix dom',
error
);
}
return;
}
dom.apply({
target,
attrs: {
[EIDOS_THEME_ATTR]: themeId,
[EIDOS_MODE_ATTR]: mode,
[EIDOS_DENSITY_ATTR]: density,
[EIDOS_SCALING_ATTR]: scaling
} satisfies Record<string, DomAttrValue>
});
this.#lastAttrs = { target, themeId, mode, density, scaling };
}
#removeStyle(host: HTMLElement, id: string): void {
this.#dom?.removeStyle(id, { host });
this.#ownedStyleIds.delete(id);
}
#requireDom(): ActiveDom {
if (!this.#dom) {
throw new ActiveEidosConfigError('dom service is required when applyDom is enabled');
}
return this.#dom;
}
static create(opts: ActiveEidosUserOptions = {}): ActiveEidos {
const uix = getActiveUix();
const instance = new ActiveEidos({
...opts,
uix,
prefs: uix.prefs,
langs: uix.langs,
format: uix.format,
dom: uix.dom
});
return _ctx.set(instance);
}
static set(active: ActiveEidos): ActiveEidos {
return _ctx.set(active);
}
static get(): ActiveEidos | undefined {
return _ctx.getOr(undefined) as ActiveEidos | undefined;
}
static require(): ActiveEidos {
const active = _ctx.getOr(undefined) as ActiveEidos | undefined;
if (!active) throw new ActiveEidosNoContextError();
return active;
}
}
export function defaultActiveEidosThemeResolver(
context: ActiveEidosThemeContext,
active: ActiveEidos
): string {
if (active.getTheme(context.theme)) return context.theme;
if (isModeQualifiedThemeId(context.theme)) return context.theme;
return `${context.theme}-${context.mode}`;
}
export function createActiveEidos(options: ActiveEidosOptions): ActiveEidos {
return new ActiveEidos(options);
}
function resolveEidosConfig(
config: EidosConfig | EidosConfigDocument | undefined,
themeBase: EidosConfigPatch | undefined
): EidosConfig {
if (!config) return createThemeBaseEidosConfig(themeBase);
if (themeBase) {
throw new ActiveEidosConfigError(
'pass either a complete config or a themeBase patch, not both'
);
}
if (isEidosConfigDocumentLike(config)) return readEidosConfigFromDocument(config);
return snapshotEidosConfig(config as EidosConfig);
}
function isModeQualifiedThemeId(themeId: string): boolean {
return /-(light|dark)$/.test(themeId);
}
function resolvePreferences(options: ActiveEidosOptions): ActiveEidosPreferenceSource {
if (options.preferences) return options.preferences;
return createComposedPreferenceSource({
theme: options.theme,
modeSource:
options.modeSource ??
(options.mode ? createStaticValueSource(options.mode) : createSystemColorSchemeSource()),
densitySource:
options.densitySource ?? createStaticValueSource(options.density ?? DEFAULT_DENSITY),
scalingSource:
options.scalingSource ?? createStaticValueSource(options.scaling ?? DEFAULT_SCALING)
});
}
function createComposedPreferenceSource(options: {
readonly theme?: string;
readonly modeSource: ActiveEidosValueSource<ThemeEffective>;
readonly densitySource: ActiveEidosValueSource<Density>;
readonly scalingSource: ActiveEidosValueSource<ScalingKey>;
}): ActiveEidosPreferenceSource {
return {
getTheme: () => options.theme ?? DEFAULT_THEME,
getMode: () => options.modeSource.get(),
getDensity: () => options.densitySource.get(),
getScaling: () => options.scalingSource.get(),
onPreferenceChange(handler) {
const detachers = [
options.modeSource.onChange(handler),
options.densitySource.onChange(handler),
options.scalingSource.onChange(handler)
];
return () => {
for (const detach of detachers) detach();
};
}
};
}
function createStaticValueSource<T>(value: T): ActiveEidosValueSource<T> {
return {
get: () => value,
onChange: () => () => {}
};
}
function createSystemColorSchemeSource(
fallback: ThemeEffective = DEFAULT_MODE
): ActiveEidosValueSource<ThemeEffective> {
const media = getColorSchemeMedia();
return {
get: () => (media ? (media.matches ? 'dark' : 'light') : fallback),
onChange(handler) {
if (!media) return () => {};
const listener = () => handler(media.matches ? 'dark' : 'light');
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}
media.addListener?.(listener);
return () => media.removeListener?.(listener);
}
};
}
function getColorSchemeMedia(): MediaQueryList | undefined {
if (typeof globalThis.matchMedia !== 'function') return undefined;
return globalThis.matchMedia('(prefers-color-scheme: dark)');
}

Powered by TurnKey Linux.