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
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;
|
||||
};
|
||||
@ -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…
Reference in new issue