Merge branch 'integration/theme-toggle' into feature/theme-toggle
@396baecefda87a5934470ca079488b0f788b081b
--- app/globals.css
+++ app/globals.css
... | ... | @@ -2,36 +2,62 @@ |
| 2 | 2 |
|
| 3 | 3 |
/* 색상 토큰 출처: edupay-app lib/ui/core/tokens/app_colors.dart |
| 4 | 4 |
(Figma: FOX PAY 1차 제작시안, node 91:10205). 다크 값은 edupay-app에 대응 |
| 5 |
- 다크 시안이 없어 "임의 도출 — 정식 다크 시안 확정 시 교체". */ |
|
| 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 패턴). */ |
|
| 6 | 13 |
:root {
|
| 14 |
+ color-scheme: light dark; |
|
| 15 |
+ |
|
| 7 | 16 |
/* Brand */ |
| 8 |
- --primary: #502ef5; |
|
| 9 |
- --primary-container: #6d5ce7; |
|
| 10 |
- --on-primary: #ffffff; |
|
| 11 |
- --on-primary-container: #e2e0fb; |
|
| 12 |
- --secondary: #877aec; |
|
| 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); |
|
| 13 | 27 |
|
| 14 | 28 |
/* Surface / background (원 토큰: surface / gray0) */ |
| 15 |
- --background: #ffffff; |
|
| 16 |
- --surface: #ffffff; |
|
| 17 |
- --surface-muted: #f5f7fa; |
|
| 29 |
+ --background: light-dark(#ffffff, #14161b); |
|
| 30 |
+ --surface: light-dark(#ffffff, #1e2128); |
|
| 31 |
+ --surface-muted: light-dark(#f5f7fa, #2c3038); |
|
| 18 | 32 |
|
| 19 | 33 |
/* Foreground / text (원 토큰: onSurface 계열) */ |
| 20 |
- --foreground: #2c3038; |
|
| 21 |
- --foreground-muted: #565d6b; |
|
| 22 |
- --foreground-subtle: #6b7486; |
|
| 34 |
+ --foreground: light-dark(#2c3038, #f5f7fa); |
|
| 35 |
+ --foreground-muted: light-dark(#565d6b, #bbc4d8); |
|
| 36 |
+ --foreground-subtle: light-dark(#6b7486, #8790a3); |
|
| 23 | 37 |
|
| 24 | 38 |
/* Border (원 토큰: outline 계열) */ |
| 25 |
- --border: #dae0ed; |
|
| 26 |
- --border-subtle: #e1e6f1; |
|
| 39 |
+ --border: light-dark(#dae0ed, #424752); |
|
| 40 |
+ --border-subtle: light-dark(#e1e6f1, #343943); |
|
| 27 | 41 |
|
| 28 | 42 |
/* Status */ |
| 29 |
- --danger: #ec2e2f; |
|
| 30 |
- --success: #21b770; |
|
| 31 |
- --warning: #ffb020; |
|
| 43 |
+ --danger: light-dark(#ec2e2f, #ff5c5d); |
|
| 44 |
+ --success: light-dark(#21b770, #3acb93); |
|
| 45 |
+ --warning: light-dark(#ffb020, #ffc24d); |
|
| 32 | 46 |
|
| 33 | 47 |
/* Overlay */ |
| 34 |
- --scrim: rgb(0 0 0 / 0.6); |
|
| 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; |
|
| 35 | 61 |
} |
| 36 | 62 |
|
| 37 | 63 |
@theme inline {
|
... | ... | @@ -232,31 +258,36 @@ |
| 232 | 258 |
정확히 일치한다: xs→p-1, sm→p-2, md→p-3, lg→p-4, xl→p-5, xxl→p-6, |
| 233 | 259 |
xxxl→p-8, xxxxl→p-10. */ |
| 234 | 260 |
|
| 235 |
-@media (prefers-color-scheme: dark) {
|
|
| 236 |
- :root {
|
|
| 237 |
- /* 임의 도출 — 정식 다크 시안 확정 시 교체 */ |
|
| 238 |
- --primary: #877aec; |
|
| 239 |
- --primary-container: #502ef5; |
|
| 240 |
- --on-primary: #ffffff; |
|
| 241 |
- --on-primary-container: #e2e0fb; |
|
| 242 |
- --secondary: #9b92ff; |
|
| 261 |
+/* 테마 수동 전환 — feature 소비처(예: 헤더 테마 토글)를 위한 CSS 구동 변형자. |
|
| 262 |
+ design 계획 §9 게이트 교차 검증에서 feature 안(아이콘 3개를 모두 DOM에 렌더하고 |
|
| 263 |
+ CSS만으로 하나를 표시)을 채택했다 — 서버/클라 출력이 항상 동일해 hydration |
|
| 264 |
+ 불일치 표면적이 0이다. `theme-light`/`theme-dark`는 명시적 선택에만 매칭되고, |
|
| 265 |
+ `theme-system`은 data-theme 부재 **및** 명시적 data-theme="system" 값 둘 다 |
|
| 266 |
+ 커버해야 하므로(둘은 동일하게 동작 — feature 전달 사양) 부정 선택자 두 개로 |
|
| 267 |
+ "light도 dark도 아님"을 표현한다(속성 부재 시에도 :not()은 매칭된다). */ |
|
| 268 |
+@custom-variant theme-system (&:where(html:not([data-theme="light"]):not([data-theme="dark"]) *)); |
|
| 269 |
+@custom-variant theme-light (&:where(html[data-theme="light"] *)); |
|
| 270 |
+@custom-variant theme-dark (&:where(html[data-theme="dark"] *)); |
|
| 243 | 271 |
|
| 244 |
- --background: #14161b; |
|
| 245 |
- --surface: #1e2128; |
|
| 246 |
- --surface-muted: #2c3038; |
|
| 247 |
- |
|
| 248 |
- --foreground: #f5f7fa; |
|
| 249 |
- --foreground-muted: #bbc4d8; |
|
| 250 |
- --foreground-subtle: #8790a3; |
|
| 251 |
- |
|
| 252 |
- --border: #424752; |
|
| 253 |
- --border-subtle: #343943; |
|
| 254 |
- |
|
| 255 |
- --danger: #ff5c5d; |
|
| 256 |
- --success: #3acb93; |
|
| 257 |
- --warning: #ffc24d; |
|
| 258 |
- |
|
| 259 |
- --scrim: rgb(0 0 0 / 0.7); |
|
| 272 |
+/* 테마 전환 색상 트랜지션 — feature가 토글 직전 <html>에 [data-theme-transition]을 |
|
| 273 |
+ 부여하고 ~200ms 후 제거하는 시간 한정 opt-in이다(상시 트랜지션이 아니므로 다른 |
|
| 274 |
+ hover/트랜지션 유틸과 경쟁하지 않는다). prefers-reduced-motion: no-preference로 |
|
| 275 |
+ 게이팅해 감소 모션 환경에서는 아무 트랜지션도 걸리지 않는다(전환 없이 즉시 반영). |
|
| 276 |
+ !important는 유틸리티의 transition 계열 클래스(transition-none 포함)가 이 200ms |
|
| 277 |
+ 동안만 존재하는 규칙을 덮어쓰지 않도록 한다. box-shadow는 제외 — --shadow 토큰은 |
|
| 278 |
+ 테마별로 값이 바뀌지 않아 전환할 대상이 없다. */ |
|
| 279 |
+@media (prefers-reduced-motion: no-preference) {
|
|
| 280 |
+ html[data-theme-transition], |
|
| 281 |
+ html[data-theme-transition] *, |
|
| 282 |
+ html[data-theme-transition] *::before, |
|
| 283 |
+ html[data-theme-transition] *::after {
|
|
| 284 |
+ transition: |
|
| 285 |
+ background-color 200ms var(--ease-out), |
|
| 286 |
+ border-color 200ms var(--ease-out), |
|
| 287 |
+ color 200ms var(--ease-out), |
|
| 288 |
+ fill 200ms var(--ease-out), |
|
| 289 |
+ stroke 200ms var(--ease-out), |
|
| 290 |
+ outline-color 200ms var(--ease-out) !important; |
|
| 260 | 291 |
} |
| 261 | 292 |
} |
| 262 | 293 |
|
+++ components/ui/icons/monitor-icon.tsx
... | ... | @@ -0,0 +1,34 @@ |
| 1 | +interface MonitorIconProps { | |
| 2 | + /** 표시 제어용 클래스 패스스루(예: 테마 CSS 변형자 `hidden theme-system:contents`) — | |
| 3 | + * 감싸는 `<span>`에 적용된다(`display: contents`는 CSS 스펙상 대체 요소인 `<svg>` | |
| 4 | + * 루트에는 적용되지 않고 `none`으로 계산되므로, 아이콘 자체가 아니라 span에 | |
| 5 | + * 걸어야 실제로 전환이 동작한다). 디자인 값(색상·치수 등) 주입은 금지 — 색은 | |
| 6 | + * 상위 요소의 currentColor를 상속한다. */ | |
| 7 | + className?: string; | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 시스템(OS) 테마를 상징하는 모니터 아이콘(화면 사각형 + 받침). 직접 작도한 인라인 SVG. | |
| 12 | + * `stroke="currentColor"`라 버튼 등 상위 요소의 text 색 토큰을 그대로 상속하며, | |
| 13 | + * 장식용이므로 `aria-hidden`을 부여한다 — 접근성 이름은 소비처(IconButton)가 책임진다. | |
| 14 | + */ | |
| 15 | +export function MonitorIcon({ className }: MonitorIconProps) { | |
| 16 | + return ( | |
| 17 | + <span className={className}> | |
| 18 | + <svg | |
| 19 | + viewBox="0 0 24 24" | |
| 20 | + fill="none" | |
| 21 | + stroke="currentColor" | |
| 22 | + strokeWidth="2" | |
| 23 | + strokeLinecap="round" | |
| 24 | + strokeLinejoin="round" | |
| 25 | + aria-hidden="true" | |
| 26 | + className="size-5" | |
| 27 | + > | |
| 28 | + <rect x="3" y="4" width="18" height="13" rx="1.5" /> | |
| 29 | + <path d="M8 21h8" /> | |
| 30 | + <path d="M12 17v4" /> | |
| 31 | + </svg> | |
| 32 | + </span> | |
| 33 | + ); | |
| 34 | +} |
+++ components/ui/icons/moon-icon.tsx
... | ... | @@ -0,0 +1,34 @@ |
| 1 | +interface MoonIconProps { | |
| 2 | + /** 표시 제어용 클래스 패스스루(예: 테마 CSS 변형자 `hidden theme-dark:contents`) — | |
| 3 | + * 감싸는 `<span>`에 적용된다(`display: contents`는 CSS 스펙상 대체 요소인 `<svg>` | |
| 4 | + * 루트에는 적용되지 않고 `none`으로 계산되므로, 아이콘 자체가 아니라 span에 | |
| 5 | + * 걸어야 실제로 전환이 동작한다). 디자인 값(색상·치수 등) 주입은 금지 — 색은 | |
| 6 | + * 상위 요소의 currentColor를 상속한다. */ | |
| 7 | + className?: string; | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 다크 테마를 상징하는 초승달 아이콘(단일 path). 직접 작도한 인라인 SVG — 큰 원호(외곽)와 | |
| 12 | + * 작은 원호(안쪽 오려내기)를 이어 붙인 표준 초승달 구성법을 사용했다(외부 아이콘 | |
| 13 | + * 저작물 복제 없음). | |
| 14 | + * `stroke="currentColor"`라 버튼 등 상위 요소의 text 색 토큰을 그대로 상속하며, | |
| 15 | + * 장식용이므로 `aria-hidden`을 부여한다 — 접근성 이름은 소비처(IconButton)가 책임진다. | |
| 16 | + */ | |
| 17 | +export function MoonIcon({ className }: MoonIconProps) { | |
| 18 | + return ( | |
| 19 | + <span className={className}> | |
| 20 | + <svg | |
| 21 | + viewBox="0 0 24 24" | |
| 22 | + fill="none" | |
| 23 | + stroke="currentColor" | |
| 24 | + strokeWidth="2" | |
| 25 | + strokeLinecap="round" | |
| 26 | + strokeLinejoin="round" | |
| 27 | + aria-hidden="true" | |
| 28 | + className="size-5" | |
| 29 | + > | |
| 30 | + <path d="M20 14.5A8.5 8.5 0 1 1 9.5 4A6.5 6.5 0 0 0 20 14.5Z" /> | |
| 31 | + </svg> | |
| 32 | + </span> | |
| 33 | + ); | |
| 34 | +} |
+++ components/ui/icons/sun-icon.tsx
... | ... | @@ -0,0 +1,40 @@ |
| 1 | +interface SunIconProps { | |
| 2 | + /** 표시 제어용 클래스 패스스루(예: 테마 CSS 변형자 `hidden theme-light:contents`) — | |
| 3 | + * 감싸는 `<span>`에 적용된다(`display: contents`는 CSS 스펙상 대체 요소인 `<svg>` | |
| 4 | + * 루트에는 적용되지 않고 `none`으로 계산되므로, 아이콘 자체가 아니라 span에 | |
| 5 | + * 걸어야 실제로 전환이 동작한다). 디자인 값(색상·치수 등) 주입은 금지 — 색은 | |
| 6 | + * 상위 요소의 currentColor를 상속한다. */ | |
| 7 | + className?: string; | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 라이트 테마를 상징하는 해 아이콘(중심 원 + 8방향 광선). 직접 작도한 인라인 SVG. | |
| 12 | + * `stroke="currentColor"`라 버튼 등 상위 요소의 text 색 토큰을 그대로 상속하며, | |
| 13 | + * 장식용이므로 `aria-hidden`을 부여한다 — 접근성 이름은 소비처(IconButton)가 책임진다. | |
| 14 | + */ | |
| 15 | +export function SunIcon({ className }: SunIconProps) { | |
| 16 | + return ( | |
| 17 | + <span className={className}> | |
| 18 | + <svg | |
| 19 | + viewBox="0 0 24 24" | |
| 20 | + fill="none" | |
| 21 | + stroke="currentColor" | |
| 22 | + strokeWidth="2" | |
| 23 | + strokeLinecap="round" | |
| 24 | + strokeLinejoin="round" | |
| 25 | + aria-hidden="true" | |
| 26 | + className="size-5" | |
| 27 | + > | |
| 28 | + <circle cx="12" cy="12" r="4" /> | |
| 29 | + <path d="M12 2v2.5" /> | |
| 30 | + <path d="M12 19.5V22" /> | |
| 31 | + <path d="M2 12h2.5" /> | |
| 32 | + <path d="M19.5 12H22" /> | |
| 33 | + <path d="M4.93 4.93l1.77 1.77" /> | |
| 34 | + <path d="M17.3 17.3l1.77 1.77" /> | |
| 35 | + <path d="M4.93 19.07l1.77-1.77" /> | |
| 36 | + <path d="M17.3 6.7l1.77-1.77" /> | |
| 37 | + </svg> | |
| 38 | + </span> | |
| 39 | + ); | |
| 40 | +} |
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?