`AppShell` is the whole partner layout — the rail down the side carrying
the brand, the workspace switcher, your navigation, the app launcher and the
account row, plus the mobile drawer and the access gate around the page body.
Mount it once in your root layout and no route thinks about chrome again.

```tsx title="app/layout.tsx"
"use client";

// `AppShell` is the one /client export without the `Connect` prefix. If your
// own layout component shares the name, alias it: `AppShell as ConnectAppShell`.
import { AppShell } from "@cosmoops/connect-sdk/client";
import Link from "next/link";
import { usePathname } from "next/navigation";

export default function Shell({ children }: { children: React.ReactNode }) {
  return (
    <AppShell
      name="Pulse"
      nav={NAV}
      secondaryNav={SETTINGS_NAV}
      activePath={usePathname()}
      renderLink={({ href, children, ...props }) => (
        <Link href={href} {...props}>{children}</Link>
      )}
    >
      {children}
    </AppShell>
  );
}
```

<Callout title="There is no top bar">
  Everything a top bar used to carry now lives in the rail, one press from
  wherever the pointer already is. Your **page title goes in the page** — see
  [Screens](/docs/ui/screens) and `ConnectPageShell`. Below `md` a slim bar
  remains, carrying the drawer trigger and nothing else.
</Callout>

<Preview name="app-shell">

```tsx
<AppShell name="Pulse" nav={NAV} secondaryNav={SETTINGS_NAV} activePath={pathname} renderLink={renderLink}>
  {children}
</AppShell>
```

</Preview>

## The rail, top to bottom

| Region | What it holds |
|---|---|
| Header | The `cosmo·ops` lockup, and the collapse control |
| Workspace | `ConnectWorkspaceSwitcher`, when the account has more than one |
| Body | `nav` — your product's own destinations |
| Foot | `secondaryNav`, then `sidebarFooter`, then the app launcher |
| Account | `ConnectUserButton`, or the signed-out auth pair |

### The brand lockup

`name` and `icon` are what `ConnectLogo` draws, in all three places the lockup
appears — the expanded header, the collapsed rail's mark, and the mobile
drawer. `icon` is your badge glyph; without it the lockup falls back to the
Connect rings, so the rail reads as unbranded rather than as yours. `homeHref`
is where pressing it goes — it defaults to `/`, which is wrong for a product
whose root is a public marketing page rather than the signed-in app:

```tsx
<AppShell name="Pulse" icon={<PulseMark />} homeHref="/dashboard" nav={NAV}>
  {children}
</AppShell>
```

`ConnectLogo` also renders standalone, for anywhere the lockup needs to appear
outside the rail — a loading screen, an email-verification page:

```tsx
<ConnectLogo name="Pulse" icon={<PulseMark />} />
```

## Two navs, and the line between them

Both take the same `ConnectNav` shape and get the same active resolution,
audience filtering and `gateExempt` handling. The only difference is where they
sit — which is the difference between two kinds of route.

**A route belongs in `nav` if a user goes there to do the job, and in
`secondaryNav` if they go there to change how the job works.** Putting Settings
in the primary list is what makes a nine-item rail out of a five-item product.

```tsx
const NAV = [
  { label: "Dashboard", href: "/", icon: <LayoutDashboard className="size-5" /> },
  { label: "Campaigns", href: "/campaigns", icon: <Send className="size-5" /> },
  { label: "Audiences", href: "/audiences", icon: <Users className="size-5" /> },
];

const SETTINGS_NAV = [
  { label: "Settings", href: "/settings", icon: <Settings className="size-5" /> },
  { label: "Plans", href: "/plans", icon: <CreditCard className="size-5" />, gateExempt: true },
];
```

`nav` also takes sections and one level of children. Reach for a section when a
flat list stops being scannable — around seven entries, not before.

```tsx
const NAV = [
  { label: "Dashboard", href: "/", icon: <LayoutDashboard className="size-5" /> },
  {
    title: "Messaging",
    items: [
      { label: "Campaigns", href: "/campaigns", icon: <Send className="size-5" /> },
      { label: "Templates", href: "/templates", icon: <FileText className="size-5" /> },
    ],
  },
];
```

An **icon is required in practice**, not optional: the rail is icon-only at rest,
and an item with no icon collapses to an empty box.

`activePath` decides which entry is highlighted. Pass the real pathname — the
shell matches by longest prefix, so `/campaigns/42` highlights `/campaigns` and
not also `/`.

## Routing is a prop

`renderLink` is why the SDK has no router dependency and works the same in Next,
Remix or a plain SPA. It receives `href` and the props a link needs; you return
whatever your router calls a link.

```tsx
// Next.js
renderLink={({ href, children, ...props }) => <Link href={href} {...props}>{children}</Link>}

// React Router
renderLink={({ href, children, ...props }) => <NavLink to={href} {...props}>{children}</NavLink>}
```

## Collapsing

**The rail starts collapsed.** The icon rail is the resting state and expanding
is the deliberate act — its job is to be *there*, not to be read. A stored
preference from the user's last visit wins over the default, per browser.

- **Expanded**, the collapse control sits at the header's trailing edge.
- **Collapsed**, hovering or focusing the lockup swaps it for the expand glyph.
  The badge is the affordance, so the rail spends no row on a permanent control.

The trade, deliberately: collapsed, the lockup is no longer a link home. It has
become the expand control. Put home in `nav` — every application already does.

Pass `defaultCollapsed={false}` to open by default, `collapsible={false}` to pin
it open, or drive it yourself with `collapsed` + `onCollapsedChange`.

