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/docs/canon/tsc.md

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

Powered by TurnKey Linux.