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

44 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)
tsc theming guide
docs/canon/tsc.md (where each token is emitted) docs/theming/reference.md (the system; §5 sizes · §6 naming) 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 — but only for FOREIGN and system vocabulary. Over the component's OWN --{c}-* names the fallback stopped excusing anything on 2026-08-26: the namespace is closed over the contract (§4, «la ley del espacio cerrado»).
  • 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.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 1154 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.

The hole that rule left is CLOSED (firma «la ley del espacio cerrado», 2026-08-26). What R-5.2 did not adjudicate, and said so in its own PASS note, was the var(--{c}-x, fallback) reference that no base.ts entry declares: recipe-css-contract.test.ts sanctioned the idiom explicitly — its phantom-public guard bit only the NO-fallback var() — and one guard must not contradict another. The contradiction was resolved by moving the OTHER guard: the public namespace is CLOSED over the contract — a --{c}-* exists if and only if base.ts declares it. There is no third state. Three corollaries:

  1. The fallback is a safety device for the VALUE, not an escape from the contract. It keeps a page from breaking; it never excuses the name.
  2. Once the key is declared, the inline fallback on the component's OWN public name dies — the default lives in ONE place. Two sites are free to disagree while the default sits at the point of use: --mockup-shadow carried var(--shadow-4) on the frame and var(--shadow-5) on the bezel under a single name, so a theme setting it could not know which of the two it was moving. It was split into --mockup-phone-shadow in the same pass — a surface that is different says so in its name.
  3. The guard hardens to the «if and only if»: an own --{c}-* outside contract is red, with or without a fallback. FOREIGN vocabulary (a borrowed --field-control-height-md in time-range-picker — «un token PRESTADO trae su semántica») and system names keep their rules: the guard filters by the component's own prefix and never sees them.

Eleven keys were declared — mockup ×9, text-scramble ×2 — each taking its retired fallback as its default, verbatim, at root scope. (The earlier note here said «mockup ×11»: that 11 is the census's count of DECLARATIONS reaching a public name, a different unit from the guard's, which counts 9 var() sites over 8 names — the extra site being the double shadow.) Neutrality was proved twice: the diff of generated/ is those eleven lines and nothing else, and an A/B in the browser — every new token forced back to initial with the old declarations restored inline — measured identical computed values on all three mockups of /blocks/feature-split, both chromes, and on text-scramble.

What the law did not close from itself was the VALUE CHANNEL: the 14 public names soma or a wrapper writes per instance, inline. Their destination was already signed — a value channel is --_{c}-*, the 318's class channel — but the rename is a cross-layer codemod through soma providers, morfos and READMEs, so it rode its own axis. recipe-css-contract.test.ts carries them meanwhile in a NAMED, DATED registry (PENDING_PRIVATE_RENAME) that may only SHRINK: an entry whose name stops being consumed, or that gains a contract entry, is reported STALE and stays red until the line is deleted. The registry COUNTS names; it does not bless them.

The codemod landed on 2026-08-26 and retired THIRTEEN of the fifteen entries — --background-{pointer-x,pointer-y,pointer-px,pointer-py,progress} · --color-swatch-fill · --emoji-picker-columns · --knob-{angle,progress,start-angle,sweep} · --menubar-panel-anchor-width · --text-gradient-duration. The flip was atomic per name (writer and every reader in one edit, found by a git ls-files sweep of the whole tracked tree) and value-preserving: the census moved by exactly ONE knob (color-swatch.background, the only one of the thirteen sitting in an appearance property whose declaration did not also read a public sibling), so public 3074 → 3073, channel 36 → 37, reach 73 % unchanged. The measured signature that retired --color-swatch-fill from the contract on 2026-08-23 stands untouched: it was, and remains, a value channel — only the spelling changed.

TWO entries survive, each for its own reason. --motion-stagger-each-default rides the registry because it is not a channel at all: nobody writes it at runtime, its default belongs to EidosConfig.motion, and declaring it would contradict motion's signed structural row. And --navigation-menu-indicator-h is where the codemod STOPPED: the provider writes FOUR sibling measurements in one object literal (--navigation-menu-indicator-{x,y,w,h}), the soma README teaches all four under a ## CSS Variables heading as consumer-controlled shape with two worked examples reading -h, and -w is a DECLARED contract key — which is the only reason -h is the single member of the family this guard can see. Privatising one of four splits a documented public API and leaves a declared sibling contradicting it. Whether the family goes private, goes public, or stays split is a SIGNATURE, not a codemod.

Annotation 2026-08-26 (later the same day) — the signature landed, and the registry is down to ONE. The paragraph above records where the codemod stood that morning; it stays because its measurement was right for its day. What was signed is option (a): the FOUR siblings are private (--_navigation-menu-indicator-{x,y,w,h}, writer and every reader flipped atomically), indicator-w leaves the contract (navigation-menu 44 → 43 rows), and the soma README's ## CSS Variables section stops teaching the measurements as API — it teaches the PART and its three shape knobs. Option (b), declaring -x/-y/-h to match the README, was REJECTED: it would bless four per-instance measurements as theming surface, the exact thing the channel class exists to deny. A new species output — a third class between public and private — was proposed and rejected too, for opening exceptions on a cheap class with a prose border. -w was a token that LIES, measured: soma writes it inline on the element, so with the underline drawn neither a write on the component root (what the sentinel does) nor one on :root reaches it; it scored reach only in the window where the indicator is mounted with no rect yet and the underline is 0px wide and invisible. Measured: public 3073 → 3072, channel 37 → 38, reach 73 % and atHundred 65 untouched, ledger 1154 · 0 new · 0 stale; the navigation-menu sentinel 43/44 → 42/43, same single adjudication. ONE entry survives, motion's.

