| name | shell |
| description | Use when scaffolding or modifying shell regions (icon rail, context rail, header, panel, command palette) in any app consuming `@medalsocial/meda` — Picasso, pilot-talk, NextMedal, apps/web. Required reading before adding a rail item, picking an AppShell variant (auth/workspace/chat), wiring command palette actions, configuring `MedaShellProvider`, or any `IconRail` change. |
Meda Shell
When to load this skill
- Scaffolding a new shell — picking
AppShell variant and configuring MedaShellProvider.
- Adding/removing/reordering icon rail items.
- Registering command palette actions.
- Wiring the workspace switcher or theme toggle in the header.
- Building or modifying the right panel.
- Touching anything under
src/shell/ in @medalsocial/meda.
App shell variants
The package exports a single <AppShell> component with a discriminated variant prop. Pick exactly one variant per route group:
| Variant | When to use | Key config |
|---|
'auth' | Sign-in / sign-up / password reset / OAuth callbacks. Lets the form scroll past viewport (dense forms, high zoom). | auth, branding, optional preview (right-side art) + actions (top-right) |
'workspace' | Logged-in product shell. Has icon rail + context rail + header + main + optional right panel. | iconRail, contextRail, rightPanel, workspace (menu items override), appTabs (router integration), headerCenter, banners, mainLayout, globalActions |
'chat' | Chat-first surfaces (full-bleed messaging UI; no rails). | globalActions |
AppShellWorkspace.workspace.menuItems REPLACES the default workspace dropdown ("Manage workspaces / Settings / Profile / Sign out") when provided. The theme toggle is preserved automatically — consumers do not have to re-implement theme cycling.
MedaShellProvider — the runtime root
Wrap your app once with <MedaShellProvider> (typically in the root layout). Props:
interface MedaShellProviderProps {
workspace: WorkspaceDefinition;
workspaces?: WorkspaceDefinition[];
apps: AppDefinition[];
defaultActiveApp?: string;
storage?: ShellStorageAdapter;
mobileBottomNav?: MobileBottomNavItem[];
commandPaletteHotkey?: string;
themeAdapter?: 'default' | 'next-themes' | ThemeAdapter;
children: ReactNode;
}
themeAdapter: 'next-themes' for Next.js apps using next-themes. The adapter is lazy-loaded so default-adapter consumers don't pay for the bridge.
themeAdapter: 'default' uses the built-in adapter (no external dep).
- Pass a custom
ThemeAdapter object to integrate with any other theme system.
apps MUST have at least one entry — the provider throws on empty array.
Regions and component map
| Region | Component | Source |
|---|
| Icon rail | IconRail | src/shell/icon-rail.tsx |
| Context rail | ContextRail | src/shell/context-rail.tsx |
| Header | ShellHeader + WorkspaceSwitcher | src/shell/shell-header.tsx |
| Main | ShellMain | src/shell/shell-main.tsx |
| Right panel | RightPanel | src/shell/right-panel.tsx |
| Command palette | CommandPalette + CommandRegistryContext | src/shell/command-palette.tsx |
The flat-rail rule (critical)
The IconRail is a flat list of equal-weight icons. No section headers, no per-group labels, no in-line dividers between groups.
IconRail accepts mainItems, utilityItems, and a footer. Use those three slots — do NOT inject section labels or dividers via renderLink, custom items, or a wrapping component.
Why: A "Testing" divider above Journeys was specced and built three different ways in 2026-05 (renderLink-injected, a real IconRailDivider API in meda, and a consumer pnpm patch). All three were abandoned — meda PR #161 and labs PR #162 were closed unmerged; labs PR #163 removed the work. The icon-button slot is 44×44; anything wider overflows and overlaps the next icon, and an icon-only ~60px rail cannot host a text label legibly. Do not re-propose dividers, group headers, or "mark this surface as testing/ops" rail treatments.
If a surface needs a type indicator, it lives in the surface itself (a banner, a header pill, a column-header badge) — never in the icon rail.
The existing RailDivider in icon-rail.tsx is a different pattern: a chevron toggle that repositions utility items between top and bottom of the rail. It is spatial, not a group label. Do not generalize it into section headers.
IconRail item shape + active styling
interface IconRailItem {
id: string;
label: string;
icon: LucideIcon;
to: string;
badge?: ReactNode;
}
Slot is h-11 w-11 rounded-xl. Active state uses bg-primary/12 text-primary (a 12%-alpha brand tint, NOT a solid brand fill — this is the legitimate bg-primary use case from the brand skill's note). Inactive uses text-muted-foreground hover:bg-accent hover:text-foreground. Don't override unless you're consciously diverging.
Pass activeId to mark which item is active; usually derived from your router's current path.
renderLink — when to use it
IconRail and other rail components accept a renderLink prop that wraps the default <a>:
renderLink={({ item, isActive, className, children, linkProps }) => (
<NextLink href={item.to} className={className} {...linkProps}>
{children}
</NextLink>
)}
Use it for: integrating with Next.js Link, TanStack Router, React Router — anything that needs client-side navigation hooks.
Do NOT use it for: injecting dividers, headers, badges outside the slot, or any non-link content (see the flat-rail rule). The className parameter constrains your wrapper to the 44×44 slot — non-link content overflows.
Command palette
CommandPalette is registry-driven. Components register their commands via the public hooks useCommands and useCommandGroup from @medalsocial/meda/shell — both must run inside a <CommandPalette> (they throw otherwise).
import { useCommands, useCommandGroup } from '@medalsocial/meda/shell';
function MyFeature() {
useCommandGroup({ id: 'tools', label: 'Tools', priority: 50 });
useCommands([
{ id: 'my.action', label: 'Run my action', group: 'tools', run: () => doIt() },
]);
return null;
}
Each hook auto-handles register-on-mount and unregister-on-unmount via useEffect. Lower priority numbers render the group earlier (default 100).
CommandRegistryContext is internal — don't import or useContext it directly. The hooks are the supported API.
The default palette hotkey is 'mod+k' — override via MedaShellProvider.commandPaletteHotkey. Hotkey matching is strict modifier-aware: 'mod+k' does NOT fire on mod+shift+k. Use 'mod' (resolves to ⌘ on macOS, Ctrl on Windows/Linux), not platform-specific keywords.
Right panel patterns
Use a single RightPanel per shell. Don't build a parallel right-side surface — multiple right panels create state and dismiss-behavior conflicts. For a stacked detail experience, register multiple PanelViews with the existing PanelViewsProvider (src/shell/panel-views-provider.tsx).
Drag patterns
RailDropSlot, RailDropZones, DragModeBanner are the canonical drag patterns. Don't add custom drag handlers to the rail — use these so the visual + a11y behavior matches across consumers.
Anti-patterns
| Anti-pattern | Why it's wrong | Correct approach |
|---|
Adding a section divider/header to IconRail | Three prior attempts abandoned; slot geometry can't host labels | Keep the rail flat; put type indicators in surfaces |
Using renderLink for non-link content | Wraps inside the 44×44 slot — overflows/overlaps | Use mainItems / utilityItems / footer only |
Picking 'workspace' variant on a sign-in route | Renders rails on routes with no app context | Use 'auth' variant; switch to 'workspace' after auth |
Re-implementing the theme toggle when overriding workspace.menuItems | The package inserts the toggle automatically | Just ship your menu items; toggle is added between items and footer |
| Custom icon library | Inconsistent sizing + brand tone | Lucide React only |
Multiple RightPanels in one shell | Dismiss/state conflicts | Use PanelViewsProvider for stacked detail |
Forking MedaShellProvider per app | Loses cross-consumer parity | Compose around it; pass a custom ThemeAdapter for theme integration |
Hard-coded modifier in hotkey strings ('cmd+k') | Breaks on Windows/Linux | Use 'mod+k' — resolves per-platform |