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

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) that THEMING.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:

  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

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