Riftix Console Bridge

Layout

The scroll contract, page grammar, sidebar behaviour and responsiveness.

The scroll contract

This is the most important layout rule in the system, and the one that is invisible until it breaks.

The host is a fixed-height grid whose content row is overflow: hidden:

<div class="bg-background grid h-screen grid-rows-[auto_1fr] overflow-hidden">
  <nav class="h-10">…</nav>
  <main class="relative min-h-0 overflow-hidden">
    <iframe class="block size-full border-0"></iframe>
  </main>
</div>

An embedded app therefore gets a viewport, not a page. There is no ambient document scroll to inherit. If your app lets <body> grow, the iframe does not grow with it — content is simply cut off, and the user has no scrollbar to reach it.

Rule. The app owns an internal scroll container. The document never scrolls.

<div class="h-svh overflow-hidden">        <!-- root: exactly one viewport -->
  <aside>…sidebar…</aside>
  <div class="flex min-h-0 flex-col">      <!-- min-h-0 so the child can shrink -->
    <header class="flex h-10 shrink-0 items-center gap-2 border-b px-2">…</header>
    <div class="min-h-0 flex-1 overflow-y-auto">
      <!-- page content scrolls here -->
    </div>
  </div>
</div>

Three details that are easy to get wrong:

  • h-svh, not h-screen. The small-viewport unit accounts for mobile browser chrome; 100vh overflows on iOS Safari.
  • min-h-0 on every flex child in the chain. Flex items default to min-height: auto, which refuses to shrink below content size — the overflow-y-auto then never activates and the whole column grows instead. This is the single most common cause of a broken scroll container.
  • shrink-0 on the header. Otherwise it compresses when content is tall.

Use native overflow-y-auto. A JavaScript scroll-area component costs you momentum scrolling, find-in-page and scroll anchoring for a styled scrollbar you do not need at zero radius.

The console's own pages follow the same contract with a simpler shell, since they have no sidebar: h-full overflow-auto p-8 on the route root.

Page grammar

Three primitives compose every page. They are small enough that the markup is the specification — implement them in whatever your framework calls a component.

Page

The content column. Two widths only.

<div class="mx-auto flex w-full flex-col gap-6 p-6 max-w-5xl">…</div>

max-w-5xl (64rem) is the default: a comfortable measure for settings and lists. Drop the max-width only for genuinely wide surfaces — an operator board, a canvas. If you find yourself wanting a third width, you probably want a different page.

<header class="flex flex-wrap items-start justify-between gap-4">
  <div class="flex min-w-0 flex-col gap-1">
    <p class="text-muted-foreground text-xs font-medium tracking-wide uppercase">Project</p>
    <h1 class="font-heading text-xl font-semibold tracking-tight text-balance">Acme Store</h1>
    <p class="text-muted-foreground max-w-2xl text-xs/relaxed text-pretty">
      One sentence on what this is and why it matters.
    </p>
  </div>
  <div class="flex shrink-0 flex-wrap items-center gap-2">
    <!-- page-level actions -->
  </div>
</header>
  • The eyebrow is the parent object's name — it tells the user where they are without a second breadcrumb trail.
  • The description is one line on why the page matters, capped at max-w-2xl. It is optional; an empty one is better than a restatement of the title.
  • flex-wrap plus min-w-0 on the title block and shrink-0 on the actions is what makes the header responsive without a breakpoint.

Put a status badge inline in the title rather than in a separate row:

<h1 class="…"><span class="flex flex-wrap items-center gap-2">Acme Store <span class="badge">live</span></span></h1>

PageSection

<section class="flex flex-col gap-3">
  <div class="flex flex-col gap-0.5">
    <h2 class="font-heading text-sm font-medium">Queues</h2>
    <p class="text-muted-foreground text-xs/relaxed">Optional one-liner.</p>
  </div>
  <!-- table, cards, form -->
</section>

The app owns in-app navigation; the host owns cross-app navigation. Your sidebar never lists other products, the user's account, or a theme toggle — those live in the host navbar, one level up.

