임동욱 임동욱 08-12
refactor: 컴포넌트 스타일을 전역 BEM으로 전환 — 프레임워크 무관 사용 가능
디자인 레이어가 React + CSS Modules 전용이던 문제를 푼다. 기존 클래스명은
`.button`·`.label`·`.icon`처럼 일반명사여서, CSS Modules 스코프 밖으로 나가면 소비
프로젝트의 동명 클래스와 전부 충돌했다. SCSS만 쓰는 프로젝트(Vue·Svelte·서버 템플릿)는
버튼을 다시 만들 수밖에 없었다.

`fox-` 접두사 + BEM(fox-button__label · fox-button--primary)으로 바꿔 이름을 전역
유일하게 만들고 CSS Modules를 걷어냈다. 충돌 방지는 도구가 아니라 접두사 규율이 담당한다.
React 컴포넌트는 이 클래스를 조립하는 얇은 래퍼가 되고, 래퍼 없이도 같은 디자인을 얻는다.

진입점을 둘 다 제공한다 — `@fox/styles/components`(전부)와 개별 파티셜
`@fox/styles/fox-button`(안 쓰는 CSS를 번들에서 뺀다). 둘을 같이 써도 Sass가 모듈을
한 번만 로드해 중복되지 않는다.

마크업 계약을 스타일 파일 상단 주석과 @fox/README.md에 적었다 — React 밖 소비자는
그것만 보고 DOM을 짤 수 있어야 한다.

앱 화면 스타일은 `.module.scss` 그대로다(앱 전용이라 스코프가 이득). 규약 문서도 그
구분이 드러나게 고쳤다.

Co-Authored-By: Claude Opus 5 
@67bb213132a1bea448986a342828eb53af324d51
 
