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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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