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

13 KiB

title type audience status source
RFC — Reference-grade typography engine (eidos) rfc human + agent implemented — phases 1-5 closed 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): 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.

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