uix(menu-dial): temable — 0 % → 25 %, y las ranuras de la capa que NO se acuñan

3 claves en una entrada NUEVA de `recipes/base.ts`. Censo 0 % → 25 %.

**La receta posee sólo el cromo del dial**, como dice su propia cabecera: las
acciones son `Button` compuestos y la colocación flotante es de la capa
`viewport-placement`. Lo suyo son tres cosas: el hueco del abanico, el paso del
escalonado de entrada y el tinte del velo.

**`--_viewport-placement-offset` y `-z` NO se acuñan, y ésa es la decisión del
commit.** Son las RANURAS DE OVERRIDE que la capa compartida publica, y esta
receta escribe en ellas su decisión — 20px en vez de los 16px por defecto de la
capa, y el comentario del CSS deja claro que es decisión y no deriva. Acuñar
`menu-dial-placement-offset` sería el vocabulario paralelo que la regla 2 de
capas compartidas prohíbe: el eje es de la capa, y un tema lo mueve ahí. Es el
mismo techo que `listbox` paga por consumir bien `list-surface` — el grueso del
75 % que no alcanza, y es correcto que no lo haga (§13).

**El escalonado es tema, no motion**: `stagger-step` no es una duración de
animación sino el INTERVALO entre hermanos (multiplica el índice de cada
acción), y es lo que da carácter al abanico. Por eso entra al contrato.

Artefactos y gates:
- Sonda antes/después = 0 diffs en 1.856 valores × 8 estados.
- R-5.4: 1/3, los dos mudos adjudicados con su medición — el velo sólo existe
  con el dial abierto (0 nodos en reposo, contados) y el escalonado sólo se
  aplica bajo `[data-stagger]` con la lista abierta; forzando ambos atributos
  con índice 3, alcanza (0.084s → 2.331s = 3 × 777ms).
- censo --only 25 % · component:audit PASS · suite eidos sin rojos nuevos ·
  rtl:check 0 · docs:check 0 · check por fichero limpio.
- README con «Talla y tema» y tab `Tokens` en la demo (3 claves).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent 9f24a31eb9
commit 471ba96d25

@ -10,9 +10,9 @@
- **Medido**: 2026-08-21 · **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**: **50%** — 2329 de 4676 knobs pasan por un token público del componente
- **Reparto**: público 2329 · privado 627 · global 1156 · literal 564 · sistema transversal 509 _(fuera del ratio)_
- **Sin token público propio**: 30 · **alcance < 20 %**: 23 · **alcance 100 %**: 10 · **con eje `size`**: 59
- **Alcance global**: **50%** — 2331 de 4676 knobs pasan por un token público del componente
- **Reparto**: público 2331 · privado 625 · global 1156 · literal 564 · sistema transversal 509 _(fuera del ratio)_
- **Sin token público propio**: 29 · **alcance < 20 %**: 22 · **alcance 100 %**: 10 · **con eje `size`**: 59
## Cómo se lee
@ -91,12 +91,12 @@ theming §6.7 lo pone delante (`hover-bg`). Las fichas proponen la forma
| 19 | [timeline](./timeline.md) | 24 | 51% | 49 | 45 |
| 20 | [avatar](./avatar.md) | 23 | 36% | 36 | 0 |
## Sin ningún token público propio (30)
## Sin ningún token público propio (29)
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) · [`aspect-ratio`](./aspect-ratio.md) · [`auto-grid`](./auto-grid.md) · [`avatar`](./avatar.md) · [`cascade`](./cascade.md) · [`css-field`](./css-field.md) · [`date-picker`](./date-picker.md) · [`display`](./display.md) · [`field-langs`](./field-langs.md) · [`heading`](./heading.md) · [`highlight`](./highlight.md) · [`knob`](./knob.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) · [`range-calendar`](./range-calendar.md) · [`skin-media-player`](./skin-media-player.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) · [`wrap`](./wrap.md) · [`year-grid`](./year-grid.md)
[`alert-dialog`](./alert-dialog.md) · [`aspect-ratio`](./aspect-ratio.md) · [`auto-grid`](./auto-grid.md) · [`avatar`](./avatar.md) · [`cascade`](./cascade.md) · [`css-field`](./css-field.md) · [`date-picker`](./date-picker.md) · [`display`](./display.md) · [`field-langs`](./field-langs.md) · [`heading`](./heading.md) · [`highlight`](./highlight.md) · [`knob`](./knob.md) · [`mask-field`](./mask-field.md) · [`mockup`](./mockup.md) · [`month-grid`](./month-grid.md) · [`motion`](./motion.md) · [`number-field`](./number-field.md) · [`palabras`](./palabras.md) · [`range-calendar`](./range-calendar.md) · [`skin-media-player`](./skin-media-player.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) · [`wrap`](./wrap.md) · [`year-grid`](./year-grid.md)
## Tabla completa (162 recetas, por alcance ascendente)
@ -109,7 +109,6 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [year-grid](./year-grid.md) | 0% | 61 | 0 | 7 | 0 | 5 | 49 | 0 | – |
| [field-langs](./field-langs.md) | 0% | 41 | 0 | 0 | 31 | 8 | 2 | 0 | – |
| [skin-media-player](./skin-media-player.md) | 0% | 11 | 0 | 0 | 2 | 7 | 2 | 0 | y |
| [menu-dial](./menu-dial.md) | 0% | 8 | 0 | 3 | 1 | 4 | 0 | 0 | y |
| [aspect-ratio](./aspect-ratio.md) | 0% | 3 | 0 | 0 | 1 | 2 | 0 | 0 | – |
| [date-picker](./date-picker.md) | 0% | 2 | 0 | 0 | 0 | 2 | 0 | 0 | – |
| [sticky](./sticky.md) | 0% | 2 | 0 | 1 | 0 | 1 | 0 | 1 | – |
@ -134,6 +133,7 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component
| [toggle](./toggle.md) | 22% | 28 | 6 | 17 | 1 | 3 | 1 | 119 | y |
| [date-range-field](./date-range-field.md) | 25% | 9 | 2 | 0 | 4 | 2 | 1 | 3 | – |
| [time-range-field](./time-range-field.md) | 25% | 9 | 2 | 0 | 4 | 2 | 1 | 3 | – |
| [menu-dial](./menu-dial.md) | 25% | 8 | 2 | 1 | 1 | 4 | 0 | 3 | y |
| [surface](./surface.md) | 25% | 4 | 1 | 3 | 0 | 0 | 0 | 25 | – |
| [callout](./callout.md) | 27% | 15 | 4 | 3 | 7 | 1 | 0 | 28 | – |
| [meter](./meter.md) | 29% | 14 | 4 | 6 | 2 | 2 | 0 | 28 | y |

