# App layout

The app frame: sidebar, main column, and an optional side panel the person can resize by dragging (or with the arrow keys on the divider). With `resizable={false}` the panel keeps `defaultPanel`'s width and the divider is a plain border. Dragging is never the only way: a click on the divider steps the panel through its narrowest, starting and widest widths, and Home and End jump to either end.

It responds to its own width, not the window's, so it behaves the same full-screen or embedded. Narrower than `sidebarAt`, the sidebar becomes a sheet behind a menu button; narrower than `panelAt`, the panel becomes a sheet from the right.

The sidebar sheet's open state is yours to control when you need it (`navOpen` and `onNavOpenChange`), so you can close it when the person navigates. With your own top bar, `navButton={false}` drops the built-in menu button; open the sheet from yours. The frame carries `data-nav` ("rail" or "sheet") and the `group/app-layout` class, so your button can show only while the sidebar is a sheet: `group-data-[nav=rail]/app-layout:hidden`.

- Registry name: `app-layout` (`@candor/app-layout`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/app-layout

## Install

```bash
npx candor-ui add app-layout
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/app-layout`.
It brings in: locale, sheet, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { Inbox, MessagesSquare, PanelRight, Settings } from "lucide-react"
import { AppLayout } from "@/components/app-layout"
import { AppSidebar } from "@/components/app-sidebar"
import { EnvironmentBand } from "@/components/environment-band"
import type { Environment } from "@/lib/team"

const staging: Environment = { id: "staging", name: "Storefront", detail: "staging", production: false, hue: 2, connection: "connected" }

export default function Example() {
  const [place, setPlace] = React.useState("threads")
  const [panelOpen, setPanelOpen] = React.useState(false)
  const [fixed, setFixed] = React.useState(false)
  return (
    <div className="h-[420px] overflow-hidden rounded-lg border">
      <AppLayout
        // Sized for this preview box; in a full-screen app the defaults (768 and 1100) suit.
        sidebarAt={560}
        panelAt={640}
        minPanel={200}
        defaultPanel={220}
        maxPanel={320}
        // resizable={false} keeps the panel at defaultPanel's width, with no drag handle.
        resizable={!fixed}
        sidebar={
          <AppSidebar
            brand={<span className="px-1 text-sm font-semibold">Keel</span>}
            nav={[
              { id: "threads", label: "Threads", Icon: MessagesSquare },
              { id: "inbox", label: "Inbox", Icon: Inbox, count: 2, urgent: true },
              { id: "settings", label: "Settings", Icon: Settings },
            ]}
            activeNav={place}
            onNavigate={setPlace}
            user={{ name: "Sam", email: "sam@acme.example" }}
          />
        }
        top={<EnvironmentBand account="Acme" environment={staging} />}
        panel={
          <div className="flex flex-col gap-2 p-4 text-sm">
            <p className="font-medium">0042_add_region</p>
            <p className="text-muted-foreground">Adds a nullable region column to orders. Ran on Staging in 1.8s.</p>
          </div>
        }
        panelOpen={panelOpen}
        onPanelOpenChange={setPanelOpen}
        panelLabel="Migration"
      >
        <header className="flex h-11 shrink-0 items-center gap-2 border-b px-4">
          <h2 className="min-w-0 flex-1 truncate text-sm font-semibold">Add a region column to orders</h2>
          <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
            <input type="checkbox" checked={fixed} onChange={(e) => setFixed(e.target.checked)} />
            Fixed width
          </label>
          <button
            type="button"
            aria-label="Migration panel"
            aria-pressed={panelOpen}
            onClick={() => setPanelOpen((o) => !o)}
            className="flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
          >
            <PanelRight className="size-4" />
          </button>
        </header>
        <div className="flex-1 overflow-y-auto p-4 text-sm text-muted-foreground">
          The migration is ready on Staging. Open the panel to see what it changes.
        </div>
      </AppLayout>
    </div>
  )
}
```

## <AppLayout>

Props:

- `sidebar: React.ReactNode`: The left rail (an AppSidebar); a sheet behind a menu button when narrow.
- `top?: React.ReactNode | undefined`: A strip above the main column (an OrgBand).
- `children: React.ReactNode`
- `panel?: React.ReactNode | undefined`: The side panel's content, shown right of the main column when `panelOpen`.
- `panelOpen?: boolean | undefined` (default `false`): Whether the side panel is showing. Defaults to false.
- `onPanelOpenChange?: ((open: boolean) => void) | undefined`: Called when the panel opens or closes, including when a narrow frame closes it.
- `panelLabel?: string | undefined`: The panel's accessible name, also used for the resize handle. Defaults to "Details".
- `minPanel?: number | undefined` (default `320`): The narrowest the panel can be dragged, in pixels. Defaults to 320.
- `maxPanel?: number | undefined` (default `720`): The widest the panel can be dragged, in pixels. Defaults to 720.
- `defaultPanel?: number | undefined` (default `400`): The panel's starting width, in pixels, and its fixed width when it isn't resizable. Defaults to 400.
- `resizable?: boolean | undefined` (default `true`): Whether the person can drag (or arrow-key) the panel's width between `minPanel` and `maxPanel`. Defaults to true.
- `sidebarAt?: number | undefined` (default `768`): Frame width in pixels below which the sidebar becomes a sheet. Defaults to 768.
- `panelAt?: number | undefined` (default `1100`): Frame width in pixels below which the panel becomes a sheet from the right. Defaults to 1100.
- `defaultWidth?: number | undefined` (default `0`): The frame width to lay out for until it can be measured: on the server, and in test environments like jsdom that measure everything as 0. Defaults to 0, the narrow layout (sidebar and panel as sheets).
- `navOpen?: boolean | undefined`: Whether the sidebar sheet is open, when narrow. Give it to control the sheet yourself; leave it out and AppLayout keeps its own state.
- `onNavOpenChange?: ((open: boolean) => void) | undefined`: Called when the sidebar sheet opens or closes: from the menu button, Escape, a click outside, or the frame growing wide enough for the rail.
- `navButton?: boolean | undefined` (default `true`): Shows the built-in menu button that opens the sidebar sheet when narrow. Defaults to true; with false, open the sheet from your own button through `navOpen`.
- `className?: string | undefined`

## Strings (`appLayout`)

The fixed words, English by default. Override any of them under `appLayout` in `<LocaleProvider strings={{ appLayout: { … } }}>`; the table is exported as `appLayoutStrings`.

- `navigation`: Navigation
- `openNavigation`: Open navigation
- `details`: Details
- `resizePanel`: Resize {panel} panel

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider` (fixed strings) and `TermsProvider` (nouns); entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
