feat(listbox): temable hasta donde le toca — 0 % → 68 %

Sexto del bloque F2-A, y el primero cuyo techo es DELIBERADO.

  censo    0 % → 68 % · global 42 %
  computed 2.813 valores en 7 estados: 0 diffs

**Este componente no puede acuñar su ritmo de fila, y esa es la regla de oro de
las capas compartidas.** Altura, padding inline y block, gap y tamaño de letra
los posee `lib/list-surface.css`, por talla, para TODAS las superficies de lista
—select, combobox, command y los menús—. Acuñar `--listbox-item-height` sería
el vocabulario paralelo que las reglas de esa capa prohíben, y encima ganaría a
la capa para todas las demás. Los cinco privados ADOPTAN la capa y se quedan
privados; un tema mueve ese ritmo por `--list-*`, que es donde vive.

Consecuencia honesta: el censo cuenta esos cinco como privados y el alcance se
queda en 68 % en vez de ~86 %. **La métrica penaliza hacer lo correcto** — el
mismo defecto de medición que D-TH.2-b arregló para los primitivos tipográficos
(`--style-*` pasó a `system`). Registrado en `next-features.md` §13; extenderlo
a las capas compartidas es decisión, no corrección al paso.

**Dos cosas medidas que NO se tocaron:**

- El `highlighted` pinta DOS veces: la receta pone un `--color-surface-overlay`
  plano y encima el arquetipo `item` añade su velo del 8 %. El plano es el
  duplicado que §38 deprecó, así que se dejó SIN token — bendecir con un nombre
  público algo condenado a morir es el error que este eje ya evitó en el
  codemod. Retirarlo mueve píxel.
- La tinta de la fila seleccionada era código MUERTO y se retiró: el arquetipo
  la fija a (0,4,0) contra los (0,3,0) de la receta. Mismo hallazgo que en
  `table`, verificado igual, 0 diffs al quitarlo.

Lo que sí es suyo y ahora es público: la superficie que sostiene las filas
(fondo, borde, radio, padding, gap, tipografía, alto máximo), la esquina y la
tinta de la fila, el glifo de selección y el grupo con su etiqueta.

  audit PASS · eidos-lint 0 invalid · rtl 0 · docs 0 · check 0 en tocados ·
  suite: sólo el rojo conocido

Demo con tab Tokens: 21 filas, 0 sin computar.

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

@ -10,9 +10,9 @@
- **Medido**: 2026-08-20 · **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**: **42%** — 2026 de 4857 knobs pasan por un token público del componente
- **Reparto**: público 2026 · privado 724 · global 1518 · literal 589 · sistema transversal 335 _(fuera del ratio)_
- **Sin token público propio**: 52 · **alcance < 20 %**: 46 · **alcance 100 %**: 7 · **con eje `size`**: 74
- **Alcance global**: **42%** — 2049 de 4856 knobs pasan por un token público del componente
- **Reparto**: público 2049 · privado 724 · global 1496 · literal 587 · sistema transversal 335 _(fuera del ratio)_
- **Sin token público propio**: 51 · **alcance < 20 %**: 45 · **alcance 100 %**: 7 · **con eje `size`**: 74
## Cómo se lee
@ -82,21 +82,21 @@ theming §6.7 lo pone delante (`hover-bg`). Las fichas proponen la forma
| 10 | [time-picker](./time-picker.md) | 47 | 18% | 59 | 16 |
| 11 | [color-picker](./color-picker.md) | 43 | 46% | 82 | 54 |
| 12 | [field-langs](./field-langs.md) | 39 | 0% | 41 | 0 |
| 13 | [listbox](./listbox.md) | 35 | 0% | 38 | 0 |
| 14 | [field](./field.md) | 33 | 51% | 73 | 60 |
| 15 | [feed](./feed.md) | 32 | 0% | 33 | 0 |
| 16 | [carousel](./carousel.md) | 31 | 0% | 36 | 0 |
| 17 | [date-range-picker](./date-range-picker.md) | 30 | 40% | 53 | 22 |
| 18 | [natural-time-picker](./natural-time-picker.md) | 30 | 61% | 77 | 62 |
| 19 | [popover](./popover.md) | 28 | 42% | 51 | 36 |
| 20 | [radio-cards](./radio-cards.md) | 28 | 60% | 72 | 39 |
| 13 | [field](./field.md) | 33 | 51% | 73 | 60 |
| 14 | [feed](./feed.md) | 32 | 0% | 33 | 0 |
| 15 | [carousel](./carousel.md) | 31 | 0% | 36 | 0 |
| 16 | [date-range-picker](./date-range-picker.md) | 30 | 40% | 53 | 22 |
| 17 | [natural-time-picker](./natural-time-picker.md) | 30 | 61% | 77 | 62 |
| 18 | [popover](./popover.md) | 28 | 42% | 51 | 36 |
| 19 | [radio-cards](./radio-cards.md) | 28 | 60% | 72 | 39 |
| 20 | [drawer](./drawer.md) | 27 | 55% | 62 | 44 |
## Sin ningún token público propio (52)
## Sin ningún token público propio (51)
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) · [`carousel`](./carousel.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) · [`feed`](./feed.md) · [`field-langs`](./field-langs.md) · [`grid-list`](./grid-list.md) · [`heading`](./heading.md) · [`highlight`](./highlight.md) · [`kbd`](./kbd.md) · [`knob`](./knob.md) · [`label`](./label.md) · [`link`](./link.md) · [`link-preview`](./link-preview.md) · [`listbox`](./listbox.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) · [`carousel`](./carousel.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) · [`feed`](./feed.md) · [`field-langs`](./field-langs.md) · [`grid-list`](./grid-list.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)
## 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 | 13 | 40 | 5 | 3 | 0 | y |
| [year-grid](./year-grid.md) | 0% | 61 | 0 | 13 | 40 | 5 | 3 | 0 | y |
| [field-langs](./field-langs.md) | 0% | 41 | 0 | 0 | 31 | 8 | 2 | 0 | – |
| [listbox](./listbox.md) | 0% | 38 | 0 | 9 | 23 | 3 | 3 | 0 | – |
| [carousel](./carousel.md) | 0% | 36 | 0 | 14 | 12 | 5 | 5 | 0 | y |
| [feed](./feed.md) | 0% | 33 | 0 | 13 | 16 | 3 | 1 | 0 | y |
| [grid-list](./grid-list.md) | 0% | 32 | 0 | 8 | 17 | 2 | 5 | 0 | y |
@ -215,6 +214,7 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [image-adjustments](./image-adjustments.md) | 67% | 18 | 10 | 0 | 4 | 1 | 3 | 12 | – |
| [text-gradient](./text-gradient.md) | 67% | 9 | 6 | 0 | 0 | 3 | 0 | 0 | – |
| [separator](./separator.md) | 67% | 6 | 4 | 0 | 0 | 2 | 0 | 3 | – |
| [listbox](./listbox.md) | 68% | 37 | 23 | 9 | 1 | 1 | 3 | 21 | – |
| [select](./select.md) | 69% | 72 | 45 | 14 | 3 | 3 | 7 | 101 | y |
| [dialog](./dialog.md) | 70% | 33 | 23 | 5 | 4 | 1 | 0 | 37 | y |
| [image-picker](./image-picker.md) | 71% | 20 | 12 | 0 | 1 | 4 | 3 | 13 | – |

