Layout & Shell
The configurable application shell — LayoutContext, its persisted options, the data-attribute-driven SCSS, per-route presentation overrides, the PageHeader/Breadcrumbs, and the responsive Row/Col grid.
Overview
The shell is fully configurable at runtime. A single context — LayoutContext
(src/context/LayoutContext.tsx) — holds a LayoutConfig object (nav behavior, sidebar mode, widgets,
motion, feedback), persists it to localStorage, and exposes it through useLayout(). AppLayout
(src/layout/AppLayout.tsx) projects that config onto the root .app-shell element as data-*
attributes; the SCSS layer (src/styles/_layout.scss, _sidebar.scss) keys off those attributes to size
and position the shell. Nothing about the shell is hard-coded — flip a config flag and the layout responds.
Two ways to change the config exist and share the same store, so they stay in sync live:
- The Layout Customizer — a right slide-in panel opened by the header gear (see Customizer & Layout Settings).
- The Layout Settings page at
/layout-settings— a full-page twin of the panel.
On top of the persisted config, individual routes can force a presentation (e.g. full-bleed, no sidebar) without ever mutating the saved config — see Per-route presentation.
Architecture & files
| File | Responsibility |
|---|---|
src/context/LayoutContext.tsx | The LayoutConfig interface, defaultLayout, layoutPresets, the provider, and useLayout(). Also owns skin + oledDark. |
src/layout/AppLayout.tsx | Renders the shell. Sets data-* attributes on .app-shell; mounts RouteProgress, BackToTop, skip-link, scroll-to-top, Footer, ChatProvider + chat widgets, and the overlays. |
src/layout/routeLayout.ts | useRouteLayout() — reads route-forced presentation from the route registry. |
src/layout/PageHeader.tsx | The slim h-11 page-context bar (title · breadcrumb · action). |
src/components/ui/Breadcrumbs.tsx | Breadcrumb trail — explicit or auto-derived from the route against menu. |
src/layout/Footer.tsx | App footer (version badge + status dot). |
src/components/layout/Grid.tsx | Row / Col responsive 12-column grid presets. |
src/styles/_layout.scss | Structural shell rules keyed off .app-shell[data-*] (sticky offsets, widths). |
src/styles/_sidebar.scss | Sidebar rail sizing / minified / dual rules (see Sidebar & Navigation). |
src/styles/_grid.scss | .cl-row / .cl-col--* grid presets. |
The .app-shell layout is a flex row: Sidebar (a reserved column) + .app-content (a flex column
holding Header, <main>, Footer), with the optional right-pinned chat rail as a third column. See
Architecture & Routing for how the shell fits into the provider
tree.
Usage
Read or update the config anywhere under the provider with useLayout():
import {useLayout} from '@/context/LayoutContext'
function Example() {
const {config, update} = useLayout()
return (
<button onClick={() => update({minSidebar: !config.minSidebar})}>
{config.minSidebar ? 'Expand sidebar' : 'Collapse sidebar'}
</button>
)
}Every page screen (except full-bleed routes) renders a PageHeader so it gets a sticky title bar,
auto-breadcrumbs, and a synced browser-tab title:
import {PageHeader} from '@/layout/PageHeader'
import {Button} from '@/components/ui'
export function ReportsPage() {
return (
<>
<PageHeader title="Reports" action={<Button>Export</Button>} />
{/* page content */}
</>
)
}API / Props
LayoutConfig options
Defined in src/context/LayoutContext.tsx. Every field is persisted to localStorage('layout_config').
Defaults come from defaultLayout.
| Option | Type | Default | Description |
|---|---|---|---|
showPrimaryNav | boolean | true | Show the primary nav (header mega-nav row). |
fixedNav | boolean | true | Header stays pinned to the top while scrolling. |
autoHideNav | boolean | false | Hide the header on scroll-down, reveal on scroll-up. |
stickyPageHeader | boolean | true | Page header sticks below the nav while scrolling. |
fixedFooter | boolean | false | Footer pins to the bottom of the viewport. |
minSidebar | boolean | false | Collapse the sidebar to an icon rail (expands on hover). |
dualSidebar | boolean | false | Dual sidebar: icon rail + secondary panel. |
headerOnly | boolean | false | Hide the sidebar; navigation lives in the header. |
darkSidebar | boolean | true | Dark sidebar even when the page theme is light. |
oledSidebar | boolean | false | True-black (OLED) variant of the dark sidebar. |
chatBubble | boolean | false | Floating chat bubble (bottom-right launcher → panel). |
chatRail | boolean | true | Right-pinned chat rail of users (contact cards → windows). |
cursorGlow | boolean | false | Ambient radial glow that follows the cursor (desktop-only). |
accordionMenu | boolean | true | Sidebar groups behave as an accordion (one open at a time). |
toastPosition | ToastPosition | 'top-center' | Where toasts appear (top-center/top-right/bottom-left/bottom-right/center). |
pageTransition | PageTransitionKind | 'none' | Route-change transition effect. |
pageLoader | PageLoaderKind | 'branded' | App-splash / route-loader style (branded/minimal/playful). |
routeLoader | boolean | true | Show the loader while lazy in-app pages load. |
splashFrequency | SplashFrequency | 'once' | How often the initial splash shows (once/always). |
panelHeaderVariant | PanelHeaderVariant | 'default' | Header chrome for Panel portlets (see the /ui/panels demo). |
panelEqualHeight | boolean | false | Stretch panels in a grid row to match the tallest. |
useLayout() return value
| Member | Type | Description |
|---|---|---|
config | LayoutConfig | The current, persisted configuration. |
update | (partial: Partial<LayoutConfig>) => void | Merge a partial patch into the config. |
applyPreset | (name: keyof typeof layoutPresets) => void | Replace the whole config with a named preset. |
reset | () => void | Full factory reset — clearAppStorage() then window.location.reload(). |
skin | Skin | The active design skin. |
setSkin | (skin: Skin) => void | Switch the design skin (persisted to localStorage, reflected on <html data-skin>). |
oledDark | boolean | True-black dark neutrals in dark mode. |
setOledDark | (v: boolean) => void | Toggle OLED dark (persisted, reflected on <html data-oled>). |
Info
skin/setSkin/oledDark/setOledDark are the theming half of the context; see
Design Skins for the full skin catalog and how to add one.
PageHeader props
| Prop | Type | Description |
|---|---|---|
title | string | Page title. Also drives the browser-tab title via useDocumentTitle. |
breadcrumbs | Crumb[] (optional) | Explicit trail; when omitted it auto-derives from the route against menu. |
action | ReactNode (optional) | Right-aligned slot; wrapped in <ButtonSizeProvider size="sm">. |
Crumb is {label: string; to?: string}.
Col presets
| Preset | Behavior (breakpoint → span of 12) |
|---|---|
full | span 12 (default) |
auto | intrinsic width (grid-column: auto) |
col2 | 12 → xl:6 |
col3 | 12 → sm:6 → lg:4 |
col4 | 12 → sm:6 → md:4 → xl:3 |
col6 | 12 → sm:6 → md:4 → lg:3 → xl:2 |
sidebar | 12 → lg:3 |
main | 12 → lg:9 |
half | 12 → sm:6 |
third | 12 → sm:6 → md:4 |
twoThirds | 12 → md:8 |
quarter | 12 → sm:6 → lg:3 |
threeQuarters | 12 → lg:9 |
Configuration & customization
Presets
layoutPresets (src/context/LayoutContext.tsx) ships three named configs applied via
applyPreset(name):
| Preset | Character |
|---|---|
default | The shipped defaults (defaultLayout) — dark sidebar, chat rail, accordion menu, branded loader. |
minimal | Chrome stripped back — no primary nav, no fixed/sticky, no widgets, light sidebar, minimal loader. |
contentFocus | Minified sidebar + slide transitions + splashFrequency: 'always', chat rail kept. |
Mutually-exclusive groups
Some options can't be on at once; enabling one clears the others (enforced by the customizer / settings UI):
- Sidebar mode:
minSidebar⟂dualSidebar⟂headerOnly(default = all three off). - Chat widgets:
chatBubble⟂chatRail. autoHideNav⟂stickyPageHeader— a hidden header would leave a gap above a stuck page header.
The data-* shell contract
AppLayout sets these attributes on .app-shell (note that two of them OR the saved config with the
route-forced override):
| Attribute | Source |
|---|---|
data-fixed-nav | config.fixedNav |
data-sticky-header | config.stickyPageHeader |
data-fixed-footer | config.fixedFooter || forceFixedFooter |
data-min-sidebar | config.minSidebar |
data-dual-sidebar | config.dualSidebar |
data-header-only | config.headerOnly || forceHeaderOnly |
The SCSS in _layout.scss reads these and drives the CSS custom properties on .app-shell:
--app-header-height: 4rem(matches theh-16header).--sidebar-width— resolves to--sidebar-width-full(16rem),--sidebar-width-min(5rem, whendata-min-sidebar),0(whendata-header-only), or19rem(dual rail 4rem + secondary 15rem).--page-header-sticky-top—var(--app-header-height)when the nav is fixed, otherwise0, so the sticky page header docks under the fixed header rather than under the viewport top.
Because the shell keys off data-* + CSS variables, adding a layout variant means adding a data-*
branch in the SCSS rather than conditional JSX.
Per-route presentation flags
Some full-page routes must always render a certain way regardless of the user's saved config. Those flags
live on the route's entry in src/routes.tsx (fullBleed?, headerOnly?, fixedFooter?, hideWidgets?)
and are surfaced by useRouteLayout() (src/layout/routeLayout.ts):
const {forceHeaderOnly, forceFixedFooter, forceHideWidgets, fullBleed} = useRouteLayout()fullBleeddrops the<main>padding so the page owns the full content area edge-to-edge. Full-bleed pages do not renderPageHeader(its negative-margin bleed assumes the padding) — calluseDocumentTitledirectly and size tocalc(100dvh - var(--app-header-height) - <footer>).headerOnly/fixedFooterforce those presentations (e.g. the chat workspace brings its own rail + a pinned footer).hideWidgetssuppresses the chat rail / bubble on marketing-style pages.
These overrides are presentation-only — the persisted config is never touched, so every other route
keeps the user's own settings. For the full mechanism (and how the route registry derives the
routePresentation map), see Architecture & Routing.
Shell polish (all mounted in AppLayout)
RouteProgress— a thin top progress bar on navigation.BackToTop— a scroll-triggered "return to top" button (useScroll).- Skip-to-content — a keyboard-focusable link to
#main-content. - Scroll-to-top on route change —
window.scrollTo(0, 0)onlocation.pathname(the window is the scroll container, so it would otherwise keep the prior page's scroll position). Footer(src/layout/Footer.tsx) — shows a versionBadge(brand.version) and asuccess-dot "All systems operational" status.
Examples
Applying a preset from a button
import {useLayout} from '@/context/LayoutContext'
import {Button} from '@/components/ui'
function PresetButtons() {
const {applyPreset} = useLayout()
return (
<div className="flex gap-2">
<Button onClick={() => applyPreset('default')}>Default</Button>
<Button onClick={() => applyPreset('minimal')}>Minimal</Button>
<Button onClick={() => applyPreset('contentFocus')}>Content focus</Button>
</div>
)
}A page column layout with Row / Col
import {Row, Col} from '@/components/layout/Grid'
function Dashboard() {
return (
<Row>
<Col preset="main">{/* wide primary column (lg:9/12) */}</Col>
<Col preset="sidebar">{/* narrow side column (lg:3/12) */}</Col>
</Row>
)
}A full-bleed page (no PageHeader)
// The route entry in src/routes.tsx sets `fullBleed: true`.
import {useDocumentTitle} from '@/hooks/useDocumentTitle'
export function CanvasPage() {
useDocumentTitle('Canvas')
return (
<div className="h-[calc(100dvh-var(--app-header-height))]">
{/* owns its own height + scroll */}
</div>
)
}Explicit breadcrumbs
<PageHeader
title="Order #1024"
breadcrumbs={[
{label: 'Home', to: '/'},
{label: 'Orders', to: '/orders'},
{label: '#1024'},
]}
/>Best practices
- Read config through
useLayout(), never fromlocalStoragedirectly — the store handles hydration, persistence, and cross-component sync. - Register any new persisted key in
src/lib/appStorage.ts(APP_LOCAL_KEYS/APP_SESSION_KEYS) so "Reset to defaults" wipes it.layout_config,design_skin, andoled_darkare already registered. - Prefer route presentation flags over ad-hoc conditionals when a page needs a fixed layout — set the
flag on its
appRoutesentry so the intent lives with the route. - Every non-full-bleed page renders
<PageHeader>— it carries the.page-headerclass the sticky behavior depends on, and syncs the tab title. - Use
Row/Colpresets for page column layouts rather than re-inventing responsive grids. - Add new layout options to a
data-*attribute + SCSS, not to inline conditional styles, to keep the shell driven by one contract.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Sticky page header overlaps content or floats in a gap | Check data-fixed-nav — --page-header-sticky-top is only the header height when the nav is fixed. autoHideNav and stickyPageHeader are mutually exclusive for this reason. |
Full-bleed page shows a broken PageHeader | Full-bleed routes must not render PageHeader (its negative margins assume <main> padding). Use useDocumentTitle instead. |
| Full-height page overflows past the footer | .app-shell is min-height: 100vh (no ceiling); size the page with calc(100dvh - var(--app-header-height) - <footer>), subtracting the measured footer only when config.fixedFooter. |
| Config change doesn't persist after reload | Confirm you called update(...) (which writes through the store) and that the key is part of LayoutConfig — arbitrary keys aren't saved. |
| "Reset to defaults" leaves stale data | The key wasn't added to APP_LOCAL_KEYS/APP_SESSION_KEYS in src/lib/appStorage.ts. |
| Sidebar / footer flag has no visual effect | The SCSS rule may be missing for the data-* attribute, or a mutually-exclusive flag is overriding it (e.g. headerOnly forces --sidebar-width: 0). |
FAQ
Where is the source of truth for the shell config?
src/context/LayoutContext.tsx — the LayoutConfig interface + defaultLayout. Persisted config in
localStorage overrides the defaults on load, so existing browsers keep their saved setup.
Do the Customizer and the Layout Settings page use different stores?
No — both call useLayout() and share the same store, so they stay in sync live. See
Customizer & Layout Settings.
Why is the default sidebar dark on a light theme?
darkSidebar defaults to true. On a light page it re-scopes the base .dark tokens to the sidebar
subtree; in dark mode it's a no-op (the whole app already covers it).
How do I make one route full-bleed / header-only?
Set fullBleed / headerOnly on its entry in src/routes.tsx. Nothing else needs editing — see
Architecture & Routing.
Can breadcrumbs be automatic?
Yes — omit the breadcrumbs prop and Breadcrumbs derives the trail from the current route against the
sidebar menu (the single source of truth).
Notes for designers & content editors
- Every layout option is togglable live in the Layout Customizer (header gear) and on the
Layout Settings page (
/layout-settings) — no code needed to preview a combination. - The Page Layouts preview routes (under Dashboard → Page Layouts) demonstrate each option full-page with realistic skeleton content and revert automatically when you leave. See Sidebar & Navigation.
- Page titles, breadcrumbs, footer text, and nav labels are all i18n keys — edit them in
src/locales/<lng>/<ns>.json, never in components. - The footer version comes from
src/config/brand.ts(brand.version); the product name lives there too.
Related
Header
The header row, mega-nav, and right-cluster controls.
Sidebar & Navigation
Sidebar modes, search, favorites, badges, accordion.
Customizer & Settings
The two config UIs that drive the shell.
Design Skins
skin / oledDark and the full skin catalog.
Architecture & Routing
The route registry and presentation flags.
Was this page helpful?
