# sellmate-design-system-react — 사용 규칙 (AGENTS.md)

> **대상**: 이 패키지로 화면을 만드는 소비 앱의 개발자와 AI 코딩 에이전트(Claude 등).
> 이 문서는 "무엇을 언제 쓰고, 무엇을 쓰면 안 되는지"의 단일 기준이다.
> 개별 컴포넌트의 상세 Props/Events는 `node_modules/sellmate-design-system-react/dist/components/<이름>/README.md` 를 참조한다.

## 0. 최우선 원칙 — 디자인 시스템 컴포넌트가 먼저다

**화면 요소를 만들기 전에, 그 역할을 하는 컴포넌트가 이미 있는지 먼저 확인한다.**
있으면 반드시 그것을 쓴다. 직접 만드는 것은 대응 컴포넌트가 **없다는 것을 확인한 뒤**의 최후 수단이다.

```tsx
❌ <button onClick={save}>저장</button>        ✅ <SButton label="저장" onClick={save} />
❌ <table>…</table>                            ✅ <STable columns={columns} rows={rows} />
❌ <div className="rounded border p-sd-16">…</div> ✅ <SSectionHeaderCard>…</SSectionHeaderCard>
❌ <ul><li>…</li></ul>                         ✅ <SList><SListItem title="…" /></SList>
```

"비슷하게 생긴 것을 직접 만드는 것"이 어색함의 가장 큰 원인이다. 대응 컴포넌트를 쓰면 색·간격·상태·접근성이 전부 따라온다.

### 0-1. 전체 컴포넌트 인덱스

무엇을 만들지 정했으면 **이 표에서 먼저 찾는다.** 상세 Props 는 `dist/components/<이름>/README.md` 참조.

**이 표에서 어느 것을 골라야 할지 모르겠으면 §3-0 "의도 → 컴포넌트 라우팅" 으로 간다.** 하려는 일을 문장으로 찾으면 답이 하나 나온다 — 여기 인덱스는 "무엇이 있는지", §3-0 은 "언제 그걸 쓰는지" 를 담당한다.

| 분류 | 컴포넌트 |
| --- | --- |
| **버튼·링크** | `SButton` `SGhostButton` `SDropdownButton` `STextLink` `SSwitch` `SToggle` |
| **입력 (폼)** | `SForm` `SField` `SInput` `SNumberInput` `STextarea` `SSelect` `SCheckbox` `SRadio` `SRadioGroup` `SRadioButton` `SChip` `SChipInput` `SBarcodeInput` `SFilePicker` |
| **날짜·시간** | `SCalendar` `SDatePicker` `SDateRangePicker` `STimePicker` `STimeRangePicker` |
| **표·목록** | `STable` `STableBar` `SKeyValueTable` `SList` `SListItem` `SExpansionList` `SDraggableList` `SDraggableItem` `STree` |
| **레이아웃** | `SLayout` `SGnb` `SPage` `SSectionHeaderCard` `SCard` `SDivider` `SSplitter` `SScrollArea` `SExpansionItem` |
| **내비게이션** | `STabs` `SPagination` `SStepper` |
| **표시·상태** | `STag` `SBadge` `SIcon` `SCallout` `SGuide` |
| **진행·로딩** | `SLinearProgress` `SCircleProgress` `SLoadingContainer` `SLoadingModal` |
| **오버레이** | `STooltip` `SPopover` `SPopup` `SDrawer` `SPortal` |
| **모달** | `SModal.confirm()` `SModal.create()` + `SActionModal` `SConfirmModal` `SModalOutlet`(앱 루트 1회) |
| **알림** | `SToast` `SToastContainer` |

표에 없는 UI 를 만들어야 할 때만 `div` 로 직접 조립하고, 그때도 §1-2 · §2 의 토큰 규칙을 지킨다.

**이 표는 패키지가 실제로 export 하는 컴포넌트와 일치해야 한다** — `npm run check:routing` 이 강제한다. 표에 없는 컴포넌트는 소비 앱 입장에서 존재하지 않는 것과 같다.

### 0-2. 프로젝트 설정

설정(Tailwind v4 `theme.css` import, `@source` 지정, Next.js 주의사항)은 패키지 [README.md](./README.md)를 따른다. 이 문서는 설정이 끝난 상태에서의 **화면 작성 규칙**만 다룬다.

---

## 1. 절대 규칙 (금지 목록)

AI 에이전트는 코드를 생성하기 전에 이 목록을 반드시 지킨다.

### 1-1. 생 HTML 컨트롤 금지

§0 원칙의 구체적 목록이다. 아래 요소는 **어떤 경우에도** 생 HTML 로 만들지 않는다.

| 금지 | 대신 사용 |
| --- | --- |
| `<button>` | `SButton`, `SGhostButton`, `SDropdownButton`, `STextLink` |
| `<input type="text/password/...">` | `SInput` |
| `<input type="number">` | `SNumberInput` |
| `<input type="checkbox">` | `SCheckbox`, `SToggle`, `SSwitch` |
| `<input type="radio">` | `SRadio`, `SRadioButton` |
| `<input type="file">` | `SFilePicker` |
| `<select>` | `SSelect` |
| `<textarea>` | `STextarea` |
| `<table>` | `STable`, `SKeyValueTable` |
| `<form>` | `SForm` |
| `<dialog>`, 직접 만든 오버레이 | `SModal.confirm(...)`, `SModal.create(...)`, `SPopup` |
| `alert()`, `confirm()` | `SToast`, `SModal.confirm(...)` |
| 직접 만든 탭/페이지네이션/스텝퍼 | `STabs`, `SPagination`, `SStepper` |
| `<ul>`/`<li>` 로 만든 목록 UI | `SList` + `SListItem` (드래그 정렬은 `SDraggableItem`) |
| 직접 만든 섹션 카드(제목 바 + 본문 박스) | `SSectionHeaderCard` + `.Header` / `.Body` |
| `<svg>` 직접 삽입, 이모지 아이콘 | `SIcon` |
| `<hr>` | `SDivider` |
| `<details>` / `<summary>` | `SExpansionItem` |
| `<progress>` | `SLinearProgress`, `SCircleProgress` |
| `<label>` (폼 레이블) | `SField` 의 `label` prop |
| 직접 만든 카드·패널 박스 | `SCard`, `SSectionHeaderCard` |
| 직접 만든 스크롤 영역 | `SScrollArea` |

예외: 순수 레이아웃 요소(`div`, `section`, 시맨틱 `h1~h6`, `p`, `span`, `a`)는 허용. 단 스타일은 아래 규칙을 따른다.

### 1-2. 임의 값(arbitrary literal) 금지

Tailwind 유틸리티는 **토큰 스케일에 있는 값만** 사용한다.

```text
❌ text-[14px]  font-[600]  bg-[#eee]  gap-[13px]  p-[10px]  rounded-[5px]  text-[#333]
✅ typo-body-sm-default  text-fg-tertiary  bg-bg-frame  gap-sd-12  p-sd-8  rounded-md
✅ w-[var(--sys-size-control-md-height)]   ← 토큰을 var()로 참조하는 것은 허용
```

- 하드코딩 hex/px/rem 리터럴은 어디에도 쓰지 않는다 (인라인 `style` 포함).
- 인라인 `style`은 런타임 계산값(동적 width 등)에만 허용한다.

**단, 디자인 토큰이 없는 속성의 임의 값은 정당하다.** 화면 고유의 레이아웃 치수가 여기 해당한다.

```tsx
✅ <div className="w-[280px] max-w-[1200px] grid-cols-[200px_1fr]" />   // 앱 고유 치수
❌ <div className="text-[13px] bg-[#eee] gap-[13px] rounded-[5px]" />   // 토큰이 있는 속성
```

규칙은 **색 · 타이포 · 간격 · 모서리** 네 계열에만 적용된다 — 이 넷은 토큰이 이미 있으므로 임의 값은 곧 디자인 시스템 우회다.

### 1-3. 타이포그래피는 프리셋으로만

`text-14 font-bold` 같은 조합을 즉흥으로 만들지 않는다. §2-1의 `typo-*` 프리셋 클래스를 쓴다.

### 1-4. 숫자는 무조건 `toLocaleString()`

**숫자를 화면에 표시할 때는 예외 없이 `toLocaleString()` 을 거쳐 세 자리마다 콤마를 넣는다.**
금액·수량·건수·재고 무엇이든, 테이블·상세·요약 문구 어디에 놓이든 같다.

```tsx
❌ <span>{price}원</span>              ❌ {`${qty}개`}         ❌ {String(count)}
✅ <span>{price.toLocaleString()}원</span>
✅ format: (v: number) => `${Number(v).toLocaleString()}개`
```

콤마 없는 `39000` 은 자릿수를 세어야 읽히지만 `39,000` 은 한눈에 읽힌다. 숫자를 그대로 출력하는 코드는 미완성으로 본다.

**번호·코드는 제외한다.** 전화번호·사업자번호·송장번호·상품코드처럼 대상을 가리키는 값은 크기를 비교하는 숫자가 아니라 **서식이 정해진 문자열**이다. 여기에 콤마를 넣으면 송장번호 `123456789` 가 `123,456,789` 로 보여 값 자체가 달라진다.

---

## 2. 조합 규칙 — 화면을 어떻게 쌓는가

§3 이 "무엇을 쓸지" 라면 여기는 **"고른 것들을 어떻게 붙일지"** 다. 층 구조(§2-0)가 먼저고, 타이포·간격·색(§2-1~2-3)은 그 층에 붙는 값이다.

### 2-0. 화면의 층 구조 — 무엇을 어디에 놓는가

**모든 화면은 다섯 층으로 쌓인다. 컴포넌트는 저마다 놓이는 층이 정해져 있고, 층이 다르면 붙이는 방법도 다르다.** 조합이 어색해지는 원인은 대개 층을 건너뛴 것이다 — 요소를 페이지에 바로 놓거나, 인라인 요소를 블록처럼 세우거나, 카드 안에 카드를 겹치는 식이다.

| 층 | 무엇인가 | 컴포넌트 |
| --- | --- | --- |
| **셸** | 앱 전체 뼈대. 페이지가 바뀌어도 남는다 | `SLayout` `SGnb` `SPage` |
| **블록** | `SPage` 의 직계 자식. 페이지를 세로로 쌓는 단위 | `SSectionHeaderCard` `SCard` `SForm` `SSplitter` `SScrollArea` `STable` `STableBar` `SKeyValueTable` `SList` `SExpansionList` `SDraggableList` `STree` `SCallout` `STabs` `SStepper` `SPagination` `SDivider` |
| **요소** | 블록 **안에** 놓이는 컨트롤. 혼자 페이지에 서지 않는다 | `SButton` `SGhostButton` `SDropdownButton` `SField` `SInput` `SNumberInput` `STextarea` `SSelect` `SCheckbox` `SRadio` `SRadioGroup` `SRadioButton` `SSwitch` `SToggle` `SChipInput` `SBarcodeInput` `SFilePicker` `SDatePicker` `SDateRangePicker` `STimePicker` `STimeRangePicker` `SCalendar` `SListItem` `SExpansionItem` `SDraggableItem` `SLinearProgress` `SCircleProgress` |
| **인라인** | 텍스트 흐름·셀·라벨 안에 섞인다. 혼자 블록이 되지 않는다 | `STag` `SBadge` `SIcon` `STextLink` `SChip` |
| **레이어** | 문서 흐름 **밖**에 떠서 그려진다. 어느 층에서 띄우든 레이아웃에 영향이 없다 | `SModal` `SActionModal` `SConfirmModal` `SPopup` `SDrawer` `SPopover` `STooltip` `SPortal` `SToast` `SLoadingModal` `SLoadingContainer` `SGuide` |

여기에 화면을 차지하지 않는 **부트스트랩** 이 따로 있다 — `SModalOutlet` `SToastContainer` 는 앱 진입점에 한 번만 렌더한다 (§4-1).

> 이 표는 §0-1 인덱스 전체를 덮는다 (`npm run check:routing` 이 강제). **컴포넌트를 골랐으면 그것이 어느 층인지 먼저 확인하고, 아래 포함 규칙에 맞는 자리에 놓는다.**

#### 블록은 두 종류다

같은 블록층이어도 **안에 다른 것을 담느냐** 로 갈린다. 이걸 구분해야 포함 규칙이 선다.

- **담는 블록** — `SSectionHeaderCard` `SCard` `SForm` `SSplitter` `SScrollArea`. 안이 비어 있고 다른 블록·요소를 받는다.
- **그리는 블록** — 나머지 전부. 자기가 내용을 그리므로 안에 무엇을 넣을지 고민할 일이 없다 (`STable` 의 셀처럼 지정된 슬롯 제외).

#### 포함 규칙 — 무엇 안에 무엇이 올 수 있나

| 담는 것 | 올 수 있는 것 | 오면 안 되는 것 |
| --- | --- | --- |
| `SPage` | **블록만** | **요소를 직접** — 버튼 하나도 블록에 담아 놓는다 |
| `SSectionHeaderCard.Body` | 그리는 블록 · 요소 | `SSectionHeaderCard` · `SCard` (카드 겹침, §3-7-8) |
| `SCard` | 그리는 블록 · 요소 | `SCard` · `SSectionHeaderCard` |
| `SForm` | 블록 (보통 `SKeyValueTable` + 하단 액션) | — |
| `SSplitter.Before` / `.After` | 블록 | — |
| `SScrollArea` | 블록 | — |
| `STable` 셀 (`render`) | 인라인 · 요소 | 블록 — 표 안에 표·카드를 넣지 않는다 |
| `SKeyValueTable` 값 셀 | 인라인 · 요소 | 블록 |
| `SListItem` | 인라인 | 블록 · 요소 |

- **블록을 `div` 로 감싸지 않는다.** 감싸면 페이지 스택에서 빠져나가 `gap-sd-12` 리듬이 끊긴다. 여러 블록을 묶어야 하면 그건 섹션이므로 `SSectionHeaderCard` 다.
- **요소를 페이지에 직접 놓지 않는다.** 하단 액션 버튼들처럼 블록이 없는 자리는 `div` 로 한 줄을 만들어 그 `div` 가 블록이 된다 (§4-3·§4-4).
- **레이어는 어디서 띄워도 된다.** `body` 로 portal 되므로 셸 안에 넣을 필요가 없고, 넣어도 레이아웃이 바뀌지 않는다 (§4-1).

