# @fox — 포터블 디자인 시스템 프로젝트에 종속되지 않는 디자인 시스템. **이 폴더를 통째로 복사하고 설정 몇 줄을 추가하면** 다른 프로젝트에서 그대로 동작합니다. ``` @fox/ styles/ SCSS — 여기가 디자인의 전부입니다 index.scss 토큰 진입점 (앱이 한 번 @use) components.scss 공용 컴포넌트 스타일 묶음 진입점 _fox-*.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. **폰트를 공급합니다** — 아래 "호스트 앱과의 계약" 참고. 빠뜨리면 컴포넌트가 OS 기본 서체로 그려져 글자 폭이 시안과 어긋납니다. ## React 없이 쓰기 **디자인 레이어는 프레임워크에 의존하지 않습니다.** 토큰도 컴포넌트 스타일도 순수 SCSS이고, 클래스명이 `fox-` 접두사 + BEM이라 전역에 풀어도 충돌하지 않습니다. Vue·Svelte·서버 템플릿·순수 HTML 어디서든 위 2~4번만 하고 마크업 계약을 지키면 같은 결과가 나옵니다. 각 컴포넌트의 **마크업 계약은 해당 스타일 파일 상단 주석**에 있습니다. 버튼 네 종류는 이렇습니다. ```html 링크
…
…