@fox/README.md (added)
+++ @fox/README.md
@@ -0,0 +1,152 @@
+# @fox — 포터블 디자인 시스템
+
+프로젝트에 종속되지 않는 디자인 시스템. **이 폴더를 통째로 복사하고 설정 몇 줄을 추가하면**
+다른 프로젝트에서 그대로 동작합니다.
+
+```
+@fox/
+  styles/                SCSS — 여기가 디자인의 전부입니다
+    index.scss           토큰 진입점 (앱이 한 번 @use)
+    components.scss      공용 컴포넌트 스타일 묶음 진입점
+    _fox-button.scss     컴포넌트별 스타일 (개별 @use 가능)
+    abstracts.scss       저작 진입점 — 함수·믹스인 (CSS 출력 0)
+    tokens/              토큰 map = 단일 진실 공급원 (자동 생성)
+    _root.scss           map → --fox-* 커스텀 프로퍼티
+    _functions.scss      fox.color() 등 토큰 접근 + 이름 검증
+    _mixins.scss         fox.pc / fox.mobile
+  core/
+    components/          React 래퍼 (선택 — 아래 "React 없이 쓰기" 참고)
+    utils/               cx() 등 의존성 없는 유틸
+  dev-test/              개발 전용 테스트·감사 화면
+  tools/build-tokens.py  Figma JSON → SCSS 변환기
+```
+
+## 이식하기
+
+1. `@fox/` 폴더를 대상 프로젝트에 복사합니다.
+
+2. `sass`를 설치합니다.
+
+   ```bash
+   npm install --save-dev sass
+   ```
+
+3. **SCSS 로드 경로**를 설정합니다. Next.js면 `next.config.ts`:
+
+   ```ts
+   sassOptions: { loadPaths: [path.join(process.cwd())] }
+   ```
+
+   다른 번들러면 그 도구의 Sass 옵션(`includePaths` / `loadPaths`)에 프로젝트 루트를
+   넣으면 됩니다. 이게 있어야 `@use "@fox/styles/..."` 같은 절대 표기가 해석되고, 없으면
+   컴포넌트마다 `../../../` 상대 경로를 써야 해서 폴더 복사가 불가능해집니다.
+
+4. 글로벌 스타일에서 진입점을 불러옵니다.
+
+   ```scss
+   @use "@fox/styles";              // 토큰
+   @use "@fox/styles/components";   // 공용 컴포넌트 스타일 (전부)
+   ```
+
+   골라 쓰려면 묶음 대신 개별 파티셜을 가져옵니다 — 안 쓰는 CSS가 번들에 실리지 않습니다.
+   둘을 같이 써도 Sass가 모듈을 한 번만 로드해 중복되지 않습니다.
+
+   ```scss
+   @use "@fox/styles/fox-button";
+   ```
+
+5. (TypeScript 프로젝트에서 React 래퍼도 쓸 경우) 경로 별칭을 추가합니다.
+
+   ```json
+   { "compilerOptions": { "paths": { "@fox/*": ["./@fox/*"] } } }
+   ```
+
+6. (선택) 폰트를 주입합니다 — 아래 "호스트 앱과의 계약" 참고.
+
+## React 없이 쓰기
+
+**디자인 레이어는 프레임워크에 의존하지 않습니다.** 토큰도 컴포넌트 스타일도 순수 SCSS이고,
+클래스명이 `fox-` 접두사 + BEM이라 전역에 풀어도 충돌하지 않습니다. Vue·Svelte·서버
+템플릿·순수 HTML 어디서든 위 2~4번만 하고 마크업 계약을 지키면 같은 결과가 나옵니다.
+
+각 컴포넌트의 **마크업 계약은 해당 스타일 파일 상단 주석**에 있습니다. 버튼은 이렇습니다.
+
+```html
+<button class="fox-button fox-button--primary fox-button--md">
+  <span class="fox-button__icon"><!-- svg (선택) --></span>
+  <span class="fox-button__label">버튼</span>
+  <span class="fox-button__icon"><!-- svg (선택) --></span>
+</button>
+```
+
+- 비활성은 네이티브 `disabled` 속성으로 표현합니다(별도 클래스 없음).
+- 로딩은 앞 아이콘 자리에 `fox-button__spinner`를 두고 `disabled`를 함께 겁니다.
+- 아이콘 SVG는 `currentColor`로 그려야 계열별 색이 적용됩니다. 크기는 슬롯이 정합니다.
+
+`core/`의 React 컴포넌트는 이 클래스를 조립해주는 **얇은 래퍼**일 뿐입니다 — 쓰지 않아도
+디자인은 그대로 얻습니다.
+
+토큰만 쓰는 것도 물론 됩니다.
+
+```scss
+@use "@fox/styles/abstracts" as fox;
+
+.whatever {
+  padding: fox.padding(6);
+  color: fox.color(font-neutral-default);
+  @include fox.pc { padding: fox.padding(8); }
+}
+```
+
+## 핵심 설계
+
+**토큰의 SSOT는 `tokens/`의 SCSS map 하나입니다.** 여기서 두 가지가 함께 파생됩니다 —
+`_root.scss`가 만드는 `--fox-*` 커스텀 프로퍼티와, `_functions.scss`가 검증에 쓰는 유효
+이름 목록. 값과 이름이 한 곳에만 있으므로 둘이 어긋날 수 없습니다.
+
+**토큰은 Figma에서 자동 생성됩니다. 손으로 고치지 않습니다.** Figma 변수를 재수출한 뒤
+변환기를 다시 돌리면 됩니다. 생성물이 재현 가능해서 재실행 diff가 곧 Figma 변경분입니다.
+
+```bash
+FOX_TOKENS_SRC=<export 폴더> python3 @fox/tools/build-tokens.py
+```
+
+**규율을 컴파일러가 강제합니다.** 화면 코드는 `color: #256EF4`를 쓸 수 없고
+`fox.color(...)`로 토큰을 지목해야 합니다. 없는 이름은 빌드가 실패하며 사용 가능한 목록을
+함께 출력합니다.
+
+```
+Error: [@fox] 알 수 없는 color 토큰: `primry` — 사용 가능한 값: background-default, ...
+```
+
+**1rem = 10px입니다.** `_root.scss`의 `html { font-size: 62.5% }`가 근거입니다.
+
+> ⚠️ **미디어 쿼리 안의 `rem`은 예외입니다.** 미디어 쿼리는 `html`의 `font-size` 영향을
+> 받지 않습니다. 그래서 브레이크포인트는 `768px`로 두었고, 미디어 쿼리는 `fox.pc` /
+> `fox.mobile` 믹스인으로만 씁니다 — 조건부는 `var()`를 해석하지 못합니다.
+
+**라이트/다크는 값이 한 곳에만 존재합니다.** `light-dark(라이트, 다크)`로 한 줄에 두 값을
+쓰고 전환은 `color-scheme`이 담당합니다. 다크 전용 오버라이드 블록이 없어 테마 불일치가
+구조적으로 불가능합니다. `<html data-theme="light"|"dark">`로 수동 선택을 덮어쓸 수 있고,
+속성이 없으면 OS 설정을 따릅니다.
+
+> `light-dark()` 자체는 Chrome 123 / Safari 17.5 / Firefox 120 이상이 필요하지만,
+> Lightning CSS(Next.js Turbopack)가 커스텀 프로퍼티 조합으로 폴리필해 출력하므로 구형
+> 브라우저에서도 동작합니다. browserslist 타깃을 좁히거나 다른 번들러로 옮길 때 재확인하세요.
+
+## 호스트 앱과의 계약
+
+`@fox`가 소유하지 않고 앱에서 받는 값입니다.
+
+| CSS 변수 | 용도 | 미주입 시 |
+| --- | --- | --- |
+| `--app-font-sans` | 기본 서체 | `system-ui`로 폴백 |
+| `--app-font-mono` | 고정폭 서체 | `ui-monospace`로 폴백 |
+
+폰트 파일은 앱이 소유합니다. `@fox`는 변수를 참조만 하므로, 복사해 간 프로젝트는 그
+프로젝트의 폰트를 그대로 씁니다.
+
+## 새 컴포넌트 추가
+
+`core/components/README.md`의 작성 규약을 따릅니다. 요점은 스타일을 `styles/_<name>.scss`에
+두고 `fox-` 접두사 + BEM으로 이름 짓는 것 — 그래야 React 밖에서도 쓸 수 있습니다.
 