#### 블록을 쌓는 방법과 순서

**`SPage` 는 자식을 자동으로 쌓지 않는다.** 페이지가 직접 세로 스택을 만든다 — 이게 모든 §4 레시피가 `flex flex-col gap-sd-12` 로 시작하는 이유다.

```tsx
<SPage background="frame">
  <div className="flex flex-col gap-sd-12">   {/* 블록 스택 — 페이지가 만든다 */}
    …블록들…
  </div>
</SPage>
```

블록 순서는 화면 종류와 무관하게 같다. **필요한 것만 남기되 순서를 바꾸지 않는다.**

```text
1. 페이지 제목        (+ 가이드·매뉴얼 링크. 액션 버튼은 오지 않는다 §4-2)
2. 상시 안내          SCallout
3. 필터               SKeyValueTable
4. 툴바               STableBar   (건수 요약 + 액션)
5. 본문               STable · 섹션 카드들 · SList …
6. 페이지네이션        SPagination (STable 이 pagination prop 으로 직접 그린다)
7. 하단 액션          되돌리기 왼쪽 · 실행 오른쪽 (§4-3)
```

간격은 층마다 다르다 — 블록 ↔ 블록은 `gap-sd-12`, 요소 ↔ 요소는 `gap-sd-8` 이 기본이고, 같은 컴포넌트를 나열할 때는 컴포넌트별 그룹 간격이 따로 있다. 전부 §2-2 에 있다.

### 2-1. 타이포그래피 프리셋

역할(role) → 크기 → 굵기 순으로 조합된 클래스가 이미 준비되어 있다.

| 용도 | 클래스 |
| --- | --- |
| 페이지/섹션 제목 | `typo-heading-lg`(18px) · `typo-heading-md`(16px) · `typo-heading-sm`(14px) · `typo-heading-xs`(12px) |
| 본문 | `typo-body-lg-*` (16px) · `typo-body-md-*`(14px) · `typo-body-sm-*`(12px) · `typo-body-xs-default`(11px) — `*` = `default`/`medium`/`bold` |
| 테이블 | `typo-table-header` · `typo-table-body` · `typo-table-accent` |
| 컨트롤·필드·피드백·내비 | `typo-control-*` `typo-field-*` `typo-feedback-*` `typo-navigation-*` (컴포넌트 내부용 — 직접 쓸 일은 드묾) |

**기본 선택 — 이 조합을 쓴다.** 이 서비스는 정보 밀도가 높아 본문이 12px 이다. 14px 를 본문 기본으로 쓰지 않는다.

**제목 위계는 §2-0 의 층을 따라간다** — 층이 한 단 내려가면 제목도 한 단 내려간다. 층을 건너뛰지 않듯 제목도 건너뛰지 않는다.

| 층 (§2-0) | 역할 | 클래스 | 크기 |
| --- | --- | --- | --- |
| 셸 | 페이지 제목 (h1) | `typo-heading-lg` | 18px |
| 블록 | 섹션 제목 | `typo-heading-sm` | 14px |
| 블록 내부 | 하위 제목 (섹션 안을 더 나눌 때) | `typo-heading-xs` | 12px |
| — | 본문 | `typo-body-sm-default` | 12px |
| — | 보조 설명 | `typo-body-sm-default` + `text-fg-tertiary` | 12px / `grey_65` |

페이지 제목만 18px 로 크게 두고 그 아래는 14 / 12 로 촘촘하게 간다. 중간 크기(16px)는 기본 골격에서 쓰지 않는다.

- **섹션 제목의 타이포를 직접 주지 않는다.** `SSectionHeaderCard.Header` 가 `title` 에 이미 넣는다 — 그 위에 `typo-heading-sm` 을 또 씌우지 않는다. 직접 쓰는 경우는 섹션 카드 없이 제목만 세울 때뿐이다.
- **하위 제목이 필요하면 먼저 섹션을 나눌 수 없는지 본다.** 한 섹션 안에서 제목이 두 단으로 갈린다는 것은 대개 섹션이 둘이라는 뜻이다 (§3-7-8).
- 본문 안에서 한 단어를 강조할 때는 `typo-body-sm-medium` 을 쓴다. `typo-body-sm-bold` 는 제목 성격의 짧은 라벨에만 쓴다. <!-- TODO(디자인): 강조 굵기 기준 확정 -->

**보조 설명의 색** — 기본은 `text-fg-tertiary`(`grey_65`) 다. 보조 설명 안에서 위계가 한 단계 더 필요할 때만 `text-fg-secondary`(`grey_80`) → `text-fg-tertiary`(`grey_65`) 순으로 내려 쓴다 (§2-3).

### 2-2. 간격 (spacing)

- **간격 유틸리티는 `sd-` 접두를 붙인다** — `gap-sd-8`, `p-sd-16`, `mt-sd-12`. 숫자 = px 다.
  접두를 빼면 Tailwind 기본 스케일이 적용된다(`gap-8` = 32px). 접두는 속성 뒤, 숫자 앞에 온다.
- 스케일: `2 4 6 8 10 12 16 19 20 22 24 28 32 36 40 44 48 56 60 62 80`
- **스케일에 없는 간격이 필요하면 임의 값(`p-[64px]`)으로 우회하지 않는다.** 가까운 스케일 값으로 맞추거나, 디자인상 그 값이어야 하면 **토큰 추가를 요청한다.** 이미 있는 토큰을 정확히 써야 할 때만 `gap-[var(--토큰명)]` 으로 참조한다.
- 형제 요소 간격은 margin 대신 부모의 `flex`/`grid` + `gap-sd-*`으로 잡는다.
- 시맨틱 간격 토큰 (텍스트 덩어리·요소 사이 기본 리듬):

**여백(padding)과 간격(gap)은 서로 다른 축이고, 규칙도 다르다.** 어느 쪽이든 값은 §2-0 의 층이 정한다.

```text
셸 (SPage)        여백을 SPage 가 넣는다. 직접 주지 않는다
└ 블록            블록 ↔ 블록 간격은 gap-sd-12
  └ 담는 블록     "안에 콘텐츠를 담는" 블록 (SSectionHeaderCard·SCard 등, §2-0)
                  여기에만 안쪽 여백 선택지가 있다 (아래 "섹션·패널 안쪽 여백")
    └ 요소        요소 ↔ 요소 간격은 gap-sd-8 이 기본
```

| 상황 | 값 |
| --- | --- |
| **페이지 콘텐츠 패딩** | **`SPage` 가 `--cmp-pageBody-padding-default` 로 이미 넣는다.** 직접 주지 않는다 (덮어쓰면 토큰이 바뀌어도 안 따라간다). `SPage` 밖에서 같은 패딩이 필요하면 `p-sd-16` |
| **섹션 ↔ 섹션**, **블록 ↔ 블록** (헤더·필터·툴바·테이블 사이) | **`gap-sd-12`** |
| 요소 ↔ 요소 | **기본 `gap-sd-8`** (`--sys-space-stack-gap-element-normal`) · 타이트 `gap-sd-4`(`-tight`) · 여유 `gap-sd-12`(`-relaxed`) / `gap-sd-16`(`-wide`) |
| 제목 ↔ 설명 텍스트 | **수직 배치 `gap-sd-4`**(`--sys-space-stack-gap-text-normal`, 타이트 `gap-sd-2`) · **가로 배치 `gap-sd-8`**(`-relaxed`) |

정보 밀도가 높은 서비스라 블록 **간격**을 넓게 벌리지 않는다. `gap-sd-16` / `gap-sd-24` 를 페이지 골격의 기본값으로 쓰지 않는다. (아래 나오는 `24` 는 **안쪽 여백**에만 열리는 값이고, 간격은 여기 표대로 12 다.)

#### 같은 컴포넌트를 여러 개 늘어놓을 때 (그룹 간격)

위 "요소 ↔ 요소 `gap-sd-8`" 은 **서로 다른 요소** 사이의 기본값이다.
**같은 컴포넌트를 여러 개 나열할 때는 컴포넌트마다 정해진 그룹 간격**이 따로 있다.

| 컴포넌트 | 수평 배열 | 수직 배열 |
| --- | --- | --- |
| `SCheckbox` | **`gap-sd-24`** | `gap-sd-8` |
| `SRadio` | **`gap-sd-24`** | `gap-sd-8` |
| `STextLink` | **`gap-sd-16`**(sm) / **`gap-sd-24`**(md·lg) | `gap-sd-4` |
| `SGhostButton` | `gap-sd-4` | `gap-sd-4` |
| `SButton` | `gap-sd-8` (xs·sm·md) / **`gap-sd-12`**(lg) | 〃 |
| `STag` | `gap-sd-8` | `gap-sd-8` |
| `SToggle` | `gap-sd-8` | `gap-sd-8` |
| `SListItem` (bordered) | — | `gap-sd-8` (+ 컨테이너 `p-sd-16`) |

**수평·수직이 다른 것에 주의한다** — 체크박스·라디오는 가로로 놓으면 `gap-sd-24`, 세로로 놓으면 `gap-sd-8` 로 3배 차이다. 가로 배열에 `gap-sd-8` 을 쓰면 항목이 붙어 보인다.

```tsx
✅ <div className="flex gap-sd-24">      {/* 체크박스 가로 */}
     <SCheckbox label="전체" … /><SCheckbox label="판매중" … />
   </div>
✅ <div className="flex flex-col gap-sd-8">  {/* 체크박스 세로 */}
     <SCheckbox label="전체" … /><SCheckbox label="판매중" … />
   </div>
❌ <div className="flex gap-sd-8">        {/* 가로인데 8 — 붙어 보인다 */}
```

- **라디오는 `SRadioGroup` 을 쓴다.** `direction="horizontal" | "vertical"` 만 주면 간격을 알아서 맞춘다 — 직접 `flex` 로 감싸지 않는다.
- `SRadioButton` 그룹의 간격은 `-1px`(테두리 겹침 처리)이라 손으로 만들지 않는다.
- 정확한 값이 필요하면 토큰을 직접 참조해도 된다: `gap-[var(--cmp-checkbox-group-gap-horizontal)]`

#### 섹션·패널 안쪽 여백

**페이지 프레임은 예외 없이 `SPage` 가 넣는다.** 아래 규칙은 그 안의 **섹션·패널 레벨에만** 적용된다.

**컴포넌트가 자체 여백을 가지면 컴포넌트 기준이 우선한다.** `SKeyValueTable`·`STable` 처럼 자기 여백을 토큰으로 갖고 있는 컴포넌트에는 이 판정을 적용하지 않는다 — 손댈 것이 없다. 아래 판정이 필요한 자리는 **직접 만든 컨테이너**와 **`SSectionHeaderCard.Body`** 두 곳뿐이다.

판정은 **그 영역이 담고 있는 콘텐츠 덩어리의 종류 수**로 한다.

```text
자체 면(배경 또는 테두리)을 가진 덩어리만 센다.
제목·설명 같은 맨 텍스트와 검색·필터·버튼 같은 조작은 세지 않는다.

  두 종류 반복  →  p-sd-16   (그중 일부가 내부적으로 반복되더라도)
  세 종류 이상  →  p-sd-24

판단이 서지 않으면 16 으로 둔다.
```

| 안에 들어가는 것 | 안쪽 여백 |
| --- | --- |
| **같은 요소의 규칙적 반복** — 테이블 행, `SKeyValueTable` 행, 아코디언 목록, 카드 목록 | **`p-sd-16`** |
| **서로 다른 요소의 복합 구성** — 말풍선 + 버튼 묶음 + 시스템 안내 / 콜아웃 + 토글 카드 + 설명 | **`p-sd-24`** |

**카드라서 24 가 아니다.** 같은 카드가 규칙적으로 반복되면 16 이다. **섹션 내부에 어떤 정보가 들어가는지도 무관하다** — 섹션이 동일하게 반복되면 안에 버튼·입력·태그가 섞여 있어도 16 이다.

적용은 **화면 단위가 아니라 영역 단위**다. 한 화면 안에서도 칸마다 다르다.

| 영역 (상담 콘솔 예) | 안쪽 여백 | 이유 |
| --- | --- | --- |
| 좌측 상담 목록 | `p-sd-16` | 같은 항목 반복 |
| 중앙 대화 | `p-sd-24` | 말풍선·버튼 묶음·시스템 메시지 혼재 |
| 우측 템플릿 목록 | `p-sd-16` | 같은 아코디언 반복 |

§4 의 표준 골격(목록·폼·상세)은 **전부 16** 이다. 24 는 반복 구조가 없고 성격이 다른 덩어리가 쌓이는 영역에만 쓴다.

**위자드·탭처럼 한 프레임을 공유하는 화면은 가장 복합적인 화면을 기준으로 통일한다.** 예를 들어 어떤 단계가 콜아웃 + 토글 카드로 2종류라 단독으로는 16 이지만, 3종류인 단계와 한 위자드를 공유하므로 통일 규칙에 따라 위자드 전체가 24 가 된다.

**중첩되면 안쪽 여백을 주지 않는다.** 24 영역 안에 또 여백을 주면 가장자리가 40 으로 벌어져 한 면적처럼 읽힌다. 안쪽 카드·목록이 **배경색이 다르거나 테두리가 있어** 경계가 스스로 보이는 경우에만 자기 여백을 유지한다.

`SSectionHeaderCard.Body` 는 이 규칙을 **prop 으로 받는다** — 직접 `p-sd-*` 를 주지 않는다.

```tsx
<SSectionHeaderCard.Body>…</SSectionHeaderCard.Body>                    {/* 기본 = 16 */}
<SSectionHeaderCard.Body padding="wide">…</SSectionHeaderCard.Body>     {/* 3종류 이상 */}
<SSectionHeaderCard.Body padding="none">…</SSectionHeaderCard.Body>     {/* 표를 가장자리까지 */}
```

#### 스크롤 영역의 하단 여백

