feat(eidos): config-driven @font-face (Phase 2) — fonts as theme data (next/font model)

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), <link rel=preload> (head markup).

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

@ -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!

@ -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));

@ -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) ───────────────────────── */

@ -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<Record<string, string | number>>;
/** 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 `<head>`). */
readonly preload?: boolean;
}
export type TypographySizeValue = TextSize | TextMetric;

@ -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'

@ -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<string>()
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 {

Loading…
Cancel
Save

Powered by TurnKey Linux.