Sidebar slots
A sidebar slot is a named insertion point in a host package’s sidebar. Other packages declare contributions targeting a slot, and the host renders them inline alongside its own UI. This is the mechanism for extending an existing package — for example, a booking-page package that adds “My Booking Pages” to the calendar sidebar instead of becoming its own top-level nav entry.
The contract is intentionally narrow: the host owns the slot’s position; the contributor owns what renders inside it.
When to use a sidebar slot vs. a new package
| You want… | Use… |
|---|---|
| A new top-level feature with its own nav entry and screens | A normal package with nav + routes |
| To add a section inside an existing package’s sidebar | A sidebar contribution |
| To add a panel to Personal Settings | settings |
Contributions are static and bundled-only — they ship at build time through the same generator pipeline as sidebar, provider, and settings. Runtime-installed packages (from the in-app package registry) cannot contribute slots.
Host side: exposing a slot
A host package does two things: declares the slot in its manifest, and renders <SidebarSlot> where contributions should appear.
const manifest = {
name: 'Calendar',
slug: 'calendar',
version: '0.1.0',
description: 'Shared calendar for your organization',
routes: { directory: 'screens' },
sidebar: { component: 'sidebar' },
slots: ['sidebar.after-calendars'],
// ...
}
Slot names are free-form strings — by convention, namespace them as sidebar.<descriptive-anchor> so contributors can read where their content lands without having to open the host’s sidebar code. Each name must be unique within the manifest; the generator errors on duplicates.
In the host’s sidebar component, drop <SidebarSlot> at the position the slot’s name promises:
import {
SidebarDivider,
SidebarItem,
SidebarNav,
SidebarSlot,
} from '@tinycld/core/components/sidebar-primitives'
export default function CalendarSidebar() {
return (
<SidebarNav>
{/* ... mini calendar, calendar list ... */}
<SidebarDivider />
<SidebarSlot target="calendar" slot="sidebar.after-calendars" />
<SidebarItem label="Subscribe to calendar" /* ... */ />
</SidebarNav>
)
}
When no contributions target the slot, <SidebarSlot> renders nothing — no extra divider, no empty container. The host’s existing layout is unchanged for users in a lean checkout.
Contributor side: rendering into a slot
A contributor package declares a sidebarContributions array in its manifest. Each entry says which host’s slot to target, and which component to render.
const manifest = {
name: 'Calendar Slots',
slug: 'calendar-slots',
version: '0.1.0',
description: 'Calendly-style booking pages for the calendar.',
sidebarContributions: [
{
target: 'calendar',
slot: 'sidebar.after-calendars',
component: 'sidebar-contributions/booking-pages',
},
],
// ...
}
target— slug of the host package.slot— slot name the host declared.component— subpath (no extension) to the React component, resolved throughpackage.jsonexports.order— optional sort priority. Default 0. Lower numbers render first; ties broken by contributor slug for stability.
Add the matching wildcard to your package.json so the generator can resolve the import:
{
"exports": {
"./sidebar-contributions/*": "./tinycld/calendar-slots/sidebar-contributions/*.tsx"
}
}
The component: contributor owns the chrome
<SidebarSlot> renders each contribution back-to-back with no wrapper. Your component is responsible for its own structure — heading, items, dividers, collapsible state, action buttons. This is deliberate: a “My Booking Pages” section and a “Quick Filters” section want very different layouts, and the slot stays out of the way.
import { SidebarHeading, SidebarItem } from '@tinycld/core/components/sidebar-primitives'
import { useOrgHref } from '@tinycld/core/lib/org-routes'
import { useRouter } from 'expo-router'
import { Calendar } from 'lucide-react-native'
import { useBookingPages } from '../hooks/use-booking-pages'
export default function BookingPagesContribution() {
const router = useRouter()
const orgHref = useOrgHref()
const { pages } = useBookingPages()
return (
<>
<SidebarHeading>My Booking Pages</SidebarHeading>
{pages.map((p) => (
<SidebarItem
key={p.id}
label={p.name}
icon={Calendar}
onPress={() => router.push(orgHref('calendar-slots/[id]', { id: p.id }))}
/>
))}
</>
)
}
The component must default-export. The generator imports by default name — a named export will not be picked up.
Ordering between contributions
When multiple packages target the same slot, contributions sort by order ascending (default 0). Ties break alphabetically by contributor slug. Set order explicitly only when relative position matters between two contributions you don’t control — for most cases the default is fine.
What the generator validates
Validation runs at generate time (tinycld/scripts/generate.ts, invoked by pnpm install and pnpm run packages:generate):
- A contribution whose
targetis installed but whoseslotis unknown → build error with the list of slots the target actually declares. - A contribution whose
targetis not in the current workspace → warning, not an error. This is normal for a partial checkout — the contribution silently won’t appear. When the host is installed, the contribution wakes up automatically. - Duplicate slot names within one manifest’s
slotsarray → build error.
Typos surface immediately. There’s no silent “contribution declared but never rendered” failure mode for present hosts.
Runtime gating: “only contribute if the host is present”
The generator already handles the absent-host case by skipping registration with a warning. If your contributor needs to vary its own behavior — for example, hide a “Sync with mail” feature when @tinycld/mail isn’t installed — use the runtime package registry:
import { usePackages } from '@tinycld/core/lib/packages/use-packages'
export default function MyContribution() {
const installed = new Set(usePackages().map((p) => p.slug))
const mailAvailable = installed.has('mail')
// ...
}
Do not add a hard import '@tinycld/mail/...' to your contributor. That turns the host into a build-time dependency and breaks the lean-shell guarantee (a workspace without mail must still typecheck and run). Filter by slug at runtime instead.
Built-in slots
| Host | Slot | Position |
|---|---|---|
calendar | sidebar.after-calendars | Below the “My calendars” / “Other calendars” / “Subscribed calendars” group, above “Subscribe to calendar” |
mail | sidebar.after-labels | Below the Labels section, above the Help link |
drive | sidebar.after-tree | Below the folder tree, above “Shared with me” |
To add a new slot to a host, declare its name in the host’s slots array and render a <SidebarSlot> at the corresponding position.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Contribution doesn’t appear after pnpm install | Generator hasn’t re-run, or package.json exports is missing the wildcard for the component subpath. |
| Build fails: “sidebarContribution targets unknown slot” | Typo in slot or the host hasn’t declared that slot in manifest.slots. The error lists the host’s actual slots. |
| Build warns: “not installed in this workspace” | The target host isn’t a present member — normal in a partial checkout. Install the host or remove the contribution. |
| Contribution renders but with no content | Component file exists but uses a named export. The generator only picks up default exports. |