uix(text-gradient): temable — 67 % → 100 %, y la ficha se equivocaba en las TRES

7 claves en una entrada NUEVA de `recipes/base.ts`. Censo 67 % → **100 %**
(10 knobs, 10 públicos, 0 literales). Global 2.634 → 2.638 públicos.

Tercero de la familia text-* con la forma de text-circular / text-focus: cuatro
públicos «de facto» consumidos con fallback y **ninguno declarado**, más tres
literales. Sin eje `size` ni privados propios, así que declarar no puede matar
una escala.

LO QUE LA PROPUESTA §4 NO ACERTABA — y se resolvió midiendo contra el CATÁLOGO,
no contra el CSS de partida:

- **`weight` era deriva de nombre.** El catálogo tiene 77 claves `font-weight`
  contra 4 `weight` a secas — y dos de esas cuatro las escribí ayer en esta
  misma familia. Pasa a `font-weight`, y su valor al paso de escala que ya
  pintaba: `500` ES `var(--font-weight-medium)`.
- **El `calc(100% - 2px)` del hueco no son dos knobs de eje físico** (la ficha
  los marcaba con ⚠ «pártelo en ejes lógicos») sino UNO: los 2 px son el trazo
  del marco asomando a cada lado. El knob es `border-width` —con
  `var(--border-width)`, el paso `thin`, no un literal `1px` igual a un paso de
  la escala— y el hueco DERIVA de él.
- **`blur(4px)` va a la escala de blur.** `blur` es familia MÉTRICA del eje
  `scaling` (theming §23): un literal es ciego al zoom global. `--blur-sm` es
  `calc(4px * var(--scaling))` — el mismo píxel al 100 y el correcto al 90/110.
  Precedente: `dialog`/`drawer.overlay-blur`, también backdrop-filter.

Y el shorthand físico `padding: var(--space-1) var(--space-2)` se parte en ejes
lógicos (`border-padding-block` / `-inline`), que es la normalización firmada el
2026-07-06; el público de facto `border-padding` era un knob con dos ejes dentro.

FUERA DEL CONTRATO POR NATURALEZA: `--text-gradient-duration` y
`--_text-gradient-play-direction` los escribe el wrapper INLINE desde
`animationSpeed` y `yoyo`. Ningún token gana a un estilo inline, así que
declararlos sería acuñar dos nombres que mienten — misma clase que los cuatro
ausentes de `knob`, el `item-gap` de `carousel` y el `preview-z` de `drag-drop`.

INSTRUMENTO — dos puntos ciegos arreglados ANTES de medir (la lección de F2-B
aplicada por adelantado, no después):

- La demo arranca con `showBorder=false`, así que sonda y centinela veían **2
  nodos** y NINGUNO de los tres tokens del marco. La sonda gana un mapa
  `DEMO_VARIANTS` (chips de variante re-pulsados tras CADA recarga — recarga una
  vez por talla) y el centinela una entrada `openWith` que no abre un panel sino
  que ENCIENDE UNA VARIANTE: uso nuevo del mismo gancho, sin tocar su lógica.
- La sonda no leía `backdropFilter`, que `KNOB_PROPS` del censo sí cuenta y el
  centinela ya leía: un token de fondo podía moverse entre las dos corridas sin
  que el diff lo viera. Con la propiedad y la variante: **4 nodos**.

Hover: 0 nodos medidos, y es correcto — las cuatro partes son `<span>` y el
único efecto de hover es `animation-play-state`, que no es knob.

LO QUE ABRE (→ next-features §13): **la escala de `font-size` no tiene
`2xl`/`3xl`/`4xl`** —es `xxs..xxxl`— y tres sitios los referencian: el token
`text-focus.word-size` que subí ayer (`var(--font-size-4xl, 3rem)`, pintando el
fallback: segundo fantasma de la clase `--radius-xs`, y en el contrato) y los
escenarios de las demos de `text-gradient` y `text-blur`, que sin fallback dejan
la declaración INVÁLIDA y enseñan un efecto tipográfico a 14 px. Y
`--uix-font-size-*` no existe: 37 referencias en el harness del sitio, cero
declaraciones. Ninguno se toca aquí: se miden, se anotan y van a su commit.

Demo: pestaña **Tokens** (`TokensPanel`, contrato vivo) y fuera la tabla de
tokens escrita a mano de la pestaña API — era una segunda fuente de verdad y
este renombrado la dejaba obsoleta al instante.

Gates: sonda antes/después **0 diffs** en 720 valores × 7 estados (4 nodos) ·
centinela **7/7 sin una sola excepción** · censo --only 100 % · --names 7/7
conformes · component-audit PASS · eidos-lint 0 invalid · suite eidos con el
único rojo conocido (`skin-media-player`, lint.test.ts) · rtl:check 0 ·
docs:check 0 · `check` con 72 errores globales y **0 en los ficheros tocados**.
Prettier: los seis ficheros editados ya estaban sucios en HEAD (el repo tiene
2.392), no se reformatean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent e7219bb909
commit 97a6f03d76

