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

56 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.5 a per-instance custom-property WRITE outside --_{c}-*, in the nine roots the guard sweeps — a contracted name (a token that lies) or an ownerless public form. src/uix/eidos is OUT by declaration, §18 (src/uix/value-channels.test.ts, 2026-08-27) 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.

And its SIBLING closes the other side — the one that WRITES (firma «el guard vive donde NACE el valor», 2026-08-27, R-5.5; its SCOPE written the same day, over the objection below). The law above audits who READS a name; this one audits who writes it:

Where the framework writes a custom property PER INSTANCE, the name lives in --_{c}-* and base.ts does not declare it. A name the contract DOES declare — public --{c}-x or private --_{c}-x, one single species — is a TOKEN THAT LIES: a theme is entitled to it and can never win it.

THE SCOPE, in the law itself, because a rule that says «every» while its guard reads two directories is prose. Its subject is the framework's writing side, and that is nine roots: src/uix/soma · src/arts · src/uix/blocks · src/packs · src/libs · src/svrs · src/uix/sema · src/uix/morfo · src/uix/active-uix. The first two write; the other seven wrote nothing on 2026-08-27 and are swept anyway, so a first write cannot be born unseen — held to the same two reds as everyone else and never to zero, because a block that stamps its own --_{c}-* geometry tomorrow is legal, and reddening it would be enforcing a census instead of a law.

