feat(theming)!: la ley del espacio cerrado - un --{c}-* existe si y solo si base.ts lo declara

Firma 2026-08-26. El espacio de nombres publico queda CERRADO sobre el
contrato: no hay tercer estado. El fallback es seguridad de VALOR, no
escapatoria de contrato; declarada la clave, el fallback inline muere (el
default vive en UN sitio, base.ts).

- El barrido real del catalogo: 25 nombres propios con fallback fuera de
  contrato en 10 componentes (la prosa decia 13 en 2). ONCE eran knobs de
  tema y quedan DECLARADOS con su default verbatim, scope root medido:
  mockup 9 (con --mockup-phone-shadow ACUNADA - un nombre cargaba shadow-4
  en el marco y shadow-5 en el bisel: un nombre con dos defaults es la
  mentira que la ley mata) y text-scramble 2. Fallbacks inline retirados.
- Los otros QUINCE no son knobs: 14 canales de VALOR que soma/envoltorio
  escriben por instancia (destino ya firmado por la firma de los 318:
  --_{c}-*; el codemod es un eje propio) y el default de motion cuyo hogar
  es EidosConfig.motion (su entrada structural queda intacta). Viven en el
  registro PENDING_PRIVATE_RENAME del guard - nombrados, fechados, con
  destino escrito y STALE simetrico: contarlos, no bendecirlos. La firma
  medida de color-swatch.fill (2026-08-23) queda INTACTA y citada.
- El guard de fantasmas endurecido al "si y solo si": un --{c}-* del propio
  componente fuera de contrato es ROJO con o sin fallback; el prestamo
  ajeno y el sistema conservan sus reglas. Mutaciones: var inventado con
  fallback -> rojo nombrado; prestamo ajeno -> verde; entrada de registro
  rancia -> rojo STALE.
- Neutralidad PROBADA: generated/base.css = +11 lineas y nada mas; computed
  A/B en navegador reconstruyendo el mundo viejo inline sobre el mismo nodo
  - mockup 3 nodos x 2 cromos x 6 propiedades identicos (el bisel conserva
  shadow-5), text-scramble identico. mockup se midio donde monta de verdad
  (/blocks/feature-split): no tiene demo propia.
- Numeros: solo no-contract 18 -> 16. El suelo NO sube y la razon importa:
  la ley cerraba un hueco de CONTRATO, no de alcance - esas declaraciones
  ya puntuaban public; lo que faltaba era declarar el NOMBRE.
- Efecto lateral anotado en la clave, el handoff y next-features: el crudo
  var(--primitive-neutral-12) migro verbatim a base.ts y R-4.6 (que escanea
  CSS) ya no lo ve - la deuda sigue, ahora nombrada en el contrato.
- docs: recipe-contract SS4 reescrito con la ley y sus corolarios (el
  value-reference con-fallback queda acotado a vocabulario ajeno/sistema);
  CONTINUE y next-features al dia - pendientes del eje: el codemod de los
  14 canales, el cableado del canal de motion, palabras y timeline.

BREAKING: consumir un --{c}-* propio no declarado es rojo desde hoy, con o
sin fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 1 month ago
parent cc2894b36f
commit 6499eb2a4c

