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

255 lines
13 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?` (variable axis ranges, keyed by 4-letter tag)
+ `fallback?` metric overrides + `unicodeRange?` per face + `preload?`. `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`.)
- **Any variable axis** ✅ (2026-07-12): `FontAxes` is a 4-letter-tag map; the
engine emits an `@font-face` descriptor for each registered tag — `wdth` →
`font-stretch: min% max%`, `slnt` → `font-style: oblique …` (OpenType `slnt`
negated to the CSS `oblique` sign), alongside `wght`/`opsz`. NON-standard axes
(`GRAD`, `MONO`, custom …) have no descriptor — engage them (and fixed axis
defaults) via `TypographyStyle.variationSettings` → the emitted
`--style-{name}-font-variation-settings` token, applied by the Text recipe
(fallback `normal`, so it is a no-op until a style sets it). Tests in
`active-eidos-config.test.ts`.
> **§5 status** (2026-07-12): **SHIPPED**. The default theme's families
> (Instrument Sans / Lora / Azeret Mono — identity unchanged) now ship as
> **variable woff2** (self-hosted, SIL OFL, latin subset, `wght` range, `preload`
> on primary) — the engine's variable path is exercised by the default theme, not
> merely capable. The old hand-written `themes/fonts.css` is retired; `@font-face`
> is config-driven from `typography.families[].faces`. The API now supports **any**
> variable font: the four registered axes (`wght`/`wdth`/`slnt`/`opsz`) + arbitrary
> axes via `variationSettings`. **Still pending (optional)**: engine-side
> auto-computation of the anti-CLS metric overrides (today `fallback` values are
> precomputed / manual — the one remaining ergonomics gap).
## 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 leading tokens**
(`--leading-{role}`, config-driven via `typography.semanticLeading`).
(A sibling `semanticTracking` axis existed with every value at `'0'` and
was removed as a speculative abstraction — per-role tracking can return
when a real design case exists. This line previously claimed it as
delivered; corrected 2026-07-06.)
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 32→48 ·
xxxl 40→80 — *xxl/xxxl values corrected 2026-07-07 against
`lib/primitives/typography.ts`; this doc had 40→48 / 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.
### Named styles inherit the scale's optics (2026-07-06)
The per-size optical tracking (Phase 3) is authored in the SCALE
(`typography.sizes[].letterSpacing`: positive on xxs/xs, ~0 on body, negative
on xl→xxxl). The named styles used to re-declare `letterSpacing: '0'` across
the board, silently nullifying the optics for every heading/text primitive —
an unrecorded contradiction of this RFC. Standing rule (user decision,
2026-07-06):
> **A named style declares `lineHeight` / `letterSpacing` ONLY when it
> deliberately diverges from its size's metric, annotated inline. A matching
> value is OMITTED — the emission falls back to the size's token, keeping
> the scale the single optical source.**
Applied: all 12 `letterSpacing: '0'` removed (hero/h1/h2 tighten,
h6/caption loosen); redundant `lineHeight`s removed (hero, h1, code); the 9
deliberate leading divergences stay, annotated. Guarded:
`active-eidos-config.test.ts` fails on a style re-declaring a metric
identical to its scale's. The size-bundle coordinate
(`--size-{k}-font-letter-spacing`) remains unconsumed by control recipes —
that adoption belongs to the upcoming component audit, not this RFC.
## 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.