스크롤을 끝까지 내렸을 때 마지막 항목이 화면 경계에 붙으면 **목록이 끝난 것인지 더 있는 것인지** 읽히지 않는다. 그래서 스크롤 영역은 **하단만** 넓게 둔다. 나머지 세 방향은 위 16 / 24 규칙 그대로다.

| 스크롤 종류 | 어떻게 |
| --- | --- |
| **패널 자체 스크롤** (좌측 목록, 중앙 대화 등) | 그 패널 안쪽 하단에 `pb-[var(--cmp-pageBody-padding-scrollEnd)]` — `SPage` 와 같은 토큰이라 값이 바뀌어도 함께 따라간다 |
| **페이지 단위 스크롤** | **`SPage` 가 넣는다. 직접 주지 않는다** |

`SPage` 는 기본으로 넣으므로 **아무것도 하지 않으면 맞다.** 끄는 경우는 하나뿐이다 — **페이지네이션이 붙은 테이블.** 페이지네이션이 이미 "여기서 끝"을 알려주므로 `scrollEndSpacing={false}` 로 끈다 (§4-2 목록 페이지).

### 2-3. 색상

**시맨틱 유틸리티를 우선 사용한다** — 의미가 이름에 담긴 토큰이 이미 유틸리티로 존재한다.

#### 텍스트 회색 위계는 세 단계다

| 단계 | 유틸리티 | 값 | 용도 |
| --- | --- | --- | --- |
| **기본** | 지정하지 않는다 — 전역 기본색이 이미 적용된다 (`text-fg-primary`) | `grey_95` `#222222` | 본문·제목 |
| **보조 1** | **`text-fg-secondary`** | `grey_80` `#555555` | 보조 정보 |
| **보조 2 / 비활성** | **`text-fg-tertiary`** | `grey_65` `#888888` | 부가 설명, 비활성 |

- **위계는 순차 적용한다.** 기본 → 보조1 → 보조2 순으로 내려가며 중간 단계를 건너뛰지 않는다. 본문 바로 아래에 곧장 `text-fg-tertiary` 를 쓰지 않는다.
- `theme.css` 가 `body` 에 `grey_95` 를 깔아두므로 **본문에 텍스트 색 클래스를 붙이지 않는다.** `text-fg-primary` 를 매번 쓰는 것은 불필요하다.
- 위계가 한 단계면 충분한 보조 설명은 `text-fg-tertiary` 하나로 끝낸다 (§2-1). 두 단계가 필요할 때만 `text-fg-secondary` 를 끼워 넣는다.
- 비활성 표시는 단계와 무관하게 `text-fg-tertiary` 다.

```tsx
✅ <p>주문이 접수되었습니다.</p>                          // 색 지정 없음 = grey_95
✅ <p className="text-fg-tertiary">최근 30일 기준</p>      // 보조 설명 한 단계
✅ <>                                                     // 보조 설명 두 단계
     <p className="text-fg-secondary">배송비 정책</p>
     <p className="text-fg-tertiary">3만원 이상 무료</p>
   </>
✅ <span className="text-fg-tertiary">-</span>            // 빈 값 (§3-4)
❌ <p className="text-fg-primary">주문이 접수되었습니다.</p>  // 불필요
```

#### 그 밖의 색

| 용도 | 유틸리티 |
| --- | --- |
| 상태 텍스트 | `text-fg-danger` `text-fg-success` `text-fg-warning` `text-fg-accent` `text-fg-inverse` |
| 배경 | `bg-bg-frame`(흰 콘텐츠 면) · `bg-bg-neutralLight`/`bg-bg-neutralBright`(옅은 회색 면) · `bg-bg-screen`(앱 바탕) |
| 보더/구분선 | `border-border-default` · `border-border-strong` · `border-divider-default` |
| 비활성 배경·보더 | `bg-disabled-bg` `border-disabled-border` |
| 링크 | `text-link-accent` |

- 시맨틱 토큰에 맞는 항목이 없을 때만 원색 스케일(`bg-blue-subtle` 등)을 쓴다.
- 어느 쪽이든 **토큰 유틸리티만** 사용 — 리터럴 hex 금지.

---

## 3. 컴포넌트 선택 규칙 — "언제 뭘 쓰나"

> ⚠️ 초안(개발 작성). <!-- TODO(디자인): 전체 검수·확정 --> 표시가 있는 행은 디자인 확정 전까지 초안 기준으로 사용.

### 3-0. 의도 → 컴포넌트 라우팅 (여기서 시작한다)

**§3 의 입구는 이 표다.** 아래 §3-1 부터는 계열별로 정리돼 있어서 "내가 만들 게 어느 계열인지"를 이미 알아야 펼 수 있다. 그런데 실제 출발점은 계열이 아니라 **하려는 일**이다. 그 문장을 여기서 찾으면 답이 하나 나온다.

- **읽는 법**: 왼쪽에서 하려는 일을 찾고 → 가운데 컴포넌트를 쓴다. 오른쪽에 § 참조가 있으면 그 자리는 답이 갈리므로 **반드시 그 절을 읽고 고른다.** 참조가 없으면 더 볼 것 없이 그대로 쓴다.
- 이 표는 §0-1 인덱스 전체를 덮는다 (`npm run check:routing` 이 강제). **여기에 해당하는 일이 없으면 대응 컴포넌트가 없는 것이므로** §1-1 의 예외 규칙(순수 레이아웃 요소)으로 간다.

#### A. 값을 입력받는다

| 하려는 일 | 컴포넌트 | 갈림 |
| --- | --- | --- |
| 한 줄 텍스트를 받는다 | `SInput` | §3-7-1 |
| 여러 줄 텍스트를 받는다 | `STextarea` | §3-7-1 |
| 숫자(수량·금액)를 받는다 | `SNumberInput` | |
| 바코드를 스캔해 받는다 | `SBarcodeInput` | |
| 목록에서 하나 고르게 한다 | `SSelect` | §3-7-2 |
| 선택지를 항상 펼쳐 두고 하나 고르게 한다 | `SRadioGroup` | §3-7-2 |
| 버튼 모양으로 모드를 하나 고르게 한다 | `SRadioButton` | §3-7-2 |
| 라디오 하나를 표 셀 등에 직접 배치한다 | `SRadio` | §3-7-2 |
| 여러 개를 고르게 한다 / 동의를 받는다 | `SCheckbox` | §3-7-3 |
| 켜는 즉시 반영되는 설정을 준다 | `SSwitch` | §3-7-3 |
| 목록을 좁히는 필터를 켜고 끄게 한다 | `SToggle` | §3-7-3 |
| 자유 입력값을 여러 개 쌓게 한다 | `SChipInput` | §3-1 |
| 입력된 값 하나를 지우거나 고치게 한다 | `SChip` | §3-1 |
| 파일을 받는다 | `SFilePicker` | |
| 날짜 하나를 받는다 | `SDatePicker` | §3-7-4 |
| 날짜 기간을 받는다 | `SDateRangePicker` | §3-7-4 |
| 시각 하나를 받는다 | `STimePicker` | |
| 시각 범위를 받는다 | `STimeRangePicker` | |
| 달력 자체를 화면에 펼쳐 보여준다 | `SCalendar` | §3-7-4 |
| 컨트롤에 라벨·필수·에러를 붙인다 | `SField` | §3-7-5 |
| 입력 여러 개를 묶어 한 번에 검증한다 | `SForm` | §4-3 |
| 폼·필터를 표 형태로 배치한다 | `SKeyValueTable` | §4 |

#### B. 정보를 읽게 보여준다

| 하려는 일 | 컴포넌트 | 갈림 |
| --- | --- | --- |
| 여러 건을 여러 열로 보여주고 열끼리 비교하게 한다 | `STable` | §3-7-6 |
| 항목 하나의 속성들을 `라벨: 값` 으로 보여준다 | `SKeyValueTable` | §4-4 |
| 한 줄로 읽히는 항목을 세로로 나열한다 | `SList` + `SListItem` | §3-7-6 |
| 나열한 항목을 펼쳐 하위 내용을 보여준다 | `SExpansionList` + `SExpansionItem` | §3-7-7 |
| 부모-자식 계층을 들여쓰기로 보여준다 | `STree` | §3-7-7 |
| 사용자가 순서를 드래그로 바꾸게 한다 | `SDraggableList` + `SDraggableItem` | §3-7-6 |
| 표 위에 건수 요약과 액션을 얹는다 | `STableBar` | §4-2 |
| 상태·분류를 라벨로 찍는다 | `STag` | §3-1 |
| 색 점만으로 상태를 찍는다 | `SBadge` | §3-1 |
| 아이콘을 넣는다 | `SIcon` | |
| 문장 안에서 다른 화면으로 보낸다 | `STextLink` | §3-5-6 |

#### C. 동작을 실행시킨다

| 하려는 일 | 컴포넌트 | 갈림 |
| --- | --- | --- |
| 라벨이 있는 일반 액션을 준다 | `SButton` | §3-5 |
| 아이콘 하나로 뜻이 통하는 부가 조작을 준다 | `SGhostButton` | §3-5-5 |
| 한 버튼에 여러 선택지를 매단다 | `SDropdownButton` | §3-5-4 |

#### D. 화면을 담고 나눈다

| 하려는 일 | 컴포넌트 | 갈림 |
| --- | --- | --- |
| 앱 셸(상단바 + 내비 + 본문)을 세운다 | `SLayout` | §4-1 |
| 좌측 내비게이션을 만든다 | `SGnb` | §4-1 |
| 페이지 본문을 담는다 (패딩·스크롤) | `SPage` | §4-1 |
| 제목 있는 섹션으로 묶는다 | `SSectionHeaderCard` | §3-7-8 |
| 제목 없이 흰 면으로만 묶는다 | `SCard` | §3-7-8 |
| 가로선으로 끊는다 | `SDivider` | §3-6 |
| 사용자가 영역 크기를 조절하게 한다 | `SSplitter` | §3-6 |
| 특정 영역 안에서만 스크롤시킨다 | `SScrollArea` | |

#### E. 다른 곳으로 이동시킨다

| 하려는 일 | 컴포넌트 | 갈림 |
| --- | --- | --- |
| 같은 화면에서 보는 관점을 바꾼다 | `STabs` | §3-7-2 |
| 긴 목록을 페이지로 끊는다 | `SPagination` | §4-2 |
| 여러 단계의 진행 위치를 보여준다 | `SStepper` | |

#### F. 흐름을 끊고 띄운다

> 이 그룹은 **전부 §3-3-1 판별 순서를 먼저 밟는다.** 아래는 그 결과를 되짚는 표다.

| 하려는 일 | 컴포넌트 | 갈림 |
| --- | --- | --- |
| 실행 여부만 확정받는다 | `SModal.confirm()` | §3-3-1 |
| 모달 안에서 작성·선택하게 한다 | `SActionModal` + `SModal.create()` | §3-3-1 |
| 띄우는 것 자체가 하나의 화면이다 | `SPopup` | §3-3-1 |
| 화면 옆에서 밀려 나오는 작업 패널을 연다 | `SDrawer` | §3-3-5 |
| 확인 다이얼로그를 화면에 직접 배치한다 | `SConfirmModal` | §3-3-4 |
| 클릭하면 상호작용 가능한 작은 콘텐츠를 띄운다 | `SPopover` | §3-3 |
| hover 하면 짧은 설명을 띄운다 | `STooltip` | §3-3 |
| 임의 요소에 붙는 저수준 레이어가 필요하다 | `SPortal` | §3-7-10 |

#### G. 알리고 안내한다

| 하려는 일 | 컴포넌트 | 갈림 |
| --- | --- | --- |
| 화면에 상시 노출되는 안내·경고를 둔다 | `SCallout` | §3-2 |
| 작업 결과를 일시적으로 알린다 | `SToast` | §3-2 |
| 기능 온보딩·도움말을 붙인다 | `SGuide` | §3-2 |

#### H. 기다리게 한다

| 하려는 일 | 컴포넌트 | 갈림 |
| --- | --- | --- |
| 화면 전체를 잠그고 기다리게 한다 | `SLoadingModal` (또는 `SModal.loading()`) | §3-2 |
| 특정 영역만 덮고 기다리게 한다 | `SLoadingContainer` | §3-2 |
| 진행률을 가로 막대로 보여준다 | `SLinearProgress` | §3-7-9 |
| 진행률·대기를 원형으로 보여준다 | `SCircleProgress` | §3-7-9 |

#### I. 앱을 켤 때 한 번만 (§4-1)

| 하려는 일 | 컴포넌트 | 갈림 |
| --- | --- | --- |
| `SModal.*` 로 띄운 모달이 그려질 자리를 만든다 | `SModalOutlet` | §4-1 |
| `SToast` 가 그려질 자리를 만든다 | `SToastContainer` | §4-1 |

### 3-1. 라벨/표시류 — STag vs SBadge vs SChip

| 상황 | 사용 |
| --- | --- |
| **상태·분류를 라벨로 표시 (기본값)** | **`STag`** — `<STag size="sm" color="..." label="판매중" />`. 목록의 상태 컬럼, 상세의 분류 태그 등 대부분이 여기 해당한다 |
| 라벨 없이 **색 점만**으로 상태를 찍을 때 | `SBadge` — 점(dot)만 그리는 인디케이터. 텍스트가 이미 있고 앞에 점만 붙이는 좁은 경우에만 |
| 사용자가 입력·삭제·편집하는 **토큰** | `SChip` (단독) / `SChipInput` (입력 필드 안에서) |

> 상태 표시는 **`STag size="sm"` 이 기본**이다. 색 점 + 텍스트 조합(`SBadge`)을 기본으로 쓰지 않는다.

### 3-2. 알림/안내류

| 상황 | 사용 |
| --- | --- |
| 화면에 **상시 노출**되는 안내·경고 문구 | `SCallout` — `type` + `message` 배열(중첩 = 들여쓰기) |
| 작업 결과를 **일시적으로** 알림 | `SToast` (+ 루트에 `SToastContainer`) |
| 진행 전 **확인/취소**를 받아야 할 때 | `SModal.confirm({...}).onOk(...)` — 무엇을 할지가 모달 밖에서 이미 정해진 경우다 (§3-3-2) |
| 특정 UI 요소에 대한 **온보딩·기능 안내** | `SGuide` |
| 로딩 중 화면 잠금 | `SLoadingModal` / 영역 로딩은 `SLoadingContainer` |

