uix(onion-menu): temable — 29 % → 84 %, y una regla que el navegador DESCARTA

7 claves nuevas (12 → 19) y 5 literales anotados. Lo que queda fuera son TRES
filas y ninguna es deuda de tokenización: una es canal de valor y dos viven en
una regla inválida.

Acuñado el color del ANILLO DEL SECTOR —uno para los dos estados, porque el
propio comentario del CSS dice que el hover usa «el mismo color que el anillo de
selección, más claro» y lo que los separa es la opacidad, que ya tenía token—,
la tipografía de la etiqueta, el radio y la tinta del disparador, y las dos
piezas del glifo `+`→`×`. Dos limpiezas de paso: un
`var(--style-label-font-family, var(--style-label-font-family))` —respaldo de sí
mismo, el defecto que ya se cazó en context-menu— y dos `var(--radius-full, …)`
con un respaldo que no puede ocurrir.

Cinco literales pasan a excepción POR IDENTIDAD: dos `opacity: 1` que DESHACEN
un atenuado (sin atenuar es 1 por definición) y la caja del icono.

⚠ `.onion-menu-icon :global(svg)` NO EXISTE. `:global()` es un envoltorio de
SVELTE y esto es un CSS plano: el selector es inválido y el navegador descarta
la regla entera. Medido: el glifo computa 14px —el tamaño propio del Icon— y el
`70 %` que el fichero cree pintar no pinta. Acuñé un `icon-glyph-size`, el
centinela lo delató y lo RETIRÉ; los literales se quedan con el defecto escrito
encima, porque arreglar el selector empieza a pintar el 70 % y MUEVE PÍXEL.
Mismo bug en `timeline.css:331`; `image-picker.css` ya documentaba la trampa
—la lección estaba escrita y se repitió—. → §13.

Canal de valor: el fondo del disparador lo escribe el componente INLINE desde el
motor de color, como los rellenos de cada sector. No es superficie de tema.

TRES CEGUERAS DEL INSTRUMENTO, dos nuevas y genéricas:

1. EL GUARD CERRABA LA SUPERFICIE. Este menú nace ABIERTO y el clic de «abrir»
   lo cerraba: 12 de 20 tokens leían muertos. Ahora, si hay `openMarker` y ya
   casa, no se pulsa nada (8 → 14 de golpe).
2. Sus tripas cuelgan de CLASES, no de `data-onion-*`: sonda y guard las miden
   con `extraNodes` — 37 nodos en reposo contra los 5 con atributo.
3. Congelar las transiciones NO BASTA cuando el nodo está ANIMADO:
   `rim-glow-opacity` leía 0,482956 —el valor animado— y parecía muerto; con la
   animación congelada computa 0,45 y sigue al token. → §13.

Los cuatro tokens del rim son del look `luminous`, que la demo del componente no
renderiza (es una prop de Svelte que emite OTRO marcado, así que no hay atributo
que forzar): medidos en `/active/docs/agnt`, que sí lo usa.

Gates: sonda 0 diffs en 7.328 valores × 8 estados (37 nodos) · centinela 14/19
con las cinco adjudicadas · censo 84 % · `component:audit` PASS · suite eidos
con el rojo conocido ajeno · `rtl:check` 0 · `docs:check` 0 · `check` sin
errores propios · capturas de reposo, sector en hover y disparador.

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

