Layouts — the four shells
An ERP suite is not one screen shape wearing different content — it is four different shells, each already shipped, each solving a different "where does the reader stand" question. This page is a map, not a new pattern: pick the shell here first, then reach for the pattern that fills it. Not a component or a fifth option — every row below is a link to something that already exists; a screen that doesn't fit one of the four is a new shell, not a variant of these.
The four shells
| Shell | What it answers | Built from |
|---|---|---|
| App shell | "Where in the suite am I?" — persistent navigation, most screens, most of the day. | .bo-app-shell: header + sidebar + main |
| Role home | "What does MY role need today?" — the first screen after sign-in, one per role. | App shell's main, filled with widgets scoped to a role |
| RF full-screen | "One task, no distraction" — a handheld scanner, gloved hands, a single action per screen. | No app shell at all — chrome-free, isolated document |
| Split master-detail | "Show me the list AND the one I picked" — browsing many records without losing the list. | App shell's main, split into a list pane + detail pane |
All four are shipped primitives or patterns. This page adds no CSS and no component — it names when each is the right call, the same job review-screen anatomy does one level down, for the three ways to show a single record.
Which shell works on which device
Jump to a device: Desktop · Tablet · Mobile · RF scanner
Desktop, tablet and mobile are not three layouts. They are
the same shells answering to width. .bo-app-shell declares
container-name: bo-shell, and the whole stylesheet contains exactly
one shell band — @container bo-shell (max-width: 56rem).
It keys on the container, not the viewport, so a shell embedded in a
panel responds to the panel. Do not branch your markup per device;
there is nothing to branch on.
| Shell | Desktop | Tablet | Mobile | RF scanner |
|---|---|---|---|---|
| App shell | Supported — Rail 224px, labels visible. | Supported — At 896px and below the rail collapses to a 52px icon strip. | Supported — Same 52px rail — there is no separate mobile drawer. Labels are visually hidden, not removed, so they still have accessible names. | Use something else — Chrome the reader never uses. Use RF full-screen. |
| Role home | Supported — Widget grid resolves to 3 columns at 1440px. | Supported — 2 columns. Never hand-place widgets to force this. | Supported — 1 column at 390px. The grid reflows on its own. | Use something else — A day-start dashboard, not one task. Use RF full-screen. |
| RF full-screen | Use something else — Removes navigation a multi-task reader immediately wants back. | Depends — Only if the tablet IS the dedicated device. | Depends — Same test: one job, or not this shell. | Supported — Its case. data-density="spacious" gives 44px controls. |
| Split master-detail | Supported — Detail pane 816px at 1440px. | Depends — Tightens to a 382px detail pane at 834px — workable, watch the content. | Use something else — Breaks: the list pane is a fixed 22rem that never shrinks, so at 390px the detail pane is 36px. Swap the split for the offcanvas drawer that pattern already ships. | Use something else — Two panes on a handheld. Use RF full-screen. |
Desktop — shell wider than 896px
- App shell Rail 224px, labels visible.
- Role home Widget grid resolves to 3 columns at 1440px.
- Split master-detail Detail pane 816px at 1440px.
Not here: RF full-screen — see the table for what to use instead.
Tablet — roughly 768–896px
- App shell At 896px and below the rail collapses to a 52px icon strip.
- Role home 2 columns. Never hand-place widgets to force this.
- RF full-screen only if Only if the tablet IS the dedicated device.
- Split master-detail only if Tightens to a 382px detail pane at 834px — workable, watch the content.
Mobile — roughly 320–767px
- App shell Same 52px rail — there is no separate mobile drawer. Labels are visually hidden, not removed, so they still have accessible names.
- Role home 1 column at 390px. The grid reflows on its own.
- RF full-screen only if Same test: one job, or not this shell.
Not here: Split master-detail — see the table for what to use instead.
RF scanner — handheld, one task, gloved hands
- RF full-screen Its case. data-density="spacious" gives 44px controls.
Not here: App shell, Role home, Split master-detail — the table says what to use instead.
Crossing the band can give content MORE
room, not less — the one result here that surprises. At 896px
the rail drops from 224px to 52px, so main gains 171px in the same
breath the window got narrower: measured, a role home goes from 1 widget column
at 897px to 2 columns at 896px. If you are testing a layout, test
both sides of the band, not just the narrow end.
Deciding — three questions, not taste
- Is this a dedicated device with one job — a warehouse scanner, a kiosk — where the surrounding chrome would just eat screen real estate a gloved thumb can't use anyway? RF full-screen, no app shell. Everything else below assumes the app shell is present.
- Otherwise, is this the FIRST screen after sign-in, answering "what needs me today" before the reader has chosen a task? Role home — its own page inside the app shell's main, scoped per role rather than one screen for everyone.
- Otherwise: does the reader need the list AND a record's detail on screen at once, switching between records without losing their place in the list? Yes → split master-detail. No — a single list, or a single record filling the page on its own — → the app shell's main holds that content directly (a list report or one of the three record-review shapes), no further layout decision needed.
Answering those three is the whole choice. A screen that seems to need two of these at once (a role home that also wants a permanent detail pane, say) is usually two decisions sharing one page — split it, per the "design the decision, not the screen" test on the design-language page — rather than merging two shells into a new one.
Spacing the shell's main — the one composition that bites
When a screen's sections run together, the framework's own answer is the
stack primitive — so putting
bo-stack on .bo-app-shell__main is the first
thing a careful reader tries. Put it on an inner element
instead.
__main is a height-constrained scroller. Making it a flex
column makes its children flex items, and a flex item that is itself a
scroll container has an automatic minimum size of zero —
so a table container directly inside it collapses to nothing: measured at
clientHeight 0 against scrollHeight 200, a header
row and no data, on a screen that still looked plausible. Two correct
primitives composing into silent data loss, and the composition that breaks
is the one a careful reader tries first (roadmap 133.3).
The framework now defends the general case:
.bo-stack > * is flex-shrink: 0, because a
stack distributes rhythm, not space — a stack that does not fit
should overflow its scroller, visibly, rather than squeeze its children
away. That costs nothing existing (applied across 3346 stack children on
this site, zero heights changed). The advice above still stands, because
it is about which element owns the spacing, not only about the collapse.
When each shell is the WRONG choice
| Shell | Wrong when… |
|---|---|
| App shell | the screen is a single dedicated task on a single-purpose device — the persistent chrome becomes pure overhead the reader never uses (that’s RF full-screen’s case). |
| Role home | used as a general dashboard/reporting surface — it is scoped to ONE role’s daily start, not an analytics page. |
| RF full-screen | the reader has a full-size screen and more than one task in the same session — dropping the app shell there just removes navigation the reader will immediately want back. |
| Split master-detail | the record is long enough that a narrow detail pane can’t hold it without its own internal scroll-and-search — that is when object page replaces the cramped pane. |
Two of those have a named destination worth following: a cross-role analytics surface is reporting dashboard, and a record too long for a narrow pane is object page — a full-width page with in-page navigation, per its own scope note (roadmap 52.2).
The template for each shell
Structure only — copy one and fill it in. These
are outlines, not renderings: a live
.bo-app-shell cannot be shown inside this page, because it is
this page's own shell and would fight the real one for the viewport. What
is below is the part you actually reuse.
App shell
<div class="bo-app-shell"> <!-- container: bo-shell -->
<header class="bo-navbar bo-app-shell__header">…</header>
<nav class="bo-sidebar-nav bo-app-shell__sidebar">…</nav>
<main class="bo-app-shell__main">
<nav aria-label="Breadcrumb">…</nav>
<h1>…</h1>
…the pattern that fills the shell goes here…
</main>
</div> Role home
<main class="bo-app-shell__main">
<h1>…the role, not the app…</h1>
<!-- Widgets reflow on their own; do not hand-place them into columns. -->
<div class="bo-widget-grid">
<section class="bo-widget">
<div class="bo-widget__header"><h2 class="bo-widget__title">…</h2></div>
<div class="bo-widget__body">…a worklist, a count, one chart…</div>
</section>
…more widgets…
</div>
</main> RF full-screen
<!-- No .bo-app-shell: the chrome is the thing being removed.
data-density="spacious" IS the gloved-hand variant — there is no
--large modifier and there should not be one. -->
<main data-density="spacious">
<h1>…the one task…</h1>
<form class="bo-stack">
<div class="bo-form-field">…scan input…</div>
<button class="bo-btn" type="submit">…the single action…</button>
</form>
</main> Split master-detail
<main class="bo-app-shell__main">
<!-- No framework split primitive ships. The two panes are YOUR grid —
one declaration, and it is the only CSS this shell needs. -->
<div class="your-split"> <!-- e.g. grid-template-columns: 22rem 1fr -->
<section aria-label="List">…a data table, one row per record…</section>
<section aria-label="Detail">…the record you picked…</section>
</div>
</main> These templates use framework classes except
your-split, which is deliberately yours because
no split primitive ships. The markup checker validates
rendered HTML; it skips code samples, including the templates above.
After adapting a template, run bo-check-markup on your built
HTML to check its framework classes and supported attributes.
The shell contract — regions, scrolling, sticky, stacking
Everything below is read from
primitives/sidebar-layout.css, sidebar-nav.css and
tokens/z-index.css at build time, so a number here cannot drift
from the stylesheet that ships. There is no second shell and no new class:
the framework declares exactly four — .bo-app-shell and its
three parts.
Regions and who owns what
| Region | Class | Responsibility |
|---|---|---|
| Header | .bo-app-shell__header | Grid area only — it sets no height and no position. Its height
is whatever you put in it; a
navbar contributes its
own minimum, and grows with its content. It does not
stick; it stays put because the grid row is auto
inside a clipped shell. |
| Navigation (optional) | .bo-app-shell__sidebar | A second, independent scroller
(overflow-y: auto). A long nav scrolls on its own
without moving the workspace. Omit the element entirely for a
shell with no rail. |
| Workspace | .bo-app-shell__main | The primary scroller, and the only region that scrolls the
application's content. Carries --bo-app-shell-pad. |
Inside the workspace there are four responsibilities. Each is met by composing elements and components that already ship — which is why no page-header wrapper is needed and none is provided:
- Page heading and breadcrumb — compose
breadcrumb with an
h1and a cluster. - Action / toolbar scope — a
table toolbar acts on
the table below it; a page-level action row acts on the record. The scope
is where you put it. Do not add
role="toolbar"to an action row: that role carries the APG arrow-key model, and a row that claims it without implementing it is worse than an unlabelled row. - Application content — the pattern you picked above.
- Sticky action region —
.bo-form-actions, which sticks to the end of the main scroller.
Who scrolls
In screen mode the shell is block-size: 100dvh and
clips its own overflow, and __main is
overflow: auto. So long workspace content
scrolls __main; the document does not scroll. The
sidebar scrolls independently of both.
The boundary, stated because it is easy
to lose. Nothing in the framework puts overflow on
html or body. The document stays put only because
the shell is exactly viewport-height and is body's only normal-flow
content. Add normal-flow content beside the shell, or give
body padding, and you get a scrolling document with the shell
sliding off the top — the framework cannot prevent that and does not try.
(An overlay that is taken out of flow — a <dialog>, a
fixed toast region, a <script> — adds no document height,
which is why the overlay compositions documented elsewhere are safe here.)
Two further boundaries: this is a
screen-mode contract — @media print sets the
shell to display: block with overflow: visible and
hides the header and sidebar, so printing deliberately scrolls the document
— and an RF screen rendered as
its own isolated document has no shell at all, so none of this applies to
it.
Sticky offsets
A sticky element positions against its nearest scroll
container, not the viewport. For page-level content with no
scroller between it and the workspace — a
form action bar, say — that
container is __main. A
bounded table brings its
own: its sticky header sticks to that container's edge, not to the
workspace's. Knowing which one you are in is the whole question.
The workspace's padding is --bo-app-shell-pad, declared as
--bo-space-6 = 1.5rem (24px).
It is declared on __main itself, so only descendants of the
workspace can read it — the header and the sidebar cannot.
That is what lets a sticky action bar paint over
the padding strip below it instead of leaving a canvas-coloured gap, and
the same fallback (0px) is what makes the rule inert for a bar
used outside a shell. A rail narrower than the band collapses to icons at
56rem (896px) of shell
width — a container query, not a viewport one, which is why the same page
behaves differently in a narrow split than at a narrow window.
Stacking, and what z-index cannot buy
The shell itself sets no z-index. Layering is one scale,
documented and generated at
cascade & layers; the
layout-relevant part is the order — a sticky column sits under a sticky
table header, which sits under a page-level sticky bar, which sits under a
toast.
A native <dialog> is not on that scale, and no
number reaches it. An open modal dialog renders in the browser's
top layer, above every positioned element in the document
regardless of z-index. So a body-level toast cannot appear over
an open dialog, and raising its number changes nothing. Render the
confirmation inside the dialog instead — the framework ships no
dialog z-index token, deliberately, because there is no value that would
work.
For feedback that must not collide with a sticky action bar, see alerts & toasts, which documents a measured in-flow composition for exactly that case. Note what that measurement is not: the form's reserved focus gutter keeps keyboard-focused controls clear of the scroller's end edge. It is not a general promise that floating overlays, or an action bar of arbitrary height, will clear anything.
Viewport and safe areas
The shipped pages declare
<meta name="viewport" content="width=device-width, initial-scale=1">
— no viewport-fit=cover. That is the shipped
assumption, and the framework ships no env(safe-area-inset-*)
anywhere. What a browser does by default without that keyword is the
browser's documented behaviour, not something this page establishes.
If your app opts into edge-to-edge by adding
viewport-fit=cover, that is your decision and the insets become
yours to handle — the shell will not do it for you, and this framework does
not ship speculative padding for a mode it does not enable.