12 KiB
| title | type | audience | authority | status | source |
|---|---|---|---|---|---|
| Token Scope Contract (TSC) | canon | human + agent | canonical — where every eidos token is allowed to be emitted | current | 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) queTHEMING.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:
: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:
: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:
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:
- Infiere
dependsparseandovar(--{c}-XXX)del value. - Valida transitivamente:
solid-on-bg(scopehost) depende depalette-solid(scopehosto más específico) — OK. - Emite cada declaración bajo su selector:
host→[data-toggle],color:affirm→[data-toggle][data-color='affirm'], etc. - 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
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:
'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:
// recipes.select._accent-track
{
parts: ['trigger', 'content'],
declarations: [
{ value: 'var(--select-primary-track)', scope: 'host' },
{ value: 'var(--select-affirm-track)', scope: 'color:affirm' }
]
}
Genera:
[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:
// 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:
[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).
Container queries — container: { ... } (2026-06-15)
Segunda key reservada sibling de los tokens (como composition):
overrides de token por breakpoint, emitidos dentro de @container:
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(deEidosConfig.breakpoints, threaded porActiveEidosdesdeActiveDom) — px literal, porque CSS prohíbevar()en condiciones@container. - Opt-in del contenedor: un ancestro con
[data-container]activacontainer-type: inline-size. stripCompositionKey(render-css) saltacompositionycontainer;contract.tslos 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-colorvive 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).