uix(textarea): temable — 0 % → 80 %, y el contador que casi pierde su 0.85

37 claves en el bloque `textarea` de `recipes/base.ts` (fusionadas con el
forward de paleta que ya estaba). Censo 0 % → 80 %, con **cero globales y cero
literales de valor**: los 16 privados leen ya su público. Global 46 %, sin
contrato 48 → 47.

LO QUE LA MEDICIÓN CAMBIÓ. La §4 generada proponía 35 tokens; cuatro eran
colisiones y uno habría movido el default:

1. `count-font-size-{k}` × 5 BORRABA el factor 0.85. El CSS calcula
   `calc(var(--_textarea-font-size) * 0.85)` y la propuesta ponía el tipo del
   input crudo en las cinco tallas: el contador habría crecido un 18 %.
   D-TH.5 lo prohíbe. Queda en UN token con el `calc` sobre el público del
   input — la escala se escribe una vez y la talla la resuelve el input. Pide
   `scope: 'host'`: emitido en `:root` su dependencia no existe, y el contrato
   de scope de la receta lo caza (lo cazó).
2. `input-fg` ⚠ eran DOS knobs (texto y placeholder) → `input-fg` +
   `input-placeholder-fg`. Igual `count-fg` → `count-fg` + `overflow-count-fg`.
3. `input-border` ⚠ NO eran dos knobs sino UNA variable con su swap de paleta,
   y el nombre estaba mal: es el borde de FOCO → `focus-input-border`. El de
   reposo es otro token, que la propuesta ni listó por viajar en el atajo.
4. `ghost-input-border` se RETIRA: la regla ghost lee ese mismo conmutador —
   sería un valor con dos nombres.

Bundle, medido: la tipografía casa 1:1 en las cinco (lee `--size-{k}-font-size`);
el spacing NO — `padding-inline` casa salvo en xs (8px vs 6px) y `padding-block`
sólo en xs. Verbatim y documentado: un campo multi-línea respira más, y el
spacing queda fuera del guard del bundle. Resueltos sin `parts` (el `data-size`
va en `[data-textarea]` y el input hereda); fuera los cuatro `[data-size]`.

DOS ARREGLOS DEL INSTRUMENTO, los dos por medición y no por sospecha:

- **El guard medía con el PUNTERO encima.** `reopen()` corre antes de CADA
  token y hace clic en `[data-{c}-input]` cuando el componente no tiene parte
  `content`; el `blur()` de la revisión adversarial quitaba el foco pero no el
  puntero, así que `:hover` casaba toda la corrida y toda regla de hover pisaba
  a sus vecinas. Costaba TRES falsos negativos aquí. Con `mouse.move(0,0)`
  tras cada blur, `input-border` revive solo (31 → 32).
- **`::placeholder` SÍ se lee con `getComputedStyle`.** La nota de §13 que
  decía lo contrario nunca se comprobó; medido, el color centinela vuelve tal
  cual. Entra en el snapshot (32 → 33) y deja STALE la entrada
  `command.input-placeholder-fg`, que el guard señaló y se ha borrado.

Corridos los DIEZ componentes con ledger tras los dos arreglos: ningún otro
cambia, un único STALE, el previsto.

Y un hallazgo de píxel que NO se toca aquí (→ next-features §13): el borde de
foco sólo se ve con Tab. Las tres reglas de `border-color` del input están
ordenadas al revés de lo que significan — hover (0,4,0) > foco (0,3,0) >
invalid (0,2,0) — y hacer clic deja el puntero encima por definición, así que
gana el hover; un campo inválido pierde su borde rojo al enfocarlo o al pasar
el ratón. El anillo de foco del sistema sí se ve siempre, así que no es fallo
de accesibilidad. Arreglarlo mueve píxel.

El 20 % que falta son las dos declaraciones que consumen
`--_textarea-border-focus`, privado a propósito: es el conmutador de una
variable con dos fuentes (el público y el forward THM-2), y aplanarlo exigiría
duplicar la regla por color. Mismo techo que `listbox` paga por consumir bien
su capa. Mi propio veredicto predecía ~100 % y la medición lo corrigió a 80 %.

Artefactos y gates:
- Sonda antes/después = 0 diffs en 1.247 valores × 8 estados (el 0.85 intacto,
  que era el riesgo). Determinismo verificado: dos corridas, 0 diffs.
- R-5.4: 33/37; los 4 mudos adjudicados en el ledger con su medición (foco,
  invalid y las dos del contador rebasado — estados que la demo no monta).