@fox/core/components/README.md (added)
+++ @fox/core/components/README.md
@@ -0,0 +1,91 @@
+# 컴포넌트 작성 규약
+
+## 파일 배치
+
+컴포넌트 하나당 폴더 하나. **스타일은 여기에 두지 않는다** — `@fox/styles/_<name>.scss`가 소유한다.
+
+```
+core/components/
+  fox-button/
+    fox-button.tsx      컴포넌트 (React)
+    index.ts            export { FoxButton } from "./fox-button";
+  index.ts              배럴 — export * from "./fox-button";
+
+styles/
+  _fox-button.scss      모양 규칙 (프레임워크 무관)
+  components.scss       전부 묶음 진입점 — 새 컴포넌트를 여기에 @use로 추가
+```
+
+**스타일과 컴포넌트를 갈라 둔 이유**는 디자인이 React에 묶이면 안 되기 때문이다. SCSS만
+쓰는 프로젝트(Vue·Svelte·서버 템플릿 등)도 `@use "@fox/styles/fox-button"` 하나로 같은
+버튼을 얻어야 한다.
+
+## 작성 규칙
+
+**1. 클래스명은 `fox-` 접두사 + BEM.**
+
+```
+.fox-button                    블록
+.fox-button__label             엘리먼트
+.fox-button--primary           모디파이어
+```
+
+CSS Modules를 쓰지 않는다. 이름이 전역 유일해서 스코프가 필요 없고, 스코프가 없어야
+React 밖에서도 같은 이름으로 쓸 수 있다. **충돌 방지는 접두사 규율이 담당한다.**
+
+**2. 모양 값은 전부 `fox.*()` 토큰으로 지목한다.**
+
+```scss
+@use "abstracts" as fox;
+
+.fox-button {
+  padding-inline: fox.form(padding-md);
+  background: fox.color(button-primary-surface);
+  border-radius: fox.form(radius-md);
+}
+```
+
+원시 값(hex·px·rem 리터럴) 금지. 없는 토큰 이름은 빌드가 실패시킨다. 맞는 토큰이 없으면
+쓰지 말고 Figma에 먼저 추가한다 — 토큰 파일은 자동 생성물이다.
+
+토큰으로 표현할 수 없는 값(시안에 있으나 변수화되지 않은 것 등)이 나오면 **사용자와 상의한
+뒤** 넣고, 파일 상단 주석에 근거를 남긴다.
+
+**3. 상태는 네이티브 속성으로.**
+
+`disabled`·`aria-busy` 같은 표준 속성을 쓰고 `.is-disabled` 같은 클래스를 만들지 않는다.
+SCSS만 쓰는 소비자도 별도 규칙 없이 같은 결과를 얻는다.
+
+**4. 마크업 계약을 스타일 파일 상단에 적는다.**
+
+React 밖 소비자는 그 주석만 보고 DOM을 짜야 한다. 필수 구조와 선택 요소를 예시로 남긴다.
+
+**5. 기본은 Server Component.**
+
+`'use client'`는 상호작용(상태·이벤트 핸들러)이 실제로 필요할 때만 붙인다.
+
+**6. variant는 `Record`로 고정한다.**
+
+```tsx
+const TYPE_CLASS: Record<FoxButtonType, string> = {
+  primary: "fox-button--primary",
+  secondary: "fox-button--secondary",
+};
+```
+
+계열을 추가하면 맵 누락이 타입 에러가 된다.
+
+**7. `className` prop은 배치용으로만 연다.**
+
+호출부가 넘기는 것은 margin·grid 배치 같은 **위치** 조정이지 디자인 값이 아니다. 모양이
+달라져야 하면 모디파이어를 추가한다.
+
+**8. 호스트 앱에 의존하지 않는다.**
+
+`@fox/` 안에서 `@/lib/...` 같은 앱 경로를 import 하지 않는다. 앱이 주는 값은 prop이나
+CSS 변수로 받는다.
+
+**9. 만들면 테스트 페이지에 등록한다.**
+
+`@fox/dev-test/component-registry.tsx`의 `COMPONENT_EXAMPLES`에 항목 하나를 추가하면
+`/dev-test/design`에 나타난다.
 
