uix(navigation-menu): el botón «aparecía» en vez del panel, y la forma del trigger no la podía tocar ningún tema

Dos defectos que el autor señaló en el megamenú, y los dos anteriores a T-1.

1 · EL TRANSLATE. `emerge-open` / `emerge-close` se estampaban en el TRIGGER,
así que la firma genérica `[data-event^='emerge-open'] { animation: present-rise }`
corría sobre el BOTÓN: opacidad 0→1 y 8px de translate en el control, mientras
el panel —la superficie que entra en el campo— no hacía nada. Medido: a +8 ms el
trigger con `present-rise` y `translateY(5.5px)`, a +120 todavía vivo.

La premisa del target era real («el content se monta al abrir, un sello dirigido
a él muere») y la receta A-36 ya la respondía, por eso dropdown-menu, popover,
context-menu y dialog declaran `content` y ninguno declara `trigger`:
`open` es `post` —el flip monta el panel y el runtime difiere el emit pasado
`tick()`— y `close` es `pre` —el panel sigue ahí para anunciarlo—, con
`targetFallback: [trigger]` para la carrera en que ya no está. Resolución por
estado de montaje, DECLARADA, no un fallback a mano en el provider.
navigation-menu era el último que la arrastraba. Ahora el trigger queda sin
evento, sin animación y sin transform en todos los instantes; el panel se lleva
la aparición. La suite lo fija en las dos ramas (content montado · fallback).

Al mover el sello salió un doble que habría enviado: el panel tiene entrada
propia (slide+scale+fade, 120 ms) y la firma se la pisaba durante el hold; al
retirarse el sello, la de la receta arrancaba DE CERO. El panel entraba dos
veces, 320 + 120 ms. Precedente exacto en tooltip («the preset entrance wins
over the global present-rise signature», medido allí el 13-08): la entrada
propia gana por especificidad. Registro de animaciones tras el arreglo: una sola
entrada de 116 ms, nada en el botón.

2 · LA FORMA. El radio del trigger vivía en un PRIVADO (`--_navigation-menu-
radius`) clavado a `--radius-default` —«decoupled from size (Radix model)», una
línea que leyó a Radix como regla cuando Radix no pinta nada—, la altura salía
de `padding-block: var(--space-2)` (un token de ESPACIO como altura de CONTROL:
medido 33,5 / 36 / 41 px en sm/md/lg, sólo `md` en la escala por aritmética
casual) y los rellenos de hover/abierto estaban escritos a mano. El contrato del
componente publicaba tres knobs (content-z, indicator-w/x): ningún tema podía
decir «píldora» sin mover el radio del sistema entero. Por el TSC y por lo que
hacen Button (`--button-radius-*`), dropdown-menu (`item-*`) y el Sidebar de
ayer (`--sidebar-row-*` por talla), eso es un error, y el autor lo firmó así:
themable, default el actual.

Ahora el trigger (y el Link, que comparte su cromo) toma el bundle canónico
`--size-{k}-*` a través de tokens públicos por talla —`--navigation-menu-
trigger-{height,font-size,line-height,padding-inline,radius}` con sus pasos
`-{sm,md,lg}` y el nombre resuelto por `data-size`— más `-bg-active` /
`-fg-active` para el acento de abierto / aria-current. A `md` el default es
exactamente lo que traía: 36 px, 6 px de radio. Medido antes → después: altura
33,5/36/41 → 30/36/44; radio 6/6/6 → 6/6/10. Y un tema decide la forma en una
línea: `--navigation-menu-trigger-radius: var(--radius-full)` en el root → 9999px
en el trigger, medido.

Y el hover: la receta pintaba `background: var(--color-surface-overlay)` con el
SHORTHAND, que resetea `background-image` —donde vive la capa de estado canónica
que `archetypes.css` ya da a todo `trigger` (R-4.3)—. Medido: `background-image:
none` en hover, la capa muerta y una superficie a mano en su lugar; y el trigger
ABIERTO sin ninguna respuesta al puntero (el mismo bug que la fila activa del
Sidebar, ayer). La regla de hover desaparece (la capa ya está) y el acento usa
`background-color`, así que compone encima: medido, hover cerrado = sólo la
capa; hover abierto = acento + capa.

Fuera del alcance y sin tocar: los enlaces DENTRO del panel
(`[data-navigation-menu-content] :is(a, …)`) conservan su `background`
shorthand — otra superficie, otro selector, otro día.

