blocks(site-header): F2.1 · primer block del tier + landmark de NavigationMenu

Arranca F2 con el patrón del tier: composición pura de canon, sin CSS propio,
sin morfo y sin una sola cadena visible suya (B-7 — todo texto llega del app
por snippets).

El block
- `Sticky` (F1.1) envuelve solo cuando `sticky` (si no, no hay wrapper que no
  haga nada) → el estado pegado llega al app como `[data-sticky][data-stuck]`
  y el tratamiento es suyo; el block no pinta.
- `Container` para la medida, `Group` para la barra, `Drawer` para la
  navegación estrecha; `brand` / `nav` / `actions` / `mobileNav` /
  `mobileTrigger` son snippets del app.
- El interruptor ancho/estrecho va por el `display` responsive de los
  componentes de layout (B-6): el block no observa nada y las dos
  navegaciones nunca coexisten.
- Suelo de referencia (dossier §P1: TW Headers 8 + Navbars 11 + Flyout 7 ·
  Untitled · Flowbite): adoptamos las tres agrupaciones, el estado pegado
  como eje visual, el drawer móvil y el FLYOUT —la brecha de paridad que el
  dossier señala— sin reimplementarlo: el app pasa un `NavigationMenu`.
  Descartado: los volcados estáticos de cada arreglo; la barra es UN layout.
  El banner de anuncio no se hornea aquí (es el block F2.11 o un `Banner` en
  `children`).

Canon arreglado de paso
- **`NavigationMenu` no emitía landmark**: su morfo declara
  `defaultElement: 'nav'` para el Provider, pero el componente de soma
  renderizaba un `<div>` — el componente que ES la navegación del sitio no
  tenía `<nav>`, justo el hueco de a11y que el dossier atribuye a todas las
  referencias. Corregido en el canon, no parcheado en el block. Verificado:
  su demo sigue abriendo flyouts, ahora bajo `<nav>`.

Hueco registrado (no falseado)
- Un CTA que NAVEGA y parece botón no existe hoy: `Button` no tiene `href`
  por decisión propia («Link posee la navegación») y `Link` no tiene variante
  prominente. Queda como candidato a canon en los Gaps del block; la demo usa
  `Link` de verdad en vez de fingirlo.

Verificado en navegador: `<header aria-label>` dentro de `[data-sticky]`,
`data-stuck` al hacer scroll con el header a top:0, flyout abriendo, cero
nativos crudos interactivos (B-2), 375px sin desbordes con el drawer
abriendo 6 enlaces, y el `<nav>` del NavigationMenu presente. `blocks:check`
verde · `svelte-check` 0 errores propios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent 528af6f72d
commit 3cfc030b3a