@ -5,9 +5,9 @@
> 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 8 knobs por token público
- **Knobs de apariencia**: 8 — público 0 · privado 3 · global 1 · literal 4 · sistema 0 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): **sin entrada en `base.ts`**
- **Medido**: 2026-08-21 · **Alcance**: **25%** — 2 de 8 knobs por token público
- **Knobs de apariencia**: 8 — público 2 · privado 1 · global 1 · literal 4 · sistema 0 _(fuera del ratio)_
- **Contrato hoy** (`lib/recipes/base.ts`): 3 pública(s) — `gap`, `stagger-step`, `backdrop-bg`
- **Eje `size`**: sí · **ficheros**: `menu-dial.css`
## 1. Knobs fuera de alcance
@ -18,13 +18,11 @@
| ---: | --- | --- | --- | --- |
| 1 | `menu-dial.css:273` | `.menu-dial-glyph::before, .menu-dial-glyph::after` | `border-radius` | `var(--radius-full, 9999px)` |
### 1.2 A través de un privado (3)
### 1.2 A través de un privado (1)
| # | fichero:línea | selector | propiedad | valor |
| ---: | --- | --- | --- | --- |
| 1 | `menu-dial.css:20` | `[data-menu-dial]` | `gap` | `var(--_menu-dial-gap, var(--space-3))` |
| 2 | `menu-dial.css:50` | `[data-menu-dial-list]` | `gap` | `var(--_menu-dial-gap, var(--space-3))` |
| 3 | `menu-dial.css:291` | `[data-menu-dial-backdrop]` | `background` | `var(--_menu-dial-backdrop-bg)` |
| 1 | `menu-dial.css:291` | `[data-menu-dial-backdrop]` | `background` | `var(--_menu-dial-backdrop-bg)` |
### 1.3 Literales (4)
@ -45,8 +43,8 @@ _Ninguno._
| privado | declaraciones | valor(es) | origen | ¿deriva de un público? |
| --- | ---: | --- | --- | :-: |
| `--_menu-dial-stagger-step` | 1 | `28ms` | literal | no |
| `--_menu-dial-backdrop-bg` | 1 | `color-mix(in oklab, var(--color-neutral-text-strong) 40%, transparent)` | global | no |
| `--_menu-dial-stagger-step` | 1 | `var(--menu-dial-stagger-step)` | public | **sí** |
| `--_menu-dial-backdrop-bg` | 1 | `var(--menu-dial-backdrop-bg)` | public | **sí** |
| `--_menu-dial-arc-start` | 13 | `270deg`, `0deg`, `180deg`, `90deg` | literal | no |
| `--_menu-dial-arc-span` | 13 | `90deg`, `180deg`, `360deg` | literal | no |
| `--_menu-dial-radius` | 1 | `max(var(--_dial-r-clear), var(--_dial-r-spread))` | global | no |
@ -59,7 +57,7 @@ Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inl
- **Consume la capa compartida `viewport-placement`.** Un eje que la capa posee se consume como `var(--_x, var(--x))`; el consumidor **no acuña** `--menu-dial-{eje}` para él — sería un vocabulario paralelo (README de `eidos/components`, «Capas compartidas» regla 2).
- **Tiene eje `size`**: los tokens dimensionales van por talla (`{part}-{eje}-{k}`) apuntando al bundle `--size-{k}-*`, nunca al primitivo crudo (theming §5; el guard `recipe-css-contract` prohíbe el primitivo).
### 4.1 Tokens a declarar en `lib/recipes/base.ts` (4)
### 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
@ -72,16 +70,11 @@ no distingue lo que debería — se marca `⚠`.
| `height` | `root` | ⚠ `1em` / `2px` | 2 |
| `width` | `root` | `1em` | 1 |
| `radius` | `root` | `var(--radius-full, 9999px)` | 1 |
| `backdrop-bg` | `root` | `color-mix(in oklab, var(--color-neutral-text-strong) 40%, transparent)` | 1 |
### 4.2 Sin nombre mecánico (3)
### 4.2 Sin nombre mecánico (2)
- **⚠ 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** — 2: `gap`.
- **⚠ 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** — 1: `opacity`.
### 4.3 Avisos sobre los tokens propuestos (1)
- **el privado `--_menu-dial-backdrop-bg` debe pasar a leer este público (o desaparecer)** — `--menu-dial-backdrop-bg`
- **ya alcanzable: el privado `--_menu-dial-backdrop-bg` deriva de un público (`var(--menu-dial-backdrop-bg)`) — sin acción; el censo lo cuenta como no alcanzable por el límite del regex** — 1: `background`.
### 4.4 Lo que hay que comprobar a mano (PLAN-theming §1.3 · §7.4)
@ -95,6 +88,24 @@ no distingue lo que debería — se marca `⚠`.
<!-- veredicto:start -->
_(pendiente — lo escribe el autor; se conserva al regenerar)_
**Medido 2026-08-22.** Su propia cabecera acota el encargo: la receta «owns
only the dial-specific chrome». Las acciones son `Button` compuestos y la
colocación flotante es de la capa `viewport-placement`, así que lo suyo son
tres cosas.
1. **Tres knobs propios**: el hueco del abanico, el paso del escalonado de
entrada (`28ms`, que multiplica el índice de cada acción) y el tinte del
velo de fondo.
2. **`--_viewport-placement-offset` y `-z` NO se acuñan.** Son las RANURAS DE
OVERRIDE que la capa compartida publica, y el componente escribe en ellas su
decisión (20px en vez de los 16px por defecto de la capa; el comentario de
la receta deja claro que es decisión, no deriva). Acuñar
`menu-dial-placement-offset` sería el vocabulario paralelo que la regla 2 de
capas compartidas prohíbe: el eje es de la capa, y un tema lo mueve ahí. Es
el mismo techo que `listbox` paga por consumir bien `list-surface`
(`next-features.md` §13).
3. **El escalonado es tema, no motion**: `--_menu-dial-stagger-step` no es una
duración de animación sino el INTERVALO entre hermanos, que define el
carácter del abanico. Entra al contrato.
<!-- veredicto:end -->