### 3-3. 플로팅/오버레이류

| 상황 | 사용 |
| --- | --- |
| hover 시 **짧은 보조 설명** (상호작용 없음) | `STooltip` |
| 클릭 시 **상호작용 가능한** 작은 콘텐츠 (메뉴·미니 폼) | `SPopover` |
| 로딩 중 화면 잠금 | `SModal.loading(...)` / `SLoadingModal` |
| 그 밖에 **흐름을 끊고 띄우는 창** | `SPopup` · `SActionModal` · `SModal.confirm` — 아래 3-3-1 판별 순서로 고른다 |

#### 3-3-1. SPopup vs SActionModal vs SModal.confirm — 판별 순서

셋은 생김새가 아니라 **무엇인가**가 다르다.

| | 무엇인가 | 크기 |
| --- | --- | --- |
| **SPopup** | **별도 브라우저 창** (`window.open` 으로 여는 전용 라우트) | 창 크기 = 콘텐츠 크기 |
| **SActionModal** | 같은 창 위 오버레이 카드 | `width` / `height` prop |
| **SModal.confirm** (`SConfirmModal`) | 같은 창 위 확인창 | 고정 |

**성격이 먼저 둘로 갈린다.**

| 성격 | 정의 | 컴포넌트 |
| --- | --- | --- |
| **관문** | 기능(트리거)을 실행하려면 **반드시 거쳐야** 하는 것 | `SModal.confirm` · `SActionModal` |
| **화면** | 그 자체가 목적. 보거나 설정하러 들어감 | `SPopup` |

```text
① 기능을 실행하기 위한 관문인가?
   ├ 아니오 (그 자체로 보거나 설정하는 화면) ──────────► SPopup
   └ 예
      ② 무엇을 할지가 모달 밖에서 이미 정해졌는가?
         ├ 예 — 실행 여부만 확정하면 됨 ──────────────► SModal.confirm
         │        (실행 옵션이 필요하면 단순 컨트롤 2개까지 optionSlot 에)
         └ 아니오 — 작업 내용을 모달 안에서 작성·구성해야 함 ► SActionModal
```

**보조 규칙 — 데이터 양.** 보여줄 데이터가 많아 모달 크기로는 한정적이면 가능한 한 `SPopup` 으로 제공한다. 상세 화면이 팝업이 되기도 하고 페이지가 되기도 하는 이유다.

**수치 기준(컬럼 N개 등)은 두지 않는다.** 화면마다 달라 숫자로 자르면 오히려 어긋난다. 아래 확정 사례와 대조해 판단하고, **애매하면 디자인팀에 반드시 확인한다.**

| 확정 사례 | 판정 | 담고 있는 것 |
| --- | --- | --- |
| `브랜드 관리` | **모달** (680×520) | 3컬럼 × 소수 행 테이블 + 검색 + 신규 추가 |
| `엑셀 파일 관리` | **팝업** | 안내 + 검색 필터 + 3컬럼 테이블 + 페이지네이션 |
| `이동 오더 상세` | **팝업** | 헤더 카드 + 탭 2개 + 13컬럼 상세 테이블 |

#### 3-3-2. confirm ↔ actionModal 경계

둘 다 관문이고 **둘 다 결과적으로 화면의 데이터를 바꾼다** (템플릿 삭제도 목록에서 행이 사라진다). 그래서 "화면 정보를 바꾸는가"로는 갈리지 않는다.

**갈리는 지점은 하나 — 무엇을 할지가 어디서 정해지는가.**

| | 작업 내용 | 컨트롤 |
| --- | --- | --- |
| **`SModal.confirm`** | 모달 **밖에서 이미 정해짐**. 모달은 실행 여부만 확정 | 0~2개 (단순 컨트롤: 토글·input·select·checkbox) → `optionSlot` |
| **`SActionModal`** | 모달 **안에서 작성·구성** | 3개 이상 또는 폼 구조 |

컨트롤 개수는 판단 기준이 아니라 **결과**다 — 내용을 모달에서 만들어야 하니 많아지는 것이므로, 헷갈릴 때 확인용으로만 쓴다.

| 상황 | 판정 | 무엇을 할지가 정해진 곳 |
| --- | --- | --- |
| `템플릿 삭제` | `confirm` | 삭제 대상은 행에서 이미 결정 |
| `이미 등록된 계약` | `confirm` | 등록 내용은 입력 완료. 중복 여부만 확인 |
| "출력하시겠습니까?" + 범위 선택 1개 | `confirm` | 출력 대상은 정해짐. 범위는 실행 옵션 → `optionSlot` |
| `출고처 등록` | `SActionModal` | 무엇을 등록할지를 모달 안에서 작성 |
| `파일로 처리` | `SActionModal` | 어떤 파일을 어떻게 처리할지 모달 안에서 |
| `브랜드 관리` | `SActionModal` | 어떤 브랜드를 추가할지 모달 안에서 |

#### 3-3-3. SPopup — 창 하나가 통째로

- 작은 창이 아니라 **화면 하나가 통째로** 들어간다 — 검색 필터·테이블·페이지네이션이 그대로 있는 목록(`엑셀 파일 관리`), 헤더 카드·탭이 있는 상세(`이동 오더 상세`).
- 상세를 팝업으로 여는 이유는 **목록을 떠나지 않고 여러 건을 번갈아 보기** 위해서다. 단 상세가 **항상** 팝업인 것은 아니고, 데이터 양이 많을 때 팝업을 쓴다.
- 구조는 헤더(제목 중앙) + 본문이고 **푸터는 기본으로 없다**. 조회만 하는 팝업은 그대로 두고, **확정할 작업이 있을 때만 `useFooter` 로 푸터를 켜서 `submitButton` 에 `저장`** 을 둔다.
- **본문 패딩은 `SPopup` 이 토큰으로 넣는다. 직접 주지 않는다** (`p-sd-*` 로 덮어쓰면 토큰이 바뀌어도 안 따라간다). 표를 가장자리까지 채우는 등 콘텐츠가 여백을 직접 다뤄야 할 때만 `noPadding` 으로 끈다.
- 그 밖에는 팝업 안도 일반 페이지와 같은 규칙(§4)을 따른다: 블록 간격 `gap-sd-12`, 표는 `SKeyValueTable` / `STable`.

```tsx
// 1) 목록에서 별도 창을 연다 — 창 크기 = 콘텐츠 크기
function openDetailPopup(orderId: string) {
  window.open(
    `${window.location.origin}/popup/transfer-orders/${orderId}`,
    `transfer-order-${orderId}`,
    'width=1200, height=800, toolbar=no, menubar=no, location=no, resizable=no',
  );
}

// 2) 그 라우트의 루트에 SPopup 을 둔다 (조회만 → 푸터 없음)
export default function TransferOrderPopupPage() {
  return (
    <SPopup popupTitle="이동 오더 상세">
      {/* 본문 패딩은 SPopup 이 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
      <div className="flex flex-col gap-sd-12">
        <SSectionHeaderCard>…</SSectionHeaderCard>
        <STabs value={tab} tabs={TABS} onValueChange={setTab} />
        <STable columns={columns} rows={rows} rowKey="id" />
      </div>
    </SPopup>
  );
}
```

#### 3-3-4. 모달 만드는 법

디자인 시스템이 띄우는 모달은 `SActionModal` · `SConfirmModal` · `SLoadingModal` 3종뿐이고 `create` · `confirm` · `loading` 이 1:1로 대응한다.
직접 오버레이를 만들지 않는다. 작업용 모달은 **`SActionModal` 을 루트로 하는 컴포넌트를 만들어 `SModal.create` 에 넘긴다.**

```tsx
// 1) SActionModal 을 루트로 하는 컴포넌트를 만든다
//    create 가 주입하는 open / onOpenChange / onClose 를 그대로 SActionModal 에 전달해야 한다
function OrderModal({ orderId, open, onOpenChange, onClose, modalRef }: OrderModalProps) {
  return (
    <SActionModal
      open={open}
      onOpenChange={onOpenChange}
      onClose={onClose}
      modalTitle="주문 상세"
      width={720}
      // 주 액션은 button(단수), 보조 버튼은 footerLeft — 하단 버튼 양끝 분리 규칙과 같다
      button={{ label: '접수', onClick: () => modalRef.ok() }}
      footerLeft={<SButton color="neutral" outline size="md" label="취소" onClick={() => modalRef.cancel()} />}
    >
      <SKeyValueTable fields={orderFields} values={order} />
    </SActionModal>
  );
}

// 2) SModal.create 로 띄운다
SModal.create({ component: OrderModal, componentProps: { orderId } })
  .onOk(() => refetch())
  .onDismissed(() => {});
```

**하단 버튼을 본문(children)에 직접 두지 않는다.** 주 액션은 `button`, 보조 버튼은 `footerLeft` 로 넘긴다 — 푸터 배경·여백·양끝 분리가 컴포넌트 규칙대로 잡히는 자리다. `button` 은 클릭해도 **모달을 닫지 않으므로**(`onClick` 만 발화) 저장 API 응답을 보고 `modalRef.ok()` 로 닫으면 되고, 그 때문에 본문에 버튼을 따로 둘 이유가 없다. `footerLeft` 는 슬롯이라 `SButton` 을 직접 배치하며 `size="md"` 를 명시한다(§3-5-2).

**모달 안에서도 앱의 훅을 그냥 쓴다 — 단, 앱 루트에 `SModalOutlet` 이 있어야 한다 (§4-1).** outlet 이 있으면 명령형 모달이 앱 렌더 트리의 자식으로 그려지므로 `useQuery`·`useNavigate`·`useTheme` 같은 Context 기반 훅이 페이지에서와 똑같이 동작한다. **모달 컴포넌트를 Provider 로 다시 감싸지 않는다.** outlet 없이 띄우면 모달이 별도 React 루트로 떠서 Provider 가 하나도 닿지 않고, `No QueryClient set` 처럼 모달을 여는 순간에만 터진다.

#### 3-3-5. 닫기 경로 — `persistent` 기본값은 컴포넌트마다 다르다

**입력을 담는 모달은 백드롭 클릭·ESC 로 닫히지 않는 것이 기본**이다. 닫기 시도는 흔들림(shake)으로만 반응한다. 작성 중인 내용을 실수로 잃지 않게 하기 위한 것이다. 반면 `SConfirmModal` 은 잃을 입력이 없으므로 **백드롭·ESC 로 닫히는 것이 기본**이다.

| | `persistent` 기본값 | 기본 닫기 경로 | 백드롭·ESC |
| --- | --- | --- | --- |
| `SActionModal` · `SLoadingModal` | `true` | X 버튼, 모달 안의 버튼 | 막힘 (흔들림) |
| `SDrawer` | `true` | X 버튼, footer 버튼 | 막힘 (흔들림) |
| `SConfirmModal` | `false` | X 버튼, 확인/취소 버튼 | **닫힌다** |
| `SPopover` · `STooltip` · `SSelect` 등 floating | — | 바깥 클릭·ESC | **막지 않는다** — 이 규칙의 대상이 아니다 |

따라서 다음을 지킨다.

- **닫을 수단을 반드시 하나는 둔다.** `SActionModal` 에 `showClose` 도 `button`/`footerLeft` 도 없으면 사용자가 모달을 닫을 방법이 없다. 백드롭이 더 이상 탈출구가 아니다.
- **기본값과 같은 `persistent` 를 직접 주지 않는다.** 중복이다.
- **`SActionModal`·`SDrawer` 에 `persistent={false}` 는 잃을 입력이 없을 때만.** 단순 알림처럼 임의로 닫혀도 아무것도 사라지지 않는 경우로 한정한다.
- **`SConfirmModal` 에 `persistent` 를 켜는 것은 반드시 답을 받아야 할 때만.** 되돌릴 수 없는 파괴적 작업의 확인처럼, 임의로 닫히면 안 되는 경우로 한정한다.

작성 중인 내용이 있을 때 닫기를 시도하면 이탈 안내를 띄우는 것은 **소비 앱 몫**이다. 디자인 시스템은 dirty 상태를 알지 못하므로 백드롭·ESC 를 일괄 차단할 뿐이다. 안내가 필요하면 앱이 자체 dirty 판정 후 `SModal.confirm` 으로 띄운다.

### 3-4. 테이블 컬럼 — 정렬과 너비

#### 정렬

**값의 크기를 비교하는 숫자 컬럼은 예외 없이 오른쪽 정렬한다** (`align: 'right'`).
자릿수가 세로로 맞아야 값의 크기를 눈으로 비교할 수 있기 때문이다.

**판별 기준은 "숫자인가"가 아니라 "크기를 비교하는가"다.** 자릿수 차이가 거의 없고 값끼리 대소를 견줄 일이 없으면 숫자로만 이루어져 있어도 우측 정렬하지 않는다.

| 값 성격 | 정렬 | 예 |
| --- | --- | --- |
| **금액·수량·개수·비율 등 양을 나타내는 값** | **`'right'`** | `39,000원` · `12개` · `3건` · `15%` |
| 코드·식별자 (주문번호, 상품코드, 순번) | **`'center'`** | `RV20250728-000010` · `1024` |
| 전화번호·사업자번호 | **`'center'`** | `010-1234-5678` |
| 일자·일시 | **`'center'`** | `2024-10-23` |
| 텍스트 | 생략(기본 `left`) | 상품명, 카테고리 |
| 상태 태그·아이콘·체크박스 등 고정폭 요소 | `'center'` | `STag`, `SIcon` |

**중앙 정렬은 `align: 'center'` 를 명시한다.** 기본값이 좌측이라 생략하면 중앙이 되지 않는다.