@ -0,0 +1,64 @@
# SiteHeader
## Function
The chrome at the top of a marketing or documentation site: brand at one end,
navigation in the middle, actions at the other, and a drawer for the narrow
viewport. It pins to the top of its scroll host and **knows** it is pinned, so
the app can change its elevation or background on `[data-stuck]` without the
block owning any treatment.
## Composition map
| Part | Composes | Key props |
| --- | --- | --- |
| root | `Sticky` (F1.1) | `offset` — passed through; skipped entirely when `sticky={false}` |
| measure | `Container` | `size` (`container` prop, default `lg`) |
| the bar | `Group` | `justify="space-between"` · `align="center"` |
| `brand` / `nav` / `actions` | — (app snippets) | the app composes `Link`, `NavigationMenu`, `Button`… |
| `mobileNav` + `mobileTrigger` | `Drawer` | `direction="end"`, `bind:open` ↔ `mobileOpen` |
**Landmark + headings**: emits a single `<header>` and NO heading — a site
header is a landmark, not a section, and the page's `h1` belongs to whatever
follows (usually a `hero`). If the app puts several navigations on the page,
name them: the block passes `aria-label` straight through to the `<header>`,
and the `nav` snippet's own `NavigationMenu` takes its own name.
## Decisions
**2026-07-23 — reference floor** (dossier §P1: Tailwind Plus «Headers 8 +
Navbars 11 + Flyout 7» · Untitled UI · Flowbite):
- **Adopted**: the three-cluster bar (brand / nav / actions), the pinned state
as a visual axis, the mobile drawer, and the **flyout navigation** — the
dossier's explicit parity gap («sin flyout/dropdown de navegación no hay
paridad»). We do not re-implement it: the app passes a `NavigationMenu`,
which already ships the flyout, into the `nav` snippet.
- **Discarded**: the references' static HTML dumps of every arrangement. The
bar is ONE layout; a different arrangement is the app's markup inside the
snippets, not a variant prop.
- **The announcement banner is NOT baked in**: the dossier's sibling category
is served by the `banner` block (F2.11) or by the canon `Banner` in
`children`, which renders under the bar. One header, one function.
- **`sticky` is a prop, not a variant**: a header that does not pin still is
this block; skipping the `Sticky` wrapper keeps the DOM honest (no wrapper
that does nothing).
## Gaps
| Gap | Disposition |
| --- | --- |
| Scroll-hide (header retreats when scrolling down) | **canon candidate** — it is behaviour with contract surface (direction detection), so it goes to `sticky` first, never into this block (admission rule) |
| Skip-link | **shells own it** — it belongs to the page shell (`app-shell` / `docs-shell`, F3/F4), which knows what the main region is; putting it here would emit a second one on every shell page |
| Search field in the bar | **app-land** — it is a `Field`/`Command` the app composes into `actions`; the block does not need to know |
| Sub-navigation row under the bar | **out** — `children` renders under the bar; a second row of links is markup, not new API |
| A NAVIGATING call-to-action that looks like a button | **canon candidate** — surfaced while composing this block: `Button` deliberately has no `href` («`Link` owns navigation», its README) and `Link` has no prominent variant, so the header's «Get started» can only be a text link today. The honest fix is one decision in the canon (a `Link` variant or `Button href`), never a block-local fake |
## Found while composing
- **`NavigationMenu` emitted no landmark** (2026-07-23): its morfo declares
`defaultElement: 'nav'` for the Provider, but the soma component rendered a
`<div>` — so the component that is supposed to BE the site's navigation had
no `<nav>` at all, the exact a11y gap the dossier attributes to every
reference. Fixed in the canon (`soma/components/navigation-menu`), not
papered over here.

@ -0,0 +1,22 @@
// SiteHeader — the site's top chrome: brand · nav · actions, pinned, with a
// drawer for narrow viewports.
//
// import { SiteHeader } from '$blocks/site-header';
//
// <SiteHeader offset={0}>
// {#snippet brand()}<Link href="/">Acme</Link>{/snippet}
// {#snippet nav()}
// <NavigationMenu>…</NavigationMenu>
// {/snippet}
// {#snippet actions()}<Button href="/signup">Sign up</Button>{/snippet}
// {#snippet mobileTrigger()}<Drawer.Trigger>…</Drawer.Trigger>{/snippet}
// {#snippet mobileNav()}<Stack>…</Stack>{/snippet}
// </SiteHeader>
//
// Every visible string is the app's (B-7). The pinned state arrives as
// `[data-sticky][data-stuck]` from the composed `Sticky`.
import SiteHeader from './site-header.svelte';
export { SiteHeader };
export default SiteHeader;
export type { SiteHeaderProps } from './types';