- censo --only 80 % · 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 (72 globales, ninguno mío).
- README con su «Talla y tema» y tab `Tokens` en la demo (37 claves; el panel
  muestra el contador computando `calc(calc(1rem * 1) * 0.85)`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent 60b5b98969
commit 893aeaf817

@ -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**: **46%** — 2130 de 4680 knobs pasan por un token público del componente
- **Reparto**: público 2130 · privado 675 · global 1291 · literal 584 · sistema transversal 509 _(fuera del ratio)_
- **Sin token público propio**: 48 · **alcance < 20 %**: 42 · **alcance 100 %**: 7 · **con eje `size`**: 67
- **Alcance global**: **46%** — 2150 de 4680 knobs pasan por un token público del componente
- **Reparto**: público 2150 · privado 661 · global 1286 · literal 583 · sistema transversal 509 _(fuera del ratio)_
- **Sin token público propio**: 47 · **alcance < 20 %**: 41 · **alcance 100 %**: 7 · **con eje `size`**: 66
## Cómo se lee
@ -88,15 +88,15 @@ theming §6.7 lo pone delante (`hover-bg`). Las fichas proponen la forma
| 16 | [code-block](./code-block.md) | 26 | 0% | 26 | 0 |
| 17 | [tree-view](./tree-view.md) | 26 | 0% | 28 | 0 |
| 18 | [mockup](./mockup.md) | 25 | 14% | 29 | 0 |
| 19 | [textarea](./textarea.md) | 25 | 0% | 28 | 0 |
| 20 | [aura](./aura.md) | 24 | 31% | 35 | 24 |
| 19 | [aura](./aura.md) | 24 | 31% | 35 | 24 |
| 20 | [password-field](./password-field.md) | 24 | 31% | 36 | 23 |
## Sin ningún token público propio (48)
## Sin ningún token público propio (47)
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) · [`card-group`](./card-group.md) · [`cascade`](./cascade.md) · [`clipboard`](./clipboard.md) · [`code`](./code.md) · [`code-block`](./code-block.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) · [`textarea`](./textarea.md) · [`tree-view`](./tree-view.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) · [`card-group`](./card-group.md) · [`cascade`](./cascade.md) · [`clipboard`](./clipboard.md) · [`code`](./code.md) · [`code-block`](./code-block.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) · [`tree-view`](./tree-view.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 | – |
| [textarea](./textarea.md) | 0% | 28 | 0 | 16 | 5 | 4 | 3 | 0 | y |
| [tree-view](./tree-view.md) | 0% | 28 | 0 | 8 | 14 | 4 | 2 | 0 | y |
| [code-block](./code-block.md) | 0% | 26 | 0 | 2 | 24 | 0 | 0 | 0 | – |
| [card-group](./card-group.md) | 0% | 24 | 0 | 0 | 18 | 4 | 2 | 0 | y |
@ -226,6 +225,7 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [radio-group](./radio-group.md) | 77% | 75 | 56 | 10 | 7 | 0 | 2 | 61 | y |
| [chat-log](./chat-log.md) | 77% | 42 | 30 | 0 | 4 | 5 | 3 | 31 | – |
| [chat-composer](./chat-composer.md) | 79% | 44 | 31 | 0 | 3 | 5 | 5 | 31 | – |
| [textarea](./textarea.md) | 80% | 28 | 20 | 2 | 0 | 3 | 3 | 37 | – |
| [gradient-builder](./gradient-builder.md) | 80% | 60 | 45 | 7 | 1 | 3 | 4 | 72 | – |
| [chat-message](./chat-message.md) | 82% | 84 | 63 | 2 | 6 | 6 | 7 | 75 | y |
| [fab](./fab.md) | 82% | 17 | 14 | 0 | 2 | 1 | 0 | 10 | – |

@ -5,52 +5,31 @@
> 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 25 knobs por token público
- **Knobs de apariencia**: 28 — público 0 · privado 16 · global 5 · literal 4 · sistema 3 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 0 pública(s) · 1 privada(s) forward — `_palette-solid`
- **Eje `size`**: sí · **ficheros**: `textarea.css`
- **Medido**: 2026-08-21 · **Alcance**: **80%** — 20 de 25 knobs por token público
- **Knobs de apariencia**: 28 — público 20 · privado 2 · global 0 · literal 3 · sistema 3 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 37 pública(s) — `input-font-size-xs`, `input-font-size-sm`, `input-font-size-md`, `input-font-size-lg`, `input-font-size-xl`, `input-padding-inline-xs`, `input-padding-inline-sm`, `input-padding-inline-md`, `input-padding-inline-lg`, `input-padding-inline-xl`, `input-padding-block-xs`, `input-padding-block-sm`, `input-padding-block-md`, `input-padding-block-lg`, `input-padding-block-xl`, `input-font-size`, `input-padding-inline`, `input-padding-block`, `gap`, `input-radius`, `input-border-width`, `input-border`, `hover-input-border`, `focus-input-border`, `invalid-input-border`, `input-bg`, `input-fg`, `input-placeholder-fg`, `input-font-family`, `input-font-weight`, `input-line-height`, `count-font-size`, `count-gap`, `count-fg`, `count-font-family`, `overflow-count-fg`, `overflow-count-font-weight` · 1 privada(s) forward — `_palette-solid`
- **Eje `size`**: no · **ficheros**: `textarea.css`
## 1. Knobs fuera de alcance
### 1.1 Directo a primitivo global (5)
### 1.1 Directo a primitivo global (0)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `textarea.css:88` | `[data-textarea-input]` | `font-family` | `var(--font-family-primary)` |
| 2 | `textarea.css:89` | `[data-textarea-input]` | `font-weight` | `var(--font-weight-medium)` |
| 3 | `textarea.css:111` | `[data-textarea-input][data-invalid]` | `border-color` | `var(--color-threat-solid)` |
| 4 | `textarea.css:142` | `[data-textarea-count]` | `font-family` | `var(--font-family-mono, ui-monospace, monospace)` |
| 5 | `textarea.css:150` | `[data-textarea-count][data-overflow]` | `font-weight` | `var(--font-weight-semibold)` |
_Ninguno._
### 1.2 A través de un privado (16)
### 1.2 A través de un privado (2)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `textarea.css:29` | `[data-textarea]` | `gap` | `var(--_textarea-gap)` |
| 2 | `textarea.css:82` | `[data-textarea-input]` | `padding-block` | `var(--_textarea-padding-block)` |
| 3 | `textarea.css:83` | `[data-textarea-input]` | `padding-inline` | `var(--_textarea-padding-inline)` |
| 4 | `textarea.css:84` | `[data-textarea-input]` | `border` | `var(--border-width) solid var(--_textarea-border)` |
| 5 | `textarea.css:85` | `[data-textarea-input]` | `border-radius` | `var(--_textarea-radius)` |
| 6 | `textarea.css:86` | `[data-textarea-input]` | `background` | `var(--_textarea-bg)` |
| 7 | `textarea.css:87` | `[data-textarea-input]` | `color` | `var(--_textarea-color)` |
| 8 | `textarea.css:90` | `[data-textarea-input]` | `font-size` | `var(--_textarea-font-size)` |
| 9 | `textarea.css:91` | `[data-textarea-input]` | `line-height` | `var(--_textarea-line-height)` |
| 10 | `textarea.css:99` | `[data-textarea-input]:hover:not([data-disabled]):not([data-readonly])` | `border-color` | `var(--_textarea-border-hover)` |
| 11 | `textarea.css:103` | `[data-textarea][data-focused] [data-textarea-input]` | `border-color` | `var(--_textarea-border-focus)` |
| 12 | `textarea.css:123` | `[data-textarea][data-variant='ghost'][data-focused] [data-textarea-input]` | `border-color` | `var(--_textarea-border-focus)` |
| 13 | `textarea.css:132` | `[data-textarea-input]::placeholder` | `color` | `var(--_textarea-placeholder)` |
| 14 | `textarea.css:141` | `[data-textarea-count]` | `color` | `var(--_textarea-count-color)` |
| 15 | `textarea.css:143` | `[data-textarea-count]` | `font-size` | `calc(var(--_textarea-font-size) * 0.85)` |
| 16 | `textarea.css:149` | `[data-textarea-count][data-overflow]` | `color` | `var(--_textarea-count-overflow)` |
### 1.3 Literales (4)
| 1 | `textarea.css:71` | `[data-textarea][data-focused] [data-textarea-input]` | `border-color` | `var(--_textarea-border-focus)` |
| 2 | `textarea.css:91` | `[data-textarea][data-variant='ghost'][data-focused] [data-textarea-input]` | `border-color` | `var(--_textarea-border-focus)` |
### 1.3 Literales (3)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `textarea.css:30` | `[data-textarea]` | `inline-size` | `100%` |
| 2 | `textarea.css:79` | `[data-textarea-input]` | `inline-size` | `100%` |
| 3 | `textarea.css:139` | `[data-textarea-count]` | `gap` | `0.25em` |
| 4 | `textarea.css:145` | `[data-textarea-count]` | `line-height` | `1` |
| 1 | `textarea.css:19` | `[data-textarea]` | `inline-size` | `100%` |
| 2 | `textarea.css:47` | `[data-textarea-input]` | `inline-size` | `100%` |
| 3 | `textarea.css:113` | `[data-textarea-count]` | `line-height` | `1` |
## 2. Sistema transversal (3) — informativo, fuera del ratio
@ -58,37 +37,22 @@ Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `textarea.css:106` | `[data-textarea][data-focused] [data-textarea-input]` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
| 2 | `textarea.css:128` | `[data-textarea-input][data-disabled]` | `opacity` | `var(--opacity-disabled)` |
| 3 | `textarea.css:154` | `[data-textarea-count-sep]` | `opacity` | `var(--opacity-muted)` |
| 1 | `textarea.css:74` | `[data-textarea][data-focused] [data-textarea-input]` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
| 2 | `textarea.css:96` | `[data-textarea-input][data-disabled]` | `opacity` | `var(--opacity-disabled)` |
| 3 | `textarea.css:122` | `[data-textarea-count-sep]` | `opacity` | `var(--opacity-muted)` |
## 3. Privados de la receta — ¿de dónde sale su valor?
| privado | declaraciones | valor(es) | origen | ¿deriva de un público? |
| --- | ---: | --- | --- | :-: |
| `--_textarea-padding-inline` | 5 | `var(--space-3)`, `var(--space-2)`, `var(--space-4)`, `var(--space-5)` | global | no |
| `--_textarea-padding-block` | 5 | `var(--space-2)`, `var(--space-1)`, `var(--space-1-5)`, `var(--space-3)`, `var(--space-4)` | global | no |
| `--_textarea-gap` | 1 | `var(--space-2)` | global | no |
| `--_textarea-font-size` | 5 | `var(--font-size-md)`, `var(--font-size-xs)`, `var(--font-size-sm)`, `var(--font-size-lg)`, `var(--font-size-xl)` | global | no |
| `--_textarea-line-height` | 1 | `var(--leading-normal)` | global | no |
| `--_textarea-radius` | 1 | `var(--radius-default)` | global | no |
| `--_textarea-border` | 1 | `var(--color-neutral-border)` | global | no |
| `--_textarea-border-hover` | 1 | `var(--color-border-strong)` | global | no |
| `--_textarea-border-focus` | 2 | `var(--focus-ring-color)`, `var(--_textarea-palette-solid)` | private, system | no |
| `--_textarea-bg` | 1 | `var(--color-surface-default)` | global | no |
| `--_textarea-color` | 1 | `var(--color-content-primary)` | global | no |
| `--_textarea-placeholder` | 1 | `var(--color-content-muted)` | global | no |
| `--_textarea-count-color` | 1 | `var(--color-content-muted)` | global | no |
| `--_textarea-count-overflow` | 1 | `var(--color-threat-text)` | global | no |
| `--_textarea-border-focus` | 2 | `var(--textarea-focus-input-border)`, `var(--_textarea-palette-solid)` | private, public | no |
| `--_textarea-resize` | 4 | `vertical`, `none`, `horizontal`, `both` | literal | no |
Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inline del wrapper): `--_textarea-palette-solid`.
## 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` (35)
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (2)
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
@ -98,63 +62,17 @@ no distingue lo que debería — se marca `⚠`.
| token (`--textarea-…`) | scope TSC | valor propuesto | usos |
| --- | --- | --- | ---: |
| `input-fg` | `root` | ⚠ `var(--color-content-primary)` / `var(--color-content-muted)` | 2 |
| `input-border` | `root` | ⚠ `var(--focus-ring-color)` / `var(--_textarea-palette-solid)` | 2 |
| `ghost-input-border` | `root` | ⚠ `var(--focus-ring-color)` / `var(--_textarea-palette-solid)` | 2 |
| `count-fg` | `root` | ⚠ `var(--color-content-muted)` / `var(--color-threat-text)` | 2 |
| `gap` | `root` | `var(--space-2)` | 1 |
| `input-padding-block-md` | `size:md` | `var(--space-2)` | 1 |
| `input-padding-block-xs` | `size:xs` | `var(--space-1)` | 1 |
| `input-padding-block-sm` | `size:sm` | `var(--space-1-5)` | 1 |
| `input-padding-block-lg` | `size:lg` | `var(--space-3)` | 1 |
| `input-padding-block-xl` | `size:xl` | `var(--space-4)` | 1 |
| `input-padding-inline-md` | `size:md` | `var(--space-3)` | 1 |
| `input-padding-inline-xs` | `size:xs` | `var(--space-2)` | 1 |
| `input-padding-inline-sm` | `size:sm` | `var(--space-2)` | 1 |
| `input-padding-inline-lg` | `size:lg` | `var(--space-4)` | 1 |
| `input-padding-inline-xl` | `size:xl` | `var(--space-5)` | 1 |
| `input-radius` | `root` | `var(--radius-default)` | 1 |
| `input-bg` | `root` | `var(--color-surface-default)` | 1 |
| `input-font-family` | `root` | `var(--font-family-primary)` | 1 |
| `input-font-weight` | `root` | `var(--font-weight-medium)` | 1 |
| `input-font-size-md` | `size:md` | `var(--font-size-md)` | 1 |
| `input-font-size-xs` | `size:xs` | `var(--font-size-xs)` | 1 |
| `input-font-size-sm` | `size:sm` | `var(--font-size-sm)` | 1 |
| `input-font-size-lg` | `size:lg` | `var(--font-size-lg)` | 1 |
| `input-font-size-xl` | `size:xl` | `var(--font-size-xl)` | 1 |
| `input-line-height` | `root` | `var(--leading-normal)` | 1 |
| `hover-input-border` | `root` | `var(--color-border-strong)` | 1 |
| `invalid-input-border` | `root` | `var(--color-threat-solid)` | 1 |
| `count-gap` | `root` | `0.25em` | 1 |
| `count-font-family` | `root` | `var(--font-family-mono, ui-monospace, monospace)` | 1 |
| `count-font-size-md` | `size:md` | `var(--font-size-md)` | 1 |
| `count-font-size-xs` | `size:xs` | `var(--font-size-xs)` | 1 |
| `count-font-size-sm` | `size:sm` | `var(--font-size-sm)` | 1 |
| `count-font-size-lg` | `size:lg` | `var(--font-size-lg)` | 1 |
| `count-font-size-xl` | `size:xl` | `var(--font-size-xl)` | 1 |
| `count-font-weight` | `root` | `var(--font-weight-semibold)` | 1 |
### 4.2 Sin nombre mecánico (4)
| `input-border` _(ya existe)_ | `root` | ⚠ `var(--textarea-focus-input-border)` / `var(--_textarea-palette-solid)` | 2 |
| `ghost-input-border` | `root` | ⚠ `var(--textarea-focus-input-border)` / `var(--_textarea-palette-solid)` | 2 |
### 4.2 Sin nombre mecánico (3)
- **⚠ decisión: `100%` 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: `inline-size`.
- **⚠ 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: `line-height`.
### 4.3 Avisos sobre los tokens propuestos (13)
- **el privado `--_textarea-gap` debe pasar a leer este público (o desaparecer)** — `--textarea-gap`
- **el privado `--_textarea-padding-block` debe pasar a leer este público (o desaparecer)** — `--textarea-input-padding-block-md`, `--textarea-input-padding-block-xs`, `--textarea-input-padding-block-sm`, `--textarea-input-padding-block-lg`, `--textarea-input-padding-block-xl`
- **el privado `--_textarea-padding-inline` debe pasar a leer este público (o desaparecer)** — `--textarea-input-padding-inline-md`, `--textarea-input-padding-inline-xs`, `--textarea-input-padding-inline-sm`, `--textarea-input-padding-inline-lg`, `--textarea-input-padding-inline-xl`
- **el privado `--_textarea-radius` debe pasar a leer este público (o desaparecer)** — `--textarea-input-radius`
- **el privado `--_textarea-bg` debe pasar a leer este público (o desaparecer)** — `--textarea-input-bg`
- **el privado `--_textarea-color` debe pasar a leer este público (o desaparecer)** — `--textarea-input-fg`
- **el privado `--_textarea-font-size` debe pasar a leer este público (o desaparecer)** — `--textarea-input-font-size-md`, `--textarea-input-font-size-xs`, `--textarea-input-font-size-sm`, `--textarea-input-font-size-lg`, `--textarea-input-font-size-xl`, `--textarea-count-font-size-md`, `--textarea-count-font-size-xs`, `--textarea-count-font-size-sm`, `--textarea-count-font-size-lg`, `--textarea-count-font-size-xl`
- **el privado `--_textarea-line-height` debe pasar a leer este público (o desaparecer)** — `--textarea-input-line-height`
- **el privado `--_textarea-border-hover` debe pasar a leer este público (o desaparecer)** — `--textarea-hover-input-border`
### 4.3 Avisos sobre los tokens propuestos (1)
- **el privado `--_textarea-border-focus` debe pasar a leer este público (o desaparecer)** — `--textarea-input-border`, `--textarea-ghost-input-border`
- **el privado `--_textarea-placeholder` debe pasar a leer este público (o desaparecer)** — `--textarea-input-fg`
- **el privado `--_textarea-count-color` debe pasar a leer este público (o desaparecer)** — `--textarea-count-fg`
- **el privado `--_textarea-count-overflow` debe pasar a leer este público (o desaparecer)** — `--textarea-count-fg`
### 4.4 Lo que hay que comprobar a mano (PLAN-theming §1.3 · §7.4)
@ -168,6 +86,67 @@ 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 1.247
valores · 8 estados). La §4 propone 35 tokens y **cuatro de ellos son
colisiones que hay que partir o retirar**, más un error que cambiaría el
default. Corregido queda en 37 claves.
1. **`count-font-size-{k}` × 5 BORRARÍA el factor 0.85 — es el error grave de
la propuesta.** El CSS calcula `calc(var(--_textarea-font-size) * 0.85)`
(con su anotación `/* literal: */`), y la §4 propone cinco tokens con el
font-size del INPUT crudo: el contador crecería un 18 % en las cinco
tallas. D-TH.5 lo prohíbe. La corrección: **UN** token
`count-font-size` que conserva el `calc` sobre el público del input, así
sigue la talla sin repetir la escala. Mismo reparto que `tree-grid.guide-fg`
— el knob es el valor, la construcción se queda en la receta.
2. **`input-fg` ⚠ son DOS knobs**: la tinta del texto
(`--color-content-primary`) y la del placeholder
(`--color-content-muted`). El clasificador las fundió por caer ambas sobre
la parte `input`. → `input-fg` + `input-placeholder-fg` (precedente
exacto: `command.input-fg` / `command.input-placeholder-fg`).
3. **`count-fg` ⚠ son DOS knobs**: reposo (`content-muted`) y rebasado
(`threat-text`). → `count-fg` + `overflow-count-fg`; `overflow` es valor
de `data-overflow`, declarado en el morfo (`textarea.ts:163`), así que
entra como modificador delante.
4. **`input-border` ⚠ NO son dos knobs, y su nombre está mal.** Los dos
valores son UNA variable (`--_textarea-border-focus`) con su swap de
paleta: `--focus-ring-color` de base y `--_textarea-palette-solid` cuando
hay `data-color`. El swap es el forward THM-2 y **se queda privado**. Y el
knob es el borde de FOCO, no el de reposo → `focus-input-border`. El de
reposo (`--color-neutral-border`) es otro token, que la §4 ni listó porque
viaja dentro del atajo `border:`.
5. **`ghost-input-border` ⚠ se RETIRA de la propuesta**: la regla ghost lee
exactamente la misma `--_textarea-border-focus` que la regla base. Un token
propio sería un duplicado con dos nombres para un valor.
6. **`border` shorthand se parte** en `input-border-width` + `input-border`
(color) — molde `command.input-border` / `input-border-width`.
7. **Qué casa con el bundle, medido** (`generated/base.css:409-450`):
- `font-size` casa **1:1 en las cinco** (`font-size-{k}` ES
`--size-{k}-font-size`) → van al bundle.
- `padding-inline` casa en sm/md/lg/xl pero **NO en xs**: el textarea pone
`--space-2` (8px) donde el bundle dice `--space-1-5` (6px).
- `padding-block` **no casa en ninguna salvo xs**: sm 6px vs 4px, md 8px vs
6px, lg 12px vs 8px. El campo multi-línea respira más que un control de
una línea, y es correcto.
Los dos ejes de spacing van **verbatim en `--space-*`**, con la desviación
escrita: el guard del bundle gobierna control-height / font-size /
icon-size, no el spacing (precedente `table.cell-padding-*` y
`tree-grid.row-padding-*`). Los resueltos **sin `parts`** — `data-size` se
estampa en `[data-textarea]` y el input hereda; molde `command`.
8. **Fuera del contrato, a propósito**: los dos `inline-size: 100%` y el
`line-height: 1` del count (identidad, D-TH.2); `--_textarea-resize`, que
no es apariencia sino el enum de comportamiento que el morfo conduce; y el
forward de paleta.
9. **Alcance final 80 %** (20 de 25) — mi propia predicción de «≈100 %» era
errónea y la medición la corrigió. **Cero globales** y cero privados de
valor: los 16 de la propuesta leen ya su público. Lo que queda son las
DOS declaraciones que consumen `--_textarea-border-focus` (base y ghost),
que el censo cuenta como `private` porque el privado sobrevive **a
propósito** — es el conmutador de una variable con dos fuentes (el token
público y el forward de paleta), y aplanarlo exigiría duplicar la regla
por color. Es el mismo techo de `listbox` (68 % por consumir bien su capa)
y el mismo que §13 registra para los forwards THM-2: la métrica penaliza
hacer lo correcto. Más los tres literales de identidad (dos `100%` y el
`line-height: 1` del contador), fuera del perímetro por D-TH.2.
<!-- veredicto:end -->

