|
|
|
|
|
---
|
|
|
|
|
|
title: Token Scope Contract (TSC)
|
|
|
|
|
|
type: canon
|
|
|
|
|
|
audience: human + agent
|
|
|
|
|
|
authority: canonical — where every eidos token is allowed to be emitted
|
|
|
|
|
|
status: current
|
|
|
|
|
|
source: extracted from src/uix/eidos/THEMING.md (was §7 + §18)
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
# Token Scope Contract (TSC)
|
|
|
|
|
|
|
|
|
|
|
|
> El TSC decide DÓNDE se emite cada token de eidos (`:root` / `[data-{c}]` /
|
|
|
|
|
|
> `[data-{c}][data-color=...]` / ...) y valida la transitividad al generar. Es
|
|
|
|
|
|
> el contrato visual canónico (E2) que [`THEMING.md`](./THEMING.md) §7 referencia.
|
|
|
|
|
|
> Se extrajo de THEMING para vivir como su propio capítulo; la cobertura
|
|
|
|
|
|
> universal v2.2 (antes THEMING §18) se incluye al final.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
> Eidos does not infer token scope from emitted CSS. Token scope is
|
|
|
|
|
|
> part of the source contract. The generator emits CSS from scoped
|
|
|
|
|
|
> declarations and validates that every token dependency is available
|
|
|
|
|
|
> in the consumer scope.
|
|
|
|
|
|
|
|
|
|
|
|
TSC es la pieza arquitectónica que distingue a Eidos de Tailwind /
|
|
|
|
|
|
Radix / Chakra / Mantine / shadcn. Resuelve un problema sutil pero
|
|
|
|
|
|
crítico que ningún otro sistema cierra estructuralmente.
|
|
|
|
|
|
|
|
|
|
|
|
### El problema que resuelve
|
|
|
|
|
|
|
|
|
|
|
|
CSS custom property substitution es **eager**, no lazy:
|
|
|
|
|
|
|
|
|
|
|
|
```css
|
|
|
|
|
|
:root {
|
|
|
|
|
|
--base: black;
|
|
|
|
|
|
--derived: var(--base);
|
|
|
|
|
|
}
|
|
|
|
|
|
.x { --base: red; }
|
|
|
|
|
|
.y { background: var(--derived); }
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
¿Qué color tiene `.x.y`? **NEGRO**, no rojo. `--derived` se computa
|
|
|
|
|
|
en `:root` con `--base=black` y se hereda como `black`. El override
|
|
|
|
|
|
de `.x` sobre `--base` no afecta a `--derived` ya congelado.
|
|
|
|
|
|
|
|
|
|
|
|
Aplicado al Toggle pre-TSC:
|
|
|
|
|
|
|
|
|
|
|
|
```css
|
|
|
|
|
|
:root {
|
|
|
|
|
|
--toggle-palette-solid: var(--toggle-color-neutral-solid);
|
|
|
|
|
|
--toggle-solid-on-bg: var(--toggle-palette-solid); /* CONGELADO */
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-toggle][data-color='affirm'] {
|
|
|
|
|
|
--toggle-palette-solid: var(--toggle-color-affirm-solid); /* INÚTIL */
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`--toggle-solid-on-bg` quedaba congelado al neutral. El toggle con
|
|
|
|
|
|
`data-color='affirm'` mostraba gris en vez de teal. **Bug
|
|
|
|
|
|
arquitectónico** que ningún linter detectaría.
|
|
|
|
|
|
|
|
|
|
|
|
### Cómo TSC lo cierra
|
|
|
|
|
|
|
|
|
|
|
|
El config del recipe declara **dónde** se emite cada token:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
recipes.toggle = {
|
|
|
|
|
|
'palette-solid': {
|
|
|
|
|
|
declarations: [
|
|
|
|
|
|
{ value: 'var(--toggle-color-neutral-solid)', scope: 'host' },
|
|
|
|
|
|
{ value: 'var(--toggle-color-affirm-solid)', scope: 'color:affirm' },
|
|
|
|
|
|
{ value: 'var(--toggle-color-threat-solid)', scope: 'color:threat' }
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
'solid-on-bg': {
|
|
|
|
|
|
value: 'var(--toggle-palette-solid)',
|
|
|
|
|
|
scope: 'host' // ← obligatorio: dep está en 'host', no en 'root'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
El generador:
|
|
|
|
|
|
|
|
|
|
|
|
1. **Infiere `depends`** parseando `var(--{c}-XXX)` del value.
|
|
|
|
|
|
2. **Valida transitivamente**: `solid-on-bg` (scope `host`) depende
|
|
|
|
|
|
de `palette-solid` (scope `host` o más específico) — OK.
|
|
|
|
|
|
3. **Emite cada declaración bajo su selector**: `host` → `[data-toggle]`,
|
|
|
|
|
|
`color:affirm` → `[data-toggle][data-color='affirm']`, etc.
|
|
|
|
|
|
4. **Falla el build** si el scope del consumer no cubre el del dep.
|
|
|
|
|
|
|
|
|
|
|
|
### Scopes disponibles
|
|
|
|
|
|
|
|
|
|
|
|
| Scope | Selector generado | Cuándo usar |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `'root'` | `:root` | Token estable. Default para bare-string. |
|
|
|
|
|
|
| `'host'` | `[data-{c}]` | Token referencia `var(--{c}-palette-*)` u otro `host` token. |
|
|
|
|
|
|
| `color:${v}` | `[data-{c}][data-color='${v}']` | Override del palette por color value. |
|
|
|
|
|
|
| `variant:${v}` | `[data-{c}][data-variant='${v}']` | Cascada de variante. |
|
|
|
|
|
|
| `state:${v}` | `[data-{c}][data-state='${v}']` | Cascada de estado. |
|
|
|
|
|
|
| `size:${v}` | `[data-{c}][data-size='${v}']` | Cascada de tamaño. |
|
|
|
|
|
|
| `event:${v}` | `[data-{c}][data-event='${v}']` | Token de motion ligado a señal perceptual. |
|
|
|
|
|
|
| `[axis:v, …]` | `[data-{c}][data-X='v'][data-Y='w']` | **Composite** — múltiples condiciones ANDed. |
|
|
|
|
|
|
|
|
|
|
|
|
### Tres formas de declarar un token
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
recipes.toggle = {
|
|
|
|
|
|
// (1) Forma corta — scope 'root' implícito (token estable)
|
|
|
|
|
|
'height-md': '32px',
|
|
|
|
|
|
|
|
|
|
|
|
// (2) Forma simple — una declaración con scope explícito
|
|
|
|
|
|
// depends se infiere automáticamente de var() en el value
|
|
|
|
|
|
'solid-on-bg': {
|
|
|
|
|
|
value: 'var(--toggle-palette-solid)',
|
|
|
|
|
|
scope: 'host'
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// (3) Forma multi-declaración — el MISMO token bajo distintos scopes
|
|
|
|
|
|
// (la realidad CSS de un custom property redeclarado por cascada)
|
|
|
|
|
|
'palette-solid': {
|
|
|
|
|
|
declarations: [
|
|
|
|
|
|
{ value: 'var(--toggle-color-neutral-solid)', scope: 'host' },
|
|
|
|
|
|
{ value: 'var(--toggle-color-affirm-solid)', scope: 'color:affirm' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Álgebra de scope (`scopeCovers`)
|
|
|
|
|
|
|
|
|
|
|
|
No es un orden total simple. La regla es:
|
|
|
|
|
|
|
|
|
|
|
|
> `consumer scopeCovers dep` ⇔ todo elemento que matchea el consumer's
|
|
|
|
|
|
> scope también matchea el dep's scope.
|
|
|
|
|
|
|
|
|
|
|
|
Equivalentemente: las constraints del dep deben ser un **subconjunto**
|
|
|
|
|
|
de las constraints del consumer.
|
|
|
|
|
|
|
|
|
|
|
|
| consumer | dep | covers? | Razón |
|
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
| `host` | `root` | ✓ | host es más específico, root siempre aplica |
|
|
|
|
|
|
| `host` | `host` | ✓ | mismo scope |
|
|
|
|
|
|
| `host` | `color:affirm` | ✗ | consumer no constraint el color |
|
|
|
|
|
|
| `color:affirm` | `host` | ✓ | host cubre todo el host scope |
|
|
|
|
|
|
| `color:affirm` | `color:affirm` | ✓ | mismo scope |
|
|
|
|
|
|
| `color:affirm` | `color:loss` | ✗ | scopes incompatibles (diferentes values del mismo axis) |
|
|
|
|
|
|
| `color:affirm` | `size:lg` | ✗ | consumer no constraint el size |
|
|
|
|
|
|
| `[color:affirm, size:lg]` | `color:affirm` | ✓ | composite cubre cada componente |
|
|
|
|
|
|
| `[color:affirm, size:lg]` | `size:lg` | ✓ | igual |
|
|
|
|
|
|
|
|
|
|
|
|
### Cross-axis collision detection
|
|
|
|
|
|
|
|
|
|
|
|
Si un token tiene declaraciones en axes incomparables (e.g.
|
|
|
|
|
|
`color:affirm` y `state:on`), un elemento con ambos atributos matchea
|
|
|
|
|
|
ambos bloques. El cascade winner depende de orden de declaración —
|
|
|
|
|
|
silent correctness bug.
|
|
|
|
|
|
|
|
|
|
|
|
El generador detecta esto y **exige una declaración composite** que
|
|
|
|
|
|
desambigüe:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
'bg': {
|
|
|
|
|
|
declarations: [
|
|
|
|
|
|
{ value: 'red', scope: 'color:affirm' },
|
|
|
|
|
|
{ value: 'blue', scope: 'state:on' },
|
|
|
|
|
|
{ value: 'purple', scope: ['color:affirm', 'state:on'] } // ← obligatorio
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Sin la composite, build falla:
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
Eidos recipe scope contract violations:
|
|
|
|
|
|
- synth.bg: declarations at scopes color:affirm and state:on can both
|
|
|
|
|
|
apply to the same element. Add an explicit composite declaration
|
|
|
|
|
|
[color:affirm, state:on] to disambiguate cascade order.
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Multi-part scope — `parts: [...]` (TSC v2.2)
|
|
|
|
|
|
|
|
|
|
|
|
Cuando `data-color` (u otro axis TSC) NO vive en el root del componente
|
|
|
|
|
|
sino en parts específicos, el generador emite una regla con selector
|
|
|
|
|
|
comma-separado:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
// recipes.select._accent-track
|
|
|
|
|
|
{
|
|
|
|
|
|
parts: ['trigger', 'content'],
|
|
|
|
|
|
declarations: [
|
|
|
|
|
|
{ value: 'var(--select-primary-track)', scope: 'host' },
|
|
|
|
|
|
{ value: 'var(--select-affirm-track)', scope: 'color:affirm' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Genera:
|
|
|
|
|
|
|
|
|
|
|
|
```css
|
|
|
|
|
|
[data-select-trigger], [data-select-content] {
|
|
|
|
|
|
--_select-accent-track: var(--select-primary-track);
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-select-trigger][data-color='affirm'], [data-select-content][data-color='affirm'] {
|
|
|
|
|
|
--_select-accent-track: var(--select-affirm-track);
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Cuándo usarlo**: el componente porta `data-color` per-part (típicamente
|
|
|
|
|
|
porque un part viaja por portal y se renderiza fuera del árbol DOM del
|
|
|
|
|
|
otro). Single-part components siguen sin necesitar `parts` — el default
|
|
|
|
|
|
`[data-{c}]` es lo correcto.
|
|
|
|
|
|
|
|
|
|
|
|
**Quién lo usa hoy**: `select` (trigger + content) — único caso real
|
|
|
|
|
|
en el catálogo. Los demás componentes con `data-color` lo declaran en
|
|
|
|
|
|
el root.
|
|
|
|
|
|
|
|
|
|
|
|
### Cross-recipe composition — `composition: { ... }` (TSC v2.2)
|
|
|
|
|
|
|
|
|
|
|
|
Cuando un recipe necesita modificar tokens de OTRO recipe scoped a su
|
|
|
|
|
|
propio cascade, declara un bloque `composition` sibling de los tokens
|
|
|
|
|
|
regulares:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
// recipes.toggle-group
|
|
|
|
|
|
{
|
|
|
|
|
|
gap: 'var(--space-1)',
|
|
|
|
|
|
composition: {
|
|
|
|
|
|
toggle: { // foreign recipe name
|
|
|
|
|
|
targetSelector: '[data-toggle-group-item]', // descendant selector
|
|
|
|
|
|
tokens: {
|
|
|
|
|
|
'palette-solid': {
|
|
|
|
|
|
declarations: [
|
|
|
|
|
|
{ value: 'var(--toggle-affirm-solid)', scope: 'color:affirm' },
|
|
|
|
|
|
{ value: 'var(--toggle-risk-solid)', scope: 'color:risk' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Genera:
|
|
|
|
|
|
|
|
|
|
|
|
```css
|
|
|
|
|
|
[data-toggle-group][data-color='affirm'] [data-toggle-group-item] {
|
|
|
|
|
|
--toggle-palette-solid: var(--toggle-affirm-solid);
|
|
|
|
|
|
}
|
|
|
|
|
|
[data-toggle-group][data-color='risk'] [data-toggle-group-item] {
|
|
|
|
|
|
--toggle-palette-solid: var(--toggle-risk-solid);
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Reglas:
|
|
|
|
|
|
- El CSS variable name se deriva del recipe FORÁNEO
|
|
|
|
|
|
(`--toggle-palette-solid`), no del host. Para tokens privados del
|
|
|
|
|
|
foreign use `_palette-solid` → `--_toggle-palette-solid`.
|
|
|
|
|
|
- El selector es `{host's scope-rule} {targetSelector}` — combinación
|
|
|
|
|
|
ancestor + descendant.
|
|
|
|
|
|
- Las composition declarations DEBEN tener scope ≠ `'root'`. Un
|
|
|
|
|
|
override no-scoped pertenece al foreign recipe, no al composition
|
|
|
|
|
|
block. El validador rechaza root-scoped composition entries.
|
|
|
|
|
|
- Composition NO se valida con el algebra de scope del host (las
|
|
|
|
|
|
composition entries modifican TOKENS del foreign, no del host), pero
|
|
|
|
|
|
sí pasa por el mismo pipeline de validación general
|
|
|
|
|
|
(`validateRecipeComposition`).
|
|
|
|
|
|
|
|
|
|
|
|
**Quién lo usa hoy**: `toggle-group` (modifica `--toggle-palette-*` en
|
|
|
|
|
|
sus items). Pattern reutilizable para futuros wrappers compositivos
|
|
|
|
|
|
(button-group, nav-menu).
|
|
|
|
|
|
|
feat(eidos): canonize theming scales + tokenize magic numbers
Audit-driven canonization of the eidos theming layer: a canonical scale that
components bypass with literals drifts into N variants. Every axis is now
retunable-by-theme AND consumed via token, never a bare literal.
Bloque A/B (scales): --blur-* (Tailwind-aligned, depth planes consume it);
inner-shadow --shadow-inset-* (mode-aware) for the recessed plane; gradient
angle/named tokens; --breakpoint-* sourced from ActiveDom (runtime, dev-settable)
+ container queries; opacity dual numeric+semantic scale; border-width
none/thin/medium/thick/heavy (adds real 3px); tracking caps/widest. Fase 7:
3 size archetypes documented + coherence guard (no px/rem in recipe
font-size/icon-size).
Bloque C (magic numbers): z-index 80/50/99 (combobox/nav-menu/drag-drop) ->
recipe content-z/preview-z tokens (overlay micro-band soma mirrors); on-scale
durations -> var(--duration-*) (dialog fast/slow, card slow); 12 single
border/ring widths -> var(--border-width-*), value-preserving. Off-scale kept
only as justified recipe tokens (continuous spinner/loading periods, sub-fast
press). Self-contained dimensional scales (avatar ring, ring-thickness) left whole.
Docs: THEMING.md SS35 + SS6 + SS29, THEMING_NOTES, README, TSC. Excludes
words/palabras/chronos (active dev tracks).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 months ago
|
|
|
|
### Container queries — `container: { ... }` (2026-06-15)
|
|
|
|
|
|
|
|
|
|
|
|
Segunda key reservada sibling de los tokens (como `composition`):
|
|
|
|
|
|
overrides de token por breakpoint, emitidos dentro de `@container`:
|
|
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
|
recipes: { card: { container: { md: { 'pad': 'var(--space-6)' } } } }
|
|
|
|
|
|
// → @container (min-width: 768px) { [data-card] { --card-pad: var(--space-6) } }
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
- Usa los **mismos breakpoints configurados** que `@media` (de
|
|
|
|
|
|
`EidosConfig.breakpoints`, threaded por `ActiveEidos` desde `ActiveDom`)
|
|
|
|
|
|
— px **literal**, porque CSS prohíbe `var()` en condiciones `@container`.
|
|
|
|
|
|
- Opt-in del contenedor: un ancestro con `[data-container]` activa
|
|
|
|
|
|
`container-type: inline-size`.
|
|
|
|
|
|
- `stripCompositionKey` (render-css) salta `composition` **y** `container`;
|
|
|
|
|
|
`contract.ts` los excluye del contrato público de tokens.
|
|
|
|
|
|
- Eje themeable, 0 consumidores hoy (jaula abierta). Detalle: THEMING.md §35.
|
|
|
|
|
|
|
|
|
|
|
|
### Pipeline de defensas (5 capas)
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
1. tsc --noEmit ← TS bien tipado
|
|
|
|
|
|
2. TSC scope algebra ← ningún token depende de scope más dinámico
|
|
|
|
|
|
3. TSC cross-axis check ← composites obligatorios donde hay collision
|
|
|
|
|
|
4. eidos-lint ← defensa secundaria del CSS generado
|
|
|
|
|
|
5. runtime probe ← confirma comportamiento real en browser
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Cobertura universal de TSC (v2.2)
|
|
|
|
|
|
|
|
|
|
|
|
**Los 15 componentes con `data-color` están en TSC**. No hay
|
|
|
|
|
|
excepciones arquitectónicas — TSC v2.2 cubre las 3 patrones que
|
|
|
|
|
|
antes vivían fuera del modelo:
|
|
|
|
|
|
|
|
|
|
|
|
| Patrón | Solución TSC v2.2 | Componentes |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `data-color` per-parte (no en root) | `parts: ['x', 'y']` en `RecipeTokenMultiDeclaration` (multi-part scope) | `select` (trigger + content) |
|
|
|
|
|
|
| Composite (variant × color) | `scope: ['variant:X', 'color:Y']` (TSC v2 composite) | `avatar` (root + badge) |
|
|
|
|
|
|
| Cross-recipe override desde ancestor | `composition: { foreignRecipe: { targetSelector, tokens } }` | `toggle-group` (modifica Toggle's palette) |
|
|
|
|
|
|
|
|
|
|
|
|
El guard universal `forbids palette-derived tokens at :root scope`
|
|
|
|
|
|
(en `recipe-css-contract.test.ts`) sigue activo como defensa
|
|
|
|
|
|
secundaria en el CSS final, pero la fuente de verdad es el contrato
|
|
|
|
|
|
de tipos.
|
|
|
|
|
|
|
|
|
|
|
|
### Cuándo se añadió cada extensión
|
|
|
|
|
|
|
|
|
|
|
|
- **Multi-part scope** (TSC v2.2): permite que un token cascadee sobre
|
|
|
|
|
|
más de un selector raíz. Necesario cuando `data-color` vive en parts
|
|
|
|
|
|
distintos por razones de portal/cascade (Select Content vive fuera
|
|
|
|
|
|
del árbol DOM del Trigger).
|
|
|
|
|
|
- **Composition** (TSC v2.2): permite que un recipe declare overrides
|
|
|
|
|
|
de los tokens de OTRO recipe, scoped a sus propias condiciones.
|
|
|
|
|
|
Necesario para wrappers compositivos (toggle-group, eventual
|
|
|
|
|
|
button-group, nav-menu, etc.).
|
|
|
|
|
|
|
|
|
|
|
|
Ambas extensiones se validan con el mismo pipeline TSC (scope
|
|
|
|
|
|
algebra + cross-axis collision detection + auto-inferred deps).
|
|
|
|
|
|
|
|
|
|
|
|
---
|