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/recipe-contract.md

24 KiB

title type audience authority status enforcement source related
Recipe Contract — the transversal contract of every eidos recipe canon human + agent canonical — which theming systems every recipe MUST consume, and under which names current scripts/component-audit.ts (rules R-4.x, all `error`) migrated from src/uix/eidos/RECIPE_CONTRACT.md (2026-07-02, docs-book F7.3)
tsc theming guide
docs/canon/tsc.md (where each token is emitted) docs/theming/reference.md (the system; §5 sizes · §6 naming) docs/theming/guide.md (how to add a component, step by step)

Recipe Contract

The TSC decides where each token is emitted. This contract decides what every recipe consumes: the theming's transversal systems (state-layer, focus, elevation, typography, opacity, motion, spacing, touch, lists, z) and the vocabulary of its dimensional tokens. A recipe deviating without an annotated exception is out of contract — the component-audit (R-4.x) flags it.

Why it exists: the 2026-07-01 audit (fable-eidos-audit.md) measured that canons defended by a mechanical guard drift ~0% while canons defended by prose drift 30–85% (state-layer 21/135, partial depth-channel adoption, 49 keyframes outside the channel). This document is the canon; the R-4.x rules are its executable form.


1. A recipe's skeleton — dimensional tokens

Every recipe declares its knobs in lib/recipes/base.ts with this vocabulary. The names are canon, just like the color slots are in THEMING §6:

Concept Canonical name Forbidden
Control height per size control-height-{size} (or {part}-height-{size} when the part is not the control) inventing a third name per component
Padding per axis padding-inline[-{size}] / padding-block[-{size}] padding-x / padding-y AND the abbreviated segments px / py (physical axes)
Margin per axis margin-inline[-{size}] / margin-block[-{size}] margin-x/-y, mx / my
Internal separation gap[-{size}] —
Radius radius[-{size}] → var(--radius-*) px that equal a step of the scale
Control font font-size-{size} = var(--font-size-{size}) (1:1, THEMING §5) literal px/rem (guarded in recipe-css-contract)
Icon icon-size-{size} = var(--icon-size-{size}) hand px; size from --control-height-* unless the element IS a control
Field label one typographic step below the input (THEMING §5) —
Ink (foreground) [{part}-]fg color as a slot — trigger-color, -color-hover (D-TH.6, 2026-08-20)
Interactive state {modifier}-{part-}{slot} — the modifier IN FRONT (hover-bg, disabled-trigger-fg) the modifier behind (bg-hover, trigger-color-active)

Where the modifier goes — one sentence: in front what is interactive, behind what is dimensional or contextual (D-TH.6, signed 2026-08-20). hover-bg and disabled-trigger-fg, but control-height-md, gap-vertical, indicator-bg-below and width-icon: a size, an orientation, a semantic range or a collapsed mode are not states of the control, and promoting them rewrites what the name MEANS (--sidebar-width-icon, the width of the icon-collapsed rail, is not --sidebar-icon-width, the width of an icon). The interactive vocabulary is closed: hover · active · selected · disabled · checked · open · focus · invalid · current.

Two families keep a grammar of their own and R-5.3 knows it:

  • role / palette — {part-}?{role|palette}-{slot} where the slot comes from COLOR_ROLE_SLOTS, which carries the modifier BEHIND by construction (primary-solid-hover, palette-hover). 447 keys; they are canon, not drift.
  • system — --state-*, --opacity-*, --focus-ring-* are consumed, never minted by a recipe (§2), so this grammar does not reach them. A recipe key that MIRRORS the system family keeps its spelling: trigger-focus-ring-color is the signed exception, along with colour as a NOUN (orb-color-*) and stop-color-*, where stop-color is a part name.

Inherited naming rules (THEMING §6): public --{c}-{slot}, private --_{c}-{slot}, no color- segment, no kebab abbreviation, no --eidos-/-soma-/-air-.

Normalización ejecutada (2026-08-20, D-TH.6 — «un idioma, todo el ecosistema», segunda parte). El catálogo hablaba dos idiomas para la tinta: 196 claves -color contra 55 fg, y el modificador caía a los dos lados (hover-bg ×13 contra bg-hover ×22). El origen era una CONTRADICCIÓN entre dos doctrinas firmadas: THEMING §6.7 r7 dice fg; el principio de plataforma de la normalización px/py («el token se llama como la propiedad») dice color. El autor adjudicó fg acotando el principio: gobierna los ejes DIMENSIONALES, no la pareja bg/fg — si la gobernara, bg tendría que llamarse background-color. Codemod value-preserving: 269 claves + 793 referencias en 101 ficheros, diff de generated/ = renombres 1:1 con cero cambios de valor, censo de alcance idéntico (162 · 5.203 · 1.841 · 37 %), computed idéntico en navegador (6.467 valores, 23 estados, 3 componentes). No entraron: las 447 role-slot (canónicas), las 13 exentas firmadas, y 47 hovers neutros que MIGRAN a la capa de estado (§38 + R-4.3) en vez de renombrarse — renombrar lo condenado es churn. El muro de tipos de defineRecipes cierra la gramática cuando esa migración termine.