@fox/core/components/fox-button/fox-button.module.scss (deleted)
--- @fox/core/components/fox-button/fox-button.module.scss
@@ -1,9 +0,0 @@
-// FoxButton의 모양 규칙은 디자인 시스템 쪽(`@fox/styles/_fox-button.scss`)이 소유한다.
-// 여기서는 그것을 CSS Module로 끌어올리기만 한다 — `@use`가 그 파일의 CSS를 이 모듈의
-// 컴파일 단위에 포함시키므로, 클래스명이 모듈 스코프로 감싸지고 컴포넌트에서
-// `styles.button`처럼 쓸 수 있다. 전역 이름 충돌이 생기지 않는다.
-//
-// ⚠️ 이 파티셜을 다른 모듈에서 또 `@use`하면 CSS가 그 모듈에도 복제된다. 소비처는
-// 이 파일 하나로 유지한다.
-
-@use "@fox/styles/fox-button";
@fox/core/components/fox-button/fox-button.tsx
--- @fox/core/components/fox-button/fox-button.tsx
+++ @fox/core/components/fox-button/fox-button.tsx
@@ -2,7 +2,6 @@
 
 import type { ReactNode, Ref } from "react";
 import { cx } from "../../utils";
-import styles from "./fox-button.module.scss";
 
 export type FoxButtonType = "primary" | "secondary" | "default" | "error";
 export type FoxButtonSize = "lg" | "md" | "sm" | "xsm";
