@xonic0506/sonic-ui 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -142,6 +142,23 @@ import { Plus } from 'lucide-react';
142
142
  <Button size="icon-md" variant="ghost" aria-label="더 보기"><Ellipsis /></Button>
143
143
  ```
144
144
 
145
+ ### 3.7 일러스트 (캐릭터)
146
+
147
+ 빈 상태, 불러오기 실패, 에러 페이지에는 Sonic 캐릭터 일러스트를 써요. Figma에서는 Foundations › 05 Illustration, `Source illustrations`에 `Illustration / <이름>` 컴포넌트로 있어요.
148
+
149
+ | 이름 | 표시 크기 | 쓰는 곳 |
150
+ |---|---|---|
151
+ | `empty` | 120 | EmptyState 기본 |
152
+ | `error` | 120 | ErrorState 기본 |
153
+ | `error-404` · `error-403` · `error-500` | 320 | ErrorPage |
154
+
155
+ - **색 토큰 규칙 밖의 이미지 자산이에요.** 캐릭터의 주황은 화면 강조색이 아니라 그림의 일부라서 §1 "강조는 한 곳에만"과 섞지 않아요. 그래서 위 세 곳 말고는 쓰지 않아요(버튼, 카드 장식, 배너 X).
156
+ - 배경이 투명해서 Light/Dark에 같은 그림을 써요.
157
+ - 장식이라 `alt=""`로 숨겨요. 무엇이 없는지, 무엇이 실패했는지는 제목과 설명이 전해요.
158
+ - 이미지는 패키지에 webp 파일로 들어 있어요(`dist/illustrations`, 표시 크기의 2배). 쓰는 쪽은 설정 없이 Vite와 Next.js가 자산으로 처리해요.
159
+ - Popover, Select 안처럼 좁은 곳에서는 `icon`을 넘겨 아이콘으로 바꿔요(§10 EmptyState).
160
+ - 추가할 때: 같은 캐릭터, 투명 배경, 표시 크기 2배로 만들어 Figma `Source illustrations`와 `src/illustrations`에 함께 넣어요.
161
+
145
162
  ## 4. Auto layout 규칙 (Figma)
146
163
 
147
164
  - 자식이 둘 이상인 프레임은 모두 auto layout을 씁니다. 절대 좌표 배치는 쓰지 않아요.
@@ -156,6 +173,7 @@ import { Plus } from 'lucide-react';
156
173
  | 위치 | 값 |
157
174
  |---|---|
158
175
  | 페이지 콘텐츠 | 최대 너비 960, 가운데 정렬. 좌우 padding `space/4`(960 안쪽), 위아래 padding `space/10` |
176
+ | 사이드 내비가 있는 화면 | 헤더와 사이드 내비(너비 240)는 화면 전체를 쓰고, 960 규칙은 본문 영역에만 적용 |
159
177
  | 섹션 사이 | `space/6` |
160
178
  | Card 안쪽 padding / Header·Body·Footer 사이 | `space/5` / `space/4` |
161
179
  | 폼 필드 사이 | `space/4` |
@@ -211,7 +229,7 @@ hover, focus는 **컴포넌트 variant로 만들지 않아요.** React prop이
211
229
  | 상태 | Figma | 코드 |
212
230
  |---|---|---|
213
231
  | hover | Core 문서 "Interaction states" 매트릭스에 인스턴스 오버라이드로 그림 | `:hover` (disabled일 때는 적용 안 함) |
214
- | focus | 같은 매트릭스. 2px `border/focus` 링, 간격 2px. invalid Input은 `border/danger` 링 | `:focus-visible` → `outline 2px`, `outline-offset 2px` (Tab만 안쪽 −2px). 포커스 링을 그리는 요소에는 `outline-none`을 같이 쓰지 않아요(Tailwind v4에서 링이 사라져요) |
232
+ | focus | 같은 매트릭스. 2px `border/focus` 링, 간격 2px. invalid Input은 `border/danger` 링 | `:focus-visible` → `outline 2px`, `outline-offset 2px` (Tab, ToggleGroup 항목만 안쪽 −2px). 포커스 링을 그리는 요소에는 `outline-none`을 같이 쓰지 않아요(Tailwind v4에서 링이 사라져요) |
215
233
  | press | 그리지 않음 | 구현 안 함 (v0.1) |
216
234
 
217
235
  상태별 토큰은 §10 컴포넌트 스펙 표가 기준이에요. 화면 디자인에서는 hover, focus를 따로 그리지 않아요. 컴포넌트가 자동으로 처리해요.
@@ -220,11 +238,11 @@ hover, focus는 **컴포넌트 variant로 만들지 않아요.** React prop이
220
238
 
221
239
  | 계층 | 위치 | 기준 | 예 |
222
240
  |---|---|---|---|
223
- | **Core** | sonic-ui `src/components/` | 더 나눌 수 없고, 도메인을 전혀 모름 | Button, Input, Textarea, Checkbox, RadioGroup, Switch, Select, Text, Card, Avatar, Dialog, AlertDialog, Menu, Popover, Tooltip, Tabs, Separator, Toast, Badge, Progress, Field, Skeleton, EmptyState, ErrorState |
241
+ | **Core** | sonic-ui `src/components/` | 더 나눌 수 없고, 도메인을 전혀 모름 | Button, Input, Textarea, Checkbox, RadioGroup, Switch, Select, Text, Card, Avatar, Dialog, AlertDialog, Menu, Popover, Tooltip, Tabs, Separator, Toast, Badge, Progress, Link, Table, ToggleGroup, Accordion, FileDropzone, FileList, Field, Skeleton, EmptyState, ErrorState, ErrorPage |
224
242
  | **Pattern** | sonic-ui `src/patterns/` | Core 조합이고 도메인을 모르지만, **두 프로젝트 이상에서 같은 형태로 복사됨** | (아직 없음) |
225
243
  | **Domain** | 각 프로젝트 | 도메인 타입, API, 비즈니스 로직 중 하나라도 앎 | `UserProfileCard`, `ApiKeyList` |
226
244
 
227
- 예외: EmptyState와 ErrorState는 Core 조합이라 원래는 Pattern 후보지만, §8 규칙이 모든 데이터 화면에 요구하는 **시스템 컴포넌트**라서 Core에 둬요.
245
+ 예외: EmptyState, ErrorState, ErrorPage는 Core 조합이라 원래는 Pattern 후보지만, §8 규칙과 모든 프로젝트의 에러 화면이 요구하는 **시스템 컴포넌트**라서 Core에 둬요.
228
246
 
229
247
  판단 순서:
230
248
  1. props에 도메인 타입(`User`, `Order`)이 들어가나요? → **Domain**
@@ -406,7 +424,7 @@ const retryRef = useRef<HTMLButtonElement>(null);
406
424
  | `description` | 설명 문구 (선택) |
407
425
  | `error` | 에러 문구. `invalid`일 때만 보이고, 그때는 **설명 자리를 대신해요** |
408
426
  | `invalid`, `disabled` | boolean. 안의 Input에 자동으로 전달돼요 (Base UI Field) |
409
- | `children` | 입력 컨트롤 (지금은 `Input`) |
427
+ | `children` | 입력 컨트롤 (`Input`, `Textarea`, `Select`, `FileDropzone`) |
410
428
 
411
429
  간격: 라벨 ↔ 입력 ↔ 설명·에러 `space/2`. 라벨 `label` + `text/default`(invalid여도 그대로), 설명 `caption` + `text/muted`, 에러 `caption` + `text/danger`. **Field 안의 Input에는 `invalid`를 따로 주지 않아요.** 입력 컨트롤과 설명 또는 에러는 `aria-describedby`로 연결돼요.
412
430
 
@@ -427,19 +445,49 @@ const retryRef = useRef<HTMLButtonElement>(null);
427
445
  ### EmptyState / ErrorState
428
446
 
429
447
  ```tsx
