feat(eidos): close typography hygiene — semantic tokens config-driven + font preloads

#2 Semantic leading/tracking config-driven: the per-role `--leading-{role}`
(ui/prose/text/heading/display) + `--tracking-{role}` (badge/label/ui/prose/
heading/display) tokens were hardcoded in render-css; moved to
`typography.semanticLeading` / `semanticTracking` (STATIC_TYPOGRAPHY), emitted
config-driven + validated, so a theme can retune them. Byte-identical output
(generated/base.css unchanged — same values, same order).

#3 Font preloads: `collectFontPreloads(typography)` (pure) + `eidos.fontPreloads()`
surface `<link rel=preload>` descriptors for families flagged `preload: true`
(the engine emits CSS, not head markup), for the app `<svelte:head>`. Inert
until a theme opts in.

Docs: TYPOGRAPHY_ENGINE_RFC fases marked closed + Fase 4 (the two-zone scale is
intentional — documented, not rewired); THEMING §10 — applyColorScheme +
applyTypeScale system builders.

`npm run check` 0 errors; new font-preload tests + config/generated tests pass.

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

@ -1142,6 +1142,20 @@ activeEidos.setCssVariables(
);
```
### Builders de sistema completo
Por encima de `setCssVariables` hay dos builders que derivan un sistema entero desde una
semilla y lo escriben como bloque gestionado (siguen el tema activo light/dark):
- **`eidos.applyColorScheme(seed, opts)`** — deriva las 31 escalas + 9 roles desde un color
de marca (`buildScheme`). `clearColorScheme()` revierte.
- **`eidos.applyTypeScale(seed, opts)`** — deriva los 8 `--font-size-*` desde un ratio
modular + base (`buildTypeScale`), opcionalmente fluido (`ratioMax`). `clearTypeScale()`
revierte.
Ambos son puros en `eidos/lib` (`build-scheme` / `build-type-scale`) + un método de
aplicación en `ActiveEidos`. Demos en vivo: `/temas/color` y `/temas/tipografia`.
---
## 11. Bundle strategy + `eidos:purge`

@ -145,17 +145,37 @@ Congelados, bare-prefixed (§6 THEMING). Nuevos:
Reformulados (mismo nombre, valor fluido): `--font-size-{key}`.
Sin cambios de nombre → cero rotura de componentes.
## 10. Fases
1. **Motor fluido** — `type-scale.ts` (puro) + `FluidSize` en el tipo + `clamp()` en
`appendTypographyDeclarations` (rem, scaling compone); retirar el `@media` de size
en estilos nombrados. Tests del generador. Demo.
2. **Fuentes** — woff2 variable + fallback anti-CLS + `unicode-range` + preload;
`FontFamily.axes`.
3. **Tracking/leading/features/measure/wrap** — escalas + props de componente.
4. **Escala canónica** — racionalizar ratios (documentado) sin romper estilos.
5. **Docs + demo** — THEMING §typography + demo `/temas/tipografia` (estilo
`/temas/color`) + `applyTypeScale` runtime opcional.
## 10. Fases — estado (cerrado)
1. ✅ **Motor fluido** — `type-scale.ts` (puro `fluidClamp`) + `FluidSize` + `clamp()` rem
en `appendTypographyDeclarations` (scaling compone); `@media` de size retirado.
2. ✅ **Fuentes** — `@font-face` config-driven + fallback anti-CLS (métricas) +
`unicode-range` + `FontFamily.axes` (rango de peso variable + `font-optical-sizing`) +
**font preloads** (`collectFontPreloads` / `eidos.fontPreloads()`). Assets aún TTF
estáticas → la variabilidad es capacidad de motor, no visible todavía (como wide-gamut).
3. ✅ **Tracking/leading/features/measure/wrap** — escalas config-driven + props de
componente (`<Text>`/`<Heading>`) + **tokens semánticos** (`--leading-{role}` /
`--tracking-{role}`) ahora config-driven (`typography.semanticLeading` /
`semanticTracking`).
4. ✅ **Escala — diseño de dos zonas (intencional)**, ver abajo. NO se cambiaron los
valores (sería regresión perceptual): la "irregularidad" es deliberada.
5. ✅ **Docs + demo** — este RFC + demo `/temas/tipografia` (escala/familias/ejes en vivo)
+ **`eidos.applyTypeScale(seed)`** runtime (`buildTypeScale`, hermano de `applyColorScheme`).
### Fase 4 — la escala es un diseño de dos zonas (intencional)
La escala (xxs 10 · xs 12 · sm 14 · md 16 · lg 18→20 · xl 24→28 · xxl 40→48 · xxxl 64→80)
NO es un único ratio modular, y eso es deliberado:
- **Zona UI** (xxs–md): pasos finos (~1.14–1.2) para controles, etiquetas y cuerpo, donde
la granularidad importa y los saltos grandes molestan.
- **Zona display** (lg–xxxl): saltos grandes (~1.25–1.7) para jerarquía y titulares, donde
el contraste manda.
Un ratio único uniforme (lo que produce `applyTypeScale`) es la alternativa **opt-in** en
runtime; la escala autorada conserva su afinado de dos zonas (como Radix / Material, que
también afinan en vez de imponer un ratio puro). Cambiarla sería una regresión, no una
mejora — por eso Fase 4 es documentación, no recableado.
## Verificación por fase

@ -53,6 +53,7 @@ import {
type TypeScaleSeed,
type BuildTypeScaleResult
} from './lib/build-type-scale';
import { collectFontPreloads, type FontPreload } from './lib/font-preload';
import type { Oklch } from '$color';
import type { EngineMotion } from '$motion';
import type { EidosConfigPatch } from './lib/options';
@ -354,6 +355,14 @@ export class ActiveEidos {
return renderEidosStaticCss(this.#config);
}
/**
* `<link rel="preload">` descriptors for the font families flagged `preload: true`.
* Render them in the app's `<svelte:head>` (the engine emits CSS, not head markup).
*/
fontPreloads(): FontPreload[] {
return collectFontPreloads(this.#config.primitives.typography);
}
getCssContract(): EidosCssContract {
return getEidosCssContract(this.#config);
}

@ -26,6 +26,8 @@ export type {
TypeScaleSize,
BuildTypeScaleResult
} from './lib/build-type-scale';
export { collectFontPreloads } from './lib/font-preload';
export type { FontPreload } from './lib/font-preload';
export {
createEidosConfigDocumentFromConfig,
getEidosColorRoleScale,

@ -648,6 +648,17 @@ export interface TypographyPrimitiveSet {
readonly features?: Record<string, string>;
/** Line-length / measure scale → `--measure-{key}` (narrow…wide, in `ch`). */
readonly measure?: Record<string, string>;
/**
* Per-role semantic line-height → `--leading-{role}` (ui / prose / text / heading /
* display). Recipes lean on these for short UI text vs prose vs headings; config-driven
* so a theme can retune them. Keys are distinct from the `leading` scale above.
*/
readonly semanticLeading?: Record<string, string>;
/**
* Per-role semantic letter-spacing → `--tracking-{role}` (badge / label / ui / prose /
* heading / display). Distinct keys from the `tracking` scale; `0` by default.
*/
readonly semanticTracking?: Record<string, string>;
}
export interface PrimitiveSet {

@ -822,8 +822,15 @@ 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) {
// Phase 3 scales + per-role semantic leading/tracking (optional CSS-value maps).
for (const scale of [
'tracking',
'leading',
'features',
'measure',
'semanticLeading',
'semanticTracking'
] as const) {
const record = typography[scale]
if (record === undefined) continue
if (!isPlainRecord(record)) {

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { collectFontPreloads } from './font-preload';
import type { TypographyPrimitiveSet } from './config-types';
function typography(families: Record<string, unknown>): TypographyPrimitiveSet {
return { families, sizes: {}, weights: {}, styles: {} } as unknown as TypographyPrimitiveSet;
}
describe('collectFontPreloads', () => {
it('returns descriptors only for preload-flagged families, deduped by url', () => {
const t = typography({
primary: { family: 'A', preload: true, faces: [{ sources: [{ url: '/a.woff2', format: 'woff2' }] }] },
secondary: { family: 'B', faces: [{ sources: [{ url: '/b.woff2', format: 'woff2' }] }] },
display: { family: 'C', preload: true, faces: [{ sources: [{ url: '/a.woff2', format: 'woff2' }] }] }
});
expect(collectFontPreloads(t)).toEqual([{ href: '/a.woff2', type: 'font/woff2' }]);
});
it('maps the face format to a MIME type (or omits it when unknown)', () => {
const t = typography({
primary: { family: 'A', preload: true, faces: [{ sources: [{ url: '/a.ttf', format: 'truetype' }] }] },
mono: { family: 'M', preload: true, faces: [{ sources: [{ url: '/m.bin' }] }] }
});
expect(collectFontPreloads(t)).toEqual([
{ href: '/a.ttf', type: 'font/ttf' },
{ href: '/m.bin', type: undefined }
]);
});
it('returns [] for undefined typography or no preload flags', () => {
expect(collectFontPreloads(undefined)).toEqual([]);
expect(collectFontPreloads(typography({ primary: { family: 'A', faces: [] } }))).toEqual([]);
});
});

@ -0,0 +1,51 @@
/**
* Collect `<link rel="preload">` descriptors for the font families flagged `preload: true`
* (the critical-path face — typically the primary regular). The eidos engine emits CSS,
* not `<head>` markup, so it surfaces this list for the app to render in its
* `<svelte:head>`:
*
* ```svelte
* {#each eidos.fontPreloads() as p}
* <link rel="preload" as="font" type={p.type} href={p.href} crossorigin />
* {/each}
* ```
*
* Pure + DOM-free. (TYPOGRAPHY_ENGINE_RFC §5)
*/
import type { TypographyPrimitiveSet } from './config-types';
export interface FontPreload {
/** The font file URL (the family's primary face). */
readonly href: string;
/** MIME type for `<link type>` (e.g. `font/woff2`), when derivable from the face format. */
readonly type?: string;
}
const FORMAT_MIME: Record<string, string> = {
woff2: 'font/woff2',
'woff2-variations': 'font/woff2',
woff: 'font/woff',
truetype: 'font/ttf',
opentype: 'font/otf'
};
/**
* Descriptors for every family flagged `preload: true`, deduped by URL. Picks each
* family's first declared face / source (its primary). Returns `[]` when no family opts in.
*/
export function collectFontPreloads(typography: TypographyPrimitiveSet | undefined): FontPreload[] {
if (!typography) return [];
const preloads: FontPreload[] = [];
const seen = new Set<string>();
for (const family of Object.values(typography.families)) {
if (!family.preload) continue;
const source = family.faces?.[0]?.sources?.[0];
if (!source || seen.has(source.url)) continue;
seen.add(source.url);
preloads.push({
href: source.url,
type: source.format ? FORMAT_MIME[source.format] : undefined
});
}
return preloads;
}

@ -202,5 +202,24 @@ export const STATIC_TYPOGRAPHY: TypographyPrimitiveSet = {
narrow: '54ch',
normal: '66ch',
wide: '78ch'
},
// Per-role semantic leading/tracking → --leading-{role} / --tracking-{role} (distinct
// keys from the scales above). Recipes consume these; config-driven so a theme can
// retune them. `ui` anchors to the label named style (propagates via
// --style-label-line-height); `text` chains off `prose`.
semanticLeading: {
ui: 'var(--style-label-line-height, 1.25)',
prose: '1.6',
text: 'var(--leading-prose)',
heading: '1.2',
display: '1.05'
},
semanticTracking: {
badge: '0',
label: '0',
ui: '0',
prose: '0',
heading: '0',
display: '0'
}
}

@ -1614,28 +1614,18 @@ function appendTypographyAliases(
declarations.push(cssVar('font-weight-normal', 'var(--font-weight-regular)'))
}
// `--leading-ui` is the canonical leading every recipe token uses
// for short UI text (Field labels, captions, controls). Anchor it
// to the `label` named style so a designer changing
// `STATIC_TYPOGRAPHY.styles.label.lineHeight` propagates through
// `--style-label-line-height` → `--leading-ui` → every recipe.
// Fall back to the literal `1.25` when the named style isn't
// emitted (so unusual foundation overrides still produce valid CSS).
declarations.push(cssVar('leading-ui', 'var(--style-label-line-height, 1.25)'))
declarations.push(cssVar('leading-prose', '1.6'))
declarations.push(cssVar('leading-text', 'var(--leading-prose)'))
declarations.push(cssVar('leading-heading', '1.2'))
declarations.push(cssVar('leading-display', '1.05'))
declarations.push(cssVar('tracking-badge', '0'))
declarations.push(cssVar('tracking-label', '0'))
declarations.push(cssVar('tracking-ui', '0'))
declarations.push(cssVar('tracking-prose', '0'))
declarations.push(cssVar('tracking-heading', '0'))
declarations.push(cssVar('tracking-display', '0'))
// The tracking SCALE (tighter/tight/normal/wide/wider) is config-driven now —
// emitted from `typography.tracking` in appendTypographyDeclarations (Fase 3) with
// real optical values. The semantic tracking tokens above (badge/ui/…) stay 0
// until a theme sets them; recipes that want the scale use --tracking-tight etc.
// Per-role semantic leading/tracking (config-driven) → --leading-{role} /
// --tracking-{role}. Distinct keys from the SCALES (tighter/tight/… · none/tight/…)
// emitted in appendTypographyDeclarations. `--leading-ui` anchors to the label named
// style through its config value (var(--style-label-line-height, …)), so the canonical
// UI leading still follows the foundation. A theme retunes any of these via
// `typography.semanticLeading` / `semanticTracking`.
for (const [name, value] of Object.entries(typography.semanticLeading ?? {})) {
declarations.push(cssVar(`leading-${name}`, value))
}
for (const [name, value] of Object.entries(typography.semanticTracking ?? {})) {
declarations.push(cssVar(`tracking-${name}`, value))
}
}
function hasFocusColor(options: EidosConfig): boolean {

Loading…
Cancel
Save

Powered by TurnKey Linux.