uix(spinner): temable — 0 % → 70 %, y 71 tokens propuestos que eran 18

18 claves en el bloque `spinner` de `recipes/base.ts` (fusionadas con los dos
forwards de paleta). Censo 0 % → 70 %, cero globales. Global 48 %, sin contrato
44 → 43.

**La §4 proponía 71 tokens para 23 knobs — la peor inflación que ha dado el
generador en todo el eje.** El spinner tiene UNA coordenada de talla
(`--spinner-size`) que gobierna el anillo, la pista de puntos y las barras, más
el trazo (`--spinner-thickness`). La propuesta multiplicaba esa única
coordenada por variante × parte × dimensión × talla y producía
`ring-track-width-xs`, `dots-track-gap-xs`, `dots-dot-width-xs`,
`bars-track-gap-xs`… todos con el mismo `0.75rem`, porque todos SON el tamaño
en xs. Un tema habría necesitado veinte claves para cambiar un número.

1. Las proporciones son CONSTRUCCIÓN, no knobs: `calc(size * 0.3)` es el punto,
   `* 0.18` la barra, `* 0.2` y `* 0.12` los huecos. Definen la FORMA de cada
   variante y se quedan en la receta — mismo reparto que los gradientes de las
   guías de tree-grid o el damero de gradient-builder.
2. `fg` / `dots-dot-bg` / `bars-bar-bg` ⚠ no eran tres knobs con dos valores en
   pugna: son UNA variable (`--_spinner-color`) leída en tres sitios, con el
   conmutador `data-color='inherit'` que la cambia de forward de paleta a
   `currentColor`. Se queda privada, igual que `--_spinner-track`, que además
   envuelve su fuente en un color-mix distinto por rama (60 % / 25 %).
   Aplanarlas exigiría duplicar cada regla por color.
3. La escala es PROPIA, no la del bundle de iconos: medido, 12/16/20/28/40 px
   contra los 14/16/18/20/32 de `--icon-size-*`, y sólo coinciden en sm.
   Verbatim con la desviación anotada — forzar el bundle habría movido el
   default en cuatro tallas de cinco.
4. `duration` entra al contrato en vez de seguir en `0.9s` a pelo: el registro
   ya tenía abierta la incoherencia de que el spinner de feed ganó tokens de
   duración y el de media-player no (§13). Aquí se hace bien de entrada.

El 30 % que falta son los dos privados conmutadores, los dos
`border-radius: 50%` (identidad de forma) y los fotogramas de los @keyframes.

Artefactos y gates:
- Sonda antes/después = 0 diffs en 377 valores × 7 estados. Determinismo
  verificado.
- R-5.4: 14/18. Los cuatro mudos, medidos uno a uno y adjudicados: la demo
  monta SÓLO la variante ring y sin etiqueta (0 barras, 0 puntos, 0 labels,
  contados), y `track-opacity` sólo se declara dentro de
  `prefers-reduced-motion` — forzando variante, etiqueta y contexto reduce,
  los cuatro alcanzan.
- censo --only 70 % · component:audit PASS · eidos-lint invalid 0, class-hooks
  0 · suite eidos sin rojos nuevos (el conocido skin-media-player) · rtl:check
  0 · docs:check 0 · check por fichero limpio.
