uix(knob): temable — 29 % → 75 %, y un nombre que significaba dos cosas

33 claves en una entrada NUEVA de `recipes/base.ts`. Censo 29 % → 75 %.

El knob ya tenía siete públicos «de facto» —consumidos como
`var(--knob-x, <default>)`— pero **ninguno declarado**, así que un tema no los
veía en `getRecipeTokens()`. Tokenizar ha sido sobre todo DECLARAR lo que ya se
consumía, y separar dos cosas que compartían nombre.

1. **Cuatro `--knob-*` quedan FUERA a propósito**: `progress`, `angle`,
   `start-angle` y `sweep` los escribe el provider INLINE desde el estado del
   control (`knob-provider.svelte.ts:358`). Canal de valor, no superficie de
   tema \u2014 ningún contrato gana a un estilo inline. Misma clase que
   `carousel.item-gap` y que la `z` del preview de drag-drop que retiró la
   revisión adversarial.

2. **⚠ `--knob-arc-width` significaba DOS cosas con DOS defaults distintos**: el
   inset de la cara del dial (`--space-3`, líneas 80 y 113) y la separación
   superior del puntero (`--space-2`, línea 165). Un tema que lo escribiera
   movía las tres declaraciones a la vez, pero **sin tema cada una valía algo
   distinto** \u2014 «un nombre que no distingue lo que debería», que es la
   definición de colisión que usa el propio generador. Separado en `arc-width`
   (la cara) y `pointer-inset` (el puntero), cada uno con su valor verbatim:
   cambia la superficie de tema, **no el píxel**.

3. **Las coordenadas por talla SUBEN al TSC, y no era opcional.** El default por
   talla vivía en un privado y el público era un override que iba DELANTE
   (`var(--knob-gap, var(--_knob-gap))`): declarar `--knob-gap` en el contrato
   **habría matado la escala**, porque ganaría siempre. Con el patrón del resto
   del eje (coordenadas + resuelto por `data-size`) el default sigue vivo y el
   tema alcanza. Eje sm|md|lg|xl, sin xs; el diámetro es `control-height × 2` en
   cada talla, proporción del bundle conservada verbatim.

`--_knob-value-digits` sigue privado: es el número de dígitos del valor máximo,
dato del consumidor.

Artefactos y gates:
- Sonda antes/después = 0 diffs en 754 valores × 7 estados. Determinismo
  verificado.
- R-5.4: 25/33; los ocho mudos adjudicados con su medición (la demo no monta
  etiqueta \u2014 0 nodos contados \u2014 ni estado de arrastre; forzados, los ocho
  mueven).
- censo --only 75 % · component:audit PASS · eidos-lint invalid 0, class-hooks
  0 · suite eidos 439/440 (el rojo conocido) · rtl:check 0 · docs:check 0 ·
  check por fichero limpio.
- README con «Talla y tema» y tab `Tokens` en la demo (33 claves).

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

