uix(file-upload): temable — 76 % → 83 %, y una clave suya NO PINTABA en ninguno de sus tres botones

Una clave acuñada, UNA RETIRADA con su declaración muerta, cuatro literales
firmados y cuarenta y cuatro adjudicaciones medidas. El default no se mueve:
diff de computed 0 sobre 11.448 valores en 17 estados y capturas 2x idénticas
byte a byte.

Censo: 76 % → 83 % (público 45, global 1 → 0, literal 4 → 0, excepción 0 → 4;
knobs 61 → 56). Contrato 98 claves (una entra, otra sale). Global no baja.

QUÉ ENTRA (1 clave)

  invalid-dropzone-border (var(--color-risk-border)). Era el único knob de
  apariencia que iba a un primitivo global a pelo.

  Lleva la PARTE en el nombre y no el `invalid-border` desnudo que el catálogo
  repite seis veces (checkbox, listbox, grid-list, radio-cards…): aquí hay TRES
  partes con borde —dropzone, botón e ítem— y el nombre desnudo no diría cuál
  tiñe. La forma modificador + parte + ranura ya está en el catálogo
  (invalid-input-border, invalid-control-border).

  Su estado no lo alcanza esta ruta: isInvalid sale de un Field envolvente y la
  demo no monta ninguno. Forzado: oklch(0.8059 0.1123 59.96) -> rgb(1,2,3).

QUÉ SALE (1 clave, y su declaración)

  button-bg NO PINTABA. La regla compartida de los tres botones declaraba
  `background: var(--file-upload-button-bg)` y los tres la pisan después: el
  trigger con el sólido de la paleta, ItemRemove y ClearTrigger con transparent.
  Medido sobre los tres nodos, uno a uno: el centinela no mueve NADA en ninguno.

  Con UNA clave la respuesta de F2-A es retirar, no adjudicar — y retirarla da
  diff 0, que es la prueba de que estaba muerta. La clave sale con ella: un
  público sin consumidor es un huérfano.

CUATRO LITERALES FIRMADOS (fuera del ratio)

  Los tres 100% de identidad —el campo ocupa el ancho que le dan, la miniatura
  llena la caja en la que se recorta, la barra ocupa la fila del grid— más el
  block-size de la miniatura. Cada uno con su anotación en la declaración
  (válvula de recipe-contract §3).

CUARENTA CLAVES DE TONO NO PINTAN

  8 tonos x 5 ranuras (solid, solid-hover, track, border, text), 40 de sus 98.
  Es la cascada de paleta: el forward emite AL FINAL un bloque genérico
  [data-file-upload][data-color] que resuelve desde el --palette-* global, misma
  especificidad (0,2,0), gana el último. Medido para los ocho tonos sobre las
  dos superficies que las consumen —el trigger (solid) y el dropzone en hover
  (track)—: ninguna se mueve, mientras --palette-solid sobre el nodo repinta.
  Adjudicadas POR PATRÓN; incidencia de fondo en next-features §13.

FUERA POR DOCTRINA (los 9 privados)

  Ocho son el puente de paleta THM-2 y el noveno es EL CANAL DE VALOR
  (--_file-upload-progress-value, que el envoltorio escribe inline por fila).
  Lo que el componente escribe inline no es tema, y un público sobre el puente
  dejaría que un tema fijara el tono y matara el color= de cada instancia. Su
  techo honesto NO es el 100 %.