@ -10,9 +10,9 @@
- **Medido**: 2026-08-22 · **162 recetas** con CSS + **8 componentes sin receta** = 170 fichas, el árbol entero de `eidos/components/`
- **La pregunta**: ¿cuánto de la apariencia de cada componente puede cambiar un tema **sin tocar el sistema ni la receta**?
- **Alcance global**: **56%** — 2634 de 4678 knobs pasan por un token público del componente
- **Reparto**: público 2634 · privado 338 · global 1145 · literal 561 · sistema transversal 509 _(fuera del ratio)_
- **Sin token público propio**: 25 · **alcance < 20 %**: 19 · **alcance 100 %**: 16 · **con eje `size`**: 58
- **Alcance global**: **56%** — 2638 de 4679 knobs pasan por un token público del componente
- **Reparto**: público 2638 · privado 338 · global 1145 · literal 558 · sistema transversal 509 _(fuera del ratio)_
- **Sin token público propio**: 24 · **alcance < 20 %**: 19 · **alcance 100 %**: 17 · **con eje `size`**: 58
## Cómo se lee
@ -91,12 +91,12 @@ theming §6.7 lo pone delante (`hover-bg`). Las fichas proponen la forma
| 19 | [float-panel](./float-panel.md) | 20 | 59% | 53 | 28 |
| 20 | [field](./field.md) | 19 | 72% | 73 | 60 |
## Sin ningún token público propio (25)
## Sin ningún token público propio (24)
Ni una clave pública en `lib/recipes/base.ts` — alguno tiene privados forward
(`_palette-*`), que no son contrato: un tema no puede nombrarlos.
[`alert-dialog`](./alert-dialog.md) · [`aspect-ratio`](./aspect-ratio.md) · [`auto-grid`](./auto-grid.md) · [`avatar`](./avatar.md) · [`cascade`](./cascade.md) · [`css-field`](./css-field.md) · [`date-picker`](./date-picker.md) · [`display`](./display.md) · [`field-langs`](./field-langs.md) · [`heading`](./heading.md) · [`highlight`](./highlight.md) · [`mask-field`](./mask-field.md) · [`mockup`](./mockup.md) · [`month-grid`](./month-grid.md) · [`motion`](./motion.md) · [`number-field`](./number-field.md) · [`palabras`](./palabras.md) · [`range-calendar`](./range-calendar.md) · [`stack`](./stack.md) · [`text`](./text.md) · [`text-blur`](./text-blur.md) · [`text-gradient`](./text-gradient.md) · [`text-scramble`](./text-scramble.md) · [`wrap`](./wrap.md) · [`year-grid`](./year-grid.md)
[`alert-dialog`](./alert-dialog.md) · [`aspect-ratio`](./aspect-ratio.md) · [`auto-grid`](./auto-grid.md) · [`avatar`](./avatar.md) · [`cascade`](./cascade.md) · [`css-field`](./css-field.md) · [`date-picker`](./date-picker.md) · [`display`](./display.md) · [`field-langs`](./field-langs.md) · [`heading`](./heading.md) · [`highlight`](./highlight.md) · [`mask-field`](./mask-field.md) · [`mockup`](./mockup.md) · [`month-grid`](./month-grid.md) · [`motion`](./motion.md) · [`number-field`](./number-field.md) · [`palabras`](./palabras.md) · [`range-calendar`](./range-calendar.md) · [`stack`](./stack.md) · [`text`](./text.md) · [`text-blur`](./text-blur.md) · [`text-scramble`](./text-scramble.md) · [`wrap`](./wrap.md) · [`year-grid`](./year-grid.md)
## Tabla completa (162 recetas, por alcance ascendente)
@ -176,7 +176,6 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [tag-group](./tag-group.md) | 65% | 46 | 28 | 14 | 0 | 1 | 3 | 91 | y |
| [image-adjustments](./image-adjustments.md) | 67% | 18 | 10 | 0 | 4 | 1 | 3 | 12 | – |
| [virtual-list](./virtual-list.md) | 67% | 13 | 8 | 0 | 0 | 4 | 1 | 12 | – |
| [text-gradient](./text-gradient.md) | 67% | 9 | 6 | 0 | 0 | 3 | 0 | 0 | – |
| [separator](./separator.md) | 67% | 6 | 4 | 0 | 0 | 2 | 0 | 3 | – |
| [natural-time-picker](./natural-time-picker.md) | 67% | 77 | 51 | 5 | 14 | 6 | 1 | 62 | y |
| [listbox](./listbox.md) | 68% | 37 | 23 | 9 | 1 | 1 | 3 | 21 | – |
@ -246,6 +245,7 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [code](./code.md) | 100% | 13 | 7 | 0 | 0 | 0 | 6 | 6 | – |
| [date-field](./date-field.md) | 100% | 11 | 11 | 0 | 0 | 0 | 0 | 21 | y |
| [time-field](./time-field.md) | 100% | 11 | 11 | 0 | 0 | 0 | 0 | 21 | y |
| [text-gradient](./text-gradient.md) | 100% | 10 | 10 | 0 | 0 | 0 | 0 | 7 | – |
| [box](./box.md) | 100% | 9 | 9 | 0 | 0 | 0 | 0 | 43 | – |
| [label](./label.md) | 100% | 8 | 2 | 0 | 0 | 0 | 6 | 2 | – |
| [text-focus](./text-focus.md) | 100% | 8 | 8 | 0 | 0 | 0 | 0 | 8 | – |

