From d85c2fd3766a75b9bce5931cc879a609cabdcaf4 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 5 Jun 2026 03:06:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(eidos):=20typography=20scales=20=E2=80=94?= =?UTF-8?q?=20tracking/leading/features/measure=20+=20optical=20tracking?= =?UTF-8?q?=20(Phase=203a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TYPOGRAPHY_ENGINE_RFC Phase 3 (engine tokens). Additive scales, pure CSS, no binaries. - config-types: TypographyPrimitiveSet += tracking / leading / features / measure (optional Record). - render-css: emits --tracking-{k} / --leading-{k} / --font-feature-{k} / --measure-{k}. - typography.ts defaults: tracking (tighter…wider), leading (none…loose), features (tabular = "tnum"+"lnum" for data, oldstyle/smallcaps/ligatures), measure (54/66/78ch). + optical tracking baked into the size tokens: small text slightly looser (xxs +0.01em), display tighter (xxxl -0.02em) — was all 0. - config.ts: validates the 4 scales (CSS-value maps). - generated/base.css regenerated. test: scales + optical tracking emission. Additive tokens (recipes/components consume var(--tracking-tight) etc.) so the scales are zero-risk; the only rendered change is the gentle optical tracking on headings/small text. check 0 errors; eidos suite green (3 pre-existing words failures only). Next (Phase 3b): wire the component props (wrap: balance/pretty, numeric: tabular, tracking/leading/measure) on /. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uix/eidos/active-eidos-config.test.ts | 11 ++++++ src/uix/eidos/generated/base.css | 29 +++++++++++--- src/uix/eidos/lib/config-types.ts | 8 ++++ src/uix/eidos/lib/config.ts | 22 +++++++++++ src/uix/eidos/lib/primitives/typography.ts | 44 +++++++++++++++++++--- src/uix/eidos/lib/render-css.ts | 15 ++++++++ 6 files changed, 119 insertions(+), 10 deletions(-) diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index aba7d58df..d4181c282 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -441,6 +441,17 @@ describe('ActiveEidos config', () => { expect((css.match(/font-family: 'Lora';/g) ?? []).length).toBe(8) }) + it('emits the Phase 3 typography scales + optical tracking', () => { + const css = createThemeBaseEidos().renderStaticCss() + expect(css).toContain('--tracking-tight: -0.02em;') + expect(css).toContain('--leading-normal: 1.5;') + expect(css).toContain('--font-feature-tabular: "tnum" 1, "lnum" 1;') + expect(css).toContain('--measure-normal: 66ch;') + // optical tracking baked into the size tokens (display negative, small positive) + expect(css).toContain('--font-letter-spacing-xxxl: -0.02em;') + expect(css).toContain('--font-letter-spacing-xxs: 0.01em;') + }) + it('validates typography style references', () => { const cfg = structuredClone(THEME_BASE_OPTIONS) const typography = cfg.primitives.typography! diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index b574c0035..7a1a499bb 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -258,10 +258,10 @@ --font-family-display: Lora, Georgia, 'Times New Roman', serif; --font-size-xxs: calc(0.625rem * var(--scaling)); --font-line-height-xxs: 1.5; - --font-letter-spacing-xxs: 0; + --font-letter-spacing-xxs: 0.01em; --font-size-xs: calc(0.75rem * var(--scaling)); --font-line-height-xs: 1.5; - --font-letter-spacing-xs: 0; + --font-letter-spacing-xs: 0.005em; --font-size-sm: calc(0.875rem * var(--scaling)); --font-line-height-sm: 1.45; --font-letter-spacing-sm: 0; @@ -273,17 +273,36 @@ --font-letter-spacing-lg: 0; --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-letter-spacing-xl: -0.01em; --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-letter-spacing-xxl: -0.015em; --font-size-xxxl: calc(clamp(2.5rem, calc(1rem + 5vw), 5rem) * var(--scaling)); --font-line-height-xxxl: 1; - --font-letter-spacing-xxxl: 0; + --font-letter-spacing-xxxl: -0.02em; --font-weight-regular: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; + --tracking-tighter: -0.04em; + --tracking-tight: -0.02em; + --tracking-normal: 0; + --tracking-wide: 0.02em; + --tracking-wider: 0.05em; + --leading-none: 1; + --leading-tight: 1.15; + --leading-snug: 1.3; + --leading-normal: 1.5; + --leading-relaxed: 1.65; + --leading-loose: 2; + --font-feature-normal: normal; + --font-feature-tabular: "tnum" 1, "lnum" 1; + --font-feature-oldstyle: "onum" 1, "pnum" 1; + --font-feature-smallcaps: "smcp" 1; + --font-feature-ligatures: "liga" 1, "clig" 1, "dlig" 1; + --measure-narrow: 54ch; + --measure-normal: 66ch; + --measure-wide: 78ch; --style-hero-font-family: var(--font-family-display); --style-hero-font-size: var(--font-size-xxxl); --style-hero-line-height: 1; diff --git a/src/uix/eidos/lib/config-types.ts b/src/uix/eidos/lib/config-types.ts index 26d1fbbbf..087a24973 100644 --- a/src/uix/eidos/lib/config-types.ts +++ b/src/uix/eidos/lib/config-types.ts @@ -640,6 +640,14 @@ export interface TypographyPrimitiveSet { readonly sizes: Record; readonly weights: Record; readonly styles: Record; + /** Letter-spacing scale → `--tracking-{key}` (tighter…wider). (Fase 3) */ + readonly tracking?: Record; + /** Line-height scale → `--leading-{key}` (none…loose). (Fase 3) */ + readonly leading?: Record; + /** `font-feature-settings` presets → `--font-feature-{key}` (tabular, ligatures…). */ + readonly features?: Record; + /** Line-length / measure scale → `--measure-{key}` (narrow…wide, in `ch`). */ + readonly measure?: Record; } export interface PrimitiveSet { diff --git a/src/uix/eidos/lib/config.ts b/src/uix/eidos/lib/config.ts index 245c4cec5..9ca438606 100644 --- a/src/uix/eidos/lib/config.ts +++ b/src/uix/eidos/lib/config.ts @@ -822,6 +822,28 @@ function validateTypography(options: EidosConfig, issues: EidosValidationIssue[] } } + // Phase 3 scales — tracking / leading / features / measure (optional CSS-value maps). + for (const scale of ['tracking', 'leading', 'features', 'measure'] as const) { + const record = typography[scale] + if (record === undefined) continue + if (!isPlainRecord(record)) { + issues.push({ + path: `primitives.typography.${scale}`, + message: `typography ${scale} must be a plain object` + }) + continue + } + for (const [name, value] of Object.entries(record)) { + validateCssTokenSuffix( + `primitives.typography.${scale}.${name}`, + name, + `typography ${scale}`, + issues + ) + validateNonEmptyCssValue(`primitives.typography.${scale}.${name}`, value, issues) + } + } + if (styles) { for (const [name, style] of Object.entries(styles)) { const path = `primitives.typography.styles.${name}` diff --git a/src/uix/eidos/lib/primitives/typography.ts b/src/uix/eidos/lib/primitives/typography.ts index ed966935c..cb18d9aea 100644 --- a/src/uix/eidos/lib/primitives/typography.ts +++ b/src/uix/eidos/lib/primitives/typography.ts @@ -52,16 +52,18 @@ export const STATIC_TYPOGRAPHY: TypographyPrimitiveSet = { sizes: { // 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 + // letterSpacing = optical tracking: small text reads better slightly looser, + // large display tighter (gentle; serif display benefits most). (Fase 3) + xxs: { size: '0.625rem', lineHeight: '1.5', letterSpacing: '0.01em' }, // 10px + xs: { size: '0.75rem', lineHeight: '1.5', letterSpacing: '0.005em' }, // 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 + xl: { size: { min: '1.5rem', max: '1.75rem' }, lineHeight: '1.2', letterSpacing: '-0.01em' }, // 24→28 + xxl: { size: { min: '2rem', max: '3rem' }, lineHeight: '1.05', letterSpacing: '-0.015em' }, // 32→48 + xxxl: { size: { min: '2.5rem', max: '5rem' }, lineHeight: '1', letterSpacing: '-0.02em' } // 40→80 }, weights: { regular: 400, @@ -168,5 +170,37 @@ export const STATIC_TYPOGRAPHY: TypographyPrimitiveSet = { letterSpacing: '0', color: 'content.primary' } + }, + // Tracking scale (letter-spacing) → --tracking-{key}. (Fase 3) + tracking: { + tighter: '-0.04em', + tight: '-0.02em', + normal: '0', + wide: '0.02em', + wider: '0.05em' + }, + // Leading scale (line-height) → --leading-{key}. + leading: { + none: '1', + tight: '1.15', + snug: '1.3', + normal: '1.5', + relaxed: '1.65', + loose: '2' + }, + // font-feature-settings presets → --font-feature-{key}. `tabular` = lining + + // tabular numerals for data/tables; others opt-in via the `numeric` / class. + features: { + normal: 'normal', + tabular: '"tnum" 1, "lnum" 1', + oldstyle: '"onum" 1, "pnum" 1', + smallcaps: '"smcp" 1', + ligatures: '"liga" 1, "clig" 1, "dlig" 1' + }, + // Measure (line-length) for readable prose → --measure-{key}. + measure: { + narrow: '54ch', + normal: '66ch', + wide: '78ch' } } diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index 39ffbfb77..f25972761 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -732,6 +732,21 @@ function appendTypographyDeclarations( declarations.push(cssVar(`font-weight-${name}`, weight)) } + // Phase 3 scales — tracking / leading / features / measure. Additive tokens that + // recipes + components consume (var(--tracking-tight), etc.). + for (const [name, value] of Object.entries(typography.tracking ?? {})) { + declarations.push(cssVar(`tracking-${name}`, value)) + } + for (const [name, value] of Object.entries(typography.leading ?? {})) { + declarations.push(cssVar(`leading-${name}`, value)) + } + for (const [name, value] of Object.entries(typography.features ?? {})) { + declarations.push(cssVar(`font-feature-${name}`, value)) + } + for (const [name, value] of Object.entries(typography.measure ?? {})) { + declarations.push(cssVar(`measure-${name}`, value)) + } + if (typography.styles) { appendTypographyStyleDeclarations(declarations, typography) }