@ -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%** — 2617 de 4678 knobs pasan por un token público del componente
- **Reparto**: público 2617 · privado 341 · global 1156 · literal 564 · sistema transversal 509 _(fuera del ratio)_
- **Sin token público propio**: 29 · **alcance < 20 %**: 20 · **alcance 100 %**: 14 · **con eje `size`**: 59
- **Alcance global**: **56%** — 2628 de 4678 knobs pasan por un token público del componente
- **Reparto**: público 2628 · privado 338 · global 1148 · literal 564 · sistema transversal 509 _(fuera del ratio)_
- **Sin token público propio**: 28 · **alcance < 20 %**: 20 · **alcance 100 %**: 14 · **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 (29)
## Sin ningún token público propio (28)
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) · [`knob`](./knob.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) · [`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) · [`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) · [`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) · [`wrap`](./wrap.md) · [`year-grid`](./year-grid.md)
## Tabla completa (162 recetas, por alcance ascendente)
@ -131,7 +131,6 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [time-range-field](./time-range-field.md) | 25% | 9 | 2 | 0 | 4 | 2 | 1 | 3 | – |
| [surface](./surface.md) | 25% | 4 | 1 | 3 | 0 | 0 | 0 | 25 | – |
| [callout](./callout.md) | 27% | 15 | 4 | 3 | 7 | 1 | 0 | 28 | – |
| [knob](./knob.md) | 29% | 27 | 7 | 4 | 9 | 4 | 3 | 0 | y |
| [onion-menu](./onion-menu.md) | 29% | 27 | 7 | 0 | 8 | 9 | 3 | 12 | – |
| [empty-state](./empty-state.md) | 29% | 17 | 5 | 7 | 5 | 0 | 0 | 6 | y |
| [banner](./banner.md) | 30% | 10 | 3 | 5 | 1 | 1 | 0 | 42 | y |
@ -196,6 +195,7 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [metrics](./metrics.md) | 74% | 50 | 37 | 0 | 9 | 4 | 0 | 68 | y |
| [editable](./editable.md) | 75% | 44 | 30 | 7 | 1 | 2 | 4 | 56 | y |
| [avatar](./avatar.md) | 75% | 36 | 27 | 3 | 0 | 6 | 0 | 0 | y |
| [knob](./knob.md) | 75% | 27 | 18 | 1 | 1 | 4 | 3 | 33 | – |
| [switch](./switch.md) | 76% | 22 | 16 | 4 | 1 | 0 | 1 | 51 | y |
| [file-upload](./file-upload.md) | 76% | 61 | 45 | 9 | 1 | 4 | 2 | 98 | y |
| [carousel](./carousel.md) | 77% | 35 | 23 | 2 | 0 | 5 | 5 | 27 | – |

@ -5,44 +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**: **29%** — 7 de 24 knobs por token público
- **Knobs de apariencia**: 27 — público 7 · privado 4 · global 9 · literal 4 · sistema 3 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): **sin entrada en `base.ts`**
- **Eje `size`**: sí · **ficheros**: `knob.css`
- **Medido**: 2026-08-22 · **Alcance**: **75%** — 18 de 24 knobs por token público
- **Knobs de apariencia**: 27 — público 18 · privado 1 · global 1 · literal 4 · sistema 3 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 33 pública(s) — `gap-sm`, `gap-md`, `gap-lg`, `gap-xl`, `diameter-sm`, `diameter-md`, `diameter-lg`, `diameter-xl`, `label-font-size-sm`, `label-font-size-md`, `label-font-size-lg`, `label-font-size-xl`, `value-font-size-sm`, `value-font-size-md`, `value-font-size-lg`, `value-font-size-xl`, `gap`, `diameter`, `label-font-size`, `value-font-size`, `radius`, `shadow`, `dragging-shadow`, `track-bg`, `face-bg`, `arc-width`, `pointer-inset`, `pointer-width`, `pointer-bg`, `indicator-radius`, `value-fg`, `label-fg`, `label-line-height`
- **Eje `size`**: no · **ficheros**: `knob.css`
## 1. Knobs fuera de alcance
### 1.1 Directo a primitivo global (9)
### 1.1 Directo a primitivo global (1)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `knob.css:59` | `[data-knob-control]` | `border-radius` | `var(--radius-full, 9999px)` |
| 2 | `knob.css:67` | `[data-knob-control]` | `box-shadow` | `var(--shadow-2)` |
| 3 | `knob.css:92` | `[data-knob-value-text]` | `color` | `var(--color-neutral-text)` |
| 4 | `knob.css:141` | `[data-knob-value-field] [data-spin-field-input]` | `color` | `var(--color-neutral-text)` |
| 5 | `knob.css:147` | `[data-knob-value-field] [data-spin-field-input]:focus-visible` | `border-radius` | `var(--radius-sm)` |
| 6 | `knob.css:168` | `[data-knob-indicator]::before` | `border-radius` | `var(--radius-sm)` |
| 7 | `knob.css:187` | `[data-knob][data-dragging] [data-knob-control]` | `box-shadow` | `var(--shadow-3)` |
| 8 | `knob.css:227` | `[data-knob-label]` | `line-height` | `var(--leading-ui)` |
| 9 | `knob.css:228` | `[data-knob-label]` | `color` | `var(--color-neutral-text)` |
### 1.2 A través de un privado (4)
| 1 | `knob.css:127` | `[data-knob-value-field] [data-spin-field-input]:focus-visible` | `border-radius` | `var(--radius-sm)` |
### 1.2 A través de un privado (1)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `knob.css:90` | `[data-knob-value-text]` | `font-size` | `var(--_knob-value-font)` |
| 2 | `knob.css:134` | `[data-knob-value-field] [data-spin-field-input]` | `inline-size` | `calc(var(--_knob-value-digits, 3) * 1ch)` |
| 3 | `knob.css:139` | `[data-knob-value-field] [data-spin-field-input]` | `font-size` | `var(--_knob-value-font)` |
| 4 | `knob.css:226` | `[data-knob-label]` | `font-size` | `var(--_knob-label-font)` |
| 1 | `knob.css:114` | `[data-knob-value-field] [data-spin-field-input]` | `inline-size` | `calc(var(--_knob-value-digits, 3) * 1ch)` |
### 1.3 Literales (4)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `knob.css:91` | `[data-knob-value-text]` | `line-height` | `1` |
| 2 | `knob.css:140` | `[data-knob-value-field] [data-spin-field-input]` | `line-height` | `1` |
| 3 | `knob.css:167` | `[data-knob-indicator]::before` | `block-size` | `28%` |
| 4 | `knob.css:177` | `[data-knob-control]:hover` | `filter` | `brightness(1.08)` |
| 1 | `knob.css:71` | `[data-knob-value-text]` | `line-height` | `1` |
| 2 | `knob.css:120` | `[data-knob-value-field] [data-spin-field-input]` | `line-height` | `1` |
| 3 | `knob.css:149` | `[data-knob-indicator]::before` | `block-size` | `28%` |
| 4 | `knob.css:159` | `[data-knob-control]:hover` | `filter` | `brightness(1.08)` |
## 2. Sistema transversal (3) — informativo, fuera del ratio
@ -50,27 +39,21 @@ Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `knob.css:145` | `[data-knob-value-field] [data-spin-field-input]:focus-visible` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
| 2 | `knob.css:181` | `[data-knob-control]:focus-visible` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
| 3 | `knob.css:192` | `[data-knob][data-disabled] [data-knob-control]` | `opacity` | `var(--opacity-disabled)` |
| 1 | `knob.css:125` | `[data-knob-value-field] [data-spin-field-input]:focus-visible` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
| 2 | `knob.css:163` | `[data-knob-control]:focus-visible` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
| 3 | `knob.css:174` | `[data-knob][data-disabled] [data-knob-control]` | `opacity` | `var(--opacity-disabled)` |
## 3. Privados de la receta — ¿de dónde sale su valor?
| privado | declaraciones | valor(es) | origen | ¿deriva de un público? |
| --- | ---: | --- | --- | :-: |
| `--_knob-gap` | 4 | `var(--size-md-gap)`, `var(--size-sm-gap)`, `var(--size-lg-gap)`, `var(--size-xl-gap)` | global | no |
| `--_knob-diameter` | 4 | `calc(var(--size-md-control-height) * 2)`, `calc(var(--size-sm-control-height) * 2)`, `calc(var(--size-lg-control-height) * 2)`, `calc(var(--size-xl-control-height) * 2)` | global | no |
| `--_knob-label-font` | 4 | `var(--size-md-font-size)`, `var(--size-sm-font-size)`, `var(--size-lg-font-size)`, `var(--size-xl-font-size)` | global | no |
| `--_knob-value-font` | 4 | `var(--size-md-font-size)`, `var(--size-sm-font-size)`, `var(--size-lg-font-size)`, `var(--size-xl-font-size)` | global | no |
_La receta no declara privados propios en su CSS._
Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inline del wrapper): `--_knob-value-digits`.
## 4. Propuesta de corrección
- **Consume la capa compartida `spin-field`.** Un eje que la capa posee se consume como `var(--_x, var(--x))`; el consumidor **no acuña** `--knob-{eje}` para él — sería un vocabulario paralelo (README de `eidos/components`, «Capas compartidas» regla 2).
- **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` (22)
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (3)
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
@ -80,39 +63,15 @@ no distingue lo que debería — se marca `⚠`.
| token (`--knob-…`) | scope TSC | valor propuesto | usos |
| --- | --- | --- | ---: |
| `control-shadow` | `root` | ⚠ `var(--shadow-2)` / `var(--shadow-3)` | 2 |
| `control-radius` | `root` | `var(--radius-full, 9999px)` | 1 |
| `value-text-font-size-md` | `size:md` | `var(--size-md-font-size)` | 1 |
| `value-text-font-size-sm` | `size:sm` | `var(--size-sm-font-size)` | 1 |
| `value-text-font-size-lg` | `size:lg` | `var(--size-lg-font-size)` | 1 |
| `value-text-font-size-xl` | `size:xl` | `var(--size-xl-font-size)` | 1 |
| `value-text-fg` | `root` | `var(--color-neutral-text)` | 1 |
| `value-field-font-size-md` | `size:md` | `var(--size-md-font-size)` | 1 |
| `value-field-font-size-sm` | `size:sm` | `var(--size-sm-font-size)` | 1 |
| `value-field-font-size-lg` | `size:lg` | `var(--size-lg-font-size)` | 1 |
| `value-field-font-size-xl` | `size:xl` | `var(--size-xl-font-size)` | 1 |
| `value-field-fg` | `root` | `var(--color-neutral-text)` | 1 |
| `value-field-radius` | `root` | `var(--radius-sm)` | 1 |
| `indicator-height` | `root` | `28%` | 1 |
| `indicator-radius` | `root` | `var(--radius-sm)` | 1 |
| `hover-control-filter` | `root` | `brightness(1.08)` | 1 |
| `label-font-size-md` | `size:md` | `var(--size-md-font-size)` | 1 |
| `label-font-size-sm` | `size:sm` | `var(--size-sm-font-size)` | 1 |
| `label-font-size-lg` | `size:lg` | `var(--size-lg-font-size)` | 1 |
| `label-font-size-xl` | `size:xl` | `var(--size-xl-font-size)` | 1 |
| `label-line-height` | `root` | `var(--leading-ui)` | 1 |
| `label-fg` | `root` | `var(--color-neutral-text)` | 1 |
### 4.2 Sin nombre mecánico (3)
- **⚠ 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** — 2: `line-height`.
- **⚠ decisión: el privado que alimenta este knob no se declara en el CSS (viene de `base.ts` o de un estilo inline) — hay que resolverlo antes de nombrarlo** — 1: `inline-size`.
### 4.3 Avisos sobre los tokens propuestos (2)
- **el privado `--_knob-value-font` debe pasar a leer este público (o desaparecer)** — `--knob-value-text-font-size-md`, `--knob-value-text-font-size-sm`, `--knob-value-text-font-size-lg`, `--knob-value-text-font-size-xl`, `--knob-value-field-font-size-md`, `--knob-value-field-font-size-sm`, `--knob-value-field-font-size-lg`, `--knob-value-field-font-size-xl`
- **el privado `--_knob-label-font` debe pasar a leer este público (o desaparecer)** — `--knob-label-font-size-md`, `--knob-label-font-size-sm`, `--knob-label-font-size-lg`, `--knob-label-font-size-xl`
### 4.4 Lo que hay que comprobar a mano (PLAN-theming §1.3 · §7.4)
- [ ] **Privado que no deriva de un público** — §3 lo marca; el privado debe leer el público o desaparecer.
@ -125,6 +84,33 @@ no distingue lo que debería — se marca `⚠`.
<!-- veredicto:start -->
_(pendiente — lo escribe el autor; se conserva al regenerar)_
**Medido 2026-08-22** (sonda ×2 = 0 diffs en 754 valores · 7 estados). El knob
ya tenía siete públicos «de facto» —consumidos con la forma
`var(--knob-x, <default>)`— pero **ninguno declarado en el contrato**, así que
un tema no los ve en `getRecipeTokens()` y el censo los cuenta bien por
casualidad. La tokenización consiste sobre todo en DECLARAR lo que ya se
consume, y en separar dos cosas que compartían nombre.
1. **Cuatro `--knob-*` NO son de tema: son canal de valor.** El provider los
escribe INLINE desde el estado del control
(`knob-provider.svelte.ts:358`): `--knob-progress`, `--knob-angle`,
`--knob-start-angle` y `--knob-sweep`. Ningún contrato puede ganarles y
ninguno debe intentarlo — es la clase de `carousel.item-gap` y de la `z` del
preview de `drag-drop`.
2. **⚠ `--knob-arc-width` significa DOS cosas con DOS defaults distintos**: el
inset de la cara del dial (`--space-3`, líneas 80 y 113) y la separación
superior del puntero (`--space-2`, línea 165). Un tema que lo escriba mueve
las tres a la vez, pero sin tema cada una vale algo distinto — «un nombre
que no distingue lo que debería», que es la definición de colisión. Se
separan: `arc-width` (la cara) y `pointer-inset` (el puntero), cada uno con
su valor verbatim. **Cambia la superficie, no el píxel.**
3. **Las cuatro coordenadas por talla suben al TSC.** Hoy el default por talla
vive en un privado y el público es un OVERRIDE que va DELANTE
(`var(--knob-gap, var(--_knob-gap))`), así que declarar `--knob-gap` en el
contrato **mataría la escala**: ganaría siempre. La forma correcta es la del
resto del eje — coordenadas `{eje}-{k}` + nombre resuelto por `data-size`, y
la receta lee el resuelto a secas. El eje es **sm|md|lg|xl**, sin xs.
4. El diámetro es `control-height × 2` por talla: una PROPORCIÓN del bundle,
que se conserva verbatim en cada coordenada.
<!-- veredicto:end -->

@ -142,6 +142,24 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'underline-offset':
"same: no underline at rest under data-underline='hover'; forced → reaches (2 → 11px). textUnderlineOffset added to the guard prop list too"
},
knob: {
'label-font-size-sm':
'the demo mounts no [data-knob-label] (0 nodes, counted); forced → reaches (per-size coordinate; the resolved name moves with it)',
'label-font-size-md':
'the demo mounts no [data-knob-label] (0 nodes, counted); forced → reaches (16 → 99px)',
'label-font-size-lg':
'the demo mounts no [data-knob-label] (0 nodes, counted); forced → reaches (per-size coordinate)',
'label-font-size-xl':
'the demo mounts no [data-knob-label] (0 nodes, counted); forced → reaches (per-size coordinate)',
'label-font-size':
'the demo mounts no [data-knob-label] (0 nodes, counted); forced → reaches (16 → 99px)',
'label-fg':
'the demo mounts no [data-knob-label] (0 nodes, counted); forced → reaches (rgb(1,2,3))',
'label-line-height':
'the demo mounts no [data-knob-label] (0 nodes, counted); forced → reaches (20px → 3.77em)',
'dragging-shadow':
'consumed only under [data-dragging], and the demo knob is at rest (0 nodes, counted); forced → reaches (0 0 0 7px rgb(4,5,6))'
},
label: {
'disabled-fg':
'the demo mounts no disabled label (0 [data-disabled] nodes, counted); forced → reaches (rgb(1,2,3))'

@ -104,6 +104,41 @@ Referencias: [APG Slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) ·
- **`Indicator` es decorativo** (`role="presentation"`, excepción A2.3
documentada en el morfo): se posiciona solo con las vars publicadas.
## Talla y tema
**Guard R-5.4** (`npm run theming:sentinel -- knob <url>`): 25/33 tokens mueven
un computed en vivo; los ocho que callan están adjudicados con su medición en
`scripts/theming-sentinel-exceptions.ts` (la demo no monta etiqueta ni estado
de arrastre). Corrido 2026-08-22.
Contrato en `lib/recipes/base.ts > knob` (33 claves, entrada NUEVA). Alcance
del censo: **75 %** (era 29 %).
**Cuatro `--knob-*` NO están en el contrato, y es deliberado**: `progress`,
`angle`, `start-angle` y `sweep` los escribe el provider **inline** desde el
estado del control (`knob-provider.svelte.ts:358`). Son canal de valor, no
superficie de tema, y ningún contrato puede ganar a un estilo inline — la clase
de `carousel.item-gap` y de la `z` del preview de `drag-drop`.
**Las cuatro coordenadas por talla subieron al TSC, y había que hacerlo así.**
Antes el default por talla vivía en un privado y el público era un OVERRIDE que
iba DELANTE (`var(--knob-gap, var(--_knob-gap))`): declarar `--knob-gap` en el
contrato **habría matado la escala**, porque ganaría siempre. El eje es
sm|md|lg|xl (sin xs), y el diámetro es `control-height × 2` en cada talla — una
proporción del bundle, conservada verbatim.
### ⚠ `--knob-arc-width` significaba dos cosas
Medido: el mismo nombre gobernaba el inset de la cara del dial (default
`--space-3`) y la separación superior del puntero (default `--space-2`). Un
tema que lo escribiera movía las tres declaraciones a la vez, pero **sin tema
cada una valía algo distinto** — «un nombre que no distingue lo que debería».
Separados en `arc-width` (la cara) y `pointer-inset` (el puntero), cada uno con
su valor verbatim: **cambia la superficie de tema, no el píxel** (0 diffs).
`--_knob-value-digits` sigue privado: es el número de dígitos del valor máximo,
dato del consumidor, no del tema.
## Gaps
| Gap | Disposición | Detalle |

@ -17,54 +17,34 @@
* diameter AND fonts scale together from the same size k (--size-{k}-*). The
* public --knob-* aliases below still override per-instance; the private var
* is only the size-driven default. */
--_knob-gap: var(--size-md-gap);
--_knob-diameter: calc(var(--size-md-control-height) * 2);
--_knob-label-font: var(--size-md-font-size);
--_knob-value-font: var(--size-md-font-size);
/* The four size coordinates (gap, diameter, label/value type) are EMITTED by
the TSC from the recipe contract. They used to live in privates behind a
`var(--knob-x, var(--_knob-x))` override, where the public went FIRST —
declaring the public in the contract would have killed the scale. */
display: inline-flex;
flex-direction: column;
align-items: center;
gap: var(--knob-gap, var(--_knob-gap));
}
[data-knob][data-size='sm'] {
--_knob-gap: var(--size-sm-gap);
--_knob-diameter: calc(var(--size-sm-control-height) * 2);
--_knob-label-font: var(--size-sm-font-size);
--_knob-value-font: var(--size-sm-font-size);
}
[data-knob][data-size='lg'] {
--_knob-gap: var(--size-lg-gap);
--_knob-diameter: calc(var(--size-lg-control-height) * 2);
--_knob-label-font: var(--size-lg-font-size);
--_knob-value-font: var(--size-lg-font-size);
gap: var(--knob-gap);
}
[data-knob][data-size='xl'] {
--_knob-gap: var(--size-xl-gap);
--_knob-diameter: calc(var(--size-xl-control-height) * 2);
--_knob-label-font: var(--size-xl-font-size);
--_knob-value-font: var(--size-xl-font-size);
}
/* Size — the TSC emits gap / diameter / label / value coordinates per size. */
[data-knob-control] {
position: relative;
/* Center the value readout in the dial's hole (known-knob convention). */
display: grid;
place-items: center;
inline-size: var(--knob-diameter, var(--_knob-diameter));
block-size: var(--knob-diameter, var(--_knob-diameter));
border-radius: var(--radius-full, 9999px); /* literal: fallback for circle */
inline-size: var(--knob-diameter);
block-size: var(--knob-diameter);
border-radius: var(--knob-radius);
/* Value arc (conic pie), driven by the provider-published progress. The face
* plate below carves it into a donut ring. */
background: conic-gradient(
from calc(var(--knob-start-angle, -135deg)),
var(--color-primary-solid) calc(var(--knob-progress, 0) * var(--knob-sweep, 270deg)),
var(--knob-track-bg, var(--color-neutral-track)) 0
var(--knob-track-bg) 0
);
box-shadow: var(--shadow-2);
box-shadow: var(--knob-shadow);
cursor: grab;
touch-action: none;
user-select: none;
@ -77,9 +57,9 @@
[data-knob-control]::before {
content: '';
position: absolute;
inset: var(--knob-arc-width, var(--space-3));
inset: var(--knob-arc-width);
border-radius: inherit;
background: var(--knob-face-bg, var(--color-surface-raised));
background: var(--knob-face-bg);
z-index: 0;
}
@ -87,9 +67,9 @@
[data-knob-value-text] {
position: relative;
z-index: 2;
font-size: var(--_knob-value-font);
font-size: var(--knob-value-font-size);
line-height: 1;
color: var(--color-neutral-text);
color: var(--knob-value-fg);
font-variant-numeric: tabular-nums;
}
@ -110,7 +90,7 @@
inline-size: auto;
min-inline-size: 0;
max-inline-size: calc(
var(--knob-diameter, var(--_knob-diameter)) - 2 * var(--knob-arc-width, var(--space-3))
var(--knob-diameter) - 2 * var(--knob-arc-width)
);
block-size: auto;
padding: 0;
@ -136,9 +116,9 @@
max-inline-size: none;
padding: 0;
text-align: center;
font-size: var(--_knob-value-font);
font-size: var(--knob-value-font-size);
line-height: 1;
color: var(--color-neutral-text);
color: var(--knob-value-fg);
font-variant-numeric: tabular-nums;
}
[data-knob-value-field] [data-spin-field-input]:focus-visible {
@ -160,13 +140,15 @@
content: '';
position: absolute;
inset-inline-start: calc(
50% - (var(--knob-pointer-width, 3px) / 2)
); /* literal: hairline pointer */
inset-block-start: var(--knob-arc-width, var(--space-2));
inline-size: var(--knob-pointer-width, 3px); /* literal: hairline pointer */
50% - (var(--knob-pointer-width) / 2)
);
/* Its own axis: the single `--knob-arc-width` that used to sit here carried
a DIFFERENT default (--space-2) than the face inset (--space-3). */
inset-block-start: var(--knob-pointer-inset);
inline-size: var(--knob-pointer-width);
block-size: 28%; /* literal: pointer proportion of face */
border-radius: var(--radius-sm);
background: var(--knob-pointer-bg, var(--color-neutral-text-strong));
border-radius: var(--knob-indicator-radius);
background: var(--knob-pointer-bg);
}
/* ---- states (R-1.x coverage) -------------------------------------------- */
@ -184,7 +166,7 @@
[data-knob][data-dragging] [data-knob-control] {
cursor: grabbing;
box-shadow: var(--shadow-3);
box-shadow: var(--knob-dragging-shadow);
}
[data-knob][data-disabled] [data-knob-control] {
@ -223,7 +205,7 @@
/* ---- typography ----------------------------------------------------------- */
[data-knob-label] {
font-size: var(--_knob-label-font);
line-height: var(--leading-ui);
color: var(--color-neutral-text);
font-size: var(--knob-label-font-size);
line-height: var(--knob-label-line-height);
color: var(--knob-label-fg);
}

@ -4084,6 +4084,35 @@
--menu-dial-gap: var(--space-3);
--menu-dial-stagger-step: 28ms;
--menu-dial-backdrop-bg: color-mix(in oklab, var(--color-neutral-text-strong) 40%, transparent);
--knob-gap-sm: var(--size-sm-gap);
--knob-gap-md: var(--size-md-gap);
--knob-gap-lg: var(--size-lg-gap);
--knob-gap-xl: var(--size-xl-gap);
--knob-diameter-sm: calc(var(--size-sm-control-height) * 2);
--knob-diameter-md: calc(var(--size-md-control-height) * 2);
--knob-diameter-lg: calc(var(--size-lg-control-height) * 2);
--knob-diameter-xl: calc(var(--size-xl-control-height) * 2);
--knob-label-font-size-sm: var(--size-sm-font-size);
--knob-label-font-size-md: var(--size-md-font-size);
--knob-label-font-size-lg: var(--size-lg-font-size);
--knob-label-font-size-xl: var(--size-xl-font-size);
--knob-value-font-size-sm: var(--size-sm-font-size);
--knob-value-font-size-md: var(--size-md-font-size);
--knob-value-font-size-lg: var(--size-lg-font-size);
--knob-value-font-size-xl: var(--size-xl-font-size);
--knob-radius: var(--radius-full);
--knob-shadow: var(--shadow-2);
--knob-dragging-shadow: var(--shadow-3);
--knob-track-bg: var(--color-neutral-track);
--knob-face-bg: var(--color-surface-raised);
--knob-arc-width: var(--space-3);
--knob-pointer-inset: var(--space-2);
--knob-pointer-width: 3px;
--knob-pointer-bg: var(--color-neutral-text-strong);
--knob-indicator-radius: var(--radius-sm);
--knob-value-fg: var(--color-neutral-text);
--knob-label-fg: var(--color-neutral-text);
--knob-label-line-height: var(--leading-ui);
--kbd-font-size: var(--size-xs-font-size);
--kbd-font-weight: var(--font-weight-medium);
--kbd-letter-spacing: var(--tracking-wide);
@ -7037,6 +7066,34 @@
--virtual-grid-font-size: var(--virtual-grid-font-size-xl);
}
[data-knob] {
--knob-gap: var(--knob-gap-md);
--knob-diameter: var(--knob-diameter-md);
--knob-label-font-size: var(--knob-label-font-size-md);
--knob-value-font-size: var(--knob-value-font-size-md);
}
[data-knob][data-size='sm'] {
--knob-gap: var(--knob-gap-sm);
--knob-diameter: var(--knob-diameter-sm);
--knob-label-font-size: var(--knob-label-font-size-sm);
--knob-value-font-size: var(--knob-value-font-size-sm);
}
[data-knob][data-size='lg'] {
--knob-gap: var(--knob-gap-lg);
--knob-diameter: var(--knob-diameter-lg);
--knob-label-font-size: var(--knob-label-font-size-lg);
--knob-value-font-size: var(--knob-value-font-size-lg);
}
[data-knob][data-size='xl'] {
--knob-gap: var(--knob-gap-xl);
--knob-diameter: var(--knob-diameter-xl);
--knob-label-font-size: var(--knob-label-font-size-xl);
--knob-value-font-size: var(--knob-value-font-size-xl);
}
[data-link-preview-content] {
--link-preview-padding-block: var(--link-preview-padding-block-md);
--link-preview-padding-inline: var(--link-preview-padding-inline-md);

@ -6602,6 +6602,86 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'stagger-step': '28ms',
'backdrop-bg': 'color-mix(in oklab, var(--color-neutral-text-strong) 40%, transparent)'
},
// Knob. FOUR `--knob-*` names are deliberately absent: `progress`, `angle`,
// `start-angle` and `sweep` are written INLINE by the provider from control
// state (`knob-provider.svelte.ts:358`) — a value channel, not a theme
// surface, and no contract can outrank an inline style.
//
// The size axis is sm|md|lg|xl (no xs). It moves here rather than staying in
// a private on purpose: the recipe used to read `var(--knob-gap,
// var(--_knob-gap))`, where the public goes FIRST — so declaring the public
// in the contract would have killed the per-size scale outright.
knob: {
'gap-sm': 'var(--size-sm-gap)',
'gap-md': 'var(--size-md-gap)',
'gap-lg': 'var(--size-lg-gap)',
'gap-xl': 'var(--size-xl-gap)',
// The dial is TWICE the control height at every size — a proportion of the
// bundle, kept verbatim per coordinate.
'diameter-sm': 'calc(var(--size-sm-control-height) * 2)',
'diameter-md': 'calc(var(--size-md-control-height) * 2)',
'diameter-lg': 'calc(var(--size-lg-control-height) * 2)',
'diameter-xl': 'calc(var(--size-xl-control-height) * 2)',
'label-font-size-sm': 'var(--size-sm-font-size)',
'label-font-size-md': 'var(--size-md-font-size)',
'label-font-size-lg': 'var(--size-lg-font-size)',
'label-font-size-xl': 'var(--size-xl-font-size)',
'value-font-size-sm': 'var(--size-sm-font-size)',
'value-font-size-md': 'var(--size-md-font-size)',
'value-font-size-lg': 'var(--size-lg-font-size)',
'value-font-size-xl': 'var(--size-xl-font-size)',
'gap': {
declarations: [
{ value: 'var(--knob-gap-md)', scope: 'host' },
{ value: 'var(--knob-gap-sm)', scope: 'size:sm' },
{ value: 'var(--knob-gap-lg)', scope: 'size:lg' },
{ value: 'var(--knob-gap-xl)', scope: 'size:xl' }
]
},
'diameter': {
declarations: [
{ value: 'var(--knob-diameter-md)', scope: 'host' },
{ value: 'var(--knob-diameter-sm)', scope: 'size:sm' },
{ value: 'var(--knob-diameter-lg)', scope: 'size:lg' },
{ value: 'var(--knob-diameter-xl)', scope: 'size:xl' }
]
},
'label-font-size': {
declarations: [
{ value: 'var(--knob-label-font-size-md)', scope: 'host' },
{ value: 'var(--knob-label-font-size-sm)', scope: 'size:sm' },
{ value: 'var(--knob-label-font-size-lg)', scope: 'size:lg' },
{ value: 'var(--knob-label-font-size-xl)', scope: 'size:xl' }
]
},
'value-font-size': {
declarations: [
{ value: 'var(--knob-value-font-size-md)', scope: 'host' },
{ value: 'var(--knob-value-font-size-sm)', scope: 'size:sm' },
{ value: 'var(--knob-value-font-size-lg)', scope: 'size:lg' },
{ value: 'var(--knob-value-font-size-xl)', scope: 'size:xl' }
]
},
// The dial face and its ring.
radius: 'var(--radius-full)',
shadow: 'var(--shadow-2)',
'dragging-shadow': 'var(--shadow-3)',
'track-bg': 'var(--color-neutral-track)',
'face-bg': 'var(--color-surface-raised)',
// SPLIT from a single `--knob-arc-width` that carried two meanings with two
// different defaults: the face inset (--space-3) and the pointer's top
// offset (--space-2). One name that does not distinguish what it should.
'arc-width': 'var(--space-3)',
'pointer-inset': 'var(--space-2)',
'pointer-width': '3px',
'pointer-bg': 'var(--color-neutral-text-strong)',
'indicator-radius': 'var(--radius-sm)',
'value-fg': 'var(--color-neutral-text)',
'label-fg': 'var(--color-neutral-text)',
'label-line-height': 'var(--leading-ui)'
},
kbd: {
'font-size': 'var(--size-xs-font-size)',
'font-weight': 'var(--font-weight-medium)',

@ -3,8 +3,9 @@
import { compileMorfo } from '$uix/morfo';
import { knobMorfo } from '@/uix/morfo/components/knob';
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 Intent = 'neutral' | 'affirm' | 'risk' | 'threat';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
@ -178,6 +179,9 @@
<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>33</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -503,6 +507,10 @@
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="knob" 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.