@@ -36,21 +35,24 @@
 }
 
 /**
- * variant마다 클래스를 `Record`로 고정한다 — 계열을 추가하면 여기 항목을 빠뜨릴 수 없다
- * (타입 에러). 모양 규칙은 `@fox/styles/_fox-button.scss`가 소유한다.
+ * 클래스명은 `@fox/styles/_fox-button.scss`의 BEM 이름을 그대로 쓴다 — CSS Modules로
+ * 감싸지 않는 이유는 이 스타일이 React 밖에서도 쓰여야 하기 때문이다. 이름이 `fox-`
+ * 접두사로 전역 유일해서 스코프가 필요 없다.
+ *
+ * variant마다 `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다.
  */
 const TYPE_CLASS: Record<FoxButtonType, string> = {
-  primary: styles.typePrimary,
-  secondary: styles.typeSecondary,
-  default: styles.typeDefault,
-  error: styles.typeError,
+  primary: "fox-button--primary",
+  secondary: "fox-button--secondary",
+  default: "fox-button--default",
+  error: "fox-button--error",
 };
 
 const SIZE_CLASS: Record<FoxButtonSize, string> = {
-  lg: styles.sizeLg,
-  md: styles.sizeMd,
-  sm: styles.sizeSm,
-  xsm: styles.sizeXsm,
+  lg: "fox-button--lg",
+  md: "fox-button--md",
+  sm: "fox-button--sm",
+  xsm: "fox-button--xsm",
 };
 
 /**
@@ -62,6 +64,9 @@
  *
  * 모양은 Figma 시안(관리자페이지 node 390:439)을 토큰으로만 옮긴 것이며 규칙은
  * `@fox/styles/_fox-button.scss`에 있다. `error` 계열만 시안 미정이라 비어 있다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 `@use "@fox/styles/fox-button"`)로 한 번 불러와야 한다.
  */
 export function FoxButton({
   type = "default",
@@ -91,10 +96,10 @@
       ref={ref}
       type={htmlType}
       className={cx(
-        styles.button,
+        "fox-button",
         TYPE_CLASS[type],
         SIZE_CLASS[size],
-        fullWidth && styles.fullWidth,
+        fullWidth && "fox-button--full-width",
         className
       )}
       disabled={inactive}
@@ -106,19 +111,19 @@
       {loading ? (
         // 스피너 비주얼은 시안에 없어 자리만 잡아둔다. 아이콘과 같은 크기를 차지하므로
         // 로딩으로 바뀔 때 버튼 폭이 흔들리지 않는다.
-        <span className={styles.spinner} aria-hidden="true" />
+        <span className="fox-button__spinner" aria-hidden="true" />
       ) : (
         leadingIcon && (
-          <span className={styles.icon} aria-hidden="true">
+          <span className="fox-button__icon" aria-hidden="true">
             {leadingIcon}
           </span>
         )
       )}
 
-      <span className={styles.label}>{label}</span>
+      <span className="fox-button__label">{label}</span>
 
       {trailingIcon && (
-        <span className={styles.icon} aria-hidden="true">
+        <span className="fox-button__icon" aria-hidden="true">
           {trailingIcon}
         </span>
       )}
@fox/styles/_fox-button.scss
--- @fox/styles/_fox-button.scss
+++ @fox/styles/_fox-button.scss
@@ -1,5 +1,19 @@
 // FoxButton 스타일 — 시안: 관리자페이지 Figma, node 390:439 (`btn`)
 //
+// **프레임워크 무관.** 클래스명이 `fox-` 접두사 + BEM이라 전역에 그대로 풀어도 충돌하지
+// 않는다. React 없이 SCSS만 쓰는 프로젝트도 이 파일 하나만 `@use`하고 아래 마크업 계약을
+// 지키면 동일한 버튼을 얻는다.
+//
+//   <button class="fox-button fox-button--primary fox-button--md">
+//     <span class="fox-button__icon">…svg…</span>   <!-- 선택 -->
+//     <span class="fox-button__label">버튼</span>
+//     <span class="fox-button__icon">…svg…</span>   <!-- 선택 -->
+//   </button>
+//
+// - 비활성은 네이티브 `disabled` 속성으로 표현한다(별도 모디파이어 없음).
+// - 로딩은 앞쪽 아이콘 자리에 `fox-button__spinner`를 두고 `disabled`를 함께 건다.
+// - 아이콘 SVG는 `currentColor`로 그려야 계열별 색이 적용된다. 크기는 슬롯이 정한다.
+//
 // **모든 디자인 값은 토큰으로만 지목한다.** 시안의 Figma 변수가 우리 토큰과 1:1로
 // 대응하므로 예외가 필요 없었고, 아래 세 가지만 토큰이 아니다(사용자 승인 후 사용):
 //
@@ -11,13 +25,10 @@
 //      높이가 고정인데 secondary·default가 1px 테두리를 가져, 없으면 계열별로 바깥
 //      높이가 2px 어긋난다. 디자인 값이 아니라 레이아웃 메커니즘이다.
 //   3. `cursor` / `white-space` / `flex-shrink` 등 구조·상호작용 규칙.
-//
-// 이 파일은 CSS를 출력하므로 `fox-button.module.scss` **한 곳에서만** `@use`한다.
-// 전역 진입점(`index.scss`)에 넣으면 안 된다.
 
 @use "abstracts" as fox;
 
-.button {
+.fox-button {
   box-sizing: border-box;
   display: inline-flex;
   align-items: center;
@@ -25,170 +36,169 @@
   border: none;
   cursor: pointer;
 
-  // 시안 label/*-strong. 크기별 font-size는 아래 size 클래스가 정한다.
+  // 시안 label/*-strong. 크기별 font-size는 아래 크기 모디파이어가 정한다.
   font-family: fox.font-family(body);
   font-weight: fox.font-weight(medium);
   line-height: 1;
   letter-spacing: -0.025em;
   white-space: nowrap;
-}
 
-.button:disabled {
-  cursor: default;
-}
-
-.fullWidth {
-  inline-size: 100%;
-}
-
-// 라벨이 길어도 아이콘을 밀어내지 않게 라벨만 줄어들 수 있게 둔다.
-.label {
-  min-inline-size: 0;
-}
-
-// 아이콘 슬롯. 크기는 size 클래스가, 색은 type 클래스가 정한다 —
-// 안에 들어오는 SVG는 `currentColor`로 그려야 계열별 색이 적용된다.
-.icon,
-.spinner {
-  display: inline-flex;
-  flex-shrink: 0;
-  align-items: center;
-  justify-content: center;
-}
-
-.icon > * {
-  inline-size: 100%;
-  block-size: 100%;
-}
-
-// ── 크기 ────────────────────────────────────────────────────────────────────
-// 높이만 고정이고 너비는 내용에 따라 가변이다(시안도 좌우 padding만 지정).
-.sizeLg {
-  block-size: fox.form(height-lg);
-  padding-inline: fox.form(padding-lg);
-  border-radius: fox.form(radius-lg);
-  gap: fox.gap(2);
-  font-size: fox.font-size(label-lg);
-
-  .icon,
-  .spinner {
-    inline-size: fox.form(icon-lg);
-    block-size: fox.form(icon-lg);
-  }
-}
-
-.sizeMd {
-  block-size: fox.form(height-md);
-  padding-inline: fox.form(padding-md);
-  border-radius: fox.form(radius-md);
-  gap: fox.gap(2);
-  font-size: fox.font-size(label-md);
-
-  .icon,
-  .spinner {
-    inline-size: fox.form(icon-md);
-    block-size: fox.form(icon-md);
-  }
-}
-
-.sizeSm {
-  block-size: fox.form(height-sm);
-  padding-inline: fox.form(padding-sm);
-  border-radius: fox.form(radius-sm);
-  gap: fox.gap(2);
-  font-size: fox.font-size(label-sm);
-
-  .icon,
-  .spinner {
-    inline-size: fox.form(icon-sm);
-    block-size: fox.form(icon-sm);
-  }
-}
-
-// xsm만 gap이 한 단계 좁다(시안 확인).
-.sizeXsm {
-  block-size: fox.form(height-xsm);
-  padding-inline: fox.form(padding-xsm);
-  border-radius: fox.form(radius-xsm);
-  gap: fox.gap(1);
-  font-size: fox.font-size(label-xsm);
-
-  .icon,
-  .spinner {
-    inline-size: fox.form(icon-xsm);
-    block-size: fox.form(icon-xsm);
-  }
-}
-
-// ── 계열 ────────────────────────────────────────────────────────────────────
-// 아이콘 색은 라벨 색과 별개 토큰으로 지정돼 있어(시안) 각각 지목한다 —
-// 지금은 두 값이 같지만 토큰이 갈라지면 시안을 따라가야 한다.
-.typePrimary {
-  background: fox.color(button-primary-surface);
-  color: fox.color(button-primary-font);
-
-  .icon {
-    color: fox.color(icon-neutral-static-inverse);
+  &:disabled {
+    cursor: default;
   }
 
-  &:hover:not(:disabled) {
-    background: fox.color(button-primary-surface-hover);
+  // 라벨이 길어도 아이콘을 밀어내지 않게 라벨만 줄어들 수 있게 둔다.
+  &__label {
+    min-inline-size: 0;
   }
 
-  &:active:not(:disabled) {
-    background: fox.color(button-primary-surface-pressed);
-  }
-}
-
-.typeSecondary {
-  border: fox.border(1) solid fox.color(button-secondary-border);
-  background: fox.color(button-secondary-surface);
-  color: fox.color(button-secondary-font);
-
-  .icon {
-    color: fox.color(icon-theme-primary-strong);
+  // 아이콘·로딩 슬롯. 크기는 크기 모디파이어가, 색은 계열 모디파이어가 정한다.
+  &__icon,
+  &__spinner {
+    display: inline-flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
   }
 
-  &:hover:not(:disabled) {
-    background: fox.color(button-secondary-surface-hover);
+  &__icon > * {
+    inline-size: 100%;
+    block-size: 100%;
   }
 
-  &:active:not(:disabled) {
-    background: fox.color(button-secondary-surface-pressed);
-  }
-}
-
-.typeDefault {
-  border: fox.border(1) solid fox.color(button-default-border);
-  background: fox.color(button-default-surface);
-  color: fox.color(button-default-font);
-
-  .icon {
-    color: fox.color(icon-neutral-default);
+  &--full-width {
+    inline-size: 100%;
   }
 
-  &:hover:not(:disabled) {
-    background: fox.color(button-default-surface-hover);
+  // ── 크기 ──────────────────────────────────────────────────────────────────
+  // 높이만 고정이고 너비는 내용에 따라 가변이다(시안도 좌우 padding만 지정).
+  &--lg {
+    block-size: fox.form(height-lg);
+    padding-inline: fox.form(padding-lg);
+    border-radius: fox.form(radius-lg);
+    gap: fox.gap(2);
+    font-size: fox.font-size(label-lg);
+
+    .fox-button__icon,
+    .fox-button__spinner {
+      inline-size: fox.form(icon-lg);
+      block-size: fox.form(icon-lg);
+    }
   }
 
-  &:active:not(:disabled) {
-    background: fox.color(button-default-surface-pressed);
-  }
-}
+  &--md {
+    block-size: fox.form(height-md);
+    padding-inline: fox.form(padding-md);
+    border-radius: fox.form(radius-md);
+    gap: fox.gap(2);
+    font-size: fox.font-size(label-md);
 
-// ⚠️ error 계열은 시안 미정이라 규칙을 두지 않는다. 확정되면 여기에 채운다.
-.typeError {
+    .fox-button__icon,
+    .fox-button__spinner {
+      inline-size: fox.form(icon-md);
+      block-size: fox.form(icon-md);
+    }
+  }
+
+  &--sm {
+    block-size: fox.form(height-sm);
+    padding-inline: fox.form(padding-sm);
+    border-radius: fox.form(radius-sm);
+    gap: fox.gap(2);
+    font-size: fox.font-size(label-sm);
+
+    .fox-button__icon,
+    .fox-button__spinner {
+      inline-size: fox.form(icon-sm);
+      block-size: fox.form(icon-sm);
+    }
+  }
+
+  // xsm만 gap이 한 단계 좁다(시안 확인).
+  &--xsm {
+    block-size: fox.form(height-xsm);
+    padding-inline: fox.form(padding-xsm);
+    border-radius: fox.form(radius-xsm);
+    gap: fox.gap(1);
+    font-size: fox.font-size(label-xsm);
+
+    .fox-button__icon,
+    .fox-button__spinner {
+      inline-size: fox.form(icon-xsm);
+      block-size: fox.form(icon-xsm);
+    }
+  }
+
+  // ── 계열 ──────────────────────────────────────────────────────────────────
+  // 아이콘 색은 라벨 색과 별개 토큰으로 지정돼 있어(시안) 각각 지목한다 —
+  // 지금은 두 값이 같지만 토큰이 갈라지면 시안을 따라가야 한다.
+  &--primary {
+    background: fox.color(button-primary-surface);
+    color: fox.color(button-primary-font);
+
+    .fox-button__icon {
+      color: fox.color(icon-neutral-static-inverse);
+    }
+
+    &:hover:not(:disabled) {
+      background: fox.color(button-primary-surface-hover);
+    }
+
+    &:active:not(:disabled) {
+      background: fox.color(button-primary-surface-pressed);
+    }
+  }
+
+  &--secondary {
+    border: fox.border(1) solid fox.color(button-secondary-border);
+    background: fox.color(button-secondary-surface);
+    color: fox.color(button-secondary-font);
+
+    .fox-button__icon {
+      color: fox.color(icon-theme-primary-strong);
+    }
+
+    &:hover:not(:disabled) {
+      background: fox.color(button-secondary-surface-hover);
+    }
+
+    &:active:not(:disabled) {
+      background: fox.color(button-secondary-surface-pressed);
+    }
+  }
+
+  &--default {
+    border: fox.border(1) solid fox.color(button-default-border);
+    background: fox.color(button-default-surface);
+    color: fox.color(button-default-font);
+
+    .fox-button__icon {
+      color: fox.color(icon-neutral-default);
+    }
+
+    &:hover:not(:disabled) {
+      background: fox.color(button-default-surface-hover);
+    }
+
+    &:active:not(:disabled) {
+      background: fox.color(button-default-surface-pressed);
+    }
+  }
+
+  // ⚠️ error 계열은 시안 미정이라 규칙을 두지 않는다. 확정되면 여기에 채운다.
+  &--error {
+  }
 }
 
 // ── 비활성 ──────────────────────────────────────────────────────────────────
 // 계열과 무관하게 같은 모양이고 **테두리가 없다**(시안에서 disabled-border 미사용).
-// `.button:disabled`가 계열 클래스보다 특이도가 높아 뒤에 두지 않아도 이긴다.
-.button:disabled {
+// 계열 모디파이어보다 특이도가 높아야 이기므로 `.fox-button` 블록과 함께 지목한다.
+.fox-button:disabled {
   border: none;
   background: fox.color(button-disabled-surface);
   color: fox.color(button-disabled-font);
 
-  .icon {
+  .fox-button__icon {
     color: fox.color(icon-neutral-disabled-strong);
   }
 }
 
@fox/styles/components.scss (added)
+++ @fox/styles/components.scss
@@ -0,0 +1,14 @@
+// 공용 컴포넌트 스타일 **묶음** 진입점 — 한 줄로 전부 가져올 때 쓴다.
+//
+//   @use "@fox/styles/components";
+//
+// 필요한 것만 쓰려면 개별 파티셜을 직접 가져온다(안 쓰는 컴포넌트 CSS가 번들에 안 실린다).
+//
+//   @use "@fox/styles/fox-button";
+//
+// 둘을 같이 써도 Sass가 모듈을 한 번만 로드하므로 CSS가 중복되지 않는다.
+//
+// ⚠️ 토큰(`@use "@fox/styles"`)은 별도다. 컴포넌트 스타일은 토큰 커스텀 프로퍼티를
+// 참조하므로, 이 파일만 가져오고 토큰 진입점을 빼면 값이 비어 렌더된다.
+
+@use "fox-button";
app/globals.scss
--- app/globals.scss
+++ app/globals.scss
@@ -6,6 +6,10 @@
 
 @use "@fox/styles";
 
+// 공용 컴포넌트 스타일. CSS Modules가 아니라 전역 BEM이라 여기서 한 번 불러온다 —
+// 그래야 React 밖(다른 프레임워크·순수 SCSS)에서도 같은 방식으로 쓸 수 있다.
+@use "@fox/styles/components";
+
 // 앱 셸 — 루트 레이아웃이 뷰포트 높이를 채우고 세로로 쌓이는 구조.
 // 푸터를 바닥에 붙이거나 본문 영역만 스크롤시키는 화면들이 이 전제에 기댄다.
 html,
Add a comment
List