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 | 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) thatTHEMING.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:
: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:
: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:
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:
- Infers
dependsby parsingvar(--{c}-XXX)out of the value. - Validates transitively:
solid-on-bg(scopehost) depends onpalette-solid(scopehostor more specific) — OK. - Emits each declaration under its selector:
host→[data-toggle],color:affirm→[data-toggle][data-color='affirm'], etc. - 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
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:
'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:
// recipes.select._accent-track
{
parts: ['trigger', 'content'],
declarations: [
{ value: 'var(--select-primary-track)', scope: 'host' },
{ value: 'var(--select-affirm-track)', scope: 'color:affirm' }
]
}
Generates:
[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:
// 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:
[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:
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(fromEidosConfig.breakpoints, threaded byActiveEidosfromActiveDom) — literal px, because CSS forbidsvar()in@containerconditions. - Container opt-in: an ancestor with
[data-container]activatescontainer-type: inline-size. stripCompositionKey(render-css) skipscompositionandcontainer;contract.tsexcludes them from the public token contract.- A themeable axis with 0 consumers today (an open cage). Detail:
THEMING.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-colorlives 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).