diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index f7fed711d..fcaa94b5e 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -561,6 +561,14 @@ describe('ActiveEidos config', () => { expect(css).toContain('--shape-smoothing: 3;'); }); + it('applyShape retunes the channel at runtime (jaula-abierta shape builder)', () => { + const eidos = createThemeBaseEidos(); + const result = eidos.applyShape({ smoothing: 1.4, families: { continuous: 'squircle' } }); + expect(result.variables).toContain('--shape-smoothing: 1.4;'); + expect(result.families).toContain("[data-shape='continuous'] { corner-shape: squircle; }"); + eidos.clearShape(); // reverts cleanly, no throw + }); + it('emits the Phase 3 typography scales + optical tracking', () => { const css = createThemeBaseEidos().renderStaticCss() expect(css).toContain('--tracking-tight: -0.02em;') diff --git a/src/uix/eidos/active-eidos.svelte.ts b/src/uix/eidos/active-eidos.svelte.ts index d4d296b8c..9b6bbb69c 100644 --- a/src/uix/eidos/active-eidos.svelte.ts +++ b/src/uix/eidos/active-eidos.svelte.ts @@ -54,6 +54,7 @@ import { type BuildTypeScaleResult } from './lib/build-type-scale'; import { buildDepth, type DepthOverrides, type BuildDepthResult } from './lib/build-depth'; +import { buildShape, type ShapeSeed, type BuildShapeResult } from './lib/build-shape'; import { collectFontPreloads, type FontPreload } from './lib/font-preload'; import type { Oklch } from '$color'; import type { EngineMotion } from '$motion'; @@ -158,6 +159,17 @@ interface ActiveEidosDepthSpec { readonly options: ApplyDepthOptions; } +/** Options for {@link ActiveEidos.applyShape}. */ +export interface ApplyShapeOptions { + /** CSS selector the variable override targets. @default ':root' */ + readonly selector?: string; +} + +interface ActiveEidosShapeSpec { + readonly seed: ShapeSeed; + readonly options: ApplyShapeOptions; +} + export interface ActiveEidosOptions { readonly config?: EidosConfig | EidosConfigDocument; readonly themeBase?: EidosConfigPatch; @@ -219,6 +231,7 @@ export class ActiveEidos { #schemeSpec: ActiveEidosSchemeSpec | undefined; #typeScaleSpec: ActiveEidosTypeScaleSpec | undefined; #depthSpec: ActiveEidosDepthSpec | undefined; + #shapeSpec: ActiveEidosShapeSpec | undefined; #unsubscribe: (() => void) | undefined; #lastAttrs: ActiveEidosLastAttrs | undefined; #disposed = false; @@ -528,6 +541,26 @@ export class ActiveEidos { this.apply(); } + /** + * Retune the shape channel at runtime — the shape analogue of {@link applyColorScheme} / + * {@link applyTypeScale} / {@link applyDepth}. Dial `smoothing` (corner continuity / squircle + * intensity) + `nestGap`, or override/add `families`, written as a managed block that wins over + * the foundation. The jaula-abierta runtime of the shape channel. + */ + applyShape(seed: ShapeSeed, options: ApplyShapeOptions = {}): BuildShapeResult { + this.#shapeSpec = { seed, options }; + const result = buildShape(seed); + this.apply(); + return result; + } + + /** Remove the applied shape retune, reverting to the foundation's shape system. */ + clearShape(): void { + if (!this.#shapeSpec) return; + this.#shapeSpec = undefined; + this.apply(); + } + #buildSchemeResult(): BuildSchemeResult { const spec = this.#schemeSpec; if (!spec) throw new ActiveEidosConfigError('no color scheme applied'); @@ -623,6 +656,16 @@ export class ActiveEidos { } else { this.#removeStyle(host, depthStyleId); } + + // The runtime shape retune (applyShape) is written last so its `--shape-*` + family rules + // win over the static foundation's shape system at equal specificity. + const shapeStyleId = `${this.#styleId}-shape`; + const shapeCss = this.#renderShapeCss(); + if (shapeCss) { + this.#writeStyle(host, shapeStyleId, shapeCss); + } else { + this.#removeStyle(host, shapeStyleId); + } } #renderSchemeCss(): string { @@ -657,6 +700,15 @@ export class ActiveEidos { return body ? `${selector} {\n${body}\n}` : ''; } + #renderShapeCss(): string { + if (!this.#shapeSpec) return ''; + const result = buildShape(this.#shapeSpec.seed); + const selector = this.#shapeSpec.options.selector ?? ':root'; + const body = result.variables.map((line) => `\t${line}`).join('\n'); + const varBlock = body ? `${selector} {\n${body}\n}` : ''; + return [varBlock, ...result.families].filter(Boolean).join('\n'); + } + dispose(): void { if (this.#disposed) return; this.#disposed = true; diff --git a/src/uix/eidos/index.ts b/src/uix/eidos/index.ts index b9c410a01..f13013a7d 100644 --- a/src/uix/eidos/index.ts +++ b/src/uix/eidos/index.ts @@ -28,6 +28,8 @@ export type { } from './lib/build-type-scale'; export { buildDepth, depthDeclarations } from './lib/build-depth'; export type { DepthOverrides, BuildDepthResult } from './lib/build-depth'; +export { buildShape } from './lib/build-shape'; +export type { ShapeSeed, BuildShapeResult } from './lib/build-shape'; export { collectFontPreloads } from './lib/font-preload'; export type { FontPreload } from './lib/font-preload'; export { @@ -102,7 +104,8 @@ export type { ActiveEidosUserOptions, ApplyColorSchemeOptions, ApplyTypeScaleOptions, - ApplyDepthOptions + ApplyDepthOptions, + ApplyShapeOptions } from './active-eidos.svelte'; export type { EidosConfigDocument, diff --git a/src/uix/eidos/lib/build-shape.ts b/src/uix/eidos/lib/build-shape.ts new file mode 100644 index 000000000..54ef864f0 --- /dev/null +++ b/src/uix/eidos/lib/build-shape.ts @@ -0,0 +1,42 @@ +/** + * Runtime shape retune — the shape analogue of `build-scheme.ts` (color), + * `build-type-scale.ts` (typography) and `build-depth.ts` (depth). Pure + isomorphic, DOM-free. + * + * Given a seed it emits the shape overrides for a managed block: `--shape-smoothing` (live + * continuity / squircle intensity) + `--shape-nest-gap` as `:root` variables, and any + * `families` as `[data-shape='{family}']` rules that win over the foundation. The jaula-abierta + * runtime of the shape channel — dial the corner continuity from a theme editor, no rebuild. + * + * `ActiveEidos.applyShape(seed)` writes the result as a managed block; the composition is here, + * the DOM application lives on ActiveEidos, matching the other three builders. + */ + +export interface ShapeSeed { + /** Superellipse exponent for the `continuous` family (1 = arc, 2 = squircle). */ + readonly smoothing?: string | number; + /** Concentric nesting gap (`--shape-nest-gap`). */ + readonly nestGap?: string; + /** `data-shape='{family}'` → `corner-shape` value — override or add families at runtime. */ + readonly families?: Record; +} + +export interface BuildShapeResult { + /** `:root` variable declarations (`--shape-smoothing` / `--shape-nest-gap`). */ + readonly variables: readonly string[]; + /** Full `[data-shape='{family}'] { corner-shape: … }` rule strings. */ + readonly families: readonly string[]; +} + +/** Build the shape override declarations + family rules from a runtime seed. */ +export function buildShape(seed: ShapeSeed): BuildShapeResult { + const variables: string[] = []; + if (seed.smoothing !== undefined) variables.push(`--shape-smoothing: ${seed.smoothing};`); + if (seed.nestGap !== undefined) variables.push(`--shape-nest-gap: ${seed.nestGap};`); + + const families: string[] = []; + for (const [family, value] of Object.entries(seed.families ?? {})) { + families.push(`[data-shape='${family}'] { corner-shape: ${value}; }`); + } + + return { variables, families }; +}