feat(eidos): runtime type-scale builder — applyTypeScale (+ buildTypeScale)

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 6a848b50e3
commit 427d1d2860

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

@ -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;

@ -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,

@ -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]);
});
});

@ -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<TextSize, number>
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(<value> * 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;
}
Loading…
Cancel
Save

Powered by TurnKey Linux.