@ -5,9 +5,9 @@
> Vista de conjunto: [README](./README.md) · método y protocolo:
> [`PLAN-theming.md`](../../process/PLAN-theming.md) §1, §2, §7.
- **Medido**: 2026-08-20 · **Alcance**: **67%** — 6 de 9 knobs por token público
- **Knobs de apariencia**: 9 — público 6 · privado 0 · global 0 · literal 3 · sistema 0 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): **sin entrada en `base.ts`**
- **Medido**: 2026-08-22 · **Alcance**: **100%** — 10 de 10 knobs por token público
- **Knobs de apariencia**: 10 — público 10 · privado 0 · global 0 · literal 0 · sistema 0 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 7 pública(s) — `radius`, `font-weight`, `blur`, `border-padding-block`, `border-padding-inline`, `border-width`, `border-inner-bg`
- **Eje `size`**: no · **ficheros**: `text-gradient.css`
## 1. Knobs fuera de alcance
@ -20,13 +20,9 @@ _Ninguno._
_Ninguno._
### 1.3 Literales (3)
### 1.3 Literales (0)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `text-gradient.css:27` | `[data-text-gradient]` | `backdrop-filter` | `blur(4px)` |
| 2 | `text-gradient.css:56` | `[data-text-gradient-border-inner]` | `width` | `calc(100% - 2px)` |
| 3 | `text-gradient.css:57` | `[data-text-gradient-border-inner]` | `height` | `calc(100% - 2px)` |
_Ninguno._
## 2. Sistema transversal (0) — informativo, fuera del ratio
@ -42,22 +38,7 @@ Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inl
## 4. Propuesta de corrección
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (1)
Valor **verbatim** del CSS de hoy: el default no se mueve, sólo cambia quién
puede moverlo. Nombres derivados de recipe-contract §1 (ejes lógicos, talla
al final) y theming §6.7 (slots de color, modificador delante). Un token con
DOS valores distintos es una colisión de nombre: son dos knobs, o el nombre
no distingue lo que debería — se marca `⚠`.
| token (`--text-gradient-…`) | scope TSC | valor propuesto | usos |
| --- | --- | --- | ---: |
| `backdrop-filter` | `root` | `blur(4px)` | 1 |
### 4.2 Sin nombre mecánico (2)
- **⚠ decisión: `width` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 1: `width`.
- **⚠ decisión: `height` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 1: `height`.
_Nada que proponer: no hay knobs fuera de alcance._
### 4.4 Lo que hay que comprobar a mano (PLAN-theming §1.3 · §7.4)
@ -71,6 +52,50 @@ no distingue lo que debería — se marca `⚠`.
<!-- veredicto:start -->
_(pendiente — lo escribe el autor; se conserva al regenerar)_
**EJECUTADO 2026-08-22 — 67 % → 100 %**, 7 claves en una entrada NUEVA.
Tercero de la familia text-* con la misma forma que `text-circular` y
`text-focus`: cuatro públicos consumidos a través de un fallback y **ninguno
declarado**, más tres literales. Sin eje `size` y sin privados propios, así
que declarar no puede matar una escala.
Lo que la propuesta §4 generada NO acertaba, y se decidió midiendo:
- **`weight` era deriva de nombre.** El catálogo tiene 77 claves
`font-weight` contra 4 `weight` a secas —y dos de esas cuatro las escribió
la sesión del 22 en esta misma familia—. El público de facto
`--text-gradient-weight` pasa a `font-weight`, y su valor al paso de la
escala que ya pintaba: `500` **es** `var(--font-weight-medium)`.
- **El `calc(100% - 2px)` del hueco no son dos knobs de eje físico** (que es
lo que la propuesta marcaba con ⚠), sino UNO: los 2 px son el trazo del
marco asomando a cada lado. El knob es `border-width` —con
`var(--border-width)`, el paso `thin` de la escala, no un literal `1px`
igual a un paso— y el hueco se deriva:
`calc(100% - var(--text-gradient-border-width) * 2)`.
- **El `blur(4px)` va a la escala de blur, no a un literal.** `blur` es
familia MÉTRICA del eje `scaling` (theming §23): un literal es ciego al zoom
global. `var(--blur-sm)` = `calc(4px * var(--scaling))` — idéntico a
scaling 100, correcto al 90 y al 110. Precedente: `dialog`/`drawer`
`overlay-blur`, que también alimentan un `backdrop-filter`.
- **El shorthand físico `padding: var(--space-1) var(--space-2)` se parte en
ejes lógicos** (`border-padding-block` / `-inline`), que es la normalización
firmada el 2026-07-06; el público de facto `border-padding` era un knob con
dos ejes dentro.
**Fuera del contrato por naturaleza — canal de valor, no superficie de tema**:
`--text-gradient-duration` y `--_text-gradient-play-direction` los escribe el
wrapper INLINE desde `animationSpeed` y `yoyo`, y ningún token gana a un
estilo inline. Misma clase que los cuatro ausentes de `knob`, el `item-gap` de
`carousel` y el `preview-z` de `drag-drop`. (No entran en el ratio: el censo
no cuenta `animation-*` como knob de apariencia.)
**Instrumento — dos puntos ciegos arreglados antes de medir**, porque un gate
verde sobre dos nodos no prueba nada: la demo arranca con `showBorder=false`,
así que sonda y centinela veían 2 nodos y NINGUNO de los tres tokens del marco;
y la sonda no leía `backdropFilter` (el censo sí lo cuenta como knob). Con el
chip de variante encendido y la propiedad añadida: **4 nodos**, 720 valores
× 7 estados, **0 diffs**, y el centinela **7/7 sin una sola excepción**.
Hover: 0 nodos medidos y es correcto — las cuatro partes son `<span>` y el
único efecto de hover del componente es `animation-play-state`, que no es knob.
<!-- veredicto:end -->