Gates: check 72 con CERO en los ficheros tocados · soma nav 9/9 · sema+morfo
550/550 · eidos 434/435 (el rojo es `skin-media-player`, ajeno, el mismo que
documenta el handoff del sidebar) · eidos-lint 31 morfo-backed / 0 invalid ·
component:audit PASS · rtl:check 0 · docs:check 0/640 · generated/base.css +41
líneas, sólo las del nav.

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

@ -46,12 +46,49 @@ air. El evento perceptivo y el pack son propios de UIX.
`hoverEnabled`, `loop`, `aria-label(ledby)`. El nombre va al `<nav>` —el
landmark—, no a la lista: dos navegaciones en una página necesitan dos
nombres (A-111, 2026-08-19).
- Props eidos: `size` (`sm|md|lg`, responsive — padding del trigger, gap de
la lista, font-size).
- Props eidos: `size` (`sm|md|lg`, responsive). Ver §Talla y tema.
- El Content se ancla bajo la fila de triggers (`top: 100%` absoluto) para
que abrir el mega-menú NO empuje a los hermanos; no hay parte `Viewport`
separada (ver Decisiones).
## Talla y tema
| Prop | Valores | Default |
| ------ | ----------------------------------- | ------- |
| `size` | `sm` · `md` · `lg` (responsive) | `md` |
El trigger (y el `Link`, que comparte su cromo) es un CONTROL, así que toma
el bundle canónico (`--size-{k}-*`) — el precedente de la fila del `Sidebar`.
A `md` mide **36px / 16px / radio 6px**, exactamente lo que traía; `sm` y `lg`
pasan a la escala de control. Medido 2026-08-19, antes → después: altura
33,5 / 36 / 41 → **30 / 36 / 44**; radio 6 / 6 / 6 → 6 / 6 / **10**.
**Tokens públicos** (contrato en `lib/recipes/base.ts`, por talla con sus pasos
`-{sm,md,lg}` y el nombre RESUELTO que consume la receta):
| Token | Qué fija |
| --------------------------------------------- | ------------------------------------------ |
| `--navigation-menu-trigger-height` | altura del trigger (`--size-{k}-control-height`) |
| `--navigation-menu-trigger-font-size` · `-line-height` | tipografía del trigger (la barra la hereda) |
| `--navigation-menu-trigger-padding-inline` | padding inline |
| `--navigation-menu-trigger-radius` | radio del cromo de barra |
| `--navigation-menu-trigger-bg-active` · `-fg-active` | acento de ABIERTO / `aria-current="page"` |
| `--navigation-menu-content-z` | banda z del panel |
Un tema decide la forma en una línea —`--navigation-menu-trigger-radius:
var(--radius-full)` da píldoras— sin mover el radio del sistema. Hasta
2026-08-19 eso era imposible: el radio vivía en un privado (`--_…-radius`)
clavado a `--radius-default`, la altura salía de `padding-block:
var(--space-2)` (un token de ESPACIO como altura de CONTROL, sólo `md` en la
escala por casualidad) y el relleno de hover/abierto estaba escrito a mano.
**Hover = la capa de estado canónica**, no un token: `archetypes.css` ya la
pinta sobre todo `trigger` (`--state-hover`, recipe-contract §2 R-4.3). La
receta la mataba con el shorthand `background`, que resetea
`background-image` — medido: `background-image: none` en hover, y el trigger
ABIERTO sin ninguna respuesta al puntero. Con `background-color` en el acento,
la capa compone encima y los dos responden.
## Comparativa
| Capacidad | UIX | Radix | Bits UI | shadcn | React Aria |

