Shells
The frame a screen sits in. A shell owns the main landmark, the skip link, and which region scrolls; the screen owns everything inside. Each is previewed in its own document, since a shell nested in this page would put a main inside a main.
AppShell
sticky header · sidebar that scrolls on its own · document scroll
The page scrolls as a document. Only the sidebar has its own scroll, so find-in-page, anchors, and the browser’s scroll restoration all keep working.
Narrow, the sidebar moves above the content instead of disappearing: navigation that vanishes cannot be reached.
Source src/lib/components/shells/doc.ts · src/lib/components/shells/app-shell/doc.ts · src/lib/components/shells/app-shell/AppShell.svelte · src/lib/components/shells/app-shell/app-shell.module.css
src/lib/components/shells/doc.ts
/**
* shells — the frame a whole screen sits in.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # What belongs here
*
* A shell owns page-level concerns: the one main landmark, the skip link that
* targets it, the header and navigation regions, viewport height, and which
* region scrolls. It owns no content, no routes, and no data.
*
* # Rules for every member
*
* R1 A screen renders exactly one shell, and the shell renders exactly one
* main. Nothing inside a shell renders another main.
* R2 The first focusable element is "Skip to content", which moves focus to
* main.
* R3 Navigation is passed in: which link is current is the caller's
* decision, made from its router, so shells work in any framework.
* R4 Navigation never disappears at narrow widths; it moves.
* R5 Shells are demonstrated in their own documents (the shell-preview
* route), never nested inside another page, since R1 would break.
*/
export {};src/lib/components/shells/app-shell/doc.ts
/**
* AppShell — header across the top, sidebar down the side, content.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* nav the sidebar, REQUIRED — usually a SidebarNav
* actions? the header's end: theme, notifications, account
* header? between the brand and the actions: search, a switcher
* brand? replaces the wordmark
* children the screen
*
* # Behaviour
*
* R1 The header sticks to the top; its height follows control density.
* R2 The sidebar sticks under the header and scrolls on its own, so a long
* navigation and a long page do not drag each other.
* R3 The page scrolls as a document: the browser's own scroll, find, and
* anchor behaviour are kept.
* R4 Below 40rem the sidebar moves above the content.
* R5 Main is focusable only by script, as the skip link's target.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — how this implementation meets the contract. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* --header-h is derived from --control-md and the header padding, and the
* sidebar's sticky offset and height read it. Main takes tabindex=-1 and an
* id from $props.id() for the skip link.
*/
export {};src/lib/components/shells/app-shell/AppShell.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { Mark } from '$lib/components/chrome/mark';
import { cn } from '$lib/utils/cn';
import styles from './app-shell.module.css';
/* Header, sidebar, main — and nothing about what goes in them. Owns the
page's one <main>, which the skip link targets. */
let {
nav,
actions,
header,
brand,
children,
class: className = ''
}: {
/** The sidebar: usually a SidebarNav, often inside a ContextSidebar. */
nav: Snippet;
/** The header's end: theme toggle, notifications, account menu. */
actions?: Snippet;
/** Between the brand and the actions: search, a workspace switcher. */
header?: Snippet;
/** Replaces the wordmark. */
brand?: Snippet;
children: Snippet;
class?: string;
} = $props();
const id = $props.id();
</script>
<div class={cn(styles.root, className)}>
<a href="#{id}-main" class={styles.skip}>Skip to content</a>
<header class={styles.header}>
{#if brand}{@render brand()}{:else}<Mark />{/if}
<div class={styles.headerContent}>{@render header?.()}</div>
{#if actions}<div class={styles.actions}>{@render actions()}</div>{/if}
</header>
<div class={styles.body}>
<aside class={styles.sidebar}>{@render nav()}</aside>
<main id="{id}-main" tabindex="-1" class={styles.main}>{@render children()}</main>
</div>
</div>src/lib/components/shells/app-shell/app-shell.module.css
@layer composition {
.root {
/* Derived from what the header holds (sm controls plus its padding), so it
follows compact density instead of a typed-in height. */
--header-h: calc(var(--control-md) + var(--space-4) * 2 + 1px);
--sidebar-w: 15rem;
display: grid;
min-height: 100dvh;
grid-template-rows: auto minmax(0, 1fr);
background: var(--surface-ground);
color: var(--ink);
font-family: var(--font-sans);
font-size: var(--text-body, var(--text-13));
}
.skip {
position: absolute;
z-index: var(--z-toast);
top: var(--space-3);
left: var(--space-3);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-2);
background: var(--fill);
color: var(--fill-ink);
transform: translateY(-200%);
}
.skip:focus {
transform: none;
}
.header {
position: sticky;
z-index: var(--z-sticky);
top: 0;
display: flex;
height: var(--header-h);
align-items: center;
gap: var(--space-6);
padding: var(--space-4) var(--space-7);
border-bottom: 1px solid var(--line);
background: var(--surface-panel);
}
.headerContent {
min-width: 0;
flex: 1;
}
.actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.body {
display: grid;
grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
}
/* Its own scroll: a long nav does not drag the page, and a long page does
not scroll the nav away. */
.sidebar {
position: sticky;
top: var(--header-h);
height: calc(100dvh - var(--header-h));
overflow-y: auto;
padding: var(--space-6) var(--space-5);
border-right: 1px solid var(--line);
background: var(--surface-rail);
}
.main {
min-width: 0;
padding: var(--space-9) clamp(var(--space-7), 4vw, var(--space-10));
}
.main:focus {
outline: none;
}
/* Narrow: the sidebar becomes a strip above the content rather than
disappearing; a navigation that vanishes cannot be reached. */
@media (max-width: 40rem) {
.body {
grid-template-columns: minmax(0, 1fr);
}
.sidebar {
position: static;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
}
}RailShell
icon rail · collapsible section sidebar · contained scroll
Two levels of navigation. The rail picks the section; the sidebar lists its
pages. Rail links are icons, so each is named by label and shows it as a tooltip.
A hidden sidebar is removed, not moved off screen: its links leave the tab
order. The toggle reports aria-expanded.
It responds to its own width through a container query, so it lays out the same in this frame as in a window.
Source src/lib/components/shells/rail-shell/doc.ts · src/lib/components/shells/rail-shell/RailShell.svelte · src/lib/components/shells/rail-shell/rail-shell.module.css · src/lib/components/shells/navigation-rail/doc.ts · src/lib/components/shells/navigation-rail/NavigationRail.svelte · src/lib/components/shells/navigation-rail/RailLink.svelte · src/lib/components/shells/navigation-rail/navigation-rail.module.css · src/lib/components/shells/context-sidebar/doc.ts · src/lib/components/shells/context-sidebar/ContextSidebar.svelte · src/lib/components/shells/context-sidebar/context-sidebar.module.css
src/lib/components/shells/rail-shell/doc.ts
/**
* RailShell — icon rail, collapsible section sidebar, header, content.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* rail the top-level sections, REQUIRED — a NavigationRail
* sidebar the section's navigation, REQUIRED
* header? after the sidebar toggle
* sidebarOpen? + onSidebarOpenChange controlled
* defaultSidebarOpen? uncontrolled, default true
* sidebarLabel? names the sidebar region
* children the screen
*
* # Behaviour
*
* R1 The shell fills its viewport and the content scrolls inside it; rail,
* sidebar, and header stay put.
* R2 The toggle is a button naming what it will do, with aria-expanded and
* aria-controls pointing at the sidebar.
* R3 A hidden sidebar is removed from the page, not just drawn off screen:
* its links cannot be tabbed to.
* R4 Narrow (under 40rem of its own width) the sidebar opens below the
* header instead of beside it; the rail stays.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — how this implementation meets the contract. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* The shell is a size container, so R4 responds to the shell's width, not
* the window's — it lays out the same in a preview frame. The sidebar uses
* the hidden attribute (R3).
*/
export {};src/lib/components/shells/rail-shell/RailShell.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { Button } from '$lib/components/forms/button';
import { PanelLeftClose, PanelLeftOpen } from '$lib/components/utility/icon';
import { cn } from '$lib/utils/cn';
import styles from './rail-shell.module.css';
/* Rail, collapsible section sidebar, header, and content that scrolls on
its own. Owns the page's one <main>. */
let {
rail,
sidebar,
header,
children,
sidebarLabel = 'Section navigation',
sidebarOpen = $bindable(true),
onSidebarOpenChange,
class: className = ''
}: {
/** The icon rail: a NavigationRail. */
rail: Snippet;
/** The section's own navigation: usually a ContextSidebar. */
sidebar: Snippet;
/** After the sidebar toggle: a title, search, actions. */
header?: Snippet;
children: Snippet;
/** Names the sidebar region. */
sidebarLabel?: string;
/** Bindable; open by default. */
sidebarOpen?: boolean;
onSidebarOpenChange?: (open: boolean) => void;
class?: string;
} = $props();
const id = $props.id();
function toggle() {
sidebarOpen = !sidebarOpen;
onSidebarOpenChange?.(sidebarOpen);
}
</script>
<div class={cn(styles.root, className)}>
<div class={styles.frame} data-sidebar-open={sidebarOpen || undefined}>
<a href="#{id}-main" class={styles.skip}>Skip to content</a>
<div class={styles.rail}>{@render rail()}</div>
<aside id="{id}-sidebar" aria-label={sidebarLabel} hidden={!sidebarOpen} class={styles.sidebar}>
{@render sidebar()}
</aside>
<header class={styles.header}>
<Button
size="icon"
variant="quiet"
aria-label={sidebarOpen ? 'Hide section navigation' : 'Show section navigation'}
aria-expanded={sidebarOpen}
aria-controls="{id}-sidebar"
onclick={toggle}
>
{#if sidebarOpen}<PanelLeftClose aria-hidden="true" />{:else}<PanelLeftOpen
aria-hidden="true"
/>{/if}
</Button>
<div class={styles.headerContent}>{@render header?.()}</div>
</header>
<main id="{id}-main" tabindex="-1" class={styles.main}>{@render children()}</main>
</div>
</div>src/lib/components/shells/rail-shell/rail-shell.module.css
@layer composition {
/* Measures itself, so it lays out correctly in a preview frame as well as a
full window. */
.root {
height: 100dvh;
min-width: 0;
background: var(--surface-ground);
color: var(--ink);
font-family: var(--font-sans);
font-size: var(--text-body, var(--text-13));
container: rail-shell / inline-size;
}
.frame {
position: relative;
display: grid;
height: 100%;
grid-template-columns: 56px 0 minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
grid-template-areas:
'rail sidebar header'
'rail sidebar main';
overflow: hidden;
}
.frame[data-sidebar-open] {
grid-template-columns: 56px 15rem minmax(0, 1fr);
}
.rail {
grid-area: rail;
overflow-y: auto;
border-right: 1px solid var(--line);
background: var(--surface-rail);
}
.sidebar {
grid-area: sidebar;
min-width: 0;
overflow-y: auto;
padding: var(--space-6) var(--space-4);
border-right: 1px solid var(--line);
background: var(--surface-panel);
}
.sidebar[hidden] {
display: none;
}
.header {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-4);
grid-area: header;
padding: var(--space-3) var(--space-6);
border-bottom: 1px solid var(--line);
background: var(--surface-panel);
}
.headerContent {
min-width: 0;
flex: 1;
}
.main {
min-width: 0;
min-height: 0;
grid-area: main;
overflow: auto;
padding: var(--space-9) clamp(var(--space-7), 4vw, var(--space-10));
overscroll-behavior: contain;
}
.main:focus {
outline: none;
}
.skip {
position: absolute;
z-index: var(--z-toast);
top: var(--space-3);
left: var(--space-3);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-2);
background: var(--fill);
color: var(--fill-ink);
transform: translateY(-200%);
}
.skip:focus {
transform: none;
}
/* Narrow: the sidebar opens below the header instead of beside it. */
@container rail-shell (max-width: 40rem) {
.frame,
.frame[data-sidebar-open] {
grid-template-columns: 56px minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr);
grid-template-areas:
'rail header'
'rail sidebar'
'rail main';
}
.sidebar {
max-height: 16rem;
border-right: 0;
border-bottom: 1px solid var(--line);
}
}
}src/lib/components/shells/navigation-rail/doc.ts
/**
* NavigationRail / RailLink — a narrow column of icon links to the app's
* top-level sections.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* NavigationRail brand?, footer?, label? (the landmark's name), children
* RailLink href and label REQUIRED, description?, active?, icon
*
* # Behaviour
*
* R1 A rail link is named by `label`. An icon is never the only name.
* R2 The name is shown as a tooltip on hover and focus, so sighted users
* get what screen reader users hear.
* R3 The active link is marked aria-current="true": it is a section, and
* the page inside it may be anything.
* R4 Footer links (settings, help) are pinned to the bottom but are still
* part of the navigation landmark. An account menu is not navigation:
* put it in the sidebar's footer instead.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — how this implementation meets the contract. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* RailLink wraps its anchor in the shared Tooltip, placed to the right, and
* sets aria-label, which the tooltip does not replace.
*/
export {};src/lib/components/shells/navigation-rail/NavigationRail.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils/cn';
import styles from './navigation-rail.module.css';
/* A narrow column of icon links to the app's top-level sections. */
let {
brand,
footer,
label = 'Application sections',
children,
class: className = ''
}: {
brand?: Snippet;
/** Pinned to the bottom of the navigation: help, settings. */
footer?: Snippet;
/** Names the navigation landmark. */
label?: string;
children: Snippet;
class?: string;
} = $props();
</script>
<div class={cn(styles.root, className)}>
{#if brand}{@render brand()}{:else}<span></span>{/if}
<nav aria-label={label} class={styles.nav}>
<div class={styles.items}>{@render children()}</div>
{#if footer}<div class={styles.items}>{@render footer()}</div>{/if}
</nav>
</div>src/lib/components/shells/navigation-rail/RailLink.svelte
<script lang="ts">
import type { HTMLAnchorAttributes } from 'svelte/elements';
import { Tooltip } from '$lib/components/overlays/tooltip';
import type { LayoutGrid } from '$lib/components/utility/icon';
import { cn } from '$lib/utils/cn';
import styles from './navigation-rail.module.css';
/* `href` (required) arrives through rest, like NavLink's: the caller
resolves it. An icon-only link. Named by `label`, never by its icon; the tooltip
repeats the name for sighted pointer users. */
let {
label,
icon: Icon,
description,
active = false,
class: className = '',
...rest
}: Omit<HTMLAnchorAttributes, 'aria-label' | 'aria-current' | 'children' | 'class'> & {
class?: string;
href: string;
/** The accessible name; also the tooltip unless description is given. */
label: string;
icon: typeof LayoutGrid;
description?: string;
active?: boolean;
} = $props();
</script>
<Tooltip content={description ?? label} side="right">
{#snippet trigger(props)}
<a
{...rest}
{...props}
aria-label={label}
aria-current={active ? 'true' : undefined}
class={cn(styles.link, className)}
>
<Icon aria-hidden="true" />
</a>
{/snippet}
</Tooltip>src/lib/components/shells/navigation-rail/navigation-rail.module.css
@layer composition {
.root {
display: grid;
height: 100%;
grid-template-rows: auto minmax(0, 1fr);
justify-items: center;
gap: var(--space-6);
padding: var(--space-5) 0;
}
.nav {
display: grid;
min-height: 0;
align-content: space-between;
gap: var(--space-6);
}
.items {
display: grid;
align-content: start;
gap: var(--space-2);
}
.link {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border-radius: var(--radius-2);
color: var(--ink-3);
}
.link svg {
width: 18px;
height: 18px;
}
.link:hover {
background: var(--surface-hover-2);
color: var(--ink);
}
.link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 0;
}
.link[aria-current]:not([aria-current='false']) {
background: var(--accent-tint);
color: var(--accent);
}
}src/lib/components/shells/context-sidebar/doc.ts
/**
* ContextSidebar — a sidebar that says which section it belongs to.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* title the section or workspace, REQUIRED
* description? a line under it
* footer? pinned to the bottom: the account, a switcher
* level? heading level, default 2
* children the section's navigation
*
* # Behaviour
*
* R1 The title is a heading, so the sidebar can be found by heading.
* R2 The footer sits at the bottom of the sidebar even when the navigation
* is short, and after it when the navigation is long.
*/
export {};src/lib/components/shells/context-sidebar/ContextSidebar.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { Heading } from '$lib/components/typography/heading';
import { Text } from '$lib/components/typography/text';
import { cn } from '$lib/utils/cn';
import styles from './context-sidebar.module.css';
let {
title,
description,
footer,
level = 2,
children,
class: className = ''
}: {
/** The section or workspace the sidebar belongs to. */
title: string;
description?: string;
/** Pinned to the bottom: the account, the workspace switcher. */
footer?: Snippet;
level?: 1 | 2 | 3 | 4 | 5 | 6;
children: Snippet;
class?: string;
} = $props();
</script>
<div class={cn(styles.root, className)}>
<div class={styles.head}>
<Heading {level} size="sm">{title}</Heading>
{#if description}<Text size="sm" tone="quiet">{description}</Text>{/if}
</div>
<div>{@render children()}</div>
{#if footer}<div class={styles.footer}>{@render footer()}</div>{/if}
</div>src/lib/components/shells/context-sidebar/context-sidebar.module.css
@layer composition {
.root {
display: grid;
min-height: 100%;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--space-7);
}
.head {
display: grid;
gap: var(--space-2);
padding: 0 var(--space-4);
}
.footer {
padding: var(--space-5) var(--space-4) 0;
border-top: 1px solid var(--line);
}
}AuthShell
one column · required h1 · used by sign-in and sign-up
The title is required and is the page’s h1; the wordmark is not a heading. The real sign-in and sign-up pages use this shell.
Source src/lib/components/shells/auth-shell/doc.ts · src/lib/components/shells/auth-shell/AuthShell.svelte · src/lib/components/shells/auth-shell/auth-shell.module.css
src/lib/components/shells/auth-shell/doc.ts
/**
* AuthShell — the frame every signed-out screen shares.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* title REQUIRED, the page's h1
* description? under the title
* actions? beside the brand: theme, language
* brand? replaces the wordmark
* footer? under the card: the link to the other door
* layout? "centered" (default) | "split"
* aside? split only: the other half — the product, a quote
* children the form
*
* # Behaviour
*
* R1 Centred in the viewport in one narrow column; on a short viewport it
* scrolls instead of clipping.
* R2 The title is required and is the h1: the brand is not a heading.
* R3 The shell is the main landmark. It owns arrangement only; the form,
* its state, and its errors belong to the page.
* R4 Split puts the form on the right half, without a card, and the aside
* on the left, held in view while the form scrolls. The aside is
* supplementary: it follows the form in reading order, and below a
* tablet's width it is left out and the form takes the screen.
*/
export {};src/lib/components/shells/auth-shell/AuthShell.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { Mark } from '$lib/components/chrome/mark';
import { Card } from '$lib/components/display/card';
import { Container } from '$lib/components/layout/container';
import { Heading } from '$lib/components/typography/heading';
import { Text } from '$lib/components/typography/text';
import { cn } from '$lib/utils/cn';
import styles from './auth-shell.module.css';
/* The frame every signed-out screen shares: centred, one narrow column,
one card. It owns the arrangement only — the form belongs to the page. */
let {
title,
description,
actions,
brand,
footer,
layout = 'centered',
aside,
children,
class: className = ''
}: {
/** Required, and rendered as the page's h1: the wordmark is not a
* heading, and a reader needs something to orient on. */
title: string;
description?: string;
/** Above the card, beside the brand: the theme toggle, a language menu. */
actions?: Snippet;
/** Replaces the wordmark; wrap it in a link home if there is one. */
brand?: Snippet;
/** Under the card: the link to the other door. */
footer?: Snippet;
/** One centred card (default), or the form on one half of the screen
* and `aside` on the other. */
layout?: 'centered' | 'split';
/** The other half, in split: the product, a promise, a quote. It is
* supplementary, so it follows the form in reading order and is left
* out when the screen is narrow. */
aside?: Snippet;
children: Snippet;
class?: string;
} = $props();
</script>
{#snippet head()}
<div class={styles.head}>
<Heading level={1} size={layout === 'split' ? 'lg' : 'md'}>{title}</Heading>
{#if description}<Text tone="muted">{description}</Text>{/if}
</div>
{/snippet}
{#if layout === 'split'}
<main class={cn(styles.root, styles.split, className)}>
<div class={styles.pane}>
<div class={styles.top}>
{#if brand}{@render brand()}{:else}<Mark />{/if}
{@render actions?.()}
</div>
<div class={styles.body}>
{@render head()}
{@render children()}
{#if footer}<div class={styles.footer}>{@render footer()}</div>{/if}
</div>
</div>
{#if aside}<aside class={styles.aside}>{@render aside()}</aside>{/if}
</main>
{:else}
<main class={cn(styles.root, className)}>
<Container width="narrow">
<div class={styles.column}>
<div class={styles.top}>
{#if brand}{@render brand()}{:else}<Mark />{/if}
{@render actions?.()}
</div>
<Card as="div" elevation="raised" class={styles.card}>
{@render head()}
{@render children()}
</Card>
{#if footer}<div class={styles.footer}>{@render footer()}</div>{/if}
</div>
</Container>
</main>
{/if}src/lib/components/shells/auth-shell/auth-shell.module.css
@layer composition {
.root {
display: grid;
min-height: 100dvh;
align-content: center;
padding-block: var(--space-9);
background: var(--surface-ground);
color: var(--ink);
font-family: var(--font-sans);
font-size: var(--text-body, var(--text-13));
}
.column {
display: grid;
gap: var(--space-7);
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-5);
}
.card {
display: grid;
gap: var(--space-7);
padding: var(--space-9);
}
.head {
display: grid;
gap: var(--space-3);
}
.footer {
color: var(--ink-2);
font-size: var(--text-body, var(--text-13));
text-align: center;
}
/* ── Split: the form on one half, the aside on the other ── */
.split {
grid-template-areas: 'aside pane';
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-content: stretch;
padding-block: 0;
}
.pane {
display: grid;
min-width: 0;
grid-area: pane;
grid-template-rows: auto 1fr;
padding: var(--space-7) var(--gutter);
}
.body {
display: grid;
width: min(100%, var(--narrow-max));
align-content: center;
gap: var(--space-7);
margin-inline: auto;
padding-block: var(--space-10);
}
.split .footer {
text-align: start;
}
/* In reading order it comes after the form; on screen it is the left
half, held in view while the form scrolls. */
.aside {
position: sticky;
top: 0;
height: 100dvh;
min-width: 0;
grid-area: aside;
overflow: hidden;
border-right: 1px solid var(--line);
}
@media (max-width: 56rem) {
.split {
grid-template-areas: 'pane';
grid-template-columns: minmax(0, 1fr);
}
.aside {
display: none;
}
}
}In the app
/w/[workspace] · RailShell with real routes · placeholder data
The signed-in app, not a preview. Every page has its own address under the workspace, so links can be shared and the shell stays mounted between pages. The rail, the section's pages, the breadcrumb, and ⌘K all come from one navigation list; the numbers are placeholders until the screens are wired to the backend.
Mark
name · compact
Source src/lib/components/chrome/mark/doc.ts · src/lib/components/chrome/mark/Mark.svelte · src/lib/components/chrome/mark/mark.module.css
src/lib/components/chrome/mark/doc.ts
/**
* Mark — the product's wordmark.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* name? the product name, default "Bento"
* compact? glyph only, default false
*
* # Behaviour
*
* R1 Every place that shows the brand renders this, so replacing the brand
* is one file.
* R2 Compact hides the name visually only; it is still read out.
* R3 The mark is not a heading and not a link; wrap it in a link home where
* one exists.
*/
export {};src/lib/components/chrome/mark/Mark.svelte
<script lang="ts">
import { cn } from '$lib/utils/cn';
import styles from './mark.module.css';
/* The product's wordmark: a glyph and the name. Swap this file for a real
logo; everything that shows the brand renders it through here. */
let {
name = 'Bento',
compact = false,
class: className = ''
}: {
/** The product name. */
name?: string;
/** Glyph only; the name stays available to assistive technology. */
compact?: boolean;
class?: string;
} = $props();
</script>
<span class={cn(styles.root, compact && styles.compact, className)}>
<span class={styles.glyph} aria-hidden="true">{name.charAt(0)}</span>
<span class={styles.word}>{name}</span>
</span>src/lib/components/chrome/mark/mark.module.css
@layer primitive {
.root {
display: inline-flex;
align-items: center;
gap: var(--space-4);
color: var(--ink);
font-size: var(--text-15);
font-weight: var(--weight-bold);
letter-spacing: var(--tracking-tight);
text-decoration: none;
}
.glyph {
display: grid;
width: 28px;
height: 28px;
flex: none;
place-items: center;
border-radius: var(--radius-2);
background: var(--fill);
color: var(--fill-ink);
font-size: var(--text-15);
}
.compact .word {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}