PVR Tech Studio

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.

10 min read
Updated July 15, 2026

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

FileResponsibility
src/context/LayoutContext.tsxThe LayoutConfig interface, defaultLayout, layoutPresets, the provider, and useLayout(). Also owns skin + oledDark.
src/layout/AppLayout.tsxRenders 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.tsuseRouteLayout() — reads route-forced presentation from the route registry.
src/layout/PageHeader.tsxThe slim h-11 page-context bar (title · breadcrumb · action).
src/components/ui/Breadcrumbs.tsxBreadcrumb trail — explicit or auto-derived from the route against menu.
src/layout/Footer.tsxApp footer (version badge + status dot).
src/components/layout/Grid.tsxRow / Col responsive 12-column grid presets.
src/styles/_layout.scssStructural shell rules keyed off .app-shell[data-*] (sticky offsets, widths).
src/styles/_sidebar.scssSidebar 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.

OptionTypeDefaultDescription
showPrimaryNavbooleantrueShow the primary nav (header mega-nav row).
fixedNavbooleantrueHeader stays pinned to the top while scrolling.
autoHideNavbooleanfalseHide the header on scroll-down, reveal on scroll-up.
stickyPageHeaderbooleantruePage header sticks below the nav while scrolling.
fixedFooterbooleanfalseFooter pins to the bottom of the viewport.
minSidebarbooleanfalseCollapse the sidebar to an icon rail (expands on hover).
dualSidebarbooleanfalseDual sidebar: icon rail + secondary panel.
headerOnlybooleanfalseHide the sidebar; navigation lives in the header.
darkSidebarbooleantrueDark sidebar even when the page theme is light.
oledSidebarbooleanfalseTrue-black (OLED) variant of the dark sidebar.
chatBubblebooleanfalseFloating chat bubble (bottom-right launcher → panel).
chatRailbooleantrueRight-pinned chat rail of users (contact cards → windows).
cursorGlowbooleanfalseAmbient radial glow that follows the cursor (desktop-only).
accordionMenubooleantrueSidebar groups behave as an accordion (one open at a time).
toastPositionToastPosition'top-center'Where toasts appear (top-center/top-right/bottom-left/bottom-right/center).
pageTransitionPageTransitionKind'none'Route-change transition effect.
pageLoaderPageLoaderKind'branded'App-splash / route-loader style (branded/minimal/playful).
routeLoaderbooleantrueShow the loader while lazy in-app pages load.
splashFrequencySplashFrequency'once'How often the initial splash shows (once/always).
panelHeaderVariantPanelHeaderVariant'default'Header chrome for Panel portlets (see the /ui/panels demo).
panelEqualHeightbooleanfalseStretch panels in a grid row to match the tallest.

useLayout() return value

MemberTypeDescription
configLayoutConfigThe current, persisted configuration.
update(partial: Partial<LayoutConfig>) => voidMerge a partial patch into the config.
applyPreset(name: keyof typeof layoutPresets) => voidReplace the whole config with a named preset.
reset() => voidFull factory reset — clearAppStorage() then window.location.reload().
skinSkinThe active design skin.
setSkin(skin: Skin) => voidSwitch the design skin (persisted to localStorage, reflected on <html data-skin>).
oledDarkbooleanTrue-black dark neutrals in dark mode.
setOledDark(v: boolean) => voidToggle OLED dark (persisted, reflected on <html data-oled>).

PageHeader props

PropTypeDescription
titlestringPage title. Also drives the browser-tab title via useDocumentTitle.
breadcrumbsCrumb[] (optional)Explicit trail; when omitted it auto-derives from the route against menu.
actionReactNode (optional)Right-aligned slot; wrapped in <ButtonSizeProvider size="sm">.

Crumb is {label: string; to?: string}.

Col presets

PresetBehavior (breakpoint → span of 12)
fullspan 12 (default)
autointrinsic width (grid-column: auto)
col212 → xl:6
col312 → sm:6 → lg:4
col412 → sm:6 → md:4 → xl:3
col612 → sm:6 → md:4 → lg:3 → xl:2
sidebar12 → lg:3
main12 → lg:9
half12 → sm:6
third12 → sm:6 → md:4
twoThirds12 → md:8
quarter12 → sm:6 → lg:3
threeQuarters12 → lg:9

Configuration & customization

Presets

layoutPresets (src/context/LayoutContext.tsx) ships three named configs applied via applyPreset(name):

PresetCharacter
defaultThe shipped defaults (defaultLayout) — dark sidebar, chat rail, accordion menu, branded loader.
minimalChrome stripped back — no primary nav, no fixed/sticky, no widgets, light sidebar, minimal loader.
contentFocusMinified 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: minSidebardualSidebarheaderOnly (default = all three off).
  • Chat widgets: chatBubblechatRail.
  • autoHideNavstickyPageHeader — 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):

AttributeSource
data-fixed-navconfig.fixedNav
data-sticky-headerconfig.stickyPageHeader
data-fixed-footerconfig.fixedFooter || forceFixedFooter
data-min-sidebarconfig.minSidebar
data-dual-sidebarconfig.dualSidebar
data-header-onlyconfig.headerOnly || forceHeaderOnly

The SCSS in _layout.scss reads these and drives the CSS custom properties on .app-shell:

  • --app-header-height: 4rem (matches the h-16 header).
  • --sidebar-width — resolves to --sidebar-width-full (16rem), --sidebar-width-min (5rem, when data-min-sidebar), 0 (when data-header-only), or 19rem (dual rail 4rem + secondary 15rem).
  • --page-header-sticky-topvar(--app-header-height) when the nav is fixed, otherwise 0, 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()
  • fullBleed drops the <main> padding so the page owns the full content area edge-to-edge. Full-bleed pages do not render PageHeader (its negative-margin bleed assumes the padding) — call useDocumentTitle directly and size to calc(100dvh - var(--app-header-height) - <footer>).
  • headerOnly / fixedFooter force those presentations (e.g. the chat workspace brings its own rail + a pinned footer).
  • hideWidgets suppresses 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 changewindow.scrollTo(0, 0) on location.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 version Badge (brand.version) and a success-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 from localStorage directly — 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, and oled_dark are already registered.
  • Prefer route presentation flags over ad-hoc conditionals when a page needs a fixed layout — set the flag on its appRoutes entry so the intent lives with the route.
  • Every non-full-bleed page renders <PageHeader> — it carries the .page-header class the sticky behavior depends on, and syncs the tab title.
  • Use Row/Col presets 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

SymptomCause / fix
Sticky page header overlaps content or floats in a gapCheck 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 PageHeaderFull-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 reloadConfirm 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 dataThe key wasn't added to APP_LOCAL_KEYS/APP_SESSION_KEYS in src/lib/appStorage.ts.
Sidebar / footer flag has no visual effectThe 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.

Was this page helpful?