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
... | ... | @@ -0,0 +1,152 @@ |
| 1 | +# @fox — 포터블 디자인 시스템 | |
| 2 | + | |
| 3 | +프로젝트에 종속되지 않는 디자인 시스템. **이 폴더를 통째로 복사하고 설정 몇 줄을 추가하면** | |
| 4 | +다른 프로젝트에서 그대로 동작합니다. | |
| 5 | + | |
| 6 | +``` | |
| 7 | +@fox/ | |
| 8 | + styles/ SCSS — 여기가 디자인의 전부입니다 | |
| 9 | + index.scss 토큰 진입점 (앱이 한 번 @use) | |
| 10 | + components.scss 공용 컴포넌트 스타일 묶음 진입점 | |
| 11 | + _fox-button.scss 컴포넌트별 스타일 (개별 @use 가능) | |
| 12 | + abstracts.scss 저작 진입점 — 함수·믹스인 (CSS 출력 0) | |
| 13 | + tokens/ 토큰 map = 단일 진실 공급원 (자동 생성) | |
| 14 | + _root.scss map → --fox-* 커스텀 프로퍼티 | |
| 15 | + _functions.scss fox.color() 등 토큰 접근 + 이름 검증 | |
| 16 | + _mixins.scss fox.pc / fox.mobile | |
| 17 | + core/ | |
| 18 | + components/ React 래퍼 (선택 — 아래 "React 없이 쓰기" 참고) | |
| 19 | + utils/ cx() 등 의존성 없는 유틸 | |
| 20 | + dev-test/ 개발 전용 테스트·감사 화면 | |
| 21 | + tools/build-tokens.py Figma JSON → SCSS 변환기 | |
| 22 | +``` | |
| 23 | + | |
| 24 | +## 이식하기 | |
| 25 | + | |
| 26 | +1. `@fox/` 폴더를 대상 프로젝트에 복사합니다. | |
| 27 | + | |
| 28 | +2. `sass`를 설치합니다. | |
| 29 | + | |
| 30 | + ```bash | |
| 31 | + npm install --save-dev sass | |
| 32 | + ``` | |
| 33 | + | |
| 34 | +3. **SCSS 로드 경로**를 설정합니다. Next.js면 `next.config.ts`: | |
| 35 | + | |
| 36 | + ```ts | |
| 37 | + sassOptions: { loadPaths: [path.join(process.cwd())] } | |
| 38 | + ``` | |
| 39 | + | |
| 40 | + 다른 번들러면 그 도구의 Sass 옵션(`includePaths` / `loadPaths`)에 프로젝트 루트를 | |
| 41 | + 넣으면 됩니다. 이게 있어야 `@use "@fox/styles/..."` 같은 절대 표기가 해석되고, 없으면 | |
| 42 | + 컴포넌트마다 `../../../` 상대 경로를 써야 해서 폴더 복사가 불가능해집니다. | |
| 43 | + | |
| 44 | +4. 글로벌 스타일에서 진입점을 불러옵니다. | |
| 45 | + | |
| 46 | + ```scss | |
| 47 | + @use "@fox/styles"; // 토큰 | |
| 48 | + @use "@fox/styles/components"; // 공용 컴포넌트 스타일 (전부) | |
| 49 | + ``` | |
| 50 | + | |
| 51 | + 골라 쓰려면 묶음 대신 개별 파티셜을 가져옵니다 — 안 쓰는 CSS가 번들에 실리지 않습니다. | |
| 52 | + 둘을 같이 써도 Sass가 모듈을 한 번만 로드해 중복되지 않습니다. | |
| 53 | + | |
| 54 | + ```scss | |
| 55 | + @use "@fox/styles/fox-button"; | |
| 56 | + ``` | |
| 57 | + | |
| 58 | +5. (TypeScript 프로젝트에서 React 래퍼도 쓸 경우) 경로 별칭을 추가합니다. | |
| 59 | + | |
| 60 | + ```json | |
| 61 | + { "compilerOptions": { "paths": { "@fox/*": ["./@fox/*"] } } } | |
| 62 | + ``` | |
| 63 | + | |
| 64 | +6. (선택) 폰트를 주입합니다 — 아래 "호스트 앱과의 계약" 참고. | |
| 65 | + | |
| 66 | +## React 없이 쓰기 | |
| 67 | + | |
| 68 | +**디자인 레이어는 프레임워크에 의존하지 않습니다.** 토큰도 컴포넌트 스타일도 순수 SCSS이고, | |
| 69 | +클래스명이 `fox-` 접두사 + BEM이라 전역에 풀어도 충돌하지 않습니다. Vue·Svelte·서버 | |
| 70 | +템플릿·순수 HTML 어디서든 위 2~4번만 하고 마크업 계약을 지키면 같은 결과가 나옵니다. | |
| 71 | + | |
| 72 | +각 컴포넌트의 **마크업 계약은 해당 스타일 파일 상단 주석**에 있습니다. 버튼은 이렇습니다. | |
| 73 | + | |
| 74 | +```html | |
| 75 | +<button class="fox-button fox-button--primary fox-button--md"> | |
| 76 | + <span class="fox-button__icon"><!-- svg (선택) --></span> | |
| 77 | + <span class="fox-button__label">버튼</span> | |
| 78 | + <span class="fox-button__icon"><!-- svg (선택) --></span> | |
| 79 | +</button> | |
| 80 | +``` | |
| 81 | + | |
| 82 | +- 비활성은 네이티브 `disabled` 속성으로 표현합니다(별도 클래스 없음). | |
| 83 | +- 로딩은 앞 아이콘 자리에 `fox-button__spinner`를 두고 `disabled`를 함께 겁니다. | |
| 84 | +- 아이콘 SVG는 `currentColor`로 그려야 계열별 색이 적용됩니다. 크기는 슬롯이 정합니다. | |
| 85 | + | |
| 86 | +`core/`의 React 컴포넌트는 이 클래스를 조립해주는 **얇은 래퍼**일 뿐입니다 — 쓰지 않아도 | |
| 87 | +디자인은 그대로 얻습니다. | |
| 88 | + | |
| 89 | +토큰만 쓰는 것도 물론 됩니다. | |
| 90 | + | |
| 91 | +```scss | |
| 92 | +@use "@fox/styles/abstracts" as fox; | |
| 93 | + | |
| 94 | +.whatever { | |
| 95 | + padding: fox.padding(6); | |
| 96 | + color: fox.color(font-neutral-default); | |
| 97 | + @include fox.pc { padding: fox.padding(8); } | |
| 98 | +} | |
| 99 | +``` | |
| 100 | + | |
| 101 | +## 핵심 설계 | |
| 102 | + | |
| 103 | +**토큰의 SSOT는 `tokens/`의 SCSS map 하나입니다.** 여기서 두 가지가 함께 파생됩니다 — | |
| 104 | +`_root.scss`가 만드는 `--fox-*` 커스텀 프로퍼티와, `_functions.scss`가 검증에 쓰는 유효 | |
| 105 | +이름 목록. 값과 이름이 한 곳에만 있으므로 둘이 어긋날 수 없습니다. | |
| 106 | + | |
| 107 | +**토큰은 Figma에서 자동 생성됩니다. 손으로 고치지 않습니다.** Figma 변수를 재수출한 뒤 | |
| 108 | +변환기를 다시 돌리면 됩니다. 생성물이 재현 가능해서 재실행 diff가 곧 Figma 변경분입니다. | |
| 109 | + | |
| 110 | +```bash | |
| 111 | +FOX_TOKENS_SRC=<export 폴더> python3 @fox/tools/build-tokens.py | |
| 112 | +``` | |
| 113 | + | |
| 114 | +**규율을 컴파일러가 강제합니다.** 화면 코드는 `color: #256EF4`를 쓸 수 없고 | |
| 115 | +`fox.color(...)`로 토큰을 지목해야 합니다. 없는 이름은 빌드가 실패하며 사용 가능한 목록을 | |
| 116 | +함께 출력합니다. | |
| 117 | + | |
| 118 | +``` | |
| 119 | +Error: [@fox] 알 수 없는 color 토큰: `primry` — 사용 가능한 값: background-default, ... | |
| 120 | +``` | |
| 121 | + | |
| 122 | +**1rem = 10px입니다.** `_root.scss`의 `html { font-size: 62.5% }`가 근거입니다. | |
| 123 | + | |
| 124 | +> ⚠️ **미디어 쿼리 안의 `rem`은 예외입니다.** 미디어 쿼리는 `html`의 `font-size` 영향을 | |
| 125 | +> 받지 않습니다. 그래서 브레이크포인트는 `768px`로 두었고, 미디어 쿼리는 `fox.pc` / | |
| 126 | +> `fox.mobile` 믹스인으로만 씁니다 — 조건부는 `var()`를 해석하지 못합니다. | |
| 127 | + | |
| 128 | +**라이트/다크는 값이 한 곳에만 존재합니다.** `light-dark(라이트, 다크)`로 한 줄에 두 값을 | |
| 129 | +쓰고 전환은 `color-scheme`이 담당합니다. 다크 전용 오버라이드 블록이 없어 테마 불일치가 | |
| 130 | +구조적으로 불가능합니다. `<html data-theme="light"|"dark">`로 수동 선택을 덮어쓸 수 있고, | |
| 131 | +속성이 없으면 OS 설정을 따릅니다. | |
| 132 | + | |
| 133 | +> `light-dark()` 자체는 Chrome 123 / Safari 17.5 / Firefox 120 이상이 필요하지만, | |
| 134 | +> Lightning CSS(Next.js Turbopack)가 커스텀 프로퍼티 조합으로 폴리필해 출력하므로 구형 | |
| 135 | +> 브라우저에서도 동작합니다. browserslist 타깃을 좁히거나 다른 번들러로 옮길 때 재확인하세요. | |
| 136 | + | |
| 137 | +## 호스트 앱과의 계약 | |
| 138 | + | |
| 139 | +`@fox`가 소유하지 않고 앱에서 받는 값입니다. | |
| 140 | + | |
| 141 | +| CSS 변수 | 용도 | 미주입 시 | | |
| 142 | +| --- | --- | --- | | |
| 143 | +| `--app-font-sans` | 기본 서체 | `system-ui`로 폴백 | | |
| 144 | +| `--app-font-mono` | 고정폭 서체 | `ui-monospace`로 폴백 | | |
| 145 | + | |
| 146 | +폰트 파일은 앱이 소유합니다. `@fox`는 변수를 참조만 하므로, 복사해 간 프로젝트는 그 | |
| 147 | +프로젝트의 폰트를 그대로 씁니다. | |
| 148 | + | |
| 149 | +## 새 컴포넌트 추가 | |
| 150 | + | |
| 151 | +`core/components/README.md`의 작성 규약을 따릅니다. 요점은 스타일을 `styles/_<name>.scss`에 | |
| 152 | +두고 `fox-` 접두사 + BEM으로 이름 짓는 것 — 그래야 React 밖에서도 쓸 수 있습니다. |
+++ @fox/core/components/README.md
... | ... | @@ -0,0 +1,91 @@ |
| 1 | +# 컴포넌트 작성 규약 | |
| 2 | + | |
| 3 | +## 파일 배치 | |
| 4 | + | |
| 5 | +컴포넌트 하나당 폴더 하나. **스타일은 여기에 두지 않는다** — `@fox/styles/_<name>.scss`가 소유한다. | |
| 6 | + | |
| 7 | +``` | |
| 8 | +core/components/ | |
| 9 | + fox-button/ | |
| 10 | + fox-button.tsx 컴포넌트 (React) | |
| 11 | + index.ts export { FoxButton } from "./fox-button"; | |
| 12 | + index.ts 배럴 — export * from "./fox-button"; | |
| 13 | + | |
| 14 | +styles/ | |
| 15 | + _fox-button.scss 모양 규칙 (프레임워크 무관) | |
| 16 | + components.scss 전부 묶음 진입점 — 새 컴포넌트를 여기에 @use로 추가 | |
| 17 | +``` | |
| 18 | + | |
| 19 | +**스타일과 컴포넌트를 갈라 둔 이유**는 디자인이 React에 묶이면 안 되기 때문이다. SCSS만 | |
| 20 | +쓰는 프로젝트(Vue·Svelte·서버 템플릿 등)도 `@use "@fox/styles/fox-button"` 하나로 같은 | |
| 21 | +버튼을 얻어야 한다. | |
| 22 | + | |
| 23 | +## 작성 규칙 | |
| 24 | + | |
| 25 | +**1. 클래스명은 `fox-` 접두사 + BEM.** | |
| 26 | + | |
| 27 | +``` | |
| 28 | +.fox-button 블록 | |
| 29 | +.fox-button__label 엘리먼트 | |
| 30 | +.fox-button--primary 모디파이어 | |
| 31 | +``` | |
| 32 | + | |
| 33 | +CSS Modules를 쓰지 않는다. 이름이 전역 유일해서 스코프가 필요 없고, 스코프가 없어야 | |
| 34 | +React 밖에서도 같은 이름으로 쓸 수 있다. **충돌 방지는 접두사 규율이 담당한다.** | |
| 35 | + | |
| 36 | +**2. 모양 값은 전부 `fox.*()` 토큰으로 지목한다.** | |
| 37 | + | |
| 38 | +```scss | |
| 39 | +@use "abstracts" as fox; | |
| 40 | + | |
| 41 | +.fox-button { | |
| 42 | + padding-inline: fox.form(padding-md); | |
| 43 | + background: fox.color(button-primary-surface); | |
| 44 | + border-radius: fox.form(radius-md); | |
| 45 | +} | |
| 46 | +``` | |
| 47 | + | |
| 48 | +원시 값(hex·px·rem 리터럴) 금지. 없는 토큰 이름은 빌드가 실패시킨다. 맞는 토큰이 없으면 | |
| 49 | +쓰지 말고 Figma에 먼저 추가한다 — 토큰 파일은 자동 생성물이다. | |
| 50 | + | |
| 51 | +토큰으로 표현할 수 없는 값(시안에 있으나 변수화되지 않은 것 등)이 나오면 **사용자와 상의한 | |
| 52 | +뒤** 넣고, 파일 상단 주석에 근거를 남긴다. | |
| 53 | + | |
| 54 | +**3. 상태는 네이티브 속성으로.** | |
| 55 | + | |
| 56 | +`disabled`·`aria-busy` 같은 표준 속성을 쓰고 `.is-disabled` 같은 클래스를 만들지 않는다. | |
| 57 | +SCSS만 쓰는 소비자도 별도 규칙 없이 같은 결과를 얻는다. | |
| 58 | + | |
| 59 | +**4. 마크업 계약을 스타일 파일 상단에 적는다.** | |
| 60 | + | |
| 61 | +React 밖 소비자는 그 주석만 보고 DOM을 짜야 한다. 필수 구조와 선택 요소를 예시로 남긴다. | |
| 62 | + | |
| 63 | +**5. 기본은 Server Component.** | |
| 64 | + | |
| 65 | +`'use client'`는 상호작용(상태·이벤트 핸들러)이 실제로 필요할 때만 붙인다. | |
| 66 | + | |
| 67 | +**6. variant는 `Record`로 고정한다.** | |
| 68 | + | |
| 69 | +```tsx | |
| 70 | +const TYPE_CLASS: Record<FoxButtonType, string> = { | |
| 71 | + primary: "fox-button--primary", | |
| 72 | + secondary: "fox-button--secondary", | |
| 73 | +}; | |
| 74 | +``` | |
| 75 | + | |
| 76 | +계열을 추가하면 맵 누락이 타입 에러가 된다. | |
| 77 | + | |
| 78 | +**7. `className` prop은 배치용으로만 연다.** | |
| 79 | + | |
| 80 | +호출부가 넘기는 것은 margin·grid 배치 같은 **위치** 조정이지 디자인 값이 아니다. 모양이 | |
| 81 | +달라져야 하면 모디파이어를 추가한다. | |
| 82 | + | |
| 83 | +**8. 호스트 앱에 의존하지 않는다.** | |
| 84 | + | |
| 85 | +`@fox/` 안에서 `@/lib/...` 같은 앱 경로를 import 하지 않는다. 앱이 주는 값은 prop이나 | |
| 86 | +CSS 변수로 받는다. | |
| 87 | + | |
| 88 | +**9. 만들면 테스트 페이지에 등록한다.** | |
| 89 | + | |
| 90 | +`@fox/dev-test/component-registry.tsx`의 `COMPONENT_EXAMPLES`에 항목 하나를 추가하면 | |
| 91 | +`/dev-test/design`에 나타난다. |
+++ @fox/styles/components.scss
... | ... | @@ -0,0 +1,14 @@ |
| 1 | +// 공용 컴포넌트 스타일 **묶음** 진입점 — 한 줄로 전부 가져올 때 쓴다. | |
| 2 | +// | |
| 3 | +// @use "@fox/styles/components"; | |
| 4 | +// | |
| 5 | +// 필요한 것만 쓰려면 개별 파티셜을 직접 가져온다(안 쓰는 컴포넌트 CSS가 번들에 안 실린다). | |
| 6 | +// | |
| 7 | +// @use "@fox/styles/fox-button"; | |
| 8 | +// | |
| 9 | +// 둘을 같이 써도 Sass가 모듈을 한 번만 로드하므로 CSS가 중복되지 않는다. | |
| 10 | +// | |
| 11 | +// ⚠️ 토큰(`@use "@fox/styles"`)은 별도다. 컴포넌트 스타일은 토큰 커스텀 프로퍼티를 | |
| 12 | +// 참조하므로, 이 파일만 가져오고 토큰 진입점을 빼면 값이 비어 렌더된다. | |
| 13 | + | |
| 14 | +@use "fox-button"; |
--- app/globals.scss
+++ app/globals.scss
... | ... | @@ -6,6 +6,10 @@ |
| 6 | 6 |
|
| 7 | 7 |
@use "@fox/styles"; |
| 8 | 8 |
|
| 9 |
+// 공용 컴포넌트 스타일. CSS Modules가 아니라 전역 BEM이라 여기서 한 번 불러온다 — |
|
| 10 |
+// 그래야 React 밖(다른 프레임워크·순수 SCSS)에서도 같은 방식으로 쓸 수 있다. |
|
| 11 |
+@use "@fox/styles/components"; |
|
| 12 |
+ |
|
| 9 | 13 |
// 앱 셸 — 루트 레이아웃이 뷰포트 높이를 채우고 세로로 쌓이는 구조. |
| 10 | 14 |
// 푸터를 바닥에 붙이거나 본문 영역만 스크롤시키는 화면들이 이 전제에 기댄다. |
| 11 | 15 |
html, |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?