--- title: Recipe Contract — the transversal contract of every eidos recipe type: canon audience: human + agent authority: canonical — which theming systems every recipe MUST consume, and under which names status: current enforcement: scripts/component-audit.ts (rules R-4.x, all `error`) source: migrated from src/uix/eidos/RECIPE_CONTRACT.md (2026-07-02, docs-book F7.3) related: tsc: docs/canon/tsc.md (where each token is emitted) theming: docs/theming/reference.md (the system; §5 sizes · §6 naming) guide: 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) | — | Inherited naming rules (THEMING §6): public `--{c}-{slot}`, private `--_{c}-{slot}`, no `color-` segment, no kebab abbreviation, no `--eidos-/-soma-/-air-`. **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 | | **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: */` | a physically-fixed value or a justified optical tuning | `font-size: 13px; /* literal: tight icon affordance */` | | `/* functional: */` | 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: */` | 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 */` annotation (THM-5, 2026-07-11) | error | **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 floats → a `--z-index-overlay-*` rung. 10. Every deviation carries its annotation (§3). 11. `node --import tsx/esm scripts/component-audit.ts --only {kebab}` with no new R-4.x.