@ -464,10 +464,13 @@ existiera. Se anotan porque el eje entero se apoya en esas mediciones.
viejo: **22 falsos negativos de 26 «no effect»** en F2-A, con tres causas
(el clic-foco, escribir sólo en `[data-{c}]` cuando las partes cuelgan de
`{c}-root`, y no leer `::before`/`::after`). Las tres, arregladas.
- **El centinela no ve `::placeholder`.** `::before`/`::after` ya se leen
(arreglo 2026-08-21 — el aro de buffering de media-player y el spinner de
feed adjudican solos); `::placeholder` sigue fuera de `getComputedStyle` y
queda como excepción adjudicada por token en el ledger.
- ~~**El centinela no ve `::placeholder`.**~~ **ERA FALSO, y ARREGLADO
2026-08-21** (al tokenizar `textarea`): `getComputedStyle(el, '::placeholder')`
SÍ devuelve el valor — medido, el color centinela volvió tal cual. La
afirmación anterior nunca se comprobó y le costó a
`command.input-placeholder-fg` una entrada de ledger «verificada a mano» que
ha salido STALE y se ha borrado. El pseudo entra ahora en el snapshot del
guard, junto a `::before`/`::after`.
- **El centinela no ve lo que pinta un componente COMPUESTO.** Los tokens que
media-player reenvía al Slider (`--media-player-track` →
`--slider-track-bg`) pintan en nodos `[data-slider-*]`, fuera del espacio de
@ -613,5 +616,30 @@ Lo que `grid-list` añadió (2026-08-21):
contra los selectores reales de cada capa (`listbox`, `menubar` y
`navigation-menu` están en la misma entrada y tampoco aparecen en el fichero).
Lo que `textarea` añadió (2026-08-21):
- **⚠ El borde de foco de `textarea` no se ve al hacer CLIC — sólo con Tab.**
Medido: la regla de hover `[data-textarea-input]:hover:not([data-disabled]):not([data-readonly])`
pesa **(0,4,0)** y la de foco `[data-textarea][data-focused] [data-textarea-input]`
**(0,3,0)**, así que con foco y puntero a la vez gana el hover — y hacer clic
deja el puntero encima por definición. Verificado con las dos reglas
apuntando a colores distintos: sale el del hover. Debajo, `[data-invalid]`
pesa **(0,2,0)**: un campo inválido pierde su borde rojo tanto al enfocarlo
como al pasar el ratón. El anillo de foco del SISTEMA (`outline`) sí se ve
siempre, así que no es un fallo de accesibilidad, pero el orden de las tres
reglas de `border-color` está invertido respecto a lo que significan.
Arreglarlo mueve píxel: no entra en el commit de tokenización.
- ~~**El guard mide con el PUNTERO encima del componente.**~~ **ARREGLADO
2026-08-21**: `reopen()` corre antes de CADA token y hace clic en
`[data-{c}-input]` cuando el componente no tiene parte `content` — el
`blur()` que añadió la revisión adversarial quita el foco pero **no el
puntero**, así que `:hover` seguía casando durante toda la corrida y toda
regla de hover pisaba a sus vecinas de reposo. Es la mitad que aquel arreglo
dejó atrás. En `textarea` costaba tres falsos negativos
(`input-border`, `focus-input-border`, `invalid-input-border`); con
`page.mouse.move(0, 0)` tras cada blur, `input-border` revive solo. Corridos
los diez componentes con ledger: ningún otro cambia, un único STALE
(`command.input-placeholder-fg`, por el arreglo del `::placeholder`).
**Deps**: ninguna. Son mejoras del instrumental del eje, ejecutables cuando
estorben.

