docs(book): F7.4 (3/4) — typography + color-model RFCs translated to docs/rfcs/

TYPOGRAPHY_ENGINE_RFC (191 L) -> rfcs/rfc-typography.md and COLOR_MODEL_RFC
(155 L) -> rfcs/rfc-color-model.md, Spanish to English. color-model is a
RESOLVED RFC kept as the historical record of the rejected single-anchor
proposal — the resolution header travels with it, canonical model stays
theming reference s25. typography's stub notes the measurement arm
(lib/canvas-text, SText) stays in src. decisions.md rows repointed;
typography's Status aligned with the RFC's own closed-phases state.
Theming reference s21/s25 links swept. docs:check 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 9034a6e3a4
commit 36e79c8f8d

@ -42,9 +42,9 @@ references rather than copying it — with the cage open".
| RFC | Status | The decision it records |
| --- | --- | --- |
| [`COLOR_MODEL_RFC.md`](../src/uix/eidos/COLOR_MODEL_RFC.md) | RESUELTO (2026-06-02) — canon in THEMING §25 | The conceptual color model: rich palette (31 Radix scales) + hierarchy roles by explicit alias + intents auto-derived from the palette (identity = step 9). Rejected the "intent = single-anchor" variant. |
| [`rfc-color-model.md`](./rfcs/rfc-color-model.md) | RESUELTO (2026-06-02) — canon in [`theming/reference.md`](./theming/reference.md) §25 | The conceptual color model: rich palette (31 Radix scales) + hierarchy roles by explicit alias + intents auto-derived from the palette (identity = step 9). Rejected the "intent = single-anchor" variant. |
| [`COLOR_ENGINE_RFC.md`](../src/uix/eidos/COLOR_ENGINE_RFC.md) | PROPUESTA (2026-06-04) | The *physical* layer of color: OKLCH · P3 wide-gamut · APCA contrast · 1-seed generator. Changes how the color variables are produced, not which exist or what they mean. |
| [`TYPOGRAPHY_ENGINE_RFC.md`](../src/uix/eidos/TYPOGRAPHY_ENGINE_RFC.md) | PROPUESTA (2026-06-05) | Typography to reference-grade, additively behind the frozen token contract (audit → compare → extend, mirroring color). |
| [`rfc-typography.md`](./rfcs/rfc-typography.md) | ✅ Implementado (fases 1–5) | Typography to reference-grade, additively behind the frozen token contract (audit → compare → extend, mirroring color). |
| [`rfc-depth.md`](./rfcs/rfc-depth.md) | ✅ Implementado (fases 1–5) | The depth/presence channel: "depth is not something an element *has*, it is something that *happens*". Two-moment model (state + event). |
| [`rfc-shape.md`](./rfcs/rfc-shape.md) | ✅ Implementado (fases 1–5) | The shape channel (the book's 8th and last expression channel) as orthogonal axes on top of the untouched `--radius-*` magnitude. |

@ -0,0 +1,164 @@
---
title: RFC — Color model: rich accents + single-anchor intents
type: rfc
audience: human + agent
status: resolved (2026-06-02) — kept as the historical record of the original proposal
source: migrated from src/uix/eidos/COLOR_MODEL_RFC.md (2026-07-02, docs-book F7.4)
---
# RFC — Color model: rich accents + single-anchor intents
> **Status: RESOLVED (2026-06-02).** The color model was decided — the
> canonical reference is [`theming/reference.md`](../theming/reference.md) §25.
>
> **What was adopted**: a rich palette (library grown to 33 scales) + a
> semantic alias layer, and the **intents auto-derive from the palette by the
> book's convention** (`CANONICAL_INTENT_SCALES`, identity = step 9). Hierarchy
> roles = explicit alias. Per-component override via the `color` prop.
>
> **What was REJECTED**: the "intent = single-color anchor" with inline derived
> slots (this document, §4-5). Radix doesn't do it (it generates a *scale* from
> a hex and aliases it), and with the rich palette the problem that motivated
> it (authoring 12 steps for `loss`) disappears — `loss` aliases the palette's
> `plum` scale.
>
> Below is kept as the historical record of the original proposal.
## 0. Origin
Born from a real bug (`loss` mapped to a blue `indigo` scale in the
`untitled-ui` theme) and from the observation that the cause was not the color
but the **authoring model**: the engine forces writing a 12-step ramp per role,
even for semantic signals that are, conceptually, **a single color**. The
triggering comparison: Radix Themes ships a much richer accent library and
lets you define an accent from **a single hex**.
## 1. Current state (still standing)
- **9 color roles**: 3 hierarchical (`primary` / `secondary` / `tertiary`) +
6 intents (`neutral` / `affirm` / `fulfill` / `risk` / `threat` / `loss`).
- `ColorRoleMap = Record<ColorRole, string | ColorRoleDefinition>` where the
`string` is a **scale name** and
`ColorRoleDefinition = { scale: string; slots?: ... }`. No "anchor color"
form exists.
- Each role → **1 scale of 12 steps**. The 13 slots **auto-derive** from those
steps (`render-css.ts > DEFAULT_COLOR_ROLE_SLOT_STEPS`):
| slot | step | use |
| --- | --- | --- |
| `track` | 1 | soft background |
| `element` | 3 | subtle fill |
| `hover` | 4 | hover over subtle |
| `active` | 5 | pressed over subtle |
| `border` | 6 | border |
| `solid` | 9 | solid fill (button/badge) |
| `solidHover` | 10 | solid's hover |
| `text` | 11 | text over surface |
| `contrast` | on-solid (12 fallback) | text over the solid |
Additionally 12 raw steps (`--primitive-{role}-{step}`) and 12 alpha
(`--primitive-{role}-a{step}`) are emitted.
- **Base library: 12 scales** (`gray`, `slate`, `blue`, `cyan`, `teal`,
`green`, `yellow`, `amber`, `orange`, `red`, `pink`, `purple`) in
`lib/themes/base.ts`, ×2 modes.
- For a hue outside the library (carbon, violet, indigo, plum…) → **the 12-step
ramp must be written by hand** (×2 modes). This is what produced the `loss`
bug.
## 2. Comparison with Radix
| | Eidos today | Radix Themes |
| --- | --- | --- |
| Palette library | 12 scales | ~30 scales × (12 + 12 alpha + dark + P3) |
| Role model | role → 1 scale of 12; auto-derived slots | accent + gray picked from the library |
| Own brand color | write the 12-step ramp by hand | **1 hex → generates** the 12-step ramp (OKLCH/APCA algorithm) |
| Semantics | each intent a hand-written 12-step ramp | just more scales from the library |
## 3. Problems with the current model
1. **Expensive, error-prone authoring**: 12 steps × 9 roles × 2 modes. Picking
the wrong step 9 of a badly chosen scale = the `loss` bug.
2. **Poor library** (12 vs ~30): missing indigo, violet, plum, iris, jade,
grass, lime, sky, mint, gold, bronze, brown, crimson, ruby, tomato, sand,
sage, olive, mauve.
3. **Intents don't need a full interactive ramp**: an intent is a signal (soft
badge, border, text, solid fill). `hover`/`active`/`element` only matter if
you render an intent-tinted interactive surface (e.g. a destructive button).
Those few cases are covered by deriving, not authoring.
## 4. Proposal — two tiers
- **Tier 1 · accents + neutral** (`primary` / `secondary` / `tertiary` /
`neutral`): **rich** scales from an expanded library. They are interactive
surfaces (buttons, toggles, tabs) and want the full ramp. `neutral` is
grouped as an "intent" in the canon but works as the surfaces/borders/text
gray → **stays a scale**.
- **Tier 2 · evaluative intents** (`affirm` / `fulfill` / `risk` / `threat` /
`loss`): declared as **a single anchor color**; the engine derives the slots.
Zero hand-written ramps; impossible to get step 9 wrong.
Key nuance: "an intent is one" means **one anchor color → derived slots**, not
a single CSS value — otherwise you lose the soft badge, the border and the
readable text.
## 5. Concrete engine changes
1. **Types** (`lib/config-types.ts`): extend `ColorRoleMap`'s value to accept
an anchor form, e.g.:
```ts
export interface ColorRoleAnchor {
readonly anchor: string // hex/CSS color
}
export type ColorRoleMap = Record<ColorRole, string | ColorRoleDefinition | ColorRoleAnchor>
```
(or detect that the `string` is a CSS color rather than a scale name).
2. **Resolution** (`lib/render-css.ts`): for anchor roles, emit the slots by
derivation with `color-mix()` (in `oklab`, mixing against
`--color-surface-default` and the ink `--color-content-*`, which already
invert per mode):
- `solid` = `anchor`
- `solidHover` = `color-mix(in oklab, var(anchor), var(--color-content-on-solid) 12%)`
- `track` (soft) = `color-mix(in oklab, var(anchor) 12%, var(--color-surface-default))`
- `element` / `hover` / `active` = graded mixes (≈18% / 24% / 32%)
- `border` = `color-mix(in oklab, var(anchor) 45%, var(--color-surface-default))`
- `text` = `color-mix(in oklab, var(anchor), var(--color-content-primary) 30%)` (readable over surface)
- `contrast` = on-solid (already the default)
- alpha steps = `color-mix(in oklab, var(anchor) {p}%, transparent)`
Percentages to calibrate against the current steps (1/3/4/5/6/9/10/11) for
visual parity.
3. **Validation** (`lib/config.ts`): `validateColorRole*` must accept the
anchor form (validate `anchor` is a CSS color, not require a scale to exist).
4. **(Phase 2) Expand the library** `THEME_BASE_*_COLOR_SCALES` toward ~24–30
scales (add the missing hues) so accents map without writing hex.
5. **(Phase 3) 1-hex → 12-step generator** Radix-style (OKLCH + APCA contrast).
Bigger effort; yields full high-fidelity scales from a single color for
arbitrary brand accents. Probably build-time.
## 6. Impact / compatibility
- **Downstream (recipes, TSC, eidos CSS): zero changes.** They keep reading
`--color-{role}-{slot}`; only *how* those vars are produced changes.
- **Sema canon (6 intents): intact.** Only the color authoring changes, not
the perceptual engine or morfo.
- **Additive**: an intent can keep pointing at a scale if it wants the full
ramp. The anchor form is the ergonomic default, not the only one.
- **Tests**: update `active-eidos-config.test.ts` / `render-css` for the
anchor form and the derivations.
## 7. Open decisions
- `color-mix` in `oklab` (perceptual mixing, recommended) vs `srgb`?
- Build-time generator vs runtime derivation? `color-mix` covers ~90% without
a generator; the generator is for full-scale fidelity.
- Calibration of the derivation percentages to match the current steps.
- Is per-intent derived alpha worth it, or are the opaque slots enough?
## 8. Concrete effect on the `untitled-ui` theme (when implemented)
- The 5 intents go from a 12-step ramp to **a single anchor**:
`affirm = #0E9384` · `fulfill = #079455` · `risk = #DC6803` ·
`threat = #D92D20` · `loss = #7A3AAD`.
- `primary` / `secondary` / `tertiary` / `neutral` stay as scales (ideally from
the expanded library: carbon, violet, blue, gray).
- Result: the `_lib/untitled-ui.ts` file shrinks drastically and the "blue
step 9 for loss" error becomes impossible.