- README con su «Talla y tema» y tab `Tokens` en la demo (18 claves, en vivo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent c56d6544e6
commit 42a3e74136

@ -10,9 +10,9 @@
- **Medido**: 2026-08-21 · **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**: **47%** — 2213 de 4678 knobs pasan por un token público del componente
- **Reparto**: público 2213 · privado 654 · global 1233 · literal 578 · sistema transversal 509 _(fuera del ratio)_
- **Sin token público propio**: 44 · **alcance < 20 %**: 38 · **alcance 100 %**: 7 · **con eje `size`**: 64
- **Alcance global**: **48%** — 2229 de 4678 knobs pasan por un token público del componente
- **Reparto**: público 2229 · privado 642 · global 1229 · literal 578 · sistema transversal 509 _(fuera del ratio)_
- **Sin token público propio**: 43 · **alcance < 20 %**: 37 · **alcance 100 %**: 7 · **con eje `size`**: 63
## Cómo se lee
@ -91,12 +91,12 @@ theming §6.7 lo pone delante (`hover-bg`). Las fichas proponen la forma
| 19 | [timeline](./timeline.md) | 24 | 51% | 49 | 45 |
| 20 | [avatar](./avatar.md) | 23 | 36% | 36 | 0 |
## Sin ningún token público propio (44)
## Sin ningún token público propio (43)
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) · [`announce`](./announce.md) · [`aspect-ratio`](./aspect-ratio.md) · [`auto-grid`](./auto-grid.md) · [`avatar`](./avatar.md) · [`cascade`](./cascade.md) · [`clipboard`](./clipboard.md) · [`code`](./code.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) · [`kbd`](./kbd.md) · [`knob`](./knob.md) · [`label`](./label.md) · [`link`](./link.md) · [`link-preview`](./link-preview.md) · [`mark`](./mark.md) · [`mask-field`](./mask-field.md) · [`menu-dial`](./menu-dial.md) · [`mockup`](./mockup.md) · [`month-grid`](./month-grid.md) · [`motion`](./motion.md) · [`number-field`](./number-field.md) · [`palabras`](./palabras.md) · [`picker-shell`](./picker-shell.md) · [`range-calendar`](./range-calendar.md) · [`scroll-frames`](./scroll-frames.md) · [`skeleton`](./skeleton.md) · [`skin-media-player`](./skin-media-player.md) · [`spinner`](./spinner.md) · [`stack`](./stack.md) · [`text`](./text.md) · [`text-blur`](./text-blur.md) · [`text-circular`](./text-circular.md) · [`text-focus`](./text-focus.md) · [`text-gradient`](./text-gradient.md) · [`text-scramble`](./text-scramble.md) · [`virtual-grid`](./virtual-grid.md) · [`virtual-list`](./virtual-list.md) · [`wrap`](./wrap.md) · [`year-grid`](./year-grid.md)
[`alert-dialog`](./alert-dialog.md) · [`announce`](./announce.md) · [`aspect-ratio`](./aspect-ratio.md) · [`auto-grid`](./auto-grid.md) · [`avatar`](./avatar.md) · [`cascade`](./cascade.md) · [`clipboard`](./clipboard.md) · [`code`](./code.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) · [`kbd`](./kbd.md) · [`knob`](./knob.md) · [`label`](./label.md) · [`link`](./link.md) · [`link-preview`](./link-preview.md) · [`mark`](./mark.md) · [`mask-field`](./mask-field.md) · [`menu-dial`](./menu-dial.md) · [`mockup`](./mockup.md) · [`month-grid`](./month-grid.md) · [`motion`](./motion.md) · [`number-field`](./number-field.md) · [`palabras`](./palabras.md) · [`picker-shell`](./picker-shell.md) · [`range-calendar`](./range-calendar.md) · [`scroll-frames`](./scroll-frames.md) · [`skeleton`](./skeleton.md) · [`skin-media-player`](./skin-media-player.md) · [`stack`](./stack.md) · [`text`](./text.md) · [`text-blur`](./text-blur.md) · [`text-circular`](./text-circular.md) · [`text-focus`](./text-focus.md) · [`text-gradient`](./text-gradient.md) · [`text-scramble`](./text-scramble.md) · [`virtual-grid`](./virtual-grid.md) · [`virtual-list`](./virtual-list.md) · [`wrap`](./wrap.md) · [`year-grid`](./year-grid.md)
## Tabla completa (162 recetas, por alcance ascendente)
@ -108,7 +108,6 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [month-grid](./month-grid.md) | 0% | 61 | 0 | 7 | 0 | 5 | 49 | 0 | – |
| [year-grid](./year-grid.md) | 0% | 61 | 0 | 7 | 0 | 5 | 49 | 0 | – |
| [field-langs](./field-langs.md) | 0% | 41 | 0 | 0 | 31 | 8 | 2 | 0 | – |
| [spinner](./spinner.md) | 0% | 23 | 0 | 15 | 4 | 4 | 0 | 0 | y |
| [picker-shell](./picker-shell.md) | 0% | 21 | 0 | 4 | 17 | 0 | 0 | 0 | y |
| [link-preview](./link-preview.md) | 0% | 17 | 0 | 4 | 9 | 1 | 3 | 0 | y |
| [drag-drop](./drag-drop.md) | 0% | 16 | 0 | 3 | 6 | 0 | 7 | 1 | – |
@ -209,6 +208,7 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [separator](./separator.md) | 67% | 6 | 4 | 0 | 0 | 2 | 0 | 3 | – |
| [listbox](./listbox.md) | 68% | 37 | 23 | 9 | 1 | 1 | 3 | 21 | – |
| [select](./select.md) | 69% | 72 | 45 | 14 | 3 | 3 | 7 | 101 | y |
| [spinner](./spinner.md) | 70% | 23 | 16 | 3 | 0 | 4 | 0 | 18 | – |
| [dialog](./dialog.md) | 70% | 33 | 23 | 5 | 4 | 1 | 0 | 37 | y |
| [image-picker](./image-picker.md) | 71% | 20 | 12 | 0 | 1 | 4 | 3 | 13 | – |
| [pagination](./pagination.md) | 71% | 26 | 17 | 7 | 0 | 0 | 2 | 34 | y |