Value-reference rules (2026-07-07, theming audit B — both guarded in recipe-css-contract.test.ts):

  • A recipe token VALUE may reference only vocabulary that exists in the emitted contract — a no-fallback var(--x) pointing at a name the generators never emit is a phantom (it freezes the token guaranteed-invalid; --color-content-tertiary shipped a month that way). var(--x, fallback) is runtime-optional by construction.
  • A value referencing a component private (--_{c}-*, declared in the component's CSS) must declare a scope that covers it (host / leaf) — never root: a :root-emitted token computes where the private does not exist and freezes invalid down the whole tree (the calendar holiday/event marks and the field segment heights shipped broken exactly this way).

Normalización ejecutada (2026-07-06, decisión usuario: "un idioma, todo el ecosistema"). El catálogo hablaba dos idiomas: 32 claves canónicas conviviendo con 198 claves abreviadas p[xy] (+3 my) que evadían el regex de R-4.4 — hábito Tailwind/Chakra sin decisión que lo respaldara. El nombre canónico es el de la PLATAFORMA (CSS Logical Properties): el token se llama como la propiedad que alimenta (nombre = contrato, sin capa de traducción — a diferencia de Panda, donde px es azúcar que un compilador resuelve), es direccional-agnóstico, y en un contrato de tokens px además es un pun con la unidad. Codemod atómico value-preserving: 198+3 claves + ~570 nombres en 57 ficheros (tracks excluidos words/palabras/chronos INCLUIDOS por decisión explícita) + los 20 shorthands físicos (padding: var(--py) var(--px)) reescritos a longhands lógicos (el único subconjunto con semántica física real — modos de escritura verticales). Triple muro: R-4.4 con el regex ampliado a segmentos abreviados (error, sin allowlist — la deuda murió en el mismo pass) · muro de tipos defineRecipes (lib/recipes/define.ts): una clave de eje físico no compila · la suite/regen verifican (diff de generated = 230 renombres 1:1; computed padding idéntico en navegador).

2. Mandatory transversal systems

The table IS the contract: concept → canonical mechanism → the rule that guards it. A recipe reimplements none of these concepts on its own.

Concept Canonical mechanism How it is consumed Rule
Neutral hover / press / selected the state-layer (rules in archetypes.css; magnitudes are config data primitives.state — changelog §40) transparent background → background: var(--state-hover); filled background → the overlay background-image: linear-gradient(var(--state-hover), var(--state-hover)) R-4.3
Valenced hover (solid / soft per color) the recipe's palette swap background: var(--{c}-solid-hover) etc. — tokens, never hand-rolled color-mix(currentColor …) R-4.3
Elevation the scale + the depth channel box-shadow: var(--shadow-*) or compose var(--depth-{plane}-shadow), var(--depth-{plane}-halo); never literal shadows R-4.1
Crisp inner ring the inset-ring (THEMING §29) box-shadow: inset 0 0 0 var(--ring-inset-width) var(--ring-inset-color, …) — the expression lives at the point of use R-4.1 (passes: it carries var())
Focus per archetype (THEMING §32) fields → the two-ring --focus-ring-* (box-shadow); surfaces/controls → their own outline (HCM-safe); declaring nothing falls back to archetypes.css R-1.5
Disabled the opacity token opacity: var(--opacity-disabled) + cursor: not-allowed — never hand 0.4/0.5/0.6 R-4.2
Typography 1:1 with the scale see §1; canonical exceptions: avatar/marker (glyph ∝ diameter), accordion (prose scale) R-2.7 + guard
Motion — state transitions tokens transition: X var(--duration-*) var(--ease-*) THEMING §35 block C
Motion — event signatures EidosConfig.motion.{keyframes,signatures} (generated, themeable) NO local @keyframes for perceptual reactions; a local @keyframes only when functional (spin/shimmer/continuous period) and annotated R-4.5
Spacing var(--space-*) density × scaling arrive composed from the foundation — never multiply by --density-*/--scaling in the recipe R-2.3
Touch target archetype (trigger/close/action) + list-surface free via @media (pointer: coarse) in archetypes.css — don't declare your own 44px minimums —
List/menu rows the list-surface (--list-item-*) list surfaces bridge --list-item-height/-py, not their own row heights —
Floating z-index the --z-index-overlay-* band each PORTALED overlay consumes its rung; raw integers 0..5 only for intra-component order guard in contracts.test
Viewport-fixed chrome (not portaled) the --z-index-* ladder, affix rung a consent strip / FAB / floating bar is page chrome, not an overlay: it rides affix (150) — above sticky, below every menu. Two elements in the SAME band tie, and DOM order breaks the tie (measured: a notice correctly first in the source lost to a sticky header, 30.7px of overlap) THEME-SYS-1 + layer:check
Color role slots / recipe tokens var(--color-{role}-{slot}) or var(--{c}-*); never direct --scale-*/--primitive-* nor hex/rgb R-2.1/2.6, R-4.6

3. Exceptions — how they are declared

A deviation is valid only when annotated on the same line. The audit honors annotations; an unannotated deviation is drift.

Annotation When Example
/* literal: <reason> */ a physically-fixed value or a justified optical tuning font-size: 13px; /* literal: tight icon affordance */
/* functional: <reason> */ a local @keyframes that is NOT a perceptual signature (continuous period, machinery) /* functional: continuous spin period, not an event signature */ @keyframes spin { … }
physically-fixed colors that must not follow the theme (the QR's white, the natural clock's skies) — hex allowed WITH a comment --_qr-bg: #ffffff; /* literal: QR quiet zone must be true white */
/* important: <reason> */ an !important that must win EVERY cascade fight — the three legitimate shapes: bypassing a soma inline style, cross-recipe fusion (the group's radius flattening), reduced-motion kills cursor: nwse-resize !important; /* important: wins over the header's inherited grab cursor */

What does not pass as an exception: "it's faster this way", "the token didn't exist" (create it), "it's just a hover" (that is exactly the contract's case).

4. Enforcement

Rule Guards Severity
R-4.1 box-shadow without var( (elevation/ring outside tokens) error
R-4.2 literal opacity: 0<N<1 outside @keyframes error
R-4.3 background* in :hover rules without a token (var() or with manual color-mix(… currentColor …) error
R-4.4 recipe tokens with physical axes padding-x/-y, margin-x/-y error
R-4.5 a local @keyframes without a /* functional: … */ annotation error
R-4.6 direct var(--scale-*) / var(--primitive-*) in component CSS error
R-4.7 !important without a same-line /* important: <reason> */ annotation (THM-5, 2026-07-11) error
R-5.3 recipe token keys outside the naming grammar of §1 — color as a slot, or an interactive modifier behind (D-TH.6, 2026-08-20) error

R-5.3 is a grammar guard, not a list guard — unlike eidos-event-vocabulary, which checks membership of a closed vocabulary, this one checks a SHAPE. It consumes the classifier of theming-census --names, the same source the codemod ran on: two implementations of one grammar are two grammars that end up disagreeing. It went to error directly, with no warn ramp, because the codemod emptied its debt in the same pass (the R-4.4 precedent). Keys queued for the state-layer migration are reported apart — they are not naming debt, they are knobs about to disappear. Muta-prueba de tres caras (it is in scripts/__names-mutatest.ts and it earned its keep: it caught the classifier promoting ANY morfo-declared value to the front, which turned --sidebar-width-icon into --sidebar-icon-width): -bg-hover with a valenced value → red · trigger-color → red · primary-solid-hover → green.

All R-4.x are error (graduated 2026-07-02: the five mechanical ones after the backfill; R-4.5 after the motion migration emptied its 15 components). The doctrine the migration left — how a recipe consumes the motion channel:

  1. Preset stamp (data-animation-style via the motion prop) when the element has no semantic data-state of its own and no conditional trigger: overlays (popover, menus, select, combobox, tooltip via the delayed-open alias), content mounts (motionAttrs).
  2. Event signature (EidosConfig.motion.signatures) when the animation reacts to sema's data-event-* and is generic per verb/family (expand/collapse, present/dismiss, announce per intent, the shift crossing per data-event-direction).
  3. Materials pattern (the rule lives in the recipe but consumes ONLY registered keyframes + --motion-* hooks) when the trigger is irreducible to the generic: a semantic data-state of its own (card), container-flag gating (timeline data-live), per-component axes (tabs data-motion × orientation), soma's directional machinery (navigation-menu from/to), or the per-component materialization of a signal (metrics value-flash per intent). The reason is documented in the rule.

Sibling guards already active: R-2.1/2.5/2.6 (color), R-2.7 (literal typography), recipe-css-contract.test.ts (consumed tokens + TSC + non-literal font/icon-size + variants vs type unions + no phantom public refs — every no-fallback var() in a recipe value must exist in the derived contract + privates need a covering scope — --_* refs never from root, both 2026-07-07), the raw-z guard in contracts.test.ts.

The WIP tracks words / palabras / chronos are excluded from R-4.x (same as the typographic guard) until they leave their parallel track.

5. Authoring checklist (before calling a recipe done)

  1. Dimensional tokens with §1's names (logical axes, canonical heights).
  2. Neutral hover = state-layer; valenced hover = palette tokens.
  3. Zero literal box-shadow — everything through --shadow-*/--depth-*/inset-ring.
  4. Focus per archetype; not reinvented.
  5. opacity: var(--opacity-disabled) on disabled.
  6. font-size/icon-size 1:1 with the scale (or a documented canonical exception).
  7. Transitions with --duration-*/--ease-*; no perceptual-signature @keyframes.
  8. Spacing with --space-*, no hand-multiplied density.
  9. If it's a list/menu → list-surface; if it PORTALS → a --z-index-overlay-* rung; if it is viewport-fixed page chrome → the affix rung, via the shared layer rather than a private copy of the geometry.
  10. Every deviation carries its annotation (§3).
  11. node --import tsx/esm scripts/component-audit.ts --only {kebab} with no new R-4.x.

Powered by TurnKey Linux.