Codexy Design System
Components

AppShell

데스크탑 앱(Tauri/macOS Overlay)의 3-패널 레이아웃 셸. Sidebar(back) + Main+SidePanel(foreground card) 구조.

데스크탑 앱의 외곽 윈도우 안에 깔리는 3-패널 셸. 좌측 사이드바, 중앙 본문, 우측 패널(예: 챗) 의 공통 구조를 하나의 컴포넌트군으로 제공한다.

전체 화면에서 보기페이지 전환, header action, 빈 header, sidebar toggle을 한 화면에서 확인한다.

CDS 는 윈도우 외곽(트래픽 라이트, 그림자, 모서리 squircle, 드래그-이동, 더블클릭-최대화) 을 떠안지 않는다. 이건 macOS Overlay 모드에서 OS 가, Windows/Linux 에서는 향후 별도 어댑터가 처리한다. AppShell 은 그 안의 레이아웃 + 시각 layer + 상태(접힘/폭) 만 책임진다.

레이어 모델

┌─ window (OS 가 외곽 처리) ─────────────────────────────────┐
│                                                            │
│  ┌─ Sidebar ──┐  ┌─ Foreground Card ────────────────────┐ │
│  │  back      │  │   Main           │   SidePanel        │ │
│  │  layer     │  │   (front)        │   (front)          │ │
│  │  (gray)    │  │   ↑                                   │ │
│  │            │  │   사이드바와 만나는 좌측만 둥글게      │ │
│  └────────────┘  └───────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
  • Sidebar = back layer. 좌측 가장자리에 깔리며 회색 톤.
  • Main + SidePanel = foreground card. 사이드바 위로 떠있는 하나의 흰 카드.

모서리 라운딩

Foreground card 의 모서리는 background layer 와 만나는 쪽만 둥글게. 윈도우 가장자리에 닿는 쪽은 OS squircle 이 처리하므로 CDS 는 신경 쓰지 않는다.

자동 처리됨 (<AppShellMain>data-adjacent-back-left 가 사이드바 열림 상태에 따라 토글).

Titlebar inset (macOS 트래픽 라이트 회피)

macOS Overlay 모드에서 트래픽 라이트(빨/노/초)가 좌상단 ~130px 영역을 차지한다. 그 자리에 우리 UI 가 닿으면 가려진다.

  • <AppShell>leftInset={130} (또는 OS 별 적절값) 을 주입.
  • 좌/우 끝의 현재 열려있는 패널 헤더가 자동으로 inset 을 흡수한다.
    • 사이드바 열림 → AppShellSidebarHeader 가 좌측 130px padding
    • 사이드바 닫힘 → AppShellMainHeader 가 좌측 130px padding (Main 이 leftmost 가 되므로)
  • CDS 는 OS 를 직접 감지하지 않는다. 제품이 Tauri API 로 OS 를 보고 inset 값을 prop 으로 주입.

사용

import {
  AppShell,
  AppShellSidebar,
  AppShellSidebarHeader,
  AppShellSidebarBody,
  AppShellSidebarFooter,
  AppShellMain,
  AppShellMainHeader,
  AppShellMainBody,
  AppShellPageHeader,
  AppShellSidePanel,
  AppShellSidePanelHeader,
  AppShellSidePanelBody,
  AppShellSplitter,
} from "@tendtoyj/cds-ui";

<AppShell leftInset={130}>
  <AppShellSidebar defaultWidth={220} minWidth={200} maxWidth={320}>
    <AppShellSidebarHeader>{/* 사이드바 토글 등 */}</AppShellSidebarHeader>
    <AppShellSidebarBody>{/* 내비 */}</AppShellSidebarBody>
    <AppShellSidebarFooter>{/* 설정 등 */}</AppShellSidebarFooter>
  </AppShellSidebar>

  <AppShellSplitter target="sidebar" doubleClickResetWidth={220} />

  <AppShellMain>
    <AppShellMainHeader>{/* breadcrumb 등 */}</AppShellMainHeader>
    <AppShellMainBody>
      <AppShellPageHeader>{/* page-level tabs/actions */}</AppShellPageHeader>
      {/* 페이지 본문 */}
    </AppShellMainBody>
  </AppShellMain>

  <AppShellSplitter target="sidePanel" doubleClickResetWidth={360} />

  <AppShellSidePanel defaultWidth={360} minWidth={280} maxWidth={560}>
    <AppShellSidePanelHeader>{/* 챗 탭 등 */}</AppShellSidePanelHeader>
    <AppShellSidePanelBody>{/* 챗 메시지 등 */}</AppShellSidePanelBody>
  </AppShellSidePanel>
</AppShell>;

Page header composition