Annotation 2026-08-26 (the same day, the third and last) — the registry is at ZERO, and DISMANTLED. The fifteenth and final entry, --motion-stagger-each-default, went out by MECHANICS — not by codemod, not by signature. It was never a value channel: nobody wrote it at runtime, and the var(--motion-stagger-each-default, 70ms) of motion/motion.css resolved to its fallback ALWAYS, because no sheet in the tree ever declared the name. The wiring its acta was waiting for landed: primitives.motion.staggerViewport (default '70ms', embarked in static.ts beside stagger: '20ms') emits --motion-stagger-viewport on :root, and the viewport rule [data-stagger] > [data-animation-trigger='viewport'] EMIGRATED from motion.css to renderMotionBlocks — the foundation that already emits the 24 [data-stagger] index rows of that same selector family. So motion.css stopped consuming --motion-* at all, the guard had nothing left to demand declared, and the entry fell STALE on its own. The dead name --motion-stagger-each-default no longer exists as a live CSS consumption (the name survives only as dated history — docs and this file's acta). Zero guards touched, zero exceptions opened, zero pixels moved (70ms verbatim, measured in real Chrome: 0s / 0.07s / 0.14s / 0.21s on /blocks/stats-band/preview, identical to the baseline of AUDIT-blocks-ledger.md; the old name written at 333ms moves nothing, the new token written at 333ms rewrites the whole cascade). And motion's signed structural row came out MORE true, not less: still no key in base.ts, still one knob, the opacity of the [data-animation-pending] gate.

With it, PENDING_PRIVATE_RENAME is DISMANTLED, not kept empty — the object, its PENDING_DESTINATION, the branch that consulted it, the stale field of auditClosedNamespace and the it that read it, all deleted; a short acta comment stands in their place and points here. The precedent is the house's own (docs/decisions/book-deviations.md: targetOverride was DELETED once the F3 census reached zero — «el estado ilegal del eje quedó inexpresable»), and the reason is the law this very guard file writes about itself at its knob axes check: an empty registry's it asserts exactly nothing, and a guard that inspects nothing is a false green. The honest counter-argument — a sixteenth entry now has nowhere to go — is precisely the point: with no registry, an own --{c}-* outside the contract is red, and that is all. There is no third state.

Mutation-proved three ways the day it landed: an invented var(--mockup-inventado, red) — own prefix, WITH a fallback — turns the guard red naming the key; the same injection with a FOREIGN borrowed name and a fallback stays green; and a registry entry that stops describing a consumption is named STALE and reddens until it is deleted.

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 (that day, 1088 → 1087).

The third unreached class closed one signature later (firma «clasificación de los 318», 2026-08-26). A private that derives from no public is debt by the letter of R-5.1 (PLAN-theming.md §3), and until that day it was OUT of the per-key ratchet — 318 knobs in a bulk, named rather than hidden, riding only the coarse reachPct floor. They are adjudicated key by key now, and a non-derived private has exactly five exits, two decided by measurement and three by an act:

  1. bridge (132) — the knob reads the private the THM-2 palette forward WRITES. Decided against the generator's real output (the --_{c}-palette-{slot} line of renderRecipePaletteForward), never against the shape of the name: a --_c-palette-shadow nobody forwards is no bridge. It is reachable twice over — the --palette-* layer and the public tones — and a public on top would let a theme PIN it and kill the instance's color=.
  2. channel (36) — the private NOBODY declares, neither the recipe nor the generator, because soma or the wrapper writes it per instance. A theme must not reach it.
  3. The private: annotation (84) — §3's valve, one class over: the same written act as literal:, on the declaration, for a shape this doctrine already blesses ONE LEVEL BELOW the knob (a variant / role CONMUTADOR whose branches are the palette bridge; a value channel read through an alias). It classes exception — the canon's existing signed-deviation class, not a new tier.
  4. An entry in the debt ledger (66) — private is a DebtClass beside global and literal, so an unsigned non-derived private is newDebt, NAMED, and its entry reports STALE the day it gains its annotation or starts deriving from a public.
  5. Deriving from a public, the exit that empties the other four.

The arithmetic closed with nothing left over: 132 + 36 + 84 + 66 = 318, and the ledger went 1088 → 1154 keys. The ratio moved 69 → 73 % (3074 of 4228) without a single recipe changing a value: the numerator stood still and the denominator fell by exactly the 252 that stopped counting as unreached (132 + 36 + 84), the way system and structural already left it — the 66 ledger'd privates stay in. The floor (theming-reach-floor.test.ts) rose to 73 % / 65 components at 100 % in the commit that earned it.

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 and R-5.1/5.2 (same as the typographic guard) until they leave their parallel track. Their DEBT is registered anyway — 583 of the 1154 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)

  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.