blocks(app-shell): la barra y el raíl no son la misma navegación en dos tamaños, son dos ÁMBITOS

Corrección del autor, y era una equivocación de fondo, no de forma. Yo había
escrito que un menú en la barra sería «una segunda navegación dentro de un shell
cuya navegación es el raíl». Es al revés:

- la BARRA lleva la APLICACIÓN — su menú general, las áreas que tiene, y lo que
  es de la persona y no de la página (buscar, avisos, la cuenta). No cambia
  cuando el lector se mueve dentro de un contexto;
- el RAÍL lleva el CONTEXTO en el que está — este proyecto, esta tabla: sus
  secciones y sus acciones. Cambias de contexto y el raíl entero cambia; la
  barra no se mueve.

Es el `TopNav` + `SideNav` de Atlassian y el `TopBar` + `Navigation` de Polaris,
que estaban en mi propio dossier. Y es también por qué una app publica DOS
landmarks `navigation` legítimamente: la APG pide nombre único en los repetidos,
y cada uno nombra un ámbito distinto.

La demo modelaba mal: el raíl era el menú general. Ahora la barra lleva marca +
`NavigationMenu` de la aplicación (Bandeja · Proyectos · Informes) y el raíl es
el proyecto Apollo (Tablero · Conversaciones · Archivos + sus Acciones). El
rastro se va con ella: su sitio es la cabecera de la PÁGINA, no la barra —
cambia con cada vista mientras la barra no, y una barra que lo lleva crece una
segunda fila en cuanto la ruta tiene tres niveles, que es lo que se midió antes
de moverlo.

De `site-header` se queda sólo la razón que era cierta: no es que sobre una
navegación, es que la pieza es de un SITIO — `Sticky` por defecto (una barra de
app bajo `scroll="main"` no debe pegarse) y `Container` MEDIDA (una barra de app
va de borde a borde). Su forma marca · nav · acciones es la correcta.

**Y un defecto de canon que sólo aparece con dos navegaciones**: `NavigationMenu`
deja su landmark ANÓNIMO — el `aria-label` se reenvía al `<ul>` y el `<nav>` se
queda sin nombre (el morfo lo declara así: «the root delegates its name to the
list it wraps»). Un `<ul>` no es un landmark. Medido:
`navigation: ['Navegación principal', null, 'Migas de pan']`. No se arregla
desde fuera —nombrar la lista no nombra la región, y envolver en otro `<nav>`
anidaría landmarks—, así que queda VISIBLE y registrado en los Gaps.

Guards: `blocks:check` verde · `docs:check` 0/0 · `svelte-check` sin errores
propios (de paso, `NavigationMenu.Item` exige `value` y no se lo pasaba).

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

