33 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) | — |
| 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 fromCOLOR_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-coloris the signed exception, along with colour as a NOUN (orb-color-*) andstop-color-*, wherestop-coloris 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
-colorcontra 55fg, y el modificador caía a los dos lados (hover-bg×13 contrabg-hover×22). El origen era una CONTRADICCIÓN entre dos doctrinas firmadas: THEMING §6.7 r7 dicefg; el principio de plataforma de la normalización px/py («el token se llama como la propiedad») dicecolor. El autor adjudicófgacotando el principio: gobierna los ejes DIMENSIONALES, no la parejabg/fg— si la gobernara,bgtendría que llamarsebackground-color. Codemod value-preserving: 269 claves + 793 referencias en 101 ficheros, diff degenerated/= 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 dedefineRecipescierra 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-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).
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: <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.1 | an appearance declaration that reaches no theme and carries no act — neither a public token, nor a /* literal: … */ annotation, nor an entry in the debt ledger (2026-08-25) |
error |
| R-5.2 | a recipe with themeable knobs, no entry in lib/recipes/base.ts, and unregistered debt (2026-08-25) |
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 -- <c> <url>, 2026-08-21) |
error |
R-5.1 measures against a LEDGER OF DEBT, not against a ceiling (firma
2026-08-25). The two rules spent months documented and unimplemented
(PLAN-theming.md §3, next-features.md §13) for one reason: with 1088
declarations out of reach, the rule could only ship as an eternal warn or as
a false claim that 74 recipes are broken. The third way is the one R-5.4
already walks — register the debt in writing.
scripts/theming-census-debt.ts
carries all 1088 keys, one line each, in the form {class} · {file} · {selector} · {property} (line-independent, so moving a rule fabricates no
regression; compared as a MULTISET, so a duplicated declaration cannot slip
in). It is not an exceptions file: an exception asserts «this is fine» and
every key there asserts the opposite.
So R-5.1 is error against the ledger. New debt outside it fails, and an
entry whose knob is no longer debt — tokenized, annotated, dead — is reported
STALE and fails until the line is deleted; the debt only shrinks, key by
key. The old coarse ceilings of theming-reach-floor.test.ts (maxLiteral
564 / maxGlobal 1156) are RETIRED by it: a total only sees a net move, so
five regressions under five fixes read green.
R-5.2 is the contract half, and its 23 contract-less recipes were
adjudicated one by one rather than waived (recounted by the adversarial pass
of the same day — an earlier draft said 18 by leaving the structural five out
of its own total): eight paint nothing; the typographic primitives
(heading, text, display) are all-system and their theming surface IS
the layer they consume (D-TH.2-b); five are structural; the three
calendar-layer consumers keep bridge privates and their raw knobs in the
ledger; palabras is a WIP track outside the audited catalogue; and the real
debt (field-langs, 31 raw globals with no contract) is covered BY the
ledger — the entry IS its written registry. What
the rule does NOT adjudicate, and says so in its own PASS note, is the
var(--{c}-x, fallback) reference that no base.ts entry declares (mockup
×11, text-scramble ×2): recipe-css-contract.test.ts sanctions that idiom
explicitly — its phantom-public guard bites only the NO-fallback var() — and
one guard must not contradict another.
The exception valve of §3 is untouched: the /* literal: <reason> */
annotation on the declaration, and the component-level R-5.1 exception: /
R-5.2 exception: of a README (23 components carry one today). Precedence,
because it is not obvious: a README valve softens the component's audit ROW,
while the catalogue floor (theming-reach-floor.test.ts) reads ONLY the
ledger — so a valve never blinds the ratchet. A STALE entry is excused by no
valve: it is not a deviation to sign, it is bookkeeping that claims a knob is
broken after it was fixed.
Mutation-proved the day it landed, both directions: injecting
border-radius: 7px into mark names the key in newDebt, reddens the floor
and fails mark's R-5.1 row; tokenizing a registered knob of aura reports
that entry STALE and stays red until the line is retired (1088 → 1087).
One tier is deliberately OUT of the per-key ratchet and named rather than
hidden: a private that derives from no public (318 knobs) is debt by the
letter of R-5.1 in PLAN-theming.md §3 and rides the coarse reachPct floor
until it is signed.
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, soblur()alone left the cursor on the node::hovermatched for the whole run and every hover rule out-ranked its resting neighbours. Cost three false negatives ontextareaalone. ::placeholderIS readable throughgetComputedStyle. The note claiming otherwise was never checked; it had already costcommand.input-placeholder-fga hand-verified ledger entry, now pruned as STALE.COMPONENT_OVERRIDES— per-component attribute prefix and opening selector, for components whose DOM does not followdata-{component}-{part}.picker-shellnames 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<a>: clicking it navigates instead of revealing the portaled panel.text-decoration-thickness/-underline-offsetjoined the property list, which hadtext-decoration-colorbut 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:
- 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 and
R-5.1/5.2 (same as the typographic guard) until they leave their parallel
track. Their DEBT is registered anyway — 568 of the 1088 keys — because the
catalogue floor asks a catalogue-wide question and leaving a third of the debt
unfrozen would make F3's gate a claim about two thirds of the tree. The
ledger's header carries the reasoning, and one measured caveat about
palabras.
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 PORTALS → a
--z-index-overlay-*rung; if it is viewport-fixed page chrome → theaffixrung, via the shared layer rather than a private copy of the geometry. - Every deviation carries its annotation (§3).
node --import tsx/esm scripts/component-audit.ts --only {kebab}with no new R-4.x.