--- title: Token Scope Contract (TSC) type: canon audience: human + agent authority: canonical — where every eidos token is allowed to be emitted status: current source: migrated from src/uix/eidos/TSC.md (2026-07-02, docs-book F7.3; originally extracted from THEMING §7 + §18) --- # Token Scope Contract (TSC) > The TSC decides WHERE every eidos token is emitted (`:root` / `[data-{c}]` / > `[data-{c}][data-color=...]` / …) and validates transitivity at generation > time. It is the canonical visual contract (E2) that > [`THEMING.md`](../theming/reference.md) §7 references. It was > extracted from THEMING to live as its own chapter; universal coverage v2.2 > (formerly THEMING §18) is included at the end. --- > 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. The TSC is the architectural piece that distinguishes Eidos from Tailwind / Radix / Chakra / Mantine / shadcn. It closes a subtle but critical problem no other system closes structurally. ### The problem it solves CSS custom-property substitution is **eager**, not lazy: ```css :root { --base: black; --derived: var(--base); } .x { --base: red; } .y { background: var(--derived); } ``` What color is `.x.y`? **BLACK**, not red. `--derived` is computed at `:root` with `--base=black` and inherited as `black`. `.x`'s override of `--base` does not affect the already-frozen `--derived`. Applied to the pre-TSC Toggle: ```css :root { --toggle-palette-solid: var(--toggle-color-neutral-solid); --toggle-solid-on-bg: var(--toggle-palette-solid); /* FROZEN */ } [data-toggle][data-color='affirm'] { --toggle-palette-solid: var(--toggle-color-affirm-solid); /* USELESS */ } ``` `--toggle-solid-on-bg` stayed frozen at neutral. A toggle with `data-color='affirm'` showed gray instead of teal. An **architectural bug** no linter would catch. ### How the TSC closes it The recipe config declares **where** each token is emitted: ```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' // ← mandatory: the dep lives in 'host', not in 'root' } } ``` The generator: 1. **Infers `depends`** by parsing `var(--{c}-XXX)` out of the value. 2. **Validates transitively**: `solid-on-bg` (scope `host`) depends on `palette-solid` (scope `host` or more specific) — OK. 3. **Emits each declaration under its selector**: `host` → `[data-toggle]`, `color:affirm` → `[data-toggle][data-color='affirm']`, etc. 4. **Fails the build** if the consumer's scope doesn't cover the dep's. ### Available scopes | Scope | Generated selector | When to use | |---|---|---| | `'root'` | `:root` | A stable token. The default for bare strings. | | `'host'` | `[data-{c}]` | The token references `var(--{c}-palette-*)` or another `host` token. | | `color:${v}` | `[data-{c}][data-color='${v}']` | Palette override per color value. | | `variant:${v}` | `[data-{c}][data-variant='${v}']` | Variant cascade. | | `state:${v}` | `[data-{c}][data-state='${v}']` | State cascade. | | `size:${v}` | `[data-{c}][data-size='${v}']` | Size cascade. | | `event:${v}` | `[data-{c}][data-event='${v}']` | Motion token bound to a perceptual signal. | | `[axis:v, …]` | `[data-{c}][data-X='v'][data-Y='w']` | **Composite** — multiple ANDed conditions. | ### Three ways to declare a token ```ts recipes.toggle = { // (1) Short form — implicit 'root' scope (a stable token) 'height-md': '32px', // (2) Simple form — one declaration with an explicit scope // depends is auto-inferred from var() in the value 'solid-on-bg': { value: 'var(--toggle-palette-solid)', scope: 'host' }, // (3) Multi-declaration form — the SAME token under different scopes // (the CSS reality of a custom property redeclared by cascade) 'palette-solid': { declarations: [ { value: 'var(--toggle-color-neutral-solid)', scope: 'host' }, { value: 'var(--toggle-color-affirm-solid)', scope: 'color:affirm' } ] } }; ``` ### Scope algebra (`scopeCovers`) It is not a simple total order. The rule is: > `consumer scopeCovers dep` ⇔ every element matching the consumer's scope > also matches the dep's scope. Equivalently: the dep's constraints must be a **subset** of the consumer's constraints. | consumer | dep | covers? | Reason | |---|---|---|---| | `host` | `root` | ✓ | host is more specific; root always applies | | `host` | `host` | ✓ | same scope | | `host` | `color:affirm` | ✗ | the consumer doesn't constrain color | | `color:affirm` | `host` | ✓ | host covers the whole host scope | | `color:affirm` | `color:affirm` | ✓ | same scope | | `color:affirm` | `color:loss` | ✗ | incompatible scopes (different values of the same axis) | | `color:affirm` | `size:lg` | ✗ | the consumer doesn't constrain size | | `[color:affirm, size:lg]` | `color:affirm` | ✓ | the composite covers each component | | `[color:affirm, size:lg]` | `size:lg` | ✓ | same | ### Cross-axis collision detection If a token has declarations on incomparable axes (e.g. `color:affirm` and `state:on`), an element carrying both attributes matches both blocks. The cascade winner then depends on declaration order — a silent correctness bug. The generator detects this and **requires a composite declaration** to disambiguate: ```ts 'bg': { declarations: [ { value: 'red', scope: 'color:affirm' }, { value: 'blue', scope: 'state:on' }, { value: 'purple', scope: ['color:affirm', 'state:on'] } // ← mandatory ] } ``` Without the composite, the build fails: ``` 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) When `data-color` (or another TSC axis) does NOT live on the component root but on specific parts, the generator emits a rule with a comma-separated selector: ```ts // recipes.select._accent-track { parts: ['trigger', 'content'], declarations: [ { value: 'var(--select-primary-track)', scope: 'host' }, { value: 'var(--select-affirm-track)', scope: 'color:affirm' } ] } ``` Generates: ```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); } ``` **When to use it**: the component carries `data-color` per-part (typically because one part travels through a portal and renders outside the other's DOM tree). Single-part components still don't need `parts` — the default `[data-{c}]` is correct. **Who uses it today**: `select` (trigger + content) — the only real case in the catalog. Other `data-color` components declare it on the root. ### Cross-recipe composition — `composition: { ... }` (TSC v2.2) When a recipe needs to modify ANOTHER recipe's tokens scoped to its own cascade, it declares a `composition` block sibling to the regular tokens: ```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' } ] } } } } } ``` Generates: ```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); } ``` Rules: - The CSS variable name derives from the FOREIGN recipe (`--toggle-palette-solid`), not from the host. For the foreign's private tokens use `_palette-solid` → `--_toggle-palette-solid`. - The selector is `{host's scope-rule} {targetSelector}` — an ancestor + descendant combination. - Composition declarations MUST have a scope ≠ `'root'`. A non-scoped override belongs to the foreign recipe, not to the composition block. The validator rejects root-scoped composition entries. - Composition is NOT validated by the host's scope algebra (composition entries modify the FOREIGN's tokens, not the host's), but it goes through the same general validation pipeline (`validateRecipeComposition`). **Who uses it today**: `toggle-group` (modifies `--toggle-palette-*` on its items). A reusable pattern for future compositional wrappers (button-group, nav-menu). ### Container queries — `container: { ... }` The second reserved key sibling to the tokens (like `composition`): per-breakpoint token overrides, emitted inside `@container`: ```ts recipes: { card: { container: { md: { 'pad': 'var(--space-6)' } } } } // → @container (min-width: 768px) { [data-card] { --card-pad: var(--space-6) } } ``` - Uses the **same configured breakpoints** as `@media` (from `EidosConfig.breakpoints`, threaded by `ActiveEidos` from `ActiveDom`) — **literal** px, because CSS forbids `var()` in `@container` conditions. - Container opt-in: an ancestor with `[data-container]` activates `container-type: inline-size`. - `stripCompositionKey` (render-css) skips `composition` **and** `container`; `contract.ts` excludes them from the public token contract. - A themeable axis with 0 consumers today (an open cage). Detail: [`THEMING.md`](../theming/reference.md) §35. ### The defense pipeline (5 layers) ``` 1. tsc --noEmit ← well-typed TS 2. TSC scope algebra ← no token depends on a more dynamic scope 3. TSC cross-axis check ← composites required where collisions exist 4. eidos-lint ← secondary defense over the generated CSS 5. runtime probe ← confirms real behavior in the browser ``` --- ## Universal TSC coverage (v2.2) **Every `data-color` component is inside the TSC**. There are no architectural exceptions — TSC v2.2 covers the 3 patterns that previously lived outside the model: | Pattern | TSC v2.2 solution | Components | |---|---|---| | `data-color` per-part (not on root) | `parts: ['x', 'y']` on `RecipeTokenMultiDeclaration` (multi-part scope) | `select` (trigger + content) | | Composite (variant × color) | `scope: ['variant:X', 'color:Y']` (TSC v2 composite) | `avatar` (root + badge) | | Cross-recipe override from an ancestor | `composition: { foreignRecipe: { targetSelector, tokens } }` | `toggle-group` (modifies Toggle's palette) | The universal guard `forbids palette-derived tokens at :root scope` (in `recipe-css-contract.test.ts`) stays active as a secondary defense over the final CSS, but the source of truth is the typed contract. ### When each extension was added - **Multi-part scope** (TSC v2.2): lets a token cascade over more than one root selector. Needed when `data-color` lives on different parts for portal/cascade reasons (Select's Content lives outside the Trigger's DOM tree). - **Composition** (TSC v2.2): lets a recipe declare overrides of ANOTHER recipe's tokens, scoped to its own conditions. Needed for compositional wrappers (toggle-group; eventually button-group, nav-menu, etc.). Both extensions are validated by the same TSC pipeline (scope algebra + cross-axis collision detection + auto-inferred deps).