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 pxsize/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 }). --scalingaxis (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-textengine (<SText>) counts exact lines replicating the browser (productionized — seelib/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-Xtoken keeps its name; its value goes fromcalc(px*scaling)tocalc(clamp(...rem...) * var(--scaling))— transparent to consumers. - The
--scalingaxis (the system's discrete zoom) composes on top of the clamp viacalc(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 }— theclampcovers the viewport continuously. The per-breakpoint@mediaemitter for size can be retired (less CSS). - Type:
TextMetric.sizeacceptsstringtoday; extends tostring | FluidSizewhereFluidSize = { 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.cssfrom static per-weight TTFs to variable woff2 (Instrument Sans / Lora / Azeret Mono all have variable versions with awghtaxis; Lora alsoital). 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/fontapproach). Metrics precomputed per font. unicode-rangesubsetting (latin / latin-ext split) → load only what's used.preloadof the critical family (primary regular).- Type:
FontFamily+=axes?(variable axis ranges, keyed by 4-letter tag)fallback?metric overrides +unicodeRange?per face +preload?.sourcealready exists. ✅
- Optical sizing ✅:
font-optical-sizing: autoat:rootwhen any family declaresopsz. - Variable weight axis ✅: when a
faceomitsweightand the family declaresaxes.wght, the@font-faceemits the range (font-weight: 100 900) → a single face covers the whole axis. (render-css > renderFontFace.) - Any variable axis ✅ (2026-07-12):
FontAxesis a 4-letter-tag map; the engine emits an@font-facedescriptor for each registered tag —wdth→font-stretch: min% max%,slnt→font-style: oblique …(OpenTypeslntnegated to the CSSobliquesign), alongsidewght/opsz. NON-standard axes (GRAD,MONO, custom …) have no descriptor — engage them (and fixed axis defaults) viaTypographyStyle.variationSettings→ the emitted--style-{name}-font-variation-settingstoken, applied by the Text recipe (fallbacknormal, so it is a no-op until a style sets it). Tests inactive-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,
wghtrange,preloadon primary) — the engine's variable path is exercised by the default theme, not merely capable. The old hand-writtenthemes/fonts.cssis retired;@font-faceis config-driven fromtypography.families[].faces. The API now supports any variable font: the four registered axes (wght/wdth/slnt/opsz) + arbitrary axes viavariationSettings. Still pending (optional): engine-side auto-computation of the anti-CLS metric overrides (todayfallbackvalues 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}(presetstabular,oldstyle,ligatures,smallcaps). Anumericprop 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); defaultbalanceon headings,prettyon 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 WHATh1means.
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)
- ✅ Fluid engine —
type-scale.ts(purefluidClamp) +FluidSize+ remclamp()inappendTypographyDeclarations(scaling composes); the size@mediaemitter retired. - ✅ 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). - ✅ Tracking/leading/features/measure/wrap — config-driven scales +
component props (
<Text>/<Heading>) + semantic leading tokens (--leading-{role}, config-driven viatypography.semanticLeading). (A siblingsemanticTrackingaxis 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.) - ✅ Scale — a two-zone design (intentional), see below. Values were NOT changed (that would be a perceptual regression): the "irregularity" is deliberate.
- ✅ Docs + demo — this RFC + the
/temas/tipografiademo (scale/families/axes live) + runtimeeidos.applyTypeScale(seed)(buildTypeScale, sibling ofapplyColorScheme).
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/letterSpacingONLY 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 check0 errors;render-csssnapshot 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-wrapdo it;<SText>only measures). - Runtime dynamic subsetting (static
unicode-rangeis enough).