The animation moves the rail's **width and nothing else**: every glyph sits on
the same horizontal centre in both states and every label is clipped rather than
re-measured, so no row reflows and nothing drifts sideways mid-transition.

## The account row

`ConnectUserButton` is the last thing in the rail — the one item that is about
*you* rather than about the product. Circular avatar, name over email, opening
the account modal. Collapsed, it is the avatar alone, which is still a complete
account control.

The modal links to Connect for the things Connect owns — profile, security,
sessions, billing — because those involve credentials, MFA enrolment and billing
identity that an application has no way to reason about.

Those destinations go through `openConnect`, so the user comes back to your
page rather than staying in Connect. With the provider's default
`handoff="popup"`, Connect opens in a new tab that closes itself when they are
done and tells your page first, which then re-reads everything Connect owns;
with `handoff="redirect"` this tab goes to Connect and comes back. Either way
Connect matches the return URL's **origin** against your registered redirect
URIs — an origin match, not the exact match the OAuth callback needs, precisely
so a partner can send users back to the page they left. Register your app's
origin there, or the trip has no way back.

For a button of your own, `useConnect().openConnect("/calendar/settings")` does
the same thing.

Want your own control instead? `ConnectProfileModal` is the same modal with no
trigger, driven by `isOpen` / `onOpenChange`.

<Preview name="user-button">

```tsx
import { ConnectUserButton } from "@cosmoops/connect-sdk/client";

<ConnectUserButton />
```

</Preview>

## The launcher

The app launcher in the rail's foot is every Connect application this account
can reach, in two groups — and the second group is the point. **Your apps** is
what the workspace holds a live subscription to, current application pinned
first and marked, a paused subscription still listed but badged (a dunning
cycle should not make a product look deleted). **Explore** is the rest of the
active catalogue, minus anything this workspace's type could never buy; a tile
there opens that application's page on Connect, where a plan is chosen.

A tile in the first group opens the application's own site; one in the second
goes to Connect. Your integration builds neither URL — the SDK resolves the
destination from `subscribed`, so a launcher cannot send someone to a product
they have not bought or to a signup page for one they have.

<Callout title="This changed in 0.20.0">
  Before it, `/apps` answered with subscriptions alone and the grid carried a
  "Browse all applications" link underneath — which pointed at a Connect route
  that has never existed. A workspace with one subscription saw one tile and
  had no working way to find anything else. If you render your own grid from
  `connect.apps.list()`, filter on `subscribed` to get the old set back.
</Callout>

<Preview name="apps-grid">

```tsx
import { ConnectAppsGrid } from "@cosmoops/connect-sdk/client";

// AppShell mounts one already; this is for a bar of your own.
<ConnectAppsGrid variant="icon" />
```

</Preview>

## Slots

Everything optional, all rendered in the chrome that never gets gated:

| Prop | Where |
|---|---|
| `secondaryNav` | A second nav group, pinned to the foot of the rail |
| `sidebarFooter` | Your own rows, above the app launcher — see `ConnectSidebarRow` |
| `footer` | Below the page body |
| `askAi` | The Ask AI panel. On by default |
| `showAppsGrid={false}` | Removes the app launcher |
| `workspaceSwitcher={false}` | Turns the switcher off — see [Workspaces](/docs/ui/workspaces) |

`ConnectSidebarRow` is what `sidebarFooter` is for — a pressable row that lines
up with the SDK's own (the launcher, the account row), so a "Help" row you add
does not drift sideways while everything above it holds still:

```tsx
<AppShell
  sidebarFooter={
    <ConnectSidebarRow icon={<LifeBuoy className="size-5" />} label="Help" onPress={openHelp} />
  }
  // ...
/>
```

## The gate, and opting out

`AppShell` wraps its children — never its chrome — in
`ConnectAccessGate`. Two ways out of it:

```tsx
// Declared on the route, next to the route:
const NAV = [{ label: "Plans", href: "/plans", icon: …, gateExempt: true }];

// Or for a route that is not in either nav at all:
<AppShell gateExemptRoutes={["/checkout/callback"]}>{children}</AppShell>
```

Whatever route *resolves* a blocker belongs outside the gate, or the user cannot
reach the thing that would unblock them. Both navs are read for `gateExempt`, so
a `gateExempt` Plans route works just as well in `secondaryNav`. Pass
`gate={false}` to turn it off entirely, which is almost never what you want —
see [Gates](/docs/ui/gates).

<Callout title="The chrome is never gated, deliberately">
  A user whose payment failed still gets their navigation, their account row and
  their workspace switcher. They are blocked, not lost.
</Callout>

## Building your own rail

`AppShell` composes exactly these, and they are all exported:

```tsx
<ConnectShellProvider value={{ collapsed, collapsible, setCollapsed, renderLink, activePath }}>
  <ConnectSidebar>
    <ConnectSidebarHeader name="Pulse" />
    <ConnectWorkspaceSwitcher />
    <ConnectSidebarBody>
      <ConnectSidebarNav nav={NAV} />
    </ConnectSidebarBody>
    <ConnectSidebarFooter>
      <ConnectUserButton />
    </ConnectSidebarFooter>
  </ConnectSidebar>
</ConnectShellProvider>
```

Reach for this only when the prop API genuinely cannot express your chrome. The
parts coordinate through `ConnectShellProvider` rather than through each other's
props, so they still agree about collapse state, routing and the mobile drawer
wherever you put them — and you keep the collapse animation and the shared
glyph alignment instead of forking them.