임동욱 임동욱 07-28
Merge branch 'integration/theme-toggle' into feature/theme-toggle
@396baecefda87a5934470ca079488b0f788b081b
app/globals.css
--- app/globals.css
+++ app/globals.css
@@ -2,36 +2,62 @@
 
 /* 색상 토큰 출처: edupay-app lib/ui/core/tokens/app_colors.dart
    (Figma: FOX PAY 1차 제작시안, node 91:10205). 다크 값은 edupay-app에 대응
-   다크 시안이 없어 "임의 도출 — 정식 다크 시안 확정 시 교체". */
+   다크 시안이 없어 "임의 도출 — 정식 다크 시안 확정 시 교체".
+   메커니즘: `color-scheme` + `light-dark()`(Chrome 123+/Safari 17.5+/Firefox 120+
+   지원, 미지원 브라우저는 값이 무효화되므로 사내 어드민 한정 트레이드오프로 채택
+   — design 계획 §4.1/§5.3, 2026-07-28 게이트 승인). 각 시맨틱 토큰이
+   `light-dark(라이트값, 다크값)`으로 라이트/다크 값을 한 선언에 함께 보유해
+   드리프트를 구조적으로 차단한다. 수동 전환은 아래 `:root[data-theme]`가
+   `color-scheme`만 덮어써 처리하므로 값 자체는 이 블록 한 곳에만 존재한다
+   (Next.js 16 번들 문서 preventing-flash-before-hydration.md §Themes 패턴). */
 :root {
+  color-scheme: light dark;
+
   /* Brand */
-  --primary: #502ef5;
-  --primary-container: #6d5ce7;
-  --on-primary: #ffffff;
-  --on-primary-container: #e2e0fb;
-  --secondary: #877aec;
+  --primary: light-dark(#502ef5, #877aec);
+  --primary-container: light-dark(#6d5ce7, #502ef5);
+  /* on-primary 다크값 대비 보정: 원래 #ffffff였으나 다크 --primary(#877aec) 위에서
+     3.49:1로 WCAG AA(4.5:1) 텍스트 기준 미달 확인 → #14161b로 교체해 5.18:1 통과.
+     Material 3 다크 규약(밝은 primary 표면 + 어두운 onPrimary 텍스트)과 일치하며
+     text-primary on surface(4.61:1)에는 영향 없음 — design 계획 §5.1(i),
+     2026-07-28 게이트 승인. */
+  --on-primary: light-dark(#ffffff, #14161b);
+  --on-primary-container: light-dark(#e2e0fb, #e2e0fb);
+  --secondary: light-dark(#877aec, #9b92ff);
 
   /* Surface / background (원 토큰: surface / gray0) */
-  --background: #ffffff;
-  --surface: #ffffff;
-  --surface-muted: #f5f7fa;
+  --background: light-dark(#ffffff, #14161b);
+  --surface: light-dark(#ffffff, #1e2128);
+  --surface-muted: light-dark(#f5f7fa, #2c3038);
 
   /* Foreground / text (원 토큰: onSurface 계열) */
-  --foreground: #2c3038;
-  --foreground-muted: #565d6b;
-  --foreground-subtle: #6b7486;
+  --foreground: light-dark(#2c3038, #f5f7fa);
+  --foreground-muted: light-dark(#565d6b, #bbc4d8);
+  --foreground-subtle: light-dark(#6b7486, #8790a3);
 
   /* Border (원 토큰: outline 계열) */
-  --border: #dae0ed;
-  --border-subtle: #e1e6f1;
+  --border: light-dark(#dae0ed, #424752);
+  --border-subtle: light-dark(#e1e6f1, #343943);
 
   /* Status */
-  --danger: #ec2e2f;
-  --success: #21b770;
-  --warning: #ffb020;
+  --danger: light-dark(#ec2e2f, #ff5c5d);
+  --success: light-dark(#21b770, #3acb93);
+  --warning: light-dark(#ffb020, #ffc24d);
 
   /* Overlay */
-  --scrim: rgb(0 0 0 / 0.6);
+  --scrim: light-dark(rgb(0 0 0 / 0.6), rgb(0 0 0 / 0.7));
+}
+
+/* 수동 테마 선택 — <html data-theme="light"|"dark">가 `color-scheme`만 덮어써
+   위 light-dark() 값이 해당 분기로 고정된다(값 자체는 여기서 재정의하지 않음).
+   data-theme 속성이 없거나 "system"이면 :root의 `color-scheme: light dark`가
+   유지되어 OS `prefers-color-scheme`를 그대로 따른다(feature 전달 사양). */
+:root[data-theme="light"] {
+  color-scheme: light;
+}
+
+:root[data-theme="dark"] {
+  color-scheme: dark;
 }
 
 @theme inline {
@@ -232,31 +258,36 @@
    정확히 일치한다: xs→p-1, sm→p-2, md→p-3, lg→p-4, xl→p-5, xxl→p-6,
    xxxl→p-8, xxxxl→p-10. */
 
-@media (prefers-color-scheme: dark) {
-  :root {
-    /* 임의 도출 — 정식 다크 시안 확정 시 교체 */
-    --primary: #877aec;
-    --primary-container: #502ef5;
-    --on-primary: #ffffff;
-    --on-primary-container: #e2e0fb;
-    --secondary: #9b92ff;
+/* 테마 수동 전환 — feature 소비처(예: 헤더 테마 토글)를 위한 CSS 구동 변형자.
+   design 계획 §9 게이트 교차 검증에서 feature 안(아이콘 3개를 모두 DOM에 렌더하고
+   CSS만으로 하나를 표시)을 채택했다 — 서버/클라 출력이 항상 동일해 hydration
+   불일치 표면적이 0이다. `theme-light`/`theme-dark`는 명시적 선택에만 매칭되고,
+   `theme-system`은 data-theme 부재 **및** 명시적 data-theme="system" 값 둘 다
+   커버해야 하므로(둘은 동일하게 동작 — feature 전달 사양) 부정 선택자 두 개로
+   "light도 dark도 아님"을 표현한다(속성 부재 시에도 :not()은 매칭된다). */
+@custom-variant theme-system (&:where(html:not([data-theme="light"]):not([data-theme="dark"]) *));
+@custom-variant theme-light (&:where(html[data-theme="light"] *));
+@custom-variant theme-dark (&:where(html[data-theme="dark"] *));
 
-    --background: #14161b;
-    --surface: #1e2128;
-    --surface-muted: #2c3038;
-
-    --foreground: #f5f7fa;
-    --foreground-muted: #bbc4d8;
-    --foreground-subtle: #8790a3;
-
-    --border: #424752;
-    --border-subtle: #343943;
-
-    --danger: #ff5c5d;
-    --success: #3acb93;
-    --warning: #ffc24d;
-
-    --scrim: rgb(0 0 0 / 0.7);
+/* 테마 전환 색상 트랜지션 — feature가 토글 직전 <html>에 [data-theme-transition]을
+   부여하고 ~200ms 후 제거하는 시간 한정 opt-in이다(상시 트랜지션이 아니므로 다른
+   hover/트랜지션 유틸과 경쟁하지 않는다). prefers-reduced-motion: no-preference로
+   게이팅해 감소 모션 환경에서는 아무 트랜지션도 걸리지 않는다(전환 없이 즉시 반영).
+   !important는 유틸리티의 transition 계열 클래스(transition-none 포함)가 이 200ms
+   동안만 존재하는 규칙을 덮어쓰지 않도록 한다. box-shadow는 제외 — --shadow 토큰은
+   테마별로 값이 바뀌지 않아 전환할 대상이 없다. */
+@media (prefers-reduced-motion: no-preference) {
+  html[data-theme-transition],
+  html[data-theme-transition] *,
+  html[data-theme-transition] *::before,
+  html[data-theme-transition] *::after {
+    transition:
+      background-color 200ms var(--ease-out),
+      border-color 200ms var(--ease-out),
+      color 200ms var(--ease-out),
+      fill 200ms var(--ease-out),
+      stroke 200ms var(--ease-out),
+      outline-color 200ms var(--ease-out) !important;
   }
 }
 
 
components/ui/icon-button.tsx (added)
+++ components/ui/icon-button.tsx
@@ -0,0 +1,48 @@
+import type { ReactNode } from "react";
+
+interface IconButtonProps {
+  /** 접근성 이름(`aria-label`)과 네이티브 툴팁(`title`)으로 쓰인다. 생략하면 두
+   * 속성을 렌더하지 않는다 — 이 경우 children에 포함된 `sr-only` 텍스트가 접근성
+   * 이름이 되어야 한다(예: 아이콘 여러 개를 CSS로 전환 노출하며 각각 옆에 개별
+   * `sr-only` 라벨을 두는 구성). */
+  label?: string;
+  /** 아이콘(들). 장식용 아이콘은 자체적으로 `aria-hidden`을 갖는다 — 접근성 이름이
+   * 필요하면 `label`을 전달하거나 children에 `sr-only` 텍스트를 포함해야 한다. */
+  children: ReactNode;
+  type?: "button" | "submit";
+  /** 2-상태 토글일 때만 전달 → `aria-pressed` 렌더. 3-상태 순환 등에서는 생략 권장
+   * (`aria-pressed`는 이진 개념이라 의미가 어긋난다). */
+  isPressed?: boolean;
+  onClick?: () => void;
+  disabled?: boolean;
+  /** 배치(레이아웃) 조정용 클래스 패스스루만 허용 — 디자인 값 주입 금지. */
+  className?: string;
+}
+
+/**
+ * 아이콘 전용 버튼 비주얼. 상태를 가지지 않는 프레젠테이션 컴포넌트 — 클릭 동작·상태
+ * 관리는 호출부(feature) 책임이다.
+ */
+export function IconButton({
+  label,
+  children,
+  type = "button",
+  isPressed,
+  onClick,
+  disabled,
+  className,
+}: IconButtonProps) {
+  return (
+    <button
+      type={type}
+      onClick={onClick}
+      disabled={disabled}
+      aria-label={label}
+      title={label}
+      aria-pressed={isPressed}
+      className={`inline-flex size-9 items-center justify-center rounded-md border border-border text-foreground-muted transition-colors duration-200 hover:bg-surface-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 ${className ?? ""}`}
+    >
+      {children}
+    </button>
+  );
+}
 
components/ui/icons/monitor-icon.tsx (added)
+++ components/ui/icons/monitor-icon.tsx
@@ -0,0 +1,34 @@
+interface MonitorIconProps {
+  /** 표시 제어용 클래스 패스스루(예: 테마 CSS 변형자 `hidden theme-system:contents`) —
+   * 감싸는 `<span>`에 적용된다(`display: contents`는 CSS 스펙상 대체 요소인 `<svg>`
+   * 루트에는 적용되지 않고 `none`으로 계산되므로, 아이콘 자체가 아니라 span에
+   * 걸어야 실제로 전환이 동작한다). 디자인 값(색상·치수 등) 주입은 금지 — 색은
+   * 상위 요소의 currentColor를 상속한다. */
+  className?: string;
+}
+
+/**
+ * 시스템(OS) 테마를 상징하는 모니터 아이콘(화면 사각형 + 받침). 직접 작도한 인라인 SVG.
+ * `stroke="currentColor"`라 버튼 등 상위 요소의 text 색 토큰을 그대로 상속하며,
+ * 장식용이므로 `aria-hidden`을 부여한다 — 접근성 이름은 소비처(IconButton)가 책임진다.
+ */
+export function MonitorIcon({ className }: MonitorIconProps) {
+  return (
+    <span className={className}>
+      <svg
+        viewBox="0 0 24 24"
+        fill="none"
+        stroke="currentColor"
+        strokeWidth="2"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        aria-hidden="true"
+        className="size-5"
+      >
+        <rect x="3" y="4" width="18" height="13" rx="1.5" />
+        <path d="M8 21h8" />
+        <path d="M12 17v4" />
+      </svg>
+    </span>
+  );
+}
 
components/ui/icons/moon-icon.tsx (added)
+++ components/ui/icons/moon-icon.tsx
@@ -0,0 +1,34 @@
+interface MoonIconProps {
+  /** 표시 제어용 클래스 패스스루(예: 테마 CSS 변형자 `hidden theme-dark:contents`) —
+   * 감싸는 `<span>`에 적용된다(`display: contents`는 CSS 스펙상 대체 요소인 `<svg>`
+   * 루트에는 적용되지 않고 `none`으로 계산되므로, 아이콘 자체가 아니라 span에
+   * 걸어야 실제로 전환이 동작한다). 디자인 값(색상·치수 등) 주입은 금지 — 색은
+   * 상위 요소의 currentColor를 상속한다. */
+  className?: string;
+}
+
+/**
+ * 다크 테마를 상징하는 초승달 아이콘(단일 path). 직접 작도한 인라인 SVG — 큰 원호(외곽)와
+ * 작은 원호(안쪽 오려내기)를 이어 붙인 표준 초승달 구성법을 사용했다(외부 아이콘
+ * 저작물 복제 없음).
+ * `stroke="currentColor"`라 버튼 등 상위 요소의 text 색 토큰을 그대로 상속하며,
+ * 장식용이므로 `aria-hidden`을 부여한다 — 접근성 이름은 소비처(IconButton)가 책임진다.
+ */
+export function MoonIcon({ className }: MoonIconProps) {
+  return (
+    <span className={className}>
+      <svg
+        viewBox="0 0 24 24"
+        fill="none"
+        stroke="currentColor"
+        strokeWidth="2"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        aria-hidden="true"
+        className="size-5"
+      >
+        <path d="M20 14.5A8.5 8.5 0 1 1 9.5 4A6.5 6.5 0 0 0 20 14.5Z" />
+      </svg>
+    </span>
+  );
+}
 
components/ui/icons/sun-icon.tsx (added)
+++ components/ui/icons/sun-icon.tsx
@@ -0,0 +1,40 @@
+interface SunIconProps {
+  /** 표시 제어용 클래스 패스스루(예: 테마 CSS 변형자 `hidden theme-light:contents`) —
+   * 감싸는 `<span>`에 적용된다(`display: contents`는 CSS 스펙상 대체 요소인 `<svg>`
+   * 루트에는 적용되지 않고 `none`으로 계산되므로, 아이콘 자체가 아니라 span에
+   * 걸어야 실제로 전환이 동작한다). 디자인 값(색상·치수 등) 주입은 금지 — 색은
+   * 상위 요소의 currentColor를 상속한다. */
+  className?: string;
+}
+
+/**
+ * 라이트 테마를 상징하는 해 아이콘(중심 원 + 8방향 광선). 직접 작도한 인라인 SVG.
+ * `stroke="currentColor"`라 버튼 등 상위 요소의 text 색 토큰을 그대로 상속하며,
+ * 장식용이므로 `aria-hidden`을 부여한다 — 접근성 이름은 소비처(IconButton)가 책임진다.
+ */
+export function SunIcon({ className }: SunIconProps) {
+  return (
+    <span className={className}>
+      <svg
+        viewBox="0 0 24 24"
+        fill="none"
+        stroke="currentColor"
+        strokeWidth="2"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        aria-hidden="true"
+        className="size-5"
+      >
+        <circle cx="12" cy="12" r="4" />
+        <path d="M12 2v2.5" />
+        <path d="M12 19.5V22" />
+        <path d="M2 12h2.5" />
+        <path d="M19.5 12H22" />
+        <path d="M4.93 4.93l1.77 1.77" />
+        <path d="M17.3 17.3l1.77 1.77" />
+        <path d="M4.93 19.07l1.77-1.77" />
+        <path d="M17.3 6.7l1.77-1.77" />
+      </svg>
+    </span>
+  );
+}
Add a comment
List