VERIFICACIÓN

  Sonda estándar antes/después 0 diffs (4.608 valores · 8 estados · 19 nodos: la
  demo arranca con dos ficheros sembrados, así que monta la superficie entera) +
  barrido de los estados que la ruta no arranca (3 variantes x 3 tallas, invalid,
  disabled, dragging) 0 diffs sobre 6.840 valores · capturas 2x idénticas byte a
  byte · R-5.4 54/98, exit 0, sin STALE · censo --only 83 % · component:audit
  PASS · eidos-lint 29 morfo-backed / 0 invalid · vitest src/uix/eidos 35/36 (el
  rojo conocido es skin-media-player) · rtl:check 0 · docs:check 0 · prettier
  limpio · npm run check sin errores en ficheros del componente · pestaña Tokens
  verificada en el navegador (98 filas, 0 errores de consola).

  ⚠ Truco de medición nuevo: el «antes» del barrido de estados se reconstruye
  INYECTANDO el CSS de HEAD en la página (gana por orden a igual especificidad),
  sin tocar un árbol que otras tres sesiones están usando. Con muta-prueba: 19
  diffs al alterar el control, 1 si sólo se altera el borde inválido.

Índice construido a mano desde HEAD sobre los tres ficheros compartidos
(recipes/base.ts, generated/base.css, theming-sentinel-exceptions.ts). Fuera del
commit, como en el de `form`, docs/audit/theming/README.md — es un agregado
global que mezcla trabajo en vuelo de otras sesiones.

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

