feat(eidos): fluid typography engine (Phase 1) — Utopia clamp, rem, fluid headings

TYPOGRAPHY_ENGINE_RFC Phase 1. Additive on TypographyPrimitiveSet, behind the frozen
token contract (--font-size-X keeps its name; only the value formula changes, like
color --scale-* hex -> oklch()).

- type-scale.ts (pure, isomorphic, no canvas): the Utopia clamp() formula. fluidClamp /
  resolveTypeSize / isFluidSize. rem-based (a11y: scales with browser font-zoom).
- config-types: TextMetric.size accepts `string | FluidSize` ({min,max,minVw?,maxVw?}).
  Plain length strings still valid -> backward-compatible.
- render-css appendTypographyDeclarations: emits calc(resolveTypeSize(size) * --scaling)
  -> a fixed rem or a fluid clamp; the --scaling axis composes on top.
- config.ts: validateSizeValue accepts a FluidSize (validates min/max/minVw/maxVw) so
  the base config validates (was the cascade root — FluidSize objects failed the
  string-only CSS-value check).
- typography.ts: sizes in rem; headings (lg/xl/xxl/xxxl) fluid (min @480px -> max
  @1280px, max = previous fixed px so desktop is unchanged); body (md) fixed. hero/h1/h2
  drop the manual { base, md } responsive sizes — the clamp covers the viewport.
- generated/base.css regenerated. type-scale.test.ts (6 tests). 2 config-test assertions
  updated to the new rem/clamp values.

Verified in browser: --font-size-xxxl 40px @480 -> 80px @1280; xxl 32->48; lg 18->20;
md 16 fixed. check 0 errors; eidos suite green (3 pre-existing words failures only).
canvas-text/<SText> unaffected (reads getComputedStyle real font, measures the clamp).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent b273ecfb69
commit 667aa49331