430
- <EmptyState icon={<Inbox />} title="API 키가 없어요" description="키를 만들면 여기에서 사용량을 볼 수 있어요." action={<Button>API 키 만들기</Button>} />
448
+ <EmptyState title="API 키가 없어요" description="키를 만들면 여기에서 사용량을 볼 수 있어요." action={<Button>API 키 만들기</Button>} />
431
449
  <ErrorState title="API 키를 불러오지 못했어요" description="네트워크 연결을 확인하고 다시 시도해 주세요." onRetry={refetch} />
450
+ <EmptyState icon={<Inbox />} title="결과가 없어요" /> // 좁은 곳: 아이콘
432
451
  ```
433
452
 
434
453
  | | EmptyState | ErrorState |
435
454
  |---|---|---|
436
- | 아이콘 | `icon` prop (24, `text/muted`, `bg/subtle` 원 `space/12`) | 기본 `triangle-alert` (`text/danger`), `icon`으로 바꿀 수 있음 |
455
+ | 그림 (기본) | 캐릭터 `empty` 120 (§3.7) | 캐릭터 `error` 120 |
456
+ | 아이콘 (`icon`을 넘기면) | 24, `text/muted`, `bg/subtle` 원 `space/12` | 24, `text/danger` (예: `triangle-alert`), 원은 같음 |
437
457
  | 제목 / 설명 | `body-strong` / `body` + `text/muted`, 가운데 정렬 | 같음 |
438
458
  | 행동 | `action` (보통 primary Button 하나) | `onRetry`가 있으면 secondary Button + `refresh-cw`, 문구 `retryLabel`(기본 "다시 시도") |
439
459
  | 간격 | 요소 사이 `space/4`, 위아래 `space/12`, 좌우 `space/6` | 같음 |
440
460
 
441
461
  에러 문구는 사과하지 않고, 무엇이 실패했고 어떻게 하면 되는지 써요.
442
462
 
463
+ 그림(120)은 카드나 표 안의 빈 상태·에러에 써요. Popover, Select, 좁은 사이드 패널처럼 공간이 작으면 `icon`을 넘겨요. 그림이 있으면 정상 화면보다 높이가 커질 수 있으니, 4가지 상태를 그릴 때 영역 높이를 함께 확인해요.
464
+
465
+ ### ErrorPage
466
+
467
+ ```tsx
468
+ // Next.js app/not-found.tsx
469
+ export default function NotFound() {
470
+ return <ErrorPage status={404} action={<Button render={<NextLink href="/" />}>홈으로 가기</Button>} />;
471
+ }
472
+ ```
473
+
474
+ | prop | 값 | 기본값 |
475
+ |---|---|---|
476
+ | `status` | `404` · `403` · `500` (필수) | — |
477
+ | `title`, `description` | 바꿀 문구 | 상태별 문구 (아래) |
478
+ | `action` | primary Button 하나 (상태별 권장은 아래 표) | 없음 |
479
+
480
+ | status | 그림 | 제목 | 설명 | 권장 action |
481
+ |---|---|---|---|---|
482
+ | 404 | `error-404` | 페이지를 찾을 수 없어요 | 주소가 바뀌었거나 삭제된 페이지예요. 주소를 확인해 주세요. | 홈으로 가기 |
483
+ | 403 | `error-403` | 이 페이지를 볼 권한이 없어요 | 관리자에게 권한을 요청해 주세요. | 홈으로 가기 |
484
+ | 500 | `error-500` | 서버에 문제가 생겼어요 | 잠시 뒤 다시 시도해 주세요. | `refresh-cw` + 다시 시도 (페이지 다시 불러오기) |
485
+
486
+ - 화면 전체를 쓰는 에러예요. 카드 하나가 실패하면 ErrorState, 페이지 자체를 보여 줄 수 없을 때 ErrorPage를 써요.
487
+ - 그림 320(§3.7), 그 아래 오류 코드 `mono-caption` + `text/muted`("오류 코드 404", 문의할 때 쓰도록), 제목 `title`(페이지의 `h1`), 설명 `body` + `text/muted`, 가운데 정렬. 숫자는 그림 안에 있어서 크게 따로 쓰지 않아요.
488
+ - 너비는 부모를 채우고, 위아래 `space/16`, 좌우 `space/6`, 요소 사이 `space/6`(글자 묶음 안 `space/2`).
489
+ - 500은 일시적인 실패일 때가 많아서 홈으로 보내지 않고 그 자리에서 다시 불러오게 해요. Next.js `error.tsx`는 클라이언트 컴포넌트라 `reset`을 연결해요: `<ErrorPage status={500} action={<Button onClick={reset}><RefreshCw />다시 시도</Button>} />`. `reset`이 없으면 `location.reload()`를 써요.
490
+
443
491
  ### Textarea
444
492
 
445
493
  `<Textarea rows={3} invalid disabled />` — Input과 같은 테두리·hover·focus·invalid 규칙이에요. 최소 높이 80(약 3줄), padding `space/3`·`space/2`, 세로로만 크기를 바꿀 수 있어요. Field 안에서 라벨·에러와 함께 써요.
@@ -616,6 +664,150 @@ toast.add({ title: 'API 키를 만들었어요', description: '키는 지금 한
616
664
 
617
665
  라벨 `label` + `text/default`, 오른쪽에 백분율 `mono-caption` + `text/muted`, 막대와 사이 `space/2`. 트랙 높이 4(`space/1`) `bg/subtle` `radius/full`, 채움 `bg/primary`이 `duration/normal`로 늘어나요. 끝이 정해진 작업(업로드, 내보내기)에만 써요. 끝을 알 수 없는 기다림은 Button `loading`이나 Skeleton을 써요.
618
666
 
667
+ ### Link
668
+
669
+ ```tsx
670
+ <Link href="/docs">DESIGN.md 보기</Link>
671
+ <Link href="https://base-ui.com" external>Base UI 문서</Link>
672
+ <Link render={<NextLink href="/docs" />}>문서</Link> // Next.js Link와 함께
673
+ ```
674
+
675
+ | prop | 값 |
676
+ |---|---|
677
+ | `href` | 이동할 주소 |
678
+ | `external` | 새 탭으로 열고(`rel="noopener noreferrer"`) 오른쪽에 `external-link` 아이콘을 붙여요 |
679
+ | `render` | 다른 링크 컴포넌트로 렌더링 (예: Next.js `Link`) |
680
+
681
+ 다른 페이지나 문서로 **이동**할 때 써요. 동작을 실행하면 Button을 써요. 글자 크기는 부모를 따르고, 색은 `text/default` + 밑줄(offset 2px)이에요. 강조는 한 곳에만 쓰는 원칙(§1)에 따라 링크에 코발트를 쓰지 않아요. hover는 `text/muted`, 포커스 링은 공통 규칙(2px, offset 2px)이에요. 외부 링크 아이콘은 16, 글자와 간격 `space/1`이에요.
682
+
683
+ ### Table
684
+
685
+ ```tsx
686
+ <Table>
687
+ <TableHeader>
688
+ <TableRow><TableHead>이름</TableHead><TableHead align="end">요청 수</TableHead></TableRow>
689
+ </TableHeader>
690
+ <TableBody>
691
+ <TableRow><TableCell>production</TableCell><TableCell align="end">12,480</TableCell></TableRow>
692
+ </TableBody>
693
+ </Table>
694
+ ```
695
+
696
+ | prop | 값 |
697
+ |---|---|
698
+ | `TableHead`, `TableCell`의 `align` | `start` · `end` (숫자 열은 `end`) |
699
+
700
+ 여러 항목의 같은 속성을 비교할 때 써요. 행 높이 40(`space/10`), 좌우 padding `space/3`, 행 아래 1px `border/default`. 머리글 `label` + `text/muted`, 본문 `body` + `text/default`, `align="end"`인 본문 칸은 숫자라서 `mono`예요. 줄무늬나 hover 바탕은 쓰지 않아요. 표가 부모보다 넓으면 가로로 스크롤해요.
701
+
702
+ ### ToggleGroup
703
+
704
+ ```tsx
705
+ <ToggleGroup value={view} onValueChange={setView} aria-label="보기 방식"> // 단일: string
706
+ <ToggleGroupItem value="list">목록</ToggleGroupItem>
707
+ <ToggleGroupItem value="card">카드</ToggleGroupItem>
708
+ </ToggleGroup>
709
+ <ToggleGroup multiple value={filters} onValueChange={setFilters}>…</ToggleGroup> // 다중: string[]
710
+ <ToggleGroupItem value="dark" iconOnly aria-label="다크 테마"><Moon /></ToggleGroupItem> // 아이콘만이면 iconOnly + aria-label 필수
711
+ ```
712
+
713
+ | prop | 값 |
714
+ |---|---|
715
+ | `multiple` | 여러 개를 함께 누를 수 있음. 켜면 `value`·`onValueChange`가 배열이에요(타입으로 강제) |
716
+ | `value`, `defaultValue`, `onValueChange` | 단일은 `string`, 다중은 `string[]`. 단일은 항상 하나가 눌려 있어요(눌린 항목을 다시 눌러도 꺼지지 않음) |
717
+ | `disabled` | 그룹 전체 또는 항목 하나 |
718
+ | `ToggleGroupItem`의 `iconOnly` | 아이콘만 있는 24×24 항목. `aria-label`이 필수예요(타입으로 강제) |
719
+
720
+ 같은 대상을 **보는 방식**을 바꿀 때 써요(목록/카드, 라이트/다크). 누르면 바로 적용돼요. 폼에서 값을 고를 때는 RadioGroup이나 Select를 써요. 바탕 `bg/subtle` + 안쪽 `space/1` + `radius/md`(전체 높이 32). 항목 높이 24(`space/6`), 좌우 `space/2`, `label`, `radius/sm`. 눌린 항목 `bg/surface` + 1px `border/default` + `text/default`, 나머지 `text/muted`(hover `text/default`). 아이콘만 있는 항목은 24×24, 아이콘 16이에요. 화살표 키로 항목 사이를 이동해요. 항목끼리 붙어 있어서 포커스 링은 Tab처럼 **안쪽**(offset −2px)에 그려요.
721
+
722
+ ### Accordion
723
+
724
+ ```tsx
725
+ <Accordion defaultValue={['limit']}>
726
+ <AccordionItem value="limit">
727
+ <AccordionTrigger>키는 몇 개까지 만들 수 있나요?</AccordionTrigger>
728
+ <AccordionPanel>프로젝트마다 10개까지 만들 수 있어요.</AccordionPanel>
729
+ </AccordionItem>
730
+ </Accordion>
731
+ ```
732
+
733
+ | prop | 값 |
734
+ |---|---|
735
+ | `multiple` | 여러 항목을 함께 열 수 있음 (기본 `false`) |
736
+ | `value`, `defaultValue`, `onValueChange` | 열린 항목들의 `value` 배열 |
737
+ | `AccordionItem`의 `disabled` | 항목 하나를 비활성화 |
738
+
739
+ 긴 내용을 제목만 먼저 보여 주고 필요할 때 펼칠 때 써요(자주 묻는 질문, 문서 사이드바 그룹). 꼭 읽어야 하는 내용은 접어 두지 않아요. 항목 아래 1px `border/default`. 트리거는 위아래 `space/3` + `body-strong`, 오른쪽 `chevron-down` 16(`text/muted`)이 열리면 180° 돌아요. 패널은 `body` + `text/muted`, 아래 `space/4`이고, 높이가 `duration/normal`로 변해요. 닫힌 내용도 브라우저 페이지 찾기로 찾아 열 수 있어요.
740
+
741
+ ### FileDropzone / FileList
742
+
743
+ ```tsx
744
+ const [items, setItems] = useState<FileItemData[]>(serverFiles.map(toItem)); // 서버 파일도 같은 모양으로
745
+
746
+ <Field label="첨부 파일" description="PDF, 10MB 이하, 5개까지" error="파일을 1개 이상 올려 주세요." invalid={submitted && !items.length}>
747
+ <FileDropzone
748
+ multiple accept=".pdf" maxSize={10_000_000} maxFiles={5 - items.length}
749
+ onFilesChange={(accepted, rejected) => { addItems(accepted); upload(accepted); }} // 업로드는 프로젝트가 해요
750
+ />
751
+ </Field>
752
+ <FileList items={items} onRemove={remove} onRetry={retry} />
753
+ <FileList layout="grid" items={images} onRemove={remove} />
754
+ ```
755
+
756
+ 컴포넌트는 **고르기·검사·표시만** 해요. 실제 업로드(어느 API로, 어떤 인증으로), 진행률 계산, 재시도는 API를 아는 프로젝트가 하고, 그 결과를 `items`로 넘겨요(§7). 두 번째 프로젝트에서 같은 업로드 코드를 복사하게 되면 `useFileUpload` Pattern으로 올려요.
757
+
758
+ **FileDropzone**
759
+
760
+ | prop | 값 |
761
+ |---|---|
762
+ | `onFilesChange` | `(accepted: File[], rejected: FileRejection[]) => void` (필수) |
763
+ | `accept` | `.pdf`, `image/*`, `image/png` 같은 목록 (쉼표로 구분) |
764
+ | `multiple`, `maxFiles` | 여러 개 고르기와 최대 개수. `multiple`이 없으면 한 개만 받아요 |
765
+ | `maxSize` | 파일 하나의 최대 크기(바이트) |
766
+ | `variant` | `file` · `image` (아이콘과 기본 문구) |
767
+ | `title`, `description` | 영역 안 문구. 기본은 "파일을 끌어다 놓거나 눌러서 고르세요". Field 안에서는 조건을 Field `description`에 쓰고 영역 안 `description`은 비워요 |
768
+ | `invalid`, `disabled` | boolean |
769
+
770
+ - **다른 폼 항목처럼 Field로 감싸요.** Field의 라벨이 버튼 이름이 되고, 설명·에러·`invalid`·`disabled`가 자동으로 연결돼요(Base UI `Field.Control`). Field 없이 쓰면 영역 안 제목이 버튼 이름이 돼요.
771
+ - 에러는 두 종류예요. 고르는 순간 조건에 맞지 않은 파일은 **영역 안**에 바로 알려 주고, "1개 이상 올려 주세요"처럼 제출할 때 확인하는 규칙은 Field `error`로 **아래**에 보여 줘요.
772
+ - 영역 전체가 버튼이라 눌러도, Enter·Space를 눌러도 파일 선택 창이 열려요. 파일을 끌어다 놓아도 돼요.
773
+ - 조건(형식 `type`, 크기 `size`, 개수 `count`)에 맞지 않는 파일은 `rejected`로 넘기고, 영역 안에 고치는 방법을 기본 문구로 보여 줘요(예: "계약서.pdf: 10MB보다 큰 파일은 올릴 수 없어요"). 문구는 `rejected[].message`로 바꿀 수 있어요.
774
+ - 점선 1px `border/strong` + `bg/surface` + `radius/lg`, 위아래 `space/6`, 좌우 `space/4`, 요소 사이 `space/2`. 아이콘 24(`upload` · `image`, `text/muted`), 제목 `body-strong`, 조건 `caption` + `text/muted`. hover `bg/subtle`, 끌어다 놓는 중 `border/focus` + `bg/subtle`, 거부되면 `border/danger` + 문구 `text/danger`.
775
+
776
+ **FileList**
777
+
778
+ | prop | 값 |
779
+ |---|---|
780
+ | `items` | `FileItemData[]` (아래) |
781
+ | `layout` | `list` (일반 파일) · `grid` (이미지 썸네일) |
782
+ | `onRemove`, `onRetry` | `(id) => void`. 넘기면 삭제·다시 시도 버튼이 생겨요 |
783
+
784
+ ```ts
785
+ interface FileItemData {
786
+ id: string;
787
+ name: string;
788
+ size?: number; // 바이트, "2.4 MB"로 표시
789
+ type?: string; // MIME, 이미지 여부 판단
790
+ status: 'uploading' | 'done' | 'error';
791
+ progress?: number; // 0~100, uploading일 때 Progress
792
+ error?: string; // error일 때 문구 (고치는 방법 포함)
793
+ url?: string; // done 파일을 열 주소 → 이름이 Link
794
+ previewUrl?: string; // 썸네일 주소
795
+ file?: File; // 방금 고른 로컬 파일
796
+ }
797
+ ```
798
+
799
+ 미리보기는 `previewUrl` 문자열 하나로 받아요.
800
+
801
+ | 출처 | `previewUrl` | 비고 |
802
+ |---|---|---|
803
+ | 서버 이미지 | `https://…/thumb.jpg` | 서버가 썸네일 주소를 따로 주는 게 가장 가벼워요 |
804
+ | 서버가 base64로 줌 | `data:image/png;base64,…` | 그대로 표시돼요. 응답이 커지니 썸네일 크기만 권장 |
805
+ | 방금 고른 파일 | 넣지 않고 `file`만 넘김 | 컴포넌트가 blob URL을 만들고, 목록에서 빠지면 해제해요 |
806
+ | 인증이 필요한 파일 | 프로젝트가 받아 만든 blob URL | `<img>`는 인증 헤더를 붙일 수 없어요 |
807
+
808
+ - `list`: `bg/surface` + 1px `border/default` + `radius/md`, padding·간격 `space/3`, 항목 사이 `space/2`. 아이콘 칸 32(`bg/subtle`, `file` 16), 이름 `body`(완료이고 `url`이 있으면 Link), 크기·진행률 `mono-caption` + `text/muted`. 업로드 중에는 라벨 없는 Progress, 에러면 `border/danger` + `caption` + `text/danger`. 버튼은 ghost `icon-sm`(`refresh-cw` 다시 시도, `x` 삭제).
809
+ - `grid`: 썸네일 96×96(`bg/subtle`, 1px `border/default`, `radius/md`, 안쪽 `space/1`), 항목 사이 `space/3`. 오른쪽 위 secondary `icon-sm` 버튼, 아래 Progress, 아래 이름 `caption` 한 줄 말줄임. 에러면 `border/danger` + `circle-alert`.
810
+
619
811
  ## 11. Figma ↔ 코드 매핑 (Code Connect 대체)
620
812
 
621
813
  Sonic 팀은 Figma Pro 요금제라 Code Connect(Organization 이상)를 쓸 수 없어요. 아래 표로 대신합니다. Figma 컴포넌트를 코드로 옮길 때는 이 표의 컴포넌트를 그대로 써요.
@@ -631,8 +823,9 @@ Sonic 팀은 Figma Pro 요금제라 Code Connect(Organization 이상)를 쓸 수
631
823
  | Dialog | `6:13` | `Dialog`, `DialogTrigger`, `DialogPopup`, `DialogTitle`, `DialogDescription`, `DialogClose`, `DialogFooter` | `src/components/dialog.tsx` |
632
824
  | Field | `31:1543` | `<Field label description error invalid disabled>` + `<Input />` | `src/components/field.tsx` |
633
825
  | Skeleton | `31:1547` | `<Skeleton shape>` | `src/components/skeleton.tsx` |
634
- | EmptyState | `31:1548` | `<EmptyState icon title description action>` | `src/components/empty-state.tsx` |
635
- | ErrorState | `31:1561` | `<ErrorState icon title description onRetry retryLabel>` | `src/components/error-state.tsx` |
826
+ | EmptyState | `92:5905` | `<EmptyState icon title description action>` (`media=illustration`이 기본, `media=icon` → `icon` prop) | `src/components/empty-state.tsx` |
827
+ | ErrorState | `92:5919` | `<ErrorState icon title description onRetry retryLabel>` (`media`는 EmptyState와 같음) | `src/components/error-state.tsx` |
828
+ | ErrorPage | `92:5953` | `<ErrorPage status title description action>` (`status` → 같은 prop, `action` → 넘길지) | `src/components/error-page.tsx` |
636
829
  | Textarea | `44:2239` | `<Textarea invalid disabled placeholder>` | `src/components/textarea.tsx` |
637
830
  | Checkbox | `44:2266` | `<Checkbox checked indeterminate disabled>` (`checked=indeterminate` → `indeterminate`) | `src/components/checkbox.tsx` |
638
831
  | Radio | `44:2281` | `<RadioGroup label>` + `<Radio value disabled>` | `src/components/radio-group.tsx` |
@@ -647,6 +840,12 @@ Sonic 팀은 Figma Pro 요금제라 Code Connect(Organization 이상)를 쓸 수
647
840
  | Toast | `57:3549` | `useToast().add({ tone, title, description })` (`showDescription` → `description`을 넘길지), 앱에는 `<ToastProvider>` | `src/components/toast.tsx` |
648
841
  | Badge | `57:3498` | `<Badge tone variant>` (`children` → 자식 문구) | `src/components/badge.tsx` |
649
842
  | Progress | `58:5093` | `<Progress value max label>` (`value` variant는 채움 미리보기, `value text`는 코드가 자동 표시) | `src/components/progress.tsx` |
650
- | Icon (32개) | Foundations `Source icons` | `lucide-react`의 같은 이름 컴포넌트 (`Icon / chevron-down` → `<ChevronDown />`) | 쓰는 쪽에서 설치 |
843
+ | Link | `65:3733` | `<Link href external>` (`children` → 자식 문구) | `src/components/link.tsx` |
844
+ | Table Cell · Table | `65:3742` · `65:3743` | `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell` (`type=header` → `TableHead`, `align` → 같은 prop) | `src/components/table.tsx` |
845
+ | Toggle Item · Toggle Group | `66:3760` · `66:3761` | `<ToggleGroup multiple>` + `<ToggleGroupItem value disabled>` (`pressed` → 현재 값, `content=icon`이면 `aria-label`) | `src/components/toggle-group.tsx` |
846
+ | Accordion Item · Accordion | `66:3792` · `66:3793` | `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionPanel` (`open` → 현재 값, `title`/`content` → 자식) | `src/components/accordion.tsx` |
847
+ | File Dropzone · File Item | `71:4191` · `72:4251` | `<FileDropzone variant accept multiple maxSize maxFiles onFilesChange>` (`state` → 끌어다 놓기·거부·`disabled`), `<FileList layout items onRemove onRetry>` (`status` → `items[].status`) | `src/components/file-upload.tsx` |
848
+ | Illustration (5개) | Foundations `Source illustrations` | 코드에서 직접 쓰지 않음. EmptyState·ErrorState·ErrorPage 안에 들어 있어요(§3.7) | `src/illustrations/*.webp` |
849
+ | Icon (35개) | Foundations `Source icons` | `lucide-react`의 같은 이름 컴포넌트 (`Icon / chevron-down` → `<ChevronDown />`) | 쓰는 쪽에서 설치 |
651
850
 
652
851
  Figma 속성 → props 변환 규칙: variant 속성은 같은 이름의 prop으로, 텍스트 속성 `children`은 자식으로, boolean `header`/`footer`는 해당 하위 컴포넌트를 넣을지 말지로 옮겨요. Button의 `iconLeft`/`iconRight`(+ `leftIcon`/`rightIcon` 교체)와 Button Icon의 `icon`은 prop이 아니라 **자식 순서**예요: `<Button><Plus />추가하기<ChevronDown /></Button>`.
package/README.md CHANGED
@@ -1,65 +1,95 @@
1
- # @xonic0506/sonic-ui
1
+ <div align="center">
2
2
 
3
- Figma를 원본으로 하는 개인 디자인 시스템이에요. React 컴포넌트는 [Base UI](https://base-ui.com) 위에 만들었고, CSS는 Tailwind v4로 미리 빌드해서 배포해요.
3
+ <img src="./docs/assets/sonic-mascot.webp" alt="Sonic 마스코트" width="240" />
4
4
 
5
- - 디자인 규칙: [DESIGN.md](./DESIGN.md)
6
- - AI 작업 규칙: [AI-RULES.md](./AI-RULES.md)
7
- - Figma: [Sonic Design System](https://www.figma.com/design/XifnZsjqJWJqFmAmrM6p3u)
5
+ # Sonic UI
8
6
 
9
- ## 설치
7
+ [![npm version](https://img.shields.io/npm/v/@xonic0506/sonic-ui.svg)](https://www.npmjs.com/package/@xonic0506/sonic-ui) ![React](https://img.shields.io/badge/react-18.2%20%7C%2019-61dafb.svg) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
+
9
+ Figma를 원본으로 하는 개인 디자인 시스템이에요.
10
+
11
+ [Figma](https://www.figma.com/design/XifnZsjqJWJqFmAmrM6p3u) | [디자인 규칙 (DESIGN.md)](./DESIGN.md) | [AI 작업 규칙 (AI-RULES.md)](./AI-RULES.md)
12
+
13
+ </div>
14
+
15
+ Sonic UI는 모든 사이드 프로젝트에서 같은 디자인을 쓰기 위해 만들었어요. 디자인 룰을 Figma와 DESIGN.md에 정해 두면, AI가 그 룰로 Figma 디자인을 만들고 이 패키지의 컴포넌트로 코드를 짜요.
16
+
17
+ ```text
18
+ Figma (원본) → DESIGN.md (룰) → @xonic0506/sonic-ui (코드) → 실제 프로젝트
19
+ ```
20
+
21
+ ## 특징
22
+
23
+ - Figma Variables가 그대로 CSS 변수가 돼요. `color/bg/primary`는 `--sn-color-bg-primary`이고, light/dark 테마를 함께 가져요.
24
+ - CSS를 미리 빌드해서 배포해요. 쓰는 쪽 프로젝트는 Tailwind가 있어도 되고 없어도 돼요.
25
+ - 모든 클래스에 `sn:` 접두사를 붙이고 `@layer sonic` 안에 넣어서, 쓰는 쪽 스타일과 부딪히지 않아요.
26
+ - [Base UI](https://base-ui.com) 위에 만들어서 키보드 조작, 포커스 관리, 화면 낭독기 지원이 기본으로 들어 있어요.
27
+ - 모든 파일에 `"use client"`가 붙어 있어서 Next.js 서버 컴포넌트에서도 바로 import할 수 있어요.
28
+ - 쓰는 쪽 AI가 읽을 규칙 문서(DESIGN.md, AI-RULES.md)와 디자인 lint 규칙을 함께 배포해요.
29
+
30
+ ## 컴포넌트
31
+
32
+ | 분류 | 컴포넌트 |
33
+ | --- | --- |
34
+ | 기본 | Button, Input, Textarea, Text, Card, Avatar, Separator |
35
+ | 폼 | Field, Checkbox, CheckboxGroup, RadioGroup, Switch, Select (단일·다중) |
36
+ | 오버레이 | Dialog, AlertDialog, Menu, Popover, Tooltip |
37
+ | 탐색 | Tabs |
38
+ | 피드백·상태 | Toast, Badge, Progress, Skeleton, EmptyState, ErrorState, ErrorPage |
39
+
40
+ 컴포넌트별 props와 스펙은 [DESIGN.md §10](./DESIGN.md#10-컴포넌트-스펙-core)에 있어요.
41
+
42
+ ## 시작하기
43
+
44
+ ### 설치
10
45
 
11
46
  ```bash
12
47
  npm i @xonic0506/sonic-ui
13
48
  ```
14
49
 
15
- `react`, `react-dom` 18.2 이상(19 포함)이 필요해요(peerDependencies).
50
+ `react`, `react-dom` 18.2 이상(19 포함)이 필요해요.
16
51
 
17
52
  ### 글꼴
18
53
 
19
- 글꼴은 패키지에 들어 있지 않아요. IBM Plex Sans(400·500·600)와 IBM Plex Mono(400)를 프로젝트에서 불러와요. 없으면 시스템 글꼴로 대체돼요.
54
+ IBM Plex Sans(400, 500, 600)와 IBM Plex Mono(400)를 프로젝트에서 불러와요. 없으면 시스템 글꼴로 대체돼요.
20
55
 
21
56
  ```html
22
57
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
23
58
  ```
24
59
 
25
- sonic-ui는 글꼴 이름 `"IBM Plex Sans"`, `"IBM Plex Mono"`를 찾아요. 이름을 바꾸는 방식(예: `next/font`의 기본 설정)으로 불러오면 적용되지 않으니, 위 링크나 `@fontsource/ibm-plex-sans`처럼 원래 이름을 유지하는 방식을 써요.
60
+ > [!NOTE]
61
+ > 글꼴은 패키지에 들어 있지 않아요. sonic-ui는 글꼴 이름 `"IBM Plex Sans"`, `"IBM Plex Mono"`를 찾기 때문에, 이름을 바꾸는 방식(예: `next/font` 기본 설정)으로 불러오면 적용되지 않아요. 위 링크나 `@fontsource/ibm-plex-sans`처럼 원래 이름을 유지하는 방식을 써요.
26
62
 
27
- ## 설정
63
+ ### CSS 연결
28
64
 
29
- ### Tailwind v4 프로젝트 (권장)
65
+ Tailwind v4 프로젝트(권장)에서는 이렇게 연결해요.
30
66
 
31
67
  ```css
32
68
  /* app/globals.css */