```tsx
const columns: STableColumn[] = [
  { name: 'orderNo', label: '주문번호', field: 'orderNo', width: '140px', align: 'center' },
  { name: 'orderedAt', label: '주문일자', field: 'orderedAt', width: '100px', align: 'center' },
  { name: 'name', label: '상품명', field: 'name' },                       // 텍스트 → 생략
  { name: 'qty', label: '수량', field: 'qty', width: '80px', align: 'right',
    format: (v: number) => `${Number(v).toLocaleString()}개` },
  { name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
    format: (v: number) => `${Number(v).toLocaleString()}원` },
  { name: 'status', label: '상태', field: 'status', width: '100px', align: 'center',
    render: () => <STag size="sm" color="green" label="판매중" /> },
];
```

- `format` 으로 단위를 붙이더라도 **양을 나타내면 오른쪽 정렬**이다. 단위 때문에 문자열이 되는 것은 정렬 판단과 무관하다.
- 양을 나타내는 숫자는 §1-4 대로 **`toLocaleString()` 이 필수**다. 세 자리 콤마 없이 출력하지 않는다.
- **번호·코드에는 세 자리 콤마를 넣지 않는다.** 송장번호 `123456789` 를 `123,456,789` 로 표시하면 값 자체가 달라 보인다.
- **헤더는 가운데, 셀만 우측**으로 두려면 `align` 이 아니라 `tdClass` 를 쓴다. `align` 은 `<th>` 와 `<td>` 에 함께 적용된다.

```tsx
{ name: 'views', label: '조회수', field: 'views', align: 'center', tdClass: 'text-right!',
  format: (v: number) => Number(v).toLocaleString() },
```

- `SKeyValueTable` 의 값 셀도 같은 기준을 따른다.

#### 컨트롤이 들어가는 컬럼은 너비를 명시한다

컬럼 폭은 `width` 로 **고정**되고, `<td>` 는 그 폭을 넘는 내용을 잘라낸다(`overflow: hidden`). 텍스트라면 말줄임으로 끝나지만, 셀에 `STag` · `SButton` · `SGhostButton` · `SSelect` · `SInput` · `SNumberInput` 처럼 **자기 폭을 가진 요소**를 넣으면 요소 자체가 잘려 **누르거나 읽거나 입력할 수 없게 된다.** `width` 를 생략해도 내용에 맞춰 늘어나지 않고 `STable` 의 기본 폭이 될 뿐이므로, 컨트롤이 들어가는 컬럼은 폭을 직접 판단해서 준다.

- 기준은 **요소가 온전히 보이는 폭 + 셀 좌우 패딩**이다. 좌우 패딩은 `STable` 이 토큰으로 넣으므로(직접 주지 않는다) 그만큼을 뺀 나머지가 요소 몫이라는 점을 계산에 넣는다.
- 요소가 둘 이상이면 요소 폭의 합에 **`gap` 까지** 더한다 (행 내부 인라인 액션 간격은 `gap-sd-4` 고정, §3-5-5).
- 값에 따라 폭이 달라지는 요소(`STag` 라벨, 라벨 있는 버튼)는 **가장 긴 값** 기준으로 잡는다. `판매중` 에 맞춰두면 `판매중지 요청` 에서 잘린다.
- `SSelect` · `SInput` 처럼 셀 폭을 채우는 컨트롤은 **컬럼 폭이 곧 컨트롤 폭**이다. 실제 선택값·입력값이 말줄임 없이 읽히는 폭인지 확인한다.
- 폭을 넉넉히 줄 수 없는 자리는 폭을 줄이는 게 아니라 **요소를 바꾼다** — 라벨 버튼 대신 아이콘만 있는 `SGhostButton`, `size="xs"` (§3-5-2, §3-5-5).
- **`autoWidth` 는 해법이 아니다.** 내용에 맞춰 늘어나는 게 아니라 고정폭 컬럼들이 가져가고 **남은 폭을 나눠 갖는 것**이라, 테이블이 좁으면 역시 잘린다. 컨트롤 컬럼은 `width` 로 직접 확보한다.

**`resizable` 테이블이면 `minWidth` 를 함께 준다.** resize 하한 기본값은 어떤 컨트롤도 담지 못할 만큼 작아, 사용자가 끝까지 끌면 그대로 잘린다. `width` 를 정한 근거와 같은 값을 하한으로 둔다 — 텍스트 컬럼과 달리 여기서는 더 줄일 여지가 없다.

```tsx
const columns: STableColumn[] = [
  // 태그 — 가장 긴 라벨 기준
  { name: 'status', label: '상태', field: 'status', width: '120px', minWidth: 120, align: 'center',
    render: (row: SRow) => <STag size="sm" color="green" label={row.statusLabel} /> },
  // 셀 안 입력 — 컬럼 폭이 곧 입력 폭
  { name: 'qty', label: '수량', field: 'qty', width: '100px', minWidth: 100, align: 'right',
    render: (row: SRow) => <SNumberInput value={row.qty} onValueChange={v => setQty(row, v)} /> },
  // 인라인 액션 둘 — 폭 = xs 버튼 2개 + gap-sd-4 + 셀 좌우 패딩
  { name: 'actions', label: '', field: 'id', width: '84px', minWidth: 84, align: 'center',
    render: (row: SRow) => (
      <div className="flex items-center justify-center gap-sd-4">
        <SGhostButton size="xs" intent="action" icon="edit" ariaLabel="수정" onClick={() => editRow(row)} />
        <SGhostButton size="xs" icon="delete" ariaLabel="삭제" onClick={() => confirmRemove(row)} />
      </div>
    ) },

  // ❌ 컨트롤 컬럼에 width 생략 — 기본 폭에 맡기면 버튼이 잘린다
  { name: 'move', label: '', field: 'id', render: () => <SButton label="재고 이동" size="xs" /> },
];
```

#### 값이 없는 셀은 회색 하이픈

셀을 **빈칸으로 두지 않는다.** 값이 `null` · `undefined` · 빈 문자열이면 `-` 를 `text-fg-tertiary`(`grey_65`)로 표시한다.
빈칸은 "데이터가 없음"인지 "렌더가 깨졌는지" 구분되지 않지만, 회색 하이픈은 없다는 사실을 명시한다.

```tsx
// 재사용 헬퍼를 하나 두고 모든 컬럼에서 쓴다
const emptyCell = <span className="text-fg-tertiary">-</span>;
const hasValue = (v: unknown) => v !== null && v !== undefined && v !== '';

const columns: STableColumn[] = [
  { name: 'memo', label: '메모', field: 'memo',
    render: (row: SRow) => (hasValue(row.memo) ? row.memo : emptyCell) },
  { name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
    render: (row: SRow) =>
      hasValue(row.price) ? `${Number(row.price).toLocaleString()}원` : emptyCell },
];
```

`0` 은 값이 있는 것이므로 하이픈으로 바꾸지 않는다 — `0원` 그대로 표시한다.

### 3-5. 버튼류

| 상황 | 사용 |
| --- | --- |
| 일반 액션 | `SButton` (`color`: `primary` / `secondary` / `neutral` / `danger`, `outline`, `size`: xs~md) |
| **삭제 등 파괴적 액션** | **`SButton color="danger"`** 또는 **`color="danger" outline`** (아래 3-5-3) |
| **아이콘 하나로 뜻이 통하는 부가 조작** | **`SGhostButton`** — 라벨이 없는 아이콘 전용 버튼 (아래 3-5-5) |
| 본문 속 이동 링크 | `STextLink` |
| 메인 액션 + 부가 메뉴 | `SDropdownButton` |

#### 3-5-1. 채움 버튼은 페이지당 개수 제한이 있다

`outline` 이 위계를 한 단계 낮춘다. **채움(outline 없음)이 그 페이지의 최상위 액션**이다.

| 조합 | 위계 | 페이지당 |
| --- | --- | --- |
| `color="primary"` (진남색 채움) | 최상위 실행 | **1개** |
| `color="danger"` (빨강 채움) | 최상위 파괴 | **1개** |
| `color="secondary"` (밝은 파랑 채움) | 중간 | N개 — **같은 속성 연속 배치 금지** |
| `primary outline` · `neutral outline` · `neutral`(흰 채움) · `danger outline` | 낮음 | N개 |

`SDropdownButton` 도 같은 규칙을 따르며, **페이지당 `primary` 채움 1개 계산에 포함**된다.

#### 3-5-2. `size` 는 놓이는 위치가 정한다

| 위치 | size |
| --- | --- |
| 테이블 **행 내부** 인라인 액션 | `xs` |
| 화면·목록 액션 (툴바, `STableBar`) | `sm` |
| **모달·위저드 푸터** | `md` |
| **페이지 하단 실행 버튼** (폼 저장·상세 수정 등) | `md` |
| 스크롤 시 헤더로 승격된 버튼 | `sm` (최대) |

`lg` 는 현재 사용처가 없다. 페이지 골격에서 쓰지 않는다.

#### 3-5-3. 파괴적 액션의 채움 / outline

물리적 위치가 아니라 **적용 범위 + 위험도**로 가른다.

| 상황 | 스타일 |
| --- | --- |
| 대상이 **전체**이거나 기능적 위험도가 높음 (계정 삭제, 전체 초기화) | `color="danger"` **채움** + 다른 버튼과 공간 분리 |
| **항목별**로 실행되거나 위험도가 상대적으로 낮음 (행 삭제, 연동 해제) | `color="danger" outline` |

#### 3-5-4. `SDropdownButton` — `split` 기준

| 설정 | 동작 | 쓰는 때 |
| --- | --- | --- |
| `split={false}` (기본) | 버튼 전체가 메뉴 토글. 클릭만으로는 실행되지 않는다 | 실행 전에 **범위·옵션을 골라야** 할 때 — `적치 지시서 출력 ▾` → 전체 항목 / 선택 항목만 |
| `split` | 좌측 = 기본 동작 **즉시 실행**, 우측 `⋯` = 변형 동작 | 기본 동작이 명확하고 **변형·부가 동작**을 곁들일 때 — `저장 \| 저장 후 신규 등록` |

메뉴 항목은 **기본 동작의 변형·부가 동작으로 한정**한다. 무관한 액션 여러 개를 한 드롭다운에 묶지 않는다.

#### 3-5-5. `SGhostButton` — 아이콘 하나로 뜻이 통하는 자리에만

`icon` 이 필수이고 **`label` 이 없다.** 아이콘만으로 무슨 조작인지 통하지 않으면 `SGhostButton` 이 아니다.

**쓰는 곳은 전부 메인 본문이 아닌 부가적인 위치다** — 밀도가 높아 라벨을 붙일 공간이 없는 자리다.

| 자리 | 예 |
| --- | --- |
| 테이블 | 컬럼 헤더 · 행 끝 · 셀 안 (복사, 펼치기, 행 수정·삭제) |
| 모달 헤더 | 닫기 `×` |
| 카드 | 드래그 핸들 `≡` · 상세 진입 `>` |

행 hover 시에만 나타나는 수정·삭제도 여기 해당한다.

**`intent` 는 조작의 성격이 정한다.**

| intent | 색 | 쓰는 곳 |
| --- | --- | --- |
| **`default`**(기본) | `#888888` | **대부분** — 닫기, 복사, 드래그, 펼치기, 행 삭제 |
| `action` | `#025497` | 진입·추가처럼 **의미 있는 조작** — 편집 모드 진입, 항목 추가, 행 수정 |
| `subAction` | `#5CB0F3` | `action` 다음 계층 — `action`·`default` 사이의 중의적 의미 + 부분 강조 |
| `danger` | `#E30000` | **되돌릴 수 없는 삭제** |
| `inverse` | `#FFFFFF` | 어두운 배경 위 (GNB 접기 등) |

**같은 `×` 도 맥락에 따라 갈린다.**

| `×` 가 하는 일 | intent |
| --- | --- |
| 모달 닫기 · 입력 취소 | `default` |
| 데이터를 지우는 삭제 | `danger` |

- **`ariaLabel` 은 필수다.** 라벨이 없어 스크린리더가 읽을 것이 아이콘밖에 없다. 뜻이 덜 명확한 아이콘은 **`tooltipText`** 로 이름을 띄운다.
- 여러 개를 나란히 둘 때 간격은 **`gap-sd-4` 고정** — 가로·세로가 같다 (§2-2).
- **라벨이 필요하거나 화면의 주 액션이면 `SGhostButton` 이 아니라 `SButton` 이다.** 위계가 낮다는 이유만으로 `SGhostButton` 을 고르지 않는다 — 라벨이 붙는 저강조 액션은 `SButton` 의 `outline` 조합(§3-5-1)이다.

```tsx
// 테이블 행 끝 인라인 액션 — 수정은 action, 행 삭제는 default, 간격은 gap-sd-4
{ name: 'actions', label: '', field: 'id', width: '72px', align: 'center',
  render: (row: SRow) => (
    <div className="flex items-center justify-center gap-sd-4">
      <SGhostButton size="xs" intent="action" icon="edit"
        ariaLabel="수정" onClick={() => editRow(row)} />
      {/* 되돌릴 수 없는 삭제일 때만 intent="danger" */}
      <SGhostButton size="xs" icon="delete"
        ariaLabel="삭제" onClick={() => confirmRemove(row)} />
    </div>
  ) },

✅ <SGhostButton icon="close" ariaLabel="닫기" onClick={onClose} />        // 모달 닫기 = default
✅ <SGhostButton icon="copy" ariaLabel="상품코드 복사" tooltipText="복사" /> // 뜻이 덜 명확 → 툴팁
❌ <SGhostButton icon="download" ariaLabel="엑셀 다운로드" />              // 목록의 주 액션 → SButton
```

#### 3-5-6. 그 밖

- 라벨은 **동사(+목적어)** — `저장` · `적치 지시` · `송장 재출력`
- 완료된 작업 버튼은 제거하지 않고 **`disabled` 로 남긴다.** 자리가 사라지면 행 높이가 흔들린다.
- **모달 보조 액션은 `color="neutral" outline`** 이 표준이다.

**하단 버튼 배치 (모든 화면 공통)** — 그룹을 모아 정렬하지 않고 **양끝으로 벌린다**(`justify-between`).