@ -5,62 +5,38 @@
> 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 35 knobs por token público
- **Knobs de apariencia**: 38 — público 0 · privado 9 · global 23 · literal 3 · sistema 3 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 0 pública(s) · 2 privada(s) forward — `_palette-solid`, `_palette-element`
- **Medido**: 2026-08-20 · **Alcance**: **68%** — 23 de 34 knobs por token público
- **Knobs de apariencia**: 37 — público 23 · privado 9 · global 1 · literal 1 · sistema 3 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 21 pública(s) — `gap`, `padding`, `radius`, `border-width`, `border`, `bg`, `fg`, `font-family`, `line-height`, `max-block-size`, `invalid-border`, `item-radius`, `item-fg`, `disabled-item-fg`, `item-indicator-size`, `group-gap`, `group-label-padding-block`, `group-label-fg`, `group-label-font-size`, `group-label-font-weight`, `group-label-tracking` · 2 privada(s) forward — `_palette-solid`, `_palette-element`
- **Eje `size`**: no · **ficheros**: `listbox.css`
## 1. Knobs fuera de alcance
### 1.1 Directo a primitivo global (23)
### 1.1 Directo a primitivo global (1)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `listbox.css:36` | `[data-listbox]` | `gap` | `var(--space-1)` |
| 2 | `listbox.css:37` | `[data-listbox]` | `padding` | `var(--space-1)` |
| 3 | `listbox.css:38` | `[data-listbox]` | `border` | `var(--border-width) solid var(--color-border-default)` |
| 4 | `listbox.css:39` | `[data-listbox]` | `border-radius` | `var(--radius-md)` |
| 5 | `listbox.css:40` | `[data-listbox]` | `background` | `var(--color-surface-default)` |
| 6 | `listbox.css:41` | `[data-listbox]` | `color` | `var(--color-content-primary)` |
| 7 | `listbox.css:42` | `[data-listbox]` | `font-family` | `var(--style-label-font-family)` |
| 8 | `listbox.css:44` | `[data-listbox]` | `line-height` | `var(--leading-ui)` |
| 9 | `listbox.css:74` | `[data-listbox][data-variant='surface']` | `background` | `var(--color-surface-default)` |
| 10 | `listbox.css:75` | `[data-listbox][data-variant='surface']` | `border-color` | `var(--color-border-default)` |
| 11 | `listbox.css:80` | `[data-listbox][data-variant='outline']` | `border-color` | `var(--color-border-default)` |
| 12 | `listbox.css:98` | `[data-listbox][data-invalid]` | `border-color` | `var(--color-threat-element)` |
| 13 | `listbox.css:115` | `[data-listbox-item]` | `border-radius` | `var(--radius-sm)` |
| 14 | `listbox.css:116` | `[data-listbox-item]` | `color` | `var(--color-content-primary)` |
| 15 | `listbox.css:125` | `[data-listbox-item][data-highlighted]:not([data-disabled])` | `background` | `var(--color-surface-overlay)` |
| 16 | `listbox.css:130` | `[data-listbox-item][data-selected]:not([data-disabled])` | `color` | `var(--color-content-primary)` |
| 17 | `listbox.css:138` | `[data-listbox-item][data-disabled]` | `color` | `var(--color-content-muted)` |
| 18 | `listbox.css:168` | `[data-listbox-group]` | `gap` | `var(--space-1)` |
| 19 | `listbox.css:172` | `[data-listbox-group-label]` | `padding-block` | `var(--space-1)` |
| 20 | `listbox.css:174` | `[data-listbox-group-label]` | `color` | `var(--color-content-muted)` |
| 21 | `listbox.css:175` | `[data-listbox-group-label]` | `font-size` | `var(--font-size-xs)` |
| 22 | `listbox.css:176` | `[data-listbox-group-label]` | `font-weight` | `var(--font-weight-medium)` |
| 23 | `listbox.css:178` | `[data-listbox-group-label]` | `letter-spacing` | `var(--tracking-caps)` |
| 1 | `listbox.css:131` | `[data-listbox-item][data-highlighted]:not([data-disabled])` | `background` | `var(--color-surface-overlay)` |
### 1.2 A través de un privado (9)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `listbox.css:43` | `[data-listbox]` | `font-size` | `var(--_listbox-item-font-size)` |
| 2 | `listbox.css:111` | `[data-listbox-item]` | `gap` | `var(--_listbox-item-gap)` |
| 3 | `listbox.css:112` | `[data-listbox-item]` | `min-block-size` | `var(--_listbox-item-height)` |
| 4 | `listbox.css:113` | `[data-listbox-item]` | `padding-block` | `var(--_listbox-item-padding-block)` |
| 5 | `listbox.css:114` | `[data-listbox-item]` | `padding-inline` | `var(--_listbox-item-padding-inline)` |
| 6 | `listbox.css:129` | `[data-listbox-item][data-selected]:not([data-disabled])` | `background` | `var(--_listbox-palette-element)` |
| 7 | `listbox.css:134` | `[data-listbox-item][data-selected][data-highlighted]:not([data-disabled])` | `background` | `var(--_listbox-palette-element)` |
| 8 | `listbox.css:155` | `[data-listbox-item-indicator]` | `color` | `var(--_listbox-palette-solid)` |
| 9 | `listbox.css:173` | `[data-listbox-group-label]` | `padding-inline` | `var(--_listbox-item-padding-inline)` |
### 1.3 Literales (3)
| 1 | `listbox.css:49` | `[data-listbox]` | `font-size` | `var(--_listbox-item-font-size)` |
| 2 | `listbox.css:117` | `[data-listbox-item]` | `gap` | `var(--_listbox-item-gap)` |
| 3 | `listbox.css:118` | `[data-listbox-item]` | `min-block-size` | `var(--_listbox-item-height)` |
| 4 | `listbox.css:119` | `[data-listbox-item]` | `padding-block` | `var(--_listbox-item-padding-block)` |
| 5 | `listbox.css:120` | `[data-listbox-item]` | `padding-inline` | `var(--_listbox-item-padding-inline)` |
| 6 | `listbox.css:139` | `[data-listbox-item][data-selected]:not([data-disabled])` | `background` | `var(--_listbox-palette-element)` |
| 7 | `listbox.css:143` | `[data-listbox-item][data-selected][data-highlighted]:not([data-disabled])` | `background` | `var(--_listbox-palette-element)` |
| 8 | `listbox.css:164` | `[data-listbox-item-indicator]` | `color` | `var(--_listbox-palette-solid)` |
| 9 | `listbox.css:182` | `[data-listbox-group-label]` | `padding-inline` | `var(--_listbox-item-padding-inline)` |
### 1.3 Literales (1)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `listbox.css:59` | `[data-listbox][data-block]` | `inline-size` | `100%` |
| 2 | `listbox.css:152` | `[data-listbox-item-indicator]` | `inline-size` | `0.875rem` |
| 3 | `listbox.css:153` | `[data-listbox-item-indicator]` | `block-size` | `0.875rem` |
| 1 | `listbox.css:65` | `[data-listbox][data-block]` | `inline-size` | `100%` |
## 2. Sistema transversal (3) — informativo, fuera del ratio
@ -68,9 +44,9 @@ Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `listbox.css:93` | `[data-listbox][data-disabled]` | `opacity` | `var(--opacity-disabled)` |
| 2 | `listbox.css:103` | `[data-listbox]:focus-visible, [data-listbox][data-focused]` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
| 3 | `listbox.css:140` | `[data-listbox-item][data-disabled]` | `opacity` | `var(--opacity-disabled)` |
| 1 | `listbox.css:99` | `[data-listbox][data-disabled]` | `opacity` | `var(--opacity-disabled)` |
| 2 | `listbox.css:109` | `[data-listbox]:focus-visible, [data-listbox][data-focused]` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` |
| 3 | `listbox.css:149` | `[data-listbox-item][data-disabled]` | `opacity` | `var(--opacity-disabled)` |
## 3. Privados de la receta — ¿de dónde sale su valor?
@ -81,7 +57,7 @@ Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
| `--_listbox-item-padding-block` | 1 | `var(--list-item-padding-block)` | global | no |
| `--_listbox-item-gap` | 1 | `var(--list-item-gap)` | global | no |
| `--_listbox-item-font-size` | 1 | `var(--list-font-size)` | global | no |
| `--_listbox-max-height` | 1 | `min(60vh, 22rem)` | literal | no |
| `--_listbox-max-height` | 1 | `var(--listbox-max-block-size)` | public | **sí** |
Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inline del wrapper): `--_listbox-palette-element`, `--_listbox-palette-solid`.
@ -90,7 +66,7 @@ Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inl
- **Consume la capa compartida `list-surface`.** Un eje que la capa posee se consume como `var(--_x, var(--x))`; el consumidor **no acuña** `--listbox-{eje}` para él — sería un vocabulario paralelo (README de `eidos/components`, «Capas compartidas» regla 2).
- **Consume la capa compartida `menu-indicator`.** Un eje que la capa posee se consume como `var(--_x, var(--x))`; el consumidor **no acuña** `--listbox-{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` (29)
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (7)
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
@ -100,40 +76,16 @@ no distingue lo que debería — se marca `⚠`.
| token (`--listbox-…`) | scope TSC | valor propuesto | usos |
| --- | --- | --- | ---: |
| `gap` | `root` | `var(--space-1)` | 1 |
| `radius` | `root` | `var(--radius-md)` | 1 |
| `bg` | `root` | `var(--color-surface-default)` | 1 |
| `fg` | `root` | `var(--color-content-primary)` | 1 |
| `font-family` | `root` | `var(--style-label-font-family)` | 1 |
| `font-size` | `root` | `var(--list-font-size)` | 1 |
| `line-height` | `root` | `var(--leading-ui)` | 1 |
| `surface-bg` | `root` | `var(--color-surface-default)` | 1 |
| `surface-border` | `root` | `var(--color-border-default)` | 1 |
| `outline-border` | `root` | `var(--color-border-default)` | 1 |
| `invalid-border` | `root` | `var(--color-threat-element)` | 1 |
| `item-gap` | `root` | `var(--list-item-gap)` | 1 |
| `item-height` | `root` | `var(--list-item-height)` | 1 |
| `item-padding-block` | `root` | `var(--list-item-padding-block)` | 1 |
| `item-padding-inline` | `root` | `var(--list-item-padding-inline)` | 1 |
| `item-radius` | `root` | `var(--radius-sm)` | 1 |
| `item-fg` | `root` | `var(--color-content-primary)` | 1 |
| `item-bg` | `root` | `var(--color-surface-overlay)` | 1 |
| `selected-item-fg` | `root` | `var(--color-content-primary)` | 1 |
| `disabled-item-fg` | `root` | `var(--color-content-muted)` | 1 |
| `item-indicator-width` | `root` | `0.875rem` | 1 |
| `item-indicator-height` | `root` | `0.875rem` | 1 |
| `group-gap` | `root` | `var(--space-1)` | 1 |
| `group-label-padding-block` | `root` | `var(--space-1)` | 1 |
| `group-label-padding-inline` | `root` | `var(--list-item-padding-inline)` | 1 |
| `group-label-fg` | `root` | `var(--color-content-muted)` | 1 |
| `group-label-font-size` | `root` | `var(--font-size-xs)` | 1 |
| `group-label-font-weight` | `root` | `var(--font-weight-medium)` | 1 |
| `group-label-letter-spacing` | `root` | `var(--tracking-caps)` | 1 |
### 4.2 Sin nombre mecánico (6)
### 4.2 Sin nombre mecánico (4)
- **⚠ decisión: `padding` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 1: `padding`.
- **⚠ 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: `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** — 1: `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** — 3: `background`, `color`.

@ -478,6 +478,19 @@ existiera. Se anotan porque el eje entero se apoya en esas mediciones.
mientras el resto del catálogo lleva la mezcla suave del sistema
(`--focus-ring-color`, 52 %). Quedó sin tocar porque alinearlo mueve píxel —
y de hecho quedó sin token, porque la regla entera está muerta bajo popover.
- **El censo PENALIZA consumir una capa compartida correctamente.** `listbox`
adopta los cinco ejes de ritmo que `list-surface` posee — que es exactamente
lo que la doctrina de capas manda — y el censo los cuenta como `private`, así
que su alcance queda en 68 % en vez de ~86 %. Es el mismo defecto de medición
que D-TH.2-b arregló para los primitivos tipográficos (`--style-*` pasó a
contar como `system`): la capa ES la superficie de tema de ese eje. Afecta a
los consumidores de `list-surface` (select · combobox · command · listbox ·
los menús) y de `viewport-placement` (affix · fab · menu-dial). Extenderlo es
una decisión, no una corrección al paso.
- **`listbox` pinta el `highlighted` DOS veces**: la receta pone un
`--color-surface-overlay` plano y encima el arquetipo `item` añade su velo del
8 % — medido. El plano es el duplicado que §38 deprecó; se dejó sin token para
no bendecirlo, pero retirarlo mueve píxel.
- **La demo del bloque F2-A ya enseña los tokens** (`TokensPanel`), pero los
~155 componentes sin contrato siguen sin superficie donde verlos hasta que se
tokenicen.

@ -40,22 +40,22 @@ dentro de Select).
- Props headless: `value: string[]`/`onValueChange`, **`selectionMode`**
(norma N1 — unión discriminada single/multiple), `loop`, `typeahead` +
`typeaheadTimeout`, `orientation`, `dir`, `disabled/readonly/required/
invalid`, `name` (form bridge), `aria-label(ledby)`.
invalid`, `name` (form bridge), `aria-label(ledby)`.
- Props eidos: `size` (`xs`–`xl`, responsive), `variant` (`ControlVariant`
del chrome contenedor), `color` (acento de selección — sistema completo:
rol / intent / 33 escalas / valor CSS, THM-2).
## Comparativa
| Capacidad | UIX | React Aria | Ark UI | Radix/Bits |
| --- | --- | --- | --- | --- |
| Listbox standalone (sin overlay) | ✓ | ✓ (ListBox) | ✓ | No (solo en Select) |
| `selectionMode` single/multiple | ✓ (N1) | ✓ | ✓ | — |
| Typeahead con timeout | ✓ | ✓ | ✓ | — |
| Grupos con label | ✓ | ✓ (Section) | ✓ | — |
| Orientación + RTL | ✓ | ✓ | ✓ | — |
| Form bridge (`name`) | ✓ | Vía Form | ✓ | — |
| Eventos perceptivos select/unselect | ✓ (pack) | No | No | — |
| Capacidad | UIX | React Aria | Ark UI | Radix/Bits |
| ----------------------------------- | -------- | ----------- | ------ | ------------------- |
| Listbox standalone (sin overlay) | ✓ | ✓ (ListBox) | ✓ | No (solo en Select) |
| `selectionMode` single/multiple | ✓ (N1) | ✓ | ✓ | — |
| Typeahead con timeout | ✓ | ✓ | ✓ | — |
| Grupos con label | ✓ | ✓ (Section) | ✓ | — |
| Orientación + RTL | ✓ | ✓ | ✓ | — |
| Form bridge (`name`) | ✓ | Vía Form | ✓ | — |
| Eventos perceptivos select/unselect | ✓ (pack) | No | No | — |
Referencias: [APG Listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) ·
[React Aria ListBox](https://react-spectrum.adobe.com/react-aria/ListBox.html) ·
@ -79,14 +79,54 @@ Referencias: [APG Listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) ·
- **`value` siempre `string[]`** (también en single): un solo tipo de dato
para las dos cardinalidades; `selectionMode` gobierna el gesto, no el tipo.
## Talla y tema
Contrato en `lib/recipes/base.ts > listbox` (21 claves). Alcance del censo:
**68 %**, y ese techo es DELIBERADO — no deuda.
### Lo que este componente NO puede acuñar
El ritmo de fila —altura, padding inline y block, gap y tamaño de letra— lo
posee `lib/list-surface.css`, por talla, para TODAS las superficies de lista
(select, combobox, command y los menús). La regla de las capas compartidas es
explícita: **un consumidor no acuña `--{componente}-{eje}` para un eje que la
capa ya posee** — sería un vocabulario paralelo, y además ganaría a la capa
para todas las demás superficies.
Así que los cinco privados `--_listbox-item-*` **adoptan** la capa y se quedan
privados. Un tema mueve ese ritmo por `--list-item-*` / `--list-font-size`, que
es donde vive. El censo los cuenta como privados y baja el alcance: la métrica
penaliza hacer lo correcto, y eso está registrado en `next-features.md` §13.
**Sin eje `size` propio**: nada estampa `data-size` para estos ejes; la talla
llega por el contexto de la capa.
### Lo que sí es suyo
La superficie que sostiene las filas (fondo, borde, radio, padding, gap,
tipografía, alto máximo), la esquina y la tinta de la fila, el tamaño del
glifo de selección, y el grupo con su etiqueta.
### Dos cosas medidas que NO se tocaron
- **El `highlighted` pinta DOS veces.** La receta pone
`background: var(--color-surface-overlay)` y encima el arquetipo `item` añade
su velo — medido: `bg` opaco + `background-image` con el gradiente del 8 %.
El plano es el duplicado que §38 deprecó, así que **no se le acuñó token**:
bendecir con un nombre público algo condenado a morir es el error que este
eje ya evitó en el codemod. Retirarlo mueve píxel ⇒ decisión aparte.
- **La tinta de la fila seleccionada era código muerto** y se retiró: el
arquetipo la fija a (0,4,0) contra los (0,3,0) de la receta. Mismo hallazgo
que en `table`, verificado igual y con 0 diffs al quitarlo.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| `data-readonly` declarado sin estilo (R-1.3) | **implementar** | Censo de familia de selección: tratamiento muted + cursor default cuando aterrice el pase readonly. |
| Selección por arrastre (drag-select de rango, estilo file manager) | **diferir** | Sin caso real; multiple + Shift/Ctrl cubre el uso actual. |
| Virtualización de listas largas | **descartar** | Pertenece a `VirtualList`/`s-text-virtual-list` — componer, no absorber. |
| Tests del wrapper eidos (soma con suite ✓) | **diferir** | Pasada SYS-2. |
| Gap | Disposición | Detalle |
| ------------------------------------------------------------------ | --------------- | --------------------------------------------------------------------------------------------------- |
| `data-readonly` declarado sin estilo (R-1.3) | **implementar** | Censo de familia de selección: tratamiento muted + cursor default cuando aterrice el pase readonly. |
| Selección por arrastre (drag-select de rango, estilo file manager) | **diferir** | Sin caso real; multiple + Shift/Ctrl cubre el uso actual. |
| Virtualización de listas largas | **descartar** | Pertenece a `VirtualList`/`s-text-virtual-list` — componer, no absorber. |
| Tests del wrapper eidos (soma con suite ✓) | **diferir** | Pasada SYS-2. |
## Referencias

@ -24,24 +24,30 @@
* block sets `--list-*`; the private knobs below just adopt them. This aligns
* md to the list rhythm (font sm→md, px space-3→space-2) — shared with the
* menu surfaces. */
/* These five ADOPT the shared layer and are deliberately NOT public tokens
* of this component: `lib/list-surface.css` owns the row rhythm, per size,
* for every list surface (select, combobox, command, the menus). Minting
* `--listbox-item-height` here would be the parallel vocabulary the layer's
* own rules forbid, and it would outrank the layer for all of them. A theme
* moves this rhythm through `--list-*`. */
--_listbox-item-height: var(--list-item-height);
--_listbox-item-padding-inline: var(--list-item-padding-inline);
--_listbox-item-padding-block: var(--list-item-padding-block);
--_listbox-item-gap: var(--list-item-gap);
--_listbox-item-font-size: var(--list-font-size);
--_listbox-max-height: min(60vh, 22rem);
--_listbox-max-height: var(--listbox-max-block-size);
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-1);
border: var(--border-width) solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-surface-default);
color: var(--color-content-primary);
font-family: var(--style-label-font-family);
gap: var(--listbox-gap);
padding: var(--listbox-padding);
border: var(--listbox-border-width) solid var(--listbox-border);
border-radius: var(--listbox-radius);
background: var(--listbox-bg);
color: var(--listbox-fg);
font-family: var(--listbox-font-family);
font-size: var(--_listbox-item-font-size);
line-height: var(--leading-ui);
line-height: var(--listbox-line-height);
outline: none;
/*
* Vertical scroll for tall lists. Default cap is min(60vh, 22rem) —
@ -71,13 +77,13 @@
/* ── Variant — surface (default) ─ */
[data-listbox][data-variant='surface'] {
background: var(--color-surface-default);
border-color: var(--color-border-default);
background: var(--listbox-bg);
border-color: var(--listbox-border);
}
[data-listbox][data-variant='outline'] {
background: transparent;
border-color: var(--color-border-default);
border-color: var(--listbox-border);
}
[data-listbox][data-variant='ghost'] {
@ -95,7 +101,7 @@
}
[data-listbox][data-invalid] {
border-color: var(--color-threat-element);
border-color: var(--listbox-invalid-border);
}
[data-listbox]:focus-visible,
@ -112,8 +118,8 @@
min-block-size: var(--_listbox-item-height);
padding-block: var(--_listbox-item-padding-block);
padding-inline: var(--_listbox-item-padding-inline);
border-radius: var(--radius-sm);
color: var(--color-content-primary);
border-radius: var(--listbox-item-radius);
color: var(--listbox-item-fg);
cursor: pointer;
user-select: none;
transition:
@ -125,9 +131,12 @@
background: var(--color-surface-overlay);
}
/* No `color` here: the item's `archetype: 'item'` rule in `archetypes.css` sets
the selected ink at (0,4,0) and beats this (0,3,0), so the declaration was
dead — the same shape `table` shed on 2026-08-20. The selected row's ink
belongs to the archetype. */
[data-listbox-item][data-selected]:not([data-disabled]) {
background: var(--_listbox-palette-element);
color: var(--color-content-primary);
}
[data-listbox-item][data-selected][data-highlighted]:not([data-disabled]) {
@ -135,7 +144,7 @@
}
[data-listbox-item][data-disabled] {
color: var(--color-content-muted);
color: var(--listbox-disabled-item-fg);
cursor: default;
opacity: var(--opacity-disabled);
}
@ -149,8 +158,8 @@
*/
[data-listbox-item-indicator] {
display: none;
inline-size: 0.875rem;
block-size: 0.875rem;
inline-size: var(--listbox-item-indicator-size);
block-size: var(--listbox-item-indicator-size);
flex: 0 0 auto;
color: var(--_listbox-palette-solid);
}
@ -165,15 +174,15 @@
[data-listbox-group] {
display: flex;
flex-direction: column;
gap: var(--space-1);
gap: var(--listbox-group-gap);
}
[data-listbox-group-label] {
padding-block: var(--space-1);
padding-block: var(--listbox-group-label-padding-block);
padding-inline: var(--_listbox-item-padding-inline);
color: var(--color-content-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--listbox-group-label-fg);
font-size: var(--listbox-group-label-font-size);
font-weight: var(--listbox-group-label-font-weight);
text-transform: uppercase;
letter-spacing: var(--tracking-caps);
letter-spacing: var(--listbox-group-label-tracking);
}

