feat: @fox 포터블 디자인 시스템 패키지 추가
토큰 SSOT는 tokens/의 SCSS map 하나다. 여기서 --fox-* 커스텀 프로퍼티(_root)와 토큰 이름 검증 목록(_functions)이 함께 파생되므로 값과 이름이 어긋날 수 없다. 없는 토큰 이름은 @error로 빌드를 실패시켜 하드코딩 금지를 컴파일러가 강제한다. 모든 치수는 rem이며 1rem = 10px(html font-size 62.5%). 단 미디어 쿼리의 rem은 브라우저 기본 크기 기준이라 브레이크포인트만 예외이며, 각 값에 px 환산을 병기했다. 토큰 값은 파이프라인 검증용 임시 플레이스홀더이며 Figma 확정 시 교체한다. Co-Authored-By: Claude Opus 5
@4353694dfe1021847ba046358b979219133c58fe
+++ @fox/README.md
... | ... | @@ -0,0 +1,123 @@ |
| 1 | +# @fox — 포터블 디자인 시스템 | |
| 2 | + | |
| 3 | +프로젝트에 종속되지 않는 디자인 시스템 패키지. **이 폴더를 통째로 복사하고 설정 2줄을 | |
| 4 | +추가하면** 다른 프로젝트에서 그대로 동작합니다. | |
| 5 | + | |
| 6 | +``` | |
| 7 | +@fox/ | |
| 8 | + styles/ SCSS 토큰 · 함수 · 믹스인 | |
| 9 | + index.scss 글로벌 진입점 (앱이 딱 한 번 import — CSS 출력) | |
| 10 | + abstracts.scss 저작 진입점 (컴포넌트가 @use — CSS 출력 0) | |
| 11 | + tokens/ 토큰 map = 단일 진실 공급원(SSOT) | |
| 12 | + _root.scss map → --fox-* 커스텀 프로퍼티 생성 | |
| 13 | + _reset.scss 리셋 & 베이스 | |
| 14 | + _functions.scss fox.color() 등 토큰 접근 + 검증 | |
| 15 | + _mixins.scss fox.typo() · fox.media() 등 조합 패턴 | |
| 16 | + core/ | |
| 17 | + components/ 공용 컴포넌트 (규약은 components/README.md) | |
| 18 | + utils/ cx() 등 의존성 없는 유틸 | |
| 19 | +``` | |
| 20 | + | |
| 21 | +## 다른 프로젝트로 이식하기 | |
| 22 | + | |
| 23 | +1. `@fox/` 폴더를 대상 프로젝트 루트에 복사합니다. | |
| 24 | + | |
| 25 | +2. `sass`를 설치합니다. | |
| 26 | + | |
| 27 | + ```bash | |
| 28 | + npm install --save-dev sass | |
| 29 | + ``` | |
| 30 | + | |
| 31 | +3. **TypeScript 경로 별칭** — `tsconfig.json`: | |
| 32 | + | |
| 33 | + ```json | |
| 34 | + { "compilerOptions": { "paths": { "@fox/*": ["./@fox/*"] } } } | |
| 35 | + ``` | |
| 36 | + | |
| 37 | +4. **SCSS 로드 경로** — `next.config.ts`: | |
| 38 | + | |
| 39 | + ```ts | |
| 40 | + sassOptions: { loadPaths: [path.join(process.cwd())] } | |
| 41 | + ``` | |
| 42 | + | |
| 43 | + TypeScript의 `paths`는 번들러 전용이라 Sass의 `@use` 해석에는 관여하지 않습니다. | |
| 44 | + 그래서 같은 경로를 양쪽에 각각 선언해야 TS와 SCSS의 import 표기가 일치합니다. | |
| 45 | + 이게 없으면 컴포넌트마다 `../../../` 상대 경로를 써야 해서 폴더 복사가 불가능해집니다. | |
| 46 | + | |
| 47 | +5. 앱의 글로벌 스타일에서 진입점을 한 번 import 합니다. | |
| 48 | + | |
| 49 | + ```scss | |
| 50 | + /* app/globals.scss */ | |
| 51 | + @use "@fox/styles"; | |
| 52 | + ``` | |
| 53 | + | |
| 54 | +6. (선택) 폰트를 주입합니다 — 아래 "호스트 앱과의 계약" 참고. | |
| 55 | + | |
| 56 | +Next.js가 아닌 번들러라면 4번만 그 도구의 Sass 옵션(`includePaths` / `loadPaths`)으로 | |
| 57 | +바꿔주면 됩니다. | |
| 58 | + | |
| 59 | +## 핵심 설계 | |
| 60 | + | |
| 61 | +**토큰의 SSOT는 `tokens/`의 SCSS map 하나뿐입니다.** 여기서 두 가지가 함께 파생됩니다 — | |
| 62 | +`_root.scss`가 만드는 `--fox-*` CSS 커스텀 프로퍼티와, `_functions.scss`가 검증에 쓰는 | |
| 63 | +유효 이름 목록. 값과 이름이 한 곳에만 있으므로 둘이 어긋날 수 없습니다. | |
| 64 | + | |
| 65 | +**규율을 컴파일러가 강제합니다.** 화면 코드는 `color: #4f46e5`를 쓸 수 없고 | |
| 66 | +`fox.color(primary)`로 토큰을 지목해야 합니다. 없는 이름을 쓰면 빌드가 실패하며, 에러 | |
| 67 | +메시지가 사용 가능한 토큰 목록을 함께 출력합니다. | |
| 68 | + | |
| 69 | +``` | |
| 70 | +Error: [@fox] 알 수 없는 color 토큰: `primry` — 사용 가능한 값: primary, primary-hover, ... | |
| 71 | +``` | |
| 72 | + | |
| 73 | +**1rem = 10px입니다.** `_reset.scss`의 `html { font-size: 62.5% }`가 근거입니다. px → rem | |
| 74 | +환산이 10으로 나누기가 되어 토큰 값이 읽기 쉽고, px 고정과 달리 사용자가 브라우저 글자 | |
| 75 | +크기를 키우면 앱 전체가 비례해 확대됩니다. | |
| 76 | + | |
| 77 | +> ⚠️ **미디어 쿼리 안의 `rem`은 예외입니다.** 미디어 쿼리는 `html`의 `font-size` 영향을 | |
| 78 | +> 받지 않고 항상 브라우저 기본 크기(보통 16px) 기준으로 계산합니다. 그래서 | |
| 79 | +> `tokens/_layout.scss`의 브레이크포인트 `48rem`은 480px가 아니라 **768px**입니다. | |
| 80 | +> 각 값에 px 환산을 주석으로 달아 두었습니다. | |
| 81 | + | |
| 82 | +**라이트/다크는 값이 한 곳에만 존재합니다.** `light-dark(라이트, 다크)`로 한 줄에 두 값을 | |
| 83 | +함께 쓰고, 전환은 `color-scheme`이 담당합니다. 다크 전용 오버라이드 블록이 없으므로 한쪽만 | |
| 84 | +고쳐서 생기는 테마 불일치가 구조적으로 불가능합니다. | |
| 85 | +`<html data-theme="light"|"dark">`로 수동 선택을 덮어쓸 수 있고, 속성이 없으면 OS 설정을 | |
| 86 | +따릅니다. | |
| 87 | + | |
| 88 | +> **브라우저 지원** — `light-dark()` 자체는 Chrome 123 / Safari 17.5 / Firefox 120 이상이 | |
| 89 | +> 필요하지만, Next.js 16(Turbopack)의 Lightning CSS가 현재 설정에서 이를 | |
| 90 | +> `var(--lightningcss-light, 라이트) var(--lightningcss-dark, 다크)` 조합으로 폴리필해 | |
| 91 | +> 출력하므로 구형 브라우저에서도 동작합니다. browserslist 타깃을 좁히면 폴리필이 빠지고 | |
| 92 | +> 원본 `light-dark()`가 그대로 나가므로, 타깃을 바꿀 때 이 전제를 다시 확인하세요. | |
| 93 | +> Lightning CSS를 쓰지 않는 번들러로 옮길 때도 마찬가지입니다. | |
| 94 | + | |
| 95 | +## 호스트 앱과의 계약 | |
| 96 | + | |
| 97 | +`@fox`가 소유하지 않고 앱에서 받는 값입니다. | |
| 98 | + | |
| 99 | +| CSS 변수 | 용도 | 미주입 시 | | |
| 100 | +| --- | --- | --- | | |
| 101 | +| `--app-font-sans` | 기본 서체 | `system-ui`로 폴백 | | |
| 102 | +| `--app-font-mono` | 고정폭 서체 | `ui-monospace`로 폴백 | | |
| 103 | + | |
| 104 | +폰트 파일은 앱이 소유합니다(Next.js에서는 `next/font`가 최적화·자체 호스팅을 담당). `@fox`는 | |
| 105 | +변수를 참조만 하므로, 폴더를 복사해 간 프로젝트는 그 프로젝트의 폰트를 그대로 씁니다. | |
| 106 | + | |
| 107 | +```tsx | |
| 108 | +// app/layout.tsx | |
| 109 | +const sans = localFont({ src: [...], variable: "--app-font-sans" }); | |
| 110 | +<html className={sans.variable}> | |
| 111 | +``` | |
| 112 | + | |
| 113 | +테마 토글을 붙이려면 `<html>`의 `data-theme` 속성을 `"light" | "dark"`로 설정하고, 전환 | |
| 114 | +애니메이션이 필요하면 같은 tick에 `data-theme-transition` 속성을 부여한 뒤 200ms 후 | |
| 115 | +제거합니다(`_root.scss`가 그 동안만 색상 트랜지션을 겁니다). | |
| 116 | + | |
| 117 | +## 토큰 추가하기 | |
| 118 | + | |
| 119 | +1. `tokens/`의 해당 map에 항목을 추가합니다. 그것으로 끝입니다 — 커스텀 프로퍼티 생성과 | |
| 120 | + 함수 검증 목록이 자동으로 따라옵니다. | |
| 121 | +2. 값의 출처(Figma file/node 등)를 주석으로 남깁니다. | |
| 122 | +3. **새 토큰 그룹**(map 자체를 신설)이 필요하면 `tokens/_index.scss`에 `@forward`를 추가하고, | |
| 123 | + `_functions.scss`에 접근 함수를, `_root.scss`에 `@each` 블록을 각각 추가합니다. |
+++ @fox/core/components/README.md
... | ... | @@ -0,0 +1,87 @@ |
| 1 | +# 컴포넌트 작성 규약 | |
| 2 | + | |
| 3 | +아직 컴포넌트가 없습니다. 디자인 확정에 따라 하나씩 추가합니다. | |
| 4 | + | |
| 5 | +## 파일 배치 | |
| 6 | + | |
| 7 | +컴포넌트 하나당 폴더 하나. 스타일을 같은 폴더에 두어야 폴더째 복사·삭제가 가능합니다. | |
| 8 | + | |
| 9 | +``` | |
| 10 | +components/ | |
| 11 | + button/ | |
| 12 | + button.tsx 컴포넌트 | |
| 13 | + button.module.scss 스타일 (반드시 .module.scss — 전역 오염 방지) | |
| 14 | + index.ts export { Button } from "./button"; | |
| 15 | + index.ts 배럴 — export * from "./button"; | |
| 16 | +``` | |
| 17 | + | |
| 18 | +## 작성 규칙 | |
| 19 | + | |
| 20 | +**1. 스타일은 `abstracts`만 `@use` 한다.** | |
| 21 | + | |
| 22 | +```scss | |
| 23 | +@use "@fox/styles/abstracts" as fox; | |
| 24 | + | |
| 25 | +.button { | |
| 26 | + padding: fox.space(2) fox.space(4); | |
| 27 | + border-radius: fox.radius(md); | |
| 28 | + background: fox.color(primary); | |
| 29 | + color: fox.color(on-primary); | |
| 30 | + | |
| 31 | + @include fox.typo(label-lg); | |
| 32 | + @include fox.focus-ring; | |
| 33 | + @include fox.transition((background-color, color)); | |
| 34 | +} | |
| 35 | +``` | |
| 36 | + | |
| 37 | +`@fox/styles`(진입점)를 `@use` 하면 안 됩니다 — 그쪽은 CSS를 출력하므로 컴포넌트마다 | |
| 38 | +토큰 선언 전체가 복제됩니다. `abstracts`는 출력이 0입니다. | |
| 39 | + | |
| 40 | +**2. 원시 디자인 값을 쓰지 않는다.** | |
| 41 | + | |
| 42 | +`color: #4f46e5`, `padding: 13px`, `border-radius: 12px` 금지. 전부 `fox.*()` 함수로 | |
| 43 | +토큰을 지목합니다. 없는 토큰 이름을 쓰면 빌드가 실패하므로 오타는 배포까지 갈 수 | |
| 44 | +없습니다. **맞는 토큰이 없다고 해서 원시 값을 쓰지 말고, 토큰을 먼저 추가**합니다 | |
| 45 | +(`@fox/styles/tokens/`). | |
| 46 | + | |
| 47 | +예외는 의미가 고정되어 토큰화가 무의미한 값뿐입니다 — `0`, `100%`, `1px`(구분선), | |
| 48 | +`auto`, `inherit`. | |
| 49 | + | |
| 50 | +**3. 기본은 Server Component로 둔다.** | |
| 51 | + | |
| 52 | +`'use client'`는 상호작용(상태·이벤트 핸들러·브라우저 API)이 실제로 필요한 컴포넌트에만 | |
| 53 | +붙입니다. CSS Module은 빌드타임에 클래스명 문자열로 바뀔 뿐이라 런타임 JS가 없으므로, | |
| 54 | +스타일링만 하는 컴포넌트는 서버에 남을 수 있습니다. 여기에 불필요하게 `'use client'`를 | |
| 55 | +붙이면 이 컴포넌트를 쓰는 화면 전체가 클라이언트로 내려갑니다. | |
| 56 | + | |
| 57 | +**4. variant는 클래스 맵으로 고정한다.** | |
| 58 | + | |
| 59 | +```tsx | |
| 60 | +import styles from "./button.module.scss"; | |
| 61 | +import { cx } from "@fox/core/utils"; | |
| 62 | + | |
| 63 | +type ButtonVariant = "primary" | "secondary" | "ghost"; | |
| 64 | + | |
| 65 | +const VARIANT: Record<ButtonVariant, string> = { | |
| 66 | + primary: styles.primary, | |
| 67 | + secondary: styles.secondary, | |
| 68 | + ghost: styles.ghost, | |
| 69 | +}; | |
| 70 | + | |
| 71 | +export function Button({ variant = "primary", className, ...props }: ButtonProps) { | |
| 72 | + return <button className={cx(styles.button, VARIANT[variant], className)} {...props} />; | |
| 73 | +} | |
| 74 | +``` | |
| 75 | + | |
| 76 | +`Record<Variant, string>`이라 variant를 추가하면 맵에 항목을 빠뜨릴 수 없습니다(타입 에러). | |
| 77 | + | |
| 78 | +**5. `className` prop은 배치용으로만 열어 둔다.** | |
| 79 | + | |
| 80 | +호출부가 `className`으로 넘기는 것은 margin·grid 배치 같은 **위치** 조정이지, 색·크기 같은 | |
| 81 | +**디자인 값**이 아닙니다. 디자인이 달라져야 하면 variant를 추가합니다. | |
| 82 | + | |
| 83 | +**6. 호스트 앱에 의존하지 않는다.** | |
| 84 | + | |
| 85 | +`@fox/` 안에서는 `@/lib/...`, `@/app/...` 같은 앱 경로를 import 하지 않습니다. 이 폴더는 | |
| 86 | +다른 프로젝트로 통째로 복사되어야 하므로, 앱이 주는 값은 반드시 prop이나 CSS 변수로 | |
| 87 | +받습니다(폰트가 `--app-font-sans`를 받는 것과 같은 방식). |
+++ @fox/core/utils/cx.ts
... | ... | @@ -0,0 +1,15 @@ |
| 1 | +export type ClassValue = string | false | null | undefined; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 조건부 클래스명을 합친다. falsy 값은 버린다. | |
| 5 | + * | |
| 6 | + * cx(styles.button, isActive && styles.active, className) | |
| 7 | + * | |
| 8 | + * 유틸리티 CSS 프레임워크에서 쓰는 클래스 병합 라이브러리(tailwind-merge 등)가 | |
| 9 | + * 필요 없는 이유: CSS Module은 클래스명이 파일 단위로 스코프되므로 서로 다른 | |
| 10 | + * 컴포넌트의 클래스가 같은 속성을 두고 충돌하지 않는다. 한 컴포넌트 안에서의 | |
| 11 | + * 충돌은 선언 순서(뒤에 온 규칙이 이김)로 결정되며, 이는 CSS의 정상 동작이다. | |
| 12 | + */ | |
| 13 | +export function cx(...values: ClassValue[]): string { | |
| 14 | + return values.filter(Boolean).join(" "); | |
| 15 | +} |
+++ @fox/core/utils/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { cx, type ClassValue } from "./cx"; |
+++ @fox/styles/_functions.scss
... | ... | @@ -0,0 +1,75 @@ |
| 1 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 2 | +// 토큰 접근 함수 — 디자인 시스템 규율을 **컴파일러가 강제**하는 지점. | |
| 3 | +// | |
| 4 | +// 화면 코드는 `color: #4f46e5`나 `padding: 13px`를 쓸 수 없고, 반드시 | |
| 5 | +// `color: fox.color(primary)` / `padding: fox.space(3)`로 토큰을 지목해야 한다. | |
| 6 | +// 존재하지 않는 이름을 쓰면 `@error`로 **빌드가 실패한다** — 오타나 임의 값이 | |
| 7 | +// 리뷰를 통과해 배포까지 흘러가는 경로가 원천 차단된다. | |
| 8 | +// | |
| 9 | +// 반환값은 CSS 커스텀 프로퍼티 참조(`var(--fox-*)`)다. 값 자체가 아니라 참조를 | |
| 10 | +// 돌려주므로 (1) 라이트/다크가 런타임에 전환되고 (2) 특정 영역만 토큰을 덮어쓰는 | |
| 11 | +// 스코프 오버라이드가 가능하며 (3) devtools에서 어떤 토큰인지 그대로 보인다. | |
| 12 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 13 | + | |
| 14 | +@use "sass:map"; | |
| 15 | +@use "tokens"; | |
| 16 | + | |
| 17 | +/// 토큰 이름을 검증하고 대응하는 CSS 커스텀 프로퍼티 참조를 반환한다. | |
| 18 | +/// @param {String} $group - 토큰 그룹(커스텀 프로퍼티 접두사에 그대로 쓰인다) | |
| 19 | +/// @param {String|Number} $name - 토큰 이름 | |
| 20 | +/// @param {Map} $registry - 검증 기준이 되는 토큰 map | |
| 21 | +@function _token($group, $name, $registry) { | |
| 22 | + @if not map.has-key($registry, $name) { | |
| 23 | + @error "[@fox] 알 수 없는 #{$group} 토큰: `#{$name}` — 사용 가능한 값: #{map.keys($registry)}"; | |
| 24 | + } | |
| 25 | + | |
| 26 | + @return var(--fox-#{$group}-#{$name}); | |
| 27 | +} | |
| 28 | + | |
| 29 | +@function color($name) { | |
| 30 | + @return _token("color", $name, tokens.$color); | |
| 31 | +} | |
| 32 | + | |
| 33 | +@function space($name) { | |
| 34 | + @return _token("space", $name, tokens.$space); | |
| 35 | +} | |
| 36 | + | |
| 37 | +@function radius($name) { | |
| 38 | + @return _token("radius", $name, tokens.$radius); | |
| 39 | +} | |
| 40 | + | |
| 41 | +@function shadow($name) { | |
| 42 | + @return _token("shadow", $name, tokens.$shadow); | |
| 43 | +} | |
| 44 | + | |
| 45 | +@function duration($name) { | |
| 46 | + @return _token("duration", $name, tokens.$duration); | |
| 47 | +} | |
| 48 | + | |
| 49 | +@function easing($name) { | |
| 50 | + @return _token("easing", $name, tokens.$easing); | |
| 51 | +} | |
| 52 | + | |
| 53 | +@function font($name) { | |
| 54 | + @return _token("font", $name, tokens.$font-family); | |
| 55 | +} | |
| 56 | + | |
| 57 | +@function weight($name) { | |
| 58 | + @return _token("weight", $name, tokens.$font-weight); | |
| 59 | +} | |
| 60 | + | |
| 61 | +@function z($name) { | |
| 62 | + @return _token("z", $name, tokens.$z); | |
| 63 | +} | |
| 64 | + | |
| 65 | +/// 브레이크포인트 원값(rem 리터럴)을 반환한다 — **유일하게 `var()`가 아닌 실제 값을 | |
| 66 | +/// 돌려주는 함수**다. 미디어 쿼리가 `var()`를 해석하지 못하기 때문이다(`tokens/_layout.scss`). | |
| 67 | +/// 미디어 쿼리 자체는 `media()` 믹스인을 쓰고, 이 함수는 컨테이너 `max-width`처럼 | |
| 68 | +/// 브레이크포인트와 같은 값을 선언에 직접 써야 할 때만 쓴다. | |
| 69 | +@function bp($name) { | |
| 70 | + @if not map.has-key(tokens.$breakpoint, $name) { | |
| 71 | + @error "[@fox] 알 수 없는 breakpoint 토큰: `#{$name}` — 사용 가능한 값: #{map.keys(tokens.$breakpoint)}"; | |
| 72 | + } | |
| 73 | + | |
| 74 | + @return map.get(tokens.$breakpoint, $name); | |
| 75 | +} |
+++ @fox/styles/_mixins.scss
... | ... | @@ -0,0 +1,109 @@ |
| 1 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 2 | +// 믹스인 — 여러 선언이 항상 함께 가야 하는 패턴을 한 줄로 묶는다. | |
| 3 | +// | |
| 4 | +// 함수(`_functions.scss`)가 "값 하나"를 강제한다면, 믹스인은 "값들의 조합"을 | |
| 5 | +// 강제한다. 타이포 4종(크기·행간·굵기·자간)이나 감소 모션 분기처럼 하나만 빠져도 | |
| 6 | +// 어긋나는 것들을 화면마다 되풀이해 적지 않게 만드는 것이 목적이다. | |
| 7 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 8 | + | |
| 9 | +@use "sass:map"; | |
| 10 | +@use "tokens"; | |
| 11 | +@use "functions" as fn; | |
| 12 | + | |
| 13 | +/// 타이포 프리셋 적용 — 크기·행간·굵기·자간 4종을 한 번에. | |
| 14 | +/// 개별 스칼라를 따로 고르지 못하게 해서 타이포 조합이 무한히 갈라지는 것을 막는다. | |
| 15 | +@mixin typo($name) { | |
| 16 | + @if not map.has-key(tokens.$typo, $name) { | |
| 17 | + @error "[@fox] 알 수 없는 typo 프리셋: `#{$name}` — 사용 가능한 값: #{map.keys(tokens.$typo)}"; | |
| 18 | + } | |
| 19 | + | |
| 20 | + font-size: var(--fox-typo-#{$name}-size); | |
| 21 | + font-weight: var(--fox-typo-#{$name}-weight); | |
| 22 | + line-height: var(--fox-typo-#{$name}-line-height); | |
| 23 | + letter-spacing: var(--fox-typo-#{$name}-letter-spacing); | |
| 24 | +} | |
| 25 | + | |
| 26 | +/// 지정 브레이크포인트 **이상**에서 적용 (모바일 우선). | |
| 27 | +@mixin media($name) { | |
| 28 | + @media (min-width: fn.bp($name)) { | |
| 29 | + @content; | |
| 30 | + } | |
| 31 | +} | |
| 32 | + | |
| 33 | +/// 지정 브레이크포인트 **미만**에서 적용. | |
| 34 | +/// `media()`와 경계가 겹치지 않도록 0.01rem을 뺀다 — 같은 뷰포트 폭에서 두 블록이 | |
| 35 | +/// 동시에 적용되면 나중 선언이 이기는 미묘한 버그가 생긴다. | |
| 36 | +@mixin media-below($name) { | |
| 37 | + @media (max-width: fn.bp($name) - 0.01rem) { | |
| 38 | + @content; | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +/// 트랜지션 — `prefers-reduced-motion: no-preference`로 감싸 감소 모션 환경에서는 | |
| 43 | +/// 아예 선언되지 않게 한다(값을 0으로 만드는 것보다 확실하다). | |
| 44 | +/// @param {List} $properties - 전환할 속성. 여러 개면 괄호로 묶어 전달한다. | |
| 45 | +@mixin transition($properties, $duration: base, $easing: out) { | |
| 46 | + @media (prefers-reduced-motion: no-preference) { | |
| 47 | + transition-property: $properties; | |
| 48 | + transition-duration: fn.duration($duration); | |
| 49 | + transition-timing-function: fn.easing($easing); | |
| 50 | + } | |
| 51 | +} | |
| 52 | + | |
| 53 | +/// 키프레임 애니메이션 — 감소 모션 분기는 `transition()`과 동일하게 내장. | |
| 54 | +@mixin animate($name, $duration: base, $easing: out, $fill: both) { | |
| 55 | + @media (prefers-reduced-motion: no-preference) { | |
| 56 | + animation: $name fn.duration($duration) fn.easing($easing) $fill; | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +/// 키보드 포커스 링 — `:focus-visible`만 대상으로 하므로 마우스 클릭 시에는 | |
| 61 | +/// 나타나지 않는다. 모든 상호작용 요소에 반드시 넣는다(접근성). | |
| 62 | +@mixin focus-ring($color: null, $width: 0.2rem, $offset: 0.2rem) { | |
| 63 | + $ring-color: fn.color(primary); | |
| 64 | + | |
| 65 | + @if $color != null { | |
| 66 | + $ring-color: $color; | |
| 67 | + } | |
| 68 | + | |
| 69 | + &:focus-visible { | |
| 70 | + outline: $width solid $ring-color; | |
| 71 | + outline-offset: $offset; | |
| 72 | + } | |
| 73 | +} | |
| 74 | + | |
| 75 | +/// 화면에는 보이지 않지만 스크린리더는 읽는 텍스트. | |
| 76 | +/// `display: none`이나 `visibility: hidden`은 보조기기에서도 사라지므로 쓰지 않는다. | |
| 77 | +@mixin visually-hidden { | |
| 78 | + position: absolute; | |
| 79 | + width: 1px; | |
| 80 | + height: 1px; | |
| 81 | + margin: -1px; | |
| 82 | + padding: 0; | |
| 83 | + overflow: hidden; | |
| 84 | + clip-path: inset(50%); | |
| 85 | + white-space: nowrap; | |
| 86 | + border: 0; | |
| 87 | +} | |
| 88 | + | |
| 89 | +/// 말줄임 — 1줄이면 `text-overflow`, 여러 줄이면 line-clamp. | |
| 90 | +@mixin truncate($lines: 1) { | |
| 91 | + overflow: hidden; | |
| 92 | + | |
| 93 | + @if $lines == 1 { | |
| 94 | + text-overflow: ellipsis; | |
| 95 | + white-space: nowrap; | |
| 96 | + } @else { | |
| 97 | + display: -webkit-box; | |
| 98 | + -webkit-box-orient: vertical; | |
| 99 | + -webkit-line-clamp: $lines; | |
| 100 | + } | |
| 101 | +} | |
| 102 | + | |
| 103 | +/// 페이지 콘텐츠 가로 폭 제한 + 좌우 여백. | |
| 104 | +@mixin container($max: xl, $gutter: 4) { | |
| 105 | + width: 100%; | |
| 106 | + max-width: fn.bp($max); | |
| 107 | + margin-inline: auto; | |
| 108 | + padding-inline: fn.space($gutter); | |
| 109 | +} |
+++ @fox/styles/_reset.scss
... | ... | @@ -0,0 +1,121 @@ |
| 1 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 2 | +// 리셋 & 베이스 | |
| 3 | +// | |
| 4 | +// 브라우저 기본 스타일 중 디자인 시스템과 충돌하는 것만 걷어낸다. 전면 리셋(모든 | |
| 5 | +// 요소를 무스타일로)이 아니라, 시맨틱 태그의 기본 동작은 살리고 시각 표현만 | |
| 6 | +// 토큰으로 되돌리는 방식이다. | |
| 7 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 8 | + | |
| 9 | +@use "abstracts" as fox; | |
| 10 | + | |
| 11 | +*, | |
| 12 | +*::before, | |
| 13 | +*::after { | |
| 14 | + box-sizing: border-box; | |
| 15 | +} | |
| 16 | + | |
| 17 | +// ⚠️ **1rem = 10px 규약의 근거.** 브라우저 기본 글자 크기(16px)의 62.5%가 10px다. | |
| 18 | +// px → rem 환산이 10으로 나누기가 되어 토큰 값이 읽기 쉬워진다. | |
| 19 | +// px 고정과 달리 사용자가 브라우저 글자 크기를 키우면 앱 전체가 비례해 확대된다 | |
| 20 | +// (접근성 설정 존중). 그 대신 `body`에서 실제 본문 크기를 되돌려 놓아야 한다 — | |
| 21 | +// 아래 `body`의 `typo(body-md)`가 그 역할이다. | |
| 22 | +// | |
| 23 | +// ⚠️ 미디어 쿼리 안의 `rem`은 이 값의 영향을 받지 않는다(항상 브라우저 기본 크기 | |
| 24 | +// 기준). `tokens/_layout.scss` 참고. | |
| 25 | +html { | |
| 26 | + font-size: 62.5%; | |
| 27 | + -webkit-text-size-adjust: 100%; | |
| 28 | +} | |
| 29 | + | |
| 30 | +body { | |
| 31 | + margin: 0; | |
| 32 | + background-color: fox.color(background); | |
| 33 | + color: fox.color(foreground); | |
| 34 | + font-family: fox.font(sans); | |
| 35 | + -webkit-font-smoothing: antialiased; | |
| 36 | + -moz-osx-font-smoothing: grayscale; | |
| 37 | + | |
| 38 | + @include fox.typo(body-md); | |
| 39 | +} | |
| 40 | + | |
| 41 | +// 제목·문단의 기본 여백을 제거한다 — 간격은 레이아웃(gap/margin 토큰)이 책임지고, | |
| 42 | +// 태그가 스스로 여백을 갖지 않게 해야 조립 결과를 예측할 수 있다. | |
| 43 | +h1, | |
| 44 | +h2, | |
| 45 | +h3, | |
| 46 | +h4, | |
| 47 | +h5, | |
| 48 | +h6, | |
| 49 | +p, | |
| 50 | +figure, | |
| 51 | +blockquote, | |
| 52 | +dl, | |
| 53 | +dd { | |
| 54 | + margin: 0; | |
| 55 | +} | |
| 56 | + | |
| 57 | +// 제목 태그의 기본 크기/굵기도 제거한다 — 시각적 위계는 `typo()` 프리셋으로만 | |
| 58 | +// 정하고, 태그 선택은 문서 구조(접근성) 기준으로 하게 만든다. | |
| 59 | +h1, | |
| 60 | +h2, | |
| 61 | +h3, | |
| 62 | +h4, | |
| 63 | +h5, | |
| 64 | +h6 { | |
| 65 | + font-size: inherit; | |
| 66 | + font-weight: inherit; | |
| 67 | +} | |
| 68 | + | |
| 69 | +ul, | |
| 70 | +ol { | |
| 71 | + margin: 0; | |
| 72 | + padding: 0; | |
| 73 | + list-style: none; | |
| 74 | +} | |
| 75 | + | |
| 76 | +// 폼 요소는 폰트를 상속하지 않는 것이 브라우저 기본값이라 명시적으로 되돌린다. | |
| 77 | +button, | |
| 78 | +input, | |
| 79 | +select, | |
| 80 | +textarea { | |
| 81 | + margin: 0; | |
| 82 | + font: inherit; | |
| 83 | + color: inherit; | |
| 84 | + letter-spacing: inherit; | |
| 85 | +} | |
| 86 | + | |
| 87 | +button { | |
| 88 | + padding: 0; | |
| 89 | + border: 0; | |
| 90 | + background: none; | |
| 91 | + cursor: pointer; | |
| 92 | +} | |
| 93 | + | |
| 94 | +button:disabled { | |
| 95 | + cursor: default; | |
| 96 | +} | |
| 97 | + | |
| 98 | +a { | |
| 99 | + color: inherit; | |
| 100 | + text-decoration: none; | |
| 101 | +} | |
| 102 | + | |
| 103 | +img, | |
| 104 | +svg, | |
| 105 | +video, | |
| 106 | +canvas { | |
| 107 | + display: block; | |
| 108 | + max-width: 100%; | |
| 109 | +} | |
| 110 | + | |
| 111 | +table { | |
| 112 | + border-collapse: collapse; | |
| 113 | + border-spacing: 0; | |
| 114 | +} | |
| 115 | + | |
| 116 | +// 브라우저 기본 포커스 링을 지우지 않는다 — 컴포넌트가 `focus-ring()` 믹스인으로 | |
| 117 | +// 자기 링을 정의하면 그것이 우선하고, 정의하지 않은 요소는 기본 링이 남아 키보드 | |
| 118 | +// 사용자가 길을 잃지 않는다. | |
| 119 | +:where(:focus-visible) { | |
| 120 | + outline-offset: 0.2rem; | |
| 121 | +} |
+++ @fox/styles/_root.scss
... | ... | @@ -0,0 +1,104 @@ |
| 1 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 2 | +// 토큰 CSS 출력 — `tokens/` 의 SCSS map을 `--fox-*` 커스텀 프로퍼티로 선언한다. | |
| 3 | +// | |
| 4 | +// **이 파일은 앱 전체에서 딱 한 번만 로드되어야 한다** (`index.scss` 경유). | |
| 5 | +// 컴포넌트의 `.module.scss`에서는 절대 `@use` 하지 않는다 — CSS Module은 파일마다 | |
| 6 | +// 별도 컴파일 단위여서, 컴포넌트마다 `:root` 블록 전체가 복제된다. | |
| 7 | +// | |
| 8 | +// map을 순회해 생성하므로 토큰을 추가할 때 이 파일은 손대지 않는다. 값의 이름·개수는 | |
| 9 | +// 언제나 `tokens/` 쪽 map 하나에만 존재한다. | |
| 10 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 11 | + | |
| 12 | +@use "sass:map"; | |
| 13 | +@use "tokens"; | |
| 14 | + | |
| 15 | +:root { | |
| 16 | + // 기본은 OS 설정 추종. 아래 `[data-theme]` 규칙이 이 값만 덮어써 수동 선택을 | |
| 17 | + // 처리하므로, 색상 값 자체는 `tokens/_color.scss` 한 곳에만 존재한다. | |
| 18 | + color-scheme: light dark; | |
| 19 | + | |
| 20 | + @each $name, $value in tokens.$color { | |
| 21 | + --fox-color-#{$name}: #{$value}; | |
| 22 | + } | |
| 23 | + | |
| 24 | + @each $name, $value in tokens.$space { | |
| 25 | + --fox-space-#{$name}: #{$value}; | |
| 26 | + } | |
| 27 | + | |
| 28 | + @each $name, $value in tokens.$radius { | |
| 29 | + --fox-radius-#{$name}: #{$value}; | |
| 30 | + } | |
| 31 | + | |
| 32 | + @each $name, $value in tokens.$shadow { | |
| 33 | + --fox-shadow-#{$name}: #{$value}; | |
| 34 | + } | |
| 35 | + | |
| 36 | + @each $name, $value in tokens.$duration { | |
| 37 | + --fox-duration-#{$name}: #{$value}; | |
| 38 | + } | |
| 39 | + | |
| 40 | + @each $name, $value in tokens.$easing { | |
| 41 | + --fox-easing-#{$name}: #{$value}; | |
| 42 | + } | |
| 43 | + | |
| 44 | + @each $name, $value in tokens.$font-family { | |
| 45 | + --fox-font-#{$name}: #{$value}; | |
| 46 | + } | |
| 47 | + | |
| 48 | + @each $name, $value in tokens.$font-weight { | |
| 49 | + --fox-weight-#{$name}: #{$value}; | |
| 50 | + } | |
| 51 | + | |
| 52 | + @each $name, $value in tokens.$z { | |
| 53 | + --fox-z-#{$name}: #{$value}; | |
| 54 | + } | |
| 55 | + | |
| 56 | + // 브레이크포인트는 **미디어 쿼리에서 쓸 수 없다** — 조건부는 `var()`를 해석하지 | |
| 57 | + // 않는다. 그럼에도 내보내는 이유는 컨테이너 `max-width`처럼 선언부에서 같은 값을 | |
| 58 | + // 참조할 일이 있고, 카탈로그가 토큰 목록을 CSSOM에서 읽기 때문이다. | |
| 59 | + // 미디어 쿼리는 언제나 `fox.media()` 믹스인을 쓴다. | |
| 60 | + @each $name, $value in tokens.$breakpoint { | |
| 61 | + --fox-breakpoint-#{$name}: #{$value}; | |
| 62 | + } | |
| 63 | + | |
| 64 | + // 타이포 프리셋은 한 이름당 4개의 프로퍼티로 펼쳐진다 — `fox.typo()` 믹스인이 | |
| 65 | + // 이 네 개를 함께 참조한다. | |
| 66 | + @each $name, $preset in tokens.$typo { | |
| 67 | + --fox-typo-#{$name}-size: #{map.get($preset, size)}; | |
| 68 | + --fox-typo-#{$name}-weight: #{map.get($preset, weight)}; | |
| 69 | + --fox-typo-#{$name}-line-height: #{map.get($preset, line-height)}; | |
| 70 | + --fox-typo-#{$name}-letter-spacing: #{map.get($preset, letter-spacing)}; | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +// 수동 테마 선택 — `color-scheme`만 고정하면 `light-dark()`가 해당 분기로 확정된다. | |
| 75 | +// 색상 값을 여기서 재정의하지 않으므로 라이트/다크 값이 어긋날 수 없다. | |
| 76 | +// 속성이 없거나 `"system"`이면 위 `:root`의 `light dark`가 유지되어 OS를 따른다. | |
| 77 | +:root[data-theme="light"] { | |
| 78 | + color-scheme: light; | |
| 79 | +} | |
| 80 | + | |
| 81 | +:root[data-theme="dark"] { | |
| 82 | + color-scheme: dark; | |
| 83 | +} | |
| 84 | + | |
| 85 | +// 테마 전환 애니메이션 — 호스트 앱이 토글 직전 `<html>`에 `[data-theme-transition]`을 | |
| 86 | +// 부여하고 ~200ms 후 제거하는 **시간 한정 opt-in**이다. 상시 트랜지션이 아니므로 | |
| 87 | +// 다른 hover 트랜지션과 경쟁하지 않는다. | |
| 88 | +// `prefers-reduced-motion` 게이팅으로 감소 모션 환경에서는 즉시 반영된다. | |
| 89 | +// `!important`는 이 200ms 동안만 존재하는 규칙이 컴포넌트의 transition 선언에 | |
| 90 | +// 밀리지 않게 하기 위한 것이다. | |
| 91 | +@media (prefers-reduced-motion: no-preference) { | |
| 92 | + html[data-theme-transition], | |
| 93 | + html[data-theme-transition] *, | |
| 94 | + html[data-theme-transition] *::before, | |
| 95 | + html[data-theme-transition] *::after { | |
| 96 | + transition: | |
| 97 | + background-color var(--fox-duration-base) var(--fox-easing-out), | |
| 98 | + border-color var(--fox-duration-base) var(--fox-easing-out), | |
| 99 | + color var(--fox-duration-base) var(--fox-easing-out), | |
| 100 | + fill var(--fox-duration-base) var(--fox-easing-out), | |
| 101 | + stroke var(--fox-duration-base) var(--fox-easing-out), | |
| 102 | + outline-color var(--fox-duration-base) var(--fox-easing-out) !important; | |
| 103 | + } | |
| 104 | +} |
+++ @fox/styles/abstracts.scss
... | ... | @@ -0,0 +1,22 @@ |
| 1 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 2 | +// 저작(authoring) 진입점 — 컴포넌트의 `.module.scss`가 `@use`하는 파일. | |
| 3 | +// | |
| 4 | +// @use "@fox/styles/abstracts" as fox; | |
| 5 | +// | |
| 6 | +// .button { | |
| 7 | +// padding: fox.space(2) fox.space(4); | |
| 8 | +// background: fox.color(primary); | |
| 9 | +// border-radius: fox.radius(md); | |
| 10 | +// @include fox.typo(label-lg); | |
| 11 | +// @include fox.focus-ring; | |
| 12 | +// } | |
| 13 | +// | |
| 14 | +// ⚠️ **이 파일은 CSS를 한 줄도 출력하지 않는다.** 함수·믹스인·map 정의만 있다. | |
| 15 | +// CSS Module은 파일마다 별도 컴파일 단위라, CSS를 출력하는 모듈을 여기서 forward하면 | |
| 16 | +// 컴포넌트 수만큼 같은 CSS가 복제된다. 실제 CSS 출력(토큰 선언·리셋)은 앱이 딱 한 번 | |
| 17 | +// import하는 `index.scss`가 전담한다. | |
| 18 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 19 | + | |
| 20 | +@forward "tokens"; | |
| 21 | +@forward "functions"; | |
| 22 | +@forward "mixins"; |
+++ @fox/styles/index.scss
... | ... | @@ -0,0 +1,13 @@ |
| 1 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 2 | +// 글로벌 스타일 진입점 — **호스트 앱이 딱 한 번 import한다.** | |
| 3 | +// | |
| 4 | +// // app/globals.scss | |
| 5 | +// @use "@fox/styles"; | |
| 6 | +// | |
| 7 | +// 여기서만 CSS가 출력된다(토큰 선언 + 리셋). 컴포넌트의 `.module.scss`는 이 파일이 | |
| 8 | +// 아니라 `abstracts.scss`를 `@use`해야 한다 — 그쪽은 CSS 출력이 0이라 몇 번을 | |
| 9 | +// 참조해도 중복이 생기지 않는다. | |
| 10 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 11 | + | |
| 12 | +@use "root"; | |
| 13 | +@use "reset"; |
+++ @fox/styles/tokens/_color.scss
... | ... | @@ -0,0 +1,61 @@ |
| 1 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 2 | +// 색상 토큰 | |
| 3 | +// | |
| 4 | +// 이 map이 단일 진실 공급원(SSOT)이다. 여기서 두 가지가 함께 파생된다: | |
| 5 | +// 1. `_root.scss` → `--fox-color-*` CSS 커스텀 프로퍼티 선언 | |
| 6 | +// 2. `_functions.scss`의 `color()` → 토큰 이름 유효성 컴파일타임 검증 | |
| 7 | +// 값과 이름 목록이 한 파일에만 존재하므로 둘 사이의 드리프트가 구조적으로 불가능하다. | |
| 8 | +// | |
| 9 | +// 라이트/다크는 `light-dark(라이트값, 다크값)` 한 줄로 함께 보유한다. 테마 전환은 | |
| 10 | +// `_root.scss`의 `color-scheme`이 담당하므로 다크 전용 블록을 따로 두지 않는다 — | |
| 11 | +// 한쪽만 고쳐서 생기는 테마 불일치가 애초에 발생할 수 없다. | |
| 12 | +// | |
| 13 | +// 브라우저 지원: `light-dark()` 자체는 Chrome 123 / Safari 17.5 / Firefox 120 이상이 | |
| 14 | +// 필요하지만, Next.js 16(Turbopack)의 Lightning CSS가 현재 설정에서 이를 | |
| 15 | +// `var(--lightningcss-light, 라이트) var(--lightningcss-dark, 다크)` 조합으로 **폴리필**해 | |
| 16 | +// 출력하므로 구형 브라우저에서도 동작한다(2026-08-11 빌드 산출물에서 확인). | |
| 17 | +// browserslist 타깃을 최신 브라우저로 좁히면 폴리필이 빠지고 원본 `light-dark()`가 | |
| 18 | +// 그대로 나가므로, 타깃을 바꿀 때 이 전제를 다시 확인한다. | |
| 19 | +// | |
| 20 | +// ⚠️ 아래 값은 전부 **임시 플레이스홀더**다. 파이프라인 검증과 /design 카탈로그 | |
| 21 | +// 렌더를 위한 중립 팔레트이며, 실제 브랜드 값은 사용자 제공 Figma/파일로 교체한다. | |
| 22 | +// 교체 시 이 파일의 값만 바꾸면 앱 전체에 반영된다. | |
| 23 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 24 | + | |
| 25 | +$color: ( | |
| 26 | + // 브랜드 | |
| 27 | + primary: light-dark(#4f46e5, #818cf8), | |
| 28 | + primary-hover: light-dark(#4338ca, #a5b4fc), | |
| 29 | + primary-subtle: light-dark(#eef2ff, #312e81), | |
| 30 | + on-primary: light-dark(#ffffff, #1e1b34), | |
| 31 | + | |
| 32 | + // 표면 — background(페이지 바닥) < surface(카드) < surface-raised(팝오버 등) | |
| 33 | + background: light-dark(#ffffff, #14161b), | |
| 34 | + surface: light-dark(#ffffff, #1e2128), | |
| 35 | + surface-muted: light-dark(#f5f7fa, #262a33), | |
| 36 | + surface-raised: light-dark(#ffffff, #2c3038), | |
| 37 | + | |
| 38 | + // 전경(텍스트·아이콘) | |
| 39 | + foreground: light-dark(#1f2430, #f5f7fa), | |
| 40 | + foreground-muted: light-dark(#565d6b, #bbc4d8), | |
| 41 | + foreground-subtle: light-dark(#8790a3, #8790a3), | |
| 42 | + foreground-inverse: light-dark(#ffffff, #14161b), | |
| 43 | + | |
| 44 | + // 경계선 | |
| 45 | + border: light-dark(#dae0ed, #424752), | |
| 46 | + border-subtle: light-dark(#eaeef6, #2f343d), | |
| 47 | + border-strong: light-dark(#b6bfd2, #5a616e), | |
| 48 | + | |
| 49 | + // 상태 — 각 상태색 위에 얹는 텍스트색을 on-* 로 함께 정의한다. | |
| 50 | + danger: light-dark(#dc2626, #f87171), | |
| 51 | + on-danger: light-dark(#ffffff, #1a0d0d), | |
| 52 | + success: light-dark(#16a34a, #4ade80), | |
| 53 | + on-success: light-dark(#ffffff, #0a1a10), | |
| 54 | + warning: light-dark(#d97706, #fbbf24), | |
| 55 | + on-warning: light-dark(#ffffff, #1a1205), | |
| 56 | + info: light-dark(#0284c7, #38bdf8), | |
| 57 | + on-info: light-dark(#ffffff, #05161f), | |
| 58 | + | |
| 59 | + // 오버레이 | |
| 60 | + scrim: light-dark(rgb(0 0 0 / 0.5), rgb(0 0 0 / 0.65)) | |
| 61 | +); |
+++ @fox/styles/tokens/_index.scss
... | ... | @@ -0,0 +1,13 @@ |
| 1 | +// 토큰 map 모음 진입점 — **CSS를 한 줄도 출력하지 않는다.** | |
| 2 | +// 값 선언(이 폴더)과 CSS 출력(`../_root.scss`)을 분리해 두었기 때문에, 컴포넌트의 | |
| 3 | +// `.module.scss`가 몇 개든 `abstracts`를 `@use`해도 `:root` 블록이 중복 출력되지 | |
| 4 | +// 않는다(각 CSS Module은 별도 컴파일 단위라 CSS를 내보내는 모듈을 공유하면 | |
| 5 | +// 파일마다 같은 CSS가 복제된다). | |
| 6 | + | |
| 7 | +@forward "color"; | |
| 8 | +@forward "space"; | |
| 9 | +@forward "typography"; | |
| 10 | +@forward "radius"; | |
| 11 | +@forward "shadow"; | |
| 12 | +@forward "motion"; | |
| 13 | +@forward "layout"; |
+++ @fox/styles/tokens/_layout.scss
... | ... | @@ -0,0 +1,41 @@ |
| 1 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 2 | +// 레이아웃 토큰 — 브레이크포인트와 z-index 층. | |
| 3 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 4 | + | |
| 5 | +// 브레이크포인트는 이 파일에서 **유일하게 CSS 커스텀 프로퍼티로 내보내지 않는 | |
| 6 | +// 토큰**이다. 미디어 쿼리 조건부는 `var()`를 해석하지 않기 때문에 | |
| 7 | +// (`@media (min-width: var(--x))`는 무효), 컴파일타임 SCSS 값이어야만 한다. | |
| 8 | +// 그래서 브레이크포인트 사용은 `fox.media()` 믹스인 경유가 강제된다. | |
| 9 | +// | |
| 10 | +// ⚠️ **미디어 쿼리 안의 `rem`은 브라우저 기본 글자 크기(보통 16px) 기준이다.** | |
| 11 | +// `html { font-size: 62.5% }`의 영향을 받지 않으므로, 다른 토큰의 "1rem = 10px" | |
| 12 | +// 규칙이 여기서는 적용되지 않는다. 아래 px 환산을 반드시 함께 본다. | |
| 13 | +// rem으로 두는 이유: 사용자가 브라우저 글자 크기를 키우면 브레이크포인트도 함께 | |
| 14 | +// 커져 확대 환경에서 레이아웃이 더 일찍 접힌다(px 고정은 이 대응이 불가능하다). | |
| 15 | +$breakpoint: ( | |
| 16 | + sm: 40rem, | |
| 17 | + // 640px | |
| 18 | + md: 48rem, | |
| 19 | + // 768px | |
| 20 | + lg: 64rem, | |
| 21 | + // 1024px | |
| 22 | + xl: 80rem, | |
| 23 | + // 1280px | |
| 24 | + 2xl: 96rem | |
| 25 | + // 1536px | |
| 26 | +); | |
| 27 | + | |
| 28 | +// z-index 층 — 숫자를 화면에서 직접 고르지 않게 하려는 토큰이다. 값 사이를 100씩 | |
| 29 | +// 띄워 둔 것은 나중에 층 사이에 새 층을 끼워 넣을 여지를 남기기 위함이다. | |
| 30 | +$z: ( | |
| 31 | + base: 0, | |
| 32 | + raised: 100, | |
| 33 | + sticky: 200, | |
| 34 | + dropdown: 300, | |
| 35 | + scrim: 400, | |
| 36 | + modal: 500, | |
| 37 | + popover: 600, | |
| 38 | + toast: 700, | |
| 39 | + tooltip: 800, | |
| 40 | + splash: 900 | |
| 41 | +); |
+++ @fox/styles/tokens/_motion.scss
... | ... | @@ -0,0 +1,26 @@ |
| 1 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 2 | +// 모션 토큰 — 지속시간과 이징. | |
| 3 | +// 이징은 "어느 방향으로 움직이는가"로 이름 붙인다: 화면에 들어올 때는 감속(out), | |
| 4 | +// 나갈 때는 가속(in), 제자리에서 상태만 바뀔 때는 in-out. | |
| 5 | +// | |
| 6 | +// 실제 트랜지션·애니메이션은 `_mixins.scss`의 `transition()` / `animate()`를 쓴다 — | |
| 7 | +// 두 믹스인 모두 `prefers-reduced-motion` 분기를 내장하고 있어 감소 모션 환경 | |
| 8 | +// 대응을 화면마다 되풀이할 필요가 없다. | |
| 9 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 10 | + | |
| 11 | +$duration: ( | |
| 12 | + instant: 0ms, | |
| 13 | + fast: 120ms, | |
| 14 | + base: 200ms, | |
| 15 | + slow: 300ms, | |
| 16 | + slower: 500ms | |
| 17 | +); | |
| 18 | + | |
| 19 | +$easing: ( | |
| 20 | + linear: linear, | |
| 21 | + in: cubic-bezier(0.4, 0, 1, 1), | |
| 22 | + out: cubic-bezier(0, 0, 0.2, 1), | |
| 23 | + in-out: cubic-bezier(0.4, 0, 0.2, 1), | |
| 24 | + // 살짝 오버슈트 — 토글·체크 같은 작은 확인 동작에. | |
| 25 | + spring: cubic-bezier(0.34, 1.56, 0.64, 1) | |
| 26 | +); |
+++ @fox/styles/tokens/_radius.scss
... | ... | @@ -0,0 +1,17 @@ |
| 1 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 2 | +// 모서리 반경 토큰 — 1rem = 10px 기준. | |
| 3 | +// `full`은 pill 형태(캡슐 버튼·뱃지)를 위한 값으로, 높이보다 크기만 하면 되므로 | |
| 4 | +// 임의의 큰 값을 쓴다. | |
| 5 | +// ⚠️ 값은 임시 플레이스홀더 — Figma 확정 시 교체. | |
| 6 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 7 | + | |
| 8 | +$radius: ( | |
| 9 | + none: 0, | |
| 10 | + xs: 0.4rem, | |
| 11 | + sm: 0.6rem, | |
| 12 | + md: 0.8rem, | |
| 13 | + lg: 1.2rem, | |
| 14 | + xl: 1.6rem, | |
| 15 | + 2xl: 2.4rem, | |
| 16 | + full: 999rem | |
| 17 | +); |
+++ @fox/styles/tokens/_shadow.scss
... | ... | @@ -0,0 +1,18 @@ |
| 1 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 2 | +// 그림자 토큰 — 고도(elevation)를 역할 이름으로 표현한다. | |
| 3 | +// 크기(sm/md/lg)가 아니라 쓰임(card/dialog/…)으로 이름 붙이면 "이 요소는 어느 | |
| 4 | +// 층에 뜨는가"가 코드에 드러나고, 나중에 값이 바뀌어도 의미가 유지된다. | |
| 5 | +// | |
| 6 | +// 그림자는 라이트/다크에서 같은 값을 쓰지 않는 편이 자연스럽다(어두운 배경에서는 | |
| 7 | +// 그림자가 거의 보이지 않는다). 필요해지면 여기서 `light-dark()`로 분기한다. | |
| 8 | +// ⚠️ 값은 임시 플레이스홀더 — Figma 확정 시 교체. | |
| 9 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 10 | + | |
| 11 | +$shadow: ( | |
| 12 | + none: none, | |
| 13 | + card: light-dark(0 0.1rem 0.3rem rgb(16 24 40 / 0.08), 0 0.1rem 0.3rem rgb(0 0 0 / 0.4)), | |
| 14 | + raised: light-dark(0 0.4rem 0.8rem rgb(16 24 40 / 0.1), 0 0.4rem 0.8rem rgb(0 0 0 / 0.45)), | |
| 15 | + popup: light-dark(0 0.8rem 1.6rem rgb(16 24 40 / 0.12), 0 0.8rem 1.6rem rgb(0 0 0 / 0.5)), | |
| 16 | + dialog: light-dark(0 1.6rem 3.2rem rgb(16 24 40 / 0.16), 0 1.6rem 3.2rem rgb(0 0 0 / 0.55)), | |
| 17 | + toast: light-dark(0 1rem 2rem rgb(16 24 40 / 0.18), 0 1rem 2rem rgb(0 0 0 / 0.6)) | |
| 18 | +); |
+++ @fox/styles/tokens/_space.scss
... | ... | @@ -0,0 +1,29 @@ |
| 1 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 2 | +// 간격 토큰 (4/8 grid) | |
| 3 | +// | |
| 4 | +// **모든 치수는 rem 기준이며 `1rem = 10px`이다** (`_reset.scss`의 | |
| 5 | +// `html { font-size: 62.5% }`). px → rem 환산이 10으로 나누기라 암산이 되고, | |
| 6 | +// 사용자가 브라우저 기본 글자 크기를 키우면 레이아웃 전체가 함께 확대된다 | |
| 7 | +// (px 고정과 달리 접근성 설정을 존중한다). | |
| 8 | +// | |
| 9 | +// 키는 4px 배수 단위다 — `space(4)` = 16px. 임의 값이 필요해 보이면 스케일에 | |
| 10 | +// 단계를 추가할 일인지 먼저 검토한다(§10.4). | |
| 11 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 12 | + | |
| 13 | +$space: ( | |
| 14 | + 0: 0, | |
| 15 | + 1: 0.4rem, | |
| 16 | + 2: 0.8rem, | |
| 17 | + 3: 1.2rem, | |
| 18 | + 4: 1.6rem, | |
| 19 | + 5: 2rem, | |
| 20 | + 6: 2.4rem, | |
| 21 | + 8: 3.2rem, | |
| 22 | + 10: 4rem, | |
| 23 | + 12: 4.8rem, | |
| 24 | + 16: 6.4rem, | |
| 25 | + 20: 8rem, | |
| 26 | + 24: 9.6rem | |
| 27 | +); | |
| 28 | +// 키 → px 환산: 1=4 · 2=8 · 3=12 · 4=16 · 5=20 · 6=24 · 8=32 · 10=40 · 12=48 · | |
| 29 | +// 16=64 · 20=80 · 24=96 |
+++ @fox/styles/tokens/_typography.scss
... | ... | @@ -0,0 +1,52 @@ |
| 1 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 2 | +// 타이포그래피 토큰 | |
| 3 | +// | |
| 4 | +// 폰트 패밀리는 **호스트 앱이 주입하는 계약**이다 — `@fox`는 `--app-font-sans` / | |
| 5 | +// `--app-font-mono`를 참조만 하고 폰트 파일을 소유하지 않는다. Next.js는 `next/font`가 | |
| 6 | +// 앱의 layout에서 CSS 변수를 만들어 주므로(폰트 최적화·자체 호스팅이 앱 책임), | |
| 7 | +// `@fox` 폴더를 다른 프로젝트로 복사해도 그 프로젝트의 폰트를 그대로 따른다. | |
| 8 | +// 미주입 시에도 깨지지 않도록 `var()` 폴백을 반드시 둔다 — 폴백 없는 `var()`가 | |
| 9 | +// undefined면 font-family 선언 전체가 무효가 된다. | |
| 10 | +// | |
| 11 | +// 개별 스칼라(size/weight/…)보다 **프리셋(`$typo`)을 우선 사용**한다. 크기·행간· | |
| 12 | +// 자간·굵기는 함께 움직여야 의도한 타이포가 되므로, 화면에서 넷을 따로 고르게 두면 | |
| 13 | +// 조합이 무한히 갈라진다. `@include fox.typo(title-md)` 한 줄로 넷이 함께 적용된다. | |
| 14 | +// | |
| 15 | +// ⚠️ 아래 값·이름은 임시 플레이스홀더다. 실제 타입 스케일은 Figma 확정 시 교체한다. | |
| 16 | +// ───────────────────────────────────────────────────────────────────────────── | |
| 17 | + | |
| 18 | +$font-family: ( | |
| 19 | + sans: (var(--app-font-sans, system-ui), "Apple SD Gothic Neo", sans-serif), | |
| 20 | + mono: (var(--app-font-mono, ui-monospace), monospace) | |
| 21 | +); | |
| 22 | + | |
| 23 | +$font-weight: ( | |
| 24 | + regular: 400, | |
| 25 | + medium: 500, | |
| 26 | + semibold: 600, | |
| 27 | + bold: 700 | |
| 28 | +); | |
| 29 | + | |
| 30 | +// 타이포 프리셋 — size / line-height / weight / letter-spacing 4종을 한 묶음으로. | |
| 31 | +// 1rem = 10px 기준이므로 1.6rem = 16px. | |
| 32 | +$typo: ( | |
| 33 | + display-lg: (size: 4.8rem, line-height: 1.2, weight: 700, letter-spacing: -0.02em), | |
| 34 | + display-md: (size: 4rem, line-height: 1.2, weight: 700, letter-spacing: -0.02em), | |
| 35 | + display-sm: (size: 3.2rem, line-height: 1.25, weight: 700, letter-spacing: -0.02em), | |
| 36 | + | |
| 37 | + headline-lg: (size: 2.8rem, line-height: 1.3, weight: 700, letter-spacing: -0.015em), | |
| 38 | + headline-md: (size: 2.4rem, line-height: 1.35, weight: 700, letter-spacing: -0.015em), | |
| 39 | + headline-sm: (size: 2rem, line-height: 1.4, weight: 700, letter-spacing: -0.01em), | |
| 40 | + | |
| 41 | + title-lg: (size: 1.8rem, line-height: 1.45, weight: 600, letter-spacing: -0.01em), | |
| 42 | + title-md: (size: 1.6rem, line-height: 1.5, weight: 600, letter-spacing: 0), | |
| 43 | + title-sm: (size: 1.4rem, line-height: 1.5, weight: 600, letter-spacing: 0), | |
| 44 | + | |
| 45 | + body-lg: (size: 1.6rem, line-height: 1.6, weight: 400, letter-spacing: 0), | |
| 46 | + body-md: (size: 1.4rem, line-height: 1.6, weight: 400, letter-spacing: 0), | |
| 47 | + body-sm: (size: 1.2rem, line-height: 1.55, weight: 400, letter-spacing: 0), | |
| 48 | + | |
| 49 | + label-lg: (size: 1.4rem, line-height: 1.4, weight: 600, letter-spacing: 0), | |
| 50 | + label-md: (size: 1.3rem, line-height: 1.4, weight: 500, letter-spacing: 0), | |
| 51 | + label-sm: (size: 1.2rem, line-height: 1.35, weight: 500, letter-spacing: 0.01em) | |
| 52 | +); |
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?