From 4fe2ee27df02ad3ed6414354799b48a8b85cd4a3 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 5 Jun 2026 05:56:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(eidos):=20close=20typography=20hygiene=20?= =?UTF-8?q?=E2=80=94=20semantic=20tokens=20config-driven=20+=20font=20prel?= =?UTF-8?q?oads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 `` descriptors for families flagged `preload: true` (the engine emits CSS, not head markup), for the app ``. 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) --- src/uix/eidos/THEMING.md | 14 ++++++ src/uix/eidos/TYPOGRAPHY_ENGINE_RFC.md | 42 +++++++++++++----- src/uix/eidos/active-eidos.svelte.ts | 9 ++++ src/uix/eidos/index.ts | 2 + src/uix/eidos/lib/config-types.ts | 11 +++++ src/uix/eidos/lib/config.ts | 11 ++++- src/uix/eidos/lib/font-preload.test.ts | 34 +++++++++++++++ src/uix/eidos/lib/font-preload.ts | 51 ++++++++++++++++++++++ src/uix/eidos/lib/primitives/typography.ts | 19 ++++++++ src/uix/eidos/lib/render-css.ts | 34 +++++---------- 10 files changed, 192 insertions(+), 35 deletions(-) create mode 100644 src/uix/eidos/lib/font-preload.test.ts create mode 100644 src/uix/eidos/lib/font-preload.ts diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md index cc6b72d3d..05613e6f1 100644 --- a/src/uix/eidos/THEMING.md +++ b/src/uix/eidos/THEMING.md @@ -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` diff --git a/src/uix/eidos/TYPOGRAPHY_ENGINE_RFC.md b/src/uix/eidos/TYPOGRAPHY_ENGINE_RFC.md index 15e63d4e0..f7917b602 100644 --- a/src/uix/eidos/TYPOGRAPHY_ENGINE_RFC.md +++ b/src/uix/eidos/TYPOGRAPHY_ENGINE_RFC.md @@ -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 (``/``) + **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 diff --git a/src/uix/eidos/active-eidos.svelte.ts b/src/uix/eidos/active-eidos.svelte.ts index e37e30ff9..380ae1b1e 100644 --- a/src/uix/eidos/active-eidos.svelte.ts +++ b/src/uix/eidos/active-eidos.svelte.ts @@ -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); } + /** + * `` descriptors for the font families flagged `preload: true`. + * Render them in the app's `` (the engine emits CSS, not head markup). + */ + fontPreloads(): FontPreload[] { + return collectFontPreloads(this.#config.primitives.typography); + } + getCssContract(): EidosCssContract { return getEidosCssContract(this.#config); } diff --git a/src/uix/eidos/index.ts b/src/uix/eidos/index.ts index a4f7c88d8..a12524037 100644 --- a/src/uix/eidos/index.ts +++ b/src/uix/eidos/index.ts @@ -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, diff --git a/src/uix/eidos/lib/config-types.ts b/src/uix/eidos/lib/config-types.ts index 087a24973..15a9aeb59 100644 --- a/src/uix/eidos/lib/config-types.ts +++ b/src/uix/eidos/lib/config-types.ts @@ -648,6 +648,17 @@ export interface TypographyPrimitiveSet { readonly features?: Record; /** Line-length / measure scale → `--measure-{key}` (narrow…wide, in `ch`). */ readonly measure?: Record; + /** + * 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; + /** + * 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; } export interface PrimitiveSet { diff --git a/src/uix/eidos/lib/config.ts b/src/uix/eidos/lib/config.ts index 9ca438606..6aaf90567 100644 --- a/src/uix/eidos/lib/config.ts +++ b/src/uix/eidos/lib/config.ts @@ -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)) { diff --git a/src/uix/eidos/lib/font-preload.test.ts b/src/uix/eidos/lib/font-preload.test.ts new file mode 100644 index 000000000..68e2879b0 --- /dev/null +++ b/src/uix/eidos/lib/font-preload.test.ts @@ -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): 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([]); + }); +}); diff --git a/src/uix/eidos/lib/font-preload.ts b/src/uix/eidos/lib/font-preload.ts new file mode 100644 index 000000000..6f485678b --- /dev/null +++ b/src/uix/eidos/lib/font-preload.ts @@ -0,0 +1,51 @@ +/** + * Collect `` descriptors for the font families flagged `preload: true` + * (the critical-path face — typically the primary regular). The eidos engine emits CSS, + * not `` markup, so it surfaces this list for the app to render in its + * ``: + * + * ```svelte + * {#each eidos.fontPreloads() as p} + * + * {/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 `` (e.g. `font/woff2`), when derivable from the face format. */ + readonly type?: string; +} + +const FORMAT_MIME: Record = { + 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(); + 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; +} diff --git a/src/uix/eidos/lib/primitives/typography.ts b/src/uix/eidos/lib/primitives/typography.ts index cb18d9aea..220abb840 100644 --- a/src/uix/eidos/lib/primitives/typography.ts +++ b/src/uix/eidos/lib/primitives/typography.ts @@ -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' } } diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index 7165c5aea..48d6451eb 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -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 {