--- 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). ### 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). ---