diff --git a/src/uix/eidos/CHANNELS_SYNTHESIS.md b/src/uix/eidos/CHANNELS_SYNTHESIS.md index 817f1f052..4ce6e99dd 100644 --- a/src/uix/eidos/CHANNELS_SYNTHESIS.md +++ b/src/uix/eidos/CHANNELS_SYNTHESIS.md @@ -85,14 +85,19 @@ Un `engine.emit(...)` estampa `data-event-*` (el canal **visual** reacciona — en color/motion/depth/forma) **y** dispara sound + haptic — desde el **mismo** evento. No hay sistemas coordinándose a mano; hay un suceso que se expresa por los canales de sema. -## 5. Builders runtime — el quinteto (jaula abierta) +## 5. Builders runtime — el sexteto (jaula abierta) Cada eje retunable en runtime, mismo patrón (semilla → bloque gestionado), opt-in sobre la escala authored: `applyColorScheme` · `applyTypeScale` · `applyDepth` · `applyShape` · `applySpacing` -(Futuro: `applyTheme(seed)` que componga varios desde una semilla compartida.) +Y el **capstone que los compone**: **`applyTheme(seed)`** — una sola semilla +(`{ color?, type?, depth?, shape?, space? }`) compone los cinco ejes en **una única +escritura gestionada** (vs cinco `apply*` sueltos), de forma **atómica**: los ejes que das se +aplican, los que omites revierten a la foundation authored. `clearTheme()` revierte todo. Para +retoques quirúrgicos por eje, los `apply*` individuales siguen ahí. Ningún referente reúne los +cinco ejes perceptuales bajo un único builder de tema en runtime. ## 6. Posición frente a referentes diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index a434c03a4..0c84db471 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -541,6 +541,37 @@ describe('ActiveEidos config', () => { eidos.clearDepth(); // reverts cleanly, no throw }); + it('applyTheme composes all five builders from one seed (the runtime capstone)', () => { + const eidos = createThemeBaseEidos(); + const result = eidos.applyTheme({ + color: '#3b82f6', + type: { base: 16, ratio: 1.25 }, + depth: { overlay: { shadow: '0 30px 70px rgb(0 0 0 / 0.5)' } }, + shape: { smoothing: 3 }, + space: { base: 4 } + }); + // every provided axis returns its built result — composed in one call + expect(result.color).toBeDefined(); + expect(result.type?.sizes).toHaveLength(8); + expect(result.depth?.variables).toContain( + '--depth-overlay-shadow: 0 30px 70px rgb(0 0 0 / 0.5);' + ); + expect(result.shape).toBeDefined(); + expect(result.space?.steps).toHaveLength(18); + eidos.clearTheme(); // reverts every axis cleanly, no throw + }); + + it('applyTheme is atomic — omitted axes revert to the foundation (whole-theme replace)', () => { + const eidos = createThemeBaseEidos(); + eidos.applyTheme({ type: { base: 16, ratio: 1.25 }, shape: { smoothing: 3 } }); + // re-applying with only color must clear the previously-set type + shape + const result = eidos.applyTheme({ color: '#3b82f6' }); + expect(result.color).toBeDefined(); + expect(result.type).toBeUndefined(); + expect(result.shape).toBeUndefined(); + eidos.clearTheme(); + }); + it('emits the shape channel — --shape-smoothing + data-shape family rules (continuity)', () => { const css = createThemeBaseEidos().renderStaticCss(); expect(css).toContain('--shape-smoothing: 2;'); diff --git a/src/uix/eidos/active-eidos.svelte.ts b/src/uix/eidos/active-eidos.svelte.ts index 553b61aa2..adca6969a 100644 --- a/src/uix/eidos/active-eidos.svelte.ts +++ b/src/uix/eidos/active-eidos.svelte.ts @@ -186,6 +186,43 @@ interface ActiveEidosSpaceScaleSpec { readonly options: ApplySpacingOptions; } +/** + * A whole-system theme seed — one config that composes the five runtime + * builders (color · type · depth · shape · space). Every axis is optional; + * {@link ActiveEidos.applyTheme} sets the axes you provide and reverts the + * ones you omit to the authored foundation (atomic whole-theme semantics). + * For surgical per-axis tweaks use the individual `apply{Color,Type,…}` methods. + */ +export interface ThemeSeed { + /** Brand color seed → whole color scheme (see {@link ActiveEidos.applyColorScheme}). */ + readonly color?: string | Oklch; + /** Modular type-scale seed → `--font-size-*` ladder. */ + readonly type?: TypeScaleSeed; + /** Per-plane depth cue overrides → `--depth-{plane}-*`. */ + readonly depth?: DepthOverrides; + /** Shape channel seed (smoothing / nestGap / families) → `--shape-*`. */ + readonly shape?: ShapeSeed; + /** Space scale seed (base unit / growth) → `--space-*`. */ + readonly space?: SpaceScaleSeed; +} + +/** Options for {@link ActiveEidos.applyTheme}. */ +export interface ApplyThemeOptions { + /** CSS selector every axis targets. @default ':root' */ + readonly selector?: string; + /** Color-axis options (variant / temper / theme / mode / per-role overrides). */ + readonly color?: Omit; +} + +/** The composed result of {@link ActiveEidos.applyTheme} — per-axis introspection. */ +export interface ApplyThemeResult { + readonly color?: BuildSchemeResult; + readonly type?: BuildTypeScaleResult; + readonly depth?: BuildDepthResult; + readonly shape?: BuildShapeResult; + readonly space?: BuildSpaceScaleResult; +} + export interface ActiveEidosOptions { readonly config?: EidosConfig | EidosConfigDocument; readonly themeBase?: EidosConfigPatch; @@ -599,6 +636,62 @@ export class ActiveEidos { this.apply(); } + /** + * Apply a whole-system theme from one seed — the capstone of the runtime + * builders. Composes color · type · depth · shape · space in a SINGLE managed + * write (vs five separate `apply*` calls), atomically: the axes you provide are + * set, the ones you omit revert to the authored foundation. The "jaula abierta" + * in one call. Returns a per-axis {@link ApplyThemeResult} for introspection. + * + * For surgical per-axis tweaks (leaving the rest untouched) use the individual + * `apply{Color,Type,Depth,Shape,Spacing}` methods instead. + */ + applyTheme(seed: ThemeSeed, options: ApplyThemeOptions = {}): ApplyThemeResult { + const selector = options.selector ?? DEFAULT_SCHEME_SELECTOR; + + this.#schemeSpec = + seed.color !== undefined + ? { seed: seed.color, options: { ...options.color, selector } } + : undefined; + this.#typeScaleSpec = + seed.type !== undefined ? { seed: seed.type, options: { selector } } : undefined; + this.#depthSpec = + seed.depth !== undefined ? { planes: seed.depth, options: { selector } } : undefined; + this.#shapeSpec = + seed.shape !== undefined ? { seed: seed.shape, options: { selector } } : undefined; + this.#spaceScaleSpec = + seed.space !== undefined ? { seed: seed.space, options: { selector } } : undefined; + + this.apply(); + + return { + color: this.#schemeSpec ? this.#buildSchemeResult() : undefined, + type: seed.type !== undefined ? buildTypeScale(seed.type) : undefined, + depth: seed.depth !== undefined ? buildDepth(seed.depth) : undefined, + shape: seed.shape !== undefined ? buildShape(seed.shape) : undefined, + space: seed.space !== undefined ? buildSpaceScale(seed.space) : undefined + }; + } + + /** Remove an applied theme, reverting EVERY axis to the authored foundation. */ + clearTheme(): void { + if ( + !this.#schemeSpec && + !this.#typeScaleSpec && + !this.#depthSpec && + !this.#shapeSpec && + !this.#spaceScaleSpec + ) { + return; + } + this.#schemeSpec = undefined; + this.#typeScaleSpec = undefined; + this.#depthSpec = undefined; + this.#shapeSpec = undefined; + this.#spaceScaleSpec = undefined; + this.apply(); + } + #buildSchemeResult(): BuildSchemeResult { const spec = this.#schemeSpec; if (!spec) throw new ActiveEidosConfigError('no color scheme applied'); diff --git a/src/uix/eidos/index.ts b/src/uix/eidos/index.ts index 2ef7e7c80..f7ac2592b 100644 --- a/src/uix/eidos/index.ts +++ b/src/uix/eidos/index.ts @@ -107,7 +107,11 @@ export type { ApplyColorSchemeOptions, ApplyTypeScaleOptions, ApplyDepthOptions, - ApplyShapeOptions + ApplyShapeOptions, + ApplySpacingOptions, + ThemeSeed, + ApplyThemeOptions, + ApplyThemeResult } from './active-eidos.svelte'; export type { EidosConfigDocument,