@ -1948,6 +1948,27 @@
--gradient-picker-selected-preset-border: var(--color-primary-border);
--gradient-picker-selected-preset-outline-offset: 2px;
--gradient-picker-actions-gap: var(--space-2);
--listbox-gap: var(--space-1);
--listbox-padding: var(--space-1);
--listbox-radius: var(--radius-md);
--listbox-border-width: var(--border-width);
--listbox-border: var(--color-border-default);
--listbox-bg: var(--color-surface-default);
--listbox-fg: var(--color-content-primary);
--listbox-font-family: var(--style-label-font-family);
--listbox-line-height: var(--leading-ui);
--listbox-max-block-size: min(60vh, 22rem);
--listbox-invalid-border: var(--color-threat-element);
--listbox-item-radius: var(--radius-sm);
--listbox-item-fg: var(--color-content-primary);
--listbox-disabled-item-fg: var(--color-content-muted);
--listbox-item-indicator-size: 0.875rem;
--listbox-group-gap: var(--space-1);
--listbox-group-label-padding-block: var(--space-1);
--listbox-group-label-fg: var(--color-content-muted);
--listbox-group-label-font-size: var(--size-xs-font-size);
--listbox-group-label-font-weight: var(--font-weight-medium);
--listbox-group-label-tracking: var(--tracking-caps);
--proof-of-human-min-height-sm: calc(var(--size-sm-control-height) * 3);
--proof-of-human-min-height-md: calc(var(--size-md-control-height) * 3);
--proof-of-human-min-height-lg: calc(var(--size-lg-control-height) * 3);

