Ir al contenido

Componentes · Ola 3 · Estructura

App shell

La estructura de una app: cabecera con la marca y el producto, navegación, acciones, barra lateral opcional y el contenido. Es la barra del demo llevada a una app: la marca del producto en blanco, sin la de Arche al lado, y la navegación con la barra de selección en el acento. En pantallas chicas la navegación pasa a un panel que se abre con un botón de menú. Para un producto editorial suma el ancho de página, la búsqueda, la barra de lectura, el pie y el menú a pantalla completa.

import { AppShell, AppShellLink } from '@archeblack/ui';

Ejemplos

Cada ejemplo es una página propia dentro de un marco, con el acento del producto de ejemplo: el AppShell ocupa toda la página y tiene sus propios landmarks. Se puede desplazar, navegar y abrir el menú.

Consola

Cabecera fija (sticky), navegación principal y acciones de solo ícono: búsqueda en un Popover, notificaciones y la cuenta en un Menu.

Svelte
<script lang="ts">
	import {
		AppShell,
		AppShellLink,
		Avatar,
		Badge,
		Button,
		Card,
		Input,
		Menu,
		MenuItem,
		MenuSeparator,
		Popover
	} from '@archeblack/ui';
	import { IconBell, IconLogout, IconSearch, IconSettings } from '@archeblack/ui/icons';

	const sections = [
		{ id: 'resumen', label: 'Resumen' },
		{ id: 'proyectos', label: 'Proyectos' },
		{ id: 'equipo', label: 'Equipo' },
		{ id: 'facturacion', label: 'Facturación' }
	];
	const projects = [
		{ name: 'portal-clientes', status: 'Activo', updated: 'hace 6 minutos' },
		{ name: 'api-pagos', status: 'Activo', updated: 'hace 2 horas' },
		{ name: 'panel-interno', status: 'En pausa', updated: 'hace 3 días' },
		{ name: 'sitio-marketing', status: 'Activo', updated: 'hace 1 semana' },
		{ name: 'app-movil', status: 'Activo', updated: 'hace 2 semanas' },
		{ name: 'servicio-correo', status: 'En pausa', updated: 'hace 3 semanas' },
		{ name: 'datos-ventas', status: 'Activo', updated: 'hace 1 mes' },
		{ name: 'docs-internas', status: 'En pausa', updated: 'hace 2 meses' }
	];

	// En un producto, la página actual sale de la URL (page.url.pathname en SvelteKit).
	let current = $state('proyectos');
</script>