@ -21,8 +21,6 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
command: {
'focus-input-border':
'consumed only under :focus-visible, which the guard blurs away ON PURPOSE (the click-focus poisoning fix); focused by hand → reaches (rgb(9,9,9))',
'input-placeholder-fg':
'::placeholder — getComputedStyle cannot read it; verified by hand 2026-08-20 (oklch muted → rgb(1,2,3))',
'loading-height': 'the loading bar is conditional and the demo does not mount it',
'loading-fg': 'the loading bar is conditional and the demo does not mount it',
'viewport-padding': 'the non-ScrollArea viewport variant is not mounted in the demo',
@ -116,6 +114,16 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'sentinel-spinner-reduced-duration':
'behind prefers-reduced-motion; emulated reduce context → reaches (4s → 11.5s)'
},
textarea: {
'focus-input-border':
'consumed only under [data-focused], which the guard blurs away ON PURPOSE (the click-focus poisoning fix); focused by hand with the pointer parked → reaches (rgb(4,5,6))',
'invalid-input-border':
'consumed only under [data-invalid]; the demo boots valid. Forced by hand 2026-08-21 → reaches (rgb(7,8,9))',
'overflow-count-fg':
'the count only carries [data-overflow] past maxLength (280 chars); forced → reaches (rgb(13,14,15))',
'overflow-count-font-weight':
'the count only carries [data-overflow] past maxLength (280 chars); forced → reaches (font-weight 333)'
},
'grid-list': {
'invalid-border':
'consumed only under [data-invalid]; the demo boots with its invalid control off, so the rule never matches at measurement. Toggled by hand 2026-08-21 → reaches (oklch(0.9555 0.0207 13.86) → rgb(1,2,3))'

@ -157,9 +157,21 @@ async function main() {
}
}
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.());
// ...and PARK THE POINTER. blur() drops the focus but Playwright leaves the
// cursor where it clicked, so `:hover` keeps matching — and a hover rule
// usually outweighs the rest / focus / invalid ones it shares a node with
// (measured on textarea 2026-08-21: hover (0,4,0) beats focus (0,3,0) beats
// invalid (0,2,0) beats rest (0,1,0), so THREE rest-state tokens read dead).
// Same class as the click-focus false negative above, and the half that fix
// left behind. The per-token hover pass re-hovers on purpose further down.
await page.mouse.move(0, 0);
const dead: string[] = [];
const live: string[] = [];
// Runs before EVERY token. A component with no `content` part (textarea,
// any flat control) falls through to the click branch on every single key,
// so the pointer parking below is not belt-and-braces — without it the
// cursor sits on the input for the whole run.
const reopen = async () => {
const open = await page.locator(`[data-${component}-content]`).count();
if (open) return;
@ -170,6 +182,7 @@ async function main() {
await el.click({ timeout: 1000 });
await page.waitForTimeout(250);
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur?.());
await page.mouse.move(0, 0);
return;
} catch {
/* keep trying */
@ -200,8 +213,13 @@ async function main() {
const own = (props as string[]).map((p) => cs[p as never]).join('|');
const b = getComputedStyle(n, '::before');
const a = getComputedStyle(n, '::after');
// ::placeholder IS readable through getComputedStyle - measured
// 2026-08-21 on textarea (the sentinel colour came straight back).
// The next-features §13 note saying it is not was wrong, and it had
// already cost command.input-placeholder-fg a hand-checked entry.
const ph = getComputedStyle(n, '::placeholder');
const pseudo = (props as string[])
.map((p) => `${b[p as never]}~${a[p as never]}`)
.map((p) => `${b[p as never]}~${a[p as never]}~${ph[p as never]}`)
.join('|');
return own + '#' + pseudo;
})

