blocks(app-shell): la pieza que posee las alturas de la página, y por eso el aviso deja de tapar la cabecera

F3.1 del tier, con su fase 0 firmada delante. El block que faltaba: el que
POSEE las regiones de una página de aplicación — sus landmarks, sus enlaces de
salto y sus alturas.

**A-95 se cierra por construcción, no compensando.** Bajo `scroll="main"` no
hay nada fijo: el aviso es una fila, el cromo es una fila y la página es la fila
que se desplaza. Medido con la tira puesta: aviso [0,54], cabecera [54,85],
**solape 0**, y el hit-test en el centro de la cabecera cae en la cabecera
(antes: 49px de solape y el clic en la tira). El eje `scroll` es el que Mantine
expone como `mode` y Atlassian como `isFixed` por slot; `body` existe porque
`docs-shell` lo necesitará para anclas y TOC.

Compone `Sidebar` (sus tres colapsos, su drawer móvil, sus dos landmarks),
`SkipLink`, `Sticky`, `Grid`/`Flex`/`Box`. Sin `.css`. Coordina tres booleanos
(`navOpen · asideOpen · mobile`) por contexto, sin máquina y sin palabras: nada
en un shell puede bloquearse, así que no hay frase que decir.

Medido en navegador (Playwright; 1280 y 375, claro y oscuro, LTR y RTL):

- landmarks por construcción — 1 banner · 1 main · 2 navigation NOMBRADOS ·
  2 complementary NOMBRADOS · 1 contentinfo; encabezados 1-2-2-2-2 sin saltos.
- `scroll="main"`: documento 720 = viewport, el main scrollea dentro y la
  cabecera se queda en 0 tras desplazar 900px. `scroll="body"`: el documento
  scrollea y la cabecera SE PEGA (top 0 a 900px de scroll).
- teclado: los tres enlaces de salto son las tres primeras paradas y Enter deja
  el foco en el `<main>`.
- 375: el raíl es drawer, devuelve el foco al trigger con Escape, el aside se
  pliega y el documento no crece. RTL: sin desbordamiento horizontal, raíl a la
  derecha, aside a la izquierda.

Cuatro averías que sólo aparecieron midiendo, y las cuatro son doctrina ahora:

1. `minHeight` es un SUELO, no un techo: con `100dvh` ahí el documento crecía
   con el contenido. Es `height`.
2. Un grid que sólo declara filas tiene UNA columna implícita `auto`, que se
   encoge a su contenido: la página salía en una tira de 32px al lado del raíl.
3. `Box` declara `flex-grow/shrink/basis`, así que pisa el `flex: 1 1 auto` de
   la receta del inset. Quien está más abajo en el árbol tiene que PEDIR crecer.
4. Un ancestro con `overflow` se lleva el `position: sticky` de dentro. El
   `overflow: auto` de `Sidebar.Inset` dejaba la barra sin pegar bajo `body`
   mientras `data-stuck` decía que sí — el atributo dando la razón a una página
   que no la tenía. Por eso el shell pone su propia caja: gap de canon anotado
   (el inset debería dejar elegir si es contenedor de scroll).

**Y una avería del ARNÉS que este block destapó**: los 19 previews del tier
llevaban los 8px de margen por defecto del navegador, así que ningún block se
enseñaba a sangre — contra la regla dura del propio tier. Medido:
`x: 8, y: 8, w: 1264` en 1280, la página compuesta incluida, cuyo README cita
cifras a sangre. Invisible porque todos scrollean; deja de serlo con un shell
que llena el viewport. Arreglado en el reset del arnés.

De paso, en el canon `skip-link`: el enlace del panel complementario decía «Ir
a la barra lateral», que es como todo el mundo llama al raíl de NAVEGACIÓN. En
una página que tiene los dos, nombraba al que no era. Ahora «Ir al panel
lateral».

Guards: `blocks:check` verde con 19 blocks · `docs:check` 0/0 (638) ·
`svelte-check` 74, ninguno aquí.

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

