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.
svelte-kit-vice/src/uix/eidos/active-eidos.svelte.ts

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)');
}

Powered by TurnKey Linux.