@ -5,18 +5,16 @@
> Vista de conjunto: [README](./README.md) · método y protocolo:
> [`PLAN-theming.md`](../../process/PLAN-theming.md) §1, §2, §7.
- **Medido**: 2026-08-23 · **Alcance**: **76%** — 45 de 59 knobs por token público
- **Knobs de apariencia**: 61 — público 45 · privado 9 · global 1 · literal 4 · sistema 2 · excepción 0 _(los dos últimos, fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 98 pública(s) — `gap-sm`, `gap-md`, `gap-lg`, `padding-sm`, `padding-md`, `padding-lg`, `control-height-sm`, `control-height-md`, `control-height-lg`, `font-size-sm`, `font-size-md`, `font-size-lg`, `preview-size-sm`, `preview-size-md`, `preview-size-lg`, `font-family`, `line-height`, `fg`, `label-fg`, `label-font-weight`, `dropzone-gap`, `dropzone-min-height`, `dropzone-border-width`, `dropzone-border`, `dropzone-radius`, `dropzone-bg`, `dropzone-fg`, `button-gap`, `button-padding-inline`, `button-border-width`, `button-border`, `hover-button-border`, `button-radius`, `button-bg`, `button-fg`, `hover-button-fg`, `button-solid-fg`, `list-gap`, `item-gap`, `item-padding`, `item-border-width`, `item-border`, `item-radius`, `item-bg`, `item-shadow`, `preview-radius`, `preview-bg`, `preview-fg`, `item-name-fg`, `item-name-font-weight`, `item-size-fg`, `item-size-font-size`, `progress-height`, `progress-radius`, `progress-track-bg`, `transition-duration`, `transition-ease`, `disabled-opacity`, `primary-solid`, `primary-solid-hover`, `primary-text`, `secondary-solid`, `secondary-solid-hover`, `secondary-text`, `neutral-solid`, `neutral-solid-hover`, `neutral-text`, `affirm-solid`, `affirm-solid-hover`, `affirm-text`, `fulfill-solid`, `fulfill-solid-hover`, `fulfill-track`, `fulfill-border`, `fulfill-text`, `risk-solid`, `risk-solid-hover`, `risk-text`, `threat-solid`, `threat-solid-hover`, `threat-text`, `loss-solid`, `loss-solid-hover`, `loss-track`, `loss-border`, `loss-text`, `primary-track`, `primary-border`, `secondary-track`, `secondary-border`, `neutral-track`, `neutral-border`, `affirm-track`, `affirm-border`, `risk-track`, `risk-border`, `threat-track`, `threat-border` · 5 privada(s) forward — `_palette-solid`, `_palette-solid-hover`, `_palette-track`, `_palette-border`, `_palette-text`
- **Medido**: 2026-08-23 · **Alcance**: **83%** — 45 de 54 knobs por token público
- **Knobs de apariencia**: 56 — público 45 · privado 9 · global 0 · literal 0 · sistema 2 · excepción 4 _(los dos últimos, fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 98 pública(s) — `gap-sm`, `gap-md`, `gap-lg`, `padding-sm`, `padding-md`, `padding-lg`, `control-height-sm`, `control-height-md`, `control-height-lg`, `font-size-sm`, `font-size-md`, `font-size-lg`, `preview-size-sm`, `preview-size-md`, `preview-size-lg`, `font-family`, `line-height`, `fg`, `label-fg`, `label-font-weight`, `dropzone-gap`, `dropzone-min-height`, `dropzone-border-width`, `dropzone-border`, `dropzone-radius`, `dropzone-bg`, `dropzone-fg`, `button-gap`, `button-padding-inline`, `button-border-width`, `button-border`, `hover-button-border`, `button-radius`, `button-fg`, `hover-button-fg`, `button-solid-fg`, `list-gap`, `item-gap`, `item-padding`, `item-border-width`, `item-border`, `item-radius`, `item-bg`, `item-shadow`, `preview-radius`, `preview-bg`, `preview-fg`, `item-name-fg`, `item-name-font-weight`, `item-size-fg`, `item-size-font-size`, `progress-height`, `progress-radius`, `progress-track-bg`, `transition-duration`, `transition-ease`, `disabled-opacity`, `invalid-dropzone-border`, `primary-solid`, `primary-solid-hover`, `primary-text`, `secondary-solid`, `secondary-solid-hover`, `secondary-text`, `neutral-solid`, `neutral-solid-hover`, `neutral-text`, `affirm-solid`, `affirm-solid-hover`, `affirm-text`, `fulfill-solid`, `fulfill-solid-hover`, `fulfill-track`, `fulfill-border`, `fulfill-text`, `risk-solid`, `risk-solid-hover`, `risk-text`, `threat-solid`, `threat-solid-hover`, `threat-text`, `loss-solid`, `loss-solid-hover`, `loss-track`, `loss-border`, `loss-text`, `primary-track`, `primary-border`, `secondary-track`, `secondary-border`, `neutral-track`, `neutral-border`, `affirm-track`, `affirm-border`, `risk-track`, `risk-border`, `threat-track`, `threat-border` · 5 privada(s) forward — `_palette-solid`, `_palette-solid-hover`, `_palette-track`, `_palette-border`, `_palette-text`
- **Eje `size`**: sí · **ficheros**: `file-upload.css`
## 1. Knobs fuera de alcance
### 1.1 Directo a primitivo global (1)
### 1.1 Directo a primitivo global (0)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `file-upload.css:208` | `[data-file-upload][data-invalid] [data-file-upload-dropzone]` | `border-color` | `var(--color-risk-border)` |
_Ninguno._
### 1.2 A través de un privado (9)
@ -25,30 +23,30 @@
| 1 | `file-upload.css:69` | `[data-file-upload-dropzone]:hover:not([data-disabled]), [data-file-upload-dropzone][data-dragging]` | `border-color` | `var(--_file-upload-palette-border)` |
| 2 | `file-upload.css:70` | `[data-file-upload-dropzone]:hover:not([data-disabled]), [data-file-upload-dropzone][data-dragging]` | `background` | `var(--_file-upload-palette-track)` |
| 3 | `file-upload.css:71` | `[data-file-upload-dropzone]:hover:not([data-disabled]), [data-file-upload-dropzone][data-dragging]` | `color` | `var(--_file-upload-palette-text)` |
| 4 | `file-upload.css:108` | `[data-file-upload-trigger]` | `border-color` | `var(--_file-upload-palette-solid)` |
| 5 | `file-upload.css:109` | `[data-file-upload-trigger]` | `background` | `var(--_file-upload-palette-solid)` |
| 6 | `file-upload.css:114` | `[data-file-upload-trigger]:hover:not([data-disabled])` | `border-color` | `var(--_file-upload-palette-solid-hover)` |
| 7 | `file-upload.css:115` | `[data-file-upload-trigger]:hover:not([data-disabled])` | `background` | `var(--_file-upload-palette-solid-hover)` |
| 8 | `file-upload.css:195` | `[data-file-upload-item-progress]::before` | `inline-size` | `calc(var(--_file-upload-progress-value, 0) * 1%)` |
| 9 | `file-upload.css:197` | `[data-file-upload-item-progress]::before` | `background` | `var(--_file-upload-palette-solid)` |
| 4 | `file-upload.css:107` | `[data-file-upload-trigger]` | `border-color` | `var(--_file-upload-palette-solid)` |
| 5 | `file-upload.css:108` | `[data-file-upload-trigger]` | `background` | `var(--_file-upload-palette-solid)` |
| 6 | `file-upload.css:113` | `[data-file-upload-trigger]:hover:not([data-disabled])` | `border-color` | `var(--_file-upload-palette-solid-hover)` |
| 7 | `file-upload.css:114` | `[data-file-upload-trigger]:hover:not([data-disabled])` | `background` | `var(--_file-upload-palette-solid-hover)` |
| 8 | `file-upload.css:194` | `[data-file-upload-item-progress]::before` | `inline-size` | `calc(var(--_file-upload-progress-value, 0) * 1%)` |
| 9 | `file-upload.css:196` | `[data-file-upload-item-progress]::before` | `background` | `var(--_file-upload-palette-solid)` |
### 1.3 Literales (4)
### 1.3 Literales (0)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `file-upload.css:10` | `[data-file-upload]` | `inline-size` | `100%` |
| 2 | `file-upload.css:162` | `[data-file-upload-item-preview] :where(img, video)` | `inline-size` | `100%` |
| 3 | `file-upload.css:163` | `[data-file-upload-item-preview] :where(img, video)` | `block-size` | `100%` |
| 4 | `file-upload.css:184` | `[data-file-upload-item-progress]` | `inline-size` | `100%` |
_Ninguno._
### 1.4 Excepciones firmadas (0) — fuera del ratio
### 1.4 Excepciones firmadas (4) — fuera del ratio
Literales que llevan su anotación `/* literal: <razón> */` en la propia
declaración: la válvula de recipe-contract §3, la misma que honra
`component-audit`. **Una desviación firmada no es deuda** — se listan para que la
razón se lea, no para acuñarlas.
_Ninguno._
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `file-upload.css:10` | `[data-file-upload]` | `inline-size` | `100%` |
| 2 | `file-upload.css:161` | `[data-file-upload-item-preview] :where(img, video)` | `inline-size` | `100%` |
| 3 | `file-upload.css:162` | `[data-file-upload-item-preview] :where(img, video)` | `block-size` | `100%` |
| 4 | `file-upload.css:183` | `[data-file-upload-item-progress]` | `inline-size` | `100%` |
## 2. Sistema transversal (2) — informativo, fuera del ratio
Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
@ -56,7 +54,7 @@ Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `file-upload.css:78` | `[data-file-upload-dropzone]:focus-visible, [data-file-upload-trigger]:focus-visible, [data-file-upload-item-remove]:focus-visible, [data-file-upload-clear-trigger]:focus-visible` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
| 2 | `file-upload.css:126` | `[data-file-upload-item-remove]:hover:not([data-disabled]), [data-file-upload-clear-trigger]:hover:not([data-disabled])` | `background-image` | `linear-gradient(var(--state-hover), var(--state-hover))` |
| 2 | `file-upload.css:125` | `[data-file-upload-item-remove]:hover:not([data-disabled]), [data-file-upload-clear-trigger]:hover:not([data-disabled])` | `background-image` | `linear-gradient(var(--state-hover), var(--state-hover))` |
## 3. Privados de la receta — ¿de dónde sale su valor?
@ -74,7 +72,7 @@ Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inl
- **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` (1)
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (0)
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
@ -84,7 +82,6 @@ no distingue lo que debería — se marca `⚠`.
| token (`--file-upload-…`) | scope TSC | valor propuesto | usos |
| --- | --- | --- | ---: |
| `invalid-dropzone-border` | `root` | `var(--color-risk-border)` | 1 |
### 4.2 Sin nombre mecánico (13)
@ -103,6 +100,61 @@ no distingue lo que debería — se marca `⚠`.
<!-- veredicto:start -->
_(pendiente — lo escribe el autor; se conserva al regenerar)_
**EJECUTADO 2026-08-23 — 76 % → 83 %.** Una clave acuñada, **una retirada con su
declaración muerta**, cuatro literales firmados y cuarenta y cuatro
adjudicaciones medidas (4 propias + 40 por patrón). Diff de computed **0 sobre
11.448 valores en 17 estados** y capturas 2× **idénticas byte a byte**.
**`invalid-dropzone-border`** era el único knob que iba a un primitivo global a
pelo (`--color-risk-border`). Lleva la PARTE en el nombre —y no el
`invalid-border` que el catálogo repite seis veces— porque aquí hay TRES partes
con borde (dropzone, botón, ítem) y el nombre desnudo no diría cuál tiñe. Su
estado no lo alcanza esta ruta: `isInvalid` sale de un `Field` envolvente y la
demo no monta ninguno, así que se mide forzando el atributo
(`oklch(0.8059 0.1123 59.96)` → `rgb(1,2,3)`).
⚠ **`button-bg` NO PINTABA, y se retira con su declaración.** La regla
compartida de los tres botones declaraba `background: var(--file-upload-button-bg)`
y los tres la pisan después: el trigger con el sólido de la paleta (línea 108),
`ItemRemove` y `ClearTrigger` con `transparent` (línea 119). Medido sobre los
tres nodos, uno a uno: el sentinel no mueve NADA en ninguno
(`oklch(0.5556 …)` y `rgba(0,0,0,0)` intactos). Es la clase «un token que no
mueve nada es un token que miente» de F2-A, y con UNA clave la respuesta es
retirar, no adjudicar: retirarla da **diff 0**, que es la prueba de que estaba
muerta. La clave sale del contrato con ella (un público sin consumidor es un
huérfano).
**Cuatro literales firmados** en vez de acuñados, con su anotación en la propia
declaración: el `100%` del campo (ocupa el ancho que le dan), los dos de la
miniatura (`img`/`video` LLENAN la caja en la que se recortan) y el de la barra
de progreso (ocupa la fila que le da el grid). Identidad, no knob.
⚠⚠ **CUARENTA claves de tono no pintan** — 8 tonos × 5 ranuras
(`solid`, `solid-hover`, `track`, `border`, `text`), 40 de sus 98. Es la cascada
de paleta: el forward emite AL FINAL un bloque genérico
`[data-file-upload][data-color]` que resuelve desde el `--palette-*` global,
misma especificidad (0,2,0), gana el último. Medido para los ocho tonos sobre
las dos superficies que las consumen —el trigger (`solid`) y el dropzone en
hover (`track`)—: ninguna se mueve, mientras `--palette-solid` sobre el nodo
repinta. Su `host` es `primary` y la demo pasa `color` explícito, así que el
fallback tampoco actúa. Adjudicadas por PATRÓN; incidencia de fondo en
`next-features.md` §13.
**Los nueve privados se quedan, y ninguno es deuda de valor**: ocho son el
puente de paleta THM-2 (`--_file-upload-palette-*` en el hover/arrastre del
dropzone, en el trigger y en el relleno de la barra) —un público encima dejaría
que un tema fijara el tono y matara el `color=` de cada instancia— y el noveno
es **el canal de valor**, `--_file-upload-progress-value`, que
`file-upload-item-progress.svelte` escribe INLINE por fila. Lo que el componente
escribe inline no es tema. **El techo honesto de este componente no es el
100 %.**
**Medición**: sonda estándar 0 diffs (4.608 valores · 8 estados · 19 nodos, la
demo arranca con dos ficheros sembrados, así que monta la superficie entera) más
un barrido de estados que la ruta no arranca —3 variantes × 3 tallas, `invalid`,
`disabled` y `dragging`— con el CSS de HEAD **inyectado en la página** como
control (gana por orden a igual especificidad): 0 diffs sobre 6.840 valores. El
instrumento pasó su muta-prueba: alterando el CSS de control acusa 19 diffs, y 1
si sólo se altera el borde inválido.
<!-- veredicto:end -->

@ -537,6 +537,26 @@ commits de corrección.
animaciones (ningún commit toca `animation`); contrato sin duplicados ni
huérfanos.
- 2026-08-23 — **`file-upload` 76 % → 83 %** (COMMIT), 1 clave acuñada y **1
RETIRADA**. `invalid-dropzone-border` (con la PARTE en el nombre: tres partes
con borde) era el único knob a un primitivo a pelo. **`button-bg` no pintaba**:
la regla compartida de los tres botones la declaraba y los tres la pisan
después (el trigger con el sólido de la paleta, `ItemRemove` y `ClearTrigger`
con `transparent`) — medido sobre los tres nodos, cero movimiento, así que se
retira la declaración Y la clave, y el diff 0 lo prueba. **Cuatro literales de
identidad firmados** (el ancho del campo, los dos de la miniatura, el de la
barra). **CUARENTA claves de tono** (8 × 5 ranuras) adjudicadas por patrón: la
cascada de paleta, medida sobre el trigger y el dropzone en hover para los
ocho tonos. Los 9 privados son 8 puentes THM-2 + **el canal de valor**
(`--_file-upload-progress-value`, inline por fila), así que **su techo honesto
no es el 100 %**. R-5.4 54/98, exit 0. Diff 0 sobre **11.448 valores en 17
estados** y capturas 2× idénticas byte a byte. ⚠ **Truco de medición nuevo**:
el barrido de estados que la demo no arranca (variante × talla × invalid ×
disabled × dragging) reconstruye el «antes» **inyectando el CSS de HEAD en la
página** —gana por orden a igual especificidad—, sin tocar el árbol que otras
tres sesiones están usando; con su muta-prueba (19 diffs al alterar el control,
1 al alterar sólo el borde inválido).
- 2026-08-23 — **`switch` 76 % → 81 %** (COMMIT), 1 clave. `invalid-track-border`
era el ÚNICO knob que iba a un primitivo a pelo (`--color-threat-element`).
**El nombre NO es el `invalid-border` del catálogo** (6 recetas lo usan):

@ -1006,6 +1006,20 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'rim-glow-blur': 'same look, same route -> reaches (blur(4px) -> blur(13px))',
'rim-glow-opacity': 'same look, and the rim is under an ANIMATION: with only the transitions frozen the read returns the animated value (0.482956) and the token looks dead. With animation frozen too it computes 0.45 and follows -> reaches (0.45 -> 0.137)'
},
'file-upload': {
// Measured one by one 2026-08-23 (`__fu-adj`). The demo mounts the WHOLE
// surface (two seeded files, so list, preview, name, size, progress, remove
// and clear all render): what is left is state the route cannot reach and
// the freeze the guard does on purpose.
'transition-duration':
'the guard freezes transitions and this token IS the transition; measured unfrozen on the dropzone -> reaches (0.12s -> 11.5s on its three properties)',
'transition-ease':
'same instrument limit; measured unfrozen -> reaches (cubic-bezier(0.4, 0, 0.2, 1) -> steps(7))',
'disabled-opacity':
'only under [data-disabled]; forced on the provider -> reaches (0.4 -> 0.123)',
'invalid-dropzone-border':
'only under [data-invalid], and this component takes invalid from an enclosing Field, which the demo route does not mount; forced -> reaches (oklch(0.8059 0.1123 59.96) -> rgb(1,2,3))'
},
switch: {
// Measured one by one 2026-08-23 (`__sw-adj`). The demo stage boots
// UNCHECKED, VALID and ENABLED and the switch has no trigger to open, so
@ -1097,6 +1111,14 @@ export const SENTINEL_PATTERN_EXCEPTIONS: Array<{
pattern: /^(primary|secondary|neutral|affirm|fulfill|risk|threat|loss)-(track|solid|contrast)$/,
reason: PALETTE_SUPERSEDED
},
{
component: 'file-upload',
pattern:
/^(primary|secondary|neutral|affirm|fulfill|risk|threat|loss)-(solid|solid-hover|track|border|text)$/,
reason:
PALETTE_SUPERSEDED +
' — the eight tones x five slots, 40 of its 97 keys. Measured 2026-08-23 on the trigger (solid) and on the hovered dropzone (track) for the eight tones: not one moves, while --palette-solid on the node repaints (oklch(0.6908 …) -> rgb(4,5,6)). Its host default is `primary`, and the demo passes color explicitly, so the fallback never applies either'
},
{
component: 'switch',
pattern: /^(primary|secondary|neutral|affirm|risk|threat)-(solid|solid-hover)$/,

@ -113,6 +113,44 @@ References:
</FileUpload>
```
## Talla y tema
**98 claves públicas** en `lib/recipes/base.ts` + cinco forwards de paleta THM-2
(`_palette-{solid,solid-hover,track,border,text}`). El eje `size` (sm/md/lg)
lleva cinco coordenadas por paso —`gap`, `padding`, `control-height`,
`font-size` y `preview-size`—, que la receta resuelve por privado. La pestaña
**Tokens** de su demo las resuelve en vivo.
Lo que cambió el **2026-08-23** (76 % → 83 %):
- **`invalid-dropzone-border`** (nueva): era el único knob que iba a un
primitivo global a pelo (`--color-risk-border`). Lleva la PARTE en el nombre
porque aquí hay tres partes con borde —dropzone, botón e ítem—, así que el
`invalid-border` desnudo del catálogo no diría cuál tiñe.
- **`button-bg` RETIRADA, con su declaración**: la regla compartida de los tres
botones pintaba `background: var(--file-upload-button-bg)` y los tres la
pisan después — el trigger con el sólido de la paleta, `ItemRemove` y
`ClearTrigger` con `transparent`. Medida sobre los tres nodos: no movía nada
en ninguno. Retirarla da diff 0, que es la prueba de que estaba muerta.
- **Cuatro literales FIRMADOS** (`/* literal: … */`, la válvula de
recipe-contract §3): los tres `100%` de identidad —el campo ocupa el ancho que
le dan, la miniatura llena su caja, la barra ocupa su fila— y el `block-size`
de la miniatura.
⚠ **Las CUARENTA claves de tono no pintan** (8 tonos × `solid`, `solid-hover`,
`track`, `border`, `text`). El forward de paleta emite AL FINAL un bloque
genérico `[data-file-upload][data-color]` que resuelve desde el `--palette-*`
global: misma especificidad (0,2,0), gana el último. Medido sobre el trigger y
sobre el dropzone en hover para los ocho tonos. Es la incidencia de 419 claves
en 49 recetas de `next-features.md` §13 — no se arregla aquí.
**Lo que queda fuera por doctrina**: las nueve lecturas de privado son ocho
puentes THM-2 (`--_file-upload-palette-*` en el hover/arrastre del dropzone, en
el trigger y en el relleno de la barra) y **el canal de valor**
`--_file-upload-progress-value`, que el envoltorio escribe INLINE por fila. Ni
uno ni otro se acuñan: un público sobre el puente dejaría que un tema fijara el
tono y matara el `color=` de cada instancia, y el canal de valor no es tema.
## Comparativa
| Capacidad | Bits UI | Ark UI | React Aria | shadcn-svelte | UIX |

@ -7,7 +7,7 @@
display: grid;
gap: var(--_file-upload-gap);
inline-size: 100%;
inline-size: 100%; /* literal: identity — the field IS its container's width */
color: var(--file-upload-fg);
font-family: var(--file-upload-font-family);
font-size: var(--_file-upload-font-size);
@ -91,7 +91,6 @@
padding-inline: var(--file-upload-button-padding-inline);
border: var(--file-upload-button-border-width) solid var(--file-upload-button-border);
border-radius: var(--file-upload-button-radius);
background: var(--file-upload-button-bg);
color: var(--file-upload-button-fg);
font: inherit;
text-decoration: none;
@ -159,8 +158,8 @@
}
[data-file-upload-item-preview] :where(img, video) {
inline-size: 100%;
block-size: 100%;
inline-size: 100%; /* literal: identity — the media FILLS the preview box it is cropped into */
block-size: 100%; /* literal: identity — same box, the other axis */
object-fit: cover;
}
@ -181,7 +180,7 @@
position: relative;
overflow: hidden;
grid-column: 1 / -1;
inline-size: 100%;
inline-size: 100%; /* literal: identity — the bar spans the row it is given */
block-size: var(--file-upload-progress-height);
border-radius: var(--file-upload-progress-radius);
background: var(--file-upload-progress-track-bg);
@ -205,7 +204,7 @@
}
[data-file-upload][data-invalid] [data-file-upload-dropzone] {
border-color: var(--color-risk-border);
border-color: var(--file-upload-invalid-dropzone-border);
}
@media (prefers-reduced-motion: reduce) {

@ -2938,7 +2938,6 @@
--file-upload-button-border: var(--color-border-subtle);
--file-upload-hover-button-border: var(--color-border-default);
--file-upload-button-radius: var(--radius-md);
--file-upload-button-bg: var(--color-surface-raised);
--file-upload-button-fg: var(--color-content-secondary);
--file-upload-hover-button-fg: var(--color-content-primary);
--file-upload-button-solid-fg: var(--color-content-on-solid);
@ -2963,6 +2962,7 @@
--file-upload-transition-duration: var(--duration-fast);
--file-upload-transition-ease: var(--ease-default);
--file-upload-disabled-opacity: var(--opacity-disabled);
--file-upload-invalid-dropzone-border: var(--color-risk-border);
--file-upload-primary-solid: var(--color-primary-solid);
--file-upload-primary-solid-hover: var(--color-primary-solid-hover);
--file-upload-primary-text: var(--color-primary-text);

@ -4470,7 +4470,6 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'button-border': 'var(--color-border-subtle)',
'hover-button-border': 'var(--color-border-default)',
'button-radius': 'var(--radius-md)',
'button-bg': 'var(--color-surface-raised)',
'button-fg': 'var(--color-content-secondary)',
'hover-button-fg': 'var(--color-content-primary)',
'button-solid-fg': 'var(--color-content-on-solid)',
@ -4495,6 +4494,10 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'disabled-opacity': 'var(--opacity-disabled)',
// The Field-aware invalid tint, on the DROPZONE: this recipe has three
// bordered parts (dropzone, button, item), so the catalogue's bare
// `invalid-border` would not say which one it tints.
'invalid-dropzone-border': 'var(--color-risk-border)',
'primary-solid': 'var(--color-primary-solid)',
'primary-solid-hover': 'var(--color-primary-solid-hover)',
'primary-text': 'var(--color-primary-text)',

@ -12,8 +12,9 @@
import { getActiveUix } from '$active-uix';
import { page } from '$app/state';
import { colorValue, PALETTE_SCALES } from '../../lib/harness.svelte';
import TokensPanel from '../../lib/TokensPanel.svelte';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'tokens' | 'a11y';
const uix = getActiveUix();
const params = page.url.searchParams;
@ -315,6 +316,9 @@
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'tokens'} onclick={() => (tab = 'tokens')}>
Tokens <span data-uix-tab-count>98</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -587,6 +591,10 @@
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="file-upload" stage={stageRef ?? undefined} />
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>

Loading…
Cancel
Save

Powered by TurnKey Linux.