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 type { EidosConfigPatch } from './lib/options'; import type { ColorRole, ColorScale, EidosCssContract, EidosCssVariableMap, EidosValidationReport, EidosConfig, EidosConfigSnapshot, RecipeTokenMap, ThemeDefinition } 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; 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; } export type ActiveEidosStyleHost = ActiveDomStyleHost; export type ActiveEidosThemeSource = 'auto' | 'config' | 'css'; export type ActiveEidosCssVariablesOptions = Omit; 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 modeSource?: ActiveEidosValueSource; readonly densitySource?: 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 _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; #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; 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.#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(); } 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); } 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 { this.#cssVariables = { ...variables }; this.#cssVariablesOptions = { ...this.#cssVariablesOptions, ...renderOptions }; this.apply(); } clearCssVariables(): void { this.#cssVariables = undefined; this.apply(); } 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.#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); } } 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 } = 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; } 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): 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 } satisfies Record }); this.#lastAttrs = { target, themeId, mode, density }; } #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) }); } function createComposedPreferenceSource(options: { readonly theme?: string; readonly modeSource: ActiveEidosValueSource; readonly densitySource: ActiveEidosValueSource; }): ActiveEidosPreferenceSource { return { getTheme: () => options.theme ?? DEFAULT_THEME, getMode: () => options.modeSource.get(), getDensity: () => options.densitySource.get(), onPreferenceChange(handler) { const detachers = [ options.modeSource.onChange(handler), options.densitySource.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)'); }