@ -94,6 +94,61 @@ Referencias: [Ant Input.TextArea](https://ant.design/components/input#inputtexta
- **Radio desacoplado de la talla** (modelo Radix): `--radius-default`
constante entre tallas.
## Talla y tema
**Guard R-5.4** (`npm run theming:sentinel -- textarea <url>`): 33/37 tokens
mueven un computed en vivo; los cuatro que callan están adjudicados uno a uno
con su porqué medido en `scripts/theming-sentinel-exceptions.ts` (los cuatro
son estados que la demo no monta: foco, invalid y las dos del contador
rebasado). Corrido 2026-08-21.
Contrato en `lib/recipes/base.ts > textarea` (37 claves). Alcance del censo:
**80 %** (era 0 % antes del 2026-08-21), con **cero globales y cero literales
de valor** — los 16 privados que había leen ya su público.
**El eje `size`** (xs..xl) se estampa en `[data-textarea]` y el input hereda,
así que los tres nombres resueltos van SIN `parts`; el CSS ya no lleva bloques
`[data-size]`. Qué casa con el bundle, medido: la tipografía **sí, 1:1 en las
cinco** (por eso lee `--size-{k}-font-size`); el spacing **no** —
`padding-inline` casa salvo en xs (8px aquí, 6px allí) y `padding-block` sólo
en xs; de sm en adelante este campo es más suelto (6/8/12/16). Es correcto: un
campo multi-línea respira más que un control de una línea, y el spacing queda
fuera del guard del bundle, que gobierna control-height / font-size /
icon-size. El `radius` está DESACOPLADO de la talla a propósito (modelo Radix).
**El contador es UN token, no cinco.** Su tipo es proporcional al campo
(`calc(var(--textarea-input-font-size) * 0.85)`), y como el font-size del input
ya resuelve por talla, la escala se escribe una sola vez. La propuesta generada
pedía cinco `count-font-size-{k}` con el tipo del input crudo: habría **borrado
el 0.85** y agrandado el contador un 18 % en las cinco tallas. El default no se
mueve (D-TH.5). El token necesita `scope: 'host'` porque lee un resuelto que
sólo existe en `[data-textarea]` — emitido en `:root` el `var()` resolvería
donde su dependencia no está, y el contrato de scope de la receta lo caza.
**Dos privados sobreviven, y no son deuda**: `--_textarea-resize` no es
apariencia sino el enum de comportamiento que conduce el morfo, y
`--_textarea-border-focus` es el conmutador de una variable con DOS fuentes —
el token público `focus-input-border` de base, y el forward de paleta THM-2
cuando hay `data-color`. Aplanarlo exigiría duplicar la regla por color. Ese
privado es todo el 20 % que falta: el censo lo cuenta como `private`, el mismo
techo que `listbox` paga por consumir bien su capa (`next-features.md` §13).
No hay `ghost-input-border`: la regla ghost lee ese mismo conmutador, y un
token propio sería un valor con dos nombres.
### El borde de foco se ve con Tab, no con el clic
Medido el 2026-08-21, **anterior a la tokenización**: las tres reglas que
pintan `border-color` sobre el input están ordenadas al revés de lo que
significan. El hover pesa **(0,4,0)**, el foco **(0,3,0)** y el invalid
**(0,2,0)**, así que con el puntero encima —que es exactamente donde queda tras
hacer clic— gana el hover, y un campo inválido pierde su borde rojo tanto al
enfocarlo como al pasar el ratón. El anillo de foco del SISTEMA (`outline`) sí
se ve siempre, así que no es un fallo de accesibilidad. Arreglarlo mueve píxel
y no pertenece a este eje: registrado en `next-features.md` §13.
`R-5.1 exception: inline-size: 100%` (×2) y el `line-height: 1` del contador —
identidad de layout, fuera del perímetro de knob (D-TH.2).
## Gaps
| Gap | Disposición | Detalle |

@ -1,57 +1,25 @@
/* TextArea — multi-line text input. Skin layered over `<textarea>`. */
[data-textarea] {
--_textarea-padding-inline: var(--space-3);
--_textarea-padding-block: var(--space-2);
--_textarea-gap: var(--space-2);
--_textarea-font-size: var(--font-size-md);
--_textarea-line-height: var(--leading-normal);
/* Radius DECOUPLED from size (Radix model) — control archetype default, constant across sizes. */
--_textarea-radius: var(--radius-default);
--_textarea-border: var(--color-neutral-border);
/* `--color-{role}-border-strong` is NOT part of this theme's token vocabulary
(only `--color-{role}-border` exists). Consume the SAME tokens the other inputs
use: the Field hovers to `--color-border-strong` and focuses to
`--focus-ring-color`. Per-color focus accents below use each role's `-solid`. */
--_textarea-border-hover: var(--color-border-strong);
--_textarea-border-focus: var(--focus-ring-color);
--_textarea-bg: var(--color-surface-default);
--_textarea-color: var(--color-content-primary);
--_textarea-placeholder: var(--color-content-muted);
--_textarea-count-color: var(--color-content-muted);
--_textarea-count-overflow: var(--color-threat-text);
/* The per-size coordinates (font-size, padding-inline, padding-block) are
EMITTED by the TSC from the recipe contract and resolve on this host; the
input inherits them. The old `[data-size]` blocks are gone — leaving them
would shadow the new tokens with the old values.
`--_textarea-border-focus` stays private because it carries the per-color
swap below: one variable, two sources (base token + palette forward). */
--_textarea-border-focus: var(--textarea-focus-input-border);
/* CSS `resize` for the input. Driven by the morfo's `data-resize` enum
(see overrides below); inherits down to `[data-textarea-input]`. */
(see overrides below); inherits down to `[data-textarea-input]`. NOT a
theme knob: it is behaviour the morfo conducts, not appearance. */
--_textarea-resize: vertical;
display: inline-flex;
flex-direction: column;
gap: var(--_textarea-gap);
gap: var(--textarea-gap);
inline-size: 100%;
min-inline-size: 0;
}
[data-textarea][data-size='xs'] {
--_textarea-padding-inline: var(--space-2);
--_textarea-padding-block: var(--space-1);
--_textarea-font-size: var(--font-size-xs);
}
[data-textarea][data-size='sm'] {
--_textarea-padding-inline: var(--space-2);
--_textarea-padding-block: var(--space-1-5);
--_textarea-font-size: var(--font-size-sm);
}
[data-textarea][data-size='lg'] {
--_textarea-padding-inline: var(--space-4);
--_textarea-padding-block: var(--space-3);
--_textarea-font-size: var(--font-size-lg);
}
[data-textarea][data-size='xl'] {
--_textarea-padding-inline: var(--space-5);
--_textarea-padding-block: var(--space-4);
--_textarea-font-size: var(--font-size-xl);
}
/* Color accent — applied on focus. The solid slot (the saturated accent,
step 9) comes from the shared palette forward (`--_textarea-palette-solid`):
any role / 33 scale / custom value routes through. `'primary'` (the default)
@ -79,16 +47,16 @@
inline-size: 100%;
min-inline-size: 0;
resize: var(--_textarea-resize);
padding-block: var(--_textarea-padding-block);
padding-inline: var(--_textarea-padding-inline);
border: var(--border-width) solid var(--_textarea-border);
border-radius: var(--_textarea-radius);
background: var(--_textarea-bg);
color: var(--_textarea-color);
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
font-size: var(--_textarea-font-size);
line-height: var(--_textarea-line-height);
padding-block: var(--textarea-input-padding-block);
padding-inline: var(--textarea-input-padding-inline);
border: var(--textarea-input-border-width) solid var(--textarea-input-border);
border-radius: var(--textarea-input-radius);
background: var(--textarea-input-bg);
color: var(--textarea-input-fg);
font-family: var(--textarea-input-font-family);
font-weight: var(--textarea-input-font-weight);
font-size: var(--textarea-input-font-size);
line-height: var(--textarea-input-line-height);
outline: none;
transition:
border-color var(--duration-fast) var(--ease-default),
@ -96,7 +64,7 @@
}
[data-textarea-input]:hover:not([data-disabled]):not([data-readonly]) {
border-color: var(--_textarea-border-hover);
border-color: var(--textarea-hover-input-border);
}
[data-textarea][data-focused] [data-textarea-input] {
@ -108,7 +76,7 @@
}
[data-textarea-input][data-invalid] {
border-color: var(--color-threat-solid);
border-color: var(--textarea-invalid-input-border);
}
[data-textarea][data-variant='outline'] [data-textarea-input] {
@ -129,25 +97,25 @@
}
[data-textarea-input]::placeholder {
color: var(--_textarea-placeholder);
color: var(--textarea-input-placeholder-fg);
}
/* Count chip — eidos default render: `count / max`. */
[data-textarea-count] {
display: inline-flex;
align-items: baseline;
gap: 0.25em;
gap: var(--textarea-count-gap);
align-self: flex-end;
color: var(--_textarea-count-color);
font-family: var(--font-family-mono, ui-monospace, monospace);
font-size: calc(var(--_textarea-font-size) * 0.85); /* literal: count caption proportional to the field size token */
color: var(--textarea-count-fg);
font-family: var(--textarea-count-font-family);
font-size: var(--textarea-count-font-size);
font-variant-numeric: tabular-nums;
line-height: 1;
}
[data-textarea-count][data-overflow] {
color: var(--_textarea-count-overflow);
font-weight: var(--font-weight-semibold);
color: var(--textarea-overflow-count-fg);
font-weight: var(--textarea-overflow-count-font-weight);
}
[data-textarea-count-sep] {

@ -2094,6 +2094,39 @@
--proof-of-human-token-size: 1.25rem;
--proof-of-human-token-radius: var(--radius-full);
--proof-of-human-token-focus-offset: 3px;
--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);
--textarea-input-font-size-lg: var(--size-lg-font-size);
--textarea-input-font-size-xl: var(--size-xl-font-size);
--textarea-input-padding-inline-xs: var(--space-2);
--textarea-input-padding-inline-sm: var(--space-2);
--textarea-input-padding-inline-md: var(--space-3);
--textarea-input-padding-inline-lg: var(--space-4);
--textarea-input-padding-inline-xl: var(--space-5);
--textarea-input-padding-block-xs: var(--space-1);
--textarea-input-padding-block-sm: var(--space-1-5);
--textarea-input-padding-block-md: var(--space-2);
--textarea-input-padding-block-lg: var(--space-3);
--textarea-input-padding-block-xl: var(--space-4);
--textarea-gap: var(--space-2);
--textarea-input-radius: var(--radius-default);
--textarea-input-border-width: var(--border-width);
--textarea-input-border: var(--color-neutral-border);
--textarea-hover-input-border: var(--color-border-strong);
--textarea-focus-input-border: var(--focus-ring-color);
--textarea-invalid-input-border: var(--color-threat-solid);
--textarea-input-bg: var(--color-surface-default);
--textarea-input-fg: var(--color-content-primary);
--textarea-input-placeholder-fg: var(--color-content-muted);
--textarea-input-font-family: var(--font-family-primary);
--textarea-input-font-weight: var(--font-weight-medium);
--textarea-input-line-height: var(--leading-normal);
--textarea-count-gap: 0.25em;
--textarea-count-fg: var(--color-content-muted);
--textarea-count-font-family: var(--font-family-mono, ui-monospace, monospace);
--textarea-overflow-count-fg: var(--color-threat-text);
--textarea-overflow-count-font-weight: var(--font-weight-semibold);
--calendar-gap: var(--space-3);
--calendar-padding-xs: var(--space-2);
--calendar-padding-sm: var(--space-3);
@ -5839,9 +5872,37 @@
}
[data-textarea] {
--textarea-input-font-size: var(--textarea-input-font-size-md);
--textarea-input-padding-inline: var(--textarea-input-padding-inline-md);
--textarea-input-padding-block: var(--textarea-input-padding-block-md);
--textarea-count-font-size: calc(var(--textarea-input-font-size) * 0.85);
--_textarea-palette-solid: var(--color-primary-solid);
}
[data-textarea][data-size='xs'] {
--textarea-input-font-size: var(--textarea-input-font-size-xs);
--textarea-input-padding-inline: var(--textarea-input-padding-inline-xs);
--textarea-input-padding-block: var(--textarea-input-padding-block-xs);
}
[data-textarea][data-size='sm'] {
--textarea-input-font-size: var(--textarea-input-font-size-sm);
--textarea-input-padding-inline: var(--textarea-input-padding-inline-sm);
--textarea-input-padding-block: var(--textarea-input-padding-block-sm);
}
[data-textarea][data-size='lg'] {
--textarea-input-font-size: var(--textarea-input-font-size-lg);
--textarea-input-padding-inline: var(--textarea-input-padding-inline-lg);
--textarea-input-padding-block: var(--textarea-input-padding-block-lg);
}
[data-textarea][data-size='xl'] {
--textarea-input-font-size: var(--textarea-input-font-size-xl);
--textarea-input-padding-inline: var(--textarea-input-padding-inline-xl);
--textarea-input-padding-block: var(--textarea-input-padding-block-xl);
}
[data-textarea][data-color], [data-textarea][data-color-custom] {
--_textarea-palette-solid: var(--palette-solid, var(--color-primary-solid));
}

