From 6499eb2a4ca8a2a21a59bafe43eb77e23259fbef Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 26 Aug 2026 01:40:59 +0200 Subject: [PATCH] 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 --- docs/canon/recipe-contract.md | 67 +++++++- docs/next-features.md | 51 +++++- docs/process/CONTINUE-theming.md | 84 ++++++++-- src/uix/eidos/components/mockup/mockup.css | 18 +-- .../text-scramble/text-scramble.css | 4 +- src/uix/eidos/generated/base.css | 11 ++ src/uix/eidos/lib/recipes/base.ts | 34 ++++ src/uix/eidos/recipe-css-contract.test.ts | 146 +++++++++++++++--- 8 files changed, 356 insertions(+), 59 deletions(-) diff --git a/docs/canon/recipe-contract.md b/docs/canon/recipe-contract.md index e657bd9cb..cc772ba16 100644 --- a/docs/canon/recipe-contract.md +++ b/docs/canon/recipe-contract.md @@ -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: */` annotation on the declaration, and the component-level `R-5.1 exception:` / diff --git a/docs/next-features.md b/docs/next-features.md index 083db41c8..9c7f220de 100644 --- a/docs/next-features.md +++ b/docs/next-features.md @@ -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 diff --git a/docs/process/CONTINUE-theming.md b/docs/process/CONTINUE-theming.md index 947b7e25b..1b15c5f7c 100644 --- a/docs/process/CONTINUE-theming.md +++ b/docs/process/CONTINUE-theming.md @@ -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). diff --git a/src/uix/eidos/components/mockup/mockup.css b/src/uix/eidos/components/mockup/mockup.css index ecf529f72..c232962b8 100644 --- a/src/uix/eidos/components/mockup/mockup.css +++ b/src/uix/eidos/components/mockup/mockup.css @@ -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] { diff --git a/src/uix/eidos/components/text-scramble/text-scramble.css b/src/uix/eidos/components/text-scramble/text-scramble.css index 1452938c9..c468676ca 100644 --- a/src/uix/eidos/components/text-scramble/text-scramble.css +++ b/src/uix/eidos/components/text-scramble/text-scramble.css @@ -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 { diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 206b0ab0b..afe42a9f5 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -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); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index e7789a660..c6fb19ed1 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -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)', diff --git a/src/uix/eidos/recipe-css-contract.test.ts b/src/uix/eidos/recipe-css-contract.test.ts index cc8fc6f19..a0a618d80 100644 --- a/src/uix/eidos/recipe-css-contract.test.ts +++ b/src/uix/eidos/recipe-css-contract.test.ts @@ -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> = { + // 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() + 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 { } 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', () => {