Usage
@prepared911/ui/blocks/app-shell and its styles.css, plus the styles.css of each component the block composes (Box, Link, VisuallyHidden, Navbar, Sidebar, Banner).
AppShell is the page shell of an application and the root wrapper of every page. It places a Navbar, an optional Sidebar, one Banner, the page, and space for the Dock. It holds layout and focus only: it fetches nothing, stores nothing and knows no router.
navbar is required. The bar is fixed at the block-start edge whatever the page does. Its header is the page's banner landmark, so the shell places it outside main and outside any sectioning element.sidebar is optional. It is fixed at the inline-start edge below the bar and the banner, and it scrolls its own content. Without it main takes the width.banner is optional and takes one element: a notice directly under the bar across the full width of the shell, above the sidebar as well. Notices do not stack.main is required. It scrolls on its own; the bar, the sidebar and the Dock's space never move.dock is optional. It reserves space under main, centered, so the Dock never covers the page. Without it no space is reserved.fit="viewport", the default), so the document does not scroll. Render it as the only thing on the page and clear the body margin (the opt-in reset.css does). In a bounded parent, such as a preview or a panel, pass fit="container"; the parent needs a definite block size.
Every region except the sidebar is a named inline-size container: app-shell-navbar, app-shell-banner, app-shell-main and app-shell-dock. Write @container app-shell-main (min-width: 60rem) and a page answers the width of main, not the window.
AppShell needs no router. main renders whatever it is given, so the root route passes <Outlet />. Navbar tabs and Sidebar rows take the current route from the router through props they already have:
NavbarTabs takes currentPath, navigate and matchPath. Sidebar takes no path: the caller sets active on the row for the current page and navigates in onClick.main is the scroll container, so the router's element scroll restoration has to find it. Pass mainData={{ "data-scroll-restoration-id": id }} and create the router with scrollRestoration: true and scrollToTopSelectors: ['[data-scroll-restoration-id="id"]']. A page you leave and return to gets its scroll position back; a new page starts at the top. mainRef hands you the element for anything else that needs it.@tanstack/react-router is not a dependency of @prepared911/ui. It is a devDependency of this repository, for the story and the browser test.routeKey is the identity of the page (usually the pathname). With routeFocus={AppShellRouteFocus.Main} a change after the first render moves focus to main, which starts a reader at the new page.routeAnnouncement is the name of the page. It is announced politely when it changes, whatever happens to focus.skipLinkLabel is the first control in the tab order. It is hidden until it has focus, then shown over the bar. Activating it moves focus to main and does not change the address.
Below the 1080px viewport floor the shell still does not reflow its regions (AS-19), but the header does (AS-21). Put the bar's actions in a Toolbar with overflowLabel inside NavbarEnd so they collapse into the overflow menu at 360px width and at 400% zoom (320 CSS px), with every action reachable by keyboard. NavbarStart does not shrink; keep it short. See Toolbar usage and Navbar usage.
PanelWorkspace, mobile navigation (the viewport floor is 1080px wide) and TakeoverPage (a separate composition) are not part of the shell. The shell imports no ControlIsland either: the role-based island arrives through navbar, which a host fills with an App Header (its island shows the outlined raise hand while lowered and the filled hand while raised).