@ -9,7 +9,18 @@
* [data-navigation-menu-link] → one-shot anchor (no Content)
* [data-navigation-menu-indicator] → arrow / underline tracking the active trigger
*
* Sizes — sm | md | lg via `data-size` on the root.
* Sizes — sm | md | lg via `data-size` on the root. The trigger (and the Link,
* which shares its chrome) is a CONTROL: height, type, inline padding and
* radius come from the canonical bundle through the recipe's public per-size
* tokens (`--navigation-menu-trigger-{height,font-size,line-height,
* padding-inline,radius}` + their `-{sm,md,lg}` steps), switched by
* `data-size`. The recipe consumes the RESOLVED name and never a raw
* primitive: a `padding-block: var(--space-2)` doing duty as the height rides
* the density-SPACE scale (measured 33.5 / 36 / 41 px before 2026-08-19), and
* a `--_radius` pinned to `--radius-default` was unreachable by any theme
* short of moving the whole system's radius. Public too:
* `--navigation-menu-trigger-{bg,fg}-active` (the open / aria-current accent).
* The neutral hover is the state layer `archetypes.css` gives every trigger.
*
* Distinct from dropdown-menu (floating click-driven) and menubar
* (roving tabindex bar). NavigationMenu is an INLINE list that
@ -19,31 +30,25 @@
*/
[data-navigation-menu] {
--_navigation-menu-trigger-padding-inline: var(--space-3);
--_navigation-menu-list-gap: var(--space-1);
--_navigation-menu-font-size: var(--font-size-md);
/* Radius DECOUPLED from size (Radix model) — archetype default, constant across sizes. */
--_navigation-menu-radius: var(--radius-default);
position: relative;
display: inline-flex;
flex-direction: column;
align-items: stretch;
font-family: var(--style-label-font-family);
font-size: var(--_navigation-menu-font-size);
line-height: var(--leading-ui);
/* The bar's type IS the trigger's: the Content inherits it, and the
trigger rule below reads the same resolved token for its line-height. */
font-size: var(--navigation-menu-trigger-font-size);
line-height: var(--navigation-menu-trigger-line-height);
}
[data-navigation-menu][data-size='sm'] {
--_navigation-menu-trigger-padding-inline: var(--space-2);
--_navigation-menu-list-gap: 0;
--_navigation-menu-font-size: var(--font-size-sm);
}
[data-navigation-menu][data-size='lg'] {
--_navigation-menu-trigger-padding-inline: var(--space-4);
--_navigation-menu-list-gap: var(--space-2);
--_navigation-menu-font-size: var(--font-size-lg);
}
/* ── List ─ */
@ -81,37 +86,45 @@
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding-block: var(--space-2);
padding-inline: var(--_navigation-menu-trigger-padding-inline);
/* Height from the control scale, not from block padding: the bundle's
control height already accounts for the type it carries. */
min-block-size: var(--navigation-menu-trigger-height);
padding-block: 0;
padding-inline: var(--navigation-menu-trigger-padding-inline);
border: none;
border-radius: var(--_navigation-menu-radius);
background: transparent;
border-radius: var(--navigation-menu-trigger-radius);
background-color: transparent;
color: var(--color-content-primary);
font: inherit;
line-height: var(--navigation-menu-trigger-line-height);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
outline: none;
transition:
background var(--duration-fast) var(--ease-default),
background-color var(--duration-fast) var(--ease-default),
color var(--duration-fast) var(--ease-default);
}
[data-navigation-menu-trigger]:hover:not([data-disabled]),
[data-navigation-menu-link]:hover:not([data-disabled]) {
background: var(--color-surface-overlay);
}
/* NO hover rule of its own. Both parts carry `data-archetype='trigger'`, and
* `archetypes.css` already paints the canonical state layer on hover
* (`background-image: linear-gradient(var(--state-hover), …)`, R-4.3). The
* rule that lived here — `background: var(--color-surface-overlay)` — used the
* SHORTHAND, which resets the `background-image` longhand: measured 2026-08-19,
* `background-image: none` on hover, the layer dead and a hand-picked surface in
* its place. The ACTIVE accent below uses `background-color` for the same
* reason — so the layer composes OVER it and an open trigger still answers the
* pointer (before: no hover feedback at all on the open trigger). */
[data-navigation-menu-trigger][data-state='open'],
[data-navigation-menu-trigger][data-state='open']:hover {
background: var(--color-primary-element);
color: var(--color-content-primary);
background-color: var(--navigation-menu-trigger-bg-active);
color: var(--navigation-menu-trigger-fg-active);
}
[data-navigation-menu-link][aria-current='page'],
[data-navigation-menu-link][data-active] {
background: var(--color-primary-element);
color: var(--color-content-primary);
background-color: var(--navigation-menu-trigger-bg-active);
color: var(--navigation-menu-trigger-fg-active);
}
[data-navigation-menu-trigger]:focus-visible,
@ -243,7 +256,19 @@
--motion-distance-xl: var(--space-3);
}
[data-navigation-menu-content][data-state='open'] {
/* The panel's OWN entrance, and it must win over the global `present-rise`
* signature — tooltip's precedent, measured there on 2026-08-13 and re-measured
* here on 2026-08-19 when `emerge-open` moved onto the content (it used to land
* on the trigger, where nothing competed). Both rules set the `animation`
* shorthand on the same node at equal specificity, so the firma was winning
* during the hold and the recipe's entrance then started FRESH the moment the
* stamp came off: the panel entered TWICE — 320 ms of `present-rise`, then
* 120 ms of slide+scale+fade. The second selector adds one attribute (0,3,0 vs
* the firma's 0,2,0) so the authored, direction-aware entrance is the only one
* that runs. `present-rise` is the generic fallback for a surface that designs
* no entrance of its own; this one designs it. */
[data-navigation-menu-content][data-state='open'],
[data-navigation-menu-content][data-state='open'][data-event-phase='active'] {
animation:
slide-from-top var(--duration-fast) var(--ease-out),
scale-in var(--duration-fast) var(--ease-out),

@ -3442,6 +3442,23 @@
--navigation-menu-content-z: var(--z-index-overlay-inline);
--navigation-menu-indicator-w: 0px;
--navigation-menu-indicator-x: 0px;
--navigation-menu-trigger-height-sm: var(--size-sm-control-height);
--navigation-menu-trigger-height-md: var(--size-md-control-height);
--navigation-menu-trigger-height-lg: var(--size-lg-control-height);
--navigation-menu-trigger-font-size-sm: var(--size-sm-font-size);
--navigation-menu-trigger-font-size-md: var(--size-md-font-size);
--navigation-menu-trigger-font-size-lg: var(--size-lg-font-size);
--navigation-menu-trigger-line-height-sm: var(--size-sm-font-line-height);
--navigation-menu-trigger-line-height-md: var(--size-md-font-line-height);
--navigation-menu-trigger-line-height-lg: var(--size-lg-font-line-height);
--navigation-menu-trigger-padding-inline-sm: var(--size-sm-padding-inline);
--navigation-menu-trigger-padding-inline-md: var(--size-md-padding-inline);
--navigation-menu-trigger-padding-inline-lg: var(--size-lg-padding-inline);
--navigation-menu-trigger-radius-sm: var(--size-sm-radius);
--navigation-menu-trigger-radius-md: var(--size-md-radius);
--navigation-menu-trigger-radius-lg: var(--size-lg-radius);
--navigation-menu-trigger-bg-active: var(--color-primary-element);
--navigation-menu-trigger-fg-active: var(--color-content-primary);
--dropdown-menu-content-z: var(--z-index-overlay-floating);
--dropdown-menu-content-min-width: 12rem;
--dropdown-menu-content-max-height: min(60vh, 22rem);
@ -5734,6 +5751,30 @@
--_drag-drop-palette-solid: var(--palette-solid, var(--color-primary-solid));
}
[data-navigation-menu] {
--navigation-menu-trigger-height: var(--navigation-menu-trigger-height-md);
--navigation-menu-trigger-font-size: var(--navigation-menu-trigger-font-size-md);
--navigation-menu-trigger-line-height: var(--navigation-menu-trigger-line-height-md);
--navigation-menu-trigger-padding-inline: var(--navigation-menu-trigger-padding-inline-md);
--navigation-menu-trigger-radius: var(--navigation-menu-trigger-radius-md);
}
[data-navigation-menu][data-size='sm'] {
--navigation-menu-trigger-height: var(--navigation-menu-trigger-height-sm);
--navigation-menu-trigger-font-size: var(--navigation-menu-trigger-font-size-sm);
--navigation-menu-trigger-line-height: var(--navigation-menu-trigger-line-height-sm);
--navigation-menu-trigger-padding-inline: var(--navigation-menu-trigger-padding-inline-sm);
--navigation-menu-trigger-radius: var(--navigation-menu-trigger-radius-sm);
}
[data-navigation-menu][data-size='lg'] {
--navigation-menu-trigger-height: var(--navigation-menu-trigger-height-lg);
--navigation-menu-trigger-font-size: var(--navigation-menu-trigger-font-size-lg);
--navigation-menu-trigger-line-height: var(--navigation-menu-trigger-line-height-lg);
--navigation-menu-trigger-padding-inline: var(--navigation-menu-trigger-padding-inline-lg);
--navigation-menu-trigger-radius: var(--navigation-menu-trigger-radius-lg);
}
[data-depth='flush'] {
box-shadow: var(--depth-flush-shadow);
}

@ -5121,7 +5121,78 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
// Inline (non-portaled) dropdown panel: stacks above normal page flow.
'content-z': 'var(--z-index-overlay-inline)',
'indicator-w': '0px',
'indicator-x': '0px'
'indicator-x': '0px',
// The in-bar TRIGGER (and the Link, which shares its chrome) is a
// control, so it takes the canonical size bundle (`--size-{k}-*`) — the
// sidebar row's precedent, 2026-08-19. Until then its whole chrome lived
// in recipe privates: `--_navigation-menu-radius` pinned to
// `--radius-default` («decoupled from size», a line that read Radix as
// a rule when Radix paints nothing), `padding-block: var(--space-2)` as
// the height (a SPACE token riding the density-space scale: measured
// 33.5 / 36 / 41 px across sm/md/lg, only `md` on the control scale by
// arithmetic luck), and the hover / open fills hand-picked. None of it
// was reachable by a theme short of moving `--radius-default` for the
// whole system. Nav runs `sm..lg`, default `md` — and at `md` the
// default is exactly what shipped (36px · 6px radius).
'trigger-height-sm': 'var(--size-sm-control-height)',
'trigger-height-md': 'var(--size-md-control-height)',
'trigger-height-lg': 'var(--size-lg-control-height)',
'trigger-font-size-sm': 'var(--size-sm-font-size)',
'trigger-font-size-md': 'var(--size-md-font-size)',
'trigger-font-size-lg': 'var(--size-lg-font-size)',
'trigger-line-height-sm': 'var(--size-sm-font-line-height)',
'trigger-line-height-md': 'var(--size-md-font-line-height)',
'trigger-line-height-lg': 'var(--size-lg-font-line-height)',
'trigger-padding-inline-sm': 'var(--size-sm-padding-inline)',
'trigger-padding-inline-md': 'var(--size-md-padding-inline)',
'trigger-padding-inline-lg': 'var(--size-lg-padding-inline)',
'trigger-radius-sm': 'var(--size-sm-radius)',
'trigger-radius-md': 'var(--size-md-radius)',
'trigger-radius-lg': 'var(--size-lg-radius)',
// The resolved coordinates for the size in force (`host` = `md`, the
// component's default); each `size:` scope is the emitted cascade entry,
// so the recipe consumes ONE name per axis and the bundle guard sees
// the reference in the token, not a literal buried in a rule.
'trigger-height': {
declarations: [
{ value: 'var(--navigation-menu-trigger-height-md)', scope: 'host' },
{ value: 'var(--navigation-menu-trigger-height-sm)', scope: 'size:sm' },
{ value: 'var(--navigation-menu-trigger-height-lg)', scope: 'size:lg' }
]
},
'trigger-font-size': {
declarations: [
{ value: 'var(--navigation-menu-trigger-font-size-md)', scope: 'host' },
{ value: 'var(--navigation-menu-trigger-font-size-sm)', scope: 'size:sm' },
{ value: 'var(--navigation-menu-trigger-font-size-lg)', scope: 'size:lg' }
]
},
'trigger-line-height': {
declarations: [
{ value: 'var(--navigation-menu-trigger-line-height-md)', scope: 'host' },
{ value: 'var(--navigation-menu-trigger-line-height-sm)', scope: 'size:sm' },
{ value: 'var(--navigation-menu-trigger-line-height-lg)', scope: 'size:lg' }
]
},
'trigger-padding-inline': {
declarations: [
{ value: 'var(--navigation-menu-trigger-padding-inline-md)', scope: 'host' },
{ value: 'var(--navigation-menu-trigger-padding-inline-sm)', scope: 'size:sm' },
{ value: 'var(--navigation-menu-trigger-padding-inline-lg)', scope: 'size:lg' }
]
},
'trigger-radius': {
declarations: [
{ value: 'var(--navigation-menu-trigger-radius-md)', scope: 'host' },
{ value: 'var(--navigation-menu-trigger-radius-sm)', scope: 'size:sm' },
{ value: 'var(--navigation-menu-trigger-radius-lg)', scope: 'size:lg' }
]
},
// The ACTIVE accent — an open trigger, a link with `aria-current`. The
// neutral hover is NOT a token: it is the state layer `archetypes.css`
// already gives every `trigger` (recipe-contract §2, R-4.3).
'trigger-bg-active': 'var(--color-primary-element)',
'trigger-fg-active': 'var(--color-content-primary)'
},
// ─────────────────────────────────────────────────────────────────────