<AppShellPageHeader>는 페이지가 소유한 tabs, filters, actions를 페이지 본문 가까이에서 선언하면서도 실제 DOM은 가장 가까운 <AppShellMainHeader>의 전용 slot에 렌더링한다. route 구성과 header 레이아웃을 서로 억지로 끌어올리지 않아도 페이지별 controls를 main titlebar에 합성할 수 있다.

import {
  AppShell,
  AppShellMain,
  AppShellMainBody,
  AppShellMainHeader,
  AppShellPageHeader,
} from "@tendtoyj/cds-ui";
import { Plus } from "@tendtoyj/cds-icons/icons";
import { IconButton } from "@tendtoyj/cds-ui/components/icon-button";
import { Tabs, TabsList, TabsTrigger } from "@tendtoyj/cds-ui/components/tabs";

<AppShell>
  <AppShellMain>
    <AppShellMainHeader>{/* persistent leading content */}</AppShellMainHeader>
    <AppShellMainBody>
      <Tabs value={tab} onValueChange={setTab}>
        <AppShellPageHeader>
          <div className="flex w-full min-w-0 items-center justify-between gap-[12px]">
            <TabsList>
              <TabsTrigger value="skills">Skills</TabsTrigger>
              <TabsTrigger value="plugins">Plugins</TabsTrigger>
            </TabsList>
            <IconButton aria-label="Add item" onClick={addItem}>
              <Plus />
            </IconButton>
          </div>
        </AppShellPageHeader>
        {/* Page content stays here. */}
      </Tabs>
    </AppShellMainBody>
  </AppShellMain>
</AppShell>;

main header에 직접 선언한 persistent content가 먼저 배치되고 page-header slot이 남은 폭을 차지한다. slot은 min-width: 0, overflow: hidden, 좌우 32px padding을 소유한다. 좁은 폭에서는 slot이 먼저 줄어들며, page header의 최상위 wrapper에도 w-full min-w-0와 필요한 truncate/shrink-0 규칙을 지정하는 것을 권장한다. padding 상수와 내부 slot/context는 public API가 아니다.

Lifecycle과 fallback

상황동작
AppShell 밖children을 inline 렌더링한다. 독립 preview와 SSR fixture에 유용하다.
AppShellMain 안, slot mount 전inline fallback을 만들지 않고 null을 렌더링한다. hydration과 본문 중복을 방지한다.
AppShellMainHeader 없음header content를 숨긴 채 유지한다. 본문 inline fallback은 만들지 않는다.
활성 page header 없음main header에 빈 slot DOM을 만들지 않는다.
route 전환 또는 unmount등록을 정리하고 stale header content를 제거한다.

여러 <AppShellPageHeader>가 동시에 mount되면 last-mounted wins다. 마지막 항목이 unmount되면 그 전에 mount되어 있던 항목이 다시 활성화된다. 이 정책은 전환 중 두 route tree가 잠깐 겹치는 경우에도 header가 중복되지 않게 한다. nested AppShell에서는 가장 가까운 <AppShellMain>의 slot을 사용하며 shell 사이에 content가 누출되지 않는다.

서버 렌더에서는 AppShellMain 안의 page header와 slot을 모두 생략한다. client의 첫 render도 동일한 markup을 사용하고 effect 이후에만 slot을 등록하므로 document 접근이나 hydration mismatch가 없다. 따라서 suppressHydrationWarning 같은 우회 설정은 필요하지 않다.

Electron / Tauri drag contract

page-header slot은 main header의 drag surface를 보존하기 위해 slot 전체를 no-drag로 바꾸지 않는다. 대신 slot 아래의 button, a, input, select, textarea에만 -webkit-app-region: no-drag를 적용한다. Electron에서는 page-level tabs/actions의 첫 클릭을 유지하고, 빈 header 영역은 계속 window drag에 사용할 수 있다. Tauri용 data-tauri-drag-region marker도 기존처럼 main header에 남으며 browser/docs에서는 두 규칙 모두 무해하다.

상태 모델 (Radix 식)

open / width 둘 다 controlled / uncontrolled 양쪽 지원.

{/* uncontrolled — 편하게 */}
<AppShellSidebar defaultOpen defaultWidth={220} />

{/* controlled — 단축키, URL 동기화, 다른 패널과 연동 등 */}
<AppShellSidebar
  open={sidebarOpen}
  onOpenChange={setSidebarOpen}
  width={sidebarWidth}
  onWidthChange={setSidebarWidth}
/>
  • defaultOpen / open / onOpenChange
  • defaultWidth / width / onWidthChange
  • minWidth / maxWidth — 드래그 한계
  • resizable (default true) — false 면 분할자가 비활성

닫힘 동작

open={false} 시 패널은 width 0 으로 줄어들지만 DOM 은 살아있고 내부 상태도 보존된다. 스크롤 위치, 입력값, 펼친 트리 등이 다시 열렸을 때 그대로 복귀.