@ -2599,7 +2599,47 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'_palette-solid': { value: 'var(--color-primary-solid)', scope: 'host' },
'_palette-element': { value: 'var(--color-primary-element)', scope: 'host' }
},
// Listbox. Consumes TWO shared layers, and the rule of those layers decides
// most of this entry: a consumer does NOT mint `--{c}-{axis}` for an axis the
// layer already owns. So the row rhythm (height, inline/block padding, gap
// and font-size) is NOT here — `lib/list-surface.css` owns it through
// `--list-item-*` / `--list-font-size`, per size, and the recipe adopts it
// through its private slots. Minting `--listbox-item-height` would be the
// parallel vocabulary that doctrine forbids, and it would silently outrank
// the layer for every surface that shares the rhythm (select, combobox,
// command and the menus).
listbox: {
// The surface the rows sit on - this IS the listbox's own.
gap: 'var(--space-1)',
padding: 'var(--space-1)',
radius: 'var(--radius-md)',
'border-width': 'var(--border-width)',
border: 'var(--color-border-default)',
bg: 'var(--color-surface-default)',
fg: 'var(--color-content-primary)',
'font-family': 'var(--style-label-font-family)',
'line-height': 'var(--leading-ui)',
// Generous but bounded; the consumer overrides it inline for a taller list.
'max-block-size': 'min(60vh, 22rem)',
'invalid-border': 'var(--color-threat-element)',
// Rows. The rhythm comes from the layer; what is left is the row's own
// corner and ink.
'item-radius': 'var(--radius-sm)',
'item-fg': 'var(--color-content-primary)',
'disabled-item-fg': 'var(--color-content-muted)',
// The check glyph. Its COLOUR is the palette forward, not a knob.
'item-indicator-size': '0.875rem',
// Groups.
'group-gap': 'var(--space-1)',
'group-label-padding-block': 'var(--space-1)',
'group-label-fg': 'var(--color-content-muted)',
'group-label-font-size': 'var(--size-xs-font-size)',
'group-label-font-weight': 'var(--font-weight-medium)',
'group-label-tracking': 'var(--tracking-caps)',
'_palette-solid': { value: 'var(--color-primary-solid)', scope: 'host' },
'_palette-element': { value: 'var(--color-primary-element)', scope: 'host' }
},