@ -10,8 +10,8 @@
- **Medido**: 2026-08-22 · **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**: **63%** — 2905 de 4620 knobs pasan por un token público del componente
- **Reparto**: público 2905 · privado 338 · global 892 · literal 485 · sistema transversal 509 · excepción firmada 62 _(los dos últimos, fuera del ratio)_
- **Alcance global**: **63%** — 2914 de 4615 knobs pasan por un token público del componente
- **Reparto**: público 2914 · privado 338 · global 885 · literal 478 · sistema transversal 509 · excepción firmada 67 _(los dos últimos, fuera del ratio)_
- **Sin token público propio**: 24 · **alcance < 20 %**: 14 · **alcance 100 %**: 17 · **con eje `size`**: 58
## Cómo se lee
@ -87,12 +87,12 @@ theming §6.7 lo pone delante (`hover-bg`). Las fichas proponen la forma
| 12 | [sidebar](./sidebar.md) | 19 | 55% | 45 | 50 |
| 13 | [mockup](./mockup.md) | 18 | 38% | 29 | 0 |
| 14 | [drawer](./drawer.md) | 17 | 72% | 62 | 54 |
| 15 | [onion-menu](./onion-menu.md) | 17 | 29% | 27 | 12 |
| 16 | [radio-group](./radio-group.md) | 17 | 77% | 75 | 61 |
| 17 | [popover](./popover.md) | 16 | 67% | 51 | 46 |
| 18 | [form](./form.md) | 15 | 71% | 52 | 54 |
| 19 | [tag-group](./tag-group.md) | 15 | 65% | 46 | 91 |
| 20 | [file-upload](./file-upload.md) | 14 | 76% | 61 | 98 |
| 15 | [radio-group](./radio-group.md) | 17 | 77% | 75 | 61 |
| 16 | [popover](./popover.md) | 16 | 67% | 51 | 46 |
| 17 | [form](./form.md) | 15 | 71% | 52 | 54 |
| 18 | [tag-group](./tag-group.md) | 15 | 65% | 46 | 91 |
| 19 | [file-upload](./file-upload.md) | 14 | 76% | 61 | 98 |
| 20 | [image](./image.md) | 14 | 60% | 35 | 22 |
## Sin ningún token público propio (24)
@ -129,7 +129,6 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [callout](./callout.md) | 27% | 15 | 4 | 3 | 7 | 1 | 0 | 28 | – |
| [date-range-field](./date-range-field.md) | 29% | 8 | 2 | 0 | 4 | 1 | 1 | 3 | – |
| [time-range-field](./time-range-field.md) | 29% | 8 | 2 | 0 | 4 | 1 | 1 | 3 | – |
| [onion-menu](./onion-menu.md) | 29% | 27 | 7 | 0 | 8 | 9 | 3 | 12 | – |
| [empty-state](./empty-state.md) | 29% | 17 | 5 | 7 | 5 | 0 | 0 | 6 | y |
| [banner](./banner.md) | 30% | 10 | 3 | 5 | 1 | 1 | 0 | 42 | y |
| [scroll-frames](./scroll-frames.md) | 33% | 9 | 3 | 1 | 1 | 4 | 0 | 4 | – |
@ -205,6 +204,7 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [select](./select.md) | 83% | 72 | 54 | 5 | 3 | 3 | 7 | 101 | y |
| [float-panel](./float-panel.md) | 84% | 53 | 41 | 3 | 0 | 5 | 4 | 40 | – |
| [calendar](./calendar.md) | 84% | 71 | 57 | 7 | 0 | 4 | 3 | 74 | – |
| [onion-menu](./onion-menu.md) | 84% | 22 | 16 | 0 | 1 | 2 | 3 | 19 | – |
| [chat-message](./chat-message.md) | 84% | 84 | 65 | 0 | 6 | 6 | 7 | 75 | y |
| [grid-list](./grid-list.md) | 85% | 31 | 22 | 2 | 1 | 1 | 5 | 38 | – |
| [combobox](./combobox.md) | 85% | 97 | 76 | 4 | 3 | 6 | 8 | 106 | – |