Floating controls (overlay)

사이드바를 접어도 같은 좌표에 있어야 하는 토글 버튼처럼, 어떤 패널에도 속하지 않는 floating 컨트롤을 위한 슬롯.

  • <AppShellLeadingControls> — 좌상단. leftInset 만큼 들여서 배치 (트래픽 라이트 회피).
  • <AppShellTrailingControls> — 우상단. rightInset 만큼 들여서 배치.

컨테이너 자체는 pointer-events: none 이라 패널 콘텐츠 클릭을 막지 않는다 — 내부 인터랙티브 자식만 클릭 가능.

<AppShell leftInset={72}>
  <AppShellLeadingControls>
    <IconButton aria-label="Toggle sidebar" onClick={toggleSidebar}>
      <SidebarSimple />
    </IconButton>
  </AppShellLeadingControls>

  <AppShellSidebar open={sidebarOpen}>...</AppShellSidebar>
  ...
</AppShell>

주의: leftInset 값은 트래픽 라이트 + leading controls 영역 전체를 덮을 만큼 충분히 크게 설정. 예: macOS 트래픽 라이트(~80px) + 토글 버튼(~32px) = leftInset={120} 정도.

Splitter

  • target="sidebar" | "sidePanel" — 어느 패널의 width 를 조절할지 명시.
  • 위치는 사용자가 결정 (해당 패널 바로 옆 에 두는 것이 자연스러움).
  • 대상 패널이 resizable={false} 거나 닫혀있으면 자동 비활성.
  • doubleClickResetWidth 옵션 — 더블클릭 시 해당 폭으로 리셋.

Tauri 통합 (참고)

CDS 는 Tauri 와 직접 결합하지 않는다. 제품 레포에서 다음만 챙기면 된다.

  1. Tauri config — macOS 는 titleBarStyle: "Overlay" 권장.
  2. leftInset — macOS 라면 ~130, Windows/Linux 라면 0 (또는 별도값) 을 <AppShell> 에 주입.
  3. 드래그 영역AppShell*Headerdata-tauri-drag-region 이 자동으로 박힌다. AppShellPageHeader slot 아래의 표준 interactive element에는 Electron용 no-drag도 자동 적용된다.
  4. 풀스크린 인식 — 제품에서 풀스크린 상태면 leftInset={0} 으로 동적 변경.

Props

<AppShell>

Prop타입기본설명
titlebarHeightnumber44titlebar 영역 높이(px). CSS 변수 --cds-app-shell-titlebar-height 로도 노출.
leftInsetnumber0좌측 패널 헤더가 비워줘야 할 가로 inset(px). macOS 트래픽 라이트 자리 회피.
rightInsetnumber0우측 패널 헤더가 비워줘야 할 가로 inset(px).

<AppShellSidebar> / <AppShellSidePanel>

Prop타입기본설명
defaultOpenbooleantrue초기 열림 상태 (uncontrolled).
openboolean현재 열림 상태 (controlled).
onOpenChange(open: boolean) => void열림 상태 변경 콜백.
defaultWidthnumberSidebar 240, SidePanel 360초기 폭(px).
widthnumber현재 폭(controlled).
onWidthChange(w: number) => void폭 변경 콜백 (드래그 시 호출).
minWidthnumberSidebar 200, SidePanel 280드래그 최소 폭.
maxWidthnumberSidebar 320, SidePanel 560드래그 최대 폭.
resizablebooleantruefalse 면 Splitter 비활성.

<AppShellSplitter>

Prop타입기본설명
target"sidebar" | "sidePanel"어느 패널의 width 를 조절할지. 필수.
doubleClickResetWidthnumber더블클릭 시 width 를 이 값으로 리셋. 미지정 시 더블클릭 비활성.

<AppShell*Header>

Prop타입기본설명
tauriDragRegionbooleantruedata-tauri-drag-region 을 박을지. 자식 인터랙티브 요소와 충돌하면 끄기.

<AppShellPageHeader>

Prop타입기본설명
childrenReactNode가장 가까운 AppShell main header slot으로 portal할 page-level controls. AppShellMain 밖에서는 inline 렌더링.

디자인 의도

  • 컴포넌트 종류 자체에 layer (back/front) 와 position (leading/center/trailing) 의 의미가 박혀있다 — <AppShellSidebar> 는 항상 back layer 이고, <AppShellMain> 은 항상 foreground card 의 시작점이다.
  • 사용자는 prop 으로 layer 를 지정하지 않는다. 대신 의도가 컴포넌트 이름에서 자명해야 한다.
  • 새로운 패널 (예: 좌측 보조 패널, 우측 두 번째 패널) 이 필요해진다면 컴포넌트 분리 로 의미를 박는다 — layer 같은 일반 prop 으로 우회하지 않는다.