Two roots are OUT by declaration, and neither one is empty:

  • src/uix/eidos — 11 writes of CONTRACTED names and 18 ownerless public forms live there today (measured by pointing the same scanner at it). They are two species and not one, which is exactly why the answer is not to add the root: 13 sites in the lib/build-* and lib/render-css generators are the foundation emitting its own vocabulary, which is what a foundation is for, while 16 in components/* are the ergonomic props a wrapper offers its consumer. Telling those apart takes a signature — one of the eleven, --dialog-overlay-opacity, is the exact twin of the drawer veil this firma just fixed, still alive because dialog has no progress channel. The expediente is next-features.md §18, and it is OPEN.
  • web/routes — FROZEN (05dcb4db7) and rebuilt whole; the 204 writes there are a tree that is leaving. Judging them would need a clause this law does not have, about WHO: a demo writing --button-palette-solid inline is a CONSUMER using a token for what it is, not a framework publishing a name.

A write inside those roots has four outcomes — two green, two red — and they are listed in the order the guard asks them, because the order is part of the law:

  1. A name base.ts declares — RED. The worst species, and the reason this law is not a style rule. An inline per-instance write beats every normal rule, so the theme entitled to that name moves NOTHING while the contract keeps promising that it does. It is invisible from the reading side by construction: the recipe reads a declared, contracted, perfectly legal name, and the census scores it public and REACHED. R-5.4 is the rule that exists to kill this species and it cannot see it either — R-5.4 writes on :root or on the component root, and the inline write outranks both. This is asked BEFORE the underscore, and it was not always: while the prefix answered first, the 124 _-prefixed keys base.ts declares could be written inline for free. A private recipe token is a theme's token too (render-css.ts:1900 emits a _x key as --_{c}-x out of the same EidosConfig.recipes a theme supplies), so the species is «the theme is entitled to this name and the write outranks it» and the underscore has nothing to say about it. Measured before flipping the order: of 64 private writes in soma + arts and 46 in eidos, zero name a contracted key — the hole closes and nothing reddens.
  2. --_{c}-* that the contract does not declare — GREEN. The destination. The layer holds the pen; it does not own the surface. A value channel is not theming surface and says so in its name.
  3. A name of the SYSTEM vocabulary — GREEN. An art writing --color-* / --space-* / --motion-* speaks the foundation's language; it is not inventing a surface.
  4. Any other --… — RED. A public FORM with no owner: published as API by its spelling alone, reachable by nobody. The --{c}-indicator-* species of §14, one floor down.

WHAT THE GUARD DOES NOT CHECK, said here so no reader infers it from a green. Two gaps, both deliberate, and each one a place where the sentence and the instrument do not coincide:

  • «PER INSTANCE» is not decidable statically, so the guard does not decide it. Its antecedent is WIDER than the doctrine's: it reddens any write of a contracted or ownerless name in those roots, per-instance or not. That is why the registry exists at all rather than being a leak in it — --scrollbar-width is a single write on the one <body>, so the doctrine does not reach it while the guard does, and the entry is what carries the difference in writing. A rule whose antecedent the machine cannot evaluate is enforced by the nearest one it can, and the gap is booked, not hidden.
  • The {c} of --_{c}-* is NOT enforced — only the underscore is. --_totally-invented-thing passes. The component segment is naming doctrine (THEMING §6: no abbreviation, the component's own namespace) and this guard is not its enforcer; the tree carries legitimate exceptions to the letter anyway, because a shared LAYER writes in the HOST's namespace (--_{host}-floating-*, §15) and some privates are layer-scoped, not component-scoped (--_viewport-placement-offset). Closing this one means a rename axis, not a line of guard.

The system vocabulary is a DERIVED list, never a hand-written one: the guard subtracts the recipe contract from everything the foundation emits (renderGeneratedBaseEidosCss), and what remains is what an art may speak. A hand-kept allowlist would need editing every time the foundation grows, and would drift into the exception file this axis has spent five signatures avoiding. What that subtraction does is carve outcome 3 out of outcome 4 — it is not what separates the two reds, which is the contract set alone. Said plainly because the earlier wording had it backwards, and «anything else is red» is false while outcome 3 exists.

The exemption registry is the debt ledger's shape, not the exception file's: it may only SHRINK — an entry whose name stops being an ownerless write is STALE and stays red until deleted. It COUNTS a name; it does not bless it. And it answers the house's own objection, which is 24 hours older than it: PENDING_PRIVATE_RENAME was DISMANTLED on 2026-08-26 over this same species, «there is no third state». What keeps this one alive is not permissiveness but FORM — that registry held names awaiting a rename already decided, which is a queue, and a queue with no deadline is a cupboard; these hold names the law does not reach or cannot judge alone, which is the ledger's own case. So an ENTRY is four fields and the guard asserts all four are filled:

field what it stops
since an undated registry cannot age
reason why the antecedent misses it — MEASURED, not asserted
destination where it goes, named. Not «to be decided»
heldBecause why it did not move THAT day. The load-bearing one: an entry that cannot say what blocks it has nothing blocking it, and belongs in the commit instead of in this list

Two adjudications opened it and both are outside the law's antecedent rather than deviations from it: --scrollbar-width (arts/adom/body-scroll-lock — one write on the single <body>, a fact of the viewport, not of an instance) and the seven --palabras-scheme-* of the WIP track, registered rather than waived because the debt ledger already settled that question the other way («THE WIP TRACKS ARE IN … debt is real»).

Why the guard is a vitest file and not a script: npm run gate ends in npm run test, so the suite is what turns a doctrine into a law. And why it sits at src/uix/ root rather than under eidos/: its SUBJECT is the writing side and its JUDGE is the eidos contract, so it belongs to no single layer.

The measurement that forced it: the census, the debt ledger, R-5.1 and the closed-namespace law all read src/uix/eidos/components and nothing else (scripts/theming-census.ts, const ROOT). The 73 % was measured over the side that READS, and only that side — deliberately not called «half», because no recomputed denominator exists: a write in soma is not a recipe declaration, so the two do not share a fraction. What is true is narrower and enough: the number says nothing about the writers, and until this rule there was no instrument that did. The commit that named the general law is 05dcb4db7 («LEY — web/routes congelado, y la consecuencia: el guard vive donde NACE el valor»), signed for the P1 axis the same week with the same argument: an instrument aimed at the observed artefact measures a tree that is leaving, not the contract. This is that sentence translated to theming.

Mutation-proved six ways, each with the tree restored byte-identical: a new unprefixed name in a provider → red, NAMING it; a write of a key that IS in the contract → red, «a token that LIES»; a correct --_ channel → green; a SYSTEM name written by an art → green (the only branch with no live case in the tree, so it had to be proved by injection); the canonical dynamic form [`--${component}-x`] → red as --{}-x; and a registry entry that stops describing a write → STALE. The scope pass added three more the same day: a CONTRACTED private key (--_avatar-badge-bg) written inline → red as a token that lies, which was green before the order flipped; and, in blocks — a root nobody swept that morning — --_hero-private-leak → green while --hero-public-leak → red, which is the difference between the law and the census of zero it used to assert there.

Sweeping by CAPTURE and not by pattern is what made it correct. The firma named four write forms; the scanner resolves six, and one of the two missing was the CANONICAL one — the computed key [`--_${component}-floating-anchor-width`] that §14 and §15 signed. A guard blind to that form is blind to the destination it enforces, and it would have been green over a public --{c}-* written that way. The other was out['--x'] = v, the member assignment palabras uses, which is what put those seven names on the record at all. Two shapes stay unresolvable and are written down rather than implied by a green: a name ASSEMBLED across statements (soma/layers/measured-indicator) and a name reaching setProperty through a variable.

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.