Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Shell plugin

The frame every admin plugin renders inside — its slots, portals and shortcuts.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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-admin

Register

// 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

SlotItem shapeHolds
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.

RegionRenders asShipped items
actionA button in Home’s top barInvite member (users), New workspace (workspaces)
attentionA card under Needs your attentionStopped webhooks, entries awaiting review, open alarm findings, actions the audit log could not record
statA tile in the Overview rowThe workspace figures
panelA panel in the grid belowWorkspaces, 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-Id on its own request; since 0.12.0 apiClient keeps 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):

RegionFill withNotes
Top bar trailing actionsPageActionsPortalPageTopBar({ icon, crumbs }) renders the PageActions region
Right panelRightPanelPortaltitle 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’s actions move into the bar, at compact density, on a desktop. Below the breakpoint they stay beside the title. keepSubtitle is gone.
  • Container is 1120px wide with a 40px gutter from sm up. 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 (with SelectionBarDivider): a light card floating at the bottom centre of the work area through InsetFloating. 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.

  • ⌘B / Ctrl+B toggles 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+K opens 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 idDefault / min / maxViewport capCollapses
shell.sidebar256 / 208 / 384px25%yes
shell.rightPanel352 / 288 / 640px35%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 localStorage and saved to the user’s preferences (PUT /api/preferences with layout) by the users plugin. A controlled sidebar no longer writes the sidebar_state cookie.
  • 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>.