import { Context } from 'runed'; import type { ActiveDom, ActiveDomStyleHost, Breakpoint, ResponsiveProp } from '$adom'; import { readActivePrefsSlot, 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, getEidosTheme, listEidosColorRoles, listEidosColorScales, 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, 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 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 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; const DEFAULT_STYLE_ID = 'uix-eidos'; const DEFAULT_THEME = 'base'; const DEFAULT_MODE: ThemeEffective = 'light'; const DEFAULT_DENSITY: Density = 'comfortable'; 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; #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); } getTheme(id: string): ThemeDefinition | undefined { return getEidosTheme(this.#config, id); } 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; this.#writeStyle(host, `${this.#styleId}-static`, this.renderStaticCss()); const themeStyleId = `${this.#styleId}-theme`; const themeCss = this.renderThemeCss(); if (themeCss) { this.#writeStyle(host, themeStyleId, themeCss); } else { this.#removeStyle(host, themeStyleId); } const variablesStyleId = `${this.#styleId}-variables`; const variablesCss = this.#cssVariables ? this.renderCssVariables() : ''; 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(); } #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); } #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; const prefs = options.prefs ?? options.uix?.prefs; if (prefs) { return createPrefsPreferenceSource(prefs, { theme: options.theme, mode: options.mode, density: options.density }); } return createStaticPreferenceSource({ theme: options.theme, mode: options.mode, density: options.density }); } function createPrefsPreferenceSource( prefs: ActivePrefs, defaults: { readonly theme?: string; readonly mode?: ThemeEffective; readonly density?: Density; } ): ActiveEidosPreferenceSource { return { getTheme: () => defaults.theme ?? DEFAULT_THEME, getMode: () => readActivePrefsSlot(prefs, 'theme')?.get() ?? defaults.mode ?? DEFAULT_MODE, getDensity: () => readActivePrefsSlot(prefs, 'density')?.get() ?? defaults.density ?? DEFAULT_DENSITY, onPreferenceChange(handler) { const detachers = [ readActivePrefsSlot(prefs, 'theme')?.onChange(handler), readActivePrefsSlot(prefs, 'density')?.onChange(handler) ].filter((off): off is () => void => typeof off === 'function'); return () => { for (const detach of detachers) detach(); }; } }; } function createStaticPreferenceSource(defaults: { readonly theme?: string; readonly mode?: ThemeEffective; readonly density?: Density; }): ActiveEidosPreferenceSource { return { getTheme: () => defaults.theme ?? DEFAULT_THEME, getMode: () => defaults.mode ?? DEFAULT_MODE, getDensity: () => defaults.density ?? DEFAULT_DENSITY, onPreferenceChange: () => () => {} }; }