@ -9,10 +9,11 @@
import { compileMorfo } from '$uix/morfo';
import { listboxMorfo } from '@/uix/morfo/components/listbox';
import { getActiveUix } from '$active-uix';
import TokensPanel from '../../lib/TokensPanel.svelte';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'tokens' | 'a11y';
let tab = $state<Tab>('live');
// ── Live state ────────────────────────────────────────────────────────
@ -133,15 +134,13 @@
<div data-uix-eyebrow>Forms · Listbox</div>
<h1 data-uix-page-title>Listbox</h1>
<p data-uix-page-lede>
Embedded selection list (<code>role="listbox"</code>) with single
or multiple selection, roving-tabindex keyboard nav, alphanumeric
typeahead, ARIA-grouped sections, and form participation. Soma
OR-merges <code>disabled</code> / <code>readonly</code> /
Embedded selection list (<code>role="listbox"</code>) with single or multiple selection,
roving-tabindex keyboard nav, alphanumeric typeahead, ARIA-grouped sections, and form
participation. Soma OR-merges <code>disabled</code> / <code>readonly</code> /
<code>required</code> / <code>invalid</code> with
<code>Field.Provider</code> when nested. For atomic options
only — use <code>GridList</code> when rows contain interactive
children, or <code>Select</code> / <code>Combobox</code> when
the surface is a closed combobox-style picker.
<code>Field.Provider</code> when nested. For atomic options only — use <code>GridList</code>
when rows contain interactive children, or <code>Select</code> / <code>Combobox</code> when the
surface is a closed combobox-style picker.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
@ -158,13 +157,18 @@
<a href={listboxMorfo.apg} target="_blank" rel="noreferrer">listbox ↗</a>
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>selected</span> {value.join(', ') || '—'}
<span data-uix-meta-key>selected</span>
{value.join(', ') || '—'}
</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef} style="min-block-size: 14rem; padding: var(--uix-space-3); display: flex; justify-content: center;">
<div
data-uix-stage-area
bind:this={stageRef}
style="min-block-size: 14rem; padding: var(--uix-space-3); display: flex; justify-content: center;"
>
<div style="inline-size: min(20rem, 100%);">
<Listbox
bind:value
@ -226,7 +230,11 @@
<span>focus the listbox + arrow / type to highlight</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
<span
><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
? ' · ' + entry.intent
: ''}</span
>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
@ -249,7 +257,12 @@
<span data-uix-layer-badge="sema">sema</span>
<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 === 'recipe'} onclick={() => (tab = 'recipe')}
>Recipe</button
>
<button data-uix-tab data-active={tab === 'tokens'} onclick={() => (tab = 'tokens')}>
Tokens <span data-uix-tab-count>21</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -269,7 +282,11 @@
<span data-uix-control-label>selectionMode</span>
<span data-uix-chips role="radiogroup">
{#each ['single', 'multiple'] as m (m)}
<button data-uix-chip data-active={selectionMode === m} onclick={() => (selectionMode = m as ListboxSelectionMode)}>{m}</button>
<button
data-uix-chip
data-active={selectionMode === m}
onclick={() => (selectionMode = m as ListboxSelectionMode)}>{m}</button
>
{/each}
</span>
</label>
@ -277,7 +294,11 @@
<span data-uix-control-label>orientation</span>
<span data-uix-chips role="radiogroup">
{#each ['vertical', 'horizontal'] as o (o)}
<button data-uix-chip data-active={orientation === o} onclick={() => (orientation = o as typeof orientation)}>{o}</button>
<button
data-uix-chip
data-active={orientation === o}
onclick={() => (orientation = o as typeof orientation)}>{o}</button
>
{/each}
</span>
</label>
@ -316,10 +337,16 @@
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>variant <span data-uix-control-hint>ControlVariant</span></span>
<span data-uix-control-label
>variant <span data-uix-control-hint>ControlVariant</span></span
>
<span data-uix-chips role="radiogroup">
{#each ['surface', 'outline', 'ghost'] as v (v)}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v as ListboxVariant)}>{v}</button>
<button
data-uix-chip
data-active={variant === v}
onclick={() => (variant = v as ListboxVariant)}>{v}</button
>
{/each}
</span>
</label>
@ -327,7 +354,11 @@
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['xs', 'sm', 'md', 'lg', 'xl'] as s (s)}
<button data-uix-chip data-active={size === s} onclick={() => (size = s as ListboxSize)}>{s}</button>
<button
data-uix-chip
data-active={size === s}
onclick={() => (size = s as ListboxSize)}>{s}</button
>
{/each}
</span>
</label>
@ -335,7 +366,11 @@
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as c (c)}
<button data-uix-chip data-active={color === c} onclick={() => (color = c as ListboxColor)}>{c}</button>
<button
data-uix-chip
data-active={color === c}
onclick={() => (color = c as ListboxColor)}>{c}</button
>
{/each}
</span>
</label>
@ -378,17 +413,62 @@
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">string[]</td><td class="default">[]</td><td>Current selection. Always string[]; single mode keeps at most one entry.</td></tr>
<tr><td class="name">selectionMode</td><td class="type">'single' | 'multiple'</td><td class="default">'single'</td><td>Selection behavior.</td></tr>
<tr><td class="name">loop</td><td class="type">boolean</td><td class="default">true</td><td>Wrap arrow nav at edges.</td></tr>
<tr><td class="name">typeahead</td><td class="type">boolean</td><td class="default">true</td><td>Alphanumeric jump-to-item.</td></tr>
<tr><td class="name">orientation</td><td class="type">'vertical' | 'horizontal'</td><td class="default">'vertical'</td><td>Arrow nav axis.</td></tr>
<tr><td class="name">disabled · readonly · required · invalid</td><td class="type">boolean</td><td class="default">false</td><td>OR-merged with Field.Provider when nested.</td></tr>
<tr><td class="name">name</td><td class="type">string</td><td class="default empty">—</td><td>Form name → hidden inputs per selected value.</td></tr>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ControlVariant: 'surface' | 'outline' | 'ghost'</td><td class="default">'surface'</td><td>Container chrome.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td><td class="default">'md'</td><td>Item height + font-size.</td></tr>
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ColorRole</td><td class="default">'primary'</td><td>Selected-item highlight + focus ring accent.</td></tr>
<tr><td class="name">block <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">false</td><td>Fill container's inline-size.</td></tr>
<tr
><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td
><td class="type">string[]</td><td class="default">[]</td><td
>Current selection. Always string[]; single mode keeps at most one entry.</td
></tr
>
<tr
><td class="name">selectionMode</td><td class="type">'single' | 'multiple'</td><td
class="default">'single'</td
><td>Selection behavior.</td></tr
>
<tr
><td class="name">loop</td><td class="type">boolean</td><td class="default">true</td
><td>Wrap arrow nav at edges.</td></tr
>
<tr
><td class="name">typeahead</td><td class="type">boolean</td><td class="default"
>true</td
><td>Alphanumeric jump-to-item.</td></tr
>
<tr
><td class="name">orientation</td><td class="type">'vertical' | 'horizontal'</td><td
class="default">'vertical'</td
><td>Arrow nav axis.</td></tr
>
<tr
><td class="name">disabled · readonly · required · invalid</td><td class="type"
>boolean</td
><td class="default">false</td><td>OR-merged with Field.Provider when nested.</td></tr
>
<tr
><td class="name">name</td><td class="type">string</td><td class="default empty">—</td
><td>Form name → hidden inputs per selected value.</td></tr
>
<tr
><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">ControlVariant: 'surface' | 'outline' | 'ghost'</td
><td class="default">'surface'</td><td>Container chrome.</td></tr
>
<tr
><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td
><td class="default">'md'</td><td>Item height + font-size.</td></tr
>
<tr
><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">ColorRole</td
><td class="default">'primary'</td><td
>Selected-item highlight + focus ring accent.</td
></tr
>
<tr
><td class="name">block <span data-uix-tag data-kind="eidos">eidos</span></td><td
class="type">boolean</td
><td class="default">false</td><td>Fill container's inline-size.</td></tr
>
</tbody>
</table>
</div>
@ -398,18 +478,25 @@
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">value</td><td class="type">string</td><td class="default empty">required</td><td>Unique identifier in the listbox.</td></tr>
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Skip the item from keyboard nav + click selection.</td></tr>
<tr
><td class="name">value</td><td class="type">string</td><td class="default empty"
>required</td
><td>Unique identifier in the listbox.</td></tr
>
<tr
><td class="name">disabled</td><td class="type">boolean</td><td class="default"
>false</td
><td>Skip the item from keyboard nav + click selection.</td></tr
>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ItemIndicator · Group · GroupLabel</div>
<p data-uix-section-desc style="margin-top: 0.25rem;">
<code>ItemIndicator</code> renders a ✓ glyph inside selected items
(same recipe as menu-indicator partial). <code>Group</code> wraps a
logical section; <code>GroupLabel</code> is the muted heading
(ARIA-linked via <code>aria-labelledby</code>).
<code>ItemIndicator</code> renders a ✓ glyph inside selected items (same recipe as
menu-indicator partial). <code>Group</code> wraps a logical section; <code>GroupLabel</code>
is the muted heading (ARIA-linked via <code>aria-labelledby</code>).
</p>
</section>
{/if}
@ -428,9 +515,21 @@
<tbody>
<tr><td class="name">name</td><td class="type">"{listboxMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{listboxMorfo.kebab}"</td></tr>
<tr><td class="name">scope</td><td class="type">[{listboxMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
<tr><td class="name">apg</td><td><a href={listboxMorfo.apg} target="_blank" rel="noreferrer">{listboxMorfo.apg}</a></td></tr>
<tr><td class="name">parts.length</td><td class="default">{listboxMorfo.parts.length}</td></tr>
<tr
><td class="name">scope</td><td class="type"
>[{listboxMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td
></tr
>
<tr
><td class="name">apg</td><td
><a href={listboxMorfo.apg} target="_blank" rel="noreferrer">{listboxMorfo.apg}</a
></td
></tr
>
<tr
><td class="name">parts.length</td><td class="default">{listboxMorfo.parts.length}</td
></tr
>
<tr><td class="name">events.length</td><td class="default">{events.length}</td></tr>
</tbody>
</table>
@ -439,7 +538,13 @@
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
<thead
><tr
><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th
>States</th
><th>Optional</th></tr
></thead
>
<tbody>
{#each partsList as part (part.kebab)}
<tr>
@ -459,12 +564,23 @@
<div data-uix-subsection-head>Events declaration</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th>target</th></tr></thead>
<thead
><tr
><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th
>target</th
></tr
></thead
>
<tbody>
{#each events as action (action.name)}
{@const sem = action.semantic}
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
{@const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}` : '—'}
{@const intentStr =
typeof intentDecl === 'string'
? intentDecl
: intentDecl
? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}`
: '—'}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
@ -487,14 +603,19 @@
· events
</h2>
<p data-uix-section-desc>
Two events: <code>commit-select</code> (intent <code>affirm</code>)
when an item enters the selection; <code>commit-unselect</code>
Two events: <code>commit-select</code> (intent <code>affirm</code>) when an item enters the
selection; <code>commit-unselect</code>
(intent <code>neutral</code>) when it leaves.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th></tr></thead>
<thead
><tr
><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th
></tr
></thead
>
<tbody>
{#each events as action (action.name)}
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
@ -509,7 +630,8 @@
<button
data-uix-play
onclick={() => {
const target = (stageRef?.querySelector('[data-listbox-item]') ?? stageRef) as HTMLElement | null;
const target = (stageRef?.querySelector('[data-listbox-item]') ??
stageRef) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
@ -517,8 +639,8 @@
target,
...(effectiveIntent ? { intent: effectiveIntent } : {})
});
}}
>▶ play</button>
}}>▶ play</button
>
</td>
</tr>
{/each}
@ -531,37 +653,80 @@
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>Selectors at <code>src/uix/eidos/components/listbox/listbox.css</code>.</p>
<p data-uix-section-desc>
Selectors at <code>src/uix/eidos/components/listbox/listbox.css</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>What it paints</th></tr></thead>
<tbody>
<tr><td class="name">[data-listbox]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Container — flex column, gap, border, padding.</td></tr>
<tr><td class="name">[data-listbox][data-size]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Item height + font-size scale.</td></tr>
<tr><td class="name">[data-listbox][data-variant='outline' | 'ghost']</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Container chrome (border + bg).</td></tr>
<tr><td class="name">[data-listbox][data-color]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Accent palette for selected highlight + focus ring.</td></tr>
<tr><td class="name">[data-listbox][data-invalid]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Threat-coloured border override.</td></tr>
<tr><td class="name">[data-listbox-item]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Option row — flex, padding, hover/highlight states.</td></tr>
<tr><td class="name">[data-listbox-item][data-selected]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Selected-state band (accent-soft fill).</td></tr>
<tr><td class="name">[data-listbox-item-indicator]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Hidden by default; revealed inside selected items.</td></tr>
<tr><td class="name">[data-listbox-group-label]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Muted uppercase section heading.</td></tr>
<tr
><td class="name">[data-listbox]</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Container — flex column, gap, border, padding.</td></tr
>
<tr
><td class="name">[data-listbox][data-size]</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Item height + font-size scale.</td></tr
>
<tr
><td class="name">[data-listbox][data-variant='outline' | 'ghost']</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Container chrome (border + bg).</td></tr
>
<tr
><td class="name">[data-listbox][data-color]</td><td
><span data-uix-tag data-kind="eidos">eidos</span></td
><td>Accent palette for selected highlight + focus ring.</td></tr
>
<tr
><td class="name">[data-listbox][data-invalid]</td><td
><span data-uix-tag data-kind="soma">morfo</span></td
><td>Threat-coloured border override.</td></tr
>
<tr
><td class="name">[data-listbox-item]</td><td
><span data-uix-tag data-kind="soma">morfo</span></td
><td>Option row — flex, padding, hover/highlight states.</td></tr
>
<tr
><td class="name">[data-listbox-item][data-selected]</td><td
><span data-uix-tag data-kind="soma">morfo</span></td
><td>Selected-state band (accent-soft fill).</td></tr
>
<tr
><td class="name">[data-listbox-item-indicator]</td><td
><span data-uix-tag data-kind="soma">morfo</span></td
><td>Hidden by default; revealed inside selected items.</td></tr
>
<tr
><td class="name">[data-listbox-group-label]</td><td
><span data-uix-tag data-kind="soma">morfo</span></td
><td>Muted uppercase section heading.</td></tr
>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="listbox" stage={stageRef ?? undefined} />
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
Follows the APG <a href={listboxMorfo.apg} target="_blank" rel="noreferrer">listbox pattern</a>.
Container is <code>role="listbox"</code>; each Item is
<code>role="option"</code> with <code>aria-selected</code>;
Group is <code>role="group"</code> with
Follows the APG <a href={listboxMorfo.apg} target="_blank" rel="noreferrer"
>listbox pattern</a
>. Container is <code>role="listbox"</code>; each Item is
<code>role="option"</code> with <code>aria-selected</code>; Group is
<code>role="group"</code>
with
<code>aria-labelledby</code> pointing to GroupLabel.
<code>aria-multiselectable</code> is stamped automatically
in <code>multiple</code> mode.
<code>aria-multiselectable</code> is stamped automatically in <code>multiple</code> mode.
</p>
<div data-uix-subsection-head>Keyboard</div>
@ -569,12 +734,34 @@
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>ArrowDown</span> / <span data-uix-kbd>ArrowUp</span></td><td>Move highlight ±1 (vertical orientation).</td></tr>
<tr><td><span data-uix-kbd>ArrowRight</span> / <span data-uix-kbd>ArrowLeft</span></td><td>Same but for horizontal orientation; RTL inverts.</td></tr>
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td>First / last item.</td></tr>
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Toggle selection of highlighted item.</td></tr>
<tr><td><span data-uix-kbd>Ctrl/Cmd+A</span></td><td>Select all (multiple mode only).</td></tr>
<tr><td>Letter / digit</td><td>Typeahead — focus next item starting with that character.</td></tr>
<tr
><td><span data-uix-kbd>ArrowDown</span> / <span data-uix-kbd>ArrowUp</span></td><td
>Move highlight ±1 (vertical orientation).</td
></tr
>
<tr
><td><span data-uix-kbd>ArrowRight</span> / <span data-uix-kbd>ArrowLeft</span></td
><td>Same but for horizontal orientation; RTL inverts.</td></tr
>
<tr
><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td
>First / last item.</td
></tr
>
<tr
><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td
>Toggle selection of highlighted item.</td
></tr
>
<tr
><td><span data-uix-kbd>Ctrl/Cmd+A</span></td><td>Select all (multiple mode only).</td
></tr
>
<tr
><td>Letter / digit</td><td
>Typeahead — focus next item starting with that character.</td
></tr
>
</tbody>
</table>
</div>
@ -585,14 +772,37 @@
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">listbox</td><td>role</td><td class="type">"listbox"</td></tr>
<tr><td class="name">listbox</td><td>aria-multiselectable</td><td class="type">"true" (multiple mode)</td></tr>
<tr><td class="name">listbox</td><td>aria-orientation</td><td class="type">"horizontal" | "vertical"</td></tr>
<tr><td class="name">listbox</td><td>aria-disabled / aria-readonly / aria-required / aria-invalid</td><td class="type">flags</td></tr>
<tr
><td class="name">listbox</td><td>aria-multiselectable</td><td class="type"
>"true" (multiple mode)</td
></tr
>
<tr
><td class="name">listbox</td><td>aria-orientation</td><td class="type"
>"horizontal" | "vertical"</td
></tr
>
<tr
><td class="name">listbox</td><td
>aria-disabled / aria-readonly / aria-required / aria-invalid</td
><td class="type">flags</td></tr
>
<tr><td class="name">item</td><td>role</td><td class="type">"option"</td></tr>
<tr><td class="name">item</td><td>aria-selected</td><td class="type">"true" | "false"</td></tr>
<tr><td class="name">item</td><td>aria-disabled</td><td class="type">"true" (disabled)</td></tr>
<tr
><td class="name">item</td><td>aria-selected</td><td class="type">"true" | "false"</td
></tr
>
<tr
><td class="name">item</td><td>aria-disabled</td><td class="type"
>"true" (disabled)</td
></tr
>
<tr><td class="name">group</td><td>role</td><td class="type">"group"</td></tr>
<tr><td class="name">group</td><td>aria-labelledby</td><td class="type">{`<group-label id>`}</td></tr>
<tr
><td class="name">group</td><td>aria-labelledby</td><td class="type"
>{`<group-label id>`}</td
></tr
>
</tbody>
</table>
</div>

Loading…
Cancel
Save

Powered by TurnKey Linux.