--- 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) | — | | 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). **Typographic primitives are measured against the STYLE LAYER, not against their own tokens** (D-TH.2, signed 2026-08-20). `heading`, `text`, `s-text`, `code`, `display` and `label` resolve every typographic axis through `--style-{name}-*`, selected by `data-style` or bound wholesale to one style: that layer IS their theming surface, and it is already public and live. Minting `--heading-*` to mirror it would be one alias per axis × level — the class of alias the changelog §39 purge killed. The census therefore counts `--style-*` as a transversal `system` for those six (their `--_{c}-*` privates are the per-INSTANCE escape hatch the wrapper writes from a prop, not a theming knob). A component that merely READS a named style for one axis — a menu label reading `--style-label-font-family` — is not a primitive and is not in the set. ## 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: */` | 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 | | 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.4 | a public token that moves no computed value in the live demo and carries no written adjudication (`npm run theming:sentinel -- `, 2026-08-21) | 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**. **R-5.4 is a REACH guard, and it needs a browser** — like `morfo-check`, it runs against a live dev server (`scripts/theming-sentinel.ts`), because what it checks no static analysis can see: for every public token it writes an unmistakable value and demands that some node's computed style follow it. It was born from two findings of the F2-A adversarial review (2026-08-21) that only this instrument caught — `gradient-picker` re-declared its trigger's entire chrome under a `popover.css` rule that always wins (36/45 tokens dead), and `carousel` minted an `item-gap` trio against a soma INLINE style that no cascade can beat. A silent token is either retired or ADJUDICATED: the ledger (`scripts/theming-sentinel-exceptions.ts`) carries one written reason per token — part not mounted by the demo, state that needs forcing, pseudo-element or composed-component limit — and the guard fails on any dead token without an entry. A ledger entry whose token moves again is reported as STALE (prune it; same debt as a stale allowlist row — warning, not failure, because the instrument keeps a residual nondeterminism the probe's history documents). **The guard grew six capabilities during F2-B (2026-08-22), each born from a measurement and each re-verified against every component that already had ledger entries — zero regressions after any of them.** They are worth knowing because each names a way the instrument used to LIE: - **The pointer is parked after the blur.** `reopen()` runs before EVERY token and clicks the component open, so `blur()` alone left the cursor on the node: `:hover` matched for the whole run and every hover rule out-ranked its resting neighbours. Cost three false negatives on `textarea` alone. - **`::placeholder` IS readable** through `getComputedStyle`. The note claiming otherwise was never checked; it had already cost `command.input-placeholder-fg` a hand-verified ledger entry, now pruned as STALE. - **`COMPONENT_OVERRIDES`** — per-component attribute prefix and opening selector, for components whose DOM does not follow `data-{component}-{part}`. `picker-shell` names its parts generically ON PURPOSE (`data-picker-header`) so every picker shares one visual contract, and that convention blinded every filter: the guard reported 0/31. - **`openBy: 'hover'`** for hover cards, whose trigger is an ``: clicking it navigates instead of revealing the portaled panel. - **`text-decoration-thickness` / `-underline-offset`** joined the property list, which had `text-decoration-color` but not its siblings. The lesson that generalizes: **a probe or guard that inspects few nodes passes in false**. Count what the instrument actually sees before trusting a green gate — `picker-shell` measured ZERO nodes and reported success. **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.