전화번호
- -
``` - 비활성은 네이티브 `disabled` 속성으로 표현합니다(별도 클래스 없음). 앵커에는 `disabled`가 없으므로 `aria-disabled="true"`를 쓰고 `href`를 뺍니다. - 로딩은 앞 아이콘 자리에 `fox-button__spinner`를 두고 `disabled`를 함께 겁니다. - 토글은 `fox-icon-button`에 `aria-pressed="true|false"`를 더합니다. - 버튼 묶음은 간격·방향·정렬만 담당합니다. 자식 버튼의 크기 클래스를 그룹 크기로 맞추는 건 작성자 몫입니다(React 래퍼는 `size` prop으로 자동화합니다). 세로(`--vertical`)는 자식이 폭을 채웁니다. - 버튼 패널은 그룹이 여럿이면 양끝으로, 하나면 가운데로 둡니다 — 자식 수는 CSS `:only-child`가 판단하므로 따로 표시할 게 없습니다. 패널 안에서는 그룹 간격이 넓어지고 버튼에 최소 폭이 붙으며, 모바일에서는 패널·그룹이 모두 세로로 쌓입니다. - 입력과 여러 줄 입력의 상태는 기본적으로 브라우저가 판단합니다 — 포커스는 `:focus-within`, 비활성·읽기전용은 네이티브 속성, 오류는 `aria-invalid="true"`. 모양을 못박으려면 루트에 `data-state`를 줍니다 (`default` `focused` `completed` `error` `disabled` `view`). - 여러 줄 입력에는 크기 축이 없습니다. 높이는 `rows`가 정하고 시안은 5줄입니다. - 이메일은 값이 `아이디@도메인`이 합쳐진 문자열 하나입니다. 도메인을 드롭다운에서 고르면 옆 칸에 그 값이 채워지고 읽기 전용이 되며, `직접입력`을 고르면 풀립니다. 확인 버튼은 `onVerify`를 넘겼을 때만 생깁니다. - 주소는 우편번호와 주소가 검색 결과로만 채워지는 읽기 전용이고 상세주소만 입력합니다. 어떤 우편번호 서비스를 쓸지는 `@fox`가 모릅니다 — 호출부가 `onSearch`에서 결과를 `FoxAddressSearchResult`(`zipCode`·`address`·선택 `detail`)로 돌려주고, 취소는 `null`입니다. - 휴대폰 인증은 보내기·확인을 호출부가 `Promise`로 넘깁니다 — resolve면 성공, reject면 실패이고 결과는 이벤트로 올라옵니다. 보내기가 성공해야 인증번호 칸과 확인 버튼이 열리고, 인증에 성공하면 전부 잠깁니다. 남은 시간은 `remainingSeconds`를 주면 그 값을, 없으면 `expiresIn`부터 컴포넌트가 셉니다. - 전화번호는 화면만 세 칸이고 값은 숫자만 담긴 문자열 하나입니다 — 하이픈은 값에 들어가지 않습니다. `unit`은 앞자리가 드롭다운이라 조각을 select·input으로 조립하고, `combine`은 상자 하나에 세 칸을 둡니다. - 파일 첨부는 세 모드(`default`·`area`·`image`)를 한 컴포넌트로 둡니다. 업로드는 하지 않고 고른 원본 `File`만 넘기며, 진행률·서버 오류를 목록에 어떻게 비출지는 `files`로 정합니다. `area`는 끌어다 놓기가 항상 켜져 있고 `image`에는 목록이 없습니다. - 아이콘 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= python3 @fox/tools/build-tokens.py ``` **아이콘도 같은 방식으로 생성됩니다.** `core/icons/`의 1,512종은 손으로 그리지 않았고 `tools/build-icons.py`가 [Phosphor Icons](https://github.com/phosphor-icons/core)(MIT) 원본에서 만듭니다. 시안의 `ico/*`가 Phosphor를 그대로 올린 것이라 그림이 같습니다 — 기존 8종과 새 생성물을 256×256으로 래스터 비교했을 때 차이가 잉크의 0.33% 이하(안티에일리어싱 경계)였고 `Pause`는 0픽셀이었습니다. ```bash FOX_ICONS_SRC= python3 @fox/tools/build-icons.py ``` 굵기는 이름이 아니라 **`weight` prop**입니다. 시안의 `Weight=` 변형과 1:1로 맞습니다. ```tsx import { FoxHouseIcon, FoxPlayIcon } from "@fox/core/icons"; {/* 기본 regular */} {/* thin · light · regular · bold · fill · duotone */} ``` > ⚠️ 시안이 굵기를 지정한 자리에서 `weight`를 빠뜨리면 조용히 다른 그림이 됩니다. 특히 > `Play`·`Pause`는 시안이 **fill**이라, 기본값 regular로 두면 속이 빈 도형이 됩니다. 배럴이 크지만 프로덕션 번들에는 실제 쓰는 글리프만 들어갑니다. dev 빌드는 트리셰이킹을 하지 않아 청크가 커집니다. **규율을 컴파일러가 강제합니다.** 화면 코드는 `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`이 담당합니다. 다크 전용 오버라이드 블록이 없어 테마 불일치가 구조적으로 불가능합니다. ``로 수동 선택을 덮어쓸 수 있고, 속성이 없으면 OS 설정을 따릅니다. > `light-dark()` 자체는 Chrome 123 / Safari 17.5 / Firefox 120 이상이 필요하지만, > Lightning CSS(Next.js Turbopack)가 커스텀 프로퍼티 조합으로 폴리필해 출력하므로 구형 > 브라우저에서도 동작합니다. browserslist 타깃을 좁히거나 다른 번들러로 옮길 때 재확인하세요. ## 호스트 앱과의 계약 **폰트 파일은 앱이 소유합니다.** `@fox`는 폰트를 담고 있지 않으므로, 앱이 아래를 공급하지 않으면 브라우저 기본 서체로 그려집니다. | 앱이 공급할 것 | 쓰이는 곳 | 미공급 시 | | --- | --- | --- | | `@font-face { font-family: "Pretendard" }` | **`@fox` 컴포넌트 전부** | OS 기본 서체(맥 Apple SD Gothic Neo / 윈도 맑은 고딕)로 갈라짐 | | `--app-font-sans` | `@fox` 밖 앱 텍스트 | 앱이 알아서 폴백 | | `--app-font-mono` | 고정폭이 필요한 앱 텍스트 | 앱이 알아서 폴백 | > ⚠️ `@fox` 컴포넌트의 서체는 `--app-font-sans`가 **아닙니다.** 토큰 > (`tokens/_primitive.scss`의 `font.family.*`)이 패밀리명을 리터럴 `Pretendard`로 못 박고 > 있어, 그 이름의 `@font-face`를 앱이 선언해 줘야 합니다. 폰트를 바꾸려면 Figma 원본 토큰을 > 고쳐 재생성하는 수밖에 없습니다 — 즉 이 지점만은 이식성이 없습니다. > > 필요한 굵기는 토큰이 쓰는 **400 / 600 / 800** 세 개입니다. 800을 빼면 `font-weight(bold)`가 > 가짜 굵기로 합성됩니다. > > `next/font/local`은 패밀리명을 해시(`__Pretendard_xxxx`)로 바꾸므로 이 용도로는 쓸 수 > 없습니다. 이 저장소는 `app/globals.scss`에 `@font-face`를 직접 선언합니다. ## 새 컴포넌트 추가 `core/components/README.md`의 작성 규약을 따릅니다. 요점은 스타일을 `styles/_.scss`에 두고 `fox-` 접두사 + BEM으로 이름 짓는 것 — 그래야 React 밖에서도 쓸 수 있습니다.