33
69
  @layer theme, base, sonic, components, utilities;
34
70
  @import "tailwindcss";
35
71
  @import "@xonic0506/sonic-ui/styles.css";
36
- @import "@xonic0506/sonic-ui/tailwind.css"; /* 선택: bg-primary, text-muted, rounded-md, text-body … 클래스 */
72
+ @import "@xonic0506/sonic-ui/tailwind.css"; /* 선택: bg-primary, text-muted, rounded-md, text-body 같은 클래스 */
37
73
  ```
38
74
 
39
- **layer 순서가 중요해요.** `sonic`은 Tailwind preflight(`base`)보다 뒤, 쓰는 쪽 `components`/`utilities`보다 앞이에요. 그래서 다음이 보장돼요.
40
-
41
- - preflight가 버튼 배경, 글자 크기를 지우지 않아요.
42
- - 쓰는 쪽 `className="bg-red-500"`은 항상 sonic-ui 기본 스타일을 이겨요.
75
+ > [!IMPORTANT]
76
+ > layer 순서를 지켜요. `sonic`이 Tailwind preflight(`base`)보다 뒤에 있어야 버튼 배경과 글자 크기가 지워지지 않고, 쓰는 쪽 `components`/`utilities`보다 앞에 있어야 `className="bg-red-500"` 같은 덮어쓰기가 항상 이겨요.
43
77
 
44
- ### Tailwind 없는 프로젝트
78
+ Tailwind가 없는 프로젝트에서는 CSS 파일 하나만 불러와요. 토큰은 `var(--sn-color-bg-primary)`처럼 CSS 변수로 써요.
45
79
 
46
80
  ```ts
