You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/rfcs/rfc-typography.md

214 lines
11 KiB

---
title: RFC — Reference-grade typography engine (eidos)
type: rfc
audience: human + agent
status: implemented — phases 1-5 closed
source: migrated from src/uix/eidos/TYPOGRAPHY_ENGINE_RFC.md (2026-07-02, docs-book F7.4)
---
# RFC — Reference-grade typography engine (eidos)
> **Status: PROPOSAL (2026-06-05), since implemented — see §10.** Plan to raise
> eidos typography to par with or above the reference frameworks, replicating
> the color approach ([`rfc-color-engine.md`](./rfc-color-engine.md)): audit →
> compare → extend ADDITIVELY behind the frozen token contract, in phases.
## 1. Current state (audit)
`TypographyPrimitiveSet` (`lib/primitives/typography.ts` + `config-types.ts`) →
emission in `render-css.ts` → tokens → the `<Text>` / `<Heading>` / `<SText>`
components.
- **4 families** (primary Instrument Sans · secondary/display Lora · mono Azeret Mono).
- **8 sizes** `xxs 10px … xxxl 80px`, each with **fixed px**
`size` / `lineHeight` / `letterSpacing`.
- **4 weights** (400/500/600/700).
- **13 named styles** (hero, h1–h6, body, prose, label, caption, code), some
with per-breakpoint responsive size (`{ base, md }`).
- **`--scaling` axis** (global zoom 90–110%) multiplying the px.
- Tokens: `--font-family-*`, `--font-size-*` (`calc(px * --scaling)`),
`--font-line-height-*`, `--font-letter-spacing-*`, `--font-weight-*`,
`--style-{name}-*`.
- **Measurement arm**: the `canvas-text` engine (`<SText>`) counts exact lines
replicating the browser (productionized — see `lib/canvas-text/fix-stext.md`).
**Good today**: named semantic styles, responsive sizes, the scaling axis
(uncommon), token contract + theming, exact line measurement.
## 2. Comparison against the reference (the gap)
| Capability | Reference | Eidos today |
|---|---|---|
| **Fluid** typography (viewport `clamp()`) | Utopia, Carbon, Tailwind(opt) | ❌ fixed px + manual breakpoints |
| **Variable fonts** (1 file, wght + opsz) | Material 3, Apple | ❌ static TTF per weight |
| **WOFF2 + subsetting + metric fallback** (anti-CLS) | Next/Fontaine | ❌ TTF, no subset, no `size-adjust` |
| **Optical sizing** (`opsz`) | Apple, Material | ❌ |
| **Per-size tracking** (optical) | Material, Apple, Radix | ❌ all `0` |
| **`text-wrap: balance`/`pretty`** | Tailwind, Chakra | ❌ |
| **Measure** (line width ~65ch) | classic, Tailwind prose | ❌ |
| **`font-feature-settings`** (tabular-nums…) | Tailwind, Radix | ❌ |
| Named styles · scaling axis | Chakra/Material · Apple(partial) | ✅ |
## 3. Inclusion model: extend, don't replace
**Guiding principle (identical to color)**: everything new is **ADDITIVE** over
`TypographyPrimitiveSet`, behind the **frozen token contract**. The old (fixed
px) keeps working; the new activates via config. Token names (`--font-size-X`,
`--style-Y-*`) **do not change** — only the *value formula* changes (just as in
color the `--scale-*` token went from hex to `oklch()` without renaming).
Implementation layers, parallel to color:
| Piece | Where | Color analog |
|---|---|---|
| Pure math (Utopia fluid scale) | `eidos/lib/type-scale.ts` (pure) | `arts/color` / `build-scheme.ts` |
| Type + config | `config-types.ts` (`TypographyPrimitiveSet`) | `EidosConfig` |
| CSS emission | `render-css.ts` (`appendTypographyDeclarations`) | `appendColorScaleDeclarations` |
| Fonts | `themes/fonts.css` (woff2/variable) | `themes/base` (palette) |
| Components | `<Text>` / `<Heading>` (new props) | `<Button>` etc. |
| Optional runtime | `eidos.applyTypeScale(seed)` | `eidos.applyColorScheme(seed)` |
## 4. Fluid typography (Phase 1, the heart)
**Formula (Utopia-style)** — a size becomes a `clamp(min, fluid, max)` in
**rem** (a11y: respects browser zoom), where the fluid stretch interpolates
between two viewports:
```
slope = (maxRem − minRem) / (maxVw − minVw)
interceptRem = minRem − slope · minVw
size = clamp(minRem, interceptRem + slope·100vw, maxRem)
```
- **rem, not px**: better accessibility (OS/browser font zoom scales). The
`--font-size-X` token keeps its name; its value goes from `calc(px*scaling)`
to `calc(clamp(...rem...) * var(--scaling))` — **transparent to consumers**.
- **The `--scaling` axis** (the system's discrete zoom) composes **on top** of
the clamp via `calc(clamp(...) * var(--scaling))`. Two orthogonal axes: fluid
(continuous, per viewport) + scaling (discrete, preference).
- **Simplifies the named styles**: hero/h1/h2 no longer need manual responsive
size `{ base, md }` — the `clamp` covers the viewport continuously. The
per-breakpoint `@media` emitter for size can be retired (less CSS).
- **Type**: `TextMetric.size` accepts `string` today; extends to
`string | FluidSize` where
`FluidSize = { min: string; max: string; minVw?: string; maxVw?: string }`.
`string` (fixed px) stays valid → backward-compatible.
## 5. Modern fonts (Phase 2, the biggest perf+quality win)
- **WOFF2 + variable**: migrate `fonts.css` from static per-weight TTFs to
**variable woff2** (Instrument Sans / Lora / Azeret Mono all have variable
versions with a `wght` axis; Lora also `ital`). One file per family instead
of 3–10.
- **Anti-CLS**: per family, a **metric-overridden fallback** `@font-face`
(`size-adjust`, `ascent-override`, `descent-override`, `line-gap-override`)
matching the webfont's geometry to the system fallback → zero layout jump on
load (the Fontaine / `next/font` approach). Metrics precomputed per font.
- **`unicode-range`** subsetting (latin / latin-ext split) → load only what's used.
- **`preload`** of the critical family (primary regular).
- **Type**: `FontFamily` += `axes?: { wght?: [min,max]; opsz?: [min,max] }` +
`sizeAdjust?` / metric overrides + `unicodeRanges?`. `source` already exists. ✅
- **Optical sizing** ✅: `font-optical-sizing: auto` at `:root` when any family
declares `opsz`.
- **Variable weight axis** ✅: when a `face` omits `weight` and the family
declares `axes.wght`, the `@font-face` emits the range (`font-weight: 100 900`)
→ a single face covers the whole axis. (`render-css > renderFontFace`; test in
`active-eidos-config.test.ts`.)
> **§5 status**: the **engine** is wired — config-driven `@font-face`, anti-CLS
> fallback, `unicode-range`, and now `axes` consumption (weight range + optical
> sizing). Inert until a theme declares `axes` — like color's wide-gamut: ready,
> but not exercised by the current assets (static TTFs). **Pending on assets**:
> migrate to real variable woff2; `preload` (separate closure).
## 6. Tracking · leading · features · measure · wrap (Phase 3)
- **Tracking/leading as scales** (Tailwind-style): `TypographyPrimitiveSet` +=
`tracking` + `leading` → `--tracking-{key}` / `--leading-{key}`. Tracking
**optical by default** (negative on display, ~0 on body, positive on
xs/caption).
- **`font-feature-settings`**: `features?: Record<string,string>` →
`--font-feature-{key}` (presets `tabular`, `oldstyle`, `ligatures`,
`smallcaps`). A `numeric` prop on components.
- **Measure**: `--measure-{key}` token (`60ch`/`66ch`/`72ch`) for readable prose.
- **`text-wrap`**: `wrap: 'balance' | 'pretty' | 'nowrap'` props on
`<Text>`/`<Heading>` (a CSS keyword, no token); default `balance` on
headings, `pretty` on prose.
## 7. Link with `<SText>` (the measurement arm)
The `canvas-text` engine counts exact lines replicating the browser (already
productionized). It is the **other half** of the system: *visual* typography
(this RFC) and *measurement* (SText). When fluid/tracking are emitted, `<SText>`
measures them the same way (it reads the real `getComputedStyle`), so the count
stays faithful without changes — the `useFontReady` epoch (T1) already covers
the recount after variable fonts load.
## 8. Doctrine (parallel to color)
- **Scale + named styles = eidos canon** (like color's roles/variants):
themeable values, but the style *set* is canon.
- **Fonts = theme data** (like the palette): the theme brings its own.
- **Fluid / tracking / features = engine capabilities**, default-on with
fallback (like OKLCH wide-gamut): sRGB-identical where unsupported.
- **Theme = retint/retype the perceptually fixed**: change WHICH font is
`primary`, not WHAT `h1` means.
## 9. Token contract (additions)
Frozen, bare-prefixed (theming reference §6). New:
`--tracking-{key}`, `--leading-{key}`, `--font-feature-{key}`, `--measure-{key}`.
Reformulated (same name, fluid value): `--font-size-{key}`.
No name changes → zero component breakage.
## 10. Phases — status (closed)
1. ✅ **Fluid engine** — `type-scale.ts` (pure `fluidClamp`) + `FluidSize` +
rem `clamp()` in `appendTypographyDeclarations` (scaling composes); the size
`@media` emitter retired.
2. ✅ **Fonts** — config-driven `@font-face` + anti-CLS fallback (metrics) +
`unicode-range` + `FontFamily.axes` (variable weight range +
`font-optical-sizing`) + **font preloads** (`collectFontPreloads` /
`eidos.fontPreloads()`). Assets still static TTFs → variability is an engine
capability, not visible yet (like wide-gamut).
3. ✅ **Tracking/leading/features/measure/wrap** — config-driven scales +
component props (`<Text>`/`<Heading>`) + **semantic tokens**
(`--leading-{role}` / `--tracking-{role}`) now config-driven
(`typography.semanticLeading` / `semanticTracking`).
4. ✅ **Scale — a two-zone design (intentional)**, see below. Values were NOT
changed (that would be a perceptual regression): the "irregularity" is
deliberate.
5. ✅ **Docs + demo** — this RFC + the `/temas/tipografia` demo
(scale/families/axes live) + runtime **`eidos.applyTypeScale(seed)`**
(`buildTypeScale`, sibling of `applyColorScheme`).
### Phase 4 — the scale is a two-zone design (intentional)
The scale (xxs 10 · xs 12 · sm 14 · md 16 · lg 18→20 · xl 24→28 · xxl 40→48 ·
xxxl 64→80) is NOT a single modular ratio, and that is deliberate:
- **UI zone** (xxs–md): fine steps (~1.14–1.2) for controls, labels and body,
where granularity matters and large jumps hurt.
- **Display zone** (lg–xxxl): large jumps (~1.25–1.7) for hierarchy and
headlines, where contrast rules.
A single uniform ratio (what `applyTypeScale` produces) is the **opt-in**
runtime alternative; the authored scale keeps its two-zone tuning (like Radix /
Material, which also tune instead of imposing a pure ratio). Changing it would
be a regression, not an improvement — which is why Phase 4 is documentation,
not rewiring.
## Verification per phase
- `npm run check` 0 errors; `render-css` snapshot regenerated
(`generated/base.css`).
- Backward-compat: a config with px (string) sizes keeps emitting the same.
- Browser: fluid scales with the viewport; `<SText>` counts correct lines; no
CLS when the variable fonts load.
## Out of scope
- Own justification / hyphenation / line balancing (the browser + `text-wrap`
do it; `<SText>` only measures).
- Runtime dynamic subsetting (static `unicode-range` is enough).

Powered by TurnKey Linux.