- **왼쪽 끝**: 취소·닫기·목록 등 되돌리는 액션
- **오른쪽 끝**: 저장·등록·수정·삭제 등 실행 액션
- 체크박스·안내 문구 등 **부가 요소는 오른쪽 그룹 안, 실행 버튼 바로 왼쪽**에 둔다

```tsx
<div className="flex items-center justify-between">
  <SButton color="neutral" outline label="취소" />   {/* 되돌리기 = 낮은 위계 */}
  <div className="flex items-center gap-sd-8">
    <SCheckbox label="계속 등록하기" value={keep} onValueChange={setKeep} />  {/* 부가 요소 */}
    <SButton label="저장" />                          {/* 페이지 유일한 primary 채움 */}
  </div>
</div>
```

### 3-6. 영역 나누기 — SDivider vs SSplitter

| 상황 | 사용 |
| --- | --- |
| 두 영역 사이에 **선만** 그을 때 | `SDivider` — 위치·두께가 고정된 구분선이다 |
| 사용자가 **경계를 끌어 넓이를 바꿀 수 있어야** 할 때 | `SSplitter` — 두 패널을 감싸고 경계 위치를 소유한다 |

`SSplitter` 의 구분선은 평소 자리만 잡고 칠해지지 않다가, 경계에 커서를 올리거나 포커스를 주면 그때 드러난다 — 조절 가능한 자리라는 신호다. **항상 보이는 선이 필요하면 `SDivider` 를 쓴다.** 선 색·두께·주변 여백은 토큰이 정하므로 직접 주지 않는다.

```tsx
<SSplitter defaultValue={30} limits={[20, 60]}>
  <SSplitter.Before>내비게이션</SSplitter.Before>
  <SSplitter.After>본문</SSplitter.After>
</SSplitter>
```

- 자식은 `SSplitter.Before` 와 `SSplitter.After` **둘뿐**이고, 루트의 직접 자식이어야 한다.
- 크기 단위는 `unit` 이 정한다. 기본 `'%'` 는 창이 바뀌어도 비율을 유지하고, `'px'` 는 폭을 유지한다. **사이드바처럼 폭이 고정돼야 하는 자리는 `'px'`**, 화면을 비율로 나누는 자리는 기본값 그대로 둔다.
- 본문이 읽을 수 없을 만큼 좁아지지 않도록 `limits={[최소, 최대]}` 를 준다. 생략하면 `'%'` 는 `[10, 90]`, `'px'` 는 `[50, Infinity]`.
- 각 패널은 넘치는 만큼 **스스로 스크롤한다.** 패널 안에 `SScrollArea` 를 겹쳐 넣지 않는다.
- 모델은 항상 **첫 패널**(`SSplitter.Before`) 크기다. 사이드가 기준인 화면이면 사이드를 `Before` 에 둔다.
- 앱 셸의 GNB 폭은 `SGnb` 가 소유한다. `SLayout`/`SGnb` 를 `SSplitter` 로 감싸지 않는다 — **GNB 폭을 끌 수 있게 하려면 `SGnb` 에 `resizable` 을 준다**(§4-1).

**Quasar `q-splitter` 에서 옮겨올 때** — `unit` · `limits` · `emitImmediately` 는 이름과 뜻이 같고, 나머지는 아래처럼 바뀐다. `emitImmediately` 를 주지 않으면 **드래그를 놓는 순간 한 번만** `onValueChange` 가 온다.

| q-splitter | SSplitter |
| --- | --- |
| `horizontal` (상/하 분할) | **`vertical`** — 이 저장소는 `STabs`·`SStepper` 와 같이 "세로 **배치**" 로 읽는다 |
| `v-model` | `value` + `onValueChange` (또는 `defaultValue`) |
| `disable` | `disabled` |
| `before` / `after` 슬롯 | `SSplitter.Before` / `SSplitter.After` |
| `before-class` / `after-class` | 각 슬롯에 `className` 을 직접 |
| `separator-class` / `separator-style` | `dividerClassName` / `dividerStyle` |
| `reverse` · `dark` | 없음 — 모델은 항상 첫 패널 크기다 |

### 3-7. 나머지 판별 — 입력·목록·컨테이너

> §3-0 라우팅에서 이 절을 가리키는 자리들이다. <!-- TODO(디자인): 전체 검수·확정 -->

#### 3-7-1. SInput vs STextarea

**줄 수가 아니라 값의 성격으로 고른다.** 값의 길이를 미리 알 수 있으면 `SInput`, 없으면 `STextarea` 다.

| 값 | 사용 |
| --- | --- |
| 이름·코드·전화번호·URL 처럼 형식이 정해진 값 | `SInput` |
| 메모·사유·설명처럼 길이가 예측되지 않는 문장 | `STextarea` |

값이 길어질 수 있는데 `SInput` 을 쓰면 사용자가 자기가 쓴 것을 다시 읽지 못한다 — 한 줄 안에서 좌우로 스크롤해야 하기 때문이다. 반대로 짧은 값에 `STextarea` 를 쓰면 빈 공간이 남아 입력량을 잘못 기대하게 한다.

#### 3-7-2. 하나를 고르게 하는 다섯 — SSelect vs SRadioGroup vs SRadioButton vs STabs vs SRadio

**먼저 "고르면 무엇이 바뀌는가"를 본다.**

1. **화면의 내용이 통째로 바뀐다** → `STabs`. 값을 고르는 게 아니라 보는 관점을 바꾸는 것이다. 폼 값이 아니다.
2. 그 밖에는 폼 값이므로 **선택지 수와 노출 여부**로 고른다.

| 선택지 | 사용 |
| --- | --- |
| 6개 이상, 또는 서버에서 오는 동적 목록 | `SSelect` |
| 2~5개 고정 + 선택지를 항상 보여야 함 | `SRadioGroup` |
| 2~4개 + 짧은 라벨의 배타적 모드 전환 (세그먼트) | `SRadioButton` |

- **`SRadio` 를 직접 나열하지 않는다.** 그룹 간격은 `SRadioGroup` 이 맞춘다 (§2-2). `SRadio` 단독은 `SRadioGroup` 이 만들 수 없는 배치 — 표 셀 안에 행마다 하나씩 놓는 경우 — 에만 쓴다.
- `SRadioButton` 은 `options` 를 통째로 받는 세그먼트 컨트롤이라 `SRadio` 를 여러 개 넣는 게 아니다.

#### 3-7-3. 켜고 끄는 셋 — SCheckbox vs SSwitch vs SToggle

셋 다 on/off 지만 **값이 언제 반영되는지**가 다르다. 이걸 틀리면 사용자가 저장 버튼을 찾다가 못 찾거나, 눌렀는데 반영이 안 돼 다시 누른다.

| 판별 | 사용 |
| --- | --- |
| **폼 값으로 제출된다** (저장 버튼을 눌러야 반영) | `SCheckbox` |
| **누르는 즉시 반영된다** (저장 버튼 없음) | `SSwitch` |
| **목록을 좁히는 필터** (여러 개를 나란히 켜고 끔) | `SToggle` |

- `SCheckbox` 만 다중 선택(배열)과 `indeterminate`(부분 선택)를 갖는다. 전체 선택 체크박스는 반드시 `SCheckbox` 다.
- 약관 동의처럼 **제출 시점에 값이 필요한 것은 항상 `SCheckbox`** 다 — 모양이 스위치에 가까워 보여도 그렇다.
- `SToggle` 은 알약형 버튼이라 여러 개를 가로로 늘어놓는 필터 자리에 맞는다. 설정 화면의 on/off 한 줄에는 쓰지 않는다.

#### 3-7-4. 날짜 셋 — SDatePicker vs SDateRangePicker vs SCalendar

| 판별 | 사용 |
| --- | --- |
| 날짜 **하나**를 값으로 받는다 | `SDatePicker` |
| **시작~종료** 를 값으로 받는다 | `SDateRangePicker` |
| 달력 격자 **자체가 화면 콘텐츠** 다 (일정·이벤트 보기) | `SCalendar` |

- **기간을 `SDatePicker` 두 개로 만들지 않는다.** 시작이 종료보다 뒤인 입력을 막는 검증과 한쪽만 고른 중간 상태 처리가 `SDateRangePicker` 안에 이미 있다. 두 개로 쪼개면 그게 전부 앱 몫이 된다.
- `SDatePicker`·`SDateRangePicker` 는 내부적으로 `SCalendar` 를 팝오버로 띄운다. 값을 받는 자리에 `SCalendar` 를 직접 쓰지 않는다.

#### 3-7-5. SField 를 직접 쓰는 경우

**거의 없다.** `SInput`·`SNumberInput`·`STextarea`·`SSelect`·날짜/시간 피커는 이미 내부에서 `SField` 를 쓰고 있어서 `label`·`required`·에러 표시를 자기 prop 으로 받는다. 그 위에 `SField` 를 한 겹 더 감싸면 라벨이 두 번 나온다.

직접 쓰는 경우는 하나뿐이다 — **디자인 시스템에 없는 컨트롤**에 다른 필드와 똑같은 라벨·필수·에러 모양을 붙일 때.

#### 3-7-6. 여러 건을 나열하는 셋 — STable vs SList vs SDraggableList

| 판별 | 사용 |
| --- | --- |
| 열이 둘 이상이고 **열끼리 값을 비교**한다 (정렬·합계·자릿수 맞춤) | `STable` |
| 한 항목이 **한 줄로 읽힌다** (제목 + 보조 텍스트) | `SList` + `SListItem` |
| **순서 자체가 데이터**라 사용자가 끌어서 바꾼다 | `SDraggableList` + `SDraggableItem` |

- **항목 하나의 속성을 나열하는 것은 목록이 아니다.** `라벨: 값` 이 세로로 쌓이는 것은 `SKeyValueTable` 이다 (§4-4).
- `SList` 는 레이아웃만 담당한다. 펼침·단일 선택 동작이 필요하면 `SExpansionList` 다 (§3-7-7).

#### 3-7-7. 펼치는 셋 — SExpansionItem vs SExpansionList vs STree

| 판별 | 사용 |
| --- | --- |
| 항목들이 **서로 독립적으로** 여닫힌다 (여러 개 동시에 열려도 됨) | `SExpansionItem` 단독 |
| **한 번에 하나만** 열려야 한다 (아코디언) | `SExpansionList` + `SExpansionItem` |
| **부모-자식 계층 자체**를 보여줘야 한다 (2단 이상, 연결선) | `STree` |

`SExpansionList` 는 depth 별 단일 확장·선택을 관리하는 wrapper 다. 직접 `useState` 로 "열린 항목 하나"를 들고 있지 않는다.

#### 3-7-8. SCard vs SSectionHeaderCard

| 판별 | 사용 |
| --- | --- |
| **제목이 붙는 섹션** 이다 | `SSectionHeaderCard` |
| 제목 없이 **흰 면만** 필요하다 (요약 타일, 빈 상태 박스) | `SCard` |

- 페이지 골격에서 콘텐츠를 묶는 섹션은 **사실상 전부 `SSectionHeaderCard`** 다 (§4-4·§4-5). 제목·필수 표시·도움말·헤더 우측 액션이 전부 여기 붙는다.
- **카드 안에 카드를 겹치지 않는다.** 섹션 안을 더 나눠야 하면 `SDivider` 로 끊거나(§3-6) 섹션을 둘로 분리한다.
- 안쪽 여백은 `SSectionHeaderCard.Body` 의 `padding` prop 으로 준다 — `p-sd-*` 를 직접 주지 않는다 (§2-2).

#### 3-7-9. SLinearProgress vs SCircleProgress

| 판별 | 사용 |
| --- | --- |
| 진행률(%)이 있고 가로로 길게 놓을 자리가 있다 | `SLinearProgress` |
| 자리가 좁다, 또는 **끝나는 시점을 모른다**(대기) | `SCircleProgress` |

`SCircleProgress` 는 `indeterminate` 로 두면 스피너가 된다. **다만 화면이나 영역을 막아야 하는 상황이면 progress 가 아니라 `SLoadingModal`·`SLoadingContainer` 다** (§3-2) — 진행 표시와 입력 차단은 다른 일이고, 막지 않으면 사용자가 로딩 중에 또 누른다.

#### 3-7-10. SPortal — 직접 쓸 일이 거의 없다

`STooltip`·`SPopover`·`SSelect`·날짜 피커가 내부에서 쓰는 저수준 레이어다. 앵커에 붙여 띄우는 동작이 필요하면 **먼저 §3-3 에서 대응 컴포넌트를 찾는다.** `SPortal` 을 직접 쓰는 것은 그 넷 중 어느 것도 아닌 새로운 부착형 레이어를 만들 때뿐이고, 그때도 모달 안에서 열릴 수 있다면 소속 컨테이너를 맞춰야 한다.

---

## 4. 페이지 레시피 — 표준 골격

> 새 페이지는 반드시 아래 골격에서 시작한다. 임의 골격을 발명하지 않는다.
>
> **아래 레시피는 §2-0 조합 문법으로 유도된 결과다.** 여기 없는 화면(대시보드·설정·마법사 등)을 만들 때는 임의로 짜지 말고 §2-0 의 층 구조·포함 규칙·블록 순서로 직접 유도한다.
>
> **핵심 원칙 — 표 형태의 정보는 `SKeyValueTable` 로 만든다.** 필터·등록/수정 폼·상세 정보가 모두 여기 해당한다.
> `SField` 컨트롤을 `div` 로 직접 나열해 폼을 만들지 않는다.

### 4-1. 앱 셸 (모든 페이지 공통)

```tsx
import { SLayout, SGnb, SPage, type SGnbMenuItem } from 'sellmate-design-system-react';

const MENU: SGnbMenuItem[] = [
  { label: '주문', value: 'orders', icon: 'bill' },
  { label: '상품', value: 'products', icon: 'box', children: [{ label: '목록', value: 'product-list' }] },
];

export default function AppShell({
  children,
  scrollEndSpacing,
}: { children: React.ReactNode; scrollEndSpacing?: boolean }) {
  return (
    <SLayout type="box" header="fix">
      {/* type/header/folded 는 SLayout 에만 준다 — SGnb 는 context 에서 읽는다 */}
      <SGnb items={MENU} value={current} onValueChange={navigate} logo={<Logo />} />
      {/* 콘텐츠 패딩은 SPage 가 토큰으로 넣는다 — p-sd-* 로 덮어쓰지 않는다 */}
      {/* 스크롤 끝 여백도 SPage 가 넣는다. 끄는 건 페이지네이션 있는 목록뿐이라 페이지가 정한다 */}
      <SPage background="frame" scrollEndSpacing={scrollEndSpacing}>{children}</SPage>
    </SLayout>
  );
}
```

