diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index a45616b39..f4686aaab 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -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));' ) diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index ab0d192d6..bcf74a99f 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -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); diff --git a/src/uix/eidos/lib/config-types.ts b/src/uix/eidos/lib/config-types.ts index f9d33afec..0b834a5bb 100644 --- a/src/uix/eidos/lib/config-types.ts +++ b/src/uix/eidos/lib/config-types.ts @@ -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; } diff --git a/src/uix/eidos/lib/config.ts b/src/uix/eidos/lib/config.ts index ed238b4ba..245c4cec5 100644 --- a/src/uix/eidos/lib/config.ts +++ b/src/uix/eidos/lib/config.ts @@ -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): boolean { return hasOwn(value, 'size') || hasOwn(value, 'lineHeight') || hasOwn(value, 'letterSpacing') } diff --git a/src/uix/eidos/lib/primitives/typography.ts b/src/uix/eidos/lib/primitives/typography.ts index 9fc5cfdc1..e4027c988 100644 --- a/src/uix/eidos/lib/primitives/typography.ts +++ b/src/uix/eidos/lib/primitives/typography.ts @@ -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', diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index 1d359ecdb..bfb912ccf 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -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)) } diff --git a/src/uix/eidos/lib/type-scale.test.ts b/src/uix/eidos/lib/type-scale.test.ts new file mode 100644 index 000000000..a38d3e1e1 --- /dev/null +++ b/src/uix/eidos/lib/type-scale.test.ts @@ -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)' + ); + }); +}); diff --git a/src/uix/eidos/lib/type-scale.ts b/src/uix/eidos/lib/type-scale.ts new file mode 100644 index 000000000..3c931551f --- /dev/null +++ b/src/uix/eidos/lib/type-scale.ts @@ -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; +}