You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/TSC.md

335 lines
12 KiB

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

Powered by TurnKey Linux.