@ -5,50 +5,33 @@
> 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**: **0%** — 0 de 23 knobs por token público
- **Knobs de apariencia**: 23 — público 0 · privado 15 · global 4 · literal 4 · sistema 0 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 0 pública(s) · 2 privada(s) forward — `_palette-text`, `_palette-track`
- **Eje `size`**: sí · **ficheros**: `spinner.css`
- **Medido**: 2026-08-21 · **Alcance**: **70%** — 16 de 23 knobs por token público
- **Knobs de apariencia**: 23 — público 16 · privado 3 · global 0 · literal 4 · sistema 0 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 18 pública(s) — `size-xs`, `size-sm`, `size-md`, `size-lg`, `size-xl`, `thickness-xs`, `thickness-sm`, `thickness-md`, `thickness-lg`, `thickness-xl`, `size`, `thickness`, `gap`, `duration`, `track-opacity`, `bar-radius`, `label-font-size`, `label-fg` · 2 privada(s) forward — `_palette-text`, `_palette-track`
- **Eje `size`**: no · **ficheros**: `spinner.css`
## 1. Knobs fuera de alcance
### 1.1 Directo a primitivo global (4)
### 1.1 Directo a primitivo global (0)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `spinner.css:108` | `[data-spinner][data-variant='bars'] [data-spinner-bar]` | `border-radius` | `var(--radius-sm)` |
| 2 | `spinner.css:129` | `[data-spinner-label]` | `font-size` | `var(--font-size-sm)` |
| 3 | `spinner.css:130` | `[data-spinner-label]` | `color` | `var(--color-content-muted)` |
| 4 | `spinner.css:176` | `[data-spinner][data-variant='ring'] [data-spinner-track]` | `opacity` | `var(--opacity-60)` |
_Ninguno._
### 1.2 A través de un privado (15)
### 1.2 A través de un privado (3)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `spinner.css:22` | `[data-spinner]` | `gap` | `var(--_spinner-gap)` |
| 2 | `spinner.css:23` | `[data-spinner]` | `color` | `var(--_spinner-color)` |
| 3 | `spinner.css:62` | `[data-spinner][data-variant='ring'] [data-spinner-track]` | `inline-size` | `var(--_spinner-size)` |
| 4 | `spinner.css:63` | `[data-spinner][data-variant='ring'] [data-spinner-track]` | `block-size` | `var(--_spinner-size)` |
| 5 | `spinner.css:65` | `[data-spinner][data-variant='ring'] [data-spinner-track]` | `border` | `var(--_spinner-thickness) solid var(--_spinner-track)` |
| 6 | `spinner.css:75` | `[data-spinner][data-variant='dots'] [data-spinner-track]` | `gap` | `calc(var(--_spinner-size) * 0.2)` |
| 7 | `spinner.css:76` | `[data-spinner][data-variant='dots'] [data-spinner-track]` | `block-size` | `var(--_spinner-size)` |
| 8 | `spinner.css:81` | `[data-spinner][data-variant='dots'] [data-spinner-dot]` | `inline-size` | `calc(var(--_spinner-size) * 0.3)` |
| 9 | `spinner.css:82` | `[data-spinner][data-variant='dots'] [data-spinner-dot]` | `block-size` | `calc(var(--_spinner-size) * 0.3)` |
| 10 | `spinner.css:84` | `[data-spinner][data-variant='dots'] [data-spinner-dot]` | `background` | `var(--_spinner-color)` |
| 11 | `spinner.css:100` | `[data-spinner][data-variant='bars'] [data-spinner-track]` | `gap` | `calc(var(--_spinner-size) * 0.12)` |
| 12 | `spinner.css:101` | `[data-spinner][data-variant='bars'] [data-spinner-track]` | `block-size` | `var(--_spinner-size)` |
| 13 | `spinner.css:106` | `[data-spinner][data-variant='bars'] [data-spinner-bar]` | `inline-size` | `calc(var(--_spinner-size) * 0.18)` |
| 14 | `spinner.css:107` | `[data-spinner][data-variant='bars'] [data-spinner-bar]` | `block-size` | `var(--_spinner-size)` |
| 15 | `spinner.css:109` | `[data-spinner][data-variant='bars'] [data-spinner-bar]` | `background` | `var(--_spinner-color)` |
| 1 | `spinner.css:24` | `[data-spinner]` | `color` | `var(--_spinner-color)` |
| 2 | `spinner.css:67` | `[data-spinner][data-variant='dots'] [data-spinner-dot]` | `background` | `var(--_spinner-color)` |
| 3 | `spinner.css:92` | `[data-spinner][data-variant='bars'] [data-spinner-bar]` | `background` | `var(--_spinner-color)` |
### 1.3 Literales (4)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `spinner.css:64` | `[data-spinner][data-variant='ring'] [data-spinner-track]` | `border-radius` | `50%` |
| 2 | `spinner.css:83` | `[data-spinner][data-variant='dots'] [data-spinner-dot]` | `border-radius` | `50%` |
| 3 | `spinner.css:148` | `0%, 80%, 100%` | `opacity` | `0.5` |
| 4 | `spinner.css:152` | `40%` | `opacity` | `1` |
| 1 | `spinner.css:47` | `[data-spinner][data-variant='ring'] [data-spinner-track]` | `border-radius` | `50%` |
| 2 | `spinner.css:66` | `[data-spinner][data-variant='dots'] [data-spinner-dot]` | `border-radius` | `50%` |
| 3 | `spinner.css:131` | `0%, 80%, 100%` | `opacity` | `0.5` |
| 4 | `spinner.css:135` | `40%` | `opacity` | `1` |
## 2. Sistema transversal (0) — informativo, fuera del ratio
@ -60,20 +43,14 @@ _Ninguno._
| privado | declaraciones | valor(es) | origen | ¿deriva de un público? |
| --- | ---: | --- | --- | :-: |
| `--_spinner-size` | 6 | `1.25rem`, `0.75rem`, `1rem`, `1.75rem`, `2.5rem` | literal | no |
| `--_spinner-thickness` | 6 | `2px`, `1.5px`, `3px`, `4px` | literal | no |
| `--_spinner-duration` | 1 | `0.9s` | literal | no |
| `--_spinner-color` | 2 | `var(--_spinner-palette-text)`, `currentColor` | literal, private | no |
| `--_spinner-track` | 2 | `color-mix(in srgb, var(--_spinner-palette-track) 60%, transparent)`, `color-mix(in srgb, currentColor 25%, transparent)` | literal, private | no |
| `--_spinner-gap` | 1 | `var(--space-2)` | global | no |
Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inline del wrapper): `--_spinner-palette-text`, `--_spinner-palette-track`.
## 4. Propuesta de corrección
- **Tiene eje `size`**: los tokens dimensionales van por talla (`{part}-{eje}-{k}`) apuntando al bundle `--size-{k}-*`, nunca al primitivo crudo (theming §5; el guard `recipe-css-contract` prohíbe el primitivo).
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (71)
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (6)
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
@ -86,85 +63,17 @@ no distingue lo que debería — se marca `⚠`.
| `fg` | `root` | ⚠ `var(--_spinner-palette-text)` / `currentColor` | 2 |
| `dots-dot-bg` | `root` | ⚠ `var(--_spinner-palette-text)` / `currentColor` | 2 |
| `bars-bar-bg` | `root` | ⚠ `var(--_spinner-palette-text)` / `currentColor` | 2 |
| `gap` | `root` | `var(--space-2)` | 1 |
| `ring-track-width` | `root` | `1.25rem` | 1 |
| `ring-track-width-xs` | `size:xs` | `0.75rem` | 1 |
| `ring-track-width-sm` | `size:sm` | `1rem` | 1 |
| `ring-track-width-md` | `size:md` | `1.25rem` | 1 |
| `ring-track-width-lg` | `size:lg` | `1.75rem` | 1 |
| `ring-track-width-xl` | `size:xl` | `2.5rem` | 1 |
| `ring-track-height` | `root` | `1.25rem` | 1 |
| `ring-track-height-xs` | `size:xs` | `0.75rem` | 1 |
| `ring-track-height-sm` | `size:sm` | `1rem` | 1 |
| `ring-track-height-md` | `size:md` | `1.25rem` | 1 |
| `ring-track-height-lg` | `size:lg` | `1.75rem` | 1 |
| `ring-track-height-xl` | `size:xl` | `2.5rem` | 1 |
| `ring-track-radius` | `root` | `50%` | 1 |
| `dots-track-gap` | `root` | `1.25rem` | 1 |
| `dots-track-gap-xs` | `size:xs` | `0.75rem` | 1 |
| `dots-track-gap-sm` | `size:sm` | `1rem` | 1 |
| `dots-track-gap-md` | `size:md` | `1.25rem` | 1 |
| `dots-track-gap-lg` | `size:lg` | `1.75rem` | 1 |
| `dots-track-gap-xl` | `size:xl` | `2.5rem` | 1 |
| `dots-track-height` | `root` | `1.25rem` | 1 |
| `dots-track-height-xs` | `size:xs` | `0.75rem` | 1 |
| `dots-track-height-sm` | `size:sm` | `1rem` | 1 |
| `dots-track-height-md` | `size:md` | `1.25rem` | 1 |
| `dots-track-height-lg` | `size:lg` | `1.75rem` | 1 |
| `dots-track-height-xl` | `size:xl` | `2.5rem` | 1 |
| `dots-dot-width` | `root` | `1.25rem` | 1 |
| `dots-dot-width-xs` | `size:xs` | `0.75rem` | 1 |
| `dots-dot-width-sm` | `size:sm` | `1rem` | 1 |
| `dots-dot-width-md` | `size:md` | `1.25rem` | 1 |
| `dots-dot-width-lg` | `size:lg` | `1.75rem` | 1 |
| `dots-dot-width-xl` | `size:xl` | `2.5rem` | 1 |
| `dots-dot-height` | `root` | `1.25rem` | 1 |
| `dots-dot-height-xs` | `size:xs` | `0.75rem` | 1 |
| `dots-dot-height-sm` | `size:sm` | `1rem` | 1 |
| `dots-dot-height-md` | `size:md` | `1.25rem` | 1 |
| `dots-dot-height-lg` | `size:lg` | `1.75rem` | 1 |
| `dots-dot-height-xl` | `size:xl` | `2.5rem` | 1 |
| `dots-dot-radius` | `root` | `50%` | 1 |
| `bars-track-gap` | `root` | `1.25rem` | 1 |
| `bars-track-gap-xs` | `size:xs` | `0.75rem` | 1 |
| `bars-track-gap-sm` | `size:sm` | `1rem` | 1 |
| `bars-track-gap-md` | `size:md` | `1.25rem` | 1 |
| `bars-track-gap-lg` | `size:lg` | `1.75rem` | 1 |
| `bars-track-gap-xl` | `size:xl` | `2.5rem` | 1 |
| `bars-track-height` | `root` | `1.25rem` | 1 |
| `bars-track-height-xs` | `size:xs` | `0.75rem` | 1 |
| `bars-track-height-sm` | `size:sm` | `1rem` | 1 |
| `bars-track-height-md` | `size:md` | `1.25rem` | 1 |
| `bars-track-height-lg` | `size:lg` | `1.75rem` | 1 |
| `bars-track-height-xl` | `size:xl` | `2.5rem` | 1 |
| `bars-bar-width` | `root` | `1.25rem` | 1 |
| `bars-bar-width-xs` | `size:xs` | `0.75rem` | 1 |
| `bars-bar-width-sm` | `size:sm` | `1rem` | 1 |
| `bars-bar-width-md` | `size:md` | `1.25rem` | 1 |
| `bars-bar-width-lg` | `size:lg` | `1.75rem` | 1 |
| `bars-bar-width-xl` | `size:xl` | `2.5rem` | 1 |
| `bars-bar-height` | `root` | `1.25rem` | 1 |
| `bars-bar-height-xs` | `size:xs` | `0.75rem` | 1 |
| `bars-bar-height-sm` | `size:sm` | `1rem` | 1 |
| `bars-bar-height-md` | `size:md` | `1.25rem` | 1 |
| `bars-bar-height-lg` | `size:lg` | `1.75rem` | 1 |
| `bars-bar-height-xl` | `size:xl` | `2.5rem` | 1 |
| `bars-bar-radius` | `root` | `var(--radius-sm)` | 1 |
| `label-font-size` | `root` | `var(--font-size-sm)` | 1 |
| `label-fg` | `root` | `var(--color-content-muted)` | 1 |
| `opacity` | `root` | `0.5` | 1 |
| `ring-track-opacity` | `root` | `var(--opacity-60)` | 1 |
### 4.2 Sin nombre mecánico (2)
### 4.2 Sin nombre mecánico (1)
- **⚠ decisión: `border` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 1: `border`.
- **⚠ decisión: `1` es un valor identidad o geometría de layout, no un knob de tema — el perímetro de «knob» es D-TH.2, sin firmar** — 1: `opacity`.
### 4.3 Avisos sobre los tokens propuestos (3)
### 4.3 Avisos sobre los tokens propuestos (1)
- **el privado `--_spinner-gap` debe pasar a leer este público (o desaparecer)** — `--spinner-gap`
- **el privado `--_spinner-color` debe pasar a leer este público (o desaparecer)** — `--spinner-fg`, `--spinner-dots-dot-bg`, `--spinner-bars-bar-bg`
- **el privado `--_spinner-size` debe pasar a leer este público (o desaparecer)** — `--spinner-ring-track-width`, `--spinner-ring-track-width-xs`, `--spinner-ring-track-width-sm`, `--spinner-ring-track-width-md`, `--spinner-ring-track-width-lg`, `--spinner-ring-track-width-xl`, `--spinner-ring-track-height`, `--spinner-ring-track-height-xs`, `--spinner-ring-track-height-sm`, `--spinner-ring-track-height-md`, `--spinner-ring-track-height-lg`, `--spinner-ring-track-height-xl`, `--spinner-dots-track-gap`, `--spinner-dots-track-gap-xs`, `--spinner-dots-track-gap-sm`, `--spinner-dots-track-gap-md`, `--spinner-dots-track-gap-lg`, `--spinner-dots-track-gap-xl`, `--spinner-dots-track-height`, `--spinner-dots-track-height-xs`, `--spinner-dots-track-height-sm`, `--spinner-dots-track-height-md`, `--spinner-dots-track-height-lg`, `--spinner-dots-track-height-xl`, `--spinner-dots-dot-width`, `--spinner-dots-dot-width-xs`, `--spinner-dots-dot-width-sm`, `--spinner-dots-dot-width-md`, `--spinner-dots-dot-width-lg`, `--spinner-dots-dot-width-xl`, `--spinner-dots-dot-height`, `--spinner-dots-dot-height-xs`, `--spinner-dots-dot-height-sm`, `--spinner-dots-dot-height-md`, `--spinner-dots-dot-height-lg`, `--spinner-dots-dot-height-xl`, `--spinner-bars-track-gap`, `--spinner-bars-track-gap-xs`, `--spinner-bars-track-gap-sm`, `--spinner-bars-track-gap-md`, `--spinner-bars-track-gap-lg`, `--spinner-bars-track-gap-xl`, `--spinner-bars-track-height`, `--spinner-bars-track-height-xs`, `--spinner-bars-track-height-sm`, `--spinner-bars-track-height-md`, `--spinner-bars-track-height-lg`, `--spinner-bars-track-height-xl`, `--spinner-bars-bar-width`, `--spinner-bars-bar-width-xs`, `--spinner-bars-bar-width-sm`, `--spinner-bars-bar-width-md`, `--spinner-bars-bar-width-lg`, `--spinner-bars-bar-width-xl`, `--spinner-bars-bar-height`, `--spinner-bars-bar-height-xs`, `--spinner-bars-bar-height-sm`, `--spinner-bars-bar-height-md`, `--spinner-bars-bar-height-lg`, `--spinner-bars-bar-height-xl`
### 4.4 Lo que hay que comprobar a mano (PLAN-theming §1.3 · §7.4)
@ -178,6 +87,48 @@ no distingue lo que debería — se marca `⚠`.
<!-- veredicto:start -->
_(pendiente — lo escribe el autor; se conserva al regenerar)_
**Medido 2026-08-21** (sonda ×2 sobre el mismo código = 0 diffs en 377
valores · 7 estados). **La §4 propone 71 tokens para 23 knobs, y es el peor
caso de inflación que ha dado el generador en todo el eje.** Corregido queda
en 18 claves.
1. **El spinner tiene UNA coordenada de talla, no veinte.** Todo su tamaño sale
de `--_spinner-size` (0.75/1/1.25/1.75/2.5rem) y todo su trazo de
`--_spinner-thickness`. La §4.1 multiplica esa única coordenada por
variante × parte × dimensión × talla y produce `ring-track-width-xs`,
`dots-track-gap-xs`, `dots-dot-width-xs`, `bars-track-gap-xs`… **todos con
el mismo valor `0.75rem`**, porque todos SON `--_spinner-size` en xs. Un
tema tendría que escribir veinte claves para cambiar un número. Las dos
coordenadas reales, por talla, son diez claves; con sus dos resueltos, doce.
2. **Las proporciones son CONSTRUCCIÓN, no knobs.** `calc(size * 0.2)` (hueco
entre puntos), `* 0.3` (diámetro del punto), `* 0.12` (hueco entre barras) y
`* 0.18` (ancho de barra) son la FORMA de cada variante: lo que define que
un punto sea un punto. Se quedan en la receta, igual que los cinco
gradientes de las guías de `tree-grid` o el damero de `gradient-builder`.
El knob es `size`; la proporción es el dibujo.
3. **`fg` / `dots-dot-bg` / `bars-bar-bg` ⚠ NO son tres knobs ni tienen dos
valores en pugna**: son una sola variable (`--_spinner-color`) leída en tres
sitios, con el conmutador `data-color='inherit'` que la cambia de forward de
paleta a `currentColor`. Es el mismo patrón que `--_textarea-border-focus`:
una variable, dos fuentes. **Se queda privada** — igual que `--_spinner-track`,
que además envuelve su fuente en un `color-mix` distinto por rama (60 % en
el modo normal, 25 % en `inherit`).
4. **La escala NO casa con el bundle de iconos**, medido: el spinner va
12/16/20/28/40 px y `--icon-size-*` va 14/16/18/20/32 — sólo coincide en sm.
Es una escala propia, más generosa de md en adelante, y se escribe verbatim
con la desviación anotada (mismo criterio que el padding de `textarea`).
Forzarla al bundle habría movido el default en cuatro tallas de cinco.
5. **`duration` entra al contrato.** Hoy es `0.9s` a pelo, y el registro ya
tiene abierta la incoherencia de que `feed` ganó tokens de duración para su
spinner mientras `media-player` conserva los suyos crudos (§13). Aquí se
hace bien de entrada; el precedente de nombre es
`feed.sentinel-spinner-duration`.
6. **`track-opacity` sólo pinta bajo `prefers-reduced-motion`** — su única
declaración vive en ese bloque de media, así que el guard lo verá mudo salvo
emulando el contexto (precedente exacto:
`feed.sentinel-spinner-reduced-duration`).
7. **Fuera del contrato**: los dos `border-radius: 50%` (identidad de forma —
un punto redondo es redondo) y los `opacity: 0.5 / 1` de los `@keyframes`,
que son fotogramas de la animación, no knobs.
<!-- veredicto:end -->