@ -213,5 +213,6 @@ lo logra. Una receta que no se ha visto en el navegador NO entra en esta tabla._
| `scroll-margin-block-start` en los destinos bajo `scroll="body"` | del consumidor: la altura de la cabecera la sabe él. Lo cerrará `docs-shell`, que es quien tiene anclas |
| **`Sidebar.MenuButton` es MUDO y `NavigationMenu.Link` no** — el mismo acto, dos respuestas | **canon, para una sesión de sema**: elegir un destino en una superficie de navegación emite `commit-select` en el `NavigationMenu` y NADA en el `Sidebar` ni en el `NavTree` (sus morfos sólo declaran `emerge-expand`/`emerge-collapse` sobre el panel). El README del `Sidebar` lo firma —«navegar una fila es nativo y no suena»— pero entonces el `NavigationMenu` contradice la firma. Además, en un shell la fila del raíl casi nunca navega: SELECCIONA la sección (`aria-current="page"`), que es exactamente el caso de `commit.select`. No se decide desde el tier |
| **`Toolbar.Button` no acepta `variant`/`color`** | **canon, anotado**: `ToolbarButtonProps` tipa contra el `ButtonProps` de SOMA (`{ id, disabled }`), así que el tratamiento es del root y todos los ítems lo llevan igual. Un grupo donde una acción es la principal no tiene cómo decirlo desde dentro; aquí la principal va al lado del cluster, como en las referencias |
| **`NavigationMenu` deja su landmark ANÓNIMO** | **canon, medido 2026-08-19**: su `aria-label` se reenvía al `<ul>` (`data-navigation-menu-list`) y el `<nav>` raíz se queda sin nombre — el morfo lo declara así a propósito («the root `<nav>` delegates its name to the list it wraps»). Un `<ul>` no es un landmark: quien navega por regiones ve el `<nav>`, y en un shell hay TRES navegaciones (la de la aplicación, la del contexto y el rastro), así que la APG exige nombre único en cada una. Medido en el preview: `navigation: ['Navegación principal', null, 'Migas de pan']`. No se arregla desde fuera —nombrar la lista no nombra la región, y envolver en otro `<nav>` sería anidar landmarks—, así que la demo lo deja visible en vez de fingirlo |
| **La barra superior no tiene pieza propia** | **tier, planificado**: lo que sostiene una barra de aplicación —disparador del raíl, rastro, buscador, avisos, menú de usuario— no existe como una pieza; `user-menu` (F3.6) y `notifications` (F3.7) están planificados y sin construir, y el shell los compondrá POR la excepción B-10 el día que aterricen. Mientras tanto la barra es el snippet del app, montado con canon. ⚠️ El candidato obvio, `site-header`, es la pieza equivocada aquí y por su propio API: es `Sticky` por defecto (una barra de app bajo `scroll="main"` NO se pega — nada se desplaza por debajo), envuelve en un `Container` MEDIDA (una barra de app va de borde a borde de su inset) y su forma es marca · nav · acciones + drawer móvil, o sea una SEGUNDA navegación dentro de un shell cuya navegación es el raíl. Es el cromo de un SITIO, y esto es una aplicación |
| **No existe `description-list`** | **canon, F5**: el backlog lo condiciona a «el primer detail-view real» — y el panel de detalle de este shell lo es. Mientras no exista, el significado lo llevan `dl`/`dt`/`dd` con la tipografía del tema, y se declara aquí en vez de fingirlo con `div`s |

@ -20,25 +20,43 @@
* words, because «Skip to main content» is the name of an accessibility
* contract and not copy (B-7 / D-BLK.5 point at exactly that door).
*
* THE TWO SCOPES, which is the thing to understand before reading anything
* else here, and the thing this block got wrong at first: the bar and the
* rail are not the same navigation at two sizes. They are two DIFFERENT
* navigations at two different scopes.
*
* - The BAR (`header`) carries the APPLICATION: its general menu, the
* switch between the products or the areas it has, plus what belongs to
* the person rather than the page — search, notifications, the account.
* It does not change when the reader moves inside a context.
* - The RAIL (`navContent`) carries the CONTEXT the reader is in — this
* project, this table, this space: its sections and its actions. Change
* context and the whole rail changes; the bar does not move.
*
* That is Atlassian's `TopNav` + `SideNav`, Polaris' `TopBar` +
* `Navigation`, and every application shell that survives its second
* screen. It is also why an app can legitimately publish TWO `navigation`
* landmarks (plus the breadcrumb's): the APG rule is that a repeated
* landmark carries a unique name, and each of these names a different
* scope — which is exactly what `navLabel` and the bar's own nav are for.
*
* B-10 exception: shells compose other blocks by design (`SHELL_ALLOWLIST`
* in `blocks-check.ts`), and this one composes none — which is a decision,
* not a pending task.
* in `blocks-check.ts`), and this one composes none — a decision, not a
* pending task.
*
* The obvious candidate is `site-header`, and it is the wrong piece here on
* three counts, all of them in its own API: it is `Sticky` by default (an
* app bar under `scroll="main"` must NOT pin — nothing scrolls under it), it
* wraps its content in a `Container` MEASURE (an app bar spans its inset
* edge to edge), and its shape is brand · nav · actions + a mobile drawer —
* a second navigation inside a shell whose navigation is the rail. It is the
* chrome of a marketing or docs SITE, and this is an application.
* `site-header` is the near miss, and the reason is NOT «it would be a
* second navigation» (see above: a second navigation is correct here). It
* is that the piece is built for a SITE: it is `Sticky` by default — an app
* bar under `scroll="main"` must not pin, since nothing scrolls under it —
* and it wraps its content in a `Container` MEASURE, while an app bar spans
* its inset edge to edge. Its brand · nav · actions shape is right; its
* geometry is not.
*
* What an app bar actually holds — the rail trigger, the trail, a search
* trigger, notifications, the user menu — has no single piece in the tier
* yet: `user-menu` (F3.6) and `notifications` (F3.7) are planned and unbuilt,
* and the shell will compose them THROUGH this exception the day they land.
* Until then the bar is the app's snippet, assembled from canon
* (`Sidebar.Trigger` + `Breadcrumb` + `Toolbar` + `Button`), which is what
* the demo shows. Registered in the README's Gaps.
* So the application's menu is composed IN the bar from canon
* (`NavigationMenu`), and the pieces that would make the bar a block of its
* own — `user-menu` (F3.6), `notifications` (F3.7) — are planned and
* unbuilt. The shell will compose them THROUGH this exception the day they
* land. Registered in the README's Gaps.
*
* The two blocks that DO drop into slots today are `banner` (in flow, above
* everything) and `site-footer`, and both go in as the app's snippets — the

