busy-office-ui
Menu

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.

Measured in a browser against the shipped stylesheet, not estimated. ✓ supported · ~ depends · ✕ use something else.
Shell DesktopTabletMobileRF 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

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

  1. 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.
  2. 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.
  3. 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

RegionClassResponsibility
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:

  1. Page heading and breadcrumb — compose breadcrumb with an h1 and a cluster.
  2. 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.
  3. Application content — the pattern you picked above.
  4. 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.

↑↓ move Enter open Esc close