|
|
|
|
|
---
|
|
|
|
|
|
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
|
docs(book): F7.3 (9/9) — THEMING reference translated; theming/ + canon/ batch COMPLETE
src/uix/eidos/THEMING.md (1499 L, Spanish) translated to English as
docs/theming/reference.md, same s1-s38 numbering: the mental model,
s1.bis (theming lives in eidos — the 2-of-3 derivation and the canonical
split), the CSS layers, the 7 token layers, the 9 roles, the size canon
(universal 1:1 + label step-down + container cap + the icon scale), the
naming conventions, the s7/s8/s9/s13/s15/s17/s18 stubs repointed into the
book (canon/tsc, theming/guide, theming/notes, theming/changelog,
theming/motion), runtime overrides + builders, bundle/purge, validation
tooling, s14 motion (the pickup lift), s16 anti-patterns, s19 variants
canon, and the s20-s38 standing-decision stubs. One internal
reconciliation applied: the s35 stub cited the control/compact/dense size
archetypes that s5 declares superseded by the universal 1:1 — aligned.
One stale v1 DEMO_AUTHORING s12.8 citation in s5 replaced by the v2 s6
parity rule. Stub at the old path carries the full s1-s38 map (the most
sN-cited doc in the repo: code comments, CLAUDE.md, RFCs). Corpus links
swept (CANON, docs map, comparison, glossary, eidos chapter, canon/tsc,
theming/guide + notes). docs:check 0 errors (260 docs).
F7.3 complete: docs/canon/ (tsc, recipe-contract) + docs/theming/
(reference, guide, notes, channels, motion, motion-guide, changelog).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
|
|
|
|
> [`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:
|
docs(book): F7.3 (9/9) — THEMING reference translated; theming/ + canon/ batch COMPLETE
src/uix/eidos/THEMING.md (1499 L, Spanish) translated to English as
docs/theming/reference.md, same s1-s38 numbering: the mental model,
s1.bis (theming lives in eidos — the 2-of-3 derivation and the canonical
split), the CSS layers, the 7 token layers, the 9 roles, the size canon
(universal 1:1 + label step-down + container cap + the icon scale), the
naming conventions, the s7/s8/s9/s13/s15/s17/s18 stubs repointed into the
book (canon/tsc, theming/guide, theming/notes, theming/changelog,
theming/motion), runtime overrides + builders, bundle/purge, validation
tooling, s14 motion (the pickup lift), s16 anti-patterns, s19 variants
canon, and the s20-s38 standing-decision stubs. One internal
reconciliation applied: the s35 stub cited the control/compact/dense size
archetypes that s5 declares superseded by the universal 1:1 — aligned.
One stale v1 DEMO_AUTHORING s12.8 citation in s5 replaced by the v2 s6
parity rule. Stub at the old path carries the full s1-s38 map (the most
sN-cited doc in the repo: code comments, CLAUDE.md, RFCs). Corpus links
swept (CANON, docs map, comparison, glossary, eidos chapter, canon/tsc,
theming/guide + notes). docs:check 0 errors (260 docs).
F7.3 complete: docs/canon/ (tsc, recipe-contract) + docs/theming/
(reference, guide, notes, channels, motion, motion-guide, changelog).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 months ago
|
|
|
|
[`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).
|