@ -154,6 +154,12 @@ export const SENTINEL_EXCEPTIONS: Record<string, Record<string, string>> = {
'trigger-underline':
'same structural conflict: the hover rule sets `text-decoration-color: currentColor` over the resting mix, and the panel cannot be opened without hovering'
},
'menu-dial': {
'backdrop-bg':
'the backdrop only exists while the dial is open (0 nodes at rest, counted); forced → reaches (rgb(1,2,3))',
'stagger-step':
"consumed only by `[data-stagger] [data-state='open'] .menu-dial-action`, and the demo dial is closed and un-staggered; forced both attrs with index 3 → reaches (0.084s → 2.331s = 3 × 777ms)"
},
'picker-shell': {
'section-gap-xs':
'its size axis is `data-picker-size` on the PORTALED [data-popover-content], not `data-size` on itself, and the guard only cycles `data-size`; the demo popover sits at md. Cycled by hand across the four picker-sizes 2026-08-21 → reaches (section/row/footer spacing follows the picker size)',

@ -133,6 +133,35 @@ the actions `role="menuitem"`, the trigger `aria-haspopup="menu"` + `aria-expand
the button's `contact-activate` (tap) AND the dial's `commit-select` (affirm). If
the layered feedback is too much, a sema cascade can mute the action's contact.
## Talla y tema
**Guard R-5.4** (`npm run theming:sentinel -- menu-dial <url>`): 1/3 en vivo;
los dos que callan están adjudicados con su medición en
`scripts/theming-sentinel-exceptions.ts` — el velo sólo existe con el dial
abierto y el escalonado sólo se aplica bajo `[data-stagger]` con la lista
abierta. Corrido 2026-08-22.
Contrato en `lib/recipes/base.ts > menu-dial` (3 claves, entrada NUEVA).
Alcance del censo: **25 %** (era 0 %).
**La receta posee sólo el cromo del dial**, como dice su propia cabecera: las
acciones son `Button` compuestos y la colocación flotante es de la capa
`viewport-placement`. Lo suyo son tres cosas: el hueco del abanico, el paso del
escalonado de entrada y el tinte del velo.
**`--_viewport-placement-offset` y `-z` NO se acuñan.** Son las RANURAS DE
OVERRIDE que la capa compartida publica, y esta receta escribe en ellas su
decisión —20px en vez de los 16px por defecto de la capa, y el comentario deja
claro que es decisión y no deriva. Acuñar `menu-dial-placement-offset` sería el
vocabulario paralelo que la regla 2 de capas compartidas prohíbe: el eje es de
la capa y un tema lo mueve ahí. Es el mismo techo que `listbox` paga por
consumir bien `list-surface`, y el grueso del 75 % que no alcanza
(`next-features.md` §13).
**El escalonado es tema, no motion**: `stagger-step` no es una duración de
animación sino el INTERVALO entre hermanos —multiplica el índice de cada
acción— y es lo que da carácter al abanico. Por eso entra al contrato.
## Gaps
- No demo page yet (deferred to the demo/docs web phase) — disposition: implementar.

@ -17,12 +17,12 @@
[data-menu-dial] {
display: inline-flex;
align-items: center;
gap: var(--_menu-dial-gap, var(--space-3));
gap: var(--_menu-dial-gap, var(--menu-dial-gap));
/* Per-action stagger step (private — internal animation timing). */
--_menu-dial-stagger-step: 28ms;
--_menu-dial-stagger-step: var(--menu-dial-stagger-step);
/* Scrim behind the open fan. */
--_menu-dial-backdrop-bg: color-mix(in oklab, var(--color-neutral-text-strong) 40%, transparent);
--_menu-dial-backdrop-bg: var(--menu-dial-backdrop-bg);
}
/* ── Fan layout per direction ────────────────────────────────────────── */
@ -47,7 +47,7 @@
[data-menu-dial-list] {
display: inline-flex;
align-items: center;
gap: var(--_menu-dial-gap, var(--space-3));
gap: var(--_menu-dial-gap, var(--menu-dial-gap));
}
/* ── Floating placement — BRIDGED to the shared layer ────────────────── */

@ -4081,6 +4081,9 @@
--scroll-frames-loader-font-size: var(--size-xs-font-size);
--scroll-frames-loader-letter-spacing: var(--tracking-widest);
--scroll-frames-loader-fg: var(--color-neutral-text);
--menu-dial-gap: var(--space-3);
--menu-dial-stagger-step: 28ms;
--menu-dial-backdrop-bg: color-mix(in oklab, var(--color-neutral-text-strong) 40%, transparent);
--kbd-font-size: var(--size-xs-font-size);
--kbd-font-weight: var(--font-weight-medium);
--kbd-letter-spacing: var(--tracking-wide);

@ -6589,6 +6589,19 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'loader-letter-spacing': 'var(--tracking-widest)',
'loader-fg': 'var(--color-neutral-text)'
},
'menu-dial': {
// The dial owns ONLY its own chrome: the fan gap, the entrance stagger step
// and the backdrop tint. Actions are composed Buttons, and the floating
// placement belongs to the `viewport-placement` layer — whose override
// slots (`--_viewport-placement-offset` / `-z`) the recipe writes into.
// Those are NOT re-coined here: the axis is the layer's, and coining
// `placement-offset` would be the parallel vocabulary rule 2 forbids.
gap: 'var(--space-3)',
// The interval BETWEEN siblings, not an animation duration: it is what
// gives the fan its character.
'stagger-step': '28ms',
'backdrop-bg': 'color-mix(in oklab, var(--color-neutral-text-strong) 40%, transparent)'
},
kbd: {
'font-size': 'var(--size-xs-font-size)',
'font-weight': 'var(--font-weight-medium)',

@ -12,10 +12,11 @@
import { compileMorfo } from '$uix/morfo';
import { menuDialMorfo } from '@/uix/morfo/components/menu-dial';
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';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
@ -216,6 +217,9 @@
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'tokens'} onclick={() => (tab = 'tokens')}>
Tokens <span data-uix-tab-count>3</span>
</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
@ -566,6 +570,10 @@
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="menu-dial" stage={stageRef ?? undefined} />
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>

Loading…
Cancel
Save

Powered by TurnKey Linux.