@ -39,6 +39,7 @@
import { Heading } from '$uix/eidos/components/heading';
import * as Icon from '$uix/eidos/components/icon';
import { Metrics } from '$uix/eidos/components/metrics';
import { NavigationMenu } from '$uix/eidos/components/navigation-menu';
import { Separator } from '$uix/eidos/components/separator';
import { Stack } from '$uix/eidos/components/stack';
import { Text } from '$uix/eidos/components/text';
@ -62,7 +63,7 @@
} = $props();
let navOpen = $state(true);
let active = $state('/app/inbox');
let active = $state('/apollo/inbox');
let noticeUp = $state(true);
const kpis = [
@ -151,20 +152,37 @@
{ id: 6, who: 'Sistema', what: 'importó 14 conversaciones del buzón compartido', when: 'ayer' }
];
const nav = [
/**
* THE APPLICATION's menu — the bar. It names the areas the product has, and
* it does not change when the reader moves inside one of them.
*/
const appMenu = [
{ href: '/inbox', label: 'Bandeja' },
{ href: '/projects', label: 'Proyectos' },
{ href: '/reports', label: 'Informes' }
];
/**
* THE CONTEXT's rail — the sections and actions of the project the reader
* has open. Change project and this whole list changes; the bar above does
* not move. That is the split the shell exists to hold (Atlassian's TopNav +
* SideNav, Polaris' TopBar + Navigation).
*/
const contextNav = [
{
group: 'Bandeja',
group: 'Apollo',
items: [
{ href: '/app/inbox', label: 'Entrada', icon: Icon.Inbox, badge: '5' },
{ href: '/app/drafts', label: 'Borradores', icon: Icon.File },
{ href: '/app/sent', label: 'Enviados', icon: Icon.Send }
{ href: '/apollo/board', label: 'Tablero', icon: Icon.LayoutGrid },
{ href: '/apollo/inbox', label: 'Conversaciones', icon: Icon.Inbox, badge: '5' },
{ href: '/apollo/files', label: 'Archivos', icon: Icon.Folder }
]
},
{
group: 'Trabajo',
group: 'Acciones',
items: [
{ href: '/app/projects', label: 'Proyectos', icon: Icon.Folder },
{ href: '/app/reports', label: 'Informes', icon: Icon.ChartBar }
{ href: '/apollo/new', label: 'Nueva conversación', icon: Icon.Plus },
{ href: '/apollo/members', label: 'Miembros', icon: Icon.Users },
{ href: '/apollo/settings', label: 'Ajustes del proyecto', icon: Icon.Settings }
]
}
];
@ -186,14 +204,22 @@
{#snippet navContent()}
<!-- No `Sidebar.Panel` here: the shell owns it (it is the landmark its skip
link jumps to). The app fills the panel, it does not re-open it. -->
<!--
The rail's head names the CONTEXT, not the product: the product is the
bar's, and repeating it here would spend the one line that tells the
reader where they are.
-->
<Sidebar.Header>
<Group gap={2} align="center">
<Icon.Sparkles size="sm" />
<Text style="label">Acme</Text>
<Icon.Folder size="sm" />
<Stack gap={0}>
<Text style="label">Apollo</Text>
<Text style="caption">Proyecto</Text>
</Stack>
</Group>
</Sidebar.Header>
<Sidebar.Content>
{#each nav as section (section.group)}
{#each contextNav as section (section.group)}
<Sidebar.Group>
<Sidebar.GroupLabel>{section.group}</Sidebar.GroupLabel>
<Sidebar.Menu>
@ -239,21 +265,45 @@
<Stack gap={0}>
<Box paddingX={4} paddingY={2}>
<Group gap={3} align="center" justify="space-between">
<Group gap={2} align="center">
<Group gap={4} align="center">
{#if rail}
<Sidebar.Trigger><Icon.PanelLeft size="sm" /></Sidebar.Trigger>
{/if}
<Breadcrumb size="sm">
<Breadcrumb.List>
<Breadcrumb.Item>
<Breadcrumb.Link href="#/app">Acme</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item>
<Breadcrumb.Link current>Entrada</Breadcrumb.Link>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb>
<Group gap={2} align="center">
<Icon.Sparkles size="sm" />
<Text style="label">Acme</Text>
</Group>
<!--
THE APPLICATION's menu — a `NavigationMenu`, its own `<nav>`
landmark, at a different scope from the rail's.
⚠️ Its landmark ships ANONYMOUS and that is not this file's
doing: the `aria-label` below reaches the `<ul>`, never the
`<nav>` — the morfo delegates the root's name to the list it
wraps. A `<ul>` is not a landmark, and this page has three
navigations, so the APG's unique-name rule is unmet.
Registered in the block's Gaps and left VISIBLE (measured:
`navigation: ['Navegación principal', null, 'Migas de pan']`)
rather than faked with a second `<nav>` around it, which
would nest one landmark inside another.
-->
<NavigationMenu aria-label="Menú de la aplicación">
<NavigationMenu.List>
{#each appMenu as entry (entry.href)}
<!-- `value` is required: it is the item's identity for the
open-menu state, even on an item that only links. -->
<NavigationMenu.Item value={entry.href}>
<NavigationMenu.Link
href={`#${entry.href}`}
active={entry.href === '/projects'}
onclick={(e: MouseEvent) => e.preventDefault()}
>
{entry.label}
</NavigationMenu.Link>
</NavigationMenu.Item>
{/each}
</NavigationMenu.List>
</NavigationMenu>
</Group>
<Group gap={2} align="center">
<Toolbar aria-label="Acciones de la página" size="sm" variant="ghost">
@ -368,7 +418,7 @@
bind:navOpen
nav={{ collapsible, mobileBreakpoint: 'md' }}
aside={{ breakpoint: 'lg' }}
navLabel="Producto"
navLabel="Proyecto Apollo"
asideLabel="Detalle de la conversación"
banner={banner ? notice : undefined}
navContent={rail ? navContent : undefined}
@ -384,10 +434,34 @@
register for chrome-sized titles — the display serif of `h1`/`h2`
belongs to a hero, not to a bandeja.
-->
<!--
The trail belongs to the PAGE, not to the application's bar: it
says where this view sits inside the context, and it changes with
every view while the bar does not. Atlassian, Toolpad and GitHub
all put it here for the same reason — a bar that carries the trail
grows a second row the moment the path is three deep, which is
what this demo measured before moving it.
-->
<Group gap={3} align="end" justify="space-between">
<Stack gap={1}>
<Text style="caption">Bandeja compartida</Text>
<Heading level={1} style="h3">Entrada</Heading>
<Stack gap={2}>
<Breadcrumb size="sm">
<Breadcrumb.List>
<Breadcrumb.Item>
<Breadcrumb.Link href="#/projects">Proyectos</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item>
<Breadcrumb.Link href="#/apollo/board">Apollo</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item>
<Breadcrumb.Link current>Conversaciones</Breadcrumb.Link>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb>
<!-- No eyebrow: the trail above already says where this is, and
a caption repeating it is a line that earns nothing. -->
<Heading level={1} style="h3">Conversaciones</Heading>
</Stack>
<Toolbar aria-label="Vista" size="sm" variant="outline">
<Toolbar.Button>

Loading…
Cancel
Save

Powered by TurnKey Linux.