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.
841 lines
25 KiB
841 lines
25 KiB
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 { 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;
|
|
}
|
|
|
|
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;
|
|
#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();
|
|
}
|
|
|
|
#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);
|
|
}
|
|
}
|
|
|
|
#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}` : '';
|
|
}
|
|
|
|
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)');
|
|
}
|