feat(eidos foundation): vertebrate typography via named-style aliases + R-2.7

Single source of truth for typography values that the recipe layer
consumes. The foundation aliases `--font-ui` and `--leading-ui` (read
by ~30 recipe tokens in `lib/recipes/base.ts`) now derive from the
canonical `label` named style instead of carrying duplicate literals:

  --style-label-font-family: var(--font-family-primary);
  --style-label-line-height: 1.25;

  --font-ui:    var(--style-label-font-family, var(--font-family-primary));
  --leading-ui: var(--style-label-line-height, 1.25);

Chain: typography.ts styles → --style-{name}-* → --leading-ui / --font-ui
→ recipe tokens → component CSS. Editing
`STATIC_TYPOGRAPHY.styles.label.lineHeight` now propagates to every
recipe in one go.

Why not push recipes to consume `--style-{name}-*` directly:
- t-shirt sizes (xs/sm/md/lg/xl) don't map to four semantic buckets
- per-component matices (description/caption/hint) need their own
  color / weight / letter-spacing
- ref libraries (Radix Themes, Mantine, MUI, Chakra) all keep
  numerical scale for component internals; semantic layer is only for
  user-facing typography primitives (`<Text variant="body2">`)

Audit rule R-2.7 (warn): detects literal font-size / font-weight /
line-height / letter-spacing in eidos component CSS. Escape valves:
var(...), numeric identities (0/0px/1), keywords (inherit/initial/
unset), or trailing `/* literal: <reason> */` comment. Current run
flags 6 components with letter-spacing/font-size literals (all
intentional micro-tracking and em-relative; can be annotated case by
case).

Documentation:
- src/uix/eidos/README.md § "Vertebración tipográfica" — two-layer
  architecture rationale, alias chain diagram, comparison vs Radix
  Themes / Chakra / Mantine / MUI, escape valves
- web/routes/uix/lib/COMPONENT_AUDIT_GUIDE.md § 4.11 — pointer to
  R-2.7 + cross-link to the foundation doc

Verified end-to-end in browser at /uix/components/field:
  --font-ui          → 'Instrument Sans', system-ui, sans-serif
  --style-label-font-family → 'Instrument Sans', system-ui, sans-serif
  --leading-ui       → 1.25
  --style-label-line-height → 1.25
  computed [data-field-label].line-height → 17.5px (= 14 × 1.25)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent c703e2e7ff
commit b504c1c4b7

@ -723,6 +723,55 @@ function checkRecipe(kebab: string, info: ComponentReport): CheckResult[] {
if (legacy.length === 0) out.push(pass('R-3.2', 'error'));
else out.push(fail('R-3.2', 'error', `Legacy color names: ${legacy.join(', ')}`));
// R-2.7: no literal typography in recipes.
//
// Eidos foundation owns one type-system anchor (`--font-size-*`,
// `--font-weight-*`, `--leading-ui`, `--font-ui`, named styles via
// `--style-{name}-*`). Recipes MUST consume that anchor via tokens
// or component-scoped tokens that themselves resolve to it.
//
// A raw literal (`font-size: 12px`, `line-height: 1.4`,
// `letter-spacing: 0.02em`, `font-weight: 500`) breaks the
// vertebration: changing the foundation no longer propagates here.
//
// Allowed escape valves — these don't count as drift:
// - `inherit`, `initial`, `unset`, `currentColor` (CSS keywords)
// - `0` / `0px` / `0em` / `1` (numeric identities — no semantic
// typography intent; common for tight-leading on icons or
// zero-tracking on monospace)
// - any value wrapped in `var(...)` (the whole point)
// - any value with a comment `/* literal: <reason> */` on the
// same line (explicit opt-out for justified exceptions)
//
// Note: `font-family` is checked but the audit isn't strict about
// generic-keyword fallbacks (`sans-serif`, `serif`, `monospace`)
// since those only appear inside `var(--font-family-*, sans-serif)`
// fallback chains, which are themselves a `var()` and already pass.
const TYPO_PROPS = ['font-size', 'font-weight', 'line-height', 'letter-spacing'] as const;
const ALLOWED_LITERALS = new Set(['0', '0px', '0em', '0rem', '1', 'inherit', 'initial', 'unset']);
const literalTypoOffenses: string[] = [];
for (const line of css.split(/\r?\n/)) {
// Strip trailing comments so we can look for the escape valve
const m = line.match(/^\s*(font-size|font-weight|line-height|letter-spacing)\s*:\s*([^;]+?);?\s*(\/\*.*\*\/)?\s*$/);
if (!m) continue;
const [, prop, rawValue, comment] = m;
const value = rawValue.trim();
if (value.startsWith('var(')) continue;
if (ALLOWED_LITERALS.has(value)) continue;
if (comment && /literal:/i.test(comment)) continue;
literalTypoOffenses.push(`${prop}: ${value}`);
void TYPO_PROPS; // silence unused-var on the tuple if linter cares
}
if (literalTypoOffenses.length === 0) out.push(pass('R-2.7', 'warn'));
else
out.push(
fail(
'R-2.7',
'warn',
`Literal typography (${literalTypoOffenses.length}): ${literalTypoOffenses.slice(0, 5).join('; ')}${literalTypoOffenses.length > 5 ? '...' : ''}. Use a recipe token or named-style var, or add a /* literal: <reason> */ comment.`
)
);
return out;
}