@ -97,7 +97,10 @@ Inherited naming rules (THEMING §6): public `--{c}-{slot}`, private
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.
`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
@ -219,12 +222,62 @@ 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. What
the rule does NOT adjudicate, and says so in its own PASS note, is the
`var(--{c}-x, fallback)` reference that no `base.ts` entry declares (`mockup`
×11, `text-scramble` ×2): `recipe-css-contract.test.ts` sanctions that idiom
explicitly — its phantom-public guard bites only the NO-fallback `var()` — and
one guard must not contradict another.
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 does **not** close from here is the VALUE CHANNEL: the 14 public
names soma or a wrapper writes per instance, inline (`--knob-angle`,
`--navigation-menu-indicator-h`, `--color-swatch-fill`, …). Their destination is
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 rides its own axis. Until it lands, `recipe-css-contract.test.ts` carries
them 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, and the measured signature that retired
`--color-swatch-fill` from the contract on 2026-08-23 stands untouched, cited in
its own entry. `--motion-stagger-each-default` rides the registry for a
different reason: its default belongs to `EidosConfig.motion`, not to a recipe
contract, and declaring it would contradict `motion`'s signed `structural` row.
Mutation-proved three ways the day it landed: an invented
`var(--mockup-inventado, red)` — own prefix, WITH a fallback — turns the guard
red naming the key; the same injection with a FOREIGN borrowed name and a
fallback stays green; and a registry entry that stops describing a consumption
is named STALE and reddens until it is deleted.
The exception valve of §3 is untouched: the `/* literal: <reason> */`
annotation on the declaration, and the component-level `R-5.1 exception:` /

@ -1026,18 +1026,55 @@ Lo que la cola pequeña añadió (2026-08-22):
como `DebtClass` de pleno derecho (`newDebt` y STALE simétricos) · derivar de
un público. 132+36+84+66 = 318, sin resto. Ledger 1088 → **1154**; reach
69 → **73 %** (3074/4228, cayó el denominador, no se movió el numerador);
suelo a **73 % / 65 componentes al 100 %**. (2) **el idioma
`var(--{c}-x, fallback)` sin entrada en `base.ts`** (`mockup` ×11,
`text-scramble` ×2): `recipe-css-contract.test.ts` lo sanciona
explícitamente (su guard de públicos fantasma sólo muerde el `var()` SIN
fallback), pero el nombre no alcanza a `applyTheme` ni al centinela R-5.4 —
forma real sin nombre; decidir si es contrato-obligatorio o idioma legal
con acta. **QUEDA PENDIENTE.** Y una re-clasificación abierta, **también
suelo a **73 % / 65 componentes al 100 %**. (2) ~~**el idioma
`var(--{c}-x, fallback)` sin entrada en `base.ts`**~~ — **EJECUTADA
2026-08-26**, firma «la ley del espacio cerrado»: el espacio de nombres
público es CERRADO sobre el contrato, un `--{c}-*` existe **si y sólo si**
`base.ts` lo declara, y no hay tercer estado. El fallback es dispositivo de
seguridad de VALOR, no escapatoria de contrato; declarada la clave, el
fallback inline sobre el nombre propio muere. **11 claves declaradas**
(`mockup` ×9 — incluida la acuñación de `--mockup-phone-shadow`, porque
`--mockup-shadow` cargaba DOS defaults bajo un nombre — y `text-scramble`
×2), defaults verbatim a scope `root`; el guard pasa al «si y sólo si» y
muta en tres caras. Neutra en píxel medida dos veces (diff de `generated/`
= esas 11 líneas y nada más; A/B en navegador con los tokens forzados a
`initial`, computed idéntico en los tres mockups de `/blocks/feature-split`,
ambos cromos, y en `text-scramble`). Números: `no contract entry` 18 → **16**,
todo lo demás quieto (reach 73 %, 65 al 100 %, ledger 1154 · 0 NEW · 0 STALE,
audit 162/4, `--names` 0 desviadas) — la ley cerraba un hueco de CONTRATO, no
de alcance. Y una re-clasificación abierta, **todavía
pendiente**: los 117 nombres `--palabras-*` que el censo puntúa como
`public` son canal de VALOR del `scheme-style` del documento
(`schemeCssVars`), no contrato de tema — el 22 % de `palabras` no es
alcance parcial.
- **⚠ DOS DEUDAS NUEVAS que dejó la ley del espacio cerrado (2026-08-26), cada
una de EJE PROPIO — no se ejecutan desde theming.** Ambas viven mientras
tanto en el registro `PENDING_PRIVATE_RENAME` de
`src/uix/eidos/recipe-css-contract.test.ts`, que sólo puede MENGUAR (entrada
que deja de describir un consumo → STALE → rojo hasta borrar la línea):
1. **El codemod de los 14 CANALES DE VALOR públicos a `--_{c}-*`.** Su
destino ya está firmado por la firma de los 318 (clase `channel`: lo
escribe soma o el envoltorio por instancia, «a theme must not reach it»),
pero el renombre cruza soma, morfo y READMEs. La lista: `--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` · `--navigation-menu-indicator-h` ·
`--text-gradient-duration`. ⚠ La firma MEDIDA que retiró
`--color-swatch-fill` del contrato el 2026-08-23 queda INTACTA: el
registro cuenta el nombre, no reabre la decisión.
2. **El cableado `EidosConfig.motion` → CSS de `--motion-stagger-each-default`**
(`motion.css:42`, único consumo). No es canal de valor: nadie lo escribe
en runtime. Su default pertenece al canal de motion, y declararlo en
`base.ts` contradiría la fila `structural` firmada de `motion`.
- Deuda menor de la misma ley: el crudo `var(--primitive-neutral-12)` de
`mockup` (era `mockup.css:73`) MIGRÓ verbatim a `base.ts` como default de
`mockup.phone-bezel`. Sigue siendo deuda R-4.6, sólo que en otra dirección
— la regla escanea CSS de componente y ya no lo ve, así que la fila de
`mockup` perdió ese error sin que nadie lo arreglara. Anotado en la propia
clave para que no se pierda; el bisel quiere un slot de rol.
**Trabajo futuro que dejó ESCRITO la medida de los 318** (2026-08-26, nada
de esto es la firma que se cerró):
- **El alias `--_proof-of-human-accent{,-soft}` es borrable a coste CERO de

@ -9,11 +9,14 @@ STALE=0 · reach ≥ 73 % · ≥65 al 100 %), `component-audit` R-5.1/5.2/5.3 en
`error`, y el centinela R-5.4. La deuda restante vive NOMBRADA clave a clave
en `scripts/theming-census-debt.ts` (1154 entradas que sólo pueden menguar).
Trabajo nuevo de theming se abre como entrada nueva de §13/next-features, no
heredando de aquí. De las DOS firmas que el cierre dejó pendientes, la de los
318 privados no-derivados está **EJECUTADA** (sección §«FIRMA EJECUTADA — los
318» abajo); queda la del idioma `var(--{c}-x, fallback)` sin contrato
(`mockup`/`text-scramble`) y, con ella, la re-clasificación de los 117 nombres
`--palabras-*` que el censo puntúa `public` siendo canal de VALOR.
heredando de aquí. Las DOS firmas que el cierre dejó pendientes están
**EJECUTADAS**: la de los 318 privados no-derivados (§«FIRMA EJECUTADA — los
318» abajo) y la del idioma `var(--{c}-x, fallback)` sin contrato (§«FIRMA
EJECUTADA — la ley del espacio cerrado»). Heredada de la segunda queda la
re-clasificación de los 117 nombres `--palabras-*` que el censo puntúa `public`
siendo canal de VALOR, más DOS deudas nuevas de eje propio que ella misma
nombró: el codemod de los 14 canales públicos a `--_{c}-*` y el cableado
`EidosConfig.motion` → CSS (las dos, con su lista, en `next-features.md`).
**El eje ya no tiene cola.** Los 16 componentes que nunca habían pasado están
cerrados o adjudicados, la deuda de artefactos está saldada, y **§12.9 y la
@ -42,6 +45,67 @@ expedientes de §13: `d5a27b87e` (árbol ARIA de tags-input, cierra F-1) ·
exactamente con el plan de VA: tres barridos escalonados, sin fusionar S2 y
S3, y la atribución quedó limpia.
### FIRMA EJECUTADA (2026-08-26) — la ley del espacio cerrado
**Qué se firmó**: el espacio de nombres público es **CERRADO sobre el
contrato** — un `--{c}-*` existe **si y sólo si** `base.ts` lo declara, no hay
tercer estado. Corolarios: el fallback es dispositivo de seguridad de VALOR, no
escapatoria de contrato; declarada la clave, el fallback inline sobre el nombre
público PROPIO muere (el default vive en UN sitio); el guard de fantasmas pasa
al «si y sólo si» — un `--{c}-*` propio fuera de contrato es rojo, con o sin
fallback. El vocabulario AJENO (préstamo de otro componente) y el de sistema
conservan sus reglas: el guard filtra por prefijo propio y no los ve.
**Lo que el barrido previo encontró — y por qué importa**: no eran dos
componentes. El catálogo tenía **25 nombres propios con fallback fuera de
contrato en 10 componentes** (27 sitios), y se parten en DOS CLASES:
- **11 knobs de tema de verdad** → declarados: `mockup` ×9, `text-scramble` ×2.
- **14 CANALES DE VALOR** que soma o el envoltorio escriben inline por
instancia → **NO se declaran** (declararlos revertiría la firma medida del
2026-08-23 que retiró `--color-swatch-fill` del contrato) **ni se renombran
aquí** (es codemod de otro eje). Van a un REGISTRO nombrado y fechado dentro
del propio test, `PENDING_PRIVATE_RENAME`, con razón y destino por entrada.
\+ `--motion-stagger-each-default`, que no es canal (nadie lo escribe): su
default pertenece a `EidosConfig.motion`. **15 entradas.**
El registro es el patrón ledger de la casa: **no bendice, cuenta**, y **sólo
puede menguar** — una entrada que deja de describir un consumo (renombrada,
declarada, muerta) se reporta **STALE** y sigue roja hasta borrar la línea.
**La mentira que la ley mató, en concreto**: `--mockup-shadow` cargaba DOS
defaults bajo un nombre — `var(--shadow-4)` en el marco, `var(--shadow-5)` en
el bisel del teléfono — así que un tema que lo moviera no podía saber cuál de
los dos tocaba. Se acuñó `--mockup-phone-shadow`: superficie distinta, nombre
distinto. El TSC no podía expresarlo de otro modo (sus ejes de scope son
cerrados y `data-chrome` no es uno).
**Neutra en píxel, medida dos veces**: el diff de `generated/` son esas 11
líneas con su default verbatim y NADA más; y el A/B en navegador — cada token
nuevo forzado a `initial` con las declaraciones viejas restauradas inline —
dio computed idéntico en los tres mockups de `/blocks/feature-split` (ambos
cromos; el bisel conserva `shadow-5` y el marco `shadow-4`, que era justo lo
que la acuñación protegía) y en `text-scramble`. ⚠ `mockup` **no tiene ruta de
demo propia**: se mide donde de verdad monta, en `/blocks/*`.
**Números**: `no contract entry` **18 → 16**; todo lo demás QUIETO — reach
73 %, 65 al 100 %, ledger 1154 · 0 NEW · 0 STALE, audit **162 PASS / 4
NEEDS-WORK** idéntico, `--names` 0 desviadas, `docs:check` 0/0. El suelo NO
sube: la ley cerraba un hueco de CONTRATO, no de alcance (esas declaraciones ya
puntuaban `public` — lo que faltaba era declarar el NOMBRE).
**Muta-prueba de tres caras**: `var(--mockup-inventado, red)` (propio, CON
fallback) → **rojo nombrando la clave** · el mismo inyectado con un nombre
AJENO y fallback → **verde** · una entrada del registro que deja de describir
un consumo → **rojo STALE nombrándola**.
**Efecto lateral que hay que saber**: el crudo `var(--primitive-neutral-12)`
migró verbatim de `mockup.css:73` a `base.ts` como default de
`mockup.phone-bezel`. Sigue siendo deuda R-4.6 — pero la regla escanea CSS de
componente y ya no lo ve, así que la fila de `mockup` perdió ese error **sin
que nadie lo arreglara**. Queda anotado en la propia clave y en
`next-features.md`; `mockup` sigue NEEDS-WORK por su E-2.3 (README ausente).
### FIRMA EJECUTADA (2026-08-26) — los 318 privados no-derivados
**Qué se firmó**: la «clasificación de los 318», en tres piezas —
@ -205,10 +269,12 @@ commits: `2c61946fb` (clase `structural`) · `ef47f3447` (deuda B) ·
- **Pendiente de TU firma, nombrado en §13 y en la cabecera del ledger**:
~~los **318 privados no-derivados**~~ — **✅ FIRMADA Y EJECUTADA 2026-08-26**
(`e812d3ffd` · `a74aeec7b` + F3; ver §«FIRMA EJECUTADA — los 318» arriba) ·
QUEDA el idioma **`var(--{c}-x, fallback)` sin contrato** (`mockup` ×11,
`text-scramble` ×2 — otro guard sanciona el idioma, pero no alcanza a
`applyTheme` ni al centinela) y con él la **re-clasificación de `palabras`**
(117 nombres `--palabras-*` puntuados `public` que son canal de VALOR).
~~el idioma **`var(--{c}-x, fallback)` sin contrato**~~ — **✅ FIRMADA Y
EJECUTADA 2026-08-26** (§«FIRMA EJECUTADA — la ley del espacio cerrado»
arriba) · QUEDA la **re-clasificación de `palabras`** (117 nombres
`--palabras-*` puntuados `public` que son canal de VALOR) y las DOS deudas
de eje propio que la ley nombró (14 canales → `--_{c}-*` · cableado
`EidosConfig.motion`), ambas detalladas en `next-features.md`.
· Deuda menor registrada: las fichas de `docs/audit/theming/`
esperan un `--report` supervisado post-`structural` (tarea aparte).

@ -6,15 +6,15 @@
*/
[data-box][data-mockup] {
--_mockup-radius: var(--mockup-radius, var(--radius-xl));
--_mockup-border: var(--mockup-border, var(--color-border-subtle));
--_mockup-radius: var(--mockup-radius);
--_mockup-border: var(--mockup-border);
display: block;
inline-size: var(--box-width, 100%);
border: 1px solid var(--_mockup-border);
border-radius: var(--_mockup-radius);
background: var(--mockup-bg, var(--color-surface-raised));
box-shadow: var(--mockup-shadow, var(--shadow-4));
background: var(--mockup-bg);
box-shadow: var(--mockup-shadow);
overflow: hidden;
}
@ -24,7 +24,7 @@
display: flex;
align-items: center;
gap: var(--space-2);
block-size: var(--mockup-bar-height, 2.25rem);
block-size: var(--mockup-bar-height);
padding-inline: var(--space-3);
background: var(--color-surface-default);
border-block-end: 1px solid var(--_mockup-border);
@ -69,16 +69,16 @@
/* ── Phone bezel ─────────────────────────────────────────────────────────── */
[data-box][data-mockup][data-chrome='phone'] {
--_mockup-radius: var(--mockup-phone-radius, 2.75rem);
--_mockup-bezel: var(--mockup-phone-bezel, var(--primitive-neutral-12));
--_mockup-bezel-width: var(--mockup-phone-bezel-width, 0.6rem);
--_mockup-radius: var(--mockup-phone-radius);
--_mockup-bezel: var(--mockup-phone-bezel);
--_mockup-bezel-width: var(--mockup-phone-bezel-width);
position: relative;
max-inline-size: 22rem;
margin-inline: auto;
border: var(--_mockup-bezel-width) solid var(--_mockup-bezel);
background: var(--_mockup-bezel);
box-shadow: var(--mockup-shadow, var(--shadow-5));
box-shadow: var(--mockup-phone-shadow);
}
[data-box][data-mockup][data-chrome='phone'] [data-mockup-screen] {

@ -8,8 +8,8 @@
*/
[data-text-scramble] {
font-family: var(--text-scramble-font, var(--font-mono, ui-monospace, monospace));
font-size: var(--text-scramble-size, inherit);
font-family: var(--text-scramble-font);
font-size: var(--text-scramble-size);
}
[data-text-scramble] p {

@ -1402,6 +1402,15 @@
--image-error-fg: var(--color-content-muted);
--image-error-font-size: var(--size-sm-font-size);
--image-error-line-height: var(--leading-ui);
--mockup-radius: var(--radius-xl);
--mockup-border: var(--color-border-subtle);
--mockup-bg: var(--color-surface-raised);
--mockup-shadow: var(--shadow-4);
--mockup-bar-height: 2.25rem;
--mockup-phone-radius: 2.75rem;
--mockup-phone-bezel: var(--primitive-neutral-12);
--mockup-phone-bezel-width: 0.6rem;
--mockup-phone-shadow: var(--shadow-5);
--meter-height-xs: 2px;
--meter-height-sm: 4px;
--meter-height-md: 6px;
@ -4438,6 +4447,8 @@
--text-gradient-border-padding-inline: var(--space-2);
--text-gradient-border-width: var(--border-width);
--text-gradient-border-inner-bg: var(--color-surface-default);
--text-scramble-font: var(--font-mono, ui-monospace, monospace);
--text-scramble-size: inherit;
--knob-gap-sm: var(--size-sm-gap);
--knob-gap-md: var(--size-md-gap);
--knob-gap-lg: var(--size-lg-gap);

@ -1562,6 +1562,32 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'error-font-size': 'var(--size-sm-font-size)',
'error-line-height': 'var(--leading-ui)'
},
// Device chrome for framed media. These nine knobs lived as INLINE FALLBACKS
// in `mockup.css` until 2026-08-26 («la ley del espacio cerrado»): the
// `--mockup-*` names were public and consumed, yet no contract entry declared
// them, so the default sat at the point of use where no theme could see it.
// Every default below is the retired fallback, verbatim.
mockup: {
radius: 'var(--radius-xl)',
border: 'var(--color-border-subtle)',
bg: 'var(--color-surface-raised)',
shadow: 'var(--shadow-4)',
'bar-height': '2.25rem',
// The phone bezel is a DIFFERENT surface and its knobs say so. `shadow`
// used to carry TWO defaults under one name — `--shadow-4` on the frame,
// `--shadow-5` on the bezel — which is exactly the lie a closed namespace
// kills: a theme setting `--mockup-shadow` could not know which of the two
// it was moving. `phone-shadow` is minted here so each surface keeps the
// elevation it had, and the split is now readable in the name.
'phone-radius': '2.75rem',
// STILL R-4.6 debt, only at a new address: this raw-layer ref arrived
// verbatim from the retired fallback (`mockup.css:73`), so the rule that
// used to name it — it scans component CSS — no longer sees it. The bezel
// wants a role slot; that is another axis's work, not this signature's.
'phone-bezel': 'var(--primitive-neutral-12)',
'phone-bezel-width': '0.6rem',
'phone-shadow': 'var(--shadow-5)'
},
meter: {
'height-xs': '2px',
'height-sm': '4px',
@ -7447,6 +7473,14 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
// The seed hardcoded #000 here; the hole follows the theme's surface.
'border-inner-bg': 'var(--color-surface-default)'
},
// Both knobs were inline fallbacks in `text-scramble.css` until 2026-08-26
// («la ley del espacio cerrado») — public names with no contract entry.
// Defaults verbatim: the mono stack the seed used, and `inherit`, which keeps
// the flicker at the size of the text it replaces unless a theme says otherwise.
'text-scramble': {
font: 'var(--font-mono, ui-monospace, monospace)',
size: 'inherit'
},
knob: {
'gap-sm': 'var(--size-sm-gap)',
'gap-md': 'var(--size-md-gap)',

@ -139,22 +139,116 @@ function readRecipeValueReferences(): string {
function collectOwnPublicVariables(component: string, css: string): readonly string[] {
const ownPrefix = `--${component}-`
// Only vars consumed WITHOUT a fallback must be recipe-declared. A
// `var(--x, default)` is runtime-optional by construction — the fallback IS
// the default — which is how soma writes dynamic values into the recipe
// surface (e.g. the nav-menu indicator height, `translateY(var(--…-h, 0px))`;
// the STUMBLES #7 soma→eidos var class). A bare `var(--x)` breaks if
// undeclared, so those still require a declaration.
const NO_FALLBACK = /var\(\s*(--[a-z][a-z0-9-]+)\s*\)/g
// EVERY consumption of the component's OWN public vocabulary, with a fallback
// or without one (firma «la ley del espacio cerrado», 2026-08-26). The guard
// used to read only the no-fallback form, on the reasoning that
// `var(--x, default)` is runtime-optional by construction. That reasoning is
// retired for the OWN prefix: the fallback is a safety device for the VALUE,
// not an escape from the contract. Foreign vocabulary (a borrowed
// `--field-control-height-md`) and system names are untouched by the prefix
// filter and keep their own rules.
const ANY_VAR = /var\(\s*(--[a-z][a-z0-9-]+)\s*[,)]/g
return [
...new Set(
[...css.matchAll(NO_FALLBACK)]
[...css.matchAll(ANY_VAR)]
.map((match) => match[1])
.filter((name) => name.startsWith(ownPrefix))
)
].sort()
}
/**
* THE PUBLIC NAMESPACE IS CLOSED OVER THE CONTRACT (firma «la ley del espacio
* cerrado», 2026-08-26): a `--{c}-*` of the component's own vocabulary exists
* **if and only if** `base.ts` declares it. There is no third state.
*
* This registry is what the law does with the one class it cannot close from
* here: the VALUE CHANNELS that soma or a wrapper writes per instance, inline.
* Their doctrinal destination is ALREADY signed — a value channel is
* `--_{c}-*` (firma de los 318, 2026-08-26, class `channel`: the private nobody
* declares because the instance writes it, and «a theme must not reach it») —
* but that rename is a cross-layer codemod through soma providers, morfos and
* READMEs, so it rides its own axis. Until it lands, the names are COUNTED
* here: named, dated, with their destination written down.
*
* It is the ledger pattern of the house, and it carries the ledger's asymmetry:
* an entry does NOT bless a name, it counts one, and the list may only SHRINK.
* A registered name that is no longer consumed — renamed, deleted — or that has
* gained a contract entry is reported STALE and stays red until its line is
* deleted, exactly as `theming-census-debt.ts` treats a debt that got fixed.
*/
const PENDING_PRIVATE_RENAME: Readonly<Record<string, string>> = {
// background.svelte writes all five through `dom.writeProperty` (pointer
// parallax + scroll progress).
'--background-pointer-x': 'pending rename to --_background-* — value channel',
'--background-pointer-y': 'pending rename to --_background-* — value channel',
'--background-pointer-px': 'pending rename to --_background-* — value channel',
'--background-pointer-py': 'pending rename to --_background-* — value channel',
'--background-progress': 'pending rename to --_background-* — value channel',
// color-swatch.svelte writes it inline from the `color` prop. RETIRED from
// the contract on 2026-08-23 by a MEASURED signature (component README):
// setting it at `:root` leaves the chip where it was, because the element's
// inline declaration always wins. That signature stands — this entry counts
// the name, it does not reopen the decision.
'--color-swatch-fill': 'pending rename to --_color-swatch-fill — value channel, firma 2026-08-23',
// emoji-picker-content.svelte writes it inline from `opts.columns`.
'--emoji-picker-columns': 'pending rename to --_emoji-picker-columns — value channel',
// knob-provider.svelte.ts publishes all four in one inline style.
'--knob-angle': 'pending rename to --_knob-* — value channel',
'--knob-progress': 'pending rename to --_knob-* — value channel',
'--knob-start-angle': 'pending rename to --_knob-* — value channel',
'--knob-sweep': 'pending rename to --_knob-* — value channel',
// menubar-provider.svelte.ts pre-measures the anchor and setProperty's it.
'--menubar-panel-anchor-width': 'pending rename to --_menubar-panel-anchor-width — value channel',
// navigation-menu-provider.svelte.ts writes the active trigger's height.
'--navigation-menu-indicator-h': 'pending rename to --_navigation-menu-indicator-h — value channel',
// text-gradient.svelte writes it inline from `animationSpeed` — the recipe's
// own base.ts header already documents why it is not declared.
'--text-gradient-duration': 'pending rename to --_text-gradient-duration — value channel',
// NOT a value channel: nobody writes it at runtime. Its home is the motion
// channel — the default belongs to `EidosConfig.motion`, not to a recipe
// contract, and declaring it in base.ts would contradict `motion`'s signed
// `structural` census entry. Pending that wiring, counted here.
'--motion-stagger-each-default':
'pending motion-channel wiring — the default belongs to EidosConfig.motion'
}
/** Retire the entry the day the name is renamed or declared. */
const PENDING_DESTINATION = 'firma 2026-08-26; delete the entry when the rename lands'
/**
* Classify every own-prefix public consumption in the catalogue against the
* contract: `missing` = out of contract and unregistered (the law's red),
* `stale` = registered but no longer an undeclared consumption (the ledger's
* red, symmetric).
*/
function auditClosedNamespace(): { missing: string[]; stale: string[] } {
const missing: string[] = []
const seen = new Set<string>()
const layerDeclared = collectLayerDeclaredVariables()
for (const { component, css } of readComponentCssFiles()) {
const declared = new Set(
tokenKeys(RECIPE_TOKENS[component] ?? {}).map((key) => recipeTokenName(component, key))
)
for (const name of layerDeclared) declared.add(name)
for (const name of collectOwnPublicVariables(component, css)) {
if (declared.has(name)) continue
if (name in PENDING_PRIVATE_RENAME) {
seen.add(name)
continue
}
missing.push(`${component}: ${name}`)
}
}
return {
missing,
stale: Object.keys(PENDING_PRIVATE_RENAME).filter((name) => !seen.has(name))
}
}
/**
* Public variables a SHARED LAYER declares (`lib/*.css`). A layer whose
* vocabulary does NOT share a component prefix (`--list-*`,
@ -178,24 +272,26 @@ function collectLayerDeclaredVariables(): ReadonlySet<string> {
}
describe('Eidos recipe CSS contract', () => {
it('declares every public component variable consumed by component CSS', () => {
const missing: string[] = []
const layerDeclared = collectLayerDeclaredVariables()
for (const { component, css } of readComponentCssFiles()) {
const declared = new Set(
tokenKeys(RECIPE_TOKENS[component] ?? {}).map((key) =>
recipeTokenName(component, key)
)
)
for (const name of layerDeclared) declared.add(name)
for (const name of collectOwnPublicVariables(component, css)) {
if (!declared.has(name)) missing.push(`${component}: ${name}`)
}
}
it('declares every public component variable consumed by component CSS (closed namespace)', () => {
const { missing } = auditClosedNamespace()
expect(
missing,
`${missing.length} own \`--{c}-*\` name(s) consumed outside the contract. A fallback ` +
'does not excuse one (firma «la ley del espacio cerrado», 2026-08-26): declare the ' +
'key in `lib/recipes/base.ts` with the fallback as its default, then retire the ' +
'fallback at the point of use.'
).toEqual([])
})
expect(missing).toEqual([])
it('keeps no stale entry in the pending-rename registry', () => {
const { stale } = auditClosedNamespace()
expect(
stale,
`${stale.length} registry entry/entries no longer describe an undeclared consumption ` +
`— renamed, declared or deleted. ${PENDING_DESTINATION}: remove those lines from ` +
'`PENDING_PRIVATE_RENAME`. The registry counts names, it does not bless them, ' +
'and it may only shrink.'
).toEqual([])
})
it('does not declare recipes for missing component CSS files', () => {

Loading…
Cancel
Save

Powered by TurnKey Linux.