@ -65,21 +65,35 @@ export const navigationMenuMorfo = {
},
{
// Unfolding a panel is an appearance, not a decision — same reading as
// dropdown-menu / popover / tooltip. `post`: the provider flips the open
// state in the handler, so `pre` would gate the panel behind the hold.
// Target = the TRIGGER, not the content, and on purpose: the content
// mounts on open and unmounts on close, so a stamp aimed at it would land
// on a node that is about to leave (or does not exist yet) — the runtime
// resolves the target to null and the emission dies silently. The trigger
// is the stable node of the pair. Declaring here what the runtime really
// stamps is the whole lesson of this component's audit: the pack used to
// select `item` while the stamp fell on `trigger`, so it NEVER matched and
// the nav ran at the family's base gain instead of its own tuning.
// dropdown-menu / popover / tooltip, and now the same TARGET: the
// CONTENT, which is the thing that appears.
//
// It used to target the `trigger`, on the theory that the content
// «mounts on open and unmounts on close, so a stamp aimed at it would
// land on a node that is about to leave (or does not exist yet)». That
// premise is real and the A-36 recipe already answers it, which is why
// every sibling overlay (dropdown-menu, popover, context-menu, dialog)
// declares `content` and none of them declares `trigger`:
// · `open` is `post` — the flip mounts the content and the runtime
// DEFERS the emit past `tick()`, so by emit time the ref is there;
// · `close` is `pre` — the content is still in the DOM to be
// announced against — and `targetFallback` covers the race where a
// re-render already removed it. Mount-state resolution is a
// DECLARATION, not a hand-resolved fallback in the provider.
//
// What the trigger target cost, measured 2026-08-19: the generic firma
// `[data-event^='emerge-open'] { animation: present-rise }` ran on the
// BUTTON — opacity 0→1 plus an 8px vertical translate — so opening a
// mega-menu made the trigger fade in and slide while the panel, the
// surface that actually entered the field, did nothing. Exactly the
// defect dropdown-menu's own provider documents having fixed («on first
// open the stamp fell on the TRIGGER, the same node a composed
// `<Button>` occupies»). `navigation-menu` was the last one carrying it.
name: 'emerge-open',
semantic: {
family: 'emerge',
verb: 'open',
target: v.partRef('trigger'),
target: v.partRef('content'),
sequence: 'post'
}
},
@ -88,7 +102,8 @@ export const navigationMenuMorfo = {
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('trigger'),
target: v.partRef('content'),
targetFallback: [v.partRef('trigger')],
sequence: 'pre',
persistence: 'transient',
allowedFamilies: ['emerge', 'commit', 'signal']

@ -19,7 +19,7 @@ import type { Sema } from '../sema-map';
* trigger composes the canon `Button`, so the tick, its tuning and its haptic
* all come from `Button`'s own pack. Re-declaring it would give one node the
* same verb twice.
* - `emerge-open` / `emerge-close` on the TRIGGER — **SILENT**. The mega-menu
* - `emerge-open` / `emerge-close` on the CONTENT — **SILENT**. The mega-menu
* opens on HOVER by default (`hoverEnabled`, `openDelay` 200 ms, swaps at
* `groupSkipDelay`), so a pointer sweep across the bar is one appearance per
* trigger crossed: measured 2026-08-19 with the family default, two triggers
@ -30,9 +30,11 @@ import type { Sema } from '../sema-map';
* minimal or absent), cap. 5 §11 — the stance `tooltip` and the sidebar's
* icon-mode flyout already take for the same gesture. The visual firma stays;
* a theme gives it a voice by naming any catalogue sound on these selectors,
* exactly as tooltip documents. The stamp lands on the trigger, not on the
* content, because the content mounts and unmounts with the panel — where it
* SHOULD land is a separate, open question (mount-state resolution).
* exactly as tooltip documents. The stamp lands on the CONTENT — the surface
* that enters the field — with the trigger as the declared `targetFallback`
* for the unmounted race (A-36 recipe). Until 2026-08-19 it landed on the
* trigger, and the visual firma made the BUTTON rise while the panel sat
* still.
*
* ⚠️ Until 2026-08-05 this pack was ONE rule matching `item` while the runtime
* stamped on the trigger, so the selector NEVER matched and the nav fell back to
@ -56,11 +58,11 @@ export const navigationMenuSema: Sema = {
name: 'navigation-menu',
cascade: [
{
selector: semaSelector(navigationMenuMorfo, 'trigger', { eventName: 'emerge-open' }),
selector: semaSelector(navigationMenuMorfo, 'content', { eventName: 'emerge-open' }),
sound: SILENT
},
{
selector: semaSelector(navigationMenuMorfo, 'trigger', { eventName: 'emerge-close' }),
selector: semaSelector(navigationMenuMorfo, 'content', { eventName: 'emerge-close' }),
sound: SILENT
}
]

@ -228,8 +228,8 @@ Disabled triggers and Links with no interactivity are included in navigation ord
| ------------------ | --------- | ---------- | ---------- | ------ | ---------------------------------------------------------------- |
| `contact-activate` | `contact` | `activate` | `link` | — | A `Link` is pressed (pointer, or Enter on the anchor). |
| `shift-navigate` | `shift` | `navigate` | `provider` | — | …and that link has an `href`, so the page actually crosses. |
| `emerge-open` | `emerge` | `open` | `trigger` | — | A Trigger discloses its Content (click, keyboard, hover). **SILENT** in the pack. |
| `emerge-close` | `emerge` | `close` | `trigger` | — | The open Content retires (`pre`). **SILENT** in the pack. |
| `emerge-open` | `emerge` | `open` | `content` | — | A Trigger discloses its Content (click, keyboard, hover). **SILENT** in the pack. |
| `emerge-close` | `emerge` | `close` | `content` | — | The open Content retires (`pre`; falls back to `trigger` if already unmounted). **SILENT**. |
**Pressing a link is `contact` + `shift`, never a `commit`.** The book is
explicit: *«Link → contact.press seguido de shift.navigate: la presión no es la
@ -257,6 +257,13 @@ Where each one lands, and why:
- **The trigger's own press is not declared here.** The eidos Trigger composes
the canon `Button`, which owns `contact-activate`; declaring it again would put
the same verb twice on one node.
- **The disclosure lands on the PANEL**, which is the surface that enters the
field — with `trigger` as the declared `targetFallback` for the unmounted race.
This is the A-36 recipe every sibling overlay follows: `open` is `post`, so the
flip mounts the content and the runtime defers the emit past `tick()`; `close`
is `pre`, so the content is still there to be announced against. Until
2026-08-19 it targeted the `trigger`, and the generic `emerge` firma made the
BUTTON fade in and slide 8px while the panel sat still.
A consumer that calls `preventDefault()` in its own `onclick` suppresses the
emission — that is the house's way of saying "I own this gesture" (`mergeProps`).

@ -435,11 +435,12 @@ describe('NavigationMenuProvider — the book’s link pair on the links', () =>
dom.dispose();
});
it('disclosing a panel is an appearance — `openNow` (the path hover reaches) commits nothing', async () => {
it('the appearance lands on the PANEL, not on the button that opened it', async () => {
const { dom, eventEngine } = installSomaHarness();
const root = anchor('nav', 'nav-root');
const opts = createNavigationMenuOpts(root);
const triggerEl = anchor('button', 'trigger-products');
const contentEl = anchor('div', 'content-products');
const { result, cleanup } = withEffectRoot(() => {
const provider = NavigationMenuProvider.create(opts);
@ -449,11 +450,15 @@ describe('NavigationMenuProvider — the book’s link pair on the links', () =>
value: state('products')
});
vi.spyOn(NavigationMenuItemProvider.ctx, 'get').mockReturnValue(item);
const trigger = NavigationMenuTriggerProvider.create({
NavigationMenuTriggerProvider.create({
...partOpts('trigger-products', triggerEl),
disabled: state(false)
});
return { provider, trigger };
NavigationMenuContentProvider.create({
...partOpts('content-products', contentEl),
onEscapeKeydown: state((_: KeyboardEvent) => undefined)
});
return { provider };
});
result.provider.openNow('products');
@ -461,10 +466,13 @@ describe('NavigationMenuProvider — the book’s link pair on the links', () =>
result.provider.closeNow();
await flushTriggers();
// The CONTENT is what enters and leaves the field. With the stamp on the
// trigger, the generic `emerge` firma made the BUTTON rise 8px while the
// panel sat still.
const calls = emitted(eventEngine);
expect(calls).toEqual([
{ name: 'emerge-open', family: 'emerge', target: triggerEl },
{ name: 'emerge-close', family: 'emerge', target: triggerEl }
{ name: 'emerge-open', family: 'emerge', target: contentEl },
{ name: 'emerge-close', family: 'emerge', target: contentEl }
]);
// The regression the 2026-08-05 audit closed and this axis must not
// re-open by another door: a pointer sweep cannot announce a decision.
@ -474,6 +482,43 @@ describe('NavigationMenuProvider — the book’s link pair on the links', () =>
dom.dispose();
});
it('with the panel already gone, the retirement falls back to the trigger', async () => {
const { dom, eventEngine } = installSomaHarness();
const root = anchor('nav', 'nav-root');
const opts = createNavigationMenuOpts(root);
const triggerEl = anchor('button', 'trigger-products');
const { result, cleanup } = withEffectRoot(() => {
const provider = NavigationMenuProvider.create(opts);
vi.spyOn(NavigationMenuProvider.ctx, 'get').mockReturnValue(provider);
const item = NavigationMenuItemProvider.create({
...partOpts('item-products'),
value: state('products')
});
vi.spyOn(NavigationMenuItemProvider.ctx, 'get').mockReturnValue(item);
NavigationMenuTriggerProvider.create({
...partOpts('trigger-products', triggerEl),
disabled: state(false)
});
return { provider };
});
// No Content is registered — the degraded mount the morfo's
// `targetFallback` exists for (A-36: mount-state resolution is DECLARED,
// not hand-resolved in the provider).
result.provider.openNow('products');
await flushTriggers();
result.provider.closeNow();
await flushTriggers();
expect(emitted(eventEngine)).toEqual([
{ name: 'emerge-close', family: 'emerge', target: triggerEl }
]);
cleanup();
dom.dispose();
});
it('the contract itself: no commit survives in the morfo', () => {
const names = navigationMenuMorfo.events.map((e) => e.name);
expect(names).toEqual(['contact-activate', 'shift-navigate', 'emerge-open', 'emerge-close']);

@ -144,13 +144,12 @@ export class NavigationMenuProvider {
// (`scheduleOpen` ← `onpointerenter`): sweeping the pointer across the bar
// announced a selection that had not happened, while the gestures that DO
// commit — pressing a link — stayed silent.
// `trigger` is a REPEATED part (one per top-level entry) and it IS the
// declared target — the panel is disclosed FROM it. Anchor on the
// registered instance by identity.
const triggerEl = this.triggerRefs.get(value);
const instance = triggerEl ? this.runtime.partInstance('trigger', triggerEl) : null;
if (instance) void instance.trigger('emerge-open');
else void this.runtime.trigger('emerge-open');
// Un-anchored on purpose: the morfo targets the CONTENT, and `post` lets
// the runtime defer the emit past `tick()`, by which time the flip above
// has mounted the panel and its ref is registered (the A-36 recipe every
// sibling overlay follows). Anchoring on the trigger here is what made the
// BUTTON carry the appearance firma instead of the panel.
void this.runtime.trigger('emerge-open');
}
/** Close whatever is open now. Arms the skip-delay window so the next
@ -158,13 +157,12 @@ export class NavigationMenuProvider {
closeNow() {
this.clearTimers();
if (this.opts.value.current === null) return;
const triggerEl = this.triggerRefs.get(this.opts.value.current);
this.opts.value.current = null;
// `pre`: the panel's disappearance is announced while it is still there to
// be announced against — on the trigger instance that owned it.
const instance = triggerEl ? this.runtime.partInstance('trigger', triggerEl) : null;
if (instance) void instance.trigger('emerge-close');
else void this.runtime.trigger('emerge-close');
// be announced against — so the emit goes BEFORE the flip below, and the
// morfo's `targetFallback` (the trigger) covers the race where a
// re-render already took the content away.
void this.runtime.trigger('emerge-close');
this.opts.value.current = null;
this.armSkipDelay();
}

@ -450,7 +450,7 @@
<thead><tr><th>Selector</th><th>Source</th><th>What it paints</th></tr></thead>
<tbody>
<tr><td class="name">[data-navigation-menu]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Root bar — inline-flex container with relative positioning.</td></tr>
<tr><td class="name">[data-navigation-menu][data-size]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Size scale — trigger padding + font + list gap.</td></tr>
<tr><td class="name">[data-navigation-menu][data-size]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Size scale — the trigger's height / type / inline padding / radius resolve from the canonical bundle through the public <code>--navigation-menu-trigger-*</code> tokens; list gap stays per size.</td></tr>
<tr><td class="name">[data-navigation-menu-list]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Horizontal/vertical flex list of items.</td></tr>
<tr><td class="name">[data-navigation-menu-trigger], [data-navigation-menu-link]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Flat in-bar chrome — transparent bg, hover tint, focus ring.</td></tr>
<tr><td class="name">[data-navigation-menu-trigger][data-state='open']</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Open-state tint + chevron rotation.</td></tr>

Loading…
Cancel
Save

Powered by TurnKey Linux.