From 427d1d28609dc4bc03feecc1345e77ab4964ef65 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 5 Jun 2026 05:31:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(eidos):=20runtime=20type-scale=20builder?= =?UTF-8?q?=20=E2=80=94=20applyTypeScale=20(+=20buildTypeScale)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typography analogue of applyColorScheme: derive a whole `--font-size-*` ladder from one modular ratio + base, optionally fluid (`ratioMax` grows the scale on wide screens), applied as a managed `:root` block that overrides the theme authored sizes at runtime. - buildTypeScale(seed) — pure, in eidos/lib; mirrors build-scheme. Steps the 8 named sizes (xxs..xxxl) off `md`=base via the ratio; reuses fluidClamp; composes with `--scaling`. - ActiveEidos.applyTypeScale(seed, opts) / clearTypeScale() — managed block written last so it wins over the static sizes. - exported from $uix/eidos (buildTypeScale, typeScaleDeclarations, types). `npm run check` 0 errors; buildTypeScale + applyTypeScale tests pass. The managed-block DOM path is the same mechanism as applyColorScheme; the /temas/tipografia showcase will dogfood it live. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uix/eidos/active-eidos-config.test.ts | 8 ++ src/uix/eidos/active-eidos.svelte.ts | 54 ++++++++++++ src/uix/eidos/index.ts | 9 +- src/uix/eidos/lib/build-type-scale.test.ts | 45 ++++++++++ src/uix/eidos/lib/build-type-scale.ts | 95 ++++++++++++++++++++++ 5 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 src/uix/eidos/lib/build-type-scale.test.ts create mode 100644 src/uix/eidos/lib/build-type-scale.ts diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index af12d50f2..fd5867fbc 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -461,6 +461,14 @@ describe('ActiveEidos config', () => { expect(css).toContain('font-optical-sizing: auto') }) + it('applyTypeScale returns a built scale (runtime type-scale builder)', () => { + const eidos = createThemeBaseEidos(); + const result = eidos.applyTypeScale({ base: 16, ratio: 1.25 }); + expect(result.sizes).toHaveLength(8); + expect(result.variables).toContain('--font-size-md: calc(1rem * var(--scaling));'); + eidos.clearTypeScale(); // 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 05e385fb8..e37e30ff9 100644 --- a/src/uix/eidos/active-eidos.svelte.ts +++ b/src/uix/eidos/active-eidos.svelte.ts @@ -48,6 +48,11 @@ import { type BuildSchemeOptions, type BuildSchemeResult } from './lib/build-scheme'; +import { + buildTypeScale, + type TypeScaleSeed, + type BuildTypeScaleResult +} from './lib/build-type-scale'; import type { Oklch } from '$color'; import type { EngineMotion } from '$motion'; import type { EidosConfigPatch } from './lib/options'; @@ -129,6 +134,17 @@ interface ActiveEidosSchemeSpec { 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; @@ -188,6 +204,7 @@ export class ActiveEidos { #cssVariables: EidosCssVariableMap | undefined; #cssVariablesOptions: ActiveEidosCssVariablesOptions; #schemeSpec: ActiveEidosSchemeSpec | undefined; + #typeScaleSpec: ActiveEidosTypeScaleSpec | undefined; #unsubscribe: (() => void) | undefined; #lastAttrs: ActiveEidosLastAttrs | undefined; #disposed = false; @@ -449,6 +466,25 @@ export class ActiveEidos { 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'); @@ -524,6 +560,16 @@ export class ActiveEidos { } 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 { @@ -542,6 +588,14 @@ export class ActiveEidos { 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; diff --git a/src/uix/eidos/index.ts b/src/uix/eidos/index.ts index 1736b0d4c..a4f7c88d8 100644 --- a/src/uix/eidos/index.ts +++ b/src/uix/eidos/index.ts @@ -20,6 +20,12 @@ export type { SchemeOnSolidPair, SchemeRoleResult } from './lib/build-scheme'; +export { buildTypeScale, typeScaleDeclarations } from './lib/build-type-scale'; +export type { + TypeScaleSeed, + TypeScaleSize, + BuildTypeScaleResult +} from './lib/build-type-scale'; export { createEidosConfigDocumentFromConfig, getEidosColorRoleScale, @@ -90,7 +96,8 @@ export type { ActiveEidosThemeResolver, ActiveEidosThemeSource, ActiveEidosUserOptions, - ApplyColorSchemeOptions + ApplyColorSchemeOptions, + ApplyTypeScaleOptions } from './active-eidos.svelte'; export type { EidosConfigDocument, diff --git a/src/uix/eidos/lib/build-type-scale.test.ts b/src/uix/eidos/lib/build-type-scale.test.ts new file mode 100644 index 000000000..dfe15e10b --- /dev/null +++ b/src/uix/eidos/lib/build-type-scale.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { buildTypeScale } from './build-type-scale'; + +describe('buildTypeScale', () => { + it('builds a static modular scale from a single ratio', () => { + const r = buildTypeScale({ base: 16, ratio: 1.25 }); + expect(r.variables).toHaveLength(8); + // md is the base (step 0) → 16px = 1rem, ratio-independent + expect(r.variables).toContain('--font-size-md: calc(1rem * var(--scaling));'); + // lg one step up: 16 * 1.25 = 20px = 1.25rem + expect(r.variables).toContain('--font-size-lg: calc(1.25rem * var(--scaling));'); + // sm one step down: 16 / 1.25 = 12.8px = 0.8rem + expect(r.variables).toContain('--font-size-sm: calc(0.8rem * var(--scaling));'); + // static (no ratioMax) → plain rem, never a clamp + expect(r.variables.join('\n')).not.toContain('clamp('); + }); + + it('builds a fluid scale when ratioMax > ratio', () => { + const r = buildTypeScale({ base: 16, ratio: 1.2, ratioMax: 1.333 }); + const byKey = Object.fromEntries(r.sizes.map((s) => [s.key, s])); + // md (step 0) stays fixed even when fluid (min === max → plain rem) + expect(byKey.md.minPx).toBe(16); + expect(byKey.md.maxPx).toBe(16); + expect(byKey.md.value).toBe('1rem'); + // lg grows between viewports: min 16*1.2=19.2, max 16*1.333≈21.328 → a clamp + expect(byKey.lg.value).toContain('clamp('); + expect(byKey.lg.minPx).toBeCloseTo(19.2, 1); + expect(byKey.lg.maxPx).toBeCloseTo(21.328, 1); + }); + + it('returns the ladder in order xxs → xxxl with correct step indices', () => { + const r = buildTypeScale({ base: 16, ratio: 1.25 }); + expect(r.sizes.map((s) => s.key)).toEqual([ + 'xxs', + 'xs', + 'sm', + 'md', + 'lg', + 'xl', + 'xxl', + 'xxxl' + ]); + expect(r.sizes.map((s) => s.step)).toEqual([-3, -2, -1, 0, 1, 2, 3, 4]); + }); +}); diff --git a/src/uix/eidos/lib/build-type-scale.ts b/src/uix/eidos/lib/build-type-scale.ts new file mode 100644 index 000000000..80f9e8d34 --- /dev/null +++ b/src/uix/eidos/lib/build-type-scale.ts @@ -0,0 +1,95 @@ +/** + * Runtime modular + fluid type-scale builder. Pure + isomorphic, DOM-free — the + * typography analogue of `build-scheme.ts` (color). Given a seed (base size + modular + * ratio + optional fluid endpoint), it generates the full `--font-size-{key}` set as + * fluid `clamp()`s, composing with the eidos `--scaling` axis at emit time. + * + * `ActiveEidos.applyTypeScale(seed)` writes the result as a managed `:root` block that + * overrides the theme's authored scale at runtime — the same posture as + * `applyColorScheme` for the palette: the engine math is here, the DOM application lives + * on `ActiveEidos`. + */ +import type { TextSize } from './config-types'; +import { fluidClamp } from './type-scale'; + +/** + * Modular-scale step index per named size (`md` = base = step 0). The UI sizes step down + * (negative), the display sizes step up (positive) — so a single ratio drives the whole + * ladder. This is intentionally a *uniform* modular scale (one ratio); the authored base + * scale is a hand-tuned two-zone scale, and `applyTypeScale` is the opt-in mathematical + * alternative. + */ +const TYPE_SCALE_STEPS = { + xxs: -3, + xs: -2, + sm: -1, + md: 0, + lg: 1, + xl: 2, + xxl: 3, + xxxl: 4 +} as const satisfies Record + +export interface TypeScaleSeed { + /** Base font size in px — the `md` / body anchor. */ + readonly base: number; + /** Modular ratio at the min viewport (1.2 minor third, 1.25 major third, 1.333 fourth…). */ + readonly ratio: number; + /** + * Ratio at the max viewport. When `> ratio` the scale grows on wide screens (fluid); + * omit (or set equal to `ratio`) for a static scale. + */ + readonly ratioMax?: number; + /** Fluid viewport floor in px. @default 480 */ + readonly minVw?: number; + /** Fluid viewport ceiling in px. @default 1280 */ + readonly maxVw?: number; +} + +export interface TypeScaleSize { + readonly key: TextSize; + readonly step: number; + readonly minPx: number; + readonly maxPx: number; + /** The resolved CSS value — a `clamp()` when fluid, a fixed `rem` when static. */ + readonly value: string; +} + +export interface BuildTypeScaleResult { + /** `--font-size-{key}: calc( * var(--scaling));` declarations, in ladder order. */ + readonly variables: readonly string[]; + readonly sizes: readonly TypeScaleSize[]; +} + +function round(n: number): number { + return Math.round(n * 1000) / 1000; +} + +/** + * Build a complete `--font-size-{key}` ladder from a modular ratio. With `ratioMax` + * the scale is fluid (the ratio interpolates between `minVw` and `maxVw`). + */ +export function buildTypeScale(seed: TypeScaleSeed): BuildTypeScaleResult { + const ratioMax = seed.ratioMax ?? seed.ratio; + const minVw = `${seed.minVw ?? 480}px`; + const maxVw = `${seed.maxVw ?? 1280}px`; + const variables: string[] = []; + const sizes: TypeScaleSize[] = []; + + for (const key of Object.keys(TYPE_SCALE_STEPS) as TextSize[]) { + const step = TYPE_SCALE_STEPS[key]; + const minPx = round(seed.base * Math.pow(seed.ratio, step)); + const maxPx = round(seed.base * Math.pow(ratioMax, step)); + const value = fluidClamp({ min: `${minPx}px`, max: `${maxPx}px`, minVw, maxVw }); + // Compose with `--scaling` at emit time, matching `render-css`'s size emission. + variables.push(`--font-size-${key}: calc(${value} * var(--scaling));`); + sizes.push({ key, step, minPx, maxPx, value }); + } + + return { variables, sizes }; +} + +/** Flatten a built scale to CSS declaration lines (for a managed `:root` block). */ +export function typeScaleDeclarations(result: BuildTypeScaleResult): readonly string[] { + return result.variables; +}