임동욱 임동욱 07-28
feat: 헤더에 테마 전환 버튼 배치
Co-Authored-By: Claude Opus 5 
@c3e89f703d2f60ce5a7c738cc31264e7a67f66b4
app/(protected)/(basic)/_components/admin-header.tsx
--- app/(protected)/(basic)/_components/admin-header.tsx
+++ app/(protected)/(basic)/_components/admin-header.tsx
@@ -1,4 +1,5 @@
 import { logout } from '@/app/(protected)/_actions';
+import { ThemeToggleButton } from '@/app/(protected)/(basic)/_components/theme-toggle-button';
 import { Header } from '@/components/ui/header';
 
 interface AdminHeaderProps {
@@ -8,8 +9,9 @@
 
 /**
  * 헤더 배치 — 비주얼 셸(Header)은 design 공용 컴포넌트를 그대로 쓰고, 사이드바 토글 버튼(공용
- * 컴포넌트가 제공하지 않는 상호작용)만 `leading` 슬롯에 주입한다. 로그아웃은 `children`(우측
- * 슬롯)에 주입한다.
+ * 컴포넌트가 제공하지 않는 상호작용)만 `leading` 슬롯에 주입한다. 테마 토글과 로그아웃은
+ * `children`(우측 슬롯)에 주입한다 — 테마 토글이 로그아웃 왼쪽에 오도록 DOM 순서를 그대로
+ * 시각 순서로 사용한다(Tab 포커스 순서도 동일하게 따라간다).
  */
 export function AdminHeader({
   onOpenSidebar,
@@ -41,6 +43,7 @@
         </>
       }
     >
+      <ThemeToggleButton />
       <form action={logout}>
         <button
           type="submit"
 
app/(protected)/(basic)/_components/theme-toggle-button.tsx (added)
+++ app/(protected)/(basic)/_components/theme-toggle-button.tsx
@@ -0,0 +1,39 @@
+import { useThemePreference } from '@/app/(protected)/(basic)/_hooks/use-theme-preference';
+import { IconButton } from '@/components/ui/icon-button';
+import { MonitorIcon } from '@/components/ui/icons/monitor-icon';
+import { MoonIcon } from '@/components/ui/icons/moon-icon';
+import { SunIcon } from '@/components/ui/icons/sun-icon';
+
+/**
+ * 헤더 우측 테마 토글 버튼 — 아이콘 3종(모니터/해/달)을 항상 DOM에 전부 렌더하고, design이
+ * globals.css에 정의한 `theme-system`/`theme-light`/`theme-dark` 커스텀 variant가 `<html
+ * data-theme>` 값에 따라 CSS만으로 하나만 보이게 한다. 서버·클라이언트 출력이 항상 동일해
+ * hydration 불일치가 없다 — React state로 아이콘을 고르지 않는다(계획 §4-4, §10 게이트 확정).
+ *
+ * 아이콘·라벨 쌍을 감싸는 `<span className="hidden theme-*:contents">` 래퍼에 표시 전환을
+ * 건다(아이콘 자체나 `sr-only` 라벨 각각에 개별로 걸지 않는다) — `display: contents`는 자신의
+ * 박스를 없애는 것이라, `sr-only`의 clip 기법이 의존하는 박스 자체가 있는 요소(라벨 span)에
+ * 직접 걸면 클리핑이 무효화돼 텍스트가 그대로 노출된다. 래퍼는 그 자신의 박스만 지우고
+ * 자식(아이콘의 내부 span, 라벨 span)은 각자의 박스를 그대로 유지하므로 안전하다. 아이콘
+ * 컴포넌트에는 `className`을 넘기지 않는다 — svg가 아니라 이 래퍼가 표시를 전담한다.
+ */
+export function ThemeToggleButton() {
+  const { cycleThemePreference } = useThemePreference();
+
+  return (
+    <IconButton onClick={cycleThemePreference}>
+      <span className="hidden theme-system:contents">
+        <MonitorIcon />
+        <span className="sr-only">테마 변경 (현재: 시스템 설정)</span>
+      </span>
+      <span className="hidden theme-light:contents">
+        <SunIcon />
+        <span className="sr-only">테마 변경 (현재: 라이트)</span>
+      </span>
+      <span className="hidden theme-dark:contents">
+        <MoonIcon />
+        <span className="sr-only">테마 변경 (현재: 다크)</span>
+      </span>
+    </IconButton>
+  );
+}
Add a comment
List