# 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-24 · **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: */` 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 **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.