@ -0,0 +1,213 @@
---
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).

@ -1384,7 +1384,7 @@ solids (APCA on-solid with a flip; see §25 and `lib/render-css.ts`).
The RFC is resolved — chronicle in [`changelog.md §21`](./changelog.md).
The standing model is §25 +
[`COLOR_MODEL_RFC.md`](../../src/uix/eidos/COLOR_MODEL_RFC.md).
[`rfc-color-model.md`](../rfcs/rfc-color-model.md).
## 22. Pending theming improvements
@ -1426,8 +1426,8 @@ color model, three layers):
The per-role slots are `COLOR_ROLE_SLOTS` (§3, layer 3 — the single
source). Detail and rationale:
[`COLOR_MODEL_RFC.md`](../../src/uix/eidos/COLOR_MODEL_RFC.md) +
[`COLOR_ENGINE_RFC.md`](../../src/uix/eidos/COLOR_ENGINE_RFC.md).
[`rfc-color-model.md`](../rfcs/rfc-color-model.md) +
[`rfc-color-engine.md`](../rfcs/rfc-color-engine.md).
## 26. Runtime theme builder — `eidos.applyColorScheme` (2026-06-04)

@ -1,155 +1,13 @@
# RFC — Modelo de color: accents ricos + intents de un solo ancla
# RFC — Color model (moved)
> **Estado: RESUELTO (2026-06-02).** El modelo de color quedó decidido — la
> referencia canónica es **THEMING.md §25**.
>
> **Qué se adoptó**: paleta rica (librería ampliada a 33 escalas) + capa
> semántica de alias, y los **intents auto-derivan de la paleta por convención
> del libro** (`CANONICAL_INTENT_SCALES`, identidad = step 9). Roles de jerarquía
> = alias explícito. Override por componente vía prop `color`.
>
> **Qué se DESCARTÓ**: el "intent = ancla de un solo color" con slots derivados
> inline (este documento, §4-5). Radix no lo hace (genera una *escala* desde un
> hex y la aliasa), y con la paleta rica el problema que lo motivaba (autorar 12
> pasos para `loss`) desaparece — `loss` aliasa la escala `plum` de la paleta.
>
> Lo de abajo se conserva como registro histórico de la propuesta original.
RESOLVED (2026-06-02). Adopted: rich palette (33 scales) + alias layer;
intents auto-derive from the palette by the book's convention
(`CANONICAL_INTENT_SCALES`, identity = step 9); hierarchy roles = explicit
alias. Rejected: the "intent = single-anchor color" variant this RFC
originally proposed.
## 0. Origen
**The RFC moved to the docs corpus:**
[`docs/rfcs/rfc-color-model.md`](../../../docs/rfcs/rfc-color-model.md)
— the resolution header + the original proposal kept as historical record.
Surge de un bug real (`loss` mapeado a una escala `indigo` azul en el tema
`untitled-ui`) y de la observación de que la causa no era el color sino el
**modelo de autoría**: el engine obliga a escribir un ramp de 12 pasos por rol,
incluso para señales semánticas que son, conceptualmente, **un solo color**.
Comparación detonante: Radix Themes ship una librería de acentos mucho más rica
y permite definir un acento desde **un solo hex**.
## 1. Estado actual (vigente)
- **9 roles de color**: 3 jerárquicos (`primary` / `secondary` / `tertiary`) +
6 intents (`neutral` / `affirm` / `fulfill` / `risk` / `threat` / `loss`).
- `ColorRoleMap = Record<ColorRole, string | ColorRoleDefinition>` donde el
`string` es el **nombre de una escala** y `ColorRoleDefinition = { scale: string; slots?: ... }`.
No existe una forma "color ancla".
- Cada rol → **1 escala de 12 pasos**. Los 13 slots se **auto-derivan** de esos
pasos (`render-css.ts > DEFAULT_COLOR_ROLE_SLOT_STEPS`):
| slot | step | uso |
| --- | --- | --- |
| `track` | 1 | fondo suave (soft bg) |
| `element` | 3 | relleno sutil |
| `hover` | 4 | hover sobre sutil |
| `active` | 5 | pressed sobre sutil |
| `border` | 6 | borde |
| `solid` | 9 | relleno sólido (botón/badge) |
| `solidHover` | 10 | hover del sólido |
| `text` | 11 | texto sobre surface |
| `contrast` | on-solid (12 fallback) | texto sobre el sólido |
Además se emiten 12 pasos crudos (`--primitive-{role}-{step}`) y 12 alpha
(`--primitive-{role}-a{step}`).
- **Librería base: 12 escalas** (`gray`, `slate`, `blue`, `cyan`, `teal`,
`green`, `yellow`, `amber`, `orange`, `red`, `pink`, `purple`) en
`lib/themes/base.ts`, ×2 modos.
- Para un hue fuera de la librería (carbon, violet, indigo, plum…) → **hay que
escribir el ramp de 12 a mano** (×2 modos). Esto fue lo que produjo el bug de
`loss`.
## 2. Comparación con Radix
| | Eidos hoy | Radix Themes |
| --- | --- | --- |
| Librería de paletas | 12 escalas | ~30 escalas × (12 + 12 alpha + dark + P3) |
| Modelo de rol | rol → 1 escala de 12; slots auto-derivados | accent + gray elegidos de la librería |
| Color de marca propio | escribir el ramp de 12 a mano | **1 hex → genera** el ramp de 12 (algoritmo OKLCH/APCA) |
| Semánticos | cada intent un ramp de 12 a mano | son más escalas de la librería |
## 3. Problemas del modelo actual
1. **Autoría cara y propensa a error**: 12 pasos × 9 roles × 2 modos. Elegir mal
el paso 9 de una escala mal escogida = el bug de `loss`.
2. **Librería pobre** (12 vs ~30): faltan indigo, violet, plum, iris, jade,
grass, lime, sky, mint, gold, bronze, brown, crimson, ruby, tomato, sand,
sage, olive, mauve.
3. **Los intents no necesitan ramp interactivo completo**: un intent es una
señal (badge suave, borde, texto, relleno sólido). `hover`/`active`/`element`
solo importan si renderizas una superficie interactiva teñida por intent
(ej. botón destructivo). Esos pocos casos se cubren derivando, no autorando.
## 4. Propuesta — dos niveles
- **Tier 1 · accents + neutral** (`primary` / `secondary` / `tertiary` /
`neutral`): escalas **ricas** de una librería ampliada. Son superficies
interactivas (botones, toggles, tabs) y quieren el ramp completo. `neutral`
está agrupado como "intent" en la canon pero funciona como gris de
superficies/bordes/texto → **se queda como escala**.
- **Tier 2 · intents evaluativos** (`affirm` / `fulfill` / `risk` / `threat` /
`loss`): se declaran como **un solo color ancla**; el engine deriva los slots.
Cero ramps a mano; imposible equivocar el paso 9.
Matiz clave: "un intent es uno" significa **un color ancla → slots derivados**,
no un único valor CSS — si no, pierdes el badge suave, el borde y el texto
legible.
## 5. Cambios de engine concretos
1. **Tipos** (`lib/config-types.ts`): extender el valor de `ColorRoleMap` para
aceptar una forma ancla, p. ej.:
```ts
export interface ColorRoleAnchor {
readonly anchor: string // hex/color CSS
}
export type ColorRoleMap = Record<ColorRole, string | ColorRoleDefinition | ColorRoleAnchor>
```
(o detectar que el `string` es un color CSS en lugar de un nombre de escala).
2. **Resolución** (`lib/render-css.ts`): para roles-ancla, emitir los slots por
derivación con `color-mix()` (en `oklab`, mezclando contra
`--color-surface-default` e ink `--color-content-*`, que ya se invierten por
modo):
- `solid` = `anchor`
- `solidHover` = `color-mix(in oklab, var(anchor), var(--color-content-on-solid) 12%)`
- `track` (soft) = `color-mix(in oklab, var(anchor) 12%, var(--color-surface-default))`
- `element` / `hover` / `active` = mezclas graduadas (≈18% / 24% / 32%)
- `border` = `color-mix(in oklab, var(anchor) 45%, var(--color-surface-default))`
- `text` = `color-mix(in oklab, var(anchor), var(--color-content-primary) 30%)` (legible sobre surface)
- `contrast` = on-solid (ya por defecto)
- alpha steps = `color-mix(in oklab, var(anchor) {p}%, transparent)`
Porcentajes a calibrar contra los pasos actuales (1/3/4/5/6/9/10/11) para
paridad visual.
3. **Validación** (`lib/config.ts`): `validateColorRole*` debe aceptar la forma
ancla (validar que `anchor` es color CSS, no exigir que exista una escala).
4. **(Fase 2) Ampliar la librería** `THEME_BASE_*_COLOR_SCALES` hacia ~24–30
escalas (añadir los hues que faltan) para que los accents se mapeen sin
escribir hex.
5. **(Fase 3) Generador 1-hex → 12 pasos** estilo Radix (OKLCH + contraste
APCA). Mayor esfuerzo; da escalas completas de alta fidelidad desde un solo
color para accents de marca arbitrarios. Probablemente build-time.
## 6. Impacto / compatibilidad
- **Aguas abajo (recipes, TSC, eidos CSS): cero cambios.** Siguen leyendo
`--color-{role}-{slot}`; solo cambia *cómo* se producen esas vars.
- **Sema canon (6 intents): intacta.** Solo cambia la autoría del color, no el
motor perceptual ni morfo.
- **Aditivo**: un intent puede seguir apuntando a una escala si quiere ramp
completo. La forma ancla es la ergonómica por defecto, no la única.
- **Tests**: actualizar `active-eidos-config.test.ts` / `render-css` para la
forma ancla y las derivaciones.
## 7. Decisiones abiertas
- ¿`color-mix` en `oklab` (mezcla perceptual, recomendado) vs `srgb`?
- ¿Generador build-time vs derivación runtime? `color-mix` cubre ~90% sin
generador; el generador es para fidelidad de escala completa.
- Calibración de porcentajes de derivación para igualar los pasos actuales.
- ¿Merece la pena alpha derivado por intent, o basta con los slots opacos?
## 8. Efecto concreto en el tema `untitled-ui` (cuando se implemente)
- Los 5 intents pasan de ramp de 12 a **un solo ancla**:
`affirm = #0E9384` · `fulfill = #079455` · `risk = #DC6803` ·
`threat = #D92D20` · `loss = #7A3AAD`.
- `primary` / `secondary` / `tertiary` / `neutral` siguen como escalas
(idealmente de la librería ampliada: carbon, violet, blue, gray).
- Resultado: el archivo `_lib/untitled-ui.ts` se reduce drásticamente y deja de
ser posible el error de "paso 9 azul para loss".
Canonical reference: [`docs/theming/reference.md`](../../../docs/theming/reference.md) §25.