@ -420,7 +420,7 @@ describe('ActiveEidos config', () => {
expect(Object.keys(typography.families)).toEqual(['primary', 'secondary', 'mono', 'display'])
expect(Object.keys(typography.sizes)).toEqual([...TEXT_SIZES])
expect(typography.styles.hero.size).toEqual({ base: 'xxl', md: 'xxxl' })
expect(typography.styles.hero.size).toBe('xxxl')
expect(typography.styles.h1.family).toBe('display')
expect(typography.styles.body.family).toBe('primary')
})
@ -784,7 +784,9 @@ describe('ActiveEidos config', () => {
// the vars so [data-density]/[data-scaling] can rebind them.
expect(css).toContain('--space-4: calc(16px * var(--density-space-scale) * var(--scaling));')
expect(css).toContain("--font-family-primary: 'Instrument Sans', system-ui, sans-serif;")
expect(css).toContain('--font-size-xxxl: calc(80px * var(--scaling));')
expect(css).toContain(
'--font-size-xxxl: calc(clamp(2.5rem, calc(1rem + 5vw), 5rem) * var(--scaling));'
)
expect(css).toContain(
'--control-height-xxs: calc(22px * var(--density-control-scale) * var(--scaling));'
)

@ -144,28 +144,28 @@
--font-family-secondary: Lora, Georgia, 'Times New Roman', serif;
--font-family-mono: 'Azeret Mono', ui-monospace, monospace;
--font-family-display: Lora, Georgia, 'Times New Roman', serif;
--font-size-xxs: calc(10px * var(--scaling));
--font-size-xxs: calc(0.625rem * var(--scaling));
--font-line-height-xxs: 1.5;
--font-letter-spacing-xxs: 0;
--font-size-xs: calc(12px * var(--scaling));
--font-size-xs: calc(0.75rem * var(--scaling));
--font-line-height-xs: 1.5;
--font-letter-spacing-xs: 0;
--font-size-sm: calc(14px * var(--scaling));
--font-size-sm: calc(0.875rem * var(--scaling));
--font-line-height-sm: 1.45;
--font-letter-spacing-sm: 0;
--font-size-md: calc(16px * var(--scaling));
--font-size-md: calc(1rem * var(--scaling));
--font-line-height-md: 1.45;
--font-letter-spacing-md: 0;
--font-size-lg: calc(20px * var(--scaling));
--font-size-lg: calc(clamp(1.125rem, calc(1.05rem + 0.25vw), 1.25rem) * var(--scaling));
--font-line-height-lg: 1.35;
--font-letter-spacing-lg: 0;
--font-size-xl: calc(28px * var(--scaling));
--font-size-xl: calc(clamp(1.5rem, calc(1.35rem + 0.5vw), 1.75rem) * var(--scaling));
--font-line-height-xl: 1.2;
--font-letter-spacing-xl: 0;
--font-size-xxl: calc(48px * var(--scaling));
--font-size-xxl: calc(clamp(2rem, calc(1.4rem + 2vw), 3rem) * var(--scaling));
--font-line-height-xxl: 1.05;
--font-letter-spacing-xxl: 0;
--font-size-xxxl: calc(80px * var(--scaling));
--font-size-xxxl: calc(clamp(2.5rem, calc(1rem + 5vw), 5rem) * var(--scaling));
--font-line-height-xxxl: 1;
--font-letter-spacing-xxxl: 0;
--font-weight-regular: 400;
@ -173,19 +173,19 @@
--font-weight-semibold: 600;
--font-weight-bold: 700;
--style-hero-font-family: var(--font-family-display);
--style-hero-font-size: var(--font-size-xxl);
--style-hero-font-size: var(--font-size-xxxl);
--style-hero-line-height: 1;
--style-hero-letter-spacing: 0;
--style-hero-font-weight: var(--font-weight-bold);
--style-hero-color: var(--color-content-primary);
--style-h1-font-family: var(--font-family-display);
--style-h1-font-size: var(--font-size-xl);
--style-h1-font-size: var(--font-size-xxl);
--style-h1-line-height: 1.05;
--style-h1-letter-spacing: 0;
--style-h1-font-weight: var(--font-weight-bold);
--style-h1-color: var(--color-content-primary);
--style-h2-font-family: var(--font-family-display);
--style-h2-font-size: var(--font-size-lg);
--style-h2-font-size: var(--font-size-xl);
--style-h2-line-height: 1.1;
--style-h2-letter-spacing: 0;
--style-h2-font-weight: var(--font-weight-bold);
@ -4178,20 +4178,6 @@
--toggle-palette-contrast: var(--toggle-threat-contrast);
}
@media (min-width: 768px) {
:root {
--style-hero-font-size: var(--font-size-xxxl);
--style-hero-line-height: var(--font-line-height-xxxl);
--style-hero-letter-spacing: var(--font-letter-spacing-xxxl);
--style-h1-font-size: var(--font-size-xxl);
--style-h1-line-height: var(--font-line-height-xxl);
--style-h1-letter-spacing: var(--font-letter-spacing-xxl);
--style-h2-font-size: var(--font-size-xl);
--style-h2-line-height: var(--font-line-height-xl);
--style-h2-letter-spacing: var(--font-letter-spacing-xl);
}
}
[data-density='compact'] {
--density-space-scale: var(--density-compact-space-scale);
--density-control-scale: var(--density-compact-control-scale);

@ -547,8 +547,21 @@ export const TEXT_SIZES = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl', 'xxxl'] a
export type TextSize = (typeof TEXT_SIZES)[number];
/**
* A fluid font size: interpolates between `min` (at `minVw`) and `max` (at `maxVw`)
* via a CSS `clamp()` (rem-based, a11y-friendly). Viewports default to the system
* breakpoints (480px–1280px). Resolved by `lib/type-scale.ts`. A `size` may still be
* a plain length string (fixed) — fluid is purely additive. (TYPOGRAPHY_ENGINE_RFC §4)
*/
export interface FluidSize {
readonly min: string;
readonly max: string;
readonly minVw?: string;
readonly maxVw?: string;
}
export interface TextMetric {
readonly size: string;
readonly size: string | FluidSize;
readonly lineHeight: string;
readonly letterSpacing: string;
}

@ -1458,11 +1458,26 @@ function validateTextMetric(
return
}
validateNonEmptyCssValue(`${path}.size`, metric.size, issues)
validateSizeValue(`${path}.size`, metric.size, issues)
validateNonEmptyCssValue(`${path}.lineHeight`, metric.lineHeight, issues)
validateNonEmptyCssValue(`${path}.letterSpacing`, metric.letterSpacing, issues)
}
/**
* A scale size is either a fixed CSS length (string / number) or a FluidSize
* `{ min, max, minVw?, maxVw? }` — validate each member as a CSS value. (Fase 1 fluid)
*/
function validateSizeValue(path: string, value: unknown, issues: EidosValidationIssue[]): void {
if (isPlainRecord(value) && hasOwn(value, 'min') && hasOwn(value, 'max')) {
validateNonEmptyCssValue(`${path}.min`, value.min, issues)
validateNonEmptyCssValue(`${path}.max`, value.max, issues)
if (value.minVw !== undefined) validateNonEmptyCssValue(`${path}.minVw`, value.minVw, issues)
if (value.maxVw !== undefined) validateNonEmptyCssValue(`${path}.maxVw`, value.maxVw, issues)
return
}
validateNonEmptyCssValue(path, value, issues)
}
function isTextMetricLike(value: Record<string, unknown>): boolean {
return hasOwn(value, 'size') || hasOwn(value, 'lineHeight') || hasOwn(value, 'letterSpacing')
}

@ -24,14 +24,18 @@ export const STATIC_TYPOGRAPHY: TypographyPrimitiveSet = {
}
},
sizes: {
xxs: { size: '10px', lineHeight: '1.5', letterSpacing: '0' },
xs: { size: '12px', lineHeight: '1.5', letterSpacing: '0' },
sm: { size: '14px', lineHeight: '1.45', letterSpacing: '0' },
md: { size: '16px', lineHeight: '1.45', letterSpacing: '0' },
lg: { size: '20px', lineHeight: '1.35', letterSpacing: '0' },
xl: { size: '28px', lineHeight: '1.2', letterSpacing: '0' },
xxl: { size: '48px', lineHeight: '1.05', letterSpacing: '0' },
xxxl: { size: '80px', lineHeight: '1', letterSpacing: '0' }
// Fixed steps in rem (a11y: scale with the browser's font-zoom). Body (md) and
// below stay fixed — fluid body harms readability + line-length stability.
xxs: { size: '0.625rem', lineHeight: '1.5', letterSpacing: '0' }, // 10px
xs: { size: '0.75rem', lineHeight: '1.5', letterSpacing: '0' }, // 12px
sm: { size: '0.875rem', lineHeight: '1.45', letterSpacing: '0' }, // 14px
md: { size: '1rem', lineHeight: '1.45', letterSpacing: '0' }, // 16px (body)
// Fluid headings (Utopia clamp): min @480px → max @1280px. Max = the previous
// fixed size, so desktop is ~unchanged; mobile scales down to avoid overflow.
lg: { size: { min: '1.125rem', max: '1.25rem' }, lineHeight: '1.35', letterSpacing: '0' }, // 18→20
xl: { size: { min: '1.5rem', max: '1.75rem' }, lineHeight: '1.2', letterSpacing: '0' }, // 24→28
xxl: { size: { min: '2rem', max: '3rem' }, lineHeight: '1.05', letterSpacing: '0' }, // 32→48
xxxl: { size: { min: '2.5rem', max: '5rem' }, lineHeight: '1', letterSpacing: '0' } // 40→80
},
weights: {
regular: 400,
@ -42,10 +46,9 @@ export const STATIC_TYPOGRAPHY: TypographyPrimitiveSet = {
styles: {
hero: {
family: 'display',
size: {
base: 'xxl',
md: 'xxxl'
},
// Single fluid size — the xxxl clamp scales it down on mobile (was a manual
// { base: xxl, md: xxxl } step).
size: 'xxxl',
weight: 'bold',
lineHeight: '1',
letterSpacing: '0',
@ -53,10 +56,7 @@ export const STATIC_TYPOGRAPHY: TypographyPrimitiveSet = {
},
h1: {
family: 'display',
size: {
base: 'xl',
md: 'xxl'
},
size: 'xxl',
weight: 'bold',
lineHeight: '1.05',
letterSpacing: '0',
@ -64,10 +64,7 @@ export const STATIC_TYPOGRAPHY: TypographyPrimitiveSet = {
},
h2: {
family: 'display',
size: {
base: 'lg',
md: 'xl'
},
size: 'xl',
weight: 'bold',
lineHeight: '1.1',
letterSpacing: '0',

@ -52,6 +52,7 @@ import type {
MotionConfig
} from '$motion'
import { apcaLc, oklchToCss, oklchToGammaRgb, safeParseColor, wcagContrastRatio } from '$color'
import { resolveTypeSize } from './type-scale'
import { EidosCssVariableError, EidosThemeNotFoundError } from '../errors'
export interface RenderThemeCssOptions {
@ -707,10 +708,14 @@ function appendTypographyDeclarations(
}
for (const [name, metric] of Object.entries(typography.sizes)) {
// font-size scales with the global `--scaling` zoom (px). line-height is a
// unitless ratio — it scales implicitly via font-size; scaling it here too
// would double-scale. letter-spacing stays as authored.
declarations.push(cssVar(`font-size-${name}`, `calc(${metric.size} * var(--scaling))`))
// font-size scales with the global `--scaling` zoom. A fluid size resolves to a
// rem-based `clamp(...)` (Utopia, viewport-fluid); a fixed size passes through.
// `--scaling` composes on top via calc(). line-height is a unitless ratio — it
// scales implicitly via font-size; scaling it here too would double-scale.
// letter-spacing stays as authored.
declarations.push(
cssVar(`font-size-${name}`, `calc(${resolveTypeSize(metric.size)} * var(--scaling))`)
)
declarations.push(cssVar(`font-line-height-${name}`, metric.lineHeight))
declarations.push(cssVar(`font-letter-spacing-${name}`, metric.letterSpacing))
}

@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { fluidClamp, isFluidSize, resolveTypeSize } from './type-scale';
describe('type-scale (fluid)', () => {
it('isFluidSize discriminates fluid descriptors from fixed strings', () => {
expect(isFluidSize({ min: '1rem', max: '2rem' })).toBe(true);
expect(isFluidSize('16px')).toBe(false);
expect(isFluidSize(null)).toBe(false);
expect(isFluidSize({ min: '1rem' })).toBe(false);
});
it('computes the Utopia clamp (rem) for the default viewports', () => {
// 40px @480 → 80px @1280: slope 0.05, slopeVw 5, intercept 16px = 1rem.
expect(fluidClamp({ min: '2.5rem', max: '5rem' })).toBe(
'clamp(2.5rem, calc(1rem + 5vw), 5rem)'
);
});
it('accepts px inputs and emits rem', () => {
expect(fluidClamp({ min: '40px', max: '80px' })).toBe('clamp(2.5rem, calc(1rem + 5vw), 5rem)');
});
it('honours custom viewports', () => {
// 16px @320 → 24px @1024: slope ≈ 0.011364, slopeVw ≈ 1.1364,
// intercept ≈ 12.3636px ≈ 0.7727rem.
expect(fluidClamp({ min: '1rem', max: '1.5rem', minVw: '320px', maxVw: '1024px' })).toMatch(
/^clamp\(1rem, calc\(0\.77\d*rem \+ 1\.13\d*vw\), 1\.5rem\)$/
);
});
it('collapses degenerate inputs to a fixed rem', () => {
expect(fluidClamp({ min: '1.25rem', max: '1.25rem' })).toBe('1.25rem');
});
it('resolveTypeSize: fixed passes through, fluid → clamp', () => {
expect(resolveTypeSize('1rem')).toBe('1rem');
expect(resolveTypeSize({ min: '2.5rem', max: '5rem' })).toBe(
'clamp(2.5rem, calc(1rem + 5vw), 5rem)'
);
});
});

@ -0,0 +1,72 @@
/**
* Fluid type scale — the Utopia `clamp()` formula. Pure + isomorphic (build +
* runtime), DOM-free, no canvas: it computes a CSS value string; the browser does the
* fluid scaling natively. This is the "fluid engine" — the typography analogue of the
* color math in `build-scheme.ts`.
*
* A fluid size interpolates linearly between two viewport widths:
*
* slope = (maxPx − minPx) / (maxVwPx − minVwPx)
* interceptRem = (minPx − slope · minVwPx) / 16
* size = clamp(minRem, interceptRem·1rem + slope·100·1vw, maxRem)
*
* Output is in **rem** (not px) so the user's browser font-zoom scales it (a11y). The
* eidos `--scaling` axis composes on top at emit time via `calc(clamp(...) * --scaling)`.
*/
import type { FluidSize } from './config-types';
const ROOT_PX = 16;
const DEFAULT_MIN_VW = '480px';
const DEFAULT_MAX_VW = '1280px';
export function isFluidSize(value: unknown): value is FluidSize {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as FluidSize).min === 'string' &&
typeof (value as FluidSize).max === 'string'
);
}
/** Parse a `px` or `rem` length to px (rem assumes a 16px root). */
function toPx(value: string): number {
const n = parseFloat(value);
if (Number.isNaN(n)) return 0;
return value.trim().toLowerCase().endsWith('rem') ? n * ROOT_PX : n;
}
function round(n: number): number {
return Math.round(n * 10000) / 10000;
}
/**
* Compute a fluid `clamp(min, preferred, max)` (in rem) from a {@link FluidSize}.
* Degenerate inputs (equal sizes, or a zero viewport span) collapse to a plain fixed
* `rem` value.
*/
export function fluidClamp(size: FluidSize): string {
const minPx = toPx(size.min);
const maxPx = toPx(size.max);
const minVwPx = toPx(size.minVw ?? DEFAULT_MIN_VW);
const maxVwPx = toPx(size.maxVw ?? DEFAULT_MAX_VW);
const lo = round(Math.min(minPx, maxPx) / ROOT_PX);
const hi = round(Math.max(minPx, maxPx) / ROOT_PX);
if (minPx === maxPx || maxVwPx === minVwPx) return `${lo}rem`;
const slope = (maxPx - minPx) / (maxVwPx - minVwPx);
const slopeVw = round(slope * 100);
const interceptRem = round((minPx - slope * minVwPx) / ROOT_PX);
return `clamp(${lo}rem, calc(${interceptRem}rem + ${slopeVw}vw), ${hi}rem)`;
}
/**
* Resolve any scale size value to a CSS size string: a fixed length passes through
* verbatim; a {@link FluidSize} becomes a fluid `clamp()`. Used by `render-css` to
* emit `--font-size-{key}`.
*/
export function resolveTypeSize(size: string | FluidSize): string {
return isFluidSize(size) ? fluidClamp(size) : size;
}
Loading…
Cancel
Save

Powered by TurnKey Linux.