The admin’s chrome: the layout every private route renders in (a collapsible
sidebar beside a <main>), the home page at /, and the auth gate.
Install
npm install @orthacms/shell-adminRegister
// apps/admin/src/plugins.ts
import { IdentityPlugin } from '@orthacms/identity-admin';
import { ShellPlugin } from '@orthacms/shell-admin';
export function buildPlugins(): AdminPlugin[] {
return [
IdentityPlugin(), // first: its public routes (sign-in, accept-invite) render outside the gate
ShellPlugin(), // second: the host mounts the FIRST layout it finds
// …everything else
];
}No server half, no configuration, no environment variables.
Keep it second
The shell’s layout wraps identity’s AuthProvider around RequireAuth around
the app. A plugin that registers a layout ahead of it renders every private
route ungated. An admin without the shell has no auth gate at all.
Slots
| Slot | Item shape | Holds |
|---|---|---|
SIDEBAR_NAV_SLOT | { labelId, defaultLabel, to, group, order, icon, iconColor?, permission?, end? } | Rail entries; group is overview or directory |
SIDEBAR_SECTION_SLOT | { id, order, Component } | Sections below the nav (e.g. the workspace list) |
SIDEBAR_FOOTER_SLOT | { id, order, Component } | The footer (e.g. the account menu) |
HOME_SECTION_SLOT | { id, region: 'action' | 'attention' | 'stat' | 'panel', order, Component } | The home page — see Home |
COMMAND_SLOT | { id, order, Component } — gets { close } | Result sections in the ⌘K palette |
import type { AdminPlugin } from '@orthacms/bootstrap-admin';
import { HOME_SECTION_SLOT, SIDEBAR_NAV_SLOT } from '@orthacms/shell-admin';
import { Receipt } from 'lucide-react';
export function OrdersPlugin(): AdminPlugin {
return {
name: 'orders',
slots: [
{
slot: SIDEBAR_NAV_SLOT,
items: [
{
labelId: 'orders.nav',
defaultLabel: 'Orders',
to: '/orders',
group: 'directory',
order: 60,
icon: Receipt,
permission: 'content:read'
}
]
},
{
slot: HOME_SECTION_SLOT,
items: [{ id: 'orders.today', region: 'stat', order: 50, Component: OrdersToday }]
}
]
};
}Home
HOME_SECTION_SLOT items are placed by region and sorted by order. Each
Component takes no props and reads what it needs.
| Region | Renders as | Shipped items |
|---|---|---|
action | A button in Home’s top bar | Invite member (users), New workspace (workspaces) |
attention | A card under Needs your attention | Stopped webhooks, entries awaiting review, open alarm findings, actions the audit log could not record |
stat | A tile in the Overview row | The workspace figures |
panel | A panel in the grid below | Workspaces, recent activity |
An attention item renders a HomeAttentionCard — or null when there is
nothing to say. The heading hides while every card in the region is empty, so a
calm install shows no section at all.
import { HomeAttentionCard } from '@orthacms/shell-admin';
import { Receipt } from 'lucide-react';
function FailedOrders() {
const { data } = useFailedOrders();
if (!data || data.count === 0) return null; // a fault: show only while it lasts
return (
<HomeAttentionCard
tone="destructive" // HomeAttentionTone: 'destructive' | 'warning' | 'info' | 'calm'
icon={Receipt}
title={`${data.count} orders failed`}
description="The payment provider refused them."
action="Open orders"
to="/orders?state=failed"
/>
);
}- The whole card is one link, named by its headline and its call to action.
- A work queue (alarm findings, reviews) stays on the row at 0 with
tone="calm", so an empty queue is not mistaken for a card that failed to load. A fault (a stopped webhook) renders only while it is happening. - Home sits outside every workspace. A card that sums across workspaces asks
each one by setting
X-Workspace-Idon its own request; since 0.12.0apiClientkeeps a header a request sets itself.
A route can replace the sidebar’s middle with useSidebarContent — the
workspace shell swaps in its per-workspace navigation that way; the footer stays.
Within that navigation, since 0.13.0, a WORKSPACE_SECTION_SLOT item whose
optional takeover(pathname) returns true is rendered alone, without the other
sections or the Tools group — the copilot’s Agents view shows its chat list
there in place of the content tree.
See slots and admin plugins.
Filling the frame from a page
Two regions the shell draws and a page fills, through React portals — so the contents keep the page’s own context (workspace, form state, handlers):
| Region | Fill with | Notes |
|---|---|---|
| Top bar trailing actions | PageActionsPortal | PageTopBar({ icon, crumbs }) renders the PageActions region |
| Right panel | RightPanelPortal | title names it. No width when empty. Docked, it is resizable from its left edge and its width and open/collapsed state are saved to the user’s preferences. A bottom sheet on a phone (Esc closes) |
import { PageActionsPortal, PageTopBar, RightPanelPortal } from '@orthacms/shell-admin';
import { Receipt } from 'lucide-react';
export function OrdersPage() {
return (
<>
<PageTopBar icon={Receipt} crumbs={[{ key: 'orders', label: 'Orders' }]} />
<PageActionsPortal>
<Button onClick={exportCsv}>Export</Button>
</PageActionsPortal>
<RightPanelPortal title="Details">
<OrderDetails />
</RightPanelPortal>
{/* page body */}
</>
);
}useRightPanel() exposes the panel’s state to the page.
Two props change how the right panel sits (since 0.12.0):
overlay— opt in per page. On a desktop the panel is then a sheet over the page, at the window’s right edge over a scrim, and starts closed on every arrival. While it is up the page behind it is inert;Esc, a click on the scrim or its Hide button closes it, and its toggle stays in the top bar. The Pin button in its header docks it back into the column. The pin is kept per browser (orthacms:right-panel:pinned), not in the synced preferences. The entry editor’s Properties panel uses it.attention— puts a dot on the reopen control while the page says the panel holds something the reader must act on. The entry editor sets it while something blocks publishing.
The children stay mounted while the panel is closed, in either mode, so closing it never throws away their state or refetches their data.
Page headers
A page’s own heading is the design system’s ContainerHeader, inside a
Container. Since 0.12.0:
- The title stays on screen under a top bar. The
<h1>and its subtitle no longer fold to screen-reader-only text; only the header’sactionsmove into the bar, at compact density, on a desktop. Below the breakpoint they stay beside the title.keepSubtitleis gone. Containeris 1120px wide with a 40px gutter fromsmup.width="full"is for a collection’s records table alone; every other page, tables included, keeps the measure.- A list’s bulk bar is
SelectionBar(withSelectionBarDivider): a light card floating at the bottom centre of the work area throughInsetFloating. The page pads whatever scrolls while it is up.
The work area is one card floating on the sidebar’s gray, and the right panel sits inside it. App-wide controls stay out of the page: since 0.9.0 the Ortha CMS AI launcher, Ask Ortha AI, sits in the strip under that card, at its right end, inside a workspace.
Sidebar and shortcuts
⌘B/Ctrl+Btoggles the sidebar. On mobile it is an overlay drawer.- On a desktop the sidebar is resizable from its right edge, and dragged well past its minimum it collapses.
- Collapsed, its reveal button renders in the page’s top bar (a floating fallback appears only on pages without one).
⌘K/Ctrl+Kopens the command palette (Search or jump to…), except while typing in a text field or the rich-text editor.
Resizable panels
Since 0.11.0 the shell mounts one panel layout per signed-in user, and every resizable panel in the admin reads and writes it. The shell owns two entries:
| Panel id | Default / min / max | Viewport cap | Collapses |
|---|---|---|---|
shell.sidebar | 256 / 208 / 384px | 25% | yes |
shell.rightPanel | 352 / 288 / 640px | 35% | yes |
The media library’s folders column (media.folders, 240 / 192 / 384px) is an
entry too, and does not collapse. The copilot’s resizable thread column
(copilot.agentsRail) is gone since 0.13.0 — its chats are the workspace
sidebar on the Agents view — and a width saved for it is left unread.
- A drag previews through a CSS variable and commits once, on release.
- The viewport cap applies at render, never in storage, so a width chosen on a wide monitor comes back there after a visit from a laptop.
- The layout is cached in
localStorageand saved to the user’s preferences (PUT /api/preferenceswithlayout) by the users plugin. A controlled sidebar no longer writes thesidebar_statecookie. - Below the mobile breakpoint there is no handle: the sidebar is a drawer and the right panel a bottom sheet.
A page of your own makes a panel resizable with the design system’s
useResizablePanel({ id, defaultWidth, minWidth, maxWidth, maxViewportRatio?, collapsible?, cssVar })
and a ResizeHandle on its edge. Name the id <plugin>.<panel>.