47
81
  import '@xonic0506/sonic-ui/styles.css';
48
82
  ```
49
83
 
50
- sonic-ui CSS는 전부 `@layer sonic` 안에 있어서, layer에 넣지 않은 쓰는 쪽 CSS가 항상 이겨요. 토큰은 `var(--sn-color-bg-primary)`처럼 CSS 변수로 써요.
84
+ ### 앱 루트와 테마
51
85
 
52
- ### 앱 최상위 요소
86
+ 앱 루트에 `isolation: isolate`를 지정해요. 앱의 z-index가 `body` 아래에 그려지는 Dialog, Toast를 가리지 않게 해 줘요.
53
87
 
54
88
  ```tsx
55
89
  <div style={{ isolation: 'isolate' }}>{/* 앱 */}</div> // Tailwind: className="isolate"
56
90
  ```
57
91
 
58
- 앱 루트에 `isolation: isolate`를 지정해요. 앱의 z-index가 Dialog Portal(`body` 아래)을 넘어서지 않게 해 줘요.
59
-
60
- ### 테마
61
-
62
- `<html data-theme="light">` 또는 `data-theme="dark"`로 바꿔요. 속성이 없으면 light예요. Portal로 `body`에 그려지는 Dialog에도 같은 테마가 적용돼요.
92
+ 테마는 `<html data-theme="light">` 또는 `data-theme="dark"`로 바꿔요. 속성이 없으면 light이고, `body` 아래에 그려지는 팝업에도 같은 테마가 적용돼요.
63
93
 
64
94
  ## 사용
65
95
 
@@ -86,22 +116,26 @@ import { Button, Card, CardBody, CardHeader, CardTitle, Dialog, DialogClose, Dia
86
116
  </Dialog>
87
117
  ```