PropertyValue
Expanded width16rem
Collapsed (icon rail)3rem
Mobile drawer18rem
Collapse modeicon rail, not off-canvas
Toggle shortcutCmd/Ctrl + B
Mobile breakpoint768px

Rule. The sidebar grows with the URL — one group per entity depth, appearing when that entity is in scope. A flat list of every route in the product does not scale past the second nesting level, and it forces the user to hold the hierarchy in their head. Groups that appear as you descend make the hierarchy visible.

Workspace          ← always
  Projects
Project            ← when a project is in scope
  Overview
  Experiences  ▸
  Settings     ▸
Experience         ← when an experience is in scope
  Overview
  Operate
  …

Swap the sidebar header by depth: the brand at the root, an entity switcher once you are inside one.

Active state matches the path exactly, or as a path prefix for parents:

const isActivePath = (pathname, href, exact) =>
  exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);

Below 768px the sidebar becomes a modal drawer. Keep the CSS breakpoint and the JS media query aligned on the same value — a rail that is hidden by CSS while the JS still thinks it is on desktop produces a toggle that does nothing.

Persist the collapsed state (a cookie works, and can be read during SSR to avoid a flash). If you write the state, read it back — a cookie nothing consumes is worse than no persistence, because it looks implemented.

Expose collapse state as a data attribute on a wrapper so children style themselves off it, rather than each child subscribing to state.

Responsiveness

Console apps run in an iframe whose width is the browser width minus nothing — but the same app may be open on a laptop beside a video call. Design for a range, not for phones.

Rule. Breakpoints are the last resort. In order of preference:

  1. Wrapping. flex-wrap with min-w-0 on the growing child and shrink-0 on the fixed one. This handles most headers, toolbars and chip rows with no breakpoint at all.
  2. Intrinsic grids. grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] fits as many tiles as the space allows and needs no maintenance when the tile count changes.
  3. Container queries, when a component's layout depends on its own width rather than the viewport's — a card in a sidebar versus the same card in a main column.
  4. Media breakpoints, when the layout genuinely changes at a viewport size.

Keep the breakpoint vocabulary small: md: for the one-to-two column shift, lg:/xl: for wide layouts. The reference app uses about a dozen breakpoint utilities in total across the whole application.

Let content drive columns

A viewport-only rule gives you a half-width column for a single item on a wide screen. Key the column count off the data as well:

const cls = [
  "grid items-start gap-4",
  queues.length >= 2 && "md:grid-cols-2",
  queues.length >= 3 && "xl:grid-cols-3",
].filter(Boolean).join(" ");

One queue gets the full width at any size. Three get three columns when there is room for three.

Floors and gutters

  • body { min-width: 320px; } is the declared floor.
  • Ramp page padding: p-4 md:p-6. A flat p-6 leaves 24px gutters on a 360px screen, which costs 13% of the usable width.

Tables on small screens

Horizontal scroll is the baseline, and it is not sufficient on its own.

<div class="relative w-full overflow-x-auto" tabindex="0" role="region" aria-label="Projects">
  <table class="w-full caption-bottom text-xs">…</table>
</div>

Rule. A scrollable region must be reachable by keyboard. A tabindex="0" plus an accessible name is required whenever the content can overflow — without it, keyboard users cannot scroll the region at all (WCAG 2.1 scrollable region focusable).

Rule. Past roughly four columns, provide a stacked fallback rather than a 700px-wide swipe strip. Cells are whitespace-nowrap by design — that keeps rows scannable, but it also means a six-column table cannot compress. Below the breakpoint, render each row as a labelled block:

<!-- ≥ md: table. < md: one block per record. -->
<dl class="flex flex-col gap-0.5 p-3 md:hidden">
  <div class="flex justify-between gap-2"><dt class="text-muted-foreground">Mode</dt><dd>Walk-in</dd></div>
  …
</dl>

Keep an affordance that the region scrolls — a fading edge or a visible scrollbar. A cut-off column with no visual hint reads as missing data.

On this page