From 97a6f03d769442d25b5d4ec9dd1b9133744c425b Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 22 Aug 2026 16:36:33 +0200 Subject: [PATCH] =?UTF-8?q?uix(text-gradient):=20temable=20=E2=80=94=2067?= =?UTF-8?q?=20%=20=E2=86=92=20100=20%,=20y=20la=20ficha=20se=20equivocaba?= =?UTF-8?q?=20en=20las=20TRES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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 --- docs/audit/theming/README.md | 12 +-- docs/audit/theming/text-gradient.md | 77 ++++++++++++------- docs/next-features.md | 31 ++++++++ docs/process/CONTINUE-theming.md | 32 +++++++- docs/process/PLAN-theming.md | 41 ++++++++++ scripts/__theming-probe.ts | 39 ++++++++-- scripts/theming-sentinel.ts | 11 ++- .../eidos/components/text-gradient/README.md | 39 +++++++++- .../text-gradient/text-gradient.css | 34 ++++---- src/uix/eidos/generated/base.css | 7 ++ src/uix/eidos/lib/recipes/base.ts | 25 ++++++ .../uix/components/text-gradient/+page.svelte | 30 ++++---- 12 files changed, 299 insertions(+), 79 deletions(-) diff --git a/docs/audit/theming/README.md b/docs/audit/theming/README.md index 53db1394a..28a2fbef5 100644 --- a/docs/audit/theming/README.md +++ b/docs/audit/theming/README.md @@ -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 | – | diff --git a/docs/audit/theming/text-gradient.md b/docs/audit/theming/text-gradient.md index 9a97b404d..2c563db47 100644 --- a/docs/audit/theming/text-gradient.md +++ b/docs/audit/theming/text-gradient.md @@ -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 `⚠`. -_(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 `` y el +único efecto de hover del componente es `animation-play-state`, que no es knob. diff --git a/docs/next-features.md b/docs/next-features.md index aed0bfab5..0c844b7f4 100644 --- a/docs/next-features.md +++ b/docs/next-features.md @@ -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. diff --git a/docs/process/CONTINUE-theming.md b/docs/process/CONTINUE-theming.md index 4d0d8f1fb..ff8e09350 100644 --- a/docs/process/CONTINUE-theming.md +++ b/docs/process/CONTINUE-theming.md @@ -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 | diff --git a/docs/process/PLAN-theming.md b/docs/process/PLAN-theming.md index 8785b49be..0d8390d78 100644 --- a/docs/process/PLAN-theming.md +++ b/docs/process/PLAN-theming.md @@ -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 diff --git a/scripts/__theming-probe.ts b/scripts/__theming-probe.ts index 0c0e74fc4..9e5b38287 100644 --- a/scripts/__theming-probe.ts +++ b/scripts/__theming-probe.ts @@ -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 = { + '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: [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('*')] diff --git a/scripts/theming-sentinel.ts b/scripts/theming-sentinel.ts index 1df30225b..bf87c4c2f 100644 --- a/scripts/theming-sentinel.ts +++ b/scripts/theming-sentinel.ts @@ -110,7 +110,16 @@ const COMPONENT_OVERRIDES: Record< // A hover card opens on POINTER-OVER, not on click: clicking its trigger // (an ``) 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 { diff --git a/src/uix/eidos/components/text-gradient/README.md b/src/uix/eidos/components/text-gradient/README.md index 721801301..55d4b8e35 100644 --- a/src/uix/eidos/components/text-gradient/README.md +++ b/src/uix/eidos/components/text-gradient/README.md @@ -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 `): **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 diff --git a/src/uix/eidos/components/text-gradient/text-gradient.css b/src/uix/eidos/components/text-gradient/text-gradient.css index 7ddb88f78..8f33aaf05 100644 --- a/src/uix/eidos/components/text-gradient/text-gradient.css +++ b/src/uix/eidos/components/text-gradient/text-gradient.css @@ -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; } diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index ed6066a58..f2dcc48c9 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -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); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 8d13e3cec..3e204bea8 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -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)', diff --git a/web/routes/uix/components/text-gradient/+page.svelte b/web/routes/uix/components/text-gradient/+page.svelte index c2817dde2..7dcd920c5 100644 --- a/web/routes/uix/components/text-gradient/+page.svelte +++ b/web/routes/uix/components/text-gradient/+page.svelte @@ -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('live'); + let stageRef = $state(null); const compiled = compileMorfo(textGradientMorfo); const partsList = $derived([...compiled.parts.byKebab.values()]); @@ -80,7 +82,7 @@
-
+
{sample} @@ -107,6 +109,9 @@ 0 +
@@ -215,19 +220,10 @@
-
Public tokens
-
- - - - - - - - - -
TokenDefault
--text-gradient-duration8s (prop)
--text-gradient-radiusvar(--radius-lg)
--text-gradient-weight500
--text-gradient-border-inner-bgvar(--color-surface-default)
--text-gradient-border-paddingvar(--space-1) var(--space-2)
-
+

+ El contrato de tema vive en la pestaña Tokens, leído del contrato vivo — + esta tabla lo duplicaba a mano y se quedaba obsoleta sola. +

{/if} @@ -286,6 +282,10 @@ {/if} + {#if tab === 'tokens'} + + {/if} + {#if tab === 'a11y'}

Accessibility