88
118
 
89
- 컴포넌트별 props는 [DESIGN.md §10](./DESIGN.md#10-컴포넌트-스펙-core)에 있어요. 모든 파일에 `"use client"`가 붙어 있어서 Next.js 서버 컴포넌트에서도 바로 import할 수 있어요. 하위 컴포넌트는 `CardHeader`처럼 이름 있는 export로 써요.
90
-
91
- Toast를 쓰려면 앱 최상단(Next.js는 `app/layout.tsx`)을 `<ToastProvider>`로 한 번 감싸고, 클라이언트 컴포넌트에서 `useToast().add({ title, tone })`로 띄워요.
119
+ 하위 컴포넌트는 `Card.Header`가 아니라 `CardHeader`처럼 이름 있는 export로 써요. 서버 컴포넌트에서도 깨지지 않게 하기 위해서예요.
92
120
 
93
- ## Claude Code에서 쓰기
121
+ Toast는 앱 최상단(Next.js는 `app/layout.tsx`)을 `<ToastProvider>`로 한 번 감싸고, 클라이언트 컴포넌트에서 띄워요.
94
122
 
95
- 프로젝트 CLAUDE.md에 추가해요.
96
-
97
- ```md
98
- @node_modules/@xonic0506/sonic-ui/DESIGN.md
99
- @node_modules/@xonic0506/sonic-ui/AI-RULES.md
123
+ ```tsx
124
+ const toast = useToast();
125
+ toast.add({ title: 'API 키를 만들었어요', tone: 'success' });
100
126
  ```
101
127
 
102
- ## 디자인 lint 규칙 (선택)
128
+ ## AI와 함께 쓰기
129
+
130
+ > [!TIP]
131
+ > Claude Code를 쓰는 프로젝트라면 CLAUDE.md에 아래 두 줄을 추가해요. AI가 디자인 규칙을 읽고 sonic-ui 컴포넌트로 화면을 만들어요.
132
+ >
133
+ > ```md
134
+ > @node_modules/@xonic0506/sonic-ui/DESIGN.md
135
+ > @node_modules/@xonic0506/sonic-ui/AI-RULES.md
136
+ > ```
103
137
 
104
- 임의의 값 클래스, Tailwind 기본 팔레트, hex 색을 막는 ESLint flat config 조각을 제공해요. 켜는 것은 쓰는 쪽 설정에서 해요.
138
+ 임의의 값 클래스, Tailwind 기본 팔레트, hex 색을 막는 ESLint flat config도 제공해요. 켜는 것은 쓰는 쪽 설정에서 해요.
105
139
 
106
140
  ```js
107
141
  // eslint.config.js
@@ -111,15 +145,28 @@ export default [/* … */, sonicDesign];
111
145
 
112
146
  ## 지원 브라우저
113
147
 
114
- Tailwind v4 기준과 같아요: Chrome 111+, Edge 111+, Safari 16.4+, Firefox 128+. CSS `@layer`와 `@property`를 쓰기 때문이에요.
148
+ Chrome 111+, Edge 111+, Safari 16.4+, Firefox 128+. CSS `@layer`와 `@property`를 쓰기 때문에 Tailwind v4와 기준이 같아요.
115
149
 
116
150
  ## 개발
117
151
 
118
152
  ```bash
119
- npm run build # 토큰 생성 → JS(Vite) → 타입 → CSS(@layer sonic)
120
- npm test # Vitest
153
+ npm run build # 토큰 생성 → JS(Vite) → 타입 → CSS(@layer sonic)
154
+ npm test # Vitest
121
155
  npm run typecheck
122
- npm run playground # Figma Core 페이지와 비교하는 화면 (build 먼저)
156
+ npm run check:contrast # 글자와 바탕 대비 4.5:1 검사
157
+ npm run playground # Figma와 비교하는 화면 (build 먼저)
158
+ ```
159
+
160
+ ```text
161
+ .
162
+ ├── src/components/ # Core 컴포넌트
163
+ ├── src/patterns/ # Pattern 승격 기준 (두 번째 프로젝트에서 복사하게 될 때)
164
+ ├── scripts/ # Figma 토큰 추출, CSS 빌드, 대비 검사
165
+ ├── lint/ # 디자인 lint 규칙
166
+ ├── playground/ # Figma와 비교하는 화면
167
+ ├── fixtures/ # 쓰는 쪽 예제 (vite, Next.js)
168
+ └── DESIGN.md # 디자인 규칙 (패키지에 포함)
123
169
  ```
124
170
 
125
- 운영 규칙은 [CLAUDE.md](./CLAUDE.md)에 있어요.
171
+ > [!NOTE]
172
+ > 디자인을 바꿀 때는 Figma → DESIGN.md → 토큰 추출 → 코드 순서를 지켜요. 자세한 운영 규칙은 [CLAUDE.md](./CLAUDE.md)에 있어요.
@@ -0,0 +1,30 @@
1
+ import * as React from 'react';
2
+ export interface AccordionProps {
3
+ /** 여러 항목을 함께 열 수 있음 (기본 false) */
4
+ multiple?: boolean;
5
+ /** 열린 항목들의 value */
6
+ value?: string[];
7
+ defaultValue?: string[];
8
+ onValueChange?: (value: string[]) => void;
9
+ className?: string;
10
+ children?: React.ReactNode;
11
+ }
12
+ /** 닫힌 내용도 브라우저 페이지 찾기로 찾아 열 수 있어요 (hiddenUntilFound) */
13
+ export declare function Accordion({ onValueChange, className, ...props }: AccordionProps): React.JSX.Element;
14
+ export interface AccordionItemProps {
15
+ value?: string;
16
+ disabled?: boolean;
17
+ className?: string;
18
+ children?: React.ReactNode;
19
+ }
20
+ export declare function AccordionItem({ className, ...props }: AccordionItemProps): React.JSX.Element;
21
+ export interface AccordionTriggerProps {
22
+ className?: string;
23
+ children?: React.ReactNode;
24
+ }
25
+ export declare function AccordionTrigger({ className, children }: AccordionTriggerProps): React.JSX.Element;
26
+ export interface AccordionPanelProps {
27
+ className?: string;
28
+ children?: React.ReactNode;
29
+ }
30
+ export declare function AccordionPanel({ className, children }: AccordionPanelProps): React.JSX.Element;
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import { cn as e } from "../lib/cn.js";
3
+ import "react";
4
+ import { ChevronDown as t } from "lucide-react";
5
+ import { jsx as n, jsxs as r } from "react/jsx-runtime";
6
+ import { Accordion as i } from "@base-ui/react/accordion";
7
+ //#region src/components/accordion.tsx
8
+ function a({ onValueChange: t, className: r, ...a }) {
9
+ return /* @__PURE__ */ n(i.Root, {
10
+ ...a,
11
+ hiddenUntilFound: !0,
12
+ onValueChange: t && ((e) => t(e)),
13
+ className: e("sn:flex sn:flex-col sn:font-sans", r)
14
+ });
15
+ }
16
+ function o({ className: t, ...r }) {
17
+ return /* @__PURE__ */ n(i.Item, {
18
+ ...r,
19
+ className: e("sn:border-b sn:border-default sn:data-disabled:opacity-(--sn-opacity-disabled)", t)
20
+ });
21
+ }
22
+ function s({ className: a, children: o }) {
23
+ return /* @__PURE__ */ n(i.Header, {
24
+ className: "sn:m-0",
25
+ children: /* @__PURE__ */ r(i.Trigger, {
26
+ className: e("sn:group sn:flex sn:w-full sn:items-center sn:gap-2 sn:rounded-sm sn:border-0 sn:bg-transparent sn:px-0 sn:py-3 sn:text-left sn:font-sans sn:text-body-strong sn:text-default sn:cursor-pointer sn:focus-visible:outline-2 sn:focus-visible:outline-offset-2 sn:focus-visible:outline-focus sn:data-disabled:cursor-not-allowed", a),
27
+ children: [/* @__PURE__ */ n("span", {
28
+ className: "sn:min-w-0 sn:flex-1",
29
+ children: o
30
+ }), /* @__PURE__ */ n(t, { className: "sn:size-4 sn:shrink-0 sn:text-muted sn:transition-transform sn:duration-(--sn-duration-normal) sn:ease-standard sn:group-data-panel-open:rotate-180 sn:motion-reduce:transition-none" })]
31
+ })
32
+ });
33
+ }
34
+ function c({ className: t, children: r }) {
35
+ return /* @__PURE__ */ n(i.Panel, {
36
+ className: "sn:h-(--accordion-panel-height) sn:overflow-hidden sn:transition-[height] sn:duration-(--sn-duration-normal) sn:ease-standard sn:data-starting-style:h-0 sn:data-ending-style:h-0 sn:motion-reduce:transition-none",
37
+ children: /* @__PURE__ */ n("div", {
38
+ className: e("sn:pb-4 sn:font-sans sn:text-body sn:text-muted", t),
39
+ children: r
40
+ })
41
+ });
42
+ }
43
+ //#endregion
44
+ export { a as Accordion, o as AccordionItem, c as AccordionPanel, s as AccordionTrigger };
@@ -1,6 +1,6 @@
1
1
  import * as React from 'react';
2
2
  export interface EmptyStateProps {
3
- /** lucide 아이콘 (24px로 맞춰져요) */
3
+ /** 없으면 캐릭터 일러스트(120). 좁은 곳에서는 lucide 아이콘을 넘겨요 (24px로 맞춰져요) */
4
4
  icon?: React.ReactNode;
5
5
  title: React.ReactNode;
6
6
  description?: React.ReactNode;
@@ -1,17 +1,19 @@
1
1
  "use client";
2
- import { StateLayout as e } from "./state-layout.js";
2
+ import { illustrations as e } from "./illustration.js";
3
+ import { StateLayout as t } from "./state-layout.js";
3
4
  import "react";
4
- import { jsx as t } from "react/jsx-runtime";
5
+ import { jsx as n } from "react/jsx-runtime";
5
6
  //#region src/components/empty-state.tsx
6
- function n({ icon: n, title: r, description: i, action: a, className: o }) {
7
- return /* @__PURE__ */ t(e, {
8
- icon: n,
7
+ function r({ icon: r, title: i, description: a, action: o, className: s }) {
8
+ return /* @__PURE__ */ n(t, {
9
+ icon: r,
9
10
  iconClassName: "sn:text-muted",
10
- title: r,
11
- description: i,
12
- className: o,
13
- children: a
11
+ illustration: e.empty,
12
+ title: i,
13
+ description: a,
14
+ className: s,
15
+ children: o
14
16
  });
15
17
  }
16
18
  //#endregion
17
- export { n as EmptyState };
19
+ export { r as EmptyState };
@@ -0,0 +1,13 @@
1
+ import * as React from 'react';
2
+ export type ErrorPageStatus = 404 | 403 | 500;
3
+ export interface ErrorPageProps {
4
+ status: ErrorPageStatus;
5
+ /** 기본값: 상태별 문구 */
6
+ title?: React.ReactNode;
7
+ description?: React.ReactNode;
8
+ /** 보통 primary <Button> 하나 (예: 홈으로 가기) */
9
+ action?: React.ReactNode;
10
+ className?: string;
11
+ }
12
+ /** 화면 전체 에러. 제목이 페이지의 h1이에요 */
13
+ export declare function ErrorPage({ status, title, description, action, className }: ErrorPageProps): React.JSX.Element;