**GNB 폭을 사용자가 조절하게 하려면 `SGnb` 에 `resizable` 을 준다.** 메뉴 오른쪽 경계가 조절선이 되고, 레일 폭은 고정된 채 메뉴 컬럼만 늘고 준다. 범위는 컴포넌트가 정하므로 숫자를 직접 주지 않는다.

```tsx
{/* 폭을 기억해야 하면 menuWidth 를 앱이 쥐고 onMenuWidthChange 로 되받아 저장한다.
    초기값만 정하면 되면 defaultMenuWidth 하나로 끝난다. */}
<SGnb items={MENU} value={current} onValueChange={navigate} resizable defaultMenuWidth={240} />
```

`onMenuWidthChange` 는 **드래그를 놓는 순간**(또는 방향키 조작) 한 번만 온다 — 저장 로직을 그대로 붙여도 프레임마다 쓰이지 않는다. 접혀 있거나 레일 리프가 활성이라 깔 메뉴가 없으면 조절선은 나오지 않는다.

**앱 부트스트랩에는 `<SModalOutlet />` 을 한 번 렌더한다 — Provider 안쪽에 둔다.**

```tsx
import { SModalOutlet } from 'sellmate-design-system-react';

// 앱 진입점 (main.tsx / App.tsx) — 앱 전체에 하나. 위치는 Provider 안쪽이기만 하면 된다
<QueryClientProvider client={queryClient}>
  <RouterProvider router={router} />
  <SModalOutlet />
</QueryClientProvider>;
```

`SModal.confirm/loading/create` 로 띄운 모달이 그려지는 자리다. outlet 이 앱 트리 안에 있어야 모달이 앱의 Context(QueryClient·Router·Theme 등)를 상속한다 — outlet 이 없으면 모달은 뜨지만 별도 React 루트라 Provider 가 닿지 않는다(§3-3-4). 모달은 언제나 `body` 로 portal 되므로 outlet 을 어디에 두든 레이아웃에는 영향이 없고, **셸 안에 넣을 필요도 없다.** 토스트를 쓴다면 `SToastContainer` 도 같은 자리에 둔다.

**최소 너비는 `SLayout` 이 보장한다 — 앱이 `min-w-*` 를 직접 주지 않는다.** 창이 최소 너비(`SLAYOUT_MIN_WIDTH`, GNB 포함한 전체 기준)보다 좁아지면 GNB·상단바는 제자리에 남고 **`SPage` 안에만 가로 스크롤이 생긴다.** 문서(브라우저 창)에는 가로 스크롤이 생기지 않는다. 그래서 셸을 감싸는 요소에 `min-width` 나 `overflow-x` 를 걸지 않는다 — 걸면 창 전체가 스크롤되어 GNB 가 화면 밖으로 밀린다.

**셸의 `SPage` 는 모든 페이지가 공유하므로, 스크롤 끝 여백을 끄려면 페이지가 셸에 알려야 한다.** 위처럼 프레임 컴포넌트가 `scrollEndSpacing` 을 받아 그대로 넘기고, 페이지네이션이 있는 목록 페이지만 `false` 를 준다 (§4-2). 나머지 페이지는 넘기지 않으면 기본값(켬)이 적용된다.

**상단바 배치는 `header` 가 정한다.** 요소 순서가 달라지므로 슬롯을 채우기 전에 어느 쪽인지부터 정한다.

| `header` | 상단바 배치 | 로고 폭 | `topContent` |
| --- | --- | --- | --- |
| `"fix"` (기본) | `[런처 · 로고 … 폴드]` — 상단바가 GNB 컬럼 안에 있고 폴드가 컬럼 오른쪽 끝 | 내용 폭 | **렌더되지 않는다** (놓을 자리가 없다) |
| `"full"` | `[런처 · 폴드 · 로고 · topContent]` — 상단바가 화면 전폭 | **140px 고정** (런처 없으면 172px) | 로고 오른쪽 남는 폭 전체 |

- `topContent` 는 상단바 로고 오른쪽 슬롯이다. 전역 검색·계정 메뉴·알림처럼 **모든 페이지에 공통인 것만** 넣는다. 페이지별 액션은 여기가 아니라 §4-2 의 `STableBar` 로 간다.
- 슬롯이 남는 폭을 통째로 받으므로 **정렬은 안에서 직접 잡는다** (좌측 정렬 + 우측은 `ml-auto`).
- `header="full"` 에서 로고 자리는 140px 로 고정된다 — 로고 내용이 바뀌어도 `topContent` 시작점이 흔들리지 않게 하기 위함이다. 로고가 그보다 넓으면 잘리므로 이 폭에 맞춰 준비한다.
- `onLauncherClick` 을 주지 않으면 런처가 렌더되지 않고, 그 자리(버튼 + 간격)를 로고 슬롯이 이어받아 172px 가 된다. `topContent` 시작점은 런처 유무와 관계없이 같은 자리다.

```tsx
<SLayout type="box" header="full">
  <SGnb
    items={MENU} value={current} onValueChange={navigate}
    logo={<Logo />}
    topContent={
      /* 남는 폭 전체를 받는다 — 왼쪽은 그대로, 오른쪽 끝은 ml-auto */
      <div className="flex w-full items-center gap-sd-8">
        <SInput value={keyword} onValueChange={setKeyword} placeholder="통합 검색" />
        <SButton size="sm" color="neutral" outline label="내 계정" className="ml-auto" onClick={openAccount} />
      </div>
    }
  />
  <SPage background="frame">{children}</SPage>
</SLayout>
```

**메뉴 목록과 함께 스크롤되면 안 되는 것은 `SGnb` 의 위아래 고정 슬롯에 둔다.** 레일과 메뉴에 각각 위(`railTop`·`menuTop`)와 아래(`railFooter`·`menuFooter`) 슬롯이 있다. 아이템이 많아 넘치면 **목록만 스크롤되고 이 슬롯들은 제자리에 남는다** — 메뉴 검색, 워크스페이스 전환, 계정 행처럼 항상 보여야 하는 것이 여기 온다. 레일 슬롯은 `useRail` 일 때만, 메뉴 슬롯은 깔 메뉴가 있을 때만 렌더된다.

**접으면 레일·메뉴가 통째로 빠져나가면서 그 슬롯들도 함께 사라진다.** 접힌 상태에서도 남겨야 할 것은 `foldedTop`·`foldedFooter` 로 따로 준다 — `header="fix"` 로 접혔을 때만 나타나며, 폭이 좁은 폴드 레일이므로 아이콘 버튼 하나 정도로 줄인다.

```tsx
{/* 접히면 menuTop·menuFooter 가 함께 빠지므로, 폴드 레일에 남길 것만 foldedTop 으로 따로 준다 */}
<SGnb
  items={MENU} value={current} onValueChange={navigate} useRail
  menuTop={<SInput value={keyword} onValueChange={setKeyword} placeholder="메뉴 검색" />}
  menuFooter={<AccountRow />}
  foldedTop={<SGhostButton icon="search" size="sm" ariaLabel="메뉴 검색" onClick={openSearch} />}
/>
```

슬롯 안쪽 여백은 **슬롯 내용이 직접 갖는다** — 컴포넌트는 자리만 잡는다(폴드 슬롯만 좁은 폭에 맞춰 가운데 정렬한다). 메뉴 폭은 `resizable` 로 바뀔 수 있으므로 슬롯 내용은 고정 폭 대신 `w-full` 로 따라가게 둔다.

### 4-2. 목록 페이지 (필터 + 테이블)

구조: **페이지 헤더(제목 + 가이드 링크) → 필터(`SKeyValueTable`) → `STableBar` → `STable`**

액션 버튼의 위치가 핵심이다:

- **페이지 제목 줄에는 액션 버튼을 두지 않는다.** 가이드·매뉴얼 링크 등 부가 정보만 온다.
- **주요 액션(등록 등)은 `STableBar` 의 `rightActions`** 에 둔다.
- **선택 상태 액션(선택 삭제 등)은 `STableBar` 의 `actions`** 에 둔다. `actions` 슬롯은 건수 요약이 있으면 앞에 구분선(`SDivider`)을 **자동으로** 넣으므로 직접 구분선을 만들지 않는다.
- **페이지네이션이 있으면 스크롤 끝 여백을 끈다** — 셸의 `SPage` 에 `scrollEndSpacing={false}` 를 넘긴다 (§2-2). 페이지네이션이 이미 "여기서 끝"을 알려준다.

```tsx
import {
  SButton, STextLink, SKeyValueTable, STableBar, STable, STag,
  type STableColumn, type SRow, type SKeyValueField,
} from 'sellmate-design-system-react';

/** 필터도 표다 — SKeyValueTable 로 만든다 (서비스 전용 전역 필터가 따로 있는 경우 제외) */
const filterFields: SKeyValueField[][] = [
  [
    { name: 'status', label: '상태', type: 'select',
      options: { options: STATUS_OPTIONS, emitValue: true } },
    { name: 'keyword', label: '검색어', type: 'input',
      options: { placeholder: '상품명 / 상품코드' } },
  ],
  [
    { name: 'period', label: '등록일', type: 'date-range-picker', tdColSpan: 3 },
  ],
];

const columns: STableColumn[] = [
  // 숫자 컬럼은 전부 align: 'right' — §3-4
  { name: 'id', label: 'ID', field: 'id', width: '80px', align: 'right' },
  { name: 'name', label: '상품명', field: 'name' },
  { name: 'stock', label: '재고', field: 'stock', width: '90px', align: 'right',
    format: (v: number) => `${Number(v).toLocaleString()}개` },
  { name: 'price', label: '판매가', field: 'price', width: '120px', align: 'right',
    format: (v: number) => `${Number(v).toLocaleString()}원` },
  // 상태 컬럼은 STag size="sm" — SBadge 색 점을 기본으로 쓰지 않는다
  { name: 'status', label: '상태', field: 'status', width: '100px', align: 'center',
    render: (row: SRow) =>
      row.status === 'selling'
        ? <STag size="sm" color="green" label="판매중" />
        : <STag size="sm" color="grey" label="판매중지" /> },
];

export default function ProductListPage() {
  const [filters, setFilters] = useState<Record<string, unknown>>({});
  const [selected, setSelected] = useState<SRow[]>([]);

  return (
    <div className="flex flex-col gap-sd-12">
      {/* 페이지 헤더 — 액션 버튼 없음. 가이드/매뉴얼 링크 자리 */}
      <div className="flex items-center justify-between">
        <h1 className="typo-heading-lg m-0">상품 목록</h1>
        <STextLink label="이용 가이드" rightArrow="chevron" onClick={openGuide} />
      </div>

      {/* 필터 — search 를 켜면 우측에 검색 패널이 붙는다 */}
      <SKeyValueTable
        fields={filterFields}
        values={filters}
        search
        onChange={({ values }) => setFilters(values)}
        onSearch={fetchList}
      />

      {/* 툴바 — 좌: 건수 + (구분선 자동) + 선택 액션 / 우: 주요 액션 */}
      <STableBar
        total={total}
        selected={selected.length}
        actions={
          /* 선택 항목 단위 파괴 액션 → danger outline (§3-5-3) */
          <SButton size="sm" color="danger" outline label="선택 삭제"
            disabled={!selected.length} onClick={removeSelected} />
        }
        rightActions={
          /* 이 페이지의 유일한 primary 채움 (§3-5-1) */
          <SButton size="sm" label="상품 등록" onClick={goCreate} />
        }
      />

      <STable
        columns={columns}
        rows={rows}
        rowKey="id"
        selectable
        selected={selected}
        onSelectedChange={setSelected}
        pagination={{ currentPage, lastPage }}
        isLoading={isLoading}
      />
    </div>
  );
}
```

### 4-3. 폼 페이지 (등록/수정)

구조: **페이지 제목 → `SForm` + `SKeyValueTable` → 하단 버튼**

- 필드를 `div` 로 나열하지 않고 **`SKeyValueTable` 의 행으로 구성**한다.
- 검증 규칙은 각 field 의 `options.rules` 로 넘긴다. `SForm` 이 하위 컨트롤을 자동 수집해 submit 시 일괄 검증하고, 실패 시 첫 실패 필드로 포커스를 옮긴다.
- **버튼 순서: 취소·닫기가 왼쪽, 저장·등록·수정·삭제가 오른쪽.** 이 순서는 모든 화면에서 동일하다.

```tsx
import {
  SForm, SKeyValueTable, SButton, SCheckbox,
  type SFormHandle, type SKeyValueField, type Rule,
} from 'sellmate-design-system-react';

const required = (msg: string): Rule => v =>
  v != null && String(v).trim() !== '' ? true : msg;

const fields: SKeyValueField[][] = [
  [
    { name: 'name', label: '상품명', required: true, type: 'input',
      options: { placeholder: '상품명 입력', rules: [required('상품명을 입력해 주세요.')] } },
    { name: 'code', label: '상품코드', type: 'input', options: { placeholder: '자동 생성' } },
  ],
  [
    { name: 'category', label: '카테고리', required: true, type: 'select',
      options: { options: CATEGORY_OPTIONS, emitValue: true,
        rules: [required('카테고리를 선택해 주세요.')] } },
    { name: 'price', label: '판매가', type: 'number-input' },
  ],
  [
    { name: 'memo', label: '메모', type: 'textarea', tdColSpan: 3,
      helpText: ['내부 관리용 메모입니다.'] },
  ],
];

export default function ProductCreatePage() {
  const formRef = useRef<SFormHandle>(null);
  const [values, setValues] = useState<Record<string, unknown>>({});

  return (
    <div className="flex flex-col gap-sd-12">
      <h1 className="typo-heading-lg m-0">상품 등록</h1>

      <SForm ref={formRef} formClass="flex flex-col gap-sd-12" onSubmit={save}>
        <SKeyValueTable
          fields={fields}
          values={values}
          onChange={({ values }) => setValues(values)}
        />

        {/* 하단 버튼은 양끝으로 벌린다. 부가 요소(체크박스 등)는 저장 바로 왼쪽 */}
        <div className="flex items-center justify-between">
          <SButton type="button" color="neutral" outline label="취소" onClick={goBack} />
          <div className="flex items-center gap-sd-8">
            <SCheckbox label="계속 등록하기" value={keepOpen} onValueChange={v => setKeepOpen(v as boolean)} />
            <SButton type="submit" label="저장" />
          </div>
        </div>
      </SForm>
    </div>
  );
}
```

