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

697 lines
56 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 — **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`](../../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`](../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.