From f53299d42b1e2851e07ebc0661b6032a0b5b6c70 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 5 Jun 2026 02:56:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(eidos):=20config-driven=20@font-face=20(Ph?= =?UTF-8?q?ase=202)=20=E2=80=94=20fonts=20as=20theme=20data=20(next/font?= =?UTF-8?q?=20model)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so @font-face becomes config-driven + generated — like the color palette — instead of a separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face), above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you. - config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback (FontFallback, metric-override) / display / preload. Additive — the family stack still works from `family`+`fallbacks`. - render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped by the real font name (a font shared across slots — Lora as secondary+display — emits once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack as `'{family} Fallback'` when declared. Emitted first in renderStaticCss. - typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config (faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback metrics by editing config only. - index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in generated/base.css. (fonts.css superseded; left in place, no longer imported.) - generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face generation + dedup; merge-without-mutation assertion updated for the faces field. Verified in browser: 3 families registered, files resolve (200), fonts load on demand (swap). check 0 errors; eidos suite green (3 pre-existing words failures only). Deferred (capability typed, theme adopts when it has the assets): variable woff2, metric-override numbers (need fontkit/precompute), (head markup). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uix/eidos/active-eidos-config.test.ts | 26 ++++- src/uix/eidos/generated/base.css | 112 +++++++++++++++++++++ src/uix/eidos/index.css | 8 +- src/uix/eidos/lib/config-types.ts | 49 +++++++++ src/uix/eidos/lib/primitives/typography.ts | 32 +++++- src/uix/eidos/lib/render-css.ts | 69 ++++++++++++- 6 files changed, 282 insertions(+), 14 deletions(-) diff --git a/src/uix/eidos/active-eidos-config.test.ts b/src/uix/eidos/active-eidos-config.test.ts index f4686aaab..aba7d58df 100644 --- a/src/uix/eidos/active-eidos-config.test.ts +++ b/src/uix/eidos/active-eidos-config.test.ts @@ -236,11 +236,17 @@ describe('ActiveEidos config', () => { expect(cfg.primitives.space?.['20']).toBe('80px') expect(THEME_BASE_OPTIONS.primitives.space?.['4']).toBe('16px') expect(THEME_BASE_OPTIONS.primitives.space?.['20']).toBeUndefined() - expect(cfg.primitives.typography?.families.primary).toEqual({ - family: 'Inter', - fallbacks: ['system-ui', 'sans-serif'], - source: 'local' - }) + // The patch overrides only `family`; fallbacks/source/faces deep-merge from the + // base (a real font swap would also override `faces` with its own files). + const mergedPrimary = cfg.primitives.typography?.families.primary + expect(mergedPrimary?.family).toBe('Inter') + expect(mergedPrimary?.fallbacks).toEqual(['system-ui', 'sans-serif']) + expect(mergedPrimary?.source).toBe('local') + expect(mergedPrimary?.faces?.length).toBe(3) + // Base constant not mutated. + expect(THEME_BASE_OPTIONS.primitives.typography?.families.primary.family).toBe( + 'Instrument Sans' + ) expect(cfg.semantics.color.roles.primary).toEqual({ scale: 'blue', slots: { @@ -425,6 +431,16 @@ describe('ActiveEidos config', () => { expect(typography.styles.body.family).toBe('primary') }) + it('generates @font-face blocks from the typography config, deduped by font', () => { + const css = createThemeBaseEidos().renderStaticCss() + expect(css).toContain('@font-face') + expect(css).toContain("font-family: 'Instrument Sans';") + expect(css).toContain("src: url('/fonts/air/Lora-Bold.ttf') format('truetype');") + expect(css).toContain('font-style: italic;') + // Lora is shared by `secondary` + `display` → its 8 faces emit once, not 16. + expect((css.match(/font-family: 'Lora';/g) ?? []).length).toBe(8) + }) + 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 bcf74a99f..b574c0035 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -7,6 +7,118 @@ * Do not edit this file by hand. */ +@font-face { + font-family: 'Instrument Sans'; + src: url('/fonts/air/InstrumentSans-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Instrument Sans'; + src: url('/fonts/air/InstrumentSans-Medium.ttf') format('truetype'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Instrument Sans'; + src: url('/fonts/air/InstrumentSans-SemiBold.ttf') format('truetype'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Lora'; + src: url('/fonts/air/Lora-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Lora'; + src: url('/fonts/air/Lora-Italic.ttf') format('truetype'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Lora'; + src: url('/fonts/air/Lora-Medium.ttf') format('truetype'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Lora'; + src: url('/fonts/air/Lora-MediumItalic.ttf') format('truetype'); + font-weight: 500; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Lora'; + src: url('/fonts/air/Lora-SemiBold.ttf') format('truetype'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Lora'; + src: url('/fonts/air/Lora-SemiBoldItalic.ttf') format('truetype'); + font-weight: 600; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Lora'; + src: url('/fonts/air/Lora-Bold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Lora'; + src: url('/fonts/air/Lora-BoldItalic.ttf') format('truetype'); + font-weight: 700; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Azeret Mono'; + src: url('/fonts/air/AzeretMono-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Azeret Mono'; + src: url('/fonts/air/AzeretMono-Medium.ttf') format('truetype'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Azeret Mono'; + src: url('/fonts/air/AzeretMono-SemiBold.ttf') format('truetype'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + :root { --space-0: 0px; --space-1: calc(4px * var(--density-space-scale) * var(--scaling)); diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index 8ba8f10a1..60a93bd2e 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -49,10 +49,10 @@ * ActiveEidos became the source for generated foundation/theme CSS. */ -/* ── Theme assets: font faces used by the base static theme tokens ───────── */ -@import './themes/fonts.css'; - -/* ── Generated foundation: ActiveEidos base config static export ────────── */ +/* ── Generated foundation: ActiveEidos base config static export ────────── + * Includes the @font-face blocks now (config-driven, Fase 2) — the theme's + * font families live in EidosConfig (`typography.families[].faces`), not in a + * hand-written CSS file. `themes/fonts.css` is superseded (no longer imported). */ @import './generated/base.css'; /* ── Eidos-specific layers (no equivalent in air) ───────────────────────── */ diff --git a/src/uix/eidos/lib/config-types.ts b/src/uix/eidos/lib/config-types.ts index 0b834a5bb..26d1fbbbf 100644 --- a/src/uix/eidos/lib/config-types.ts +++ b/src/uix/eidos/lib/config-types.ts @@ -568,11 +568,60 @@ export interface TextMetric { export type FontSource = 'system' | 'local' | 'remote'; +/** One `@font-face` source — a URL + optional `format()` hint. */ +export interface FontFaceSource { + readonly url: string; + readonly format?: string; +} + +/** + * A declarative `@font-face` for a family. The engine emits it (config-driven loading, + * next/font model). `weight` may be a single value ('400') or a variable range + * ('100 900'). (TYPOGRAPHY_ENGINE_RFC §5) + */ +export interface FontFace { + readonly sources: readonly FontFaceSource[]; + readonly weight?: string | number; + readonly style?: string; + readonly display?: string; + readonly unicodeRange?: string; +} + +/** + * Metric-overridden `local()` fallback face (anti-CLS, Fontaine / next/font style). + * Emitted as `'{family} Fallback'` and injected into the stack after the real family, + * so layout doesn't shift when the web font swaps in. Override values are theme data + * (precomputed from the font metrics). + */ +export interface FontFallback { + readonly local: string; + readonly sizeAdjust?: string; + readonly ascentOverride?: string; + readonly descentOverride?: string; + readonly lineGapOverride?: string; +} + +/** Variable-font axis ranges. */ +export interface FontAxes { + readonly wght?: readonly [number, number]; + readonly opsz?: readonly [number, number]; +} + export interface FontFamily { readonly family: string; readonly fallbacks?: readonly string[]; readonly source?: FontSource; readonly weights?: Partial>; + /** Config-driven `@font-face` entries; the engine emits them (Fase 2). */ + readonly faces?: readonly FontFace[]; + /** Variable-font axes (enables `font-optical-sizing` when `opsz` is present). */ + readonly axes?: FontAxes; + /** Metric-overridden fallback, injected into the stack as `'{family} Fallback'`. */ + readonly fallback?: FontFallback; + /** Default `font-display` for this family's faces. @default 'swap' */ + readonly display?: string; + /** Hint to preload this family's primary face (consumed by the app's ``). */ + readonly preload?: boolean; } export type TypographySizeValue = TextSize | TextMetric; diff --git a/src/uix/eidos/lib/primitives/typography.ts b/src/uix/eidos/lib/primitives/typography.ts index e4027c988..ed966935c 100644 --- a/src/uix/eidos/lib/primitives/typography.ts +++ b/src/uix/eidos/lib/primitives/typography.ts @@ -2,22 +2,48 @@ import type { TypographyPrimitiveSet } from '../config-types' export const STATIC_TYPOGRAPHY: TypographyPrimitiveSet = { families: { + // @font-face is config-driven (Fase 2): the engine emits these from `faces`. + // These are the BASE THEME's fonts — a theme owns its families. TTF today; + // a theme can swap to woff2/variable + a metric-override `fallback` by editing + // this config only (no separate fonts.css). The font files live in /fonts/air. primary: { family: 'Instrument Sans', fallbacks: ['system-ui', 'sans-serif'], - source: 'local' + source: 'local', + faces: [ + { sources: [{ url: '/fonts/air/InstrumentSans-Regular.ttf', format: 'truetype' }], weight: 400 }, + { sources: [{ url: '/fonts/air/InstrumentSans-Medium.ttf', format: 'truetype' }], weight: 500 }, + { sources: [{ url: '/fonts/air/InstrumentSans-SemiBold.ttf', format: 'truetype' }], weight: 600 } + ] }, secondary: { family: 'Lora', fallbacks: ['Georgia', 'Times New Roman', 'serif'], - source: 'local' + source: 'local', + faces: [ + { sources: [{ url: '/fonts/air/Lora-Regular.ttf', format: 'truetype' }], weight: 400 }, + { sources: [{ url: '/fonts/air/Lora-Italic.ttf', format: 'truetype' }], weight: 400, style: 'italic' }, + { sources: [{ url: '/fonts/air/Lora-Medium.ttf', format: 'truetype' }], weight: 500 }, + { sources: [{ url: '/fonts/air/Lora-MediumItalic.ttf', format: 'truetype' }], weight: 500, style: 'italic' }, + { sources: [{ url: '/fonts/air/Lora-SemiBold.ttf', format: 'truetype' }], weight: 600 }, + { sources: [{ url: '/fonts/air/Lora-SemiBoldItalic.ttf', format: 'truetype' }], weight: 600, style: 'italic' }, + { sources: [{ url: '/fonts/air/Lora-Bold.ttf', format: 'truetype' }], weight: 700 }, + { sources: [{ url: '/fonts/air/Lora-BoldItalic.ttf', format: 'truetype' }], weight: 700, style: 'italic' } + ] }, mono: { family: 'Azeret Mono', fallbacks: ['ui-monospace', 'monospace'], - source: 'local' + source: 'local', + faces: [ + { sources: [{ url: '/fonts/air/AzeretMono-Regular.ttf', format: 'truetype' }], weight: 400 }, + { sources: [{ url: '/fonts/air/AzeretMono-Medium.ttf', format: 'truetype' }], weight: 500 }, + { sources: [{ url: '/fonts/air/AzeretMono-SemiBold.ttf', format: 'truetype' }], weight: 600 } + ] }, display: { + // Shares the Lora font with `secondary`; faces are declared there and the + // generator dedupes by font name, so no @font-face is emitted twice. family: 'Lora', fallbacks: ['Georgia', 'Times New Roman', 'serif'], source: 'local' diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index bfb912ccf..39ffbfb77 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -23,6 +23,8 @@ import { type EidosCssVariableMap, type EidosCssVariableValue, type EidosConfig, + type FontFace, + type FontFallback, type FontFamily, type FocusColorRoles, type LayoutPrimitiveSet, @@ -206,7 +208,13 @@ export function renderStaticCss(options: EidosConfig): string { appendTransitionAliasDeclarations(declarations, options) const scopedRecipeBlocks = appendRecipeDeclarations(declarations, options.recipes) - const blocks = [renderBlock(':root', declarations), ...scopedRecipeBlocks] + const blocks: string[] = [] + // @font-face first (config-driven loading); the family-stack tokens reference them. + if (primitives.typography) { + const fontFaceCss = renderFontFaceBlocks(primitives.typography) + if (fontFaceCss) blocks.push(fontFaceCss) + } + blocks.push(renderBlock(':root', declarations), ...scopedRecipeBlocks) // Responsive typography style overrides — one media-query block per // breakpoint that has at least one responsive size override. @@ -1878,7 +1886,64 @@ function renderBlock(selector: string, declarations: string[]): string { } function formatFontFamily(family: FontFamily): string { - return [family.family, ...(family.fallbacks ?? [])].map(formatFontName).join(', ') + const names = [family.family] + // Inject the metric-override fallback right after the real family so it renders + // (matched to the webfont's metrics) until the webfont loads — zero CLS. + if (family.fallback) names.push(`${family.family} Fallback`) + names.push(...(family.fallbacks ?? [])) + return names.map(formatFontName).join(', ') +} + +/** + * Generate `@font-face` blocks from the theme's font families (config-driven loading, + * next/font model). Deduped by the real font name — a font shared across slots + * (e.g. Lora as both `secondary` and `display`) emits its faces once. Each family may + * also declare a metric-override `fallback` (anti-CLS). (TYPOGRAPHY_ENGINE_RFC §5) + */ +function renderFontFaceBlocks(typography: TypographyPrimitiveSet): string { + const seen = new Set() + const blocks: string[] = [] + for (const family of Object.values(typography.families)) { + if (!family.faces || family.faces.length === 0) continue + if (seen.has(family.family)) continue + seen.add(family.family) + for (const face of family.faces) { + blocks.push(renderFontFace(family.family, face, family.display)) + } + if (family.fallback) blocks.push(renderFallbackFontFace(family.family, family.fallback)) + } + return blocks.join('\n\n') +} + +function renderFontFace(family: string, face: FontFace, defaultDisplay: string | undefined): string { + const src = face.sources + .map((s) => `url('${s.url}')${s.format ? ` format('${s.format}')` : ''}`) + .join(', ') + const lines = [ + '@font-face {', + `\tfont-family: '${family}';`, + `\tsrc: ${src};`, + `\tfont-weight: ${face.weight ?? 400};`, + `\tfont-style: ${face.style ?? 'normal'};`, + `\tfont-display: ${face.display ?? defaultDisplay ?? 'swap'};` + ] + if (face.unicodeRange) lines.push(`\tunicode-range: ${face.unicodeRange};`) + lines.push('}') + return lines.join('\n') +} + +function renderFallbackFontFace(family: string, fallback: FontFallback): string { + const lines = [ + '@font-face {', + `\tfont-family: '${family} Fallback';`, + `\tsrc: local('${fallback.local}');` + ] + if (fallback.sizeAdjust) lines.push(`\tsize-adjust: ${fallback.sizeAdjust};`) + if (fallback.ascentOverride) lines.push(`\tascent-override: ${fallback.ascentOverride};`) + if (fallback.descentOverride) lines.push(`\tdescent-override: ${fallback.descentOverride};`) + if (fallback.lineGapOverride) lines.push(`\tline-gap-override: ${fallback.lineGapOverride};`) + lines.push('}') + return lines.join('\n') } function formatFontName(name: string): string {