@ -902,5 +902,36 @@ Lo que `code-block` añadió (2026-08-21):
así que no hay daño que reparar). El handoff queda corregido con la versión
que sí mira.
Lo que `text-gradient` destapó (2026-08-22):
- **⚠ La escala de `font-size` NO tiene `2xl` / `3xl` / `4xl`**, y tres
sitios los referencian. La escala emitida es
`xxs · xs · sm · md · lg · xl · xxl · xxxl`; el hábito Tailwind escribe
`2xl`. Es la MISMA clase que `--radius-xs` (el hallazgo de `text-focus`),
ahora con tres ocurrencias más y una de ellas ya EN EL CONTRATO:
| dónde | referencia | qué pinta hoy |
| --- | --- | --- |
| `recipes/base.ts > text-focus.word-size` | `var(--font-size-4xl, 3rem)` | el fallback `3rem` — token SHIPPED el 2026-08-22 |
| demo `text-gradient` (escenario) | `var(--font-size-3xl)` | **nada**: declaración inválida, el texto sale a 14 px |
| demo `text-blur` (escenario) | `var(--font-size-2xl)` | **nada**: mismo caso |
Las dos demos son el escaparate de un efecto de TIPOGRAFÍA y lo enseñan a
tamaño de párrafo. `text-focus.word-size` no mueve píxel al corregirse (el
fallback es lo que pinta), pero es una referencia muerta en el contrato: o
toma `var(--font-size-xxxl)` —lo que cambia el default y es firma— o guarda
el `3rem` verbatim, como se hizo con `corner-radius`. **No se toca dentro
del commit de otro componente**; queda aquí, medido, para su propio commit.
- **⚠ `--uix-font-size-*` no existe: 37 referencias en el harness de la
documentación y CERO declaraciones.** El namespace `--uix-*` de
`web/routes/uix/uix.css` declara 111 nombres —`--uix-space-*`,
`--uix-font-sans`…— pero ninguno de tamaño de fuente, y las páginas escriben
`var(--uix-font-size-sm)` (×19), `-xs` (×7), `-1`, `-2`, `-2xl`, `-3xl`,
`-lg`. Cada una de esas declaraciones es inválida y el nodo hereda. Es la
misma clase de defecto un piso más abajo, en el sitio en vez de en el
catálogo — y refuerza el barrido mecánico que el hallazgo de `--radius-xs`
ya pedía: `var(--nombre)` cuyo nombre no está declarado en NINGÚN sitio.
**Deps**: ninguna. Son mejoras del instrumental del eje, ejecutables cuando
estorben.