@ -537,6 +537,120 @@ Las capas superiores (sema, soma, morfo) **NO consumen** estos tokens y
(perceptual durations, behavior, contract DNA) ortogonales al
rendering visual.
## Vertebración tipográfica — single source of truth en foundation
Eidos tiene **dos anclas tipográficas, en capas distintas, por diseño**.
Esta sección documenta por qué y cómo se relacionan.
### Las dos capas
```
src/uix/eidos/lib/primitives/typography.ts
│
├── families / sizes / weights (escala numérica)
│ ↓
│ foundation tokens
│ --font-family-{primary,secondary,display,mono}
│ --font-size-{xxs..xxxl}
│ --font-weight-{regular,medium,semibold,bold}
│ --font-line-height-{xxs..xxxl}
│
└── styles (capa semántica)
↓
named-style tokens
--style-{hero,h1..h6,body,prose,label,caption,code}-{font-family,
font-size,line-height,letter-spacing,font-weight,color}
```
| Capa | Quién la consume | Para qué |
|---|---|---|
| **Foundation numerical** (`--font-size-*`, `--font-family-primary`, …) | recipe tokens en `lib/recipes/base.ts` + foundation aliases (`--font-ui`, `--leading-ui`, …) | Internals de componentes (Field labels, Combobox triggers, Button text, …) — necesitan **escalado t-shirt** (`xs/sm/md/lg/xl`) que NO mapea limpio a una semántica fija. |
| **Named styles** (`--style-label-*`, `--style-body-*`, `--style-caption-*`, `--style-h{1..6}-*`, `--style-{hero,prose,code}-*`) | typography primitives (`<Text>`, `<Heading>`, `<Display>`, `<Code>`, `<Link>`, …) | API de usuario para componer contenido — el USUARIO eligió "label" o "body" y quiere que ese rol semántico se respete. |
**Las dos capas no son redundantes**: sirven a contextos distintos. La
numérica vertebra el _interior_ del sistema; la semántica vertebra la
_superficie_ que el consumer compone.
### Cómo se vertebran sin duplicarse — la chain de aliases
Donde un valor coincide entre las dos capas, **el foundation alias lee
del named style, no al revés**. Single source of truth: el named style.
```css
/* generated/base.css (vía render-css.ts) */
:root {
/* Named style — fuente de verdad */
--style-label-font-family: var(--font-family-primary);
--style-label-line-height: 1.25;
/* Foundation alias — vertebra los recipes */
--font-ui: var(--style-label-font-family, var(--font-family-primary));
--leading-ui: var(--style-label-line-height, 1.25);
}
/* recipes/base.ts → generated/base.css */
:root {
--field-label-line-height: var(--leading-ui);
--field-control-line-height: var(--leading-ui);
/* … docenas de recipe tokens más */
}
/* components/field/field.css */
[data-field-label] {
line-height: var(--field-label-line-height);
}
```
Cambiar `STATIC_TYPOGRAPHY.styles.label.lineHeight = '1.3'` (en
`primitives/typography.ts`) propaga a `--style-label-line-height` →
`--leading-ui` → todos los recipes → todos los componentes. **Una sola
edición** llega a Field, Form, Combobox, Select, Toolbar, Toast y los
typography primitives simultáneamente.
El fallback `, 1.25` / `, var(--font-family-primary)` garantiza que el
sistema sigue produciendo CSS válido si el consumer apaga los named
styles en su foundation override.
### Por qué los component recipes NO leen `--style-{name}-*` directamente
Tentación recurrente: "cada componente debería leer `--style-label-font-size`
para que sea coherente". **No es la forma.**
1. **Las t-shirts no caben en cuatro buckets.** Un Field con `size="xs"`
tiene un label más pequeño que el "label canónico". Si su recipe leyera
`--style-label-font-size`, perderías ese escalado o tendrías que crear
`--style-label-{xs,sm,md,lg,xl}-*`, replicando lo que ya viven los
recipe tokens.
2. **Los matices por componente son legítimos.** El message-de-Field, el
description-de-Tooltip y el subtitle-de-Card son todos "caption-ish"
pero cada uno quiere su color/weight/letter-spacing propios. Forzarlos
a un único `--style-caption-*` mata expresividad.
3. **Coupling lock-in.** Día 100 el sistema quiere `label-form`,
`label-table`, `label-chart`. Forzar acoplamiento día 1 te lleva a
replicar la jerarquía recipe en la capa semántica.
4. **Cómo lo hacen las referencias.** Radix Themes, Mantine, MUI todos
tienen escala numérica que los componentes leen; la capa semántica
existe sólo para los typography primitives (`<Text variant="body2">`).
Chakra v3 ofrece `textStyle` acoplable pero la mayoría de sus
componentes hardcodean igualmente. **Acoplar todo a la capa semántica
no es la práctica dominante** y por buenas razones (1-3).
### Auditoría: regla `R-2.7`
`scripts/component-audit.ts` detecta literales tipográficos en CSS de
componentes: `font-size: 12px`, `line-height: 1.4`, `font-weight: 500`,
`letter-spacing: 0.02em` que **no** estén envueltos en `var()`. Severidad
`warn`, no `error` — el componente sigue pasando, pero queda visible en
el report.
**Escape valves** (no cuentan como drift):
- valores en `var(...)`
- ceros e identidades: `0`, `0px`, `0em`, `0rem`, `1`
- keywords: `inherit`, `initial`, `unset`
- comentario en línea: `font-size: 13px; /* literal: tight icon affordance */`
Si necesitas un literal con justificación, anótalo. Si no, tokenizalo.
## La regla "2-de-3" (heredada de morfo)
Una extensión a morfo se justifica si **al menos dos de las tres capas**

