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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

---
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.