@ -0,0 +1,80 @@
<script lang="ts">
/**
* SiteHeader — the marketing/site chrome at the top of a page: brand on one
* side, navigation in the middle, actions on the other, and a drawer for the
* narrow viewport. It sticks and KNOWS it is stuck, so the app can dress the
* pinned state through the composed components' own tokens.
*
* B contract: this file composes canon components only — no `.css`, no
* strings of its own (every visible word arrives as children), no morfo.
*/
import { Sticky } from '$uix/eidos/components/sticky';
import { Container } from '$uix/eidos/components/container';
import { Group } from '$uix/eidos/components/group';
import { Box } from '$uix/eidos/components/box';
import { Drawer } from '$uix/eidos/components/drawer';
import type { SiteHeaderProps } from './types';
let {
sticky = true,
offset = 0,
container = 'lg',
breakpoint = 'md',
mobileOpen = $bindable(false),
brand,
nav,
actions,
mobileNav,
mobileTrigger,
children,
...rest
}: SiteHeaderProps = $props();
</script>
{#snippet bar()}
<header {...rest}>
<Container size={container}>
<Group justify="space-between" align="center" gap={4}>
{@render brand?.()}
<!-- The wide/narrow switch rides the layout components' responsive
`display` (B-6): the block observes nothing of its own, and the
two navigations are never both present. -->
{#if nav}
<Box display={{ base: 'none', [breakpoint]: 'contents' }}>
{@render nav()}
</Box>
{/if}
<!-- `justify` explicit: the layout components pass it down through an
inheriting custom property, so a nested cluster would otherwise
spread its own children with the bar's `space-between`. -->
<Group align="center" gap={2} justify="end">
{#if actions}
<Box display={{ base: 'none', [breakpoint]: 'contents' }}>
{@render actions()}
</Box>
{/if}
{#if mobileNav}
<Box display={{ base: 'contents', [breakpoint]: 'none' }}>
<Drawer bind:open={mobileOpen} direction="end">
{@render mobileTrigger?.()}
<Drawer.Overlay />
<Drawer.Content>
{@render mobileNav()}
</Drawer.Content>
</Drawer>
</Box>
{/if}
</Group>
</Group>
</Container>
{@render children?.()}
</header>
{/snippet}
{#if sticky}
<Sticky {offset}>
{@render bar()}
</Sticky>
{:else}
{@render bar()}
{/if}

@ -0,0 +1,39 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ContainerSize } from '$uix/eidos/components/container';
import type { Breakpoint } from '$adom';
export type SiteHeaderProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
/**
* Pin the header to the top of its scroll host. The pinned state is exposed
* by the composed `Sticky` as `[data-sticky][data-stuck]` — dress it from
* the app with the composed components' own tokens, never with CSS here.
* @default true
*/
sticky?: boolean;
/** Offset from the pinning edge, in px. Passed straight to `Sticky`. @default 0 */
offset?: number;
/** Content measure. Passed straight to `Container`. @default 'lg' */
container?: ContainerSize;
/**
* From this breakpoint up the header shows `nav` + `actions`; below it, the
* drawer. The switch is the layout components' responsive `display` — the
* block observes nothing itself (B-6).
* @default 'md'
*/
breakpoint?: Breakpoint;
/** The mobile drawer's open state (bindable). @default false */
mobileOpen?: boolean;
/** Brand / logo cluster — the inline-start end of the bar. */
brand?: Snippet;
/** The navigation itself (a `NavigationMenu`, a `Group` of `Link`s…). */
nav?: Snippet;
/** Actions at the inline-end (sign-in, theme toggle, CTA `Button`…). */
actions?: Snippet;
/** Navigation for the narrow viewport — rendered inside a `Drawer`. */
mobileNav?: Snippet;
/** The control that opens that drawer (a `Drawer.Trigger`). */
mobileTrigger?: Snippet;
/** Anything under the bar (an announcement `Banner`, a sub-nav…). */
children?: Snippet;
};

@ -57,7 +57,10 @@
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
<!-- `<nav>`, as the morfo's Provider part declares (`defaultElement: 'nav'`).
It rendered a `<div>` until 2026-07-23, so the component emitted no
landmark at all — the very gap the dossier says every reference has. -->
<nav {...mergedProps}>
{@render children?.(state.snippetProps)}
</div>
</nav>
{/if}

@ -21,7 +21,7 @@
{
title: 'Site',
items: [
{ slug: 'site-header', label: 'Site header', shipped: false },
{ slug: 'site-header', label: 'Site header', shipped: true },
{ slug: 'hero', label: 'Hero', shipped: false },
{ slug: 'feature-grid', label: 'Feature grid', shipped: false },
{ slug: 'pricing', label: 'Pricing', shipped: false },

@ -0,0 +1,147 @@
<script lang="ts">
/**
* SiteHeader demo — a long page so the pinned state is real, with the
* navigation flyout, the actions cluster and the mobile drawer wired the way
* an app would wire them. Everything visible here is the APP's content: the
* block owns no string (B-7).
*/
import { SiteHeader } from '$blocks/site-header';
import { NavigationMenu } from '$uix/eidos/components/navigation-menu';
import { Drawer } from '$uix/eidos/components/drawer';
import { Button } from '$uix/eidos/components/button';
import { Link } from '$uix/eidos/components/link';
import { Container } from '$uix/eidos/components/container';
import { Section } from '$uix/eidos/components/section';
import { Stack } from '$uix/eidos/components/stack';
import { Group } from '$uix/eidos/components/group';
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Separator } from '$uix/eidos/components/separator';
import * as Icon from '$uix/eidos/components/icon';
let sticky = $state(true);
let mobileOpen = $state(false);
const sections = ['Product', 'Solutions', 'Developers', 'Pricing', 'Company'];
</script>
<SiteHeader
{sticky}
aria-label="Site"
bind:mobileOpen
style="background: var(--color-surface-default); border-block-end: var(--border-width) solid var(--color-border-subtle);"
>
{#snippet brand()}
<Link href="/blocks" variant="plain" underline="none">
<Group align="center" gap={2} justify="start">
<Icon.Boxes size="sm" />
<Text weight="semibold">Acme</Text>
</Group>
</Link>
{/snippet}
{#snippet nav()}
<NavigationMenu>
<NavigationMenu.List>
<NavigationMenu.Item value="product">
<NavigationMenu.Trigger>Product</NavigationMenu.Trigger>
<NavigationMenu.Content>
<Stack gap={2} style="min-inline-size: 16rem;">
<Link href="#overview">Overview</Link>
<Link href="#integrations">Integrations</Link>
<Link href="#changelog">Changelog</Link>
</Stack>
</NavigationMenu.Content>
</NavigationMenu.Item>
<NavigationMenu.Item value="solutions">
<NavigationMenu.Trigger>Solutions</NavigationMenu.Trigger>
<NavigationMenu.Content>
<Stack gap={2} style="min-inline-size: 16rem;">
<Link href="#startups">For startups</Link>
<Link href="#enterprise">For enterprise</Link>
</Stack>
</NavigationMenu.Content>
</NavigationMenu.Item>
<NavigationMenu.Item value="pricing">
<NavigationMenu.Link href="#pricing">Pricing</NavigationMenu.Link>
</NavigationMenu.Item>
<NavigationMenu.Item value="docs">
<NavigationMenu.Link href="#docs">Docs</NavigationMenu.Link>
</NavigationMenu.Item>
</NavigationMenu.List>
</NavigationMenu>
{/snippet}
{#snippet actions()}
<!-- Navigation, so `Link` — `Button` deliberately has no `href` (its
README: «Link posee la navegación»). A link styled as a prominent
button is a recorded gap of the block, not something to fake here. -->
<Link href="#signin" variant="subtle">Sign in</Link>
<Text weight="semibold"><Link href="#signup">Get started</Link></Text>
{/snippet}
{#snippet mobileTrigger()}
<Drawer.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="sm" aria-label="Open navigation">
<Icon.Menu size="sm" />
</Button>
{/snippet}
</Drawer.Trigger>
{/snippet}
{#snippet mobileNav()}
<Stack gap={3}>
<Heading level={2} size="sm">Navigate</Heading>
<Separator />
{#each sections as section (section)}
<Link href={`#${section.toLowerCase()}`} onclick={() => (mobileOpen = false)}>
{section}
</Link>
{/each}
<Separator />
<Text weight="semibold"><Link href="#signup">Get started</Link></Text>
</Stack>
{/snippet}
</SiteHeader>
<Section>
<Container size="lg">
<Stack gap={4}>
<Heading level={1}>Site header</Heading>
<Text color="muted">
The bar above is the block: brand, a `NavigationMenu` with flyouts, an actions cluster and —
below the `md` breakpoint — a drawer. Scroll: it pins and stamps
<code>data-stuck</code>, which is what this page uses to raise its own surface. The block
itself paints nothing.
</Text>
<Group gap={2}>
<Button size="sm" variant={sticky ? 'solid' : 'outline'} onclick={() => (sticky = true)}>
sticky
</Button>
<Button size="sm" variant={sticky ? 'outline' : 'solid'} onclick={() => (sticky = false)}>
static
</Button>
</Group>
</Stack>
</Container>
</Section>
{#each sections as section (section)}
<Section id={section.toLowerCase()}>
<Container size="lg">
<Stack gap={3}>
<Heading level={2}>{section}</Heading>
<Text color="muted">
Filler section so the page scrolls and the header has something to pin over. Each of these
is a plain `Section` + `Container` + `Stack` — the same layout components the block
composes.
</Text>
<Text color="muted">
A second paragraph keeps the section tall enough to make the pinned state obvious while
scrolling.
</Text>
</Stack>
</Container>
</Section>
{/each}
Loading…
Cancel
Save

Powered by TurnKey Linux.