@ -3014,6 +3014,106 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
// `'primary'` default stamps no `data-color` and keeps `--focus-ring-color`,
// so the host value here is the forward fallback only.
textarea: {
// ── Per-size coordinates (xs..xl) ──
// Type is 1:1 with the bundle in all five (measured: `--font-size-{k}` IS
// `--size-{k}-font-size`), so it reads the bundle coordinate.
'input-font-size-xs': 'var(--size-xs-font-size)',
'input-font-size-sm': 'var(--size-sm-font-size)',
'input-font-size-md': 'var(--size-md-font-size)',
'input-font-size-lg': 'var(--size-lg-font-size)',
'input-font-size-xl': 'var(--size-xl-font-size)',
// Spacing keeps the field's OWN rhythm, verbatim. Measured against the
// bundle 2026-08-21: padding-inline matches at sm/md/lg/xl but NOT at xs
// (8px here, 6px there), and padding-block matches at xs only — from sm up
// this field is looser (6/8/12/16 vs 4/6/8/…). A multi-line field breathes
// more than a one-line control, and that is the intended default. Spacing
// sits outside the bundle guard, which governs control-height / font-size /
// icon-size only (same call as table's cell padding).
'input-padding-inline-xs': 'var(--space-2)',
'input-padding-inline-sm': 'var(--space-2)',
'input-padding-inline-md': 'var(--space-3)',
'input-padding-inline-lg': 'var(--space-4)',
'input-padding-inline-xl': 'var(--space-5)',
'input-padding-block-xs': 'var(--space-1)',
'input-padding-block-sm': 'var(--space-1-5)',
'input-padding-block-md': 'var(--space-2)',
'input-padding-block-lg': 'var(--space-3)',
'input-padding-block-xl': 'var(--space-4)',
// The RESOLVED names. `data-size` is stamped on `[data-textarea]` and the
// input inherits, so the cascade hangs from the host — no `parts` needed
// (precedent: command).
'input-font-size': {
declarations: [
{ value: 'var(--textarea-input-font-size-md)', scope: 'host' },
{ value: 'var(--textarea-input-font-size-xs)', scope: 'size:xs' },
{ value: 'var(--textarea-input-font-size-sm)', scope: 'size:sm' },
{ value: 'var(--textarea-input-font-size-lg)', scope: 'size:lg' },
{ value: 'var(--textarea-input-font-size-xl)', scope: 'size:xl' }
]
},
'input-padding-inline': {
declarations: [
{ value: 'var(--textarea-input-padding-inline-md)', scope: 'host' },
{ value: 'var(--textarea-input-padding-inline-xs)', scope: 'size:xs' },
{ value: 'var(--textarea-input-padding-inline-sm)', scope: 'size:sm' },
{ value: 'var(--textarea-input-padding-inline-lg)', scope: 'size:lg' },
{ value: 'var(--textarea-input-padding-inline-xl)', scope: 'size:xl' }
]
},
'input-padding-block': {
declarations: [
{ value: 'var(--textarea-input-padding-block-md)', scope: 'host' },
{ value: 'var(--textarea-input-padding-block-xs)', scope: 'size:xs' },
{ value: 'var(--textarea-input-padding-block-sm)', scope: 'size:sm' },
{ value: 'var(--textarea-input-padding-block-lg)', scope: 'size:lg' },
{ value: 'var(--textarea-input-padding-block-xl)', scope: 'size:xl' }
]
},
// ── Chassis ──
gap: 'var(--space-2)',
// ── Input ──
// Radius is DECOUPLED from size on purpose (Radix model): the control
// archetype default, constant across the five.
'input-radius': 'var(--radius-default)',
'input-border-width': 'var(--border-width)',
'input-border': 'var(--color-neutral-border)',
'hover-input-border': 'var(--color-border-strong)',
// The FOCUS border, not the resting one. Its per-color swap
// (`--_textarea-palette-solid`) is the THM-2 palette forward and stays
// private — it is one variable with two sources, not two knobs. The ghost
// variant reads this same token: a `ghost-input-border` would be one value
// under two names.
'focus-input-border': 'var(--focus-ring-color)',
'invalid-input-border': 'var(--color-threat-solid)',
'input-bg': 'var(--color-surface-default)',
'input-fg': 'var(--color-content-primary)',
'input-placeholder-fg': 'var(--color-content-muted)',
'input-font-family': 'var(--font-family-primary)',
'input-font-weight': 'var(--font-weight-medium)',
'input-line-height': 'var(--leading-normal)',
// ── Count chip ──
// ONE token, not five per size: the caption is PROPORTIONAL to the field
// (0.85×) and the input's own font-size already resolves per size, so the
// scale is written once. Five per-size tokens would have dropped the 0.85
// and grown the counter 18 % — the default does not move (D-TH.5).
// `scope: 'host'` is REQUIRED, not decoration: it reads the resolved
// `input-font-size`, which only exists on `[data-textarea]`. Emitted at
// :root the var() would resolve where its dependency is missing — the
// recipe scope contract catches it.
'count-font-size': {
value: 'calc(var(--textarea-input-font-size) * 0.85)',
scope: 'host'
},
'count-gap': '0.25em',
'count-fg': 'var(--color-content-muted)',
'count-font-family': 'var(--font-family-mono, ui-monospace, monospace)',
'overflow-count-fg': 'var(--color-threat-text)',
'overflow-count-font-weight': 'var(--font-weight-semibold)',
'_palette-solid': { value: 'var(--color-primary-solid)', scope: 'host' }
},
calendar: {

@ -8,8 +8,9 @@
import type { TextAreaResize, TextAreaSubmitMode } from '$soma/components/textarea';
import { compileMorfo } from '$uix/morfo';
import { textareaMorfo } from '@/uix/morfo/components/textarea';
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 };
let tab = $state<Tab>('live');
@ -228,6 +229,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>37</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -494,6 +498,8 @@
</div>
<pre data-uix-snippet><code>{eidosSnippet}</code></pre>
</section>
{:else if tab === 'tokens'}
<TokensPanel component="textarea" stage={stageRef ?? undefined} />
{:else if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>

Loading…
Cancel
Save

Powered by TurnKey Linux.