@ -5,52 +5,44 @@
> Vista de conjunto: [README](./README.md) · método y protocolo:
> [`PLAN-theming.md`](../../process/PLAN-theming.md) §1, §2, §7.
- **Medido**: 2026-08-22 · **Alcance**: **29%** — 7 de 24 knobs por token público
- **Knobs de apariencia**: 27 — público 7 · privado 0 · global 8 · literal 9 · sistema 3 · excepción 0 _(los dos últimos, fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 12 pública(s) — `trigger-lift`, `trigger-shadow`, `sector-shadow`, `sector-ring-width`, `sector-hover-ring-width`, `glyph-bar-thickness`, `muted-opacity`, `hover-ring-opacity`, `rim-width`, `rim-glow-width`, `rim-glow-opacity`, `rim-glow-blur`
- **Medido**: 2026-08-22 · **Alcance**: **84%** — 16 de 19 knobs por token público
- **Knobs de apariencia**: 22 — público 16 · privado 0 · global 1 · literal 2 · sistema 3 · excepción 5 _(los dos últimos, fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 19 pública(s) — `trigger-lift`, `trigger-shadow`, `trigger-radius`, `trigger-fg`, `sector-shadow`, `sector-ring-fg`, `sector-ring-width`, `sector-hover-ring-width`, `label-font-family`, `label-font-weight`, `glyph-size`, `glyph-bar-thickness`, `glyph-bar-radius`, `muted-opacity`, `hover-ring-opacity`, `rim-width`, `rim-glow-width`, `rim-glow-opacity`, `rim-glow-blur`
- **Eje `size`**: no · **ficheros**: `onion-menu.css`
## 1. Knobs fuera de alcance
### 1.1 Directo a primitivo global (8)
### 1.1 Directo a primitivo global (1)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `onion-menu.css:123` | `.onion-menu-sector-group.is-highlight .onion-menu-sector` | `stroke` | `var(--color-content-on-solid)` |
| 2 | `onion-menu.css:139` | `.onion-menu-sector-group:not(.is-disabled):not(.is-highlight):hover .onion-menu-sector` | `stroke` | `var(--color-content-on-solid)` |
| 3 | `onion-menu.css:152` | `.onion-menu-label` | `font-family` | `var(--style-label-font-family, var(--style-label-font-family))` |
| 4 | `onion-menu.css:153` | `.onion-menu-label` | `font-weight` | `var(--style-label-font-weight, 500)` |
| 5 | `onion-menu.css:193` | `.onion-menu-trigger` | `border-radius` | `var(--radius-full, 50%)` |
| 6 | `onion-menu.css:195` | `.onion-menu-trigger` | `color` | `var(--color-content-on-solid)` |
| 7 | `onion-menu.css:196` | `.onion-menu-trigger` | `background` | `var(--_onion-trigger-fill)` |
| 8 | `onion-menu.css:245` | `.onion-menu-trigger-glyph::before, .onion-menu-trigger-glyph::after` | `border-radius` | `var(--radius-full, 1px)` |
| 1 | `onion-menu.css:202` | `.onion-menu-trigger` | `background` | `var(--_onion-trigger-fill)` |
### 1.2 A través de un privado (0)
_Ninguno._
### 1.3 Literales (9)
### 1.3 Literales (2)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `onion-menu.css:86` | `[data-look='luminous'] g:has([data-onion-menu-item]:hover) [data-onion-rim='glow']` | `opacity` | `1` |
| 2 | `onion-menu.css:146` | `.onion-menu-sector-group:not(.is-disabled):hover .onion-menu-label, .onion-menu-sector-group:not(.is-disabled):hover .onion-menu-icon` | `opacity` | `1` |
| 3 | `onion-menu.css:160` | `.onion-menu-icon` | `inline-size` | `100%` |
| 4 | `onion-menu.css:161` | `.onion-menu-icon` | `block-size` | `100%` |
| 5 | `onion-menu.css:165` | `.onion-menu-icon` | `line-height` | `1` |
| 6 | `onion-menu.css:174` | `.onion-menu-icon :global(svg)` | `inline-size` | `70%` |
| 7 | `onion-menu.css:175` | `.onion-menu-icon :global(svg)` | `block-size` | `70%` |
| 8 | `onion-menu.css:232` | `.onion-menu-trigger-glyph` | `inline-size` | `42%` |
| 9 | `onion-menu.css:233` | `.onion-menu-trigger-glyph` | `block-size` | `42%` |
| 1 | `onion-menu.css:180` | `.onion-menu-icon :global(svg)` | `inline-size` | `70%` |
| 2 | `onion-menu.css:181` | `.onion-menu-icon :global(svg)` | `block-size` | `70%` |
### 1.4 Excepciones firmadas (0) — fuera del ratio
### 1.4 Excepciones firmadas (5) — fuera del ratio
Literales que llevan su anotación `/* literal: <razón> */` en la propia
declaración: la válvula de recipe-contract §3, la misma que honra
`component-audit`. **Una desviación firmada no es deuda** — se listan para que la
razón se lea, no para acuñarlas.
_Ninguno._
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `onion-menu.css:86` | `[data-look='luminous'] g:has([data-onion-menu-item]:hover) [data-onion-rim='glow']` | `opacity` | `1` |
| 2 | `onion-menu.css:146` | `.onion-menu-sector-group:not(.is-disabled):hover .onion-menu-label, .onion-menu-sector-group:not(.is-disabled):hover .onion-menu-icon` | `opacity` | `1` |
| 3 | `onion-menu.css:160` | `.onion-menu-icon` | `inline-size` | `100%` |
| 4 | `onion-menu.css:161` | `.onion-menu-icon` | `block-size` | `100%` |
| 5 | `onion-menu.css:165` | `.onion-menu-icon` | `line-height` | `1` |
## 2. Sistema transversal (3) — informativo, fuera del ratio
Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
@ -59,7 +51,7 @@ Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
| ---: | --- | --- | --- | --- |
| 1 | `onion-menu.css:50` | `.onion-menu-sector[data-disabled]` | `opacity` | `var(--opacity-muted, 0.55)` |
| 2 | `onion-menu.css:55` | `.onion-menu-sector:focus-visible` | `stroke` | `var(--focus-ring-color, currentColor)` |
| 3 | `onion-menu.css:224` | `.onion-menu-trigger:focus-visible` | `outline` | `2px solid var(--focus-ring-color, currentColor)` |
| 3 | `onion-menu.css:230` | `.onion-menu-trigger:focus-visible` | `outline` | `2px solid var(--focus-ring-color, currentColor)` |
## 3. Privados de la receta — ¿de dónde sale su valor?
@ -67,7 +59,7 @@ _La receta no declara privados propios en su CSS._
## 4. Propuesta de corrección
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (9)
### 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
@ -77,14 +69,8 @@ no distingue lo que debería — se marca `⚠`.
| token (`--onion-menu-…`) | scope TSC | valor propuesto | usos |
| --- | --- | --- | ---: |
| `width` | `root` | ⚠ `70%` / `42%` | 2 |
| `height` | `root` | ⚠ `70%` / `42%` | 2 |
| `radius` | `root` | ⚠ `var(--radius-full, 50%)` / `var(--radius-full, 1px)` | 2 |
| `stroke` | `root` | `var(--color-content-on-solid)` | 1 |
| `hover-stroke` | `root` | `var(--color-content-on-solid)` | 1 |
| `font-family` | `root` | `var(--style-label-font-family, var(--style-label-font-family))` | 1 |
| `font-weight` | `root` | `var(--style-label-font-weight, 500)` | 1 |
| `fg` | `root` | `var(--color-content-on-solid)` | 1 |
| `width` | `root` | `70%` | 1 |
| `height` | `root` | `70%` | 1 |
| `bg` | `root` | `var(--_onion-trigger-fill)` | 1 |
### 4.2 Sin nombre mecánico (5)
@ -104,6 +90,61 @@ no distingue lo que debería — se marca `⚠`.
<!-- veredicto:start -->
_(pendiente — lo escribe el autor; se conserva al regenerar)_
**EJECUTADO 2026-08-23 — 29 % → 84 %**, 7 claves nuevas (12 → 19) y 5 literales
ANOTADOS. Lo que queda fuera son TRES filas, y ninguna es deuda de tokenización:
una es canal de valor y dos viven en una regla que el navegador DESCARTA.
Lo acuñado: el color del **anillo del sector** —UNO para los dos estados, porque
el propio comentario del CSS dice que el hover usa «el mismo color que el anillo
de selección, más claro», y lo que los distingue es la opacidad que ya tenía
token—, la **tipografía de la etiqueta** (familia y peso), el **radio y la tinta
del disparador**, y las dos piezas del glifo `+`→`×` (su proporción y el radio
de las barras).
**Dos limpiezas de paso**: `var(--style-label-font-family, var(--style-label-font-family))`
era respaldo de sí mismo (el mismo defecto que se cazó en context-menu), y
`var(--radius-full, 50%)` / `var(--radius-full, 1px)` arrastraban respaldos que
no puede haber — el token existe.
**Cinco literales pasan a excepción por IDENTIDAD**: dos `opacity: 1` que
DESHACEN un atenuado (el de la marca muted y el del brillo del rim: «sin
atenuar» es 1 por definición) y la caja del icono (`100%` × 2 y `line-height: 1`).
**⚠ Una regla del recipe NO EXISTE: `.onion-menu-icon :global(svg)`.**
`:global()` es un envoltorio de SVELTE y esto es un CSS plano, así que el
selector es inválido y el navegador descarta la regla ENTERA. Medido: el glifo
computa 14px (el tamaño propio del Icon), y el `70%` que el fichero cree estar
pintando no pinta. Acuñé un `icon-glyph-size` y el centinela lo delató; lo
RETIRÉ y dejé los literales con el defecto escrito encima, porque arreglar el
selector empieza a pintar el 70 % y **mueve píxel**. Mismo bug en
`timeline.css:331`; `image-picker.css` documenta la trampa desde antes. → §13.
**Canal de valor**: el fondo del disparador (`--_onion-trigger-fill`) lo escribe
el componente INLINE desde el motor de color (`onion-menu.svelte:590`), como los
rellenos de cada sector. Ningún token gana a un estilo inline: no es superficie
de tema.
**Los 12 rojos del guard**: 6 los destapó el instrumento y 5 se midieron a mano.
- **El guard CERRABA el menú.** Esta superficie nace ABIERTA, y el clic de
«abrir» la cerraba: 12 de 20 tokens leían muertos. Ahora, si hay `openMarker`
y ya casa, no se pulsa nada. Con eso, 8 → 14.
- **El instrumento no veía las tripas**: sectores, etiquetas, iconos y glifo
cuelgan de CLASES, no de `data-onion-*` — sonda y guard miden ahora esos nodos
(37 en reposo, contra los 5 con atributo).
- `trigger-lift` **transiciona**: leído en el acto devuelve el valor viejo.
Congelado, alcanza.
- Los cuatro del rim son del look `luminous`, **que la demo del componente no
renderiza** (es una prop de Svelte que emite OTRO marcado, así que no hay
atributo que forzar): medidos en `/active/docs/agnt`.
- `rim-glow-opacity` enseñó algo nuevo: **el rim está bajo una ANIMACIÓN**, y
congelando sólo las transiciones se lee el valor animado (0,482956) y el token
parece muerto. Congelando también la animación computa 0,45 y sigue al token.
Verificación: **diff de computed = 0** sobre 7.328 valores en 8 estados (37
nodos) · centinela **14/19** con las cinco adjudicadas · censo 84 % ·
`component:audit` PASS · suite eidos con el rojo conocido ajeno · `rtl:check` 0 ·
`docs:check` 0 · `check` sin errores propios · capturas de reposo, sector en
hover y disparador.
<!-- veredicto:end -->

@ -1157,5 +1157,24 @@ Lo que `chart` destapó (2026-08-22):
token de placeholder lo sufrirá si su demo arranca con el input vacío. El guard
podría sembrar un placeholder antes de esa medida.
- **⚠ DOS recetas escriben `:global()` en un CSS PLANO, y el navegador descarta
la regla entera** (medido 2026-08-23): `onion-menu.css` (`.onion-menu-icon
:global(svg)`, que cree pintar el glifo al 70 % de su caja — computa 14px, el
tamaño propio del Icon) y `timeline.css:331` (`[data-timeline-marker]
:global(svg)`). `:global()` es un envoltorio de SVELTE: fuera de un
`<style>` de componente el selector es inválido. `image-picker.css` ya
documenta la trampa en su propio comentario, así que la lección estaba escrita
y se repitió. Arreglar el selector EMPIEZA A PINTAR lo que la regla dice, así
que mueve píxel en los dos y es firma. Mientras tanto, los literales se quedan
con el defecto escrito encima y sin token (uno acuñado se retiró porque
mentía).
- **⚠ Congelar las transiciones no basta cuando el nodo está ANIMADO** (medido
en `onion-menu`, 2026-08-23). `rim-glow-opacity` leía 0,482956 —el valor que
la animación tenía en ese instante— y el token parecía muerto; con la
animación congelada también, computa 0,45 y sigue al token. El guard no puede
congelar animaciones en general (Presence necesita la suya para montar), así
que es una medida A MANO cuando el nodo pulsa.
**Deps**: ninguna. Son mejoras del instrumental del eje, ejecutables cuando
estorben.

@ -455,6 +455,43 @@ de commit. Sin artefacto, el paso no se ha hecho.
## 8. Registro
- 2026-08-23 — **`onion-menu` 29 % → 84 %** (7 claves + 5 literales anotados), y
**una regla del recipe que el navegador DESCARTA**. Global 63 %.
Acuñados el color del anillo del sector —UNO para los dos estados: el propio
CSS dice que el hover usa «el mismo color que el anillo de selección, más
claro», y lo que los separa es la opacidad que ya tenía token—, la tipografía
de la etiqueta, el radio y la tinta del disparador y las dos piezas del glifo
`+`→`×`. Dos limpiezas de paso: un `var(--style-label-font-family, var(--style-label-font-family))`
—respaldo de sí mismo, el defecto de context-menu— y dos `var(--radius-full, …)`
con respaldo imposible.
**⚠ `.onion-menu-icon :global(svg)` NO EXISTE**: `:global()` es un envoltorio
de Svelte y esto es un CSS plano, así que el selector es inválido y la regla
entera se descarta. Medido: el glifo computa 14px (el tamaño propio del Icon)
y el `70 %` que el fichero cree pintar no pinta. Acuñé `icon-glyph-size`, el
centinela lo delató y lo RETIRÉ: arreglar el selector mueve píxel. Mismo bug
en `timeline.css:331`; `image-picker.css` ya documentaba la trampa. → §13.
**Tres cegueras del instrumento, dos nuevas y genéricas**:
1. **El guard CERRABA la superficie.** Este menú nace ABIERTO y el clic de
«abrir» lo cerraba — 12 de 20 tokens muertos. Ahora, si hay `openMarker` y
ya casa, no se pulsa nada (8 → 14 de golpe).
2. Sus tripas cuelgan de CLASES, no de `data-onion-*`: sonda y guard las miden
con `extraNodes` (37 nodos en reposo contra 5).
3. **Congelar las transiciones no basta cuando el nodo está ANIMADO**:
`rim-glow-opacity` leía 0,482956 —el valor animado— y parecía muerto;
congelando también la animación computa 0,45 y sigue al token.
Los cuatro tokens del rim son del look `luminous`, **que la demo del
componente no renderiza** (prop de Svelte que emite otro marcado, no hay
atributo que forzar): medidos en `/active/docs/agnt`.
Gates: sonda **0 diffs** en 7.328 valores × 8 estados · centinela 14/19 con
las cinco adjudicadas · censo 84 % · audit PASS · suite con el rojo conocido
ajeno · rtl 0 · docs 0 · `check` sin errores propios · capturas.
- 2026-08-23 — **`field` 73 % → 98 %** (8 claves + 8 literales ANOTADOS) **y sus
18 rojos, medidos**. Global **63 %**. El 2 % que falta es el puente de paleta
(THM-2), que no es deuda.

@ -48,6 +48,8 @@ const PROPS = [
'opacity',
'fill',
'stroke',
'strokeWidth',
'strokeOpacity',
'outlineColor',
'outlineWidth',
'filter',
@ -70,7 +72,11 @@ const SIZES = ['xs', 'sm', 'md', 'lg', 'xl'];
* matches join the measured set.
*/
const EXTRA_NODES: Record<string, string> = {
prose: '[data-prose] *'
prose: '[data-prose] *',
// Its SVG innards hook on CLASSES, not on `data-onion-*`: without this the
// filter sees the five attribute nodes and misses every sector, label, icon
// and glyph — the whole surface the recipe paints.
'onion-menu': '.onion-menu-sector, .onion-menu-label, .onion-menu-icon, .onion-menu-icon svg, .onion-menu-trigger, .onion-menu-trigger-glyph'
};
/**

@ -612,5 +612,16 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'segment-height': 'a FAMILY token that field OWNS and the pickers CONSUME - nothing on the field routes reads it - and it is RESOLVED (declared per [data-field][data-size]), so a :root write loses BY DESIGN: a theme moves the coordinate. Measured on /time-picker: from :root nothing, from the field host 28px -> 1234px, and moving --field-control-height-md 28px -> 992px',
'transition-duration': 'the guard freezes transitions to measure and this token IS the transition; measured unfrozen -> reaches (0.12s -> 11.5s)',
'transition-ease': 'same instrument limit; measured unfrozen -> reaches (cubic-bezier(0.4, 0, 0.2, 1) -> steps(7))'
},
'onion-menu': {
// Measured 2026-08-23. The lift needs the transitions frozen; the four rim
// tokens belong to the `luminous` look, which the component demo does not
// render at all (it is a Svelte prop that emits DIFFERENT markup, so no
// attribute can be forced): measured on /active/docs/agnt, which uses it.
'trigger-lift': 'the lift TRANSITIONS, so an immediate read after the write returns the old value; hovered with transitions frozen -> reaches (translate 0 -2px -> 0 -123px)',
'rim-width': 'the luminous look is not rendered by the component demo; measured on /active/docs/agnt -> reaches (2px -> 17px)',
'rim-glow-width': 'same look, same route -> reaches (9px -> 17px)',
'rim-glow-blur': 'same look, same route -> reaches (blur(4px) -> blur(13px))',
'rim-glow-opacity': 'same look, and the rim is under an ANIMATION: with only the transitions frozen the read returns the animated value (0.482956) and the token looks dead. With animation frozen too it computes 0.45 and follows -> reaches (0.45 -> 0.137)'
}
};

@ -172,6 +172,9 @@ const COMPONENT_OVERRIDES: Record<
// no attribute of its own: without this the guard measures ONE node (the root)
// and 27 of its 37 tokens read dead. Measured 2026-08-22.
prose: { extraNodes: '[data-prose] *' },
// Same class-hook blindness as prose, inside an SVG: sectors, labels, icons
// and the trigger glyph carry no `data-onion-*`.
'onion-menu': { openMarker: '.onion-menu-sector', extraNodes: '.onion-menu-sector, .onion-menu-label, .onion-menu-icon, .onion-menu-icon svg, .onion-menu-trigger, .onion-menu-trigger-glyph' },
// The shared control trigger (the icon affordance a picker drops inside the
// field) is mounted by NO field demo: /field has plain inputs and the
// segmented fields have segments, not triggers. It lives on the pickers, so
@ -328,12 +331,20 @@ async function main() {
// focus rule repaints the rest-state chrome (the input-border false
// negative). Popovers dismiss on outside pointerdown, not on blur, so the
// open state survives.
for (const sel of [
...(override.openWith ?? []),
`[data-${component}-trigger]`,
`[data-${component}-input]`,
`[data-${component}-stop]`
]) {
// A surface that boots OPEN must not be clicked SHUT: onion-menu renders its
// sectors from the start, and the opening click toggled the whole recipe out
// of the document (12 of its 20 tokens read dead, 2026-08-23).
const alreadyOpen = override.openMarker
? (await page.locator(override.openMarker).count()) > 0
: false;
for (const sel of alreadyOpen
? []
: [
...(override.openWith ?? []),
`[data-${component}-trigger]`,
`[data-${component}-input]`,
`[data-${component}-stop]`
]) {
const el = page.locator(sel).first();
if (await el.count()) {
try {

@ -83,7 +83,7 @@
}
[data-look='luminous'] g:has([data-onion-menu-item]:hover) [data-onion-rim='glow'] {
opacity: 1;
opacity: 1; /* literal: undoes the rest dimming above — full strength is 1 by definition */
}
/* Commit — the ring answers. The generic `commit` firma animates `box-shadow`,
@ -120,7 +120,7 @@
/* Selection / active ring — the followed branch + the chosen leaf (the morfo's
active / selected state). Uses the on-solid token, not a raw colour. */
.onion-menu-sector-group.is-highlight .onion-menu-sector {
stroke: var(--color-content-on-solid);
stroke: var(--onion-menu-sector-ring-fg);
stroke-width: var(--onion-menu-sector-ring-width);
}
@ -136,40 +136,46 @@
Uses the on-solid token at reduced opacity — the same colour as the selection
ring, kept lighter so hover reads as "candidate", not "chosen". */
.onion-menu-sector-group:not(.is-disabled):not(.is-highlight):hover .onion-menu-sector {
stroke: var(--color-content-on-solid);
stroke: var(--onion-menu-sector-ring-fg);
stroke-width: var(--onion-menu-sector-hover-ring-width);
stroke-opacity: var(--onion-menu-hover-ring-opacity);
}
.onion-menu-sector-group:not(.is-disabled):hover .onion-menu-label,
.onion-menu-sector-group:not(.is-disabled):hover .onion-menu-icon {
opacity: 1;
opacity: 1; /* literal: undoes the muting — the un-dimmed state is 1 by definition */
}
/* ── Labels + icons ──────────────────────────────────────────────────── */
.onion-menu-label {
font-family: var(--style-label-font-family, var(--style-label-font-family));
font-weight: var(--style-label-font-weight, 500);
font-family: var(--onion-menu-label-font-family);
font-weight: var(--onion-menu-label-font-weight);
pointer-events: none;
user-select: none;
transition: opacity var(--duration-fast) var(--ease-default);
}
.onion-menu-icon {
inline-size: 100%;
block-size: 100%;
inline-size: 100%; /* literal: the icon box IS its sector slot — identity, not a knob */
block-size: 100%; /* literal: the icon box IS its sector slot — identity, not a knob */
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
line-height: 1; /* literal: a lone glyph owns no leading — identity, not a knob */
/* clicks pass through the icon to the sector path beneath it */
pointer-events: none;
transition: opacity var(--duration-fast) var(--ease-default);
}
/* The glyph fills a proportion of its geometry box (ICON_SIZE) — a layout ratio,
not a fixed px, so it scales with the ring sizing. */
not a fixed px, so it scales with the ring sizing.
⚠ THIS RULE IS DROPPED: `:global()` is a SVELTE wrapper and this is a plain
CSS file, so the selector is invalid and the browser discards the whole rule
(measured 2026-08-23: the glyph computes 14px, the Icon's own size, and no
token here reaches it). Left as literals ON PURPOSE — fixing the selector
starts painting 70% and MOVES PIXEL, so it is a decision, not a cleanup.
Same bug in `timeline.css`; `image-picker.css` documents the trap. */
.onion-menu-icon :global(svg) {
inline-size: 70%;
block-size: 70%;
@ -190,9 +196,9 @@
place-items: center;
border: 0;
padding: 0;
border-radius: var(--radius-full, 50%);
border-radius: var(--onion-menu-trigger-radius);
cursor: pointer;
color: var(--color-content-on-solid);
color: var(--onion-menu-trigger-fg);
background: var(--_onion-trigger-fill);
box-shadow: var(--onion-menu-trigger-shadow);
/* FAB-style hover lift — `translate` composes with the centring `transform`. */
@ -229,8 +235,8 @@
on open to cross them. */
.onion-menu-trigger-glyph {
position: relative;
inline-size: 42%;
block-size: 42%;
inline-size: var(--onion-menu-glyph-size);
block-size: var(--onion-menu-glyph-size);
transition: transform var(--duration-normal, 300ms) var(--ease-default);
}
@ -242,7 +248,7 @@
inset-block-start: 50%;
block-size: var(--onion-menu-glyph-bar-thickness);
margin-block-start: calc(var(--onion-menu-glyph-bar-thickness) / -2);
border-radius: var(--radius-full, 1px);
border-radius: var(--onion-menu-glyph-bar-radius);
background: currentColor;
}

@ -4721,10 +4721,17 @@
--fab-lift: 2px;
--onion-menu-trigger-lift: var(--fab-lift);
--onion-menu-trigger-shadow: var(--fab-shadow);
--onion-menu-trigger-radius: var(--radius-full);
--onion-menu-trigger-fg: var(--color-content-on-solid);
--onion-menu-sector-shadow: var(--shadow-raised);
--onion-menu-sector-ring-fg: var(--color-content-on-solid);
--onion-menu-sector-ring-width: 2.5;
--onion-menu-sector-hover-ring-width: 2;
--onion-menu-label-font-family: var(--style-label-font-family);
--onion-menu-label-font-weight: var(--style-label-font-weight);
--onion-menu-glyph-size: 42%;
--onion-menu-glyph-bar-thickness: 2px;
--onion-menu-glyph-bar-radius: var(--radius-full);
--onion-menu-muted-opacity: 0.85;
--onion-menu-hover-ring-opacity: 0.65;
--onion-menu-rim-width: 2;

@ -7944,10 +7944,23 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'onion-menu': {
'trigger-lift': 'var(--fab-lift)',
'trigger-shadow': 'var(--fab-shadow)',
'trigger-radius': 'var(--radius-full)',
'trigger-fg': 'var(--color-content-on-solid)',
'sector-shadow': 'var(--shadow-raised)',
// ONE ring colour for the two states: the hover ring is the SAME colour
// as the selection ring, told apart by its opacity (below) — the recipe
// says so and the census counted it twice.
'sector-ring-fg': 'var(--color-content-on-solid)',
'sector-ring-width': '2.5',
'sector-hover-ring-width': '2',
'label-font-family': 'var(--style-label-font-family)',
'label-font-weight': 'var(--style-label-font-weight)',
// A RATIO of its geometry box, not pixels, so the trigger's +/x scales with
// the ring sizing. (The option icon has no twin: the rule that would size
// it is DROPPED — see the CSS.)
'glyph-size': '42%',
'glyph-bar-thickness': '2px',
'glyph-bar-radius': 'var(--radius-full)',
'muted-opacity': '0.85',
'hover-ring-opacity': '0.65',
// ── `luminous` look: the lit rim (a blurred pass under a crisp one) ──

Loading…
Cancel
Save

Powered by TurnKey Linux.