### 4-4. 상세(조회) 페이지

구조: **페이지 헤더(제목) → 섹션별 `SSectionHeaderCard` + `SKeyValueTable` → 하단 버튼**

- 조회 값은 `type: 'text'` 행으로 표시한다. **상태·분류 태그도 별도 영역이 아니라 표의 한 행**으로 넣는다 (`render` 에 `STag`).
- 행이 많아지면 **유형별로 섹션을 나누고, 각 섹션을 `SSectionHeaderCard` 로 감싼다.**
  합성 컴포넌트라 `SSectionHeaderCard.Header` / `SSectionHeaderCard.Body` 를 자식으로 쓴다.
- **수정·삭제 버튼은 하단에 둔다.** 내용이 짧아 우측 상단에 두는 변형도 있으나 기본은 하단이다.

```tsx
import {
  SSectionHeaderCard, SKeyValueTable, SButton, STag, type SKeyValueField,
} from 'sellmate-design-system-react';

const basicFields: SKeyValueField[][] = [
  [
    { name: 'code', label: '상품코드', type: 'text' },
    { name: 'createdAt', label: '등록일', type: 'text' },
  ],
  [
    { name: 'category', label: '카테고리', type: 'text' },
    // 상태 태그도 표의 한 행으로 표현한다
    { name: 'status', label: '상태',
      render: <STag size="sm" color="green" label="판매중" /> },
  ],
];

const priceFields: SKeyValueField[][] = [
  [
    { name: 'price', label: '판매가', type: 'text' },
    { name: 'cost', label: '원가', type: 'text' },
  ],
];

export default function ProductDetailPage() {
  return (
    <div className="flex flex-col gap-sd-12">
      <h1 className="typo-heading-lg m-0">클래식 셔츠</h1>

      <SSectionHeaderCard>
        <SSectionHeaderCard.Header title="기본 정보" marker thickness="accent" />
        <SSectionHeaderCard.Body>
          <SKeyValueTable fields={basicFields} values={product} />
        </SSectionHeaderCard.Body>
      </SSectionHeaderCard>

      <SSectionHeaderCard>
        {/* 헤더 우측에 액션이 필요하면 slot 을 쓴다 */}
        <SSectionHeaderCard.Header
          title="가격 정보"
          marker
          helpText={['부가세 포함 금액입니다.']}
          slot={<SButton size="sm" color="secondary" label="이력" onClick={openHistory} />}
        />
        <SSectionHeaderCard.Body>
          <SKeyValueTable fields={priceFields} values={product} />
        </SSectionHeaderCard.Body>
      </SSectionHeaderCard>

      {/* 액션은 하단 — 목록(되돌리기)은 왼쪽 끝, 실행 액션은 오른쪽 끝 */}
      <div className="flex items-center justify-between">
        <SButton color="neutral" outline label="목록" onClick={goList} />
        <div className="flex items-center gap-sd-8">
          <SButton color="danger" outline label="삭제" onClick={confirmDelete} />
          <SButton label="수정" onClick={goEdit} />
        </div>
      </div>
    </div>
  );
}
```

### 4-5. 섹션 카드 — SSectionHeaderCard

폼 페이지에서도 입력 항목이 많으면 유형별로 `SSectionHeaderCard` 로 나눈다. 주요 옵션:

| Prop (Header) | 용도 |
| --- | --- |
| `title` | 섹션 제목 (필수) |
| `marker` | 제목 앞 점 표시 |
| `required` | 제목 뒤 필수(\*) 표시 — 필수 입력 섹션에 |
| `helpText` | 도움말 툴팁 (`string[]`) |
| `subtitle` | 부제 |
| `slot` | 헤더 우측 영역 (버튼 등) |
| `thickness` | 상단 강조선 — `false`(기본) / `'default'` / `'accent'` |

| Prop (Body) | 용도 |
| --- | --- |
| `padding` | 안쪽 여백 — `'default'`(기본) / `'wide'` / `'none'`. 판정은 §2-2 "섹션·패널 안쪽 여백". `p-sd-*` 를 직접 주지 않는다 |

---

## 5. 자가 점검 체크리스트

페이지를 완성하면 다음을 확인한다. 하나라도 어기면 수정 후 완료를 보고한다.

- [ ] 생 HTML 컨트롤(`<button>` `<input>` `<select>` `<table>` …)이 없는가
- [ ] `text-[14px]`, `bg-[#...]` 같은 리터럴 임의 값이 없는가 (`var(--sys-*)` 참조는 허용)
- [ ] 텍스트에 `typo-*` 프리셋을 썼는가
- [ ] 간격이 전부 `sd-` 접두 스케일 값인가 (`gap-13`·`gap-sd-13` ❌ → `gap-sd-12` ✅)
- [ ] 본문이 12px(`typo-body-sm-default`)인가 (14px 본문 ❌)
- [ ] 텍스트 회색 위계를 순차 적용했는가 (기본 → `text-fg-secondary` → `text-fg-tertiary`, 단계 건너뛰기 ❌)
- [ ] `SPage`·`SPopup` 의 기본 패딩을 `p-sd-*` 로 덮어쓰지 않았는가, 블록·섹션 **간격**이 `gap-sd-12` 인가 (`gap-sd-16`/`gap-sd-24` ❌ — 24 는 안쪽 여백에만 열린다)
- [ ] 섹션·패널의 안쪽 **여백**이 §2-2 판정과 맞는가 (덩어리 두 종류 → 16 / 세 종류 이상 → 24, 서면 16)
- [ ] `SSectionHeaderCard.Body` 의 여백을 `p-sd-*` 가 아니라 `padding` prop 으로 줬는가
- [ ] 자체 스크롤하는 패널의 하단에 `pb-[var(--cmp-pageBody-padding-scrollEnd)]` 이 있는가, 페이지네이션 있는 목록에서 `scrollEndSpacing={false}` 를 넘겼는가
- [ ] 같은 컴포넌트를 나열할 때 §2-2 그룹 간격을 썼는가 (체크박스 가로 `gap-sd-24` 등)
- [ ] 페이지가 §4의 표준 골격에서 시작했는가
- [ ] 앱 셸이나 그 바깥에 `min-width`·`overflow-x` 를 직접 걸지 않았는가 (최소 너비는 `SLayout` 이 보장한다, §4-1)
- [ ] 필터·폼·상세 정보를 `SKeyValueTable` 로 만들었는가 (컨트롤을 `div` 로 나열하지 않았는가)
- [ ] 섹션 구분에 `SSectionHeaderCard` 를 썼는가 (직접 만든 카드가 아니라)
- [ ] 목록의 주요 액션이 `STableBar` 의 `rightActions` 에 있는가 (페이지 제목 줄이 아니라)
- [ ] 상태 표시에 `STag size="sm"` 을 썼는가
- [ ] 테이블에서 양을 나타내는 컬럼(금액·수량·개수 등)이 전부 `align: 'right'` 인가
- [ ] 번호·코드·전화번호·일자 컬럼에 `align: 'center'` 를 **명시**했는가 (생략하면 좌측이 된다)
- [ ] 컨트롤(`STag`·`SButton`·`SSelect`·`SInput` …)이 들어가는 컬럼에 `width` 를 명시했는가, `resizable` 이면 `minWidth` 도 줬는가 (§3-4 — 폭이 모자라면 요소가 잘려 못 쓴다)
- [ ] 금액·수량 등 양을 나타내는 숫자에 빠짐없이 `toLocaleString()` 을 썼는가 (번호·코드는 제외)
- [ ] 하단 버튼이 양끝 분리(`justify-between`)이고, 되돌리기가 왼쪽 끝 · 실행이 오른쪽 끝인가
- [ ] 페이지에 `color="primary"` 채움 버튼이 **1개뿐**인가 (`danger` 채움도 1개, `SDropdownButton` 포함)
- [ ] 버튼 `size` 가 위치 규칙과 맞는가 (행 내부 `xs` / 화면 액션 `sm` / 모달 푸터 `md`)
- [ ] 파괴 액션의 채움/outline 이 **적용 범위 + 위험도** 기준과 맞는가
- [ ] `SGhostButton` 이 아이콘만으로 뜻이 통하는 부가 위치(표·모달 헤더·카드)에만 쓰였고, `ariaLabel` 이 전부 있는가 (라벨이 필요하면 `SButton`)
- [ ] `SGhostButton` 의 `intent` 가 조작 성격과 맞는가 (되돌릴 수 없는 삭제만 `danger`, 진입·추가는 `action`, 나머지는 `default`)
- [ ] 창을 띄울 때 §3-3-1 판별 순서를 따랐는가 (그 자체가 화면 → `SPopup` / 실행 여부만 확정 → `SModal.confirm` / 모달 안에서 작성 → `SActionModal`)
- [ ] 작업용 모달을 `SActionModal` + `SModal.create` 로 만들었는가 (직접 오버레이 ❌)
- [ ] 모달·드로어의 하단 버튼을 본문이 아니라 `button` · `footerLeft` prop 으로 넘겼는가 (§3-3-4)
- [ ] 앱 부트스트랩의 Provider 안쪽에 `<SModalOutlet />` 이 한 번 렌더되어 있는가 (§4-1 — 없으면 모달 안에서 앱 훅이 죽는다), 그 대신으로 모달 컴포넌트를 Provider 로 다시 감싸지 않았는가
- [ ] 고른 컴포넌트를 §2-0 의 제 층에 놓았는가 (요소를 `SPage` 에 직접 놓지 않았는가, 블록을 `div` 로 감싸지 않았는가)
- [ ] §2-0 포함 규칙을 지켰는가 (카드 안 카드 ❌, 표 셀 안 블록 ❌)
- [ ] 블록 순서가 §2-0 순서와 맞는가 (제목 → 안내 → 필터 → 툴바 → 본문 → 페이지네이션 → 하단 액션)
- [ ] 제목 위계가 층을 따라갔는가 (18 → 14 → 12, 건너뛰기 ❌), 섹션 제목 타이포를 `SSectionHeaderCard` 위에 덧씌우지 않았는가
- [ ] 화면 요소마다 §3-0 라우팅에서 컴포넌트를 골랐는가 (직접 만들거나 비슷한 것으로 대체하지 않았는가)
- [ ] §3-0 의 "갈림" 열이 가리킨 판별 절을 읽고 골랐는가 (§3-7-2 선택 컨트롤, §3-7-3 켜고 끄기 등)
- [ ] 상태 표시·알림·확인 다이얼로그가 §3의 선택 규칙을 따르는가

---

## 6. 린트로 강제되는 규칙

위 규칙 중 일부는 소비 앱의 ESLint 로 검출된다 (`sellmate-design-system-react/eslint`).
**코드를 넘기기 전에 린트를 통과시킨다.**

**error 는 "지키지 않으면 깨지는 것" 뿐이다.** 이 문서의 나머지 규칙은 warn 이거나 꺼져 있다 —
경고가 떠도 빌드는 통과하지만, 그렇다고 무시하라는 뜻은 아니다. 위 규칙을 지켜 작성한다.

| 규칙 | 기본 | 잡히는 것 |
| --- | --- | --- |
| `sellmate/no-off-scale-spacing` | **error** | §2-2 스케일 밖 간격 (`gap-sd-13`) — 스타일이 아예 안 생긴다 |
| `sellmate/no-raw-html-control` | **error** | §1-1 생 HTML 컨트롤, `alert()`/`confirm()` |
| `sellmate/prefer-typo-preset` | warn | §1-3 낱개 폰트 조합 (`text-14 font-bold`) |
| `sellmate/component-group-gap` | warn | §2-2 컴포넌트 그룹 간격 (체크박스 가로 24 / 세로 8 등) |
| `sellmate/table-numeric-align` | warn | §3-4 숫자 컬럼의 `align: 'right'` 누락 (`--fix` 지원) |
| `sellmate/require-locale-number` | warn | §1-4 금액·수량 등 수량 컬럼의 `toLocaleString()` 누락 |
| `sellmate/no-arbitrary-class` | off | §1-2 토큰 있는 속성의 임의 값 (`text-[14px]`, `bg-[#eee]`) — 팀이 켤 때만 |

`configs.strict` 를 쓰는 프로젝트는 전부 error 이고 간격 `sd-` 접두까지 강제된다.

`gap-sd-13` 처럼 스케일 밖 값은 Tailwind v4 에서 **에러 없이 조용히 무시된다**. 반대로 접두를 빠뜨린 `gap-8` 은 Tailwind 기본 스케일이라 **32px 이 적용된다**. 둘 다 "간격이 왜 이러지" 로만 보이므로 반드시 `sd-` 접두 스케일 값을 쓴다. (린트는 기본값에서 앞쪽만 잡는다 — 뒤쪽까지 강제하려면 `requirePrefix: true`)

접두 누락은 **자동 수정되지 않는다.** `p-16` 은 지금 64px 로 동작하는 코드라 `p-sd-16`(16px)으로 바꾸면 여백이 달라지기 때문이다. 린트는 "숫자를 px 로 의도했나(`p-sd-16`)" 와 "현재 값을 유지할 건가(스케일에 있으면 그 값)" 를 제안으로 내놓으니 **의도에 맞는 쪽을 골라 적용한다.**

린트가 잡지 못하는 것(§3 컴포넌트 선택, §4 페이지 골격, 버튼 배치)은 §5 체크리스트로 직접 확인한다.