<!-- En el producto, data-product va en <html> (src/app.html) y la cabecera toma su acento. -->
<AppShell product="Consola" homeHref="#resumen" sticky>
	{#snippet navigation()}
		{#each sections as section (section.id)}
			<AppShellLink
				href="#{section.id}"
				current={current === section.id}
				onclick={() => (current = section.id)}
			>
				{section.label}
			</AppShellLink>
		{/each}
	{/snippet}

	{#snippet actions()}
		<Popover aria-label="Buscar proyectos" align="end">
			{#snippet trigger(props)}
				<Button {...props} variant="ghost" icon={IconSearch} aria-label="Buscar proyectos" />
			{/snippet}
			<Input
				type="search"
				icon={IconSearch}
				placeholder="Nombre del proyecto"
				aria-label="Buscar proyectos"
			/>
		</Popover>
		<Button variant="ghost" icon={IconBell} aria-label="Notificaciones" />
		<Menu align="end">
			{#snippet trigger(props)}
				<!-- Un botón sin caja: el avatar es el objetivo y el foco lo rodea. -->
				<button
					{...props}
					type="button"
					aria-label="Cuenta de Jimmy Mora"
					style:display="grid"
					style:padding="0"
					style:border="0"
					style:background="none"
					style:border-radius="var(--arche-radius-full)"
					style:cursor="pointer"
				>
					<Avatar name="Jimmy Mora" alt="" />
				</button>
			{/snippet}
			<MenuItem icon={IconSettings}>Preferencias</MenuItem>
			<MenuSeparator />
			<MenuItem icon={IconLogout}>Cerrar sesión</MenuItem>
		</Menu>
	{/snippet}

	<div
		style:display="grid"
		style:gap="var(--arche-spacing-6)"
		style:max-width="64rem"
		style:padding="var(--arche-spacing-8) var(--arche-spacing-6)"
	>
		<div style:display="grid" style:gap="var(--arche-spacing-2)">
			<h1 style:margin="0" style:font="var(--arche-typography-heading)">Proyectos</h1>
			<p style:margin="0" style:color="var(--arche-color-text-muted)">
				Ocho proyectos en la organización. La cabecera queda fija al desplazarse.
			</p>
		</div>
		<div
			style:display="grid"
			style:grid-template-columns="repeat(auto-fill, minmax(min(16rem, 100%), 1fr))"
			style:gap="var(--arche-spacing-4)"
		>
			{#each projects as project (project.name)}
				<Card>
					{#snippet header()}{project.name}{/snippet}
					<div
						style:display="flex"
						style:align-items="center"
						style:justify-content="space-between"
						style:gap="var(--arche-spacing-3)"
					>
						<Badge variant={project.status === 'Activo' ? 'success' : 'neutral'} dot>
							{project.status}
						</Badge>
						<span style:color="var(--arche-color-text-muted)">{project.updated}</span>
					</div>
				</Card>
			{/each}
		</div>
	</div>
</AppShell>

En un teléfono

La misma consola en un marco de 390 px: la navegación pasa al panel del botón de menú y las acciones siguen en la cabecera.

Svelte
<script lang="ts">
	import {
		AppShell,
		AppShellLink,
		Avatar,
		Badge,
		Button,
		Card,
		Input,
		Menu,
		MenuItem,
		MenuSeparator,
		Popover
	} from '@archeblack/ui';
	import { IconBell, IconLogout, IconSearch, IconSettings } from '@archeblack/ui/icons';

	const sections = [
		{ id: 'resumen', label: 'Resumen' },
		{ id: 'proyectos', label: 'Proyectos' },
		{ id: 'equipo', label: 'Equipo' },
		{ id: 'facturacion', label: 'Facturación' }
	];
	const projects = [
		{ name: 'portal-clientes', status: 'Activo', updated: 'hace 6 minutos' },
		{ name: 'api-pagos', status: 'Activo', updated: 'hace 2 horas' },
		{ name: 'panel-interno', status: 'En pausa', updated: 'hace 3 días' },
		{ name: 'sitio-marketing', status: 'Activo', updated: 'hace 1 semana' },
		{ name: 'app-movil', status: 'Activo', updated: 'hace 2 semanas' },
		{ name: 'servicio-correo', status: 'En pausa', updated: 'hace 3 semanas' },
		{ name: 'datos-ventas', status: 'Activo', updated: 'hace 1 mes' },
		{ name: 'docs-internas', status: 'En pausa', updated: 'hace 2 meses' }
	];

	// En un producto, la página actual sale de la URL (page.url.pathname en SvelteKit).
	let current = $state('proyectos');
</script>

<!-- En el producto, data-product va en <html> (src/app.html) y la cabecera toma su acento. -->
<AppShell product="Consola" homeHref="#resumen" sticky>
	{#snippet navigation()}
		{#each sections as section (section.id)}
			<AppShellLink
				href="#{section.id}"
				current={current === section.id}
				onclick={() => (current = section.id)}
			>
				{section.label}
			</AppShellLink>
		{/each}
	{/snippet}

	{#snippet actions()}
		<Popover aria-label="Buscar proyectos" align="end">
			{#snippet trigger(props)}
				<Button {...props} variant="ghost" icon={IconSearch} aria-label="Buscar proyectos" />
			{/snippet}
			<Input
				type="search"
				icon={IconSearch}
				placeholder="Nombre del proyecto"
				aria-label="Buscar proyectos"
			/>
		</Popover>
		<Button variant="ghost" icon={IconBell} aria-label="Notificaciones" />
		<Menu align="end">
			{#snippet trigger(props)}
				<!-- Un botón sin caja: el avatar es el objetivo y el foco lo rodea. -->
				<button
					{...props}
					type="button"
					aria-label="Cuenta de Jimmy Mora"
					style:display="grid"
					style:padding="0"
					style:border="0"
					style:background="none"
					style:border-radius="var(--arche-radius-full)"
					style:cursor="pointer"
				>
					<Avatar name="Jimmy Mora" alt="" />
				</button>
			{/snippet}
			<MenuItem icon={IconSettings}>Preferencias</MenuItem>
			<MenuSeparator />
			<MenuItem icon={IconLogout}>Cerrar sesión</MenuItem>
		</Menu>
	{/snippet}

	<div
		style:display="grid"
		style:gap="var(--arche-spacing-6)"
		style:max-width="64rem"
		style:padding="var(--arche-spacing-8) var(--arche-spacing-6)"
	>
		<div style:display="grid" style:gap="var(--arche-spacing-2)">
			<h1 style:margin="0" style:font="var(--arche-typography-heading)">Proyectos</h1>
			<p style:margin="0" style:color="var(--arche-color-text-muted)">
				Ocho proyectos en la organización. La cabecera queda fija al desplazarse.
			</p>
		</div>
		<div
			style:display="grid"
			style:grid-template-columns="repeat(auto-fill, minmax(min(16rem, 100%), 1fr))"
			style:gap="var(--arche-spacing-4)"
		>
			{#each projects as project (project.name)}
				<Card>
					{#snippet header()}{project.name}{/snippet}
					<div
						style:display="flex"
						style:align-items="center"
						style:justify-content="space-between"
						style:gap="var(--arche-spacing-3)"
					>
						<Badge variant={project.status === 'Activo' ? 'success' : 'neutral'} dot>
							{project.status}
						</Badge>
						<span style:color="var(--arche-color-text-muted)">{project.updated}</span>
					</div>
				</Card>
			{/each}
		</div>
	</div>
</AppShell>

Editorial

La marca de Hermes (mark) en blanco, alineada con la columna de página (width="page"), con la búsqueda y su atajo, el pie (footer y footerNavigation) y la cabecera que se va: sin sticky, sube con la página y la barra de lectura del post (progress) queda sola arriba. ⌘K o Ctrl+K abre la búsqueda. Desplaza el marco.

Svelte
<script lang="ts">
	import {
		AppShell,
		AppShellLink,
		Container,
		Dialog,
		Eyebrow,
		Highlight,
		IndexList,
		IndexListItem,
		Input,
		Masthead,
		Prose,
		ReadingProgress
	} from '@archeblack/ui';
	import { essay } from '../../reading-progress/examples/text.ts';

	// En un producto, la página actual sale de la URL. En la página de un post ningún link lleva
	// current, como en Hermes: un post no es ninguna de las secciones de la navegación.
	const links = [
		{ href: '#inicio', label: 'Inicio' },
		{ href: '#publicaciones', label: 'Publicaciones' },
		{ href: '#temas', label: 'Temas' }
	];
	const footerLinks = [
		{ href: '#publicaciones', label: 'Publicaciones' },
		{ href: '#temas', label: 'Temas' },
		{ href: '#autores', label: 'Autores' },
		{ href: '#sobre', label: 'Sobre' }
	];
	const posts = [
		{ title: 'El Alma Pide Locura', slug: 'el-alma-pide-locura', date: '2026-07-27T21:45:16Z' },
		{
			title: 'Lo que cambia cuando alguien escucha',
			slug: 'lo-que-cambia-cuando-alguien-escucha',
			date: '2026-05-03T01:33:44Z'
		},
		{
			title: 'El Daimon en la Máquina',
			slug: 'el-daimon-en-la-maquina',
			date: '2026-01-28T12:00:00Z'
		},
		{
			title: 'El Doctor que Bailó con el Tiempo',
			slug: 'el-doctor-que-bailo-con-el-tiempo',
			date: '2026-01-17T12:00:00Z'
		},
		{
			title: 'Lo Numinoso en CONTROL',
			slug: 'lo-numinoso-en-control',
			date: '2026-01-04T12:00:00Z'
		}
	];

	let article = $state<HTMLElement | null>(null);
	let searching = $state(false);
	let query = $state('');

	/** Sin mayúsculas ni tildes, la misma regla que usa Highlight para marcar. */
	const fold = (text: string) => text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLocaleLowerCase();
	const term = $derived(fold(query.trim()));
	const found = $derived(term ? posts.filter((post) => fold(post.title).includes(term)) : []);

	// ⌘K (Mac) o Ctrl+K abre y cierra la búsqueda: el atajo lo escucha el producto. AppShell solo lo
	// muestra en el botón (searchShortcut) y lo declara en aria-keyshortcuts.
	function onkeydown(event: KeyboardEvent) {
		if (event.key.toLowerCase() !== 'k' || event.altKey || event.shiftKey) return;
		const mac = /Mac|iPhone|iPad/.test(navigator.userAgent);
		if (!(mac ? event.metaKey : event.ctrlKey)) return;
		event.preventDefault();
		searching = !searching;
	}
</script>

<svelte:window {onkeydown} />

<!-- En el producto, data-product va en <html> (src/app.html): la capa del menú y la de búsqueda se
     abren en un portal y toman el mismo acento. La cabecera se va con la página (sin sticky): la de
     una página de lectura. La barra de lectura, en progress con placement="viewport" (por defecto),
     queda fija arriba de la ventana. -->
<AppShell
	mark="hermes"
	homeHref="#inicio"
	width="page"
	menu="fullscreen"
	searchHref="#buscar"
	searchShortcut="K"
	onSearch={() => (searching = true)}
>
	{#snippet navigation()}
		{#each links as link (link.href)}
			<AppShellLink href={link.href}>{link.label}</AppShellLink>
		{/each}
	{/snippet}

	{#snippet progress()}
		<ReadingProgress target={article} />
	{/snippet}

	{#snippet footer()}© 2026 Hermes{/snippet}

	{#snippet footerNavigation()}
		{#each footerLinks as link (link.href)}
			<AppShellLink href={link.href}>{link.label}</AppShellLink>
		{/each}
	{/snippet}

	<!-- La página de un post, recortada: la cabecera del texto y el artículo, que mide la barra. -->
	<Container style="padding-top: var(--arche-spacing-12)">
		<Masthead
			title="El Alma Pide Locura"
			dek="Hay caminos fuera del jardín. Sobre la sed que el saber no calma, y la disposición que el alma estaba pidiendo."
		>
			{#snippet eyebrow()}
				<Eyebrow topic="Filosofía" href="#filosofia" date="2026-07-27T21:45:16Z" />
			{/snippet}
		</Masthead>
	</Container>
	<Container width="reading" style="margin-top: var(--arche-spacing-12)">
		<article bind:this={article}>
			<Prose html={essay} />
		</article>
	</Container>
</AppShell>

<!-- La capa de búsqueda: el producto la abre con onSearch o con el atajo. La x queda al final de la
     columna de página, donde está «Buscar ⌘K»; el campo y los resultados, en la columna de lectura,
     centrada. -->
<Dialog bind:open={searching} size="fullscreen" title="Buscar">
	<div
		style="display: grid; gap: var(--arche-spacing-6); width: min(100%, var(--arche-layout-width-reading)); margin-inline: auto"
	>
		<Input
			size="xl"
			type="search"
			bind:value={query}
			aria-label="Buscar en Hermes"
			placeholder="Busca por título…"
			autocomplete="off"
			suffix={term ? `${found.length} ${found.length === 1 ? 'resultado' : 'resultados'}` : ''}
			liveSuffix
		/>
		{#if found.length > 0}
			<IndexList variant="listing" aria-label="Resultados" onclick={() => (searching = false)}>
				{#each found as post (post.slug)}
					<IndexListItem href={`#${post.slug}`} date={post.date}>
						{#snippet title()}<Highlight text={post.title} {query} />{/snippet}
					</IndexListItem>
				{/each}
			</IndexList>
		{/if}
	</div>
</Dialog>

Editorial en un teléfono

Con menos de 48 rem quedan la lupa y el menú, a la derecha (menu="fullscreen"): una capa a pantalla completa con los links en la voz de los titulares a 40 px (typography.menu). Al abrirla, la marca y el botón no se mueven: la x cae donde estaba el menú.

Svelte
<script lang="ts">
	import {
		AppShell,
		AppShellLink,
		Container,
		Dialog,
		Eyebrow,
		Highlight,
		IndexList,
		IndexListItem,
		Input,
		Masthead,
		Prose,
		ReadingProgress
	} from '@archeblack/ui';
	import { essay } from '../../reading-progress/examples/text.ts';

	// En un producto, la página actual sale de la URL. En la página de un post ningún link lleva
	// current, como en Hermes: un post no es ninguna de las secciones de la navegación.
	const links = [
		{ href: '#inicio', label: 'Inicio' },
		{ href: '#publicaciones', label: 'Publicaciones' },
		{ href: '#temas', label: 'Temas' }
	];
	const footerLinks = [
		{ href: '#publicaciones', label: 'Publicaciones' },
		{ href: '#temas', label: 'Temas' },
		{ href: '#autores', label: 'Autores' },
		{ href: '#sobre', label: 'Sobre' }
	];
	const posts = [
		{ title: 'El Alma Pide Locura', slug: 'el-alma-pide-locura', date: '2026-07-27T21:45:16Z' },
		{
			title: 'Lo que cambia cuando alguien escucha',
			slug: 'lo-que-cambia-cuando-alguien-escucha',
			date: '2026-05-03T01:33:44Z'
		},
		{
			title: 'El Daimon en la Máquina',
			slug: 'el-daimon-en-la-maquina',
			date: '2026-01-28T12:00:00Z'
		},
		{
			title: 'El Doctor que Bailó con el Tiempo',
			slug: 'el-doctor-que-bailo-con-el-tiempo',
			date: '2026-01-17T12:00:00Z'
		},
		{
			title: 'Lo Numinoso en CONTROL',
			slug: 'lo-numinoso-en-control',
			date: '2026-01-04T12:00:00Z'
		}
	];

	let article = $state<HTMLElement | null>(null);
	let searching = $state(false);
	let query = $state('');

	/** Sin mayúsculas ni tildes, la misma regla que usa Highlight para marcar. */
	const fold = (text: string) => text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLocaleLowerCase();
	const term = $derived(fold(query.trim()));
	const found = $derived(term ? posts.filter((post) => fold(post.title).includes(term)) : []);

	// ⌘K (Mac) o Ctrl+K abre y cierra la búsqueda: el atajo lo escucha el producto. AppShell solo lo
	// muestra en el botón (searchShortcut) y lo declara en aria-keyshortcuts.
	function onkeydown(event: KeyboardEvent) {
		if (event.key.toLowerCase() !== 'k' || event.altKey || event.shiftKey) return;
		const mac = /Mac|iPhone|iPad/.test(navigator.userAgent);
		if (!(mac ? event.metaKey : event.ctrlKey)) return;
		event.preventDefault();
		searching = !searching;
	}
</script>

<svelte:window {onkeydown} />

<!-- En el producto, data-product va en <html> (src/app.html): la capa del menú y la de búsqueda se
     abren en un portal y toman el mismo acento. La cabecera se va con la página (sin sticky): la de
     una página de lectura. La barra de lectura, en progress con placement="viewport" (por defecto),
     queda fija arriba de la ventana. -->
<AppShell
	mark="hermes"
	homeHref="#inicio"
	width="page"
	menu="fullscreen"
	searchHref="#buscar"
	searchShortcut="K"
	onSearch={() => (searching = true)}
>
	{#snippet navigation()}
		{#each links as link (link.href)}
			<AppShellLink href={link.href}>{link.label}</AppShellLink>
		{/each}
	{/snippet}

	{#snippet progress()}
		<ReadingProgress target={article} />
	{/snippet}

	{#snippet footer()}© 2026 Hermes{/snippet}

	{#snippet footerNavigation()}
		{#each footerLinks as link (link.href)}
			<AppShellLink href={link.href}>{link.label}</AppShellLink>
		{/each}
	{/snippet}

	<!-- La página de un post, recortada: la cabecera del texto y el artículo, que mide la barra. -->
	<Container style="padding-top: var(--arche-spacing-12)">
		<Masthead
			title="El Alma Pide Locura"
			dek="Hay caminos fuera del jardín. Sobre la sed que el saber no calma, y la disposición que el alma estaba pidiendo."
		>
			{#snippet eyebrow()}
				<Eyebrow topic="Filosofía" href="#filosofia" date="2026-07-27T21:45:16Z" />
			{/snippet}
		</Masthead>
	</Container>
	<Container width="reading" style="margin-top: var(--arche-spacing-12)">
		<article bind:this={article}>
			<Prose html={essay} />
		</article>
	</Container>
</AppShell>

<!-- La capa de búsqueda: el producto la abre con onSearch o con el atajo. La x queda al final de la
     columna de página, donde está «Buscar ⌘K»; el campo y los resultados, en la columna de lectura,
     centrada. -->
<Dialog bind:open={searching} size="fullscreen" title="Buscar">
	<div
		style="display: grid; gap: var(--arche-spacing-6); width: min(100%, var(--arche-layout-width-reading)); margin-inline: auto"
	>
		<Input
			size="xl"
			type="search"
			bind:value={query}
			aria-label="Buscar en Hermes"
			placeholder="Busca por título…"
			autocomplete="off"
			suffix={term ? `${found.length} ${found.length === 1 ? 'resultado' : 'resultados'}` : ''}
			liveSuffix
		/>
		{#if found.length > 0}
			<IndexList variant="listing" aria-label="Resultados" onclick={() => (searching = false)}>
				{#each found as post (post.slug)}
					<IndexListItem href={`#${post.slug}`} date={post.date}>
						{#snippet title()}<Highlight text={post.title} {query} />{/snippet}
					</IndexListItem>
				{/each}
			</IndexList>
		{/if}
	</div>
</Dialog>

La marca de cada producto

Cada producto muestra solo su marca, en blanco: con mark, su sigilo y su nombre; con mark="arche" (o sin marca ni nombre), la de Arche; con product («Nombre»: un producto sin sigilo, Consola), solo su nombre en la voz del logotipo. El acento del producto queda en la barra de la pestaña actual.

Marca de la cabecera
Svelte
<script lang="ts">
	import { AppShell, AppShellLink, type MarkName } from '@archeblack/ui';

	// La marca de la cabecera, en blanco y sin la de Arche al lado: con mark, el sigilo y el nombre
	// del producto; con product (un producto sin sigilo), solo el nombre; sin ninguno, Arche.
	let { mark, product }: { mark?: MarkName; product?: string } = $props();
</script>

<AppShell {mark} {product} homeHref="#inicio">
	{#snippet navigation()}
		<AppShellLink href="#inicio" current>Inicio</AppShellLink>
		<AppShellLink href="#archivo">Archivo</AppShellLink>
	{/snippet}

	<div style:padding="var(--arche-spacing-8) var(--arche-spacing-6)">
		<h1 style:margin="0" style:font="var(--arche-typography-heading)">Inicio</h1>
		<p style:margin="var(--arche-spacing-2) 0 0" style:color="var(--arche-color-text-muted)">
			La marca va en blanco; el acento del producto está en la barra de la pestaña actual.
		</p>
	</div>
</AppShell>

Con barra lateral

sidebar suma la navegación de una sección a la izquierda, con íconos. En un teléfono va al panel del menú, debajo de la navegación principal.

Svelte
<script lang="ts">
	import { AppShell, AppShellLink, Button } from '@archeblack/ui';
	import { IconBell, IconChartBar, IconHome, IconSettings, IconWorld } from '@archeblack/ui/icons';

	const pages = [
		{ id: 'general', label: 'General', icon: IconHome },
		{ id: 'dominios', label: 'Dominios', icon: IconWorld },
		{ id: 'metricas', label: 'Métricas', icon: IconChartBar },
		{ id: 'ajustes', label: 'Ajustes', icon: IconSettings }
	];

	let current = $state('dominios');
</script>

<AppShell product="Consola" homeHref="#general">
	{#snippet navigation()}
		<AppShellLink href="#resumen">Resumen</AppShellLink>
		<AppShellLink href="#proyectos" current>Proyectos</AppShellLink>
		<AppShellLink href="#equipo">Equipo</AppShellLink>
	{/snippet}

	{#snippet actions()}
		<Button variant="ghost" icon={IconBell} aria-label="Notificaciones" />
	{/snippet}

	{#snippet sidebar()}
		<!-- La barra lateral es navegación: el producto pone su <nav> con nombre. -->
		<nav aria-label="portal-clientes" style:display="grid" style:gap="var(--arche-spacing-1)">
			{#each pages as page (page.id)}
				<AppShellLink
					href="#{page.id}"
					icon={page.icon}
					current={current === page.id}
					onclick={() => (current = page.id)}
				>
					{page.label}
				</AppShellLink>
			{/each}
		</nav>
	{/snippet}

	<div
		style:display="grid"
		style:gap="var(--arche-spacing-2)"
		style:padding="var(--arche-spacing-8) var(--arche-spacing-6)"
	>
		<p
			style:margin="0"
			style:font="var(--arche-typography-label)"
			style:letter-spacing="var(--arche-typography-label-letter-spacing)"
			style:text-transform="uppercase"
			style:color="var(--arche-color-text-muted)"
		>
			portal-clientes
		</p>
		<h1 style:margin="0" style:font="var(--arche-typography-heading)">
			{pages.find((page) => page.id === current)?.label}
		</h1>
		<p style:margin="0" style:max-width="44rem" style:color="var(--arche-color-text-muted)">
			La barra lateral lleva la navegación del proyecto. En pantallas de menos de 48 rem pasa al
			panel del menú, debajo de la navegación principal.
		</p>
	</div>
</AppShell>

En un producto

El AppShell va una sola vez, en el +layout.svelte raíz. El producto se activa con data-product en <html> y su CSS de producto.

El acento del producto

data-product va en <html> y no en la raíz del AppShell: el panel del menú, los menús y los popovers se abren en un portal al final del <body>, y así también toman el acento. Con él, la barra de selección, los links y el foco cambian de color; la marca de la cabecera sigue en blanco.

html
<!doctype html><html lang="es" data-product="example">	<head>		<meta charset="utf-8" />		<meta name="viewport" content="width=device-width, initial-scale=1" />		%sveltekit.head%	</head>	<body data-sveltekit-preload-data="hover">		<div style="display: contents">%sveltekit.body%</div>	</body></html>

El layout raíz

Importa los estilos y el CSS del producto, pone el link para saltar al contenido y marca la página actual desde la URL.

svelte
<script lang="ts">	import '@archeblack/ui/fonts.css';	import '@archeblack/ui/tokens.css';	import '@archeblack/ui/base.css';	import '@archeblack/ui/products/example.css';	import { page } from '$app/state';	import { resolve } from '$app/paths';	import { AppShell, AppShellLink } from '@archeblack/ui';	let { children } = $props();	const links = [		{ href: resolve('/proyectos'), label: 'Proyectos' },		{ href: resolve('/equipo'), label: 'Equipo' }	];</script><a class="arche-skip-link" href="#content">Ir al contenido</a><AppShell product="Example" homeHref={resolve('/')} sticky>	{#snippet navigation()}		{#each links as link (link.href)}			<AppShellLink href={link.href} current={page.url.pathname.startsWith(link.href)}>				{link.label}			</AppShellLink>		{/each}	{/snippet}	{@render children()}</AppShell>

Props

AppShell

Props de AppShell
PropDescripción
children Snippet El contenido de la página. Va en un <main> sin relleno: cada página decide sus márgenes.
product? string Nombre de un producto sin sigilo: la cabecera lo muestra solo, en la voz del logotipo (Archivo 800 condensada al 75 %, 17 px, mayúsculas, tracking 0,1 em) y en blanco, sin la marca de Arche al lado ni un punto de color. Con mark se ignora. Sin mark ni product, la cabecera muestra la marca de Arche. Con menos de 48 rem de pantalla, un nombre largo se parte en lugar de cortarse con puntos suspensivos.
mark? 'arche' | 'hermes' | 'iris' | 'hekate' Marca del producto: la cabecera muestra su logotipo, el sigilo y el nombre (Logo layout="header" tone="white": sigilo de 20 px y palabra de 17 px a 8 px, en blanco), sin la marca de Arche al lado. Se ignora product. Con arche, la marca de Arche (su sigilo y ARCHE). El link a homeHref lo envuelve, con el anillo de foco de radio md.
width? 'full' | 'page' Por defecto 'full'full: la cabecera de una app, de borde a borde. page: la de un producto editorial, con la cabecera y el pie alineados con la columna de Container page (el margen fluido o lo que sobra de 80 rem a cada lado), también en un teléfono. En los dos la cabecera va en surface, con la línea border-subtle abajo.
homeHref? string Destino de la marca, casi siempre el inicio. El producto lo arma con resolve(). Sin homeHref, la marca no es un link.
navigation? Snippet Navegación principal: AppShellLink con current en el de la página actual. En la cabecera son pestañas con la barra de selección sobre la línea inferior; con menos de 48 rem de pantalla pasan al panel del menú.
navigationLabel? string Por defecto 'Principal'Nombre accesible del <nav> de la navegación principal.
actions? Snippet Acciones a la derecha de la cabecera: búsqueda, notificaciones, la cuenta. Quedan en la cabecera en todos los anchos, así que conviene que sean botones de solo ícono.
searchHref? string Destino de la búsqueda (la página /search). Con searchHref o onSearch, la cabecera suma el botón de búsqueda antes de las acciones: la lupa, «Buscar» y el atajo, sin caja (Button ghost sm, 28 px de alto: la voz de los links de la cabecera) y, con menos de 48 rem, solo la lupa (Button ghost de solo ícono). Es un link: sin JavaScript lleva a la página de búsqueda.
onSearch? (event: MouseEvent) => void Se llama al elegir el botón de búsqueda. El link no navega (salvo con una tecla modificadora, que lo abre en otra pestaña) y el producto abre su capa, por ejemplo un Dialog fullscreen con bind:open. Al cerrarla, el foco vuelve al botón.
searchLabel? string Por defecto 'Buscar'Texto del botón de búsqueda y nombre accesible de la lupa.
searchShortcut? string Letra del atajo, por ejemplo K: el botón muestra «⌘K» en Mac o «Ctrl K» (en la letra de las etiquetas, text-subtle, sin caja) y lo declara en aria-keyshortcuts. Aparece al hidratar, cuando se sabe el sistema. El atajo lo escucha el producto con <svelte:window onkeydown>.
sidebar? Snippet Barra lateral de 15 rem a la izquierda del contenido, con una línea border-subtle y su propio desplazamiento. Si es navegación, el producto pone su <nav aria-label>. Con menos de 48 rem de pantalla pasa al panel del menú, debajo de la navegación principal.
progress? Snippet Lugar para la barra de lectura, como último hijo del <header>. Con la cabecera que se va (sin sticky): <ReadingProgress target={article} />, fija arriba de la ventana. Con la cabecera fija: <ReadingProgress placement="header" target={article} />; la cabecera es siempre su contenedor posicionado, así la barra y su pista tapan la línea inferior de lado a lado de la ventana, y conviene que en esa página ningún link lleve current (la barra del link actual ocupa la misma franja). Ver «Cabecera fija o cabecera que se va».
footer? Snippet Texto del pie, al inicio («© 2026 Hermes»), en la letra de las etiquetas y text-subtle. Con footer o footerNavigation el shell suma un <footer> (contentinfo) con una línea border-subtle arriba, 32 px de relleno y 64 px desde el contenido (48 con menos de 48 rem), el ritmo entre bloques grandes de una página editorial. El menú fullscreen lo repite abajo.
footerNavigation? Snippet Links del pie, al final: AppShellLink de 13 px en text-muted, que en hover pasan a text-strong con el subrayado de Arche. Si no entran junto al texto, bajan a su propia línea.
footerNavigationLabel? string Por defecto 'Pie'Nombre accesible del <nav> del pie.
sticky? boolean Por defecto falseCabecera fija: queda arriba al desplazarse, con z-index-sticky y el fondo opaco de surface. Suma scroll-padding-top a la página para que un ancla o un control enfocado no quede debajo de ella. Es la de una app. Sin sticky, la cabecera se va con la página: la de una página de lectura, con la barra de lectura arriba de la ventana.
mainId? string Por defecto 'content'id del <main>, el destino del link para saltar al contenido.
menuLabel? string Por defecto 'Abrir la navegación'Nombre accesible del botón de menú de las pantallas chicas (solo tiene un ícono).
menuTitle? string Por defecto 'Navegación'Título del panel del menú, que también lo nombra. En el menú fullscreen queda oculto a la vista y sigue nombrando a la capa.
menu? 'sheet' | 'fullscreen' Por defecto 'sheet'Menú de las pantallas chicas. sheet: un panel desde la izquierda, con el botón al inicio de la cabecera. fullscreen: una capa a pantalla completa (Dialog fullscreen) con los links en typography.menu (la voz de los titulares a 40 px) y el botón al final de la cabecera; su fila de arriba repite la cabecera, con la marca en el mismo lugar y la x donde estaba el botón.
class? ClassValue Clases del producto; se suman a arche-app-shell.
...rest HTMLAttributes<HTMLDivElement> Cualquier otro atributo va a la raíz. Un data-product puesto en la raíz se repite en el panel del menú, que se abre en un portal fuera de ella.

AppShellLink

Props de AppShellLink
PropDescripción
href string Destino del link. En SvelteKit, con resolve().
children Snippet El texto del link.
current? boolean | 'section' Por defecto falseEs la página actual: pone aria-current="page", el texto en text-strong y la barra de 2 px en color-selected, abajo en la cabecera y al inicio en la barra lateral y el panel. Con section, la página actual es una hija del destino del link (el archivo de un tema bajo «Temas»): se ve igual y pone aria-current="true", porque el link no lleva a esta página.
icon? IconGlyph Ícono decorativo de 16 px antes del texto. Conviene en la barra lateral; en la cabecera, el texto solo.
class? ClassValue Clases del producto; se suman a arche-app-shell__link.
...rest HTMLAnchorAttributes Cualquier otro atributo va al <a>. Su onclick se llama y, si el link está en el panel del menú, el panel se cierra. En el menú fullscreen el link va dentro de su fila (arche-app-shell__menu-item), la que lleva la línea de arriba.

Accesibilidad

  • Tiene los landmarks de una app: la cabecera es un <header> (banner), la navegación principal un <nav> con nombre («Principal») y el contenido un <main> con id="content", el destino del link para saltar al contenido que pone el producto como primer elemento de la página.
  • Va una sola vez por página y fuera de otro <main>: por eso la documentación lo muestra dentro de un iframe.
  • El link de la página actual lleva aria-current="page", y el de su sección, en una página hija, aria-current="true" (current="section"): así el lector no anuncia como «página actual» un link que lleva a otra. La barra de selección y el texto en text-strong lo muestran, no solo el color. En colores forzados la barra pasa a Highlight.
  • La marca es un link nombrado con lo que se lee: el nombre del producto («Hermes», «Consola») o, sin producto, «Arche». La palabra se ve en mayúsculas por CSS, y Chrome pasa ese texto transformado al árbol de accesibilidad: el aria-label evita que el lector la deletree y contiene el texto visible. Con mark, el logotipo es una sola imagen con el mismo nombre y el sigilo va oculto por dentro.
  • Con menos de 48 rem de pantalla, la navegación y la barra lateral pasan a una Sheet que entra desde la izquierda. El botón de menú es un Button ghost de solo ícono con aria-label («Abrir la navegación»). La Sheet atrapa el foco y lo devuelve al botón al cerrar; elegir un link la cierra. Si la pantalla se agranda con el panel abierto, el panel se cierra y el foco, si estaba en el panel, pasa al link actual de la navegación de la cabecera (o al primero): el botón de menú ya no se ve y el foco no se pierde en la página.
  • Con sticky, la cabecera no tapa el foco: la página suma scroll-padding-top del alto de la cabecera, así un control enfocado con Tab o un ancla quedan a la vista (WCAG 2.4.11). Con la cabecera que se va no hay nada que reservar: solo la barra de lectura de 2 px queda arriba, y ReadingProgress suma 8 px de scroll-padding-top para que el anillo de foco no quede debajo de ella.
  • La navegación de la cabecera se desplaza a lo ancho si no entra, sin barra visible, y el anillo de foco de cada link no se recorta.
  • En colores forzados la marca, el sigilo incluido, toma el color de link del sistema: no depende del blanco ni del acento.
  • El menú fullscreen es un Dialog modal: el botón lleva aria-haspopup="dialog", aria-expanded y aria-controls; al abrir, el foco va al primer link, Tab no sale de la capa, Escape y la x la cierran, el foco vuelve al botón y la página de atrás no se desplaza. El título «Navegación» queda para los lectores de pantalla y nombra la capa; la marca de la fila de arriba es decorativa (ya está en la cabecera). Los links van en filas dentro del <nav>, sin lista, como en la cabecera: el snippet navigation es del producto y puede traer otra cosa además de links, que no sería un hijo válido de un <ul>. El actual lleva aria-current="page", el color de selección y una barra de 2 px al inicio de la fila, así no depende solo del color. Entra solo con opacidad: con movimiento reducido, sin transición.
  • El botón de búsqueda es un link a searchHref: sin JavaScript lleva a la página de búsqueda, y con una tecla modificadora se abre en otra pestaña. Sin caja mide 28 px de alto (el objetivo mínimo es 24, WCAG 2.5.8) y lleva el anillo de foco de base.css; su nombre es el texto («Buscar»), con la lupa decorativa. El atajo va en aria-keyshortcuts; el <kbd> es decorativo (aria-hidden). La lupa de las pantallas chicas tiene nombre («Buscar»).
  • El pie es un <footer> fuera de <main>: el landmark contentinfo. Sus links están en un <nav> con nombre («Pie»), miden 24 px de alto (WCAG 2.5.8) y en colores forzados se ven siempre subrayados.

Qué evitar

  • Un AppShell por sección o dentro de otro <main>. Uno solo en el +layout.svelte raíz del producto, que envuelve todas las páginas.
  • Más de cinco o seis links en la navegación principal. Las secciones de primer nivel arriba y el resto en la barra lateral de cada sección.
  • Un campo de búsqueda ancho en las acciones: en un teléfono no entra junto a la marca. Un botón de solo ícono que abre la búsqueda en un Popover o un Dialog.
  • Más de tres acciones en la cabecera. Tres como mucho (búsqueda, avisos y la cuenta); el resto en el Menu de la cuenta. En una pantalla de 320 px, con más de tres la marca ya no entra entera junto a las acciones.
  • Poner la marca de Arche al lado de la del producto, o el sigilo en su color. mark con la marca del producto: la cabecera la muestra sola y en blanco. El acento lo pone data-product en <html> y el CSS del producto, en los links, la selección y el foco.
  • Un <kbd> con estilos propios para mostrar el atajo de la búsqueda. searchShortcut: el botón lo muestra con la letra de las etiquetas y lo declara en aria-keyshortcuts.
  • Una cabecera propia para poner ReadingProgress en la página de un post. El snippet progress del AppShell: arriba de la ventana con la cabecera que se va, o en la línea de la cabecera fija.
  • La cabecera fija en un producto de lectura, o la barra de lectura arriba de la ventana con la cabecera fija (las dos franjas se pisan). La cabecera que se va (sin sticky) con ReadingProgress en viewport; la fija, con la barra en header, queda para las apps.
  • Marcar la página actual solo con el color del texto. current en el AppShellLink, que suma aria-current y la barra de selección.