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