@ -1,16 +1,17 @@
# CONTINUE — eje Theming «theme-reach» (handoff, act. 2026-08-22)
**Estado: EN EJECUCIÓN — F0 y F1 CERRADAS · F2 en curso, bloque F2-B CERRADO
con su revisión adversarial pasada (2026-08-22).** Alcance global **56 %** (33 % al abrir el eje, 45 % tras F2-A, 50 % tras F2-B,
con su revisión adversarial pasada (2026-08-22); la cola sigue abierta y
`text-gradient` ya está dentro (2026-08-22).** Alcance global **56 %** (33 % al abrir el eje, 45 % tras F2-A, 50 % tras F2-B,
y **+6 puntos al arreglar la MEDICIÓN**, no el código: ver abajo). Sin contrato
**25**; al 100 % **16**; alcance <20 % **19**.
**24**; al 100 % **17**; alcance <20 % **19**.
## LO PRIMERO AL ENTRAR (cinco minutos, en este orden)
1. **Comprueba que el suelo no se ha movido**:
```bash
node --import tsx/esm scripts/theming-census.ts # 162 · 5.187 · 2.634 (56 %) · 25 sin contrato
node --import tsx/esm scripts/theming-census.ts # 162 · 5.188 · 2.638 (56 %) · 24 sin contrato
node --import tsx/esm scripts/theming-census.ts --names # DESVIADAS 0 — la gramática se mantiene
```
@ -94,6 +95,30 @@ el test del suelo.
tres puntos ciegos ya medidos: congela transiciones, su valor grande se capa
contra `min()`, y no ve lo que la demo no monta.
### 3.bis Lo último ejecutado — `text-gradient` (2026-08-22)
**67 % → 100 %**, 7 claves. Detalle y porqués en `PLAN §8` y en el veredicto
§5 de su ficha. Lo que conviene tener delante al seguir con la cola:
- **La ficha §4 falla también contra el CATÁLOGO, no sólo contra la doctrina.**
Proponía conservar `weight` (77 claves del catálogo dicen `font-weight`), y
marcaba `width`/`height` con «⚠ eje físico» cuando el `calc(100% - 2px)`
era UN knob —el trazo del marco— del que el hueco deriva. **Antes de acuñar
un nombre, cuéntalo en `base.ts`**: el catálogo es el que habla.
- **Un literal igual a un paso de escala no se congela: se nombra.**
`1px` → `var(--border-width)`, `blur(4px)` → `var(--blur-sm)`. Y ojo con
`blur`: es familia MÉTRICA del eje `scaling` (theming §23), así que un
literal es ciego al zoom global aunque el computed coincida a scaling 100.
- **Los dos instrumentos ya saben encender una VARIANTE.** La sonda tiene
`DEMO_VARIANTS` (chips que se re-pulsan tras CADA recarga — la sonda recarga
una vez por talla) y el centinela acepta `openWith` apuntando a un chip, no
sólo a un trigger de panel. Sin eso, esta demo daba 2 nodos y los tres tokens
del marco no se medían. **Cuenta los nodos antes de creerte el gate.**
- **La tabla de tokens escrita a mano en la pestaña API es deuda**: este
renombrado la dejó obsoleta al instante. `text-circular`, `text-focus` y
`text-scramble` siguen con la suya, y ninguna de las tres tiene pestaña
`Tokens` (el paso 9 del protocolo se saltó el 22).
### 4. La cola que queda, por alcance
| componente | knobs | nota |
@ -101,7 +126,6 @@ el test del suelo.
| `palabras` | 471 (22 %) | **el mayor del catálogo con diferencia**, sin tocar; D-TH.3 decide si entra |
| `field-langs` | 41 | espera el **mandato Field** (firma 2), que NO es tokenización: manda que los x-field incorporen el wrapper Field — cambia composición y mueve píxel |
| `mockup` | 29 (38 %) | ⚠ **sin ruta de demo (404)**, como `picker-shell`: sin sonda ni guard. Y sale NEEDS-WORK en el audit, preexistente |
| `text-gradient` | 9 (67 %) | el siguiente ejecutable sin decisión: mismo patrón que `text-focus`/`text-circular` |
| `text-scramble` | 4 (50 %) | sus dos knobs son el `1px` de sr-only — **0 % estructural**, como `text-blur`: escribir el veredicto, no tokens |
| `chronos` | 245 (1 %) | el segundo mayor; sin abrir |
| `result` · `banner` · … | varios | ver el censo, `reach < 20 %` = 19 |

@ -455,6 +455,47 @@ de commit. Sin artefacto, el paso no se ha hecho.
## 8. Registro
- 2026-08-22 — **`text-gradient` 67 % → 100 %** (7 claves, entrada nueva).
Tercero de la familia text-* con la forma de `text-circular` / `text-focus`
(públicos «de facto» con fallback y sin declarar), pero la propuesta §4
generada fallaba en las tres decisiones y las tres se resolvieron midiendo
contra el CATÁLOGO, no contra el CSS de partida:
- **`weight` → `font-weight`**: 77 claves `font-weight` en el catálogo
contra 4 `weight` a secas, dos de ellas escritas el mismo 22 en esta
familia. Y `500` **es** `var(--font-weight-medium)`.
- **`calc(100% - 2px)` no son dos knobs de eje físico** (que es lo que la
ficha marcaba con ⚠) sino UNO: los 2 px son el trazo del marco asomando a
cada lado. Knob = `border-width` con `var(--border-width)` (el paso
`thin`, no un literal igual a un paso), y el hueco DERIVA de él.
- **`blur(4px)` → `var(--blur-sm)`**: `blur` es familia MÉTRICA del eje
`scaling` (theming §23) y un literal es ciego al zoom global.
Fuera del contrato por naturaleza: `duration` y `play-direction`, que el
wrapper escribe INLINE desde `animationSpeed`/`yoyo` — misma clase que el
`item-gap` de `carousel` y el `preview-z` de `drag-drop`.
**Dos puntos ciegos del instrumento, arreglados ANTES de medir** (la lección
de F2-B aplicada por adelantado): la demo arranca con `showBorder=false`, así
que sonda y centinela veían 2 nodos y NINGUNO de los tres tokens del marco;
y la sonda no leía `backdropFilter`, que el censo sí cuenta como knob. La
sonda gana un mapa `DEMO_VARIANTS` (chips de variante que se re-pulsan tras
cada recarga) y el centinela una entrada `openWith` que no abre un panel sino
que ENCIENDE UNA VARIANTE — un uso nuevo del mismo gancho.
Gates: 4 nodos · 720 valores × 7 estados · **0 diffs** · centinela **7/7 sin
excepciones** · censo `--only` 100 % y global 2.634 → 2.638 públicos ·
`--names` 7/7 conformes · audit PASS · eidos-lint 0 invalid · suite eidos con
el único rojo conocido (`skin-media-player`) · rtl 0 · docs 0 · `check` sin
errores en los ficheros tocados. Demo con pestaña **Tokens** (y fuera la
tabla de tokens escrita a mano, que este mismo renombrado dejaba obsoleta).
Abre (→ next-features §13): **la escala de `font-size` no tiene
`2xl`/`3xl`/`4xl`** y tres sitios los referencian —uno ya en el contrato
(`text-focus.word-size`, pintando su fallback) y dos escenarios de demo que
enseñan un efecto tipográfico a 14 px—; y **`--uix-font-size-*` no existe**:
37 referencias en el harness del sitio, cero declaraciones.
- 2026-08-22 — **Bloque F2-B: 21 componentes tokenizados + 5 adjudicados como
«0 % estructural».** Global **45 % → 50 %**; sin contrato 49 → 29; al 100 %
7 → 10; alcance <20 % 43 → 22. Uno por commit, protocolo §7 entero, sonda

@ -50,11 +50,25 @@ const PROPS = [
'stroke',
'outlineColor',
'outlineWidth',
'filter'
'filter',
'backdropFilter'
];
const SIZES = ['xs', 'sm', 'md', 'lg', 'xl'];
/**
* Demo controls that must be ON before measuring: a part the demo does not
* mount is a part the diff never compares, and a probe over two nodes passes
* in false (the F2-B lesson — count the nodes before believing a green gate).
* text-gradient boots with `showBorder=false`, so its whole frame — three of
* its seven tokens — is absent from the default stage.
*/
const DEMO_VARIANTS: Record<string, string[]> = {
'text-gradient': [
'[data-uix-control]:has([data-uix-control-label]:text-is("showBorder")) [data-uix-chip]:text-is("true")'
]
};
async function main() {
const [component, out, urlArg] = process.argv.slice(2);
if (!component || !out) throw new Error('usage: <component> <out.json> [url]');
@ -73,9 +87,24 @@ async function main() {
.waitForFunction(() => !document.querySelector('[data-busy]'), null, { timeout: 15000 })
.catch(() => {});
// Every load resets the demo's controls, so the variant chips are re-clicked
// after each one — the probe reloads once per size, plus open and hover.
const variants = async () => {
for (const sel of DEMO_VARIANTS[component] ?? []) {
const el = page.locator(sel).first();
if (!(await el.count())) throw new Error(`variant selector matches nothing: ${sel}`);
await el.click({ timeout: 2000 });
await page.waitForTimeout(200);
}
};
const prepare = async () => {
await settle();
await variants();
};
await page.goto(url, { waitUntil: 'networkidle' });
await page.waitForTimeout(600);
await settle();
await prepare();
const measure = (c: string, props: string[], size: string | null) =>
page.evaluate(
@ -116,7 +145,7 @@ async function main() {
for (const size of SIZES) {
await page.reload({ waitUntil: 'networkidle' });
await page.waitForTimeout(400);
await settle();
await prepare();
result[`size:${size}`] = await measure(component, PROPS, size);
}
@ -124,7 +153,7 @@ async function main() {
// document once the trigger has opened it (§7.2 — probe the popup open).
await page.reload({ waitUntil: 'networkidle' });
await page.waitForTimeout(400);
await settle();
await prepare();
const trigger = page.locator(`[data-${component}-trigger], [data-${component}-input]`).first();
if (await trigger.count()) {
try {
@ -163,7 +192,7 @@ async function main() {
// Hover + focus on every interactive part the recipe could paint.
await page.reload({ waitUntil: 'networkidle' });
await page.waitForTimeout(400);
await settle();
await prepare();
const targets = await page.evaluate((kebab) => {
const root = document.querySelector('[data-uix-stage]') ?? document.body;
return [...root.querySelectorAll<HTMLElement>('*')]

@ -110,7 +110,16 @@ const COMPONENT_OVERRIDES: Record<
// A hover card opens on POINTER-OVER, not on click: clicking its trigger
// (an `<a>`) navigates instead of revealing the panel, so the portaled
// content never enters the document and 32 of its 35 tokens read dead.
'link-preview': { openBy: 'hover' }
'link-preview': { openBy: 'hover' },
// Not a panel to open but a VARIANT to switch on: the demo boots with
// `showBorder=false`, and the frame owns three of the seven tokens. The
// chip is outside the component, so clicking it cannot poison a hover
// state (the pointer is parked right after, as for every other opener).
'text-gradient': {
openWith: [
'[data-uix-control]:has([data-uix-control-label]:text-is("showBorder")) [data-uix-chip]:text-is("true")'
]
}
};
function sentinelFor(key: string): string {

@ -33,11 +33,42 @@ Familia text-effects (`TextBlur` / `TextCircular` / `TextFocus` /
| `pauseOnHover` | `boolean` | `false` | Pausa al hover. |
| `yoyo` | `boolean` | `true` | Alterna la dirección (alternate) o loop continuo. |
## Tokens públicos
## Talla y tema
`--text-gradient-duration` · `--text-gradient-radius` ·
`--text-gradient-weight` · `--text-gradient-border-inner-bg` ·
`--text-gradient-border-padding`.
Contrato en `lib/recipes/base.ts > text-gradient` (7 claves, entrada NUEVA).
Alcance del censo: **100 %** (era 67 %). **Guard R-5.4**
(`npm run theming:sentinel -- text-gradient <url>`): **7/7** tokens mueven un
computed en vivo, sin una sola excepción. Corrido 2026-08-22.
```
--text-gradient-radius var(--radius-lg)
--text-gradient-font-weight var(--font-weight-medium)
--text-gradient-blur var(--blur-sm)
--text-gradient-border-padding-block var(--space-1)
--text-gradient-border-padding-inline var(--space-2)
--text-gradient-border-width var(--border-width)
--text-gradient-border-inner-bg var(--color-surface-default)
```
Sin eje `size`: el efecto toma la tipografía de su contexto.
**El marco es su propio trazo.** El hueco del borde era `calc(100% - 2px)` —
dos trazos del gradiente asomando alrededor—, así que el knob es el TRAZO
(`border-width`) y el hueco se deriva de él. Un tema que quiera marco grueso
escribe un número, no dos restas.
**`blur` va a la escala, no a un literal.** El `blur(4px)` de partida era
ciego al zoom global: `blur` es familia MÉTRICA del eje `scaling`
(theming §23), y `--blur-sm` (4px × scaling) es el mismo píxel a scaling 100
y el correcto al 90 y al 110.
### Lo que NO es contrato
`--text-gradient-duration` y `--_text-gradient-play-direction` los escribe el
wrapper INLINE desde `animationSpeed` y `yoyo`. **Ningún token gana a un
estilo inline**, así que declararlos sería acuñar dos nombres que mienten —
misma clase que los cuatro ausentes de `knob`, el `item-gap` de `carousel`
y el `preview-z` de `drag-drop`. Se escriben por prop, que es su canal.
## Baseline

@ -5,14 +5,11 @@
* component (stops interpolate eidos tokens as var(), so mode switches
* re-tint without JS).
*
* Public tokens:
* --text-gradient-duration pan cycle duration (default 8s)
* --text-gradient-radius shell + border radius
* --text-gradient-weight font weight of the treated text
* --text-gradient-border-inner-bg the "hole" of the border frame —
* the seed hardcoded #000; here it is
* the surface color, override to taste
* --text-gradient-border-padding padding when the border shows
* Public tokens live in `lib/recipes/base.ts > text-gradient` (7 keys:
* radius, font-weight, blur, border-padding-block / -inline, border-width,
* border-inner-bg). `--text-gradient-duration` and
* `--_text-gradient-play-direction` are NOT part of that contract: the
* component writes both inline from its props, which no token can outrank.
*/
[data-text-gradient] {
@ -21,14 +18,15 @@
align-items: center;
justify-content: center;
max-width: fit-content;
border-radius: var(--text-gradient-radius, var(--radius-lg));
font-weight: var(--text-gradient-weight, 500);
border-radius: var(--text-gradient-radius);
font-weight: var(--text-gradient-font-weight);
overflow: hidden;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(var(--text-gradient-blur));
-webkit-backdrop-filter: blur(var(--text-gradient-blur));
}
[data-text-gradient][data-border] {
padding: var(--text-gradient-border-padding, var(--space-1) var(--space-2));
padding-block: var(--text-gradient-border-padding-block);
padding-inline: var(--text-gradient-border-padding-inline);
}
[data-text-gradient-fill] {
@ -46,18 +44,18 @@
inset: 0;
z-index: 0;
pointer-events: none;
border-radius: var(--text-gradient-radius, var(--radius-lg));
border-radius: var(--text-gradient-radius);
background-repeat: repeat;
}
[data-text-gradient-border-inner] {
position: absolute;
left: 50%;
top: 50%;
width: calc(100% - 2px);
height: calc(100% - 2px);
width: calc(100% - var(--text-gradient-border-width) * 2);
height: calc(100% - var(--text-gradient-border-width) * 2);
transform: translate(-50%, -50%);
background: var(--text-gradient-border-inner-bg, var(--color-surface-default));
border-radius: var(--text-gradient-radius, var(--radius-lg));
background: var(--text-gradient-border-inner-bg);
border-radius: var(--text-gradient-radius);
z-index: -1;
}

@ -4108,6 +4108,13 @@
--text-circular-fg: currentColor;
--text-circular-font-size: 1.5rem;
--text-circular-char-transition: all 0.5s cubic-bezier(0, 0, 0, 1);
--text-gradient-radius: var(--radius-lg);
--text-gradient-font-weight: var(--font-weight-medium);
--text-gradient-blur: var(--blur-sm);
--text-gradient-border-padding-block: var(--space-1);
--text-gradient-border-padding-inline: var(--space-2);
--text-gradient-border-width: var(--border-width);
--text-gradient-border-inner-bg: var(--color-surface-default);
--knob-gap-sm: var(--size-sm-gap);
--knob-gap-md: var(--size-md-gap);
--knob-gap-lg: var(--size-lg-gap);

@ -6673,6 +6673,31 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
// The character's turn, kept verbatim: it is a gesture, not four axes.
'char-transition': 'all 0.5s cubic-bezier(0, 0, 0, 1)'
},
// TextGradient, third of the same shape: four publics consumed through a
// fallback and none declared, plus three literals. No size axis and no
// private behind the fallbacks, so declaring cannot kill a scale.
//
// `border-width` is the frame's stroke: the hole of the border is
// `calc(100% - 2px)`, which is TWO strokes of the linear-gradient showing
// around it, so the knob is the stroke and the hole derives from it.
// `blur` goes to the blur scale rather than a literal 4px because blur is a
// METRIC family under the scaling axis (theming §23) — a literal is blind to
// the global zoom the rest of the shell obeys.
//
// NOT declared: `duration` and `play-direction`. The wrapper writes both
// INLINE from props (`animationSpeed`, `yoyo`), and no contract outranks an
// inline style — same value channel as knob's four, carousel's item-gap and
// drag-drop's preview-z.
'text-gradient': {
radius: 'var(--radius-lg)',
'font-weight': 'var(--font-weight-medium)',
blur: 'var(--blur-sm)',
'border-padding-block': 'var(--space-1)',
'border-padding-inline': 'var(--space-2)',
'border-width': 'var(--border-width)',
// The seed hardcoded #000 here; the hole follows the theme's surface.
'border-inner-bg': 'var(--color-surface-default)'
},
knob: {
'gap-sm': 'var(--size-sm-gap)',
'gap-md': 'var(--size-md-gap)',

@ -2,9 +2,11 @@
import { TextGradient, type TextGradientDirection } from '$uix/eidos/components/text-gradient';
import { compileMorfo } from '$uix/morfo';
import { textGradientMorfo } from '@/uix/morfo/components/text-gradient';
import TokensPanel from '../../lib/TokensPanel.svelte';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'tokens' | 'a11y';
let tab = $state<Tab>('live');
let stageRef = $state<HTMLElement | null>(null);
const compiled = compileMorfo(textGradientMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
@ -80,7 +82,7 @@
</header>
<div data-uix-stage>
<div data-uix-stage-area style="font-size: var(--font-size-3xl); background: var(--color-surface-default);">
<div data-uix-stage-area bind:this={stageRef} style="font-size: var(--font-size-3xl); background: var(--color-surface-default);">
<TextGradient {colors} {animationSpeed} {showBorder} {direction} {pauseOnHover} {yoyo}>
{sample}
</TextGradient>
@ -107,6 +109,9 @@
<span data-uix-tab-count>0</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'tokens'} onclick={() => (tab = 'tokens')}>
Tokens <span data-uix-tab-count>7</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -215,19 +220,10 @@
</tbody>
</table>
</div>
<div data-uix-subsection-head>Public tokens</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Token</th><th>Default</th></tr></thead>
<tbody>
<tr><td class="name">--text-gradient-duration</td><td class="type">8s (prop)</td></tr>
<tr><td class="name">--text-gradient-radius</td><td class="type">var(--radius-lg)</td></tr>
<tr><td class="name">--text-gradient-weight</td><td class="type">500</td></tr>
<tr><td class="name">--text-gradient-border-inner-bg</td><td class="type">var(--color-surface-default)</td></tr>
<tr><td class="name">--text-gradient-border-padding</td><td class="type">var(--space-1) var(--space-2)</td></tr>
</tbody>
</table>
</div>
<p data-uix-section-desc>
El contrato de tema vive en la pestaña <strong>Tokens</strong>, leído del contrato vivo —
esta tabla lo duplicaba a mano y se quedaba obsoleta sola.
</p>
</section>
{/if}
@ -286,6 +282,10 @@
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="text-gradient" stage={stageRef ?? undefined} />
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>

Loading…
Cancel
Save

Powered by TurnKey Linux.