|
|
# combobox — alcance de tema: análisis y propuesta
|
|
|
|
|
|
> Generado por `node --import tsx/esm scripts/theming-census.ts --report`.
|
|
|
> Lo **medido** y la **propuesta** se regeneran; el **Veredicto** (§5) se conserva.
|
|
|
> 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**: **85%** — 76 de 89 knobs por token público
|
|
|
- **Knobs de apariencia**: 97 — público 76 · privado 4 · global 3 · literal 6 · sistema 8 · excepción 0 _(los dos últimos, fuera del ratio)_
|
|
|
- **Contrato hoy** (`lib/recipes/base.ts`): 106 pública(s) — `content-z`, `control-height-xs`, `control-height-sm`, `control-height-md`, `control-height-lg`, `control-height-xl`, `control-font-size-xs`, `control-font-size-sm`, `control-font-size-md`, `control-font-size-lg`, `control-font-size-xl`, `control-gap-xs`, `control-gap-sm`, `control-gap-md`, `control-gap-lg`, `control-gap-xl`, `control-padding-inline-xs`, `control-padding-inline-sm`, `control-padding-inline-md`, `control-padding-inline-lg`, `control-padding-inline-xl`, `content-padding-block-xs`, `content-padding-block-sm`, `content-padding-block-md`, `content-padding-block-lg`, `content-padding-block-xl`, `content-padding-inline-xs`, `content-padding-inline-sm`, `content-padding-inline-md`, `content-padding-inline-lg`, `content-padding-inline-xl`, `content-font-size-xs`, `content-font-size-sm`, `content-font-size-md`, `content-font-size-lg`, `content-font-size-xl`, `control-height`, `control-font-size`, `control-gap`, `control-padding-inline`, `content-padding-block`, `content-padding-inline`, `content-font-size`, `gap`, `font-family`, `fg`, `border-width`, `control-radius`, `control-bg`, `control-border`, `control-shadow`, `control-fg`, `control-line-height`, `disabled-control-bg`, `disabled-control-border`, `disabled-control-fg`, `placeholder-fg`, `disabled-input-fg`, `trigger-size`, `trigger-radius`, `trigger-fg`, `indicator-size`, `content-min-width`, `content-max-width`, `content-max-height`, `content-radius`, `content-fg`, `content-font-family`, `content-line-height`, `viewport-gap`, `viewport-padding-block-end`, `scrollbar-inset`, `group-gap`, `group-padding-block`, `group-heading-padding-block`, `group-heading-padding-inline`, `group-heading-bg`, `group-heading-fg`, `group-heading-font-family`, `group-heading-font-size`, `group-heading-font-weight`, `group-heading-line-height`, `group-heading-letter-spacing`, `separator-thickness`, `separator-margin-block`, `separator-bg`, `item-gap`, `item-padding-inline`, `item-fg`, `disabled-item-fg`, `item-indicator-size`, `selected-tags-gap`, `selected-tags-margin-block-end`, `selected-tag-gap`, `selected-tag-padding-inline-start`, `selected-tag-padding-inline-end`, `selected-tag-inset`, `selected-tag-radius`, `selected-tag-bg`, `selected-tag-border`, `selected-tag-fg`, `selected-tag-line-height`, `selected-tag-font-size-offset`, `selected-tag-remove-size`, `selected-tag-remove-margin-inline-start`, `selected-tag-remove-radius` · 3 privada(s) forward — `_palette-track`, `_palette-border`, `_palette-text`
|
|
|
- **Eje `size`**: no · **ficheros**: `combobox.css`
|
|
|
|
|
|
## 1. Knobs fuera de alcance
|
|
|
|
|
|
### 1.1 Directo a primitivo global (3)
|
|
|
|
|
|
| # | fichero:línea | selector | propiedad | valor |
|
|
|
| ---: | --- | --- | --- | --- |
|
|
|
| 1 | `combobox.css:118` | `[data-combobox-trigger]:hover:not(:disabled):not([data-disabled])` | `background` | `var(--color-surface-raised)` |
|
|
|
| 2 | `combobox.css:119` | `[data-combobox-trigger]:hover:not(:disabled):not([data-disabled])` | `color` | `var(--color-content-primary)` |
|
|
|
| 3 | `combobox.css:415` | `[data-combobox-selected-tag-remove]:hover` | `background` | `var(--color-surface-overlay)` |
|
|
|
|
|
|
### 1.2 A través de un privado (4)
|
|
|
|
|
|
| # | fichero:línea | selector | propiedad | valor |
|
|
|
| ---: | --- | --- | --- | --- |
|
|
|
| 1 | `combobox.css:51` | `[data-combobox-control]:has([data-combobox-input]:focus-visible), [data-combobox-control]:has([data-combobox-trigger]:focus-visible), [data-combobox-control]:has([data-state='open'])` | `border-color` | `var(--_combobox-palette-border)` |
|
|
|
| 2 | `combobox.css:157` | `[data-combobox-trigger][data-state='open'] [data-combobox-indicator]` | `color` | `var(--_combobox-palette-text)` |
|
|
|
| 3 | `combobox.css:296` | `[data-combobox-item][data-state='checked']` | `background` | `var(--_combobox-palette-track)` |
|
|
|
| 4 | `combobox.css:297` | `[data-combobox-item][data-state='checked']` | `color` | `var(--_combobox-palette-text)` |
|
|
|
|
|
|
### 1.3 Literales (6)
|
|
|
|
|
|
| # | fichero:línea | selector | propiedad | valor |
|
|
|
| ---: | --- | --- | --- | --- |
|
|
|
| 1 | `combobox.css:4` | `[data-combobox]` | `inline-size` | `100%` |
|
|
|
| 2 | `combobox.css:20` | `[data-combobox-control]` | `inline-size` | `100%` |
|
|
|
| 3 | `combobox.css:68` | `[data-combobox-input]` | `inline-size` | `100%` |
|
|
|
| 4 | `combobox.css:321` | `[data-combobox-item][data-state='checked'] [data-combobox-item-indicator]` | `opacity` | `1` |
|
|
|
| 5 | `combobox.css:416` | `[data-combobox-selected-tag-remove]:hover` | `opacity` | `1` |
|
|
|
| 6 | `combobox.css:422` | `[data-combobox-selected-tag-remove]:focus-visible` | `opacity` | `1` |
|
|
|
|
|
|
### 1.4 Excepciones firmadas (0) — 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._
|
|
|
## 2. Sistema transversal (8) — informativo, fuera del ratio
|
|
|
|
|
|
Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
|
|
|
|
|
|
| # | fichero:línea | selector | propiedad | valor |
|
|
|
| ---: | --- | --- | --- | --- |
|
|
|
| 1 | `combobox.css:53` | `[data-combobox-control]:has([data-combobox-input]:focus-visible), [data-combobox-control]:has([data-combobox-trigger]:focus-visible), [data-combobox-control]:has([data-state='open'])` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
|
|
|
| 2 | `combobox.css:123` | `[data-combobox-trigger]:focus-visible` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
|
|
|
| 3 | `combobox.css:331` | `[data-combobox-arrow] polygon` | `fill` | `var(--depth-overlay-surface)` |
|
|
|
| 4 | `combobox.css:336` | `[data-combobox-arrow] path` | `stroke` | `var(--depth-overlay-border)` |
|
|
|
| 5 | `combobox.css:393` | `[data-combobox-selected-tag][data-disabled]` | `opacity` | `var(--opacity-disabled)` |
|
|
|
| 6 | `combobox.css:407` | `[data-combobox-selected-tag-remove]` | `opacity` | `var(--opacity-muted)` |
|
|
|
| 7 | `combobox.css:420` | `[data-combobox-selected-tag-remove]:focus-visible` | `outline` | `var(--focus-ring-width) solid var(--color-primary-border)` |
|
|
|
| 8 | `combobox.css:428` | `[data-combobox-selected-tag-remove][data-disabled], [data-combobox-selected-tag-remove]:disabled` | `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? |
|
|
|
| --- | ---: | --- | --- | :-: |
|
|
|
| `--_combobox-control-height` | 1 | `var(--combobox-control-height)` | public | **sí** |
|
|
|
| `--_combobox-control-padding-inline` | 1 | `var(--combobox-control-padding-inline)` | public | **sí** |
|
|
|
| `--_combobox-control-gap` | 1 | `var(--combobox-control-gap)` | public | **sí** |
|
|
|
| `--_combobox-font-size` | 2 | `var(--combobox-control-font-size)`, `var(--combobox-content-font-size)` | public | **sí** |
|
|
|
| `--_combobox-content-padding-block` | 1 | `var(--combobox-content-padding-block)` | public | **sí** |
|
|
|
| `--_combobox-content-padding-inline` | 1 | `var(--combobox-content-padding-inline)` | public | **sí** |
|
|
|
| `--_combobox-content-width` | 1 | `var(--_combobox-match-anchor-width, var(--combobox-content-min-width))` | public | **sí** |
|
|
|
|
|
|
Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inline del wrapper): `--_combobox-match-anchor-width`, `--_combobox-palette-border`, `--_combobox-palette-text`, `--_combobox-palette-track`.
|
|
|
|
|
|
## 4. Propuesta de corrección
|
|
|
|
|
|
- **Consume la capa compartida `list-surface`.** Un eje que la capa posee se consume como `var(--_x, var(--x))`; el consumidor **no acuña** `--combobox-{eje}` para él — sería un vocabulario paralelo (README de `eidos/components`, «Capas compartidas» regla 2).
|
|
|
|
|
|
### 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
|
|
|
al final) y theming §6.7 (slots de color, modificador delante). Un token con
|
|
|
DOS valores distintos es una colisión de nombre: son dos knobs, o el nombre
|
|
|
no distingue lo que debería — se marca `⚠`.
|
|
|
|
|
|
| token (`--combobox-…`) | scope TSC | valor propuesto | usos |
|
|
|
| --- | --- | --- | ---: |
|
|
|
| `hover-trigger-bg` | `root` | `var(--color-surface-raised)` | 1 |
|
|
|
| `hover-trigger-fg` | `root` | `var(--color-content-primary)` | 1 |
|
|
|
| `hover-selected-tag-remove-bg` | `root` | `var(--color-surface-overlay)` | 1 |
|
|
|
|
|
|
### 4.2 Sin nombre mecánico (10)
|
|
|
|
|
|
- **⚠ 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** — 3: `inline-size`.
|
|
|
- **⚠ 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** — 4: `border-color`, `color`, `background`.
|
|
|
- **⚠ 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** — 3: `opacity`.
|
|
|
|
|
|
### 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.
|
|
|
- [ ] **Velo o acento en el nodo equivocado** (`archetype: 'item'` en un envoltorio, un `background` en shorthand que mata la capa de estado) — se mide desde el píxel hacia arriba.
|
|
|
- [ ] **Doble animación** al mover un sello a una superficie con animación propia — registro de `animationstart`/`animationend`.
|
|
|
- [ ] **Diff de computed = 0** en reposo · hover · abierto · disabled · foco, por talla, antes y después.
|
|
|
- [ ] **Centinela por token nuevo**: valor imposible en el root → el nodo lo sigue. Si no, el token miente.
|
|
|
|
|
|
## 5. Veredicto
|
|
|
|
|
|
<!-- veredicto:start -->
|
|
|
|
|
|
**Revisión 2026-08-20 — verificación previa a implementación (Opus).**
|
|
|
|
|
|
**Análisis (§1–§3): CORRECTO.** Cifras reproducidas (96 · 0 · 15 · 67 · 6 · 8);
|
|
|
filas muestreadas contra `combobox.css` ✓. §3 acierta: los 7 privados propios
|
|
|
son todos `global` (ninguno deriva de un público) y los 4 de paleta vienen del
|
|
|
forward THM-2 en `base.ts`.
|
|
|
|
|
|
**Propuesta (§4.1): APTA CON CINCO CORRECCIONES**, verificadas contra el CSS y
|
|
|
el patrón firmado (Sidebar F3, `recipes/base.ts:4735`):
|
|
|
|
|
|
1. **La forma por talla no es `size:{k}` a secas.** El patrón firmado es DOS
|
|
|
piezas: coordenadas por talla en scope estable (`control-height-{k}`,
|
|
|
`root`) **+ un nombre RESUELTO** (`control-height`) con `declarations`
|
|
|
— default **`host`** (= md) y overrides `size:xs..xl` — que es lo ÚNICO que
|
|
|
la receta consume. La tabla propone sólo la mitad (y con `size:md` como si
|
|
|
md necesitara selector). El root sí estampa `data-size`
|
|
|
(`combobox.svelte:45`), pero el **content viaja por portal**: sus
|
|
|
declarations por talla necesitan `parts: ['content']` (precedente `select`,
|
|
|
tsc.md §multi-part). Control/input/trigger cuelgan del root → sin `parts`.
|
|
|
2. **Valores por talla: bundle donde casa, verbatim donde desvía.** Medido
|
|
|
contra `--size-{k}-*` emitido: `control-height-{k}`, `*-font-size-{k}` y
|
|
|
`control-gap-{k}` casan 1:1 → el valor correcto es la coordenada del bundle
|
|
|
(`var(--size-{k}-control-height)` …), que computa idéntico y es lo que
|
|
|
`recipe-css-contract` exige (el primitivo crudo está prohibido en esas
|
|
|
coordenadas). `control-padding-inline-{k}` DESVÍA en xs/sm/lg/xl
|
|
|
(space-2/2-5/3-5/4 vs bundle 1-5/2/4/5) → valor verbatim de hoy, desviación
|
|
|
visible; adoptar el bundle sería cambio visual = decisión aparte.
|
|
|
3. **DOS filas romperían el default (value-changing, NO copiar):**
|
|
|
`selected-tag-height-{k}` propone `var(--control-height-{k})` pero el CSS
|
|
|
real es `calc(var(--_combobox-control-height) - var(--space-2))`
|
|
|
(`combobox.css:427`) — perdería el −8px; y `selected-tag-font-size-{k}`
|
|
|
propone `var(--font-size-{k})` pero el real es
|
|
|
`calc(var(--_combobox-font-size) - 1px)` (`:432`, con anotación
|
|
|
`/* literal: optical */` — candidata a excepción R-5). Corrección: el tag
|
|
|
consume el público resuelto DENTRO de su expresión
|
|
|
(`calc(var(--combobox-control-height) - var(--space-2))`), no un token
|
|
|
plano.
|
|
|
4. **`content-width` ⚠ se resuelve con el precedente `select`**: acuñar
|
|
|
`content-min-width: 14rem` (público, `root`) y el privado pasa a
|
|
|
`var(--_combobox-match-anchor-width, var(--combobox-content-min-width))` —
|
|
|
idéntico a `--_select-content-width`. El `max-inline-size: min(28rem, …)`
|
|
|
pide su par `content-max-width: 28rem`. El `calc(100vw - var(--space-4))`
|
|
|
se queda en la receta (guarda de viewport, no knob).
|
|
|
5. **Los hovers neutros NO se tokenizan: son la capa de estado.**
|
|
|
`hover-trigger-bg/fg` (`:hover → var(--color-surface-raised)`) y
|
|
|
`hover-selected-tag-remove-bg` (`var(--color-surface-overlay)`) son el
|
|
|
hover bespoke que §38 deprecó — la corrección del eje D es migrarlos a
|
|
|
`--state-hover` sobre el nodo CON forma, no darles nombre propio. Eso mueve
|
|
|
el píxel (leve) ⇒ **decisión firmada** antes de tocarlos, como en
|
|
|
nav-menu; mientras no se firme, se quedan como están y NO entran en el
|
|
|
contrato.
|
|
|
|
|
|
Resto de §4.1 (colores/espacios one-off del control, input, group-heading,
|
|
|
separator, item-indicator, selected-tags): **válida tal cual** — nombres en
|
|
|
vocabulario, scope `root`, valores verbatim. §4.2: los 3 `border` y el
|
|
|
`padding` shorthand se parten en ejes lógicos (mecánico, mismo computed);
|
|
|
los `100%`/`opacity:1` quedan fuera del knob (D-TH.2). Los 4 knobs de paleta
|
|
|
(`open-control-border`, `open-trigger-fg`, checked item) se QUEDAN en el
|
|
|
patrón THM-2 (forward privado + shared palette layer); promoverlos a público
|
|
|
`palette-*` es decisión, no corrección.
|
|
|
|
|
|
**Instrucción para el implementador**: bloque `combobox` en `base.ts` con el
|
|
|
patrón Sidebar (coordenadas + resueltos por eje: `control-height`,
|
|
|
`control-font-size`, `control-gap`, `control-padding-inline`,
|
|
|
`content-padding-block`, `content-padding-inline`, `content-font-size`); los
|
|
|
privados de la receta pasan a leer el público resuelto o mueren; regenerar
|
|
|
(`npm run generate:eidos-css`), y el protocolo §7 entero (sonda antes/después
|
|
|
por talla y estado — reposo·hover·abierto·disabled·foco —, centinela por token
|
|
|
nuevo, censo `--only combobox` al 100 % o excepciones escritas, diff de
|
|
|
computed = 0). Ojo §7.2: la sonda del content abre el popup ANTES de medir.
|
|
|
|
|
|
**Bloqueos de firma**: hover→state-layer (punto 5) · adopción del bundle en
|
|
|
`control-padding-inline` (punto 2) · D-TH.2/D-TH.6 siguen sin firmar.
|
|
|
|
|
|
---
|
|
|
|
|
|
**EJECUTADO 2026-08-20.** Alcance **0 % → 76 %** · contrato **1 → 106 claves
|
|
|
públicas**. Fuera quedan los 3 hovers bespoke (esperan la firma del punto 5), 6
|
|
|
literales de layout (`100%`, `opacity: 1`) y 12 knobs que pasan por privados que
|
|
|
AHORA derivan de públicos.
|
|
|
|
|
|
Se implementaron las cinco correcciones. Dos cosas sólo aparecieron al ejecutar:
|
|
|
|
|
|
- **Los resueltos del control se emiten con `parts: ['control', 'input']`**, no
|
|
|
en el root: el `data-size` que manda vive en el control, y el input suelto lo
|
|
|
necesita fuera de él. Los del panel, con `parts: ['content']` por el portal.
|
|
|
- **El centinela cazó un fallo REAL de la primera pasada**: había dejado vivos
|
|
|
los ocho bloques `[data-…][data-size='…']` de la receta, que pisaban los
|
|
|
tokens nuevos con los valores viejos. El diff de computed daba 0 justamente
|
|
|
porque la ruta vieja seguía mandando — sin centinela habría pasado por bueno.
|
|
|
|
|
|
Verificación (artefactos):
|
|
|
|
|
|
- **Diff de computed = 0**: 1.566 valores en **8 estados** (reposo · 5 tallas ·
|
|
|
panel ABIERTO · hover), sobre los 6 nodos en reposo y los 13 con el panel
|
|
|
desplegado.
|
|
|
- **Centinela**: 67/106 automático. Verificados a mano: `content-z`
|
|
|
(80 → 4321; vive en `[data-floating-wrapper]`, sin attr del componente),
|
|
|
`placeholder-fg`, `disabled-input-fg`, `trigger-fg`, `disabled-control-bg`,
|
|
|
`disabled-control-fg`, y los del panel abierto por teclado (`content-fg`,
|
|
|
`content-radius`, `item-gap`, `group-heading-bg`, `viewport-gap`). Los 7
|
|
|
nombres RESUELTOS no se mueven por diseño — el tema mueve la coordenada.
|
|
|
`control-border`, `disabled-control-border` e `item-fg` los pisa la paleta en
|
|
|
estado abierto / checked. Los 14 `selected-tag-*`, los 3 `separator-*` y
|
|
|
`scrollbar-inset` **no son verificables en esta demo** (no monta modo
|
|
|
múltiple, ni separadores, ni scroll): se comprobó su consumidor textual en la
|
|
|
receta, y así queda dicho en vez de darlos por buenos.
|
|
|
- **`content-font-family` y `content-line-height` son inertes**:
|
|
|
`[data-depth='overlay']` declara lo mismo con igual especificidad y gana por
|
|
|
orden (la tipografía de portal del Build contract). Anotado en el README.
|
|
|
- Guards: `component:audit` PASS · `eidos-lint` 0 invalid · suite eidos sin
|
|
|
rojos nuevos · `rtl:check` 0 · `docs:check` 0. El guard del bundle obligó a
|
|
|
apuntar `indicator-size` / `item-indicator-size` a `--size-sm-icon-size`.
|
|
|
|
|
|
**Instrumento**: la sonda congela `transition` (una propiedad transicionada lee
|
|
|
su valor INICIAL justo tras la escritura, y un token vivo parecía muerto) pero
|
|
|
**no** `animation` — Presence espera `animationend` para montar, y congelarla
|
|
|
impide que el panel se abra.
|
|
|
|
|
|
<!-- veredicto:end -->
|