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
parent
6a848b50e3
commit
427d1d2860
@ -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…
Reference in new issue