@ -114,6 +114,15 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'sentinel-spinner-reduced-duration':
'behind prefers-reduced-motion; emulated reduce context → reaches (4s → 11.5s)'
},
spinner: {
'bar-radius':
"the demo mounts the RING variant only (0 bars, 0 dots, counted); forced variant='bars' → reaches (1234px)",
'label-font-size':
'the label is optional and the demo mounts none (0 nodes, counted); forced → reaches (1234px)',
'label-fg': 'the label is optional and the demo mounts none; forced → reaches (rgb(1,2,3))',
'track-opacity':
'its only declaration lives inside `prefers-reduced-motion`; emulated reduce context → reaches (0.6 → 0.123)'
},
'card-group': {
'title-fg':
'the STATIC heading form is not mounted in the demo (0 [data-static] nodes vs 1 [data-button], counted); forced → reaches (rgb(1,2,3))',

@ -66,6 +66,48 @@ Referencias: [Chakra Spinner](https://chakra-ui.com/docs/components/spinner) ·
- **Indeterminado a propósito**: el progreso con valor pertenece a
`<Progress>`/`<Meter>` — Spinner nunca acepta `value`.
## Talla y tema
**Guard R-5.4** (`npm run theming:sentinel -- spinner <url>`): 14/18 tokens
mueven un computed en vivo; los cuatro que callan están adjudicados con su
medición en `scripts/theming-sentinel-exceptions.ts` — la demo monta sólo la
variante `ring` y sin etiqueta, y `track-opacity` vive dentro de
`prefers-reduced-motion`. Corrido 2026-08-21.
Contrato en `lib/recipes/base.ts > spinner` (18 claves). Alcance del censo:
**70 %** (era 0 %), con **cero globales**.
**UNA coordenada de talla gobierna el componente entero.** El tamaño del anillo,
la altura de la pista de puntos y la de las barras salen todos de
`--spinner-size`; el trazo, de `--spinner-thickness`. Todo lo demás es una
PROPORCIÓN de la primera — `calc(size * 0.3)` es el punto, `* 0.18` la barra,
`* 0.2` y `* 0.12` los huecos — y eso es la FORMA de cada variante, no un knob:
se queda en la receta, igual que los gradientes de las guías de `tree-grid`.
La propuesta generada pedía **71 tokens para 23 knobs** multiplicando esa única
coordenada por variante × parte × dimensión × talla:
`ring-track-width-xs`, `dots-track-gap-xs`, `dots-dot-width-xs`,
`bars-track-gap-xs`… todos con el mismo `0.75rem`, porque todos SON el tamaño
en xs. Un tema habría necesitado veinte claves para cambiar un número.
**La escala es propia, no la del bundle de iconos**: medida, el spinner va
12/16/20/28/40 px contra los 14/16/18/20/32 de `--icon-size-*`, y sólo coinciden
en sm. Verbatim con la desviación anotada — forzar el bundle habría movido el
default en cuatro tallas de cinco.
**Tres privados sobreviven a propósito.** `--_spinner-color` y
`--_spinner-track` son una variable con DOS fuentes cada una, conmutadas por
`data-color='inherit'` (forward de paleta → `currentColor`), y la pista además
envuelve la suya en un `color-mix` distinto por rama (60 % normal, 25 %
heredado). Aplanarlos exigiría duplicar cada regla por color — mismo criterio
que el borde de foco de `textarea`. Son el grueso del 30 % que falta, junto a
los dos `border-radius: 50%` (identidad de forma) y los fotogramas de los
`@keyframes`.
`duration` entra al contrato en vez de quedarse en `0.9s` a pelo: el registro
ya tenía abierta la incoherencia de que el spinner de `feed` ganó tokens de
duración y el de `media-player` no (`next-features.md` §13).
## Gaps
| Gap | Disposición | Detalle |

@ -10,39 +10,22 @@
* "loading" affordance is preserved.
*/
[data-spinner] {
--_spinner-size: 1.25rem;
--_spinner-thickness: 2px;
--_spinner-duration: 0.9s;
/* `size` and `thickness` are EMITTED per `data-size` by the TSC from the
recipe contract; every other dimension in this file is a PROPORTION of
`--spinner-size` and stays here — that is the shape of each variant.
The ink and the track stay private: one variable, two sources, switched by
`data-color='inherit'` below. */
--_spinner-color: var(--_spinner-palette-text);
--_spinner-track: color-mix(in srgb, var(--_spinner-palette-track) 60%, transparent);
--_spinner-gap: var(--space-2);
display: inline-flex;
align-items: center;
gap: var(--_spinner-gap);
gap: var(--spinner-gap);
color: var(--_spinner-color);
}
[data-spinner][data-size='xs'] {
--_spinner-size: 0.75rem;
--_spinner-thickness: 1.5px;
}
[data-spinner][data-size='sm'] {
--_spinner-size: 1rem;
--_spinner-thickness: 2px;
}
[data-spinner][data-size='md'] {
--_spinner-size: 1.25rem;
--_spinner-thickness: 2px;
}
[data-spinner][data-size='lg'] {
--_spinner-size: 1.75rem;
--_spinner-thickness: 3px;
}
[data-spinner][data-size='xl'] {
--_spinner-size: 2.5rem;
--_spinner-thickness: 4px;
}
/* Size coordinates (`size`, `thickness`) are emitted by the TSC on the host
* from the recipe contract — the old `[data-size]` blocks would shadow them. */
/* Colour comes from the shared palette forward (`--_spinner-palette-*`): the
base tokens above consume it directly (host default = primary), so any role /
@ -59,12 +42,12 @@
[data-spinner][data-variant='ring'] [data-spinner-track] {
display: block;
inline-size: var(--_spinner-size);
block-size: var(--_spinner-size);
inline-size: var(--spinner-size);
block-size: var(--spinner-size);
border-radius: 50%;
border: var(--_spinner-thickness) solid var(--_spinner-track);
border: var(--spinner-thickness) solid var(--_spinner-track);
border-top-color: var(--_spinner-color);
animation: spinner-rotate var(--_spinner-duration) linear infinite;
animation: spinner-rotate var(--spinner-duration) linear infinite;
}
/* ── Dots variant ──────────────────────────────────────────────────── */
@ -72,14 +55,14 @@
[data-spinner][data-variant='dots'] [data-spinner-track] {
display: inline-flex;
align-items: center;
gap: calc(var(--_spinner-size) * 0.2);
block-size: var(--_spinner-size);
gap: calc(var(--spinner-size) * 0.2);
block-size: var(--spinner-size);
}
[data-spinner][data-variant='dots'] [data-spinner-dot] {
display: block;
inline-size: calc(var(--_spinner-size) * 0.3);
block-size: calc(var(--_spinner-size) * 0.3);
inline-size: calc(var(--spinner-size) * 0.3);
block-size: calc(var(--spinner-size) * 0.3);
border-radius: 50%;
background: var(--_spinner-color);
animation: spinner-bounce 1.2s ease-in-out infinite both;
@ -97,15 +80,15 @@
[data-spinner][data-variant='bars'] [data-spinner-track] {
display: inline-flex;
align-items: center;
gap: calc(var(--_spinner-size) * 0.12);
block-size: var(--_spinner-size);
gap: calc(var(--spinner-size) * 0.12);
block-size: var(--spinner-size);
}
[data-spinner][data-variant='bars'] [data-spinner-bar] {
display: block;
inline-size: calc(var(--_spinner-size) * 0.18);
block-size: var(--_spinner-size);
border-radius: var(--radius-sm);
inline-size: calc(var(--spinner-size) * 0.18);
block-size: var(--spinner-size);
border-radius: var(--spinner-bar-radius);
background: var(--_spinner-color);
animation: spinner-stretch 1s ease-in-out infinite;
}
@ -126,8 +109,8 @@
/* ── Label ─────────────────────────────────────────────────────────── */
[data-spinner-label] {
font-size: var(--font-size-sm);
color: var(--color-content-muted);
font-size: var(--spinner-label-font-size);
color: var(--spinner-label-fg);
}
/* ── Keyframes ─────────────────────────────────────────────────────── */
@ -173,6 +156,6 @@
}
[data-spinner][data-variant='ring'] [data-spinner-track] {
border-top-color: var(--_spinner-track);
opacity: var(--opacity-60);
opacity: var(--spinner-track-opacity);
}
}

@ -2135,6 +2135,22 @@
--proof-of-human-token-size: 1.25rem;
--proof-of-human-token-radius: var(--radius-full);
--proof-of-human-token-focus-offset: 3px;
--spinner-size-xs: 0.75rem;
--spinner-size-sm: 1rem;
--spinner-size-md: 1.25rem;
--spinner-size-lg: 1.75rem;
--spinner-size-xl: 2.5rem;
--spinner-thickness-xs: 1.5px;
--spinner-thickness-sm: 2px;
--spinner-thickness-md: 2px;
--spinner-thickness-lg: 3px;
--spinner-thickness-xl: 4px;
--spinner-gap: var(--space-2);
--spinner-duration: 0.9s;
--spinner-track-opacity: var(--opacity-60);
--spinner-bar-radius: var(--radius-sm);
--spinner-label-font-size: var(--size-sm-font-size);
--spinner-label-fg: var(--color-content-muted);
--textarea-input-font-size-xs: var(--size-xs-font-size);
--textarea-input-font-size-sm: var(--size-sm-font-size);
--textarea-input-font-size-md: var(--size-md-font-size);
@ -5978,10 +5994,32 @@
}
[data-spinner] {
--spinner-size: var(--spinner-size-md);
--spinner-thickness: var(--spinner-thickness-md);
--_spinner-palette-text: var(--color-primary-text);
--_spinner-palette-track: var(--color-primary-track);
}
[data-spinner][data-size='xs'] {
--spinner-size: var(--spinner-size-xs);
--spinner-thickness: var(--spinner-thickness-xs);
}
[data-spinner][data-size='sm'] {
--spinner-size: var(--spinner-size-sm);
--spinner-thickness: var(--spinner-thickness-sm);
}
[data-spinner][data-size='lg'] {
--spinner-size: var(--spinner-size-lg);
--spinner-thickness: var(--spinner-thickness-lg);
}
[data-spinner][data-size='xl'] {
--spinner-size: var(--spinner-size-xl);
--spinner-thickness: var(--spinner-thickness-xl);
}
[data-spinner][data-color], [data-spinner][data-color-custom] {
--_spinner-palette-track: var(--palette-track, var(--color-primary-track));
--_spinner-palette-text: var(--palette-text, var(--color-primary-text));

@ -3134,6 +3134,62 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
// tokens consume the palette directly (host default = primary); `'inherit'`
// keeps its own literal rule (currentColor takeover) outside the forward.
spinner: {
// ONE size coordinate drives the whole component: the ring's box, the dot
// track's height, the bar's height. Every other dimension is a PROPORTION
// of it (`calc(size * 0.3)` = the dot, `* 0.18` = the bar, `* 0.2` / `* 0.12`
// = the gaps) and those stay in the recipe: they are the SHAPE of each
// variant, not knobs. Same split as tree-grid's indent guides.
//
// The scale is the spinner's OWN, not the icon bundle's: measured
// 12/16/20/28/40 px here against 14/16/18/20/32 in `--icon-size-*`, which
// only agree at sm. Verbatim on purpose — forcing the bundle would move the
// default in four sizes out of five.
'size-xs': '0.75rem',
'size-sm': '1rem',
'size-md': '1.25rem',
'size-lg': '1.75rem',
'size-xl': '2.5rem',
'thickness-xs': '1.5px',
'thickness-sm': '2px',
'thickness-md': '2px',
'thickness-lg': '3px',
'thickness-xl': '4px',
size: {
declarations: [
{ value: 'var(--spinner-size-md)', scope: 'host' },
{ value: 'var(--spinner-size-xs)', scope: 'size:xs' },
{ value: 'var(--spinner-size-sm)', scope: 'size:sm' },
{ value: 'var(--spinner-size-lg)', scope: 'size:lg' },
{ value: 'var(--spinner-size-xl)', scope: 'size:xl' }
]
},
thickness: {
declarations: [
{ value: 'var(--spinner-thickness-md)', scope: 'host' },
{ value: 'var(--spinner-thickness-xs)', scope: 'size:xs' },
{ value: 'var(--spinner-thickness-sm)', scope: 'size:sm' },
{ value: 'var(--spinner-thickness-lg)', scope: 'size:lg' },
{ value: 'var(--spinner-thickness-xl)', scope: 'size:xl' }
]
},
gap: 'var(--space-2)',
// The spin period. It used to be `0.9s` bare; feed's spinner already earned
// duration tokens and media-player's did not (next-features §13) — done
// right here from the start.
duration: '0.9s',
// Only ever consumed under `prefers-reduced-motion`, where the ring stops
// and dims instead of spinning.
'track-opacity': 'var(--opacity-60)',
'bar-radius': 'var(--radius-sm)',
'label-font-size': 'var(--size-sm-font-size)',
'label-fg': 'var(--color-content-muted)',
// The ink and the track stay PRIVATE: each is one variable with two
// sources, switched by `data-color='inherit'` (palette forward →
// currentColor), and the track wraps its source in a different color-mix per
// branch (60 % normal, 25 % inherit). Flattening them would mean duplicating
// every rule per colour — same call as textarea's focus border.
'_palette-text': { value: 'var(--color-primary-text)', scope: 'host' },
'_palette-track': { value: 'var(--color-primary-track)', scope: 'host' }
},

@ -10,8 +10,9 @@
import { compileMorfo } from '$uix/morfo';
import { spinnerMorfo } from '@/uix/morfo/components/spinner';
import { getActiveUix } from '$active-uix';
import TokensPanel from '../../lib/TokensPanel.svelte';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'tokens' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
const uix = getActiveUix();
@ -158,6 +159,9 @@
</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>18</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -476,6 +480,10 @@
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="spinner" 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.