12 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) |
|
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-tertiaryshipped 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) — neverroot: 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](+3my) 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, dondepxes azúcar que un compilador resuelve), es direccional-agnóstico, y en un contrato de tokenspxademá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 tiposdefineRecipes(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: <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 |
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:
- Preset stamp (
data-animation-stylevia themotionprop) when the element has no semanticdata-stateof its own and no conditional trigger: overlays (popover, menus, select, combobox, tooltip via thedelayed-openalias), content mounts (motionAttrs). - Event signature (
EidosConfig.motion.signatures) when the animation reacts to sema'sdata-event-*and is generic per verb/family (expand/collapse, present/dismiss, announce per intent, theshiftcrossing perdata-event-direction). - Materials pattern (the rule lives in the recipe but consumes ONLY
registered keyframes +
--motion-*hooks) when the trigger is irreducible to the generic: a semanticdata-stateof its own (card), container-flag gating (timelinedata-live), per-component axes (tabsdata-motion× orientation), soma's directional machinery (navigation-menu from/to), or the per-component materialization of a signal (metricsvalue-flashper 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)
- Dimensional tokens with §1's names (logical axes, canonical heights).
- Neutral hover = state-layer; valenced hover = palette tokens.
- Zero literal
box-shadow— everything through--shadow-*/--depth-*/inset-ring. - Focus per archetype; not reinvented.
opacity: var(--opacity-disabled)on disabled.font-size/icon-size1:1 with the scale (or a documented canonical exception).- Transitions with
--duration-*/--ease-*; no perceptual-signature@keyframes. - Spacing with
--space-*, no hand-multiplied density. - If it's a list/menu → list-surface; if it floats → a
--z-index-overlay-*rung. - Every deviation carries its annotation (§3).
node --import tsx/esm scripts/component-audit.ts --only {kebab}with no new R-4.x.