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 { 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; /** * 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 { /** 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; } /** 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; readonly densitySource?: ActiveEidosValueSource; readonly scalingSource?: ActiveEidosValueSource; 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'); 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(); #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); } /** * `` descriptors for the font families flagged `preload: true`. * Render them in the app's `` (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(value: ResponsiveProp | undefined): T | undefined; resolve(value: ResponsiveProp | undefined, fallback: T): T; resolve(value: ResponsiveProp | 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 = {}; 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 = {}; 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 }); 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; readonly densitySource: ActiveEidosValueSource; readonly scalingSource: ActiveEidosValueSource; }): 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(value: T): ActiveEidosValueSource { return { get: () => value, onChange: () => () => {} }; } function createSystemColorSchemeSource( fallback: ThemeEffective = DEFAULT_MODE ): ActiveEidosValueSource { 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)'); }