@ -0,0 +1,213 @@
# AppShell
## Function
The geometry of an application page: a navigation rail, a top bar, the content,
an optional complementary panel and an optional footer. It is the block that
**owns the page's regions** — their landmarks, their bypass links and their
heights — which is the one thing no component can own, because a component
cannot know what else is on the page.
That ownership is why the tier needed it. An affixed notice and a pinned header
both anchor to the viewport and neither can see the other, so the strip covered
the header by its entire height — 49px, measured, with the hit test landing on
the strip (ledger **A-95**). Every fix inside either block was a number agreed
by hand, and the author's question killed all of them at once: «¿y si tenemos
banners a diferentes alturas?». The shell answers by removing the question
rather than by picking a better number: under `scroll="main"` nothing is fixed
at all. The notice is a row, the chrome is a row, the page is the row that
scrolls.
```svelte
<AppShell bind:navOpen navLabel="Product" {navContent} {header}>
<PageContent />
</AppShell>
```
**Landmark + headings**: the shell emits `<header>` (`banner`), `<main>`,
`<footer>` (`contentinfo`) and — when an aside is mounted — a named `<aside>`
(`complementary`); the canon `Sidebar` brings the other two, its own named
`<aside>` and the `<nav>` inside it. So a full shell publishes **one banner, one
main, one navigation, two named complementary regions and one contentinfo**,
which is the APG's rule satisfied by construction: the only role that repeats is
`complementary`, and both instances carry a name. The shell emits **no heading
at all** — it is chrome, and the page's `h1` belongs to whatever the app renders
inside `children`.
## Composition map
| Slot / part | Composes | Notes |
| -------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| root | `Grid` (`templateRows`, `minHeight`) | two rows: the notice and everything else. `data-app-shell` + `data-app-shell-scroll` |
| bypass links | `SkipLink` ×1–3 | one per region that EXISTS (main · navigation · complementary), rendered first |
| `banner` | — (app: the `banner` block, or canon `Banner`) | **in flow**, above everything, intrinsic height |
| `navContent` | `Sidebar` + `Sidebar.Panel` + `Sidebar.Rail` | the app fills it with `Sidebar.Header` / `.Content` / `.Group` / `.Menu`. Omit the snippet for a stacked shell with no rail |
| page box | `Grid` (`scroll="main"`) · `Box` (`scroll="body"`) | the shell's own box beside the panel — NOT `Sidebar.Inset`, whose recipe owns `overflow` (see Decisions) |
| `header` | bare `<header>` (+ `Sticky` under `scroll="body"`) | the app drops `Sidebar.Trigger`, a `Breadcrumb`, a `Command` trigger, the user menu |
| `children` | bare `<main id>` | the scroll host under `scroll="main"`; the destination of the main skip link |
| `asideContent` | bare `<aside id aria-label>` | folds below `aside.breakpoint` (default `lg`) |
| `footer` | bare `<footer>` | `contentinfo` |
| coordination | `context.ts` → `useAppShell()` | `scroll · navOpen · asideOpen · mobile · toggleNav() · toggleAside()` |
## Coordination
_Position under the 2026-07-31 doctrine ([`architecture/blocks.md`](../../../../docs/architecture/blocks.md) §«Coordination»)._
**Coordinates, without a machine and without words.** The shell owns three
booleans its regions have to agree on — is the rail open, is the aside showing,
are we on a phone — and publishes them through `useAppShell()` so a toggle in
the header and a panel in the content read ONE value instead of each deriving
its own. That is the whole of it: nothing in a shell can be blocked or in
flight, so there is no exhaustive `Record` and no sentence to say. Compare
`contact`, which owns five states and a phrase for each because its send CAN be
blocked; inventing that here would be the opposite mistake.
`mobile` is read from the framework's own reactive breakpoint
(`dom.isAtLeast`), never a `matchMedia` of the block's (B-6) — and it is the
same source the `Sidebar` reads, so the two can never disagree about which
presentation is on screen.
Persistence is **not** here. A rail state remembered across visits is a cookie
the server reads to seed `navOpen` before the first paint, and that is the app's
job by D-BLK.6 — the same line the canon `Sidebar` drew for its own `open` seam.
## Decisions
### `scroll` is the axis, and it is a prop rather than two blocks
Every reference exposes it: Mantine as `mode: 'fixed' | 'static'`, Atlassian as
`isFixed` per slot with the rule written out — «on large viewports main can be
fixed… on small viewports the element will always use body scroll, to make it
easier to scroll the page when the content is tall». It is not a variant of
appearance; it decides who owns the scrollbar, which is the single most
consequential thing about a page's frame.
- **`scroll="main"`** — the shell fills the viewport (`100dvh`) and the main
region scrolls inside it. The chrome does not move because it never scrolls:
no `position: fixed`, no declared heights, no offsets to keep in sync. **This
is what closes A-95 by construction**, and it is the reason the notice slot
exists in flow rather than affixed.
- **`scroll="body"`** — the document scrolls, the way a docs or marketing page
does. Fragment anchors, a table of contents and the mobile URL bar all behave
natively; the header pins with `Sticky` instead of standing still. `docs-shell`
(F4.1) is the consumer this exists for.
⚠️ **The risk this splits deliberately**: Skeleton **retired** its `AppShell` in
v3 — «the number of issues introduced by it far outweigh its potential gains» —
citing accessibility problems in mobile browsers with `100vh` and a confused
sticky-header story. Both failures come from a shell that assumes ONE model.
Here the model is chosen, the viewport unit is `100dvh` (which follows the
mobile URL bar), and under `body` nothing caps the document at one screen.
### The header lives inside the inset
The provider of the canon `Sidebar` **is** the flex row (`sidebar.svelte`
renders the `<div data-sidebar>` that lays panel beside inset), so anything
inside it becomes a sibling of the panel. A bar spanning the full width above
the rail — Mantine's default layout, Atlassian's `TopNav`, Toolpad's `AppBar` —
would need that provider split in two, which is a change to a canon component
with 17 parts and a shipped contract. Not for v1. The arrangement we ship is
shadcn's and Mantine's `layout="alt"`, and it is the one where the rail reads as
the app's spine.
Registered as a canon gap for v2: a `layout` axis on `Sidebar` (provider ≠ row).
`docs-shell` will ask for it the day it wants a full-width top bar.
### The page box is the shell's own, not `Sidebar.Inset`
The inset is the obvious choice — it is the sidebar's own name for «the region
beside the panel» — and it WAS the choice, until two measurements took it away:
1. The inset renders `<main>` by default, so the page's `<header>` would sit
inside it, and a `banner` landmark inside `main` is not a page banner (APG:
`header` is `banner` only when its context is `body`). `child` solves that
one: it hands over the props and lets the region be a plain box.
2. Its recipe declares `overflow: auto`, which is right for a rail that fills a
viewport and wrong for a document that scrolls — **an ancestor with
`overflow` becomes the containing block of anything `position: sticky`
inside it**. Under `scroll="body"` the top bar stopped pinning and slid away
at −195px while `data-stuck` cheerfully said yes: the attribute agreeing
with a page that disagreed. And the `Box` prop could not win it back —
`[data-box]` and `[data-sidebar-inset]` have the same specificity, so the
later rule takes it.
What the inset actually contributes is four declarations (`flex`,
`min-inline-size`, `min-block-size`, `overflow`), and the first three are props
of `Box`. So the shell asks for them by name and skips the part. Registered as a
canon gap: `Sidebar.Inset` should let its consumer say whether it is a scroll
container.
Two more things the same measurements bought, both written into the block:
- **`flex="1 1 auto"` is not redundant** with the recipe that says the same:
`Box` declares `flex-grow`/`-shrink`/`-basis` itself, so a page box that does
not ASK to grow falls back to `0 1 auto` and collapses to its content —
measured at 32px wide, the page squeezed into a strip beside the rail.
- **The root grid needs `templateColumns`.** With only `templateRows`, its
single implicit column is `auto`-sized and shrinks to content, which is the
same 32px failure one level up.
### The bypass links are generated from the regions that exist
Not from a list the consumer writes. Polaris asks for a `ref`
(`skipToContentTarget`); Atlassian asks each slot for an `id` and a
`skipLinkTitle`. Here the shell already knows which regions it mounted and
already owns their ids, so the links follow — mount an aside and its link
appears, drop it and the link goes. This is WCAG technique **G124** (links to
each area of the content); with only the main region present it degrades to
**G1**, and both are sufficient.
The words are not the shell's: the canon `SkipLink` owns them, by landmark role,
because «Skip to main content» is the name of an accessibility contract and not
copy — the door B-7 / D-BLK.5 point at.
### Slots, not parts
The tier's shape rule: compound only when the parts REPEAT or COORDINATE with
each other. A shell's regions do neither — there is one of each, and they talk
to the SHELL, not to one another. So the regions are snippet slots and the
coordination is a context, which is the same answer `hero` and `cta` got. (The
F3.1 sketch in `PLAN-blocks.md` drew `<AppShell.Header>` parts; the deviation is
this paragraph, per the tier's rule that a sketch does not outrank the shape
rule.)
### `ScrollArea` is not composed
The fiche listed it. It is left out on purpose: the scroll host is the main
region, and mounting custom scrollbars on the surface that scrolls the whole
application is a visual decision nobody asked for — with a real cost, since
`ScrollArea` hides the native bars and replaces their behaviour. An app that
wants them composes `ScrollArea` inside `children`.
## Equivalencias
_Cada arreglo que las referencias shippean, frente a la composición nuestra que
lo logra. Una receta que no se ha visto en el navegador NO entra en esta tabla._
| Variante de la referencia | Refs | Receta | Demostrada en | Estado |
| --------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------- |
| Rail + topbar + contenido (el shell canónico) | Mantine · shadcn · AntD · Toolpad · TW Plus | por defecto: `navContent` + `header` + `children` | control `rail` | cubierta |
| Colapso a raíl de iconos en escritorio | shadcn · AntD · Toolpad · Atlassian | `nav={{ collapsible: 'icon' }}` (del canon `Sidebar`) | control `collapsible` | cubierta |
| Colapso off-canvas | shadcn · Mantine | `nav={{ collapsible: 'offcanvas' }}` | control `collapsible` | cubierta |
| Rail en drawer bajo un breakpoint | los 6 | `nav={{ mobileBreakpoint }}` — el `Sidebar` compone `Drawer` | anchura 375 de la demo | cubierta |
| Shell **stacked** / topbar-only (el primer fork de todas) | TW Plus (9) · Mantine · AntD Pro `layout` | omitir `navContent` | control `rail: off` | cubierta |
| Panel derecho / aside | Mantine · Atlassian · TW Plus (multi-col) | `asideContent` + `aside={{ breakpoint }}` | control `aside` | cubierta |
| Footer de página | Mantine · AntD | `footer` | control `footer` | cubierta |
| Aviso global sobre el cromo | Polaris (`globalRibbon`) · Atlassian | `banner` — EN FLUJO, altura intrínseca | control `banner` | cubierta, y es superación: sin altura declarada |
| Main con scroll propio vs scroll del documento | Mantine (`mode`) · Atlassian (`isFixed`) | `scroll="main" \| "body"` | control `scroll` | cubierta |
| Skip-links | Polaris · Atlassian | automáticos, uno por región montada | primer Tab de la demo | cubierta, y es superación: nadie los deriva de las regiones |
| Topbar a todo lo ancho POR ENCIMA del rail | Mantine (`default`) · Atlassian · Toolpad | — | — | **gap**: pide partir el provider del `Sidebar` (ver Decisiones); v2 |
| Rail redimensionable con ratón y teclado | Atlassian | — | — | **no se ofrece**: el `Sidebar` lo descartó en su v1 (un ancho continuo pide persistencia y contrato de tamaño) |
| Persistencia del rail (cookie + SSR sin parpadeo) | shadcn · Atlassian | `bind:navOpen` — la app la lee del servidor y la siembra | — | app-land por diseño (D-BLK.6) |
| Atajo de teclado del rail (⌘B / Ctrl+[) | shadcn · Atlassian | `useAppShell().toggleNav()` desde un listener de la app | — | app-land por diseño; Atlassian lo ignora bajo un modal, y esa política es del app |
## Gaps
| Gap | Disposición |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Los primitivos de layout no pueden cambiar de elemento** (F21) | **canon, registrado**: `Box` —y con él `Grid`/`Flex`/`Stack`— renderiza un `<div>` fijo, así que `<main>` / `<aside>` no pueden SER una caja del sistema. Por eso esos dos elementos llevan un `style` en línea con dos declaraciones (`flex`, `min-inline-size`, `overflow`). No es re-estilizar internals de un compuesto (B-3): es el elemento propio del block. Precedente en el tier: `article-grid-article.svelte` |
| **Topbar a todo lo ancho** (eje `layout` del `Sidebar`) | **canon, v2** — el provider del `Sidebar` es la fila flex. Lo pedirá `docs-shell` (F4.1) |
| **El `Sidebar` no publica su ancho actual** | no hace falta aquí (la reserva es flexbox), pero un app que quiera anclar algo al borde del rail no tiene a qué. Mantine expone 8 vars `--app-shell-*`; anotado por si aparece un consumidor real |
| **Superficie online/offline** | no existe en el ecosistema (ni art ni dimensión de prefs). Un ribbon de «sin conexión» en `banner` lo tiene que alimentar el app desde `Connections.anyReconnecting` |
| **Dos landmarks `banner`** cuando el `banner` que entra es el canon `Banner` | **ledger A-109**, del canon: `Banner` estampa `role="banner"` DESPUÉS de los rest props, así que no se puede quitar. Nombrar los dos es todo lo que puede un app |
| `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 |

@ -0,0 +1,278 @@
<script lang="ts">
/**
* AppShell — the geometry of an application page: a rail, a top bar, the
* content, an optional complementary panel and an optional footer.
*
* What it is FOR, since a shell can look like a `Grid` with opinions: it is
* the piece that OWNS the page's regions — their landmarks, their bypass
* links, and their heights. That last one is why the tier needed it. An
* affixed notice and a pinned header both anchor to the viewport and neither
* of them can know about the other, so the strip covered the header by its
* whole height (49px, ledger A-95) and no fix inside either block could be
* more than a number agreed by hand — which breaks the day the notice wraps
* to two lines, or there are two of them. The shell answers by removing the
* question: under `scroll="main"` nothing is fixed at all. The notice is a
* row, the chrome is a row, the page is the row that scrolls.
*
* B contract: composes canon only, ships no `.css`, and every visible word
* arrives from the app. The one thing it says on its own is the bypass
* links, and it does not say those either — the canon `SkipLink` owns those
* 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).
*
* B-10 exception: shells compose other blocks by design (`SHELL_ALLOWLIST`
* in `blocks-check.ts`). This one composes none yet — the app drops a
* `banner` or a `site-footer` into its slots.
*/
import { Box } from '$uix/eidos/components/box';
import { Grid } from '$uix/eidos/components/grid';
import { Flex } from '$uix/eidos/components/flex';
import { Sidebar } from '$uix/eidos/components/sidebar';
import { SkipLink } from '$uix/eidos/components/skip-link';
import { Sticky } from '$uix/eidos/components/sticky';
import { ActiveEidos } from '$uix/eidos';
import { setAppShellContext } from './context';
import type { AppShellProps } from './types';
let {
scroll = 'main',
nav,
aside,
navOpen = $bindable(true),
asideOpen = $bindable(true),
navLabel,
asideLabel,
banner,
navContent,
header,
children,
asideContent,
footer,
...rest
}: AppShellProps = $props();
const uid = $props.id();
const mainId = `${uid}-main`;
const navId = `${uid}-nav`;
const asideId = `${uid}-aside`;
const dom = ActiveEidos.require().dom;
const collapsible = $derived(nav?.collapsible ?? 'icon');
const mobileBreakpoint = $derived(nav?.mobileBreakpoint ?? 'md');
const asideBreakpoint = $derived(aside?.breakpoint ?? 'lg');
/**
* The rail is a drawer below its breakpoint. Read from the framework's own
* reactive breakpoint, never a `matchMedia` of our own (B-6) — and the same
* source the `Sidebar` reads, so the two can never disagree about which
* presentation is on screen.
*/
const mobile = $derived(!dom.isAtLeast(mobileBreakpoint));
/** The aside folds away where there is no room for it. Same source, same reason. */
const asideFits = $derived(dom.isAtLeast(asideBreakpoint));
const asideVisible = $derived(!!asideContent && asideOpen && asideFits);
// The page fills the viewport ONLY when the main region owns the scroll.
// Under `scroll="body"` a `100dvh` root would cap the document at one screen
// and hand the overflow to an inner box — the failure Skeleton cited when it
// retired its own AppShell in v3.
const fillsViewport = $derived(scroll === 'main');
setAppShellContext({
get scroll() {
return scroll;
},
get navOpen() {
return navOpen;
},
get asideOpen() {
return asideOpen;
},
get mobile() {
return mobile;
},
toggleNav() {
navOpen = !navOpen;
},
toggleAside() {
asideOpen = !asideOpen;
}
});
/** The scroll host the header pins against — the main box, or the viewport. */
let mainEl = $state<HTMLElement | null>(null);
</script>
<!--
The bypass links, and they are FIRST because that is the entire mechanism:
a skip link reached after the chrome has skipped nothing. One per region
that actually exists, which is WCAG technique G124 — and generating them
from the mounted regions instead of a written list is the half neither
Polaris nor Atlassian ships (both make the consumer supply an id and a
label by hand).
-->
{#snippet skipLinks()}
<SkipLink to={mainId} region="main" />
{#if navContent}
<SkipLink to={navId} region="navigation" />
{/if}
{#if asideVisible}
<SkipLink to={asideId} region="complementary" />
{/if}
{/snippet}
<!--
The inset's contents: the top bar, then the page beside its panel, then the
footer. `<header>` is the page's banner landmark, so it CANNOT be inside the
`<main>` the sidebar renders by default — hence the `child` on `Sidebar.Inset`
below, which hands us its props and lets the region be a plain box.
-->
{#snippet insetBody()}
{#if header}
{#if scroll === 'body'}
<!-- The document scrolls, so the bar has to pin. `Sticky` with no
`root` observes the viewport, which is the scroll host here. -->
<Sticky offset={0}>
<header>{@render header()}</header>
</Sticky>
{:else}
<!-- The main region scrolls underneath; the bar simply stays where it
is. No `position`, no z-index, no height to declare — the reason
`scroll="main"` closes A-95 instead of compensating for it. -->
<header>{@render header()}</header>
{/if}
{/if}
<Flex align="stretch" minHeight={0}>
<!--
`min-inline-size: 0` because a grid/flex item's floor is its CONTENT,
so one wide table would push the whole page instead of scrolling
inside it. `overflow` makes this box the scroll host under
`scroll="main"`.
⚠️ Inline style, and it is the block's own landmark element — not a
composed component's internals (B-3). The reason is a registered canon
gap: the layout primitives cannot change element (F21 in
`PLAN-blocks-quality.md`), so `<main>` cannot BE a `Box` and there is
nowhere else to put these two declarations. Recorded in the README.
-->
<main
id={mainId}
bind:this={mainEl}
style={scroll === 'main'
? 'flex: 1 1 auto; min-inline-size: 0; min-block-size: 0; overflow: auto;'
: 'flex: 1 1 auto; min-inline-size: 0;'}
>
{@render children?.()}
</main>
{#if asideVisible}
<aside id={asideId} aria-label={asideLabel} style="flex: 0 0 auto; overflow: auto;">
{@render asideContent?.()}
</aside>
{/if}
</Flex>
{#if footer}
<footer>{@render footer()}</footer>
{/if}
{/snippet}
<!--
The root grid: the notice, then everything else. Two rows and no more,
because the shell only ever stacks those two things — the rail and the page
share the second row through the sidebar, which already knows how to lay a
panel beside an inset and how to become a drawer.
-->
<Grid
{...rest}
data-app-shell=""
data-app-shell-scroll={scroll}
templateRows={banner ? 'auto minmax(0, 1fr)' : 'minmax(0, 1fr)'}
templateColumns="minmax(0, 1fr)"
height={fillsViewport ? '100dvh' : undefined}
>
{@render skipLinks()}
{#if banner}
{@render banner()}
{/if}
{#if navContent}
<Sidebar bind:open={navOpen} {collapsible} side={nav?.side} {mobileBreakpoint}>
<!--
The block owns the Panel, not the app: it is the landmark the
navigation skip link jumps to, so it needs the id, and a region
the app could forget to render is a bypass link pointing at
nothing. The app fills it with `Sidebar.Header` / `.Content` /
`.Footer` — never another `Panel`.
`navLabel` names THIS element (the `complementary` region), which
is what the skip link lands on. Undefined leaves the canon's own
localized name: for `aria-label` the consumer wins when it says
something, and says nothing by staying quiet.
-->
<Sidebar.Panel id={navId} aria-label={navLabel}>
{@render navContent()}
</Sidebar.Panel>
{#if collapsible !== 'none' && !mobile}
<Sidebar.Rail />
{/if}
{@render pageBox()}
</Sidebar>
{:else}
<!--
The stacked shell — no rail. It is the first fork every reference
offers (Tailwind Plus ships nine of them), and here it costs nothing:
the same page box without the sidebar around it.
-->
{@render pageBox()}
{/if}
</Grid>
{#snippet pageBox()}
<!--
The page's box, and the shell renders it ITSELF rather than taking
`Sidebar.Inset`.
The inset would be the obvious choice — it is the sidebar's own name for
«the region beside the panel» — and it was, until `scroll="body"` was
measured: its recipe declares `overflow: auto`, which is right for a rail
that fills a viewport and wrong for a document that scrolls, because an
ancestor with `overflow` becomes the containing block of anything
`position: sticky` inside it. The header stopped pinning and slid away at
-195px while `data-stuck` cheerfully said yes: the attribute agreeing with
a page that disagreed. And the prop could not win it back — `[data-box]`
and `[data-sidebar-inset]` have the same specificity, so the later rule
takes it.
What the inset actually contributes is four declarations (`flex`,
`min-inline-size`, `min-block-size`, `overflow`), and the first three are
props of `Box`. So the shell asks for them by name. Registered as a canon
gap: `Sidebar.Inset` should let its consumer say whether it is a scroll
container.
-->
{#if fillsViewport}
<Grid
flex="1 1 auto"
minWidth={0}
minHeight={0}
height="100%"
templateRows="auto minmax(0, 1fr) auto"
templateColumns="minmax(0, 1fr)"
>
{@render insetBody()}
</Grid>
{:else}
<!--
Block flow, NOT a grid: `position: sticky` is confined to its
containing block, and a grid item's containing block is its AREA — an
`auto` row is exactly as tall as the header, so the bar had nowhere to
travel.
-->
<Box flex="1 1 auto" minWidth={0}>
{@render insetBody()}
</Box>
{/if}
{/snippet}

@ -0,0 +1,33 @@
import { getContext, setContext } from 'svelte';
import type { AppShellState } from './types';
/**
* The shell's coordination, published for whatever the app puts in its regions.
*
* Why it exists at all, since a shell is mostly layout: the pieces that live in
* a shell's chrome need to agree about ONE thing — is the rail open, is the
* aside showing, are we on a phone — and if the answer lives outside, every
* consumer re-derives it at the point of use and the answers drift. That is the
* exact failure the tier's coordination doctrine names (`blocks.md`
* §Coordination): the state of a section belongs to the section.
*
* It is deliberately SMALL. No words, no machine, no `disabled`: nothing in a
* shell can be blocked, so there is nothing to explain. Compare `contact`,
* which owns five states and a sentence for each because its send CAN be
* blocked. Inventing that here would be the opposite mistake.
*
* The canon `Sidebar` publishes its own state through `useSidebar()`; this does
* not replace it. Anything INSIDE the rail reads the sidebar's context, which is
* richer (it knows `iconMode`). This one answers the pieces OUTSIDE it — a
* toggle in the header, a panel that wants to know whether the aside is up.
*/
const KEY = Symbol('uix.app-shell');
export function setAppShellContext(state: AppShellState): void {
setContext(KEY, state);
}
/** Read the shell's state. `undefined` outside a shell — callers degrade. */
export function useAppShell(): AppShellState | undefined {
return getContext<AppShellState | undefined>(KEY);
}

@ -0,0 +1,23 @@
// AppShell — the geometry of an application page: rail · top bar · content
// (+ complementary panel, + footer).
//
// import { AppShell, useAppShell } from '$blocks/app-shell';
//
// <AppShell bind:navOpen navLabel="Main" {navContent} {header}>
// …the page…
// </AppShell>
//
// Content arrives by snippet and the shell owns the landmarks — which is the
// point: an app cannot forget the `<main>` that its skip link jumps to.
import AppShell from './app-shell.svelte';
export { AppShell };
export default AppShell;
export { useAppShell } from './context';
export type {
AppShellProps,
AppShellScroll,
AppShellNav,
AppShellAside,
AppShellState
} from './types';

@ -0,0 +1,106 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { Breakpoint } from '$adom';
import type { SidebarCollapsible, SidebarSide } from '$uix/eidos/components/sidebar';
/**
* Who scrolls. The axis every reference exposes and the one that decides the
* whole geometry — Mantine calls it `mode`, Atlassian sets it per slot with
* `isFixed`.
*
* - `main` — the shell fills the viewport (`100dvh`) and the main region has
* its own scroll container. The chrome does not move because it never
* scrolls: no `position: fixed`, no declared heights, no offsets to keep in
* sync. This is what closes ledger row A-95 by construction.
* - `body` — the document scrolls, the way a marketing or docs page does.
* Anchors, a table of contents and the mobile URL bar all behave natively;
* the header pins with `Sticky` instead of standing still.
*/
export type AppShellScroll = 'main' | 'body';
/** Navigation rail configuration — forwarded to the canon `Sidebar`. */
export type AppShellNav = {
/** `offcanvas` slides out · `icon` narrows to a rail · `none` is fixed. @default 'icon' */
collapsible?: SidebarCollapsible;
/** Which side the rail sits on. @default 'left' */
side?: SidebarSide;
/** Below this breakpoint the rail becomes a drawer. @default 'md' */
mobileBreakpoint?: Breakpoint;
};
/** Complementary panel configuration. */
export type AppShellAside = {
/**
* Below this breakpoint the aside folds away. A right panel is the first
* thing a narrow viewport cannot afford, and folding it is not a decision
* the app should have to re-derive at every width.
* @default 'lg'
*/
breakpoint?: Breakpoint;
};
// `class` and `style` are omitted from the element attributes and re-declared
// as plain strings: the shell's root IS a `Grid`, and Svelte's
// `ClassValue | null` / `string | null` do not fit `Box`'s `string`. Narrowing
// here keeps the spread honest instead of casting it at the call site.
export type AppShellProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'class' | 'style'> & {
/** Extra class names on the shell's root. */
class?: string;
/** Extra inline style on the shell's root. */
style?: string;
/** Who scrolls — see {@link AppShellScroll}. @default 'main' */
scroll?: AppShellScroll;
/** Rail configuration. Omit the `nav` snippet entirely for a stacked shell. */
nav?: AppShellNav;
/** Aside configuration. */
aside?: AppShellAside;
/**
* Whether the rail is expanded. Bindable — the shell coordinates it so the
* header's trigger, the rail and the drawer all read one value; PERSISTING
* it (a cookie read on the server to avoid a hydration flash) is the app's,
* like every other service.
* @default true
*/
navOpen?: boolean;
/** Whether the aside is showing. Bindable. @default true */
asideOpen?: boolean;
/**
* Accessible name for the whole navigation region. Passed to the canon
* `Sidebar`, which names its `<aside>` and its `<nav>` with it.
*/
navLabel?: string;
/** Accessible name for the complementary panel. Required when `asideContent` is used. */
asideLabel?: string;
/**
* A page-wide notice, IN FLOW above everything. In flow is the decision:
* an affixed strip reserves no space, so it covers the chrome underneath —
* measured at 49px, the whole height of a header (ledger A-95). Here the
* notice pushes, and the geometry stays true whatever its height, whether
* there are two of them, or whether it wraps to three lines on a phone.
*/
banner?: Snippet;
/**
* The rail's content — the app composes `Sidebar.Header` / `.Content` /
* `.Group` / `.Menu` inside it. Omit for a shell with no rail.
*/
navContent?: Snippet;
/** The top bar: the rail trigger, a breadcrumb, a search trigger, the user menu. */
header?: Snippet;
/** The page. */
children?: Snippet;
/** The complementary panel beside the page. */
asideContent?: Snippet;
/** The page footer. */
footer?: Snippet;
};
/** What `useAppShell()` hands to anything that needs the shell's state. */
export type AppShellState = {
readonly scroll: AppShellScroll;
readonly navOpen: boolean;
readonly asideOpen: boolean;
/** True while the rail is a drawer — the system breakpoint decided it, never a `matchMedia`. */
readonly mobile: boolean;
toggleNav(): void;
toggleAside(): void;
};

@ -36,7 +36,7 @@
const REGION_FALLBACKS = {
main: 'Skip to main content',
navigation: 'Skip to navigation',
complementary: 'Skip to sidebar',
complementary: 'Skip to side panel',
search: 'Skip to search',
banner: 'Skip to header',
contentinfo: 'Skip to footer'

@ -16,9 +16,11 @@ export const skipLinkLangs = {
es: 'Ir a la navegación',
en: 'Skip to navigation'
},
// NOT «barra lateral» / «sidebar»: that is the word everyone uses for the
// NAVIGATION rail, so on a page that has both it would name the wrong one.
'to-complementary': {
es: 'Ir a la barra lateral',
en: 'Skip to sidebar'
es: 'Ir al panel lateral',
en: 'Skip to side panel'
},
'to-search': {
es: 'Ir a la búsqueda',

@ -44,7 +44,7 @@ export const skipLinkMorfo = {
// so the caller names a CONTRACT, not a piece of copy.
'to-main': '#?components.skip-link.to-main|Skip to main content',
'to-navigation': '#?components.skip-link.to-navigation|Skip to navigation',
'to-complementary': '#?components.skip-link.to-complementary|Skip to sidebar',
'to-complementary': '#?components.skip-link.to-complementary|Skip to side panel',
'to-search': '#?components.skip-link.to-search|Skip to search',
'to-banner': '#?components.skip-link.to-banner|Skip to header',
'to-contentinfo': '#?components.skip-link.to-contentinfo|Skip to footer'

@ -57,7 +57,7 @@ export const BLOCK_CATALOG: BlockGroup[] = [
{
title: 'App',
items: [
{ slug: 'app-shell', label: 'App shell', shipped: false },
{ slug: 'app-shell', label: 'App shell', shipped: true },
{ slug: 'auth', label: 'Auth', shipped: false },
{ slug: 'data-table', label: 'Data table', shipped: false },
{ slug: 'dashboard', label: 'Dashboard', shipped: false },

@ -24,3 +24,23 @@
*::after {
box-sizing: border-box;
}
/*
* The browser's default `body { margin: 8px }`, removed — and it is not a
* cosmetic call either.
*
* The tier's hard rule is that a block is shown A SANGRE: nothing between it
* and the edge, because a frame changes what the block DOES (a header pinned at
* `offset: 0` measured 21px off inside a Card). That rule was being broken by
* the document itself: measured 2026-08-19, every preview rendered at
* `x: 8, y: 8, w: 1264` inside a 1280 viewport — the composed landing page
* included, whose README quotes full-bleed numbers.
*
* It stayed invisible because every other block's page scrolls anyway, so 16px
* of extra document height reads as nothing. `app-shell` is where it stops
* being invisible: a shell that fills the viewport (`100dvh`) plus 16px of
* margin is a page with a scrollbar and no content to scroll.
*/
body {
margin: 0;
}

@ -0,0 +1,293 @@
<script lang="ts">
/**
* AppShell demo. Full-bleed on the page; device widths from `./preview`.
*
* The shell is the one block whose full-bleed rule is not a nicety: it owns
* the page's height, so a frame with padding would give it a viewport that
* is not the viewport and every measurement taken here would be a
* measurement of the frame.
*/
import { Stack } from '$uix/eidos/components/stack';
import { Group } from '$uix/eidos/components/group';
import { Wrap } from '$uix/eidos/components/wrap';
import { Text } from '$uix/eidos/components/text';
import { Code } from '$uix/eidos/components/code';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import BlockDemo from '../_lib/BlockDemo.svelte';
import DocRow from '../_lib/DocRow.svelte';
import AppShellSite from './AppShellSite.svelte';
import type { AppShellScroll } from '$blocks/app-shell';
import type { SidebarCollapsible } from '$uix/eidos/components/sidebar';
let scroll = $state<AppShellScroll>('main');
let collapsible = $state<SidebarCollapsible>('icon');
let rail = $state(true);
let aside = $state(true);
let footer = $state(true);
let banner = $state(false);
const previewSrc = $derived(
`/blocks/app-shell/preview?scroll=${scroll}&collapsible=${collapsible}&rail=${rail}&aside=${aside}&footer=${footer}&banner=${banner}`
);
</script>
<BlockDemo
name="AppShell"
slug="$blocks/app-shell"
{previewSrc}
previewHeight="760px"
meta={[
{ key: 'compone', value: 'Sidebar · SkipLink · Sticky · Grid · Flex' },
{ key: 'landmark', value: 'banner · main · navigation · complementary ×2 · contentinfo' },
{ key: 'forma', value: 'slots de snippet + contexto' },
{ key: 'coordina', value: 'navOpen · asideOpen · mobile' }
]}
>
{#snippet preview()}
<AppShellSite {scroll} {collapsible} {rail} {aside} {footer} {banner} />
{/snippet}
{#snippet lede()}
El esqueleto de una página de aplicación, y el block que <strong>posee las regiones</strong> de
la página: sus landmarks, sus enlaces de salto y sus alturas. Esa última es la que faltaba en el
tier — un aviso fijado y una cabecera pegada se anclan los dos al viewport sin verse, y la tira
tapaba la cabecera entera (49px medidos, fila <Code>A-95</Code>). Aquí no se compensa con un
número: bajo
<Code>scroll="main"</Code> no hay nada fijo. El aviso es una fila, el cromo es una fila, y la página
es la fila que se desplaza.
{/snippet}
{#snippet controls()}
<Wrap gap={5}>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">scroll</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[scroll]}
onValueChange={(v) => (scroll = (v[0] ?? scroll) as AppShellScroll)}
aria-label="quién scrollea"
>
<ToggleGroup.Item value="main">main</ToggleGroup.Item>
<ToggleGroup.Item value="body">body</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">collapsible</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[collapsible]}
onValueChange={(v) => (collapsible = (v[0] ?? collapsible) as SidebarCollapsible)}
aria-label="qué hace el colapso del raíl"
>
<ToggleGroup.Item value="icon">icon</ToggleGroup.Item>
<ToggleGroup.Item value="offcanvas">offcanvas</ToggleGroup.Item>
<ToggleGroup.Item value="none">none</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">raíl</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[rail ? 'sí' : 'no']}
onValueChange={(v) => (rail = (v[0] ?? 'sí') === 'sí')}
aria-label="con raíl o shell apilado"
>
<ToggleGroup.Item value="sí">con raíl</ToggleGroup.Item>
<ToggleGroup.Item value="no">apilado</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">aside</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[aside ? 'sí' : 'no']}
onValueChange={(v) => (aside = (v[0] ?? 'sí') === 'sí')}
aria-label="panel complementario"
>
<ToggleGroup.Item value="sí">sí</ToggleGroup.Item>
<ToggleGroup.Item value="no">no</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">footer</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[footer ? 'sí' : 'no']}
onValueChange={(v) => (footer = (v[0] ?? 'sí') === 'sí')}
aria-label="pie de página"
>
<ToggleGroup.Item value="sí">sí</ToggleGroup.Item>
<ToggleGroup.Item value="no">no</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">aviso</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[banner ? 'sí' : 'no']}
onValueChange={(v) => (banner = (v[0] ?? 'no') === 'sí')}
aria-label="aviso de página"
>
<ToggleGroup.Item value="sí">sí</ToggleGroup.Item>
<ToggleGroup.Item value="no">no</ToggleGroup.Item>
</ToggleGroup>
</Group>
</Wrap>
{/snippet}
{#snippet composition()}
<Stack gap={4}>
<Text color="muted">
Todo lo que se ve es canon: el block coloca, nombra y coordina. No trae ni un
<Code>.css</Code>.
</Text>
<Stack gap={3}>
<DocRow term="el raíl">
El <Code>Sidebar</Code> del canon entero, con sus tres modos de colapso, su drawer móvil por
el breakpoint del SISTEMA y sus dos landmarks nombrados. El block no reimplementa nada de eso:
le pasa la configuración y le presta su <Code>navOpen</Code>.
</DocRow>
<DocRow term="la rejilla">
<Code>Grid</Code> con <Code>templateRows</Code>. Dos filas arriba (aviso · resto) y tres
dentro del inset (cabecera · página · pie). El <Code>Grid</Code> reenvía sus rest props al
<Code>Box</Code>, así que el inset del <Code>Sidebar</Code> y la rejilla del shell son
<strong>un solo elemento</strong>: sin div intermedio.
</DocRow>
<DocRow term="la cabecera">
Va DENTRO del inset, no por encima del raíl, y no es estética: el provider del
<Code>Sidebar</Code> ES la fila flex, así que su <Code>Trigger</Code> sólo vive dentro. Es la
disposición de shadcn y del <Code>layout="alt"</Code> de Mantine. La de Mantine por defecto
pide partir ese provider — gap de canon anotado para v2.
</DocRow>
<DocRow term="los enlaces de salto">
Uno por región que EXISTE, generados de las regiones montadas. Monta un aside y aparece el
suyo. Es la técnica G124 de la WCAG, y la mitad que ni Polaris ni Atlassian traen: los dos
piden al consumidor un <Code>id</Code> y una etiqueta a mano.
</DocRow>
<DocRow term="el aviso">
En flujo, con su altura intrínseca. Es la respuesta a <Code>A-95</Code>: cualquier número
acordado se rompe con un aviso más alto, con dos avisos, o con un mensaje que envuelve en
un móvil.
</DocRow>
</Stack>
</Stack>
{/snippet}
{#snippet api()}
<Stack gap={3}>
<DocRow term="scroll">
<Code>'main'</Code> (por defecto): el shell llena el viewport (<Code>100dvh</Code>) y el
<Code>&lt;main&gt;</Code> tiene su propio scroll — nada fijo, ningún offset que mantener.
<Code>'body'</Code>: scrollea el documento y la cabecera se pega con <Code>Sticky</Code>. Es
el eje que Mantine expone como <Code>mode</Code> y Atlassian como <Code>isFixed</Code> por slot.
</DocRow>
<DocRow term="nav · aside">
<Code>nav={'{ collapsible, side, mobileBreakpoint }'}</Code> se reenvía al
<Code>Sidebar</Code>; <Code>aside={'{ breakpoint }'}</Code> decide dónde se pliega el panel.
</DocRow>
<DocRow term="navOpen · asideOpen">
Bindables. El shell los COORDINA (la cabecera, el raíl y el drawer leen uno solo); la
persistencia es del app — una cookie leída en el servidor para no parpadear al hidratar, la
misma línea que trazó el <Code>Sidebar</Code> (D-BLK.6).
</DocRow>
<DocRow term="banner · navContent · header · children · asideContent · footer">
Slots de snippet. El block pone los elementos de landmark alrededor; el contenido es del app
(B-7). Omite <Code>navContent</Code> y sale el shell apilado, el primer fork de todas las referencias.
</DocRow>
<DocRow term="useAppShell()">
<Code>scroll · navOpen · asideOpen · mobile · toggleNav() · toggleAside()</Code>. Para lo
que vive FUERA del raíl; lo de dentro lee <Code>useSidebar()</Code>, que sabe más.
</DocRow>
</Stack>
{/snippet}
{#snippet a11y()}
<Stack gap={3}>
<DocRow term="Los landmarks salen por construcción">
Un shell completo publica <strong>un</strong>
<Code>banner</Code>, <strong>un</strong>
<Code>main</Code>, <strong>una</strong>
<Code>navigation</Code>, <strong>dos</strong>
<Code>complementary</Code> NOMBRADOS (el panel del raíl y el aside) y <strong>un</strong>
<Code>contentinfo</Code>. La regla de la APG —el rol que se repite lleva nombre— se cumple
sin que el app haga nada.
</DocRow>
<DocRow term="Los enlaces de salto son el primer foco">
WCAG 2.4.1. Con sólo el main es la técnica G1; con las tres regiones, G124. Las palabras no
son del block: las posee el canon <Code>SkipLink</Code>, por rol de landmark, porque «Ir al
contenido principal» es el nombre de un contrato y no copy.
</DocRow>
<DocRow term="El salto mueve el FOCO">
Un <Code>#fragment</Code> desplaza la vista y deja el foco donde estaba; el
<Code>SkipLink</Code> enfoca el destino y le presta un <Code>tabindex="-1"</Code> mientras dura
el salto. Sin eso, el siguiente Tab vuelve al cromo que el lector pidió saltar.
</DocRow>
<DocRow term="El raíl en móvil devuelve el foco">
Porque es un <Code>Drawer</Code> compuesto, no una reimplementación: la trampa de foco, el bloqueo
de scroll y el descarte vienen con él.
</DocRow>
<DocRow term="Dos landmarks `banner` si el aviso es el canon `Banner`">
<Code>A-109</Code>: el componente estampa <Code>role="banner"</Code> después de sus rest props,
así que no se puede quitar. Nombrar los dos es lo único que puede un app hoy.
</DocRow>
</Stack>
{/snippet}
{#snippet gaps()}
<Stack gap={3}>
<DocRow term="Los primitivos de layout no cambian de elemento (F21)">
<Code>Box</Code> —y con él <Code>Grid</Code>/<Code>Flex</Code>— renderiza un
<Code>&lt;div&gt;</Code> fijo, así que <Code>&lt;main&gt;</Code> y
<Code>&lt;aside&gt;</Code> no pueden SER una caja del sistema y llevan dos declaraciones en línea.
Es el elemento propio del block, no internals de un compuesto (B-3), pero es una carencia del
canon y está registrada.
</DocRow>
<DocRow term="Topbar a todo lo ancho">
Pide partir el provider del <Code>Sidebar</Code> (que hoy ES la fila flex). Canon, v2 — lo pedirá
<Code>docs-shell</Code>.
</DocRow>
<DocRow term="El Sidebar no publica su ancho actual">
La reserva es flexbox y aquí basta, pero un app que quiera anclar algo al borde del raíl no
tiene a qué. Mantine expone ocho variables <Code>--app-shell-*</Code>.
</DocRow>
<DocRow term="Sin superficie online/offline">
No existe en el ecosistema. Un ribbon de «sin conexión» en <Code>banner</Code> lo alimenta el
app desde <Code>Connections.anyReconnecting</Code>.
</DocRow>
</Stack>
{/snippet}
{#snippet notes()}
<Stack gap={3}>
<DocRow term="Skeleton retiró su AppShell en v3">
«The number of issues introduced by it far outweigh its potential gains» — a11y en
navegadores móviles con <Code>100vh</Code> y una historia de sticky header confusa. Las dos averías
vienen de un shell que asume UN modelo. Por eso aquí el modelo se elige, la unidad es
<Code>100dvh</Code> y bajo <Code>body</Code> nada capa el documento a una pantalla.
</DocRow>
<DocRow term="Sin ScrollArea">
La ficha del plan lo listaba. Se deja fuera: montar barras propias sobre la superficie que
scrollea toda la aplicación es una decisión visual que nadie pidió, y con coste — esconde
las nativas. Un app que las quiera compone <Code>ScrollArea</Code> dentro de
<Code>children</Code>.
</DocRow>
</Stack>
{/snippet}
</BlockDemo>

@ -0,0 +1,288 @@
<script lang="ts">
/**
* A real application inside the shell — the only way to see what a shell
* does. A frame with lorem in it would show the grid and hide the point:
* the rail that collapses, the bar that stays put while the page scrolls,
* the aside that folds when the width runs out, and the bypass links that
* appear on the first Tab.
*/
import { AppShell, type AppShellScroll } from '$blocks/app-shell';
import { Sidebar } from '$uix/eidos/components/sidebar';
import { Banner } from '$uix/eidos/components/banner';
import { Breadcrumb } from '$uix/eidos/components/breadcrumb';
import { Avatar } from '$uix/eidos/components/avatar';
import { Badge } from '$uix/eidos/components/badge';
import { Button } from '$uix/eidos/components/button';
import { Card } from '$uix/eidos/components/card';
import { Box } from '$uix/eidos/components/box';
import { Container } from '$uix/eidos/components/container';
import { Grid } from '$uix/eidos/components/grid';
import { Group } from '$uix/eidos/components/group';
import { Heading } from '$uix/eidos/components/heading';
import * as Icon from '$uix/eidos/components/icon';
import { Separator } from '$uix/eidos/components/separator';
import { Stack } from '$uix/eidos/components/stack';
import { Text } from '$uix/eidos/components/text';
import type { SidebarCollapsible } from '$uix/eidos/components/sidebar';
let {
scroll = 'main' as AppShellScroll,
collapsible = 'icon' as SidebarCollapsible,
rail = true,
aside = true,
footer = true,
banner = false
}: {
scroll?: AppShellScroll;
collapsible?: SidebarCollapsible;
rail?: boolean;
aside?: boolean;
footer?: boolean;
banner?: boolean;
} = $props();
let navOpen = $state(true);
let active = $state('/app/inbox');
let noticeUp = $state(true);
const rows = [
{ from: 'Ada Lovelace', subject: 'Analytical engine — v2 notes', when: '09:41' },
{ from: 'Grace Hopper', subject: 'Compiler pass is green', when: '08:12' },
{ from: 'Karen Spärck Jones', subject: 'Ranking weights for the index', when: 'Yesterday' },
{ from: 'Radia Perlman', subject: 'Spanning tree, revisited', when: 'Yesterday' },
{ from: 'Barbara Liskov', subject: 'Substitution in the new API', when: 'Monday' }
];
</script>
{#snippet notice()}
{#if banner && noticeUp}
<Banner intent="primary" variant="solid" aria-label="Aviso de producto">
<Container width="100%" paddingX={0}>
<Group gap={3} align="center" justify="space-between">
<Text size="sm">La facturación cambia el 1 de septiembre.</Text>
<Banner.Close onclick={() => (noticeUp = false)} />
</Group>
</Container>
</Banner>
{/if}
{/snippet}
{#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. -->
<Sidebar.Header>
<Group gap={2} align="center">
<Icon.Sparkles size="sm" />
<strong style="font-size: var(--font-size-sm);">Acme</strong>
</Group>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel>Bandeja</Sidebar.GroupLabel>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
href="#/app/inbox"
active={active === '/app/inbox'}
tooltip="Entrada"
onclick={(e: MouseEvent) => {
e.preventDefault();
active = '/app/inbox';
}}
>
<Icon.Inbox size="sm" />
<span>Entrada</span>
<Sidebar.MenuBadge>5</Sidebar.MenuBadge>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton
href="#/app/drafts"
active={active === '/app/drafts'}
tooltip="Borradores"
onclick={(e: MouseEvent) => {
e.preventDefault();
active = '/app/drafts';
}}
>
<Icon.File size="sm" />
<span>Borradores</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Group>
<Sidebar.Group>
<Sidebar.GroupLabel>Trabajo</Sidebar.GroupLabel>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
href="#/app/projects"
active={active === '/app/projects'}
tooltip="Proyectos"
onclick={(e: MouseEvent) => {
e.preventDefault();
active = '/app/projects';
}}
>
<Icon.Folder size="sm" />
<span>Proyectos</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton
href="#/app/reports"
active={active === '/app/reports'}
tooltip="Informes"
onclick={(e: MouseEvent) => {
e.preventDefault();
active = '/app/reports';
}}
>
<Icon.ChartBar size="sm" />
<span>Informes</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<Group gap={2} align="center">
<Avatar size="xs"><Avatar.Fallback>JV</Avatar.Fallback></Avatar>
<Text size="sm" color="muted">Juan V.</Text>
</Group>
</Sidebar.Footer>
{/snippet}
{#snippet header()}
<Box
paddingX={4}
paddingY={3}
style="border-block-end: var(--border-width) solid var(--color-border-subtle); background: var(--color-surface-default);"
>
<Group gap={3} align="center" justify="space-between">
<Group gap={3} align="center">
{#if rail}
<Sidebar.Trigger><Icon.PanelLeft size="sm" /></Sidebar.Trigger>
{/if}
<Breadcrumb>
<Breadcrumb.List>
<Breadcrumb.Item><Breadcrumb.Link href="#/app">Acme</Breadcrumb.Link></Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item
><Breadcrumb.Link href="#/app/inbox">Entrada</Breadcrumb.Link></Breadcrumb.Item
>
</Breadcrumb.List>
</Breadcrumb>
</Group>
<Group gap={2} align="center">
<Badge size="sm">Beta</Badge>
<Button variant="ghost" size="sm">Nuevo</Button>
<Avatar size="xs"><Avatar.Fallback>JV</Avatar.Fallback></Avatar>
</Group>
</Group>
</Box>
{/snippet}
{#snippet asideContent()}
<Box
padding={4}
height="100%"
style="inline-size: 16rem; border-inline-start: var(--border-width) solid var(--color-border-subtle); background: var(--color-surface-muted);"
>
<Stack gap={3}>
<Heading level={2} size="sm">Detalle</Heading>
<Text size="sm" color="muted">
El panel que se pliega cuando la anchura no da. A 1280 está; a 375 no.
</Text>
<Separator />
<Stack gap={2}>
<Text size="sm">Estado: <strong>abierto</strong></Text>
<Text size="sm">Asignado: Grace H.</Text>
</Stack>
</Stack>
</Box>
{/snippet}
{#snippet pageFooter()}
<Box
paddingX={4}
paddingY={3}
style="border-block-start: var(--border-width) solid var(--color-border-subtle);"
>
<Group gap={3} align="center" justify="space-between">
<Text size="sm" color="muted">© 2026 Acme</Text>
<Text size="sm" color="muted">v0.1</Text>
</Group>
</Box>
{/snippet}
<AppShell
{scroll}
bind:navOpen
nav={{ collapsible, mobileBreakpoint: 'md' }}
aside={{ breakpoint: 'lg' }}
navLabel="Producto"
asideLabel="Detalle del elemento"
banner={banner ? notice : undefined}
navContent={rail ? navContent : undefined}
{header}
asideContent={aside ? asideContent : undefined}
footer={footer ? pageFooter : undefined}
>
<Box padding={5}>
<Stack gap={5}>
<Group gap={3} align="center" justify="space-between">
<Heading level={1} size="lg">Entrada</Heading>
<Text size="sm" color="muted">5 sin leer</Text>
</Group>
<Grid columns={{ base: 1, md: 3 }} gap={4}>
<Card
><Stack gap={1}
><Text size="sm" color="muted">Abiertos</Text><Heading level={2} size="md">128</Heading
></Stack
></Card
>
<Card
><Stack gap={1}
><Text size="sm" color="muted">Sin asignar</Text><Heading level={2} size="md"
>17</Heading
></Stack
></Card
>
<Card
><Stack gap={1}
><Text size="sm" color="muted">Cerrados hoy</Text><Heading level={2} size="md"
>42</Heading
></Stack
></Card
>
</Grid>
<Stack gap={0}>
{#each rows as row (row.subject)}
<div
style="padding-block: var(--space-3); border-block-end: var(--border-width) solid var(--color-border-subtle);"
>
<Group gap={3} align="center" justify="space-between">
<Stack gap={1}>
<Text size="sm"><strong>{row.from}</strong></Text>
<Text size="sm" color="muted">{row.subject}</Text>
</Stack>
<Text size="sm" color="muted">{row.when}</Text>
</Group>
</div>
{/each}
</Stack>
<!-- Enough page to make the scroll model visible: with `scroll="main"`
the bar above stays while this moves; with `body` the whole document
goes and the bar pins. -->
{#each Array(8) as _, i (i)}
<Text color="muted">
Bloque de contenido {i + 1} — desplaza para ver quién scrollea.
</Text>
{/each}
</Stack>
</Box>
</AppShell>

@ -0,0 +1,35 @@
<script lang="ts">
/**
* The preview is its OWN page — `@` resets the layout. Axes arrive via the URL
* so the demo's iframe can drive them.
*
* The wrapper below does NOT set `min-block-size: 100dvh` the way the other
* previews do: this block owns the page's height under `scroll="main"` (that
* is the whole point of it), and a second owner would give the shell a floor
* it did not ask for and a scrollbar nobody can explain.
*/
import { page } from '$app/state';
import BootUix, { type BootLanguage } from '../../_lib/BootUix.svelte';
import '@/uix/eidos/index.css';
let { children } = $props();
const mode = $derived(page.url.searchParams.get('mode') === 'dark' ? 'dark' : 'light');
const dir = $derived(page.url.searchParams.get('dir') === 'rtl' ? 'rtl' : 'ltr');
const language = $derived((page.url.searchParams.get('lang') ?? 'es') as BootLanguage);
</script>
<svelte:head>
<meta name="color-scheme" content="light dark" />
</svelte:head>
<BootUix {mode} {dir} {language}>
<div
data-theme={mode}
data-mode={mode}
{dir}
style="background: var(--color-surface-default); color: var(--color-content-primary);"
>
{@render children?.()}
</div>
</BootUix>

@ -0,0 +1,24 @@
<script lang="ts">
/**
* Standalone page for the app-shell mini-site — the same component the demo
* renders inline, served as its OWN document for the device-width frame.
*
* A shell needs this more than any other block: `100dvh` means the frame's
* viewport, and the rail's drawer measures the same viewport to decide
* whether it is on a phone. Inside a scroll box, both would read the page.
*/
import { page } from '$app/state';
import AppShellSite from '../AppShellSite.svelte';
import type { AppShellScroll } from '$blocks/app-shell';
import type { SidebarCollapsible } from '$uix/eidos/components/sidebar';
const params = $derived(page.url.searchParams);
const scroll = $derived((params.get('scroll') ?? 'main') as AppShellScroll);
const collapsible = $derived((params.get('collapsible') ?? 'icon') as SidebarCollapsible);
const rail = $derived(params.get('rail') !== 'false');
const aside = $derived(params.get('aside') !== 'false');
const footer = $derived(params.get('footer') !== 'false');
const banner = $derived(params.get('banner') === 'true');
</script>
<AppShellSite {scroll} {collapsible} {rail} {aside} {footer} {banner} />
Loading…
Cancel
Save

Powered by TurnKey Linux.