@ -1,191 +1,14 @@
# RFC — Motor de tipografía reference-grade (eidos)
# RFC — Reference-grade typography engine (moved)
> **Estado: PROPUESTA (2026-06-05).** Plan para elevar la tipografía de eidos a la par
> o por encima de los frameworks de referencia, replicando el enfoque del color
> (`COLOR_ENGINE_RFC.md`): auditar → comparar → extender de forma ADITIVA detrás del
> contrato de tokens congelado, por fases.
✅ Implemented (phases 1–5 closed). Fluid rem `clamp()` behind the frozen
`--font-size-*` names (scaling composes on top), config-driven `@font-face`
with anti-CLS metric fallback + `unicode-range` + variable `axes` (engine
ready, assets still static TTF), tracking/leading/features/measure/wrap
scales, the intentional two-zone authored scale, and the runtime builder
`applyTypeScale(seed)` (`buildTypeScale`).
## 1. Estado actual (auditoría)
**The RFC moved to the docs corpus:**
[`docs/rfcs/rfc-typography.md`](../../../docs/rfcs/rfc-typography.md)
— audit, reference comparison, the additive inclusion model, phases.
`TypographyPrimitiveSet` (`lib/primitives/typography.ts` + `config-types.ts`) →
emisión en `render-css.ts` → tokens → componentes `<Text>` / `<Heading>` / `<SText>`.
- **4 familias** (primary Instrument Sans · secondary/display Lora · mono Azeret Mono).
- **8 tamaños** `xxs 10px … xxxl 80px`, cada uno `size` / `lineHeight` / `letterSpacing`
**fijos en px**.
- **4 pesos** (400/500/600/700).
- **13 estilos nombrados** (hero, h1–h6, body, prose, label, caption, code) con size
responsive por breakpoint en algunos (`{ base, md }`).
- **Eje `--scaling`** (zoom global 90–110%) que multiplica los px.
- Tokens: `--font-family-*`, `--font-size-*` (`calc(px * --scaling)`),
`--font-line-height-*`, `--font-letter-spacing-*`, `--font-weight-*`, `--style-{name}-*`.
- **Brazo de medición**: el motor `canvas-text` (`<SText>`) cuenta líneas exactas
replicando el navegador (productionizado — ver `lib/canvas-text/fix-stext.md`).
**Bueno hoy**: estilos semánticos nombrados, sizes responsive, eje de scaling (poco
común), contrato de tokens + theming, medición exacta de líneas.
## 2. Comparación con referencia (el gap)
| Capacidad | Referencia | Eidos hoy |
|---|---|---|
| Tipografía **fluida** (`clamp()` por viewport) | Utopia, Carbon, Tailwind(opt) | ❌ px fijos + breakpoints manuales |
| **Variable fonts** (1 archivo, wght + opsz) | Material 3, Apple | ❌ TTF estáticas por peso |
| **WOFF2 + subsetting + fallback con métricas** (anti-CLS) | Next/Fontaine | ❌ TTF, sin subset, sin `size-adjust` |
| **Optical sizing** (`opsz`) | Apple, Material | ❌ |
| **Tracking por tamaño** (óptico) | Material, Apple, Radix | ❌ todo `0` |
| **`text-wrap: balance`/`pretty`** | Tailwind, Chakra | ❌ |
| **Measure** (ancho de línea ~65ch) | clásico, Tailwind prose | ❌ |
| **`font-feature-settings`** (tabular-nums…) | Tailwind, Radix | ❌ |
| Estilos nombrados · eje de scaling | Chakra/Material · Apple(parcial) | ✅ |
## 3. Modelo de inclusión: extender, no reemplazar
**Principio rector (idéntico a color)**: todo lo nuevo es **ADITIVO** sobre
`TypographyPrimitiveSet`, detrás del **contrato de tokens congelado**. Lo viejo (px
fijos) sigue funcionando; lo nuevo se activa por config. Los nombres de token
(`--font-size-X`, `--style-Y-*`) **no cambian** — solo cambia la *fórmula del valor*
(igual que en color el token `--scale-*` pasó de hex a `oklch()` sin renombrar).
Capas de la implementación, paralelas a color:
| Pieza | Dónde | Análogo en color |
|---|---|---|
| Matemática pura (fluid scale Utopia) | `eidos/lib/type-scale.ts` (puro) | `arts/color` / `build-scheme.ts` |
| Tipo + config | `config-types.ts` (`TypographyPrimitiveSet`) | `EidosConfig` |
| Emisión CSS | `render-css.ts` (`appendTypographyDeclarations`) | `appendColorScaleDeclarations` |
| Fuentes | `themes/fonts.css` (woff2/variable) | `themes/base` (paleta) |
| Componentes | `<Text>` / `<Heading>` (props nuevas) | `<Button>` etc. |
| Runtime opcional | `eidos.applyTypeScale(seed)` | `eidos.applyColorScheme(seed)` |
## 4. Tipografía fluida (Fase 1, el corazón)
**Fórmula (estilo Utopia)** — un tamaño se vuelve un `clamp(min, fluido, max)` en
**rem** (a11y: respeta el zoom del navegador), donde el tramo fluido interpola entre
dos viewports:
```
slope = (maxRem − minRem) / (maxVw − minVw)
interceptRem = minRem − slope · minVw
size = clamp(minRem, interceptRem + slope·100vw, maxRem)
```
- **rem, no px**: mejora la accesibilidad (el zoom de fuente del SO/navegador escala).
El token `--font-size-X` mantiene su nombre; su valor pasa de `calc(px*scaling)` a
`calc(clamp(...rem...) * var(--scaling))` — **transparente para los consumidores**.
- **El eje `--scaling`** (zoom discreto del sistema) compone **encima** del clamp vía
`calc(clamp(...) * var(--scaling))`. Dos ejes ortogonales: fluid (continuo, por
viewport) + scaling (discreto, preferencia).
- **Simplifica los estilos nombrados**: hero/h1/h2 ya no necesitan size responsive
`{ base, md }` manual — el `clamp` cubre el viewport de forma continua. El emisor de
`@media` por breakpoint para size se puede retirar (menos CSS).
- **Tipo**: `TextMetric.size` acepta hoy `string`; se extiende a
`string | FluidSize` donde `FluidSize = { min: string; max: string; minVw?: string; maxVw?: string }`.
`string` (px fijo) sigue válido → backward-compatible.
## 5. Fuentes modernas (Fase 2, mayor golpe perf+calidad)
- **WOFF2 + variable**: migrar `fonts.css` de TTF estáticas por peso a **woff2
variable** (Instrument Sans / Lora / Azeret Mono tienen versión variable con eje
`wght`; Lora además `ital`). Un archivo por familia en vez de 3–10.
- **Anti-CLS**: por cada familia, un `@font-face` de **fallback con métricas
sobreescritas** (`size-adjust`, `ascent-override`, `descent-override`,
`line-gap-override`) que iguala la geometría de la webfont al fallback de sistema →
cero salto de layout al cargar (enfoque Fontaine / `next/font`). Las métricas se
precalculan por fuente.
- **`unicode-range`** subsetting (latin / latin-ext separados) → cargar solo lo usado.
- **`preload`** de la familia crítica (primary regular).
- **Tipo**: `FontFamily` += `axes?: { wght?: [min,max]; opsz?: [min,max] }` +
`sizeAdjust?` / overrides de métricas + `unicodeRanges?`. `source` ya existe. ✅
- **Optical sizing** ✅: `font-optical-sizing: auto` en `:root` cuando alguna familia
declara `opsz`.
- **Eje de peso variable** ✅: cuando un `face` omite `weight` y la familia declara
`axes.wght`, el `@font-face` emite el rango (`font-weight: 100 900`) → un solo face
cubre todo el eje. (`render-css > renderFontFace`; test en `active-eidos-config.test.ts`.)
> **Estado §5**: el **motor** está cableado — `@font-face` config-driven, fallback
> anti-CLS, `unicode-range`, y ahora el consumo de `axes` (rango de peso + optical
> sizing). Inerte hasta que un tema declare `axes` — como el wide-gamut de color: listo,
> pero no ejercitado por los assets actuales (TTF estáticas). **Pendiente de assets**:
> migrar a woff2 variable reales; `preload` (cierre aparte).
## 6. Tracking · leading · features · measure · wrap (Fase 3)
- **Tracking/leading como escalas** (estilo Tailwind): `TypographyPrimitiveSet` +=
`tracking` + `leading` → `--tracking-{key}` / `--leading-{key}`. Tracking **óptico
por defecto** (negativo en display, ~0 en body, positivo en xs/caption).
- **`font-feature-settings`**: `features?: Record<string,string>` → `--font-feature-{key}`
(presets `tabular`, `oldstyle`, `ligatures`, `smallcaps`). Prop `numeric` en componentes.
- **Measure**: token `--measure-{key}` (`60ch`/`66ch`/`72ch`) para prosa legible.
- **`text-wrap`**: props `wrap: 'balance' | 'pretty' | 'nowrap'` en `<Text>`/`<Heading>`
(keyword CSS, sin token); default `balance` en headings, `pretty` en prose.
## 7. Vínculo con `<SText>` (brazo de medición)
El motor `canvas-text` cuenta líneas exactas replicando el navegador (ya
productionizado). Es la **otra mitad** del sistema: la tipografía *visual* (este RFC) y
la *medición* (SText). Al emitir fluid/tracking, `<SText>` los mide igual (lee
`getComputedStyle` real), así que el conteo sigue siendo fiel sin cambios — el epoch de
`useFontReady` (T1) ya cubre el re-cálculo tras cargar variable fonts.
## 8. Doctrina (paralela a color)
- **Escala + estilos nombrados = canon del eidos** (como roles/variants de color):
valores themeables, pero el *set* de estilos es canon.
- **Fuentes = dato del tema** (como la paleta): el tema trae las suyas.
- **Fluid / tracking / features = capacidades del motor**, default-on con fallback
(como el wide-gamut OKLCH): sRGB-idéntico donde no hay soporte.
- **Theme = retintar/retipar lo perceptualmente fijo**: cambia QUÉ fuente es `primary`,
no QUÉ significa `h1`.
## 9. Contrato de tokens (añadidos)
Congelados, bare-prefixed (§6 THEMING). Nuevos:
`--tracking-{key}`, `--leading-{key}`, `--font-feature-{key}`, `--measure-{key}`.
Reformulados (mismo nombre, valor fluido): `--font-size-{key}`.
Sin cambios de nombre → cero rotura de componentes.
## 10. Fases — estado (cerrado)
1. ✅ **Motor fluido** — `type-scale.ts` (puro `fluidClamp`) + `FluidSize` + `clamp()` rem
en `appendTypographyDeclarations` (scaling compone); `@media` de size retirado.
2. ✅ **Fuentes** — `@font-face` config-driven + fallback anti-CLS (métricas) +
`unicode-range` + `FontFamily.axes` (rango de peso variable + `font-optical-sizing`) +
**font preloads** (`collectFontPreloads` / `eidos.fontPreloads()`). Assets aún TTF
estáticas → la variabilidad es capacidad de motor, no visible todavía (como wide-gamut).
3. ✅ **Tracking/leading/features/measure/wrap** — escalas config-driven + props de
componente (`<Text>`/`<Heading>`) + **tokens semánticos** (`--leading-{role}` /
`--tracking-{role}`) ahora config-driven (`typography.semanticLeading` /
`semanticTracking`).
4. ✅ **Escala — diseño de dos zonas (intencional)**, ver abajo. NO se cambiaron los
valores (sería regresión perceptual): la "irregularidad" es deliberada.
5. ✅ **Docs + demo** — este RFC + demo `/temas/tipografia` (escala/familias/ejes en vivo)
+ **`eidos.applyTypeScale(seed)`** runtime (`buildTypeScale`, hermano de `applyColorScheme`).
### Fase 4 — la escala es un diseño de dos zonas (intencional)
La escala (xxs 10 · xs 12 · sm 14 · md 16 · lg 18→20 · xl 24→28 · xxl 40→48 · xxxl 64→80)
NO es un único ratio modular, y eso es deliberado:
- **Zona UI** (xxs–md): pasos finos (~1.14–1.2) para controles, etiquetas y cuerpo, donde
la granularidad importa y los saltos grandes molestan.
- **Zona display** (lg–xxxl): saltos grandes (~1.25–1.7) para jerarquía y titulares, donde
el contraste manda.
Un ratio único uniforme (lo que produce `applyTypeScale`) es la alternativa **opt-in** en
runtime; la escala autorada conserva su afinado de dos zonas (como Radix / Material, que
también afinan en vez de imponer un ratio puro). Cambiarla sería una regresión, no una
mejora — por eso Fase 4 es documentación, no recableado.
## Verificación por fase
- `npm run check` 0 errores; `render-css` snapshot regenerado (`generated/base.css`).
- Backward-compat: un config con sizes en px (string) sigue emitiendo igual.
- Navegador: el fluid escala con el viewport; `<SText>` cuenta líneas correctas; sin CLS
al cargar las variable fonts.
## Fuera de alcance
- Justificación / guionado / balanceo de líneas propio (el navegador + `text-wrap` lo
hacen; `<SText>` solo mide).
- Subsetting dinámico en runtime (los `unicode-range` estáticos bastan).
The measurement arm stays here: `lib/canvas-text/` (`<SText>`).

Loading…
Cancel
Save

Powered by TurnKey Linux.