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

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) que 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:

: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:

  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

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