# 테마 만들기

> 토큰이 어떻게 이어지고, 내 테마를 어떻게 만드는지 알려 드려요.

- URL: https://dsui.vercel.app/docs/theming

## 토큰은 이렇게 동작해요

모든 컴포넌트는 값을 직접 쓰지 않고 토큰 변수만 읽어요. 토큰은 `components/arc/foundation.css` 한 파일에 있어요.

| 무엇 | 대표 토큰 |
| --- | --- |
| 면과 글자 | `--background`, `--surface`, `--surface-raised`, `--surface-muted`, `--foreground`, `--text-secondary` |
| 강조와 상태 | `--accent`, `--accent-strong`, `--accent-foreground`, `--success`, `--warning`, `--danger` |
| 선 | `--border`, `--border-subtle`, `--border-strong` |
| 모서리 | `--radius-control`, `--radius-panel`, `--radius-surface`, `--radius-pill` |
| 그림자 | `--shadow-resting`, `--shadow-raised`, `--shadow-floating` |
| 서체 | `--font-display`, `--font-body` |
| 모션 | `--duration-*`, `--ease-*` |

라이트 값은 `:root`에, 다크 값은 `:root[data-theme="dark"]`와 `:root.dark`에 있어요. 그래서 `<html data-theme="dark">`로 켜도 되고, shadcn처럼 `<html class="dark">`로 켜도 돼요. 둘 다 없으면 운영체제의 다크 모드 설정을 따라가요.

### 강조색 하나로 컨트롤 전체가 바뀌어요

몇몇 토큰은 값을 따로 갖지 않고 다른 토큰에서 계산해요. 그래서 `--accent`와 `--accent-foreground`만 바꿔도 버튼, 스위치, 체크박스, 차트 첫 계열이 함께 바뀌어요.

| 토큰 | 계산하는 법 |
| --- | --- |
| `--accent-strong` | `--accent` 그대로예요. |
| `--accent-subtle` | `--accent`를 투명하게 섞어요(라이트 12%, 다크 18%). |
| `--accent-foreground-muted` | `--accent-foreground`의 50%예요. |
| `--control-on`, `--control-glyph` | 라이트에서는 `--accent`, `--accent-foreground`예요. 다크에서는 흰 표시가 잘 보이도록 따로 정한 값을 써요. |
| `--series-1` | `--accent`예요. |
| `--highlight-subtle`, `--glow` | `--highlight`에서 계산해요. |

### 토큰 이름은 하나예요

컴포넌트와 블록은 `--accent`, `--surface` 같은 토큰 이름을 읽어요. 새 화면과 새 테마도 이 이름을 써요.

## base와 시즌 테마

