|
|
---
|
|
|
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`** (physical axes — they break RTL) |
|
|
|
| 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-`.
|
|
|
|
|
|
## 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 (`archetypes.css`) | 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 */` |
|
|
|
|
|
|
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 |
|
|
|
|
|
|
**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).
|
|
|
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), 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.
|