@ -596,7 +596,15 @@ function appendFontFamilyAliases(
if (families.primary) {
declarations.push(cssVar('font-sans', 'var(--font-family-primary)'))
declarations.push(cssVar('font-ui', 'var(--font-family-primary)'))
// `--font-ui` is the alias every recipe token leans on for
// "UI typeface" (Field labels, Combobox triggers, Toolbar
// buttons, …). Anchor it to the canonical `label` named style
// so changing the label's family propagates everywhere; fall
// back to the primary family if the foundation doesn't emit
// the named style.
declarations.push(
cssVar('font-ui', 'var(--style-label-font-family, var(--font-family-primary))')
)
}
if (families.secondary) {
@ -646,7 +654,14 @@ function appendTypographyAliases(
declarations.push(cssVar('font-weight-normal', 'var(--font-weight-regular)'))
}
declarations.push(cssVar('leading-ui', '1.25'))
// `--leading-ui` is the canonical leading every recipe token uses
// for short UI text (Field labels, captions, controls). Anchor it
// to the `label` named style so a designer changing
// `STATIC_TYPOGRAPHY.styles.label.lineHeight` propagates through
// `--style-label-line-height` → `--leading-ui` → every recipe.
// Fall back to the literal `1.25` when the named style isn't
// emitted (so unusual foundation overrides still produce valid CSS).
declarations.push(cssVar('leading-ui', 'var(--style-label-line-height, 1.25)'))
declarations.push(cssVar('leading-prose', '1.6'))
declarations.push(cssVar('leading-text', 'var(--leading-prose)'))
declarations.push(cssVar('leading-heading', '1.2'))

@ -186,6 +186,33 @@ the popover opens downward and would cover chips that sit below the
input. Do NOT inline chips inside the control — MUI Autocomplete's
inline pattern fights wrap behavior. (Decision from `20709caf`.)
### 4.11 No literal typography in recipes (`R-2.7`)
Component CSS recipes consume tokens, never literal values, for
`font-size` / `font-weight` / `line-height` / `letter-spacing` /
`font-family`. The audit script flags violations with `warn`
severity.
**Why:** the system vertebrates via the foundation token chain:
```
typography.ts styles → --style-{name}-* → --leading-ui / --font-ui
→ --field-*-* (recipe tokens)
→ component CSS
```
A literal in a recipe breaks the chain — changing the foundation
no longer propagates. See `src/uix/eidos/README.md` § "Vertebración
tipográfica" for the full architecture (why two anchors, why recipes
don't read `--style-{name}-*` directly, comparison vs Radix Themes /
Chakra / Mantine / MUI).
Allowed escape valves:
- `var(...)` wrapping the value
- numeric identities: `0`, `0px`, `0em`, `0rem`, `1`
- keywords: `inherit`, `initial`, `unset`
- explicit opt-out via trailing `/* literal: <reason> */` comment
## 4.11 Run the contract audit script
The repo ships `scripts/component-audit.ts` (alias `npm run

Loading…
Cancel
Save

Powered by TurnKey Linux.