import { createActiveDom, type ActiveDom, type ActiveDomStyleHost } from '$adom' import type { ActiveFrontend, FrontendDensity, ResolvedFrontendMode } from '$frontend/active-frontend.svelte' import { EngineEidos, createEngineEidos, createEngineEidosFromDocument, createThemeBaseEngineEidos, isEngineEidosDocumentLike, type EidosCssContract, type EidosCssVariableMap, type EngineEidosDocument, type EngineEidosOptions, type EngineEidosOptionsPatch, type RenderContractCssOptions, type RenderCssVariablesOptions, type RenderThemeCssOptions } from './engine' export interface ActiveEidosThemeContext { readonly theme: string readonly mode: ResolvedFrontendMode readonly density: FrontendDensity } export type ActiveEidosThemeResolver = ( context: ActiveEidosThemeContext, engine: EngineEidos ) => string export type ActiveEidosStyleHost = ActiveDomStyleHost export type ActiveEidosThemeSource = 'auto' | 'engine' | 'css' export type ActiveEidosCssVariablesOptions = Omit export interface ActiveEidosOptions { readonly engine?: EngineEidos | EngineEidosOptions | EngineEidosDocument readonly themeBase?: EngineEidosOptionsPatch readonly frontend: ActiveFrontend 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' export class ActiveEidos { readonly engine: EngineEidos readonly #frontend: ActiveFrontend #dom: ActiveDom | undefined #ownedDom: ActiveDom | undefined readonly #styleId: string readonly #styleHost: ActiveEidosStyleHost | undefined readonly #themeSource: ActiveEidosThemeSource readonly #themeResolver: ActiveEidosThemeResolver readonly #ownedStyleIds = new Set() #cssVariables: EidosCssVariableMap | undefined #cssVariablesOptions: ActiveEidosCssVariablesOptions #unsubscribe: (() => void) | undefined #disposed = false constructor(options: ActiveEidosOptions) { this.engine = resolveEngine(options.engine, options.themeBase) this.engine.assertValid() this.#frontend = options.frontend this.#dom = options.dom this.#styleId = options.styleId ?? DEFAULT_STYLE_ID this.#styleHost = options.styleHost this.#themeSource = options.themeSource ?? 'auto' this.#themeResolver = options.themeResolver ?? defaultActiveEidosThemeResolver this.#cssVariables = options.cssVariables this.#cssVariablesOptions = { selector: options.cssVariablesSelector, strict: options.cssVariablesStrict } if (options.applyDom !== false) { this.apply() this.#unsubscribe = this.#frontend.onPreferenceChange(() => this.apply()) } } get disposed(): boolean { return this.#disposed } getThemeContext(): ActiveEidosThemeContext { return { theme: this.#frontend.getTheme(), mode: this.#frontend.getMode(), density: this.#frontend.getDensity() } } getThemeId(): string { return this.#themeResolver(this.getThemeContext(), this.engine) } renderStaticCss(): string { return this.engine.renderStaticCss() } getCssContract(): EidosCssContract { return this.engine.getCssContract() } toDocument(): EngineEidosDocument { return this.engine.toDocument() } serialize(): string { return this.engine.serialize() } renderContractCss(renderOptions?: RenderContractCssOptions): string { return this.engine.renderContractCss(renderOptions) } renderCssVariables( variables = this.#cssVariables ?? {}, renderOptions: ActiveEidosCssVariablesOptions = this.#cssVariablesOptions ): string { return this.engine.renderCssVariables(variables, renderOptions) } renderThemeCss( themeId = this.getThemeId(), renderOptions: RenderThemeCssOptions = { selector: ':root' } ): string { if (this.#themeSource === 'css') return '' if (this.#themeSource === 'auto' && !this.engine.getTheme(themeId)) return '' return this.engine.renderThemeCss(themeId, renderOptions) } renderCss(themeId = this.getThemeId()): string { const staticCss = this.renderStaticCss() const themeCss = this.renderThemeCss(themeId) return themeCss ? `${staticCss}\n\n${themeCss}` : staticCss } 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) 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.#resolveStyleHost() for (const id of this.#ownedStyleIds) { this.#dom?.removeStyle(id, host ? { host } : undefined) } this.#ownedStyleIds.clear() this.#ownedDom?.dispose() } #resolveStyleHost(): HTMLElement | null { if (this.#styleHost) { return typeof this.#styleHost === 'function' ? this.#styleHost() : this.#styleHost } try { return this.#getDom().getDocument().head } catch { return null } } #writeStyle(host: HTMLElement, id: string, css: string): void { this.#getDom().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) } #getDom(): ActiveDom { if (this.#dom) return this.#dom this.#ownedDom = createActiveDom() this.#dom = this.#ownedDom return this.#dom } } export class ActiveEidosConfigError extends Error { constructor(message: string) { super(`[eidos] ${message}`) this.name = 'ActiveEidosConfigError' } } export function defaultActiveEidosThemeResolver( context: ActiveEidosThemeContext, engine: EngineEidos ): string { if (engine.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 resolveEngine( engine: EngineEidos | EngineEidosOptions | EngineEidosDocument | undefined, themeBase: EngineEidosOptionsPatch | undefined ): EngineEidos { if (!engine) return createThemeBaseEngineEidos(themeBase) if (themeBase) { throw new ActiveEidosConfigError('pass either a complete engine or a themeBase patch, not both') } if (engine instanceof EngineEidos) return engine if (isEngineEidosDocumentLike(engine)) return createEngineEidosFromDocument(engine) return createEngineEidos(engine as EngineEidosOptions) } function isModeQualifiedThemeId(themeId: string): boolean { return /-(light|dark)$/.test(themeId) }