You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/canon/recipe-contract.md

298 lines
28 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

---
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: <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.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.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 `<a>`: 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.

Powered by TurnKey Linux.