| 테마 | 설명 |
| --- | --- |
| [base](/themes#base) | 모든 테마의 기준이 되는 중립 토큰 세트예요. 서체, 모서리, 그림자의 기준값도 여기 있어요. |
| [halloween](/themes#halloween) | 호박 주황 강조색과 보라 하이라이트, 깊은 보랏빛 밤의 다크 모드예요. 탄력 있는 스프링을 쓰고 알림과 성공 아이콘이 유령과 호박이 돼요. |
| [seollal](/themes#seollal) | 한지 같은 따뜻한 바탕에 다홍 강조색과 금빛 하이라이트를 올린 테마예요. 움직임이 경쾌하고 알림과 성공 아이콘이 복주머니와 방패연이 돼요. |
| [chuseok](/themes#chuseok) | 보름달 금빛 강조색과 감 주황 하이라이트, 밤하늘 남색 다크 모드예요. 움직임이 느긋하고 알림과 성공 아이콘이 송편과 감이 돼요. |
| [christmas](/themes#christmas) | 진홍 강조색과 소나무 초록 하이라이트를 눈처럼 흰 바탕에 올린 테마예요. 움직임이 부드럽고 알림과 성공 아이콘이 종과 별이 돼요. |

시즌 테마는 색과 모션, 아이콘 일부, 소리 일부, 제목 글꼴을 바꿔요.

- **제목 글꼴:** 할로윈은 제주돌담체, 설날은 빙그레체II, 추석은 그리운 옥수니 손글씨, 크리스마스는 넥슨 메이플스토리예요. 제목(`display`)에만 쓰고 본문은 Pretendard 그대로예요. 테마는 `"Halloween Display"`처럼 별칭만 가리키고, 실제 글꼴 파일은 `app/globals.css`의 `@font-face`가 연결해요. 글꼴을 바꾸려면 그 `src`만 고치면 돼요. 설치한 프로젝트에서는 같은 이름으로 글꼴을 직접 불러 주세요. 글꼴 파일은 고치지 않고 받은 그대로 쓰고, 라이선스는 각 글꼴의 것을 확인해 주세요(`public/fonts/season/SEASON-FONTS.txt`).
- **소리:** `click`, `success`, `error`, `notify`, `confirm` 다섯 역할만 시즌 소리로 바꾸고 나머지는 기본 소리를 써요. 소리는 `scripts/gen-season-sounds.ts`가 직접 합성한 것이라 따로 지킬 라이선스가 없어요. `pnpm sounds:season`으로 다시 만들 수 있고, 설치한 프로젝트에서 쓰려면 `public/sounds/<테마 이름>/` 폴더를 함께 옮겨 주세요.
시즌 테마는 일러스트도 따로 그렸어요. 일러스트는 도형 목록이라 `item.json`의 `illustrations`에 쓰고, 17장 중 그리지 않은 그림은 base 그림을 그 테마의 색으로 써요. 크리스마스는 전나무와 화환, 추석은 보름달과 언덕, 할로윈은 호박과 유령, 설날은 색동 부채와 보자기 조각이에요.

- 테마 페이지에서 제목 글꼴, 바꾼 아이콘, "소리 들어 보기" 버튼을 볼 수 있어요.

`base`는 서체, 모서리, 그림자의 기준값만 담고 색 토큰은 담지 않아요. 토큰의 기본값은 `foundation.css`에 있어서, 테마가 적지 않은 토큰은 그 값을 그대로 써요.

[테마 페이지](/themes)에서 테마마다 버튼, 입력, 배지, 스위치, 카드를 그 테마로 그린 미리보기를 볼 수 있어요. 미리보기는 사이트의 라이트와 다크 전환을 따라가요. 항목 상세 페이지의 "테마 미리보기"도 같은 방식으로 데모를 바꿔요.

## 테마 설치하기

테마는 `registry:theme` 항목이라서 파일 대신 전역 스타일시트에 CSS 규칙을 넣어요.

```bash
npx shadcn@latest add https://dsui.vercel.app/r/halloween.json
```

CLI는 부모 테마인 `base`를 먼저, 그다음 `halloween`을 전역 스타일시트(`app/globals.css` 등)에 써 넣어요. 테마마다 자기가 바꾼 값만 들어가요.

| 선택자 | 들어가는 값 |
| --- | --- |
| `:root` | 라이트 값, 그리고 두 모드가 같은 값(모서리, 서체) |
| `:root[data-theme="dark"]`, `:root.dark` | 다크 값 |
| `@media (prefers-color-scheme: dark)` 안의 `:root:not(…)` | 다크 값(운영체제 설정을 따를 때) |

선택자는 `foundation.css`와 같아요. 같은 우선순위에서는 나중에 오는 규칙이 이기니까 **`foundation.css`를 전역 스타일시트 맨 위에서 불러와요.** 그러면 테마 규칙이 그 뒤에 쓰여서 기본값을 덮어요.

```css
@import "../components/arc/foundation.css";
@import "tailwindcss";
/* … CLI가 테마 규칙을 이 아래에 써 넣어요 … */
```

다크 모드는 `<html data-theme="dark">`와 `<html class="dark">` 어느 쪽으로 켜도 테마의 다크 값이 들어가요.

테마의 서체, 모서리, 그림자(`typography`, `shape`, `elevation`)도 CSS 변수라서 같은 규칙에 함께 들어가요. 아이콘, 사운드, 모션은 CSS로 옮길 수 없어서 테마 JSON의 `meta.themeRoles`에만 담겨요. 아래 "아이콘, 사운드, 모션은 역할로 불러요"를 보고 `ThemeAssetsProvider`로 넘겨 주세요.

## 내 테마 만들기

### 내 프로젝트에서 색 바꾸기

가장 빠른 방법은 내 전역 스타일시트에서 토큰을 다시 정하는 거예요. `foundation.css` 다음에 쓰면 덮어써요. 계산되는 토큰(`--accent-subtle`, `--control-on` 등)은 따로 적지 않아도 따라와요.

```css
:root {
  --accent: oklch(58% .17 165);
  --accent-foreground: oklch(100% 0 0);
  --radius-control: .75rem;
}

:root[data-theme="dark"],
:root.dark {
  --accent: oklch(72% .15 165);
  --accent-foreground: oklch(20% .04 165);
}
```

강조색을 바꾸면 `--accent-foreground`와의 대비를 꼭 확인해요. 강조색 위의 글자는 4.5:1 이상이어야 읽혀요. 모서리는 `--radius-control`(버튼, 입력), `--radius-panel`(카드, 패널), `--radius-surface`(큰 면) 세 단계만 바꾸고, 알약 모양 `--radius-pill`은 그대로 둬요.

### 테마 항목 만들기

dsui의 테마는 `registry/theme/<이름>/item.json` 파일 하나로 정해요. 저장소에서 아래 명령을 쓰면 시작 파일을 만들어 줘요.

```bash
pnpm theme:new forest
pnpm theme:new forest --extends base
```

이름과 요약 자리, `extends`, 그리고 라이트와 다크의 주요 토큰(면, 글자, 선, 강조색, 하이라이트, 컨트롤)이 기본값으로 채워져요. 같은 이름의 테마가 있으면 덮어쓰지 않고 멈춰요. 상속은 한 단계까지라서 `--extends`에는 부모가 없는 테마만 쓸 수 있어요. 값을 바꾼 뒤 `pnpm gen`으로 검사하고 `pnpm test`로 대비를 확인해요.

### 테마 항목의 모양

부모를 고르고 바꾸고 싶은 것만 써요.

```json
{
  "slug": "my-theme",
  "type": "theme",
  "title": { "ko": "내 테마", "en": "My theme" },
  "summary": { "ko": "초록 강조색과 작은 모서리의 테마" },
  "extends": "base",
  "tokens": {
    "light": { "--accent": "oklch(58% .14 160)", "--accent-foreground": "oklch(100% 0 0)" },
    "dark": { "--accent": "oklch(78% .13 160)", "--accent-foreground": "oklch(20% .04 160)" }
  },
  "sounds": { "click": "tick-001" },
  "motion": { "duration": { "fast": 0.2 } },
  "typography": { "family": { "display": "'Pretendard Variable', sans-serif" } },
  "shape": { "control": "8px", "panel": "14px" },
  "elevation": { "floating": "0 24px 60px oklch(0% 0 0 / .12)" }
}
```

| 필드 | 쓰는 법 |
| --- | --- |
| `extends` | 물려받을 테마예요. 없으면 `base`를 물려받아요. |
| `tokens` | `light`와 `dark` 토큰이에요. 아래 토큰을 받아요. 적지 않은 토큰은 `foundation.css` 기본값을 써요. 값에는 `;`, 중괄호, 역슬래시, 주석, `url()`을 쓸 수 없어요. |
| `icons` | 역할 아이콘 34개 중 바꿀 것만 써요. 24px 격자의 SVG 경로(`paths`)에 `strokeWidth`, `fill`, `viewBox`를 더할 수 있어요. |
| `sounds` | 역할 소리 11개 중 바꿀 것만 써요. 번들 소리 이름, `/`로 시작하는 경로, `https://` 주소, `data:audio/` URI를 받아요. |
| `motion` | `duration`(초), `ease`(cubic-bezier 네 수), `spring`을 일부만 써도 돼요. |
| `typography` | `display`(제목)와 `body`(본문)의 `family`, `tracking`, `leading`이에요. |
| `shape` | `control`, `panel`, `surface`의 모서리 크기예요. |
| `elevation` | `resting`, `raised`, `floating` 그림자예요. 문자열 하나면 라이트와 다크에 같이 쓰고, `{ "light": …, "dark": … }`면 따로 써요. |

테마가 쓸 수 있는 토큰은 이 목록이 전부예요(`lib/theme/arc-tokens.ts`). 다른 이름은 검사에서 걸러져요.

| 묶음 | 토큰 |
| --- | --- |
| 면 | `--background`, `--surface`, `--surface-raised`, `--surface-muted`, `--surface-sunken`, `--inverse`, `--inverse-foreground` |
| 글자 | `--foreground`, `--text-secondary`, `--text-muted` |
| 선 | `--border`, `--border-subtle`, `--border-strong` |
| 강조 | `--accent`, `--accent-strong`, `--accent-subtle`, `--accent-foreground`, `--accent-foreground-muted` |
| 하이라이트 | `--highlight`, `--highlight-strong`, `--highlight-subtle`, `--highlight-foreground`, `--glow` |
| 컨트롤 | `--control-on`, `--control-glyph`, `--control-track`, `--control-track-hover`, `--control-thumb`, `--control-thumb-on` |
| 상태 | `--success`, `--warning`, `--danger` |
| 차트 | `--series-1` ~ `--series-4` |
| 모서리 | `--radius-control`, `--radius-panel`, `--radius-surface`, `--radius-pill` (두 모드가 같은 값이라 `light`에만 써요) |
| 그림자 | `--shadow-resting`, `--shadow-raised`, `--shadow-floating` |

글자와 배경의 대비는 테스트가 테마마다 확인해요. 강조색 위 글자(`--accent-foreground`), 면 위 본문(`--foreground`)과 보조 글자(`--text-secondary`), 하이라이트 위 글자는 4.5:1, 켜진 컨트롤 위 표시(`--control-glyph`)는 3:1 이상이어야 해요. 다크에서 `--control-on`을 강조색으로 맞추고 싶으면 `"--control-on": "var(--accent)"`, `"--control-glyph": "var(--accent-foreground)"`처럼 다른 토큰을 가리켜도 돼요.

상속은 역할 하나씩 따로 일어나요. 아이콘 하나만 바꾸면 나머지 아이콘은 부모 것을 그대로 써요. 서체, 모서리, 그림자 값은 스타일시트에 그대로 들어가기 때문에 `;`, 중괄호, 역슬래시, 주석, `url()`은 받지 않아요.

dsui 레지스트리에 테마를 올리는 일은 저장소 안에서 해요. 우리 팀만의 테마가 필요하면 devs7udio 스튜디오에 알려 주세요.

## 아이콘, 사운드, 모션은 역할로 불러요

컴포넌트는 아이콘 파일이나 소리 파일을 직접 고르지 않아요. 역할 이름을 부르면 지금 테마가 무엇을 쓸지 정해요.

```tsx
import { UiIcon } from "@/components/arc/theme";
import { playUiSound } from "@/components/arc/sound";

<UiIcon iconRole="close" size={16} aria-hidden="true" />;
playUiSound("click");
```

| 종류 | 역할 |
| --- | --- |
| 아이콘 (34개) | `close`, `check`, `chevron-*`, `arrow-*`, `search`, `plus`, `minus`, `more`, `delete`, `calendar`, `clock`, `info`, `success`, `warning`, `error`, `alert`, `copy`, `link`, `play`, `pause`, `reset`, `retry`, `loading`, `notification`, `mail`, `file`, `theme-light`, `theme-dark` |
| 사운드 (11개) | `click`, `tick`, `select`, `toggle-on`, `toggle-off`, `open`, `close`, `success`, `error`, `notify`, `confirm` |
| 모션 | 길이 `instant`, `fast`, `exit`, `standard`, `considered` / 곡선 `enter`, `exit`, `standard`, `inOut` / 스프링 `responsive`, `gentle`, `snappy`, `smooth`, `morph` |

테마의 아이콘, 사운드, 모션, 서체, 모서리, 그림자는 `ThemeAssetsProvider`로 적용해요. 페이지에 하나만 두면 돼요.

```tsx
import { ThemeAssetsProvider } from "@/components/arc/theme";

const assets = {
  sounds: { click: "tick-001" },
  shape: { control: "8px", panel: "14px" },
};

<ThemeAssetsProvider assets={assets}>{children}</ThemeAssetsProvider>;
```

설치한 프로젝트에서는 부모 테마와 합치는 과정이 없어요. 물려받을 값까지 합친 객체를 넘겨 주세요. 설치한 테마 JSON의 `meta.themeRoles`에 그 테마가 쓴 값이 들어 있어요. 서체, 모서리, 그림자는 CLI가 이미 CSS로 넣었으니, 제공자에는 아이콘, 사운드, 모션만 넘겨도 돼요.

## 아직 못 하는 것

- **CLI는 아이콘, 사운드, 모션을 넣지 못해요.** shadcn에 맞는 칸이 없어서 `meta.themeRoles`에만 담겨요. `ThemeAssetsProvider`로 직접 넘겨야 적용돼요.
- **화면 위에 뜨는 층은 미리보기 테마를 따르지 않아요.** 대화상자, 메뉴, 토스트는 `body`에 그려져서 미리보기 영역(`data-dsui-theme`, 데모의 `data-theme`)의 테마 밖에 있어요. 설치한 프로젝트에서는 `:root`에 들어가니 문제없어요.
- **shadcn init의 변수와 이름이 겹쳐요.** `shadcn init`이 만든 `:root`에도 `--background`, `--foreground`, `--accent`, `--accent-foreground`, `--border`가 있어요. 이 블록이 `foundation.css`보다 뒤에 있어서, 테마 없이 쓰면 라이트의 강조색이 shadcn의 회색이 돼요. 테마를 설치하면 테마 값으로 덮이고, 테마 없이 쓸 때는 그 다섯 줄을 지워 주세요.
- **모듈에 붙잡힌 모션 값은 늦게 바뀌어요.** 페이지가 열려 있는 동안 테마를 바꾸면, 처음 불러올 때 값을 붙잡아 둔 일부 전환은 다음 로드에서 바뀌어요.
- **스프링은 CSS 변수가 없어요.** 길이와 곡선은 CSS 변수로 바로 바뀌지만 스프링은 스크립트에만 있어요.
- **블록의 예시 아이콘은 그대로예요.** 테마는 인터페이스 공통 아이콘 34개만 바꿔요.
- **서체 파일은 테마가 싣지 않아요.** 페이지가 `@font-face`로 미리 불러 둔 서체 이름만 쓸 수 있어요.
- **사운드 켜기와 음량은 사용자의 선택이에요.** 테마가 바꾸지 않아요.
- **토큰 편집기와 테마 내보내기는 준비 중이에요.**
