refactor: 앱 글로벌 스타일을 @fox 기반 SCSS로 전환
globals는 @fox 로드와 앱 셸 구조만 담당하고 디자인 값은 갖지 않는다. 폰트는 앱이 소유하고 @fox는 --app-font-sans/--app-font-mono를 참조만 하므로, @fox 폴더를 다른 프로젝트로 복사해도 그쪽 서체를 그대로 따른다. Co-Authored-By: Claude Opus 5
@03f6ce0f02e4d76ad84836ca8995341819ffcac8
--- app/globals.css
... | ... | @@ -1,273 +0,0 @@ |
| 1 | -@import "tailwindcss"; | |
| 2 | - | |
| 3 | -/* 색상 토큰 출처: edupay-app lib/ui/core/tokens/app_colors.dart | |
| 4 | - (Figma: FOX PAY 1차 제작시안, node 91:10205). 다크 값은 edupay-app에 대응 | |
| 5 | - 다크 시안이 없어 "임의 도출 — 정식 다크 시안 확정 시 교체". | |
| 6 | - 메커니즘: `color-scheme` + `light-dark()`(Chrome 123+/Safari 17.5+/Firefox 120+ | |
| 7 | - 지원, 미지원 브라우저는 값이 무효화되므로 사내 어드민 한정 트레이드오프로 채택 | |
| 8 | - — design 계획 §4.1/§5.3, 2026-07-28 게이트 승인). 각 시맨틱 토큰이 | |
| 9 | - `light-dark(라이트값, 다크값)`으로 라이트/다크 값을 한 선언에 함께 보유해 | |
| 10 | - 드리프트를 구조적으로 차단한다. 수동 전환은 아래 `:root[data-theme]`가 | |
| 11 | - `color-scheme`만 덮어써 처리하므로 값 자체는 이 블록 한 곳에만 존재한다 | |
| 12 | - (Next.js 16 번들 문서 preventing-flash-before-hydration.md §Themes 패턴). */ | |
| 13 | -:root { | |
| 14 | - color-scheme: light dark; | |
| 15 | - | |
| 16 | - /* Brand */ | |
| 17 | - --primary: light-dark(#502ef5, #877aec); | |
| 18 | - --primary-container: light-dark(#6d5ce7, #502ef5); | |
| 19 | - /* on-primary 다크값 대비 보정: 원래 #ffffff였으나 다크 --primary(#877aec) 위에서 | |
| 20 | - 3.49:1로 WCAG AA(4.5:1) 텍스트 기준 미달 확인 → #14161b로 교체해 5.18:1 통과. | |
| 21 | - Material 3 다크 규약(밝은 primary 표면 + 어두운 onPrimary 텍스트)과 일치하며 | |
| 22 | - text-primary on surface(4.61:1)에는 영향 없음 — design 계획 §5.1(i), | |
| 23 | - 2026-07-28 게이트 승인. */ | |
| 24 | - --on-primary: light-dark(#ffffff, #14161b); | |
| 25 | - --on-primary-container: light-dark(#e2e0fb, #e2e0fb); | |
| 26 | - --secondary: light-dark(#877aec, #9b92ff); | |
| 27 | - | |
| 28 | - /* Surface / background (원 토큰: surface / gray0) */ | |
| 29 | - --background: light-dark(#ffffff, #14161b); | |
| 30 | - --surface: light-dark(#ffffff, #1e2128); | |
| 31 | - --surface-muted: light-dark(#f5f7fa, #2c3038); | |
| 32 | - | |
| 33 | - /* Foreground / text (원 토큰: onSurface 계열) */ | |
| 34 | - --foreground: light-dark(#2c3038, #f5f7fa); | |
| 35 | - --foreground-muted: light-dark(#565d6b, #bbc4d8); | |
| 36 | - --foreground-subtle: light-dark(#6b7486, #8790a3); | |
| 37 | - | |
| 38 | - /* Border (원 토큰: outline 계열) */ | |
| 39 | - --border: light-dark(#dae0ed, #424752); | |
| 40 | - --border-subtle: light-dark(#e1e6f1, #343943); | |
| 41 | - | |
| 42 | - /* Status */ | |
| 43 | - --danger: light-dark(#ec2e2f, #ff5c5d); | |
| 44 | - --success: light-dark(#21b770, #3acb93); | |
| 45 | - --warning: light-dark(#ffb020, #ffc24d); | |
| 46 | - | |
| 47 | - /* Overlay */ | |
| 48 | - --scrim: light-dark(rgb(0 0 0 / 0.6), rgb(0 0 0 / 0.7)); | |
| 49 | -} | |
| 50 | - | |
| 51 | -/* 수동 테마 선택 — <html data-theme="light"|"dark">가 `color-scheme`만 덮어써 | |
| 52 | - 위 light-dark() 값이 해당 분기로 고정된다(값 자체는 여기서 재정의하지 않음). | |
| 53 | - data-theme 속성이 없거나 "system"이면 :root의 `color-scheme: light dark`가 | |
| 54 | - 유지되어 OS `prefers-color-scheme`를 그대로 따른다(feature 전달 사양). */ | |
| 55 | -:root[data-theme="light"] { | |
| 56 | - color-scheme: light; | |
| 57 | -} | |
| 58 | - | |
| 59 | -:root[data-theme="dark"] { | |
| 60 | - color-scheme: dark; | |
| 61 | -} | |
| 62 | - | |
| 63 | -@theme inline { | |
| 64 | - --color-primary: var(--primary); | |
| 65 | - --color-primary-container: var(--primary-container); | |
| 66 | - --color-on-primary: var(--on-primary); | |
| 67 | - --color-on-primary-container: var(--on-primary-container); | |
| 68 | - --color-secondary: var(--secondary); | |
| 69 | - | |
| 70 | - --color-background: var(--background); | |
| 71 | - --color-surface: var(--surface); | |
| 72 | - --color-surface-muted: var(--surface-muted); | |
| 73 | - | |
| 74 | - --color-foreground: var(--foreground); | |
| 75 | - --color-foreground-muted: var(--foreground-muted); | |
| 76 | - --color-foreground-subtle: var(--foreground-subtle); | |
| 77 | - | |
| 78 | - --color-border: var(--border); | |
| 79 | - --color-border-subtle: var(--border-subtle); | |
| 80 | - | |
| 81 | - --color-danger: var(--danger); | |
| 82 | - --color-success: var(--success); | |
| 83 | - --color-warning: var(--warning); | |
| 84 | - | |
| 85 | - --color-scrim: var(--scrim); | |
| 86 | - | |
| 87 | - /* WantedSans 연결 전에도 안전한 폴백(feature 레인이 app/layout.tsx에서 | |
| 88 | - next/font/local로 --font-wanted-sans를 정의). */ | |
| 89 | - --font-sans: var(--font-wanted-sans, system-ui), system-ui, | |
| 90 | - "Apple SD Gothic Neo", sans-serif; | |
| 91 | - --font-mono: var(--font-geist-mono); | |
| 92 | - | |
| 93 | - /* 타이포 토큰 출처: edupay-app lib/ui/core/tokens/app_typography.dart | |
| 94 | - (Figma: FOX PAY 1차 제작시안, node 112:11439). voucher/numeric 등 앱 전용 | |
| 95 | - 스타일은 어드민 무관으로 미이식. */ | |
| 96 | - --text-display-lg: 3rem; | |
| 97 | - --text-display-lg--line-height: 1.5; | |
| 98 | - --text-display-lg--font-weight: 700; | |
| 99 | - | |
| 100 | - --text-display-md: 2.25rem; | |
| 101 | - --text-display-md--line-height: 1.5; | |
| 102 | - --text-display-md--letter-spacing: -0.72px; | |
| 103 | - --text-display-md--font-weight: 700; | |
| 104 | - | |
| 105 | - --text-display-sm: 2rem; | |
| 106 | - --text-display-sm--line-height: 1.5; | |
| 107 | - --text-display-sm--letter-spacing: -0.64px; | |
| 108 | - --text-display-sm--font-weight: 700; | |
| 109 | - | |
| 110 | - --text-headline-lg: 1.5rem; | |
| 111 | - --text-headline-lg--line-height: 1.5; | |
| 112 | - --text-headline-lg--letter-spacing: -0.72px; | |
| 113 | - --text-headline-lg--font-weight: 700; | |
| 114 | - | |
| 115 | - --text-headline-md: 1.25rem; | |
| 116 | - --text-headline-md--line-height: 1.5; | |
| 117 | - --text-headline-md--letter-spacing: -0.6px; | |
| 118 | - --text-headline-md--font-weight: 700; | |
| 119 | - | |
| 120 | - --text-headline-sm: 1.125rem; | |
| 121 | - --text-headline-sm--line-height: 1.3; | |
| 122 | - --text-headline-sm--letter-spacing: -0.54px; | |
| 123 | - --text-headline-sm--font-weight: 700; | |
| 124 | - | |
| 125 | - --text-title-lg: 1.125rem; | |
| 126 | - --text-title-lg--line-height: 1.5; | |
| 127 | - --text-title-lg--font-weight: 700; | |
| 128 | - | |
| 129 | - --text-title-md: 1rem; | |
| 130 | - --text-title-md--line-height: 1.5; | |
| 131 | - --text-title-md--font-weight: 700; | |
| 132 | - | |
| 133 | - --text-title-sm: 0.875rem; | |
| 134 | - --text-title-sm--line-height: 1.5; | |
| 135 | - --text-title-sm--letter-spacing: -0.42px; | |
| 136 | - --text-title-sm--font-weight: 700; | |
| 137 | - | |
| 138 | - --text-body-lg: 1rem; | |
| 139 | - --text-body-lg--line-height: 1.5; | |
| 140 | - --text-body-lg--letter-spacing: -0.48px; | |
| 141 | - --text-body-lg--font-weight: 400; | |
| 142 | - | |
| 143 | - --text-body-md: 0.875rem; | |
| 144 | - --text-body-md--line-height: 1.5; | |
| 145 | - --text-body-md--font-weight: 400; | |
| 146 | - | |
| 147 | - --text-body-sm: 0.75rem; | |
| 148 | - --text-body-sm--line-height: 1.5; | |
| 149 | - --text-body-sm--font-weight: 400; | |
| 150 | - | |
| 151 | - --text-label-lg: 1rem; | |
| 152 | - --text-label-lg--line-height: 1.4; | |
| 153 | - --text-label-lg--letter-spacing: -0.48px; | |
| 154 | - --text-label-lg--font-weight: 700; | |
| 155 | - | |
| 156 | - --text-label-md: 0.875rem; | |
| 157 | - --text-label-md--letter-spacing: -0.28px; | |
| 158 | - --text-label-md--font-weight: 400; | |
| 159 | - | |
| 160 | - --text-label-sm: 0.75rem; | |
| 161 | - --text-label-sm--font-weight: 400; | |
| 162 | - | |
| 163 | - /* radius 토큰 출처: edupay-app lib/ui/core/tokens/app_radius.dart. | |
| 164 | - Tailwind 기본 스케일과 다른 값으로 의도적 재정의(앱 일체화, §8-4 승인). | |
| 165 | - pill(999)은 기본 rounded-full로 충족, card(50)은 앱 전용 미이식. */ | |
| 166 | - --radius-xs: 0.25rem; | |
| 167 | - --radius-sm: 0.5rem; | |
| 168 | - --radius-md: 0.75rem; | |
| 169 | - --radius-lg: 1rem; | |
| 170 | - --radius-xl: 1.25rem; | |
| 171 | - --radius-2xl: 1.5rem; | |
| 172 | - | |
| 173 | - /* 그림자 토큰 출처: edupay-app lib/ui/core/tokens/app_shadows.dart — | |
| 174 | - 어드민 유효분만 이식(voucher inner·stacked 등 미이식). */ | |
| 175 | - --shadow-card: 0 5.16px 5.16px rgb(93 100 119 / 0.12); | |
| 176 | - --shadow-list-card: 0 8px 16px rgb(93 100 119 / 0.12); | |
| 177 | - --shadow-dialog: 0 8px 16px rgb(0 0 0 / 0.1); | |
| 178 | - --shadow-popup: 2.16px 6.32px 6px rgb(177 178 180 / 0.24); | |
| 179 | - --shadow-toast: 0 10px 12px rgb(0 0 0 / 0.2); | |
| 180 | - --shadow-subtle: 0 2px 4px rgb(0 0 0 / 0.08); | |
| 181 | - | |
| 182 | - /* 애니메이션 토큰(신규 네임스페이스 — §4.2 승인). 오버레이·다이얼로그·토스트· | |
| 183 | - 스플래시 전환에 사용. --ease-*는 Tailwind 기본값 사용, duration은 값에 | |
| 184 | - 내장(트랜지션 duration 표준은 bare 유틸 duration-200 사용). */ | |
| 185 | - --animate-fade-in: fade-in 200ms var(--ease-out); | |
| 186 | - --animate-fade-out: fade-out 200ms var(--ease-in); | |
| 187 | - --animate-dialog-in: dialog-in 200ms var(--ease-out); | |
| 188 | - --animate-toast-in: toast-in 300ms var(--ease-out); | |
| 189 | -} | |
| 190 | - | |
| 191 | -@keyframes fade-in { | |
| 192 | - from { | |
| 193 | - opacity: 0; | |
| 194 | - } | |
| 195 | - to { | |
| 196 | - opacity: 1; | |
| 197 | - } | |
| 198 | -} | |
| 199 | - | |
| 200 | -/* 스플래시 등 풀스크린 오버레이 퇴장용 — fade-in의 역방향 키프레임. */ | |
| 201 | -@keyframes fade-out { | |
| 202 | - from { | |
| 203 | - opacity: 1; | |
| 204 | - } | |
| 205 | - to { | |
| 206 | - opacity: 0; | |
| 207 | - } | |
| 208 | -} | |
| 209 | - | |
| 210 | -@keyframes dialog-in { | |
| 211 | - from { | |
| 212 | - opacity: 0; | |
| 213 | - transform: scale(0.95); | |
| 214 | - } | |
| 215 | - to { | |
| 216 | - opacity: 1; | |
| 217 | - transform: scale(1); | |
| 218 | - } | |
| 219 | -} | |
| 220 | - | |
| 221 | -@keyframes toast-in { | |
| 222 | - from { | |
| 223 | - opacity: 0; | |
| 224 | - transform: translateY(0.5rem); | |
| 225 | - } | |
| 226 | - to { | |
| 227 | - opacity: 1; | |
| 228 | - transform: translateY(0); | |
| 229 | - } | |
| 230 | -} | |
| 231 | - | |
| 232 | -/* 간격 토큰 매핑 (변경 없음) — edupay-app lib/ui/core/tokens/app_spacing.dart의 | |
| 233 | - 4/8 grid(4·8·12·16·20·24·32·40)는 Tailwind 기본 --spacing: 0.25rem 스케일과 | |
| 234 | - 정확히 일치한다: xs→p-1, sm→p-2, md→p-3, lg→p-4, xl→p-5, xxl→p-6, | |
| 235 | - xxxl→p-8, xxxxl→p-10. */ | |
| 236 | - | |
| 237 | -/* 테마 수동 전환 — feature 소비처(예: 헤더 테마 토글)를 위한 CSS 구동 변형자. | |
| 238 | - design 계획 §9 게이트 교차 검증에서 feature 안(아이콘 3개를 모두 DOM에 렌더하고 | |
| 239 | - CSS만으로 하나를 표시)을 채택했다 — 서버/클라 출력이 항상 동일해 hydration | |
| 240 | - 불일치 표면적이 0이다. `theme-light`/`theme-dark`는 명시적 선택에만 매칭되고, | |
| 241 | - `theme-system`은 data-theme 부재 **및** 명시적 data-theme="system" 값 둘 다 | |
| 242 | - 커버해야 하므로(둘은 동일하게 동작 — feature 전달 사양) 부정 선택자 두 개로 | |
| 243 | - "light도 dark도 아님"을 표현한다(속성 부재 시에도 :not()은 매칭된다). */ | |
| 244 | -@custom-variant theme-system (&:where(html:not([data-theme="light"]):not([data-theme="dark"]) *)); | |
| 245 | -@custom-variant theme-light (&:where(html[data-theme="light"] *)); | |
| 246 | -@custom-variant theme-dark (&:where(html[data-theme="dark"] *)); | |
| 247 | - | |
| 248 | -/* 테마 전환 색상 트랜지션 — feature가 토글 직전 <html>에 [data-theme-transition]을 | |
| 249 | - 부여하고 ~200ms 후 제거하는 시간 한정 opt-in이다(상시 트랜지션이 아니므로 다른 | |
| 250 | - hover/트랜지션 유틸과 경쟁하지 않는다). prefers-reduced-motion: no-preference로 | |
| 251 | - 게이팅해 감소 모션 환경에서는 아무 트랜지션도 걸리지 않는다(전환 없이 즉시 반영). | |
| 252 | - !important는 유틸리티의 transition 계열 클래스(transition-none 포함)가 이 200ms | |
| 253 | - 동안만 존재하는 규칙을 덮어쓰지 않도록 한다. box-shadow는 제외 — --shadow 토큰은 | |
| 254 | - 테마별로 값이 바뀌지 않아 전환할 대상이 없다. */ | |
| 255 | -@media (prefers-reduced-motion: no-preference) { | |
| 256 | - html[data-theme-transition], | |
| 257 | - html[data-theme-transition] *, | |
| 258 | - html[data-theme-transition] *::before, | |
| 259 | - html[data-theme-transition] *::after { | |
| 260 | - transition: | |
| 261 | - background-color 200ms var(--ease-out), | |
| 262 | - border-color 200ms var(--ease-out), | |
| 263 | - color 200ms var(--ease-out), | |
| 264 | - fill 200ms var(--ease-out), | |
| 265 | - stroke 200ms var(--ease-out), | |
| 266 | - outline-color 200ms var(--ease-out) !important; | |
| 267 | - } | |
| 268 | -} | |
| 269 | - | |
| 270 | -body { | |
| 271 | - background: var(--background); | |
| 272 | - color: var(--foreground); | |
| 273 | -} |
+++ app/globals.scss
... | ... | @@ -0,0 +1,20 @@ |
| 1 | +// 앱 글로벌 스타일 — 디자인 시스템 로드 + **이 앱에만 해당하는** 최소 베이스. | |
| 2 | +// | |
| 3 | +// 색·타이포·간격 같은 디자인 값은 여기에 두지 않는다. 전부 `@fox/styles/tokens/`가 | |
| 4 | +// 소유하며, 이 파일은 "@fox를 켜고, 이 앱의 셸 구조를 잡는다"까지만 한다. | |
| 5 | +// 화면·컴포넌트 스타일은 각자의 `.module.scss`로 간다. | |
| 6 | + | |
| 7 | +@use "@fox/styles"; | |
| 8 | + | |
| 9 | +// 앱 셸 — 루트 레이아웃이 뷰포트 높이를 채우고 세로로 쌓이는 구조. | |
| 10 | +// 푸터를 바닥에 붙이거나 본문 영역만 스크롤시키는 화면들이 이 전제에 기댄다. | |
| 11 | +html, | |
| 12 | +body { | |
| 13 | + block-size: 100%; | |
| 14 | +} | |
| 15 | + | |
| 16 | +body { | |
| 17 | + display: flex; | |
| 18 | + flex-direction: column; | |
| 19 | + min-block-size: 100%; | |
| 20 | +} |
--- app/layout.tsx
+++ app/layout.tsx
... | ... | @@ -9,7 +9,7 @@ |
| 9 | 9 |
THEME_ATTRIBUTE_NAME, |
| 10 | 10 |
THEME_STORAGE_KEY, |
| 11 | 11 |
} from "@/lib/constants/theme"; |
| 12 |
-import "./globals.css"; |
|
| 12 |
+import "./globals.scss"; |
|
| 13 | 13 |
|
| 14 | 14 |
// 첫 페인트 전 테마 적용 — <head>는 <body>보다 먼저 파싱되므로 <html>만 조작 가능하다 |
| 15 | 15 |
// (preventing-flash-before-hydration.md의 Themes 패턴). localStorage에 명시 선택("light"
|
... | ... | @@ -41,9 +41,11 @@ |
| 41 | 41 |
SPLASH_SHOWN_STORAGE_KEY |
| 42 | 42 |
)}))document.documentElement.setAttribute("data-splash-shown","")}catch(e){}})()`;
|
| 43 | 43 |
|
| 44 |
-// 어드민 전체 기본 서체(edupay-app 일체화, design 전달 사양) — Geist는 대체되어 제거했다. |
|
| 45 |
-// globals.css `--font-sans`가 이 변수를 우선 참조하고, 로드 전에도 system-ui로 안전하게 |
|
| 46 |
-// 폴백하도록 이미 구성돼 있다. |
|
| 44 |
+// 어드민 전체 기본 서체. 폰트 파일은 **앱이 소유**하고 디자인 시스템은 참조만 한다 — |
|
| 45 |
+// `@fox`는 `--app-font-sans`를 읽을 뿐이라(@fox/README.md "호스트 앱과의 계약") 폴더를 |
|
| 46 |
+// 다른 프로젝트로 복사해도 그 프로젝트의 서체를 그대로 따른다. 변수명이 `--app-*`인 것도 |
|
| 47 |
+// 그 계약의 이름이기 때문이며, 미주입 시에도 `system-ui`로 폴백하도록 토큰 쪽에 폴백이 |
|
| 48 |
+// 들어 있다. |
|
| 47 | 49 |
const wantedSans = localFont({
|
| 48 | 50 |
src: [ |
| 49 | 51 |
{
|
... | ... | @@ -67,14 +69,13 @@ |
| 67 | 69 |
style: "normal", |
| 68 | 70 |
}, |
| 69 | 71 |
], |
| 70 |
- variable: "--font-wanted-sans", |
|
| 72 |
+ variable: "--app-font-sans", |
|
| 71 | 73 |
display: "swap", |
| 72 | 74 |
}); |
| 73 | 75 |
|
| 74 |
-// 코드/숫자 표기용 고정폭 서체 — globals.css `--font-mono`가 여전히 이 변수를 직접 참조하므로 |
|
| 75 |
-// (폴백 체인 없음) 유지한다. |
|
| 76 |
+// 코드/숫자 표기용 고정폭 서체 — sans와 동일하게 `--app-font-mono` 계약으로 주입한다. |
|
| 76 | 77 |
const geistMono = Geist_Mono({
|
| 77 |
- variable: "--font-geist-mono", |
|
| 78 |
+ variable: "--app-font-mono", |
|
| 78 | 79 |
subsets: ["latin"], |
| 79 | 80 |
}); |
| 80 | 81 |
|
... | ... | @@ -107,14 +108,14 @@ |
| 107 | 108 |
<html |
| 108 | 109 |
lang="ko" |
| 109 | 110 |
suppressHydrationWarning |
| 110 |
- className={`${wantedSans.variable} ${geistMono.variable} h-full antialiased`}
|
|
| 111 |
+ className={`${wantedSans.variable} ${geistMono.variable}`}
|
|
| 111 | 112 |
> |
| 112 | 113 |
<head> |
| 113 | 114 |
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
|
| 114 | 115 |
<style dangerouslySetInnerHTML={{ __html: SPLASH_HIDE_STYLE }} />
|
| 115 | 116 |
<script dangerouslySetInnerHTML={{ __html: SPLASH_GATE_SCRIPT }} />
|
| 116 | 117 |
</head> |
| 117 |
- <body className="min-h-full flex flex-col"> |
|
| 118 |
+ <body> |
|
| 118 | 119 |
<SplashGate /> |
| 119 | 120 |
<FeedbackProvider> |
| 120 | 121 |
{children}
|
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?