임동욱 임동욱 07-28
feat: 테마 전환 훅 추가
Co-Authored-By: Claude Opus 5 
@bdb50e6636f5c18a345c7c50c55c40ef3a5369b3
 
app/(protected)/(basic)/_hooks/use-theme-preference.ts (added)
+++ app/(protected)/(basic)/_hooks/use-theme-preference.ts
@@ -0,0 +1,81 @@
+'use client';
+
+import { useCallback } from 'react';
+import {
+  DEFAULT_THEME_PREFERENCE,
+  THEME_ATTRIBUTE_NAME,
+  THEME_PREFERENCE_CYCLE,
+  THEME_STORAGE_KEY,
+  type ThemePreference,
+} from '@/lib/constants/theme';
+
+// design이 globals.css에서 이 속성이 존재하는 동안만 색상 전환에 트랜지션을 건다(design 계획
+// §8.3). prefers-reduced-motion 분기는 그 CSS 쪽 셀렉터가 처리하므로 이 훅은 조건 없이
+// 부여/제거만 한다.
+const THEME_TRANSITION_ATTRIBUTE_NAME = 'data-theme-transition';
+const THEME_TRANSITION_DURATION_MS = 200;
+
+export interface ThemePreferenceState {
+  cycleThemePreference: () => void;
+}
+
+/** `<html data-theme>`에서 현재 선택을 읽는다. 속성이 없거나 알 수 없는 값이면 system이다. */
+function readThemePreference(): ThemePreference {
+  const value = document.documentElement.getAttribute(THEME_ATTRIBUTE_NAME);
+  return value === 'light' || value === 'dark'
+    ? value
+    : DEFAULT_THEME_PREFERENCE;
+}
+
+/** 순환 순서(THEME_PREFERENCE_CYCLE)에서 다음 값을 계산한다. */
+function getNextThemePreference(current: ThemePreference): ThemePreference {
+  const currentIndex = THEME_PREFERENCE_CYCLE.indexOf(current);
+  const nextIndex = (currentIndex + 1) % THEME_PREFERENCE_CYCLE.length;
+  return THEME_PREFERENCE_CYCLE[nextIndex];
+}
+
+/** 선택값을 기기에 영속화한다. system은 저장값을 지워 "선택 없음" 상태로 되돌린다. */
+function persistThemePreference(next: ThemePreference): void {
+  try {
+    if (next === 'system') {
+      localStorage.removeItem(THEME_STORAGE_KEY);
+    } else {
+      localStorage.setItem(THEME_STORAGE_KEY, next);
+    }
+  } catch {
+    // localStorage 접근이 차단된 환경(시크릿 모드 등)에서도 조용히 무시한다 — <html data-theme>만
+    // 으로 현재 세션 동안은 정상 동작하고, 다음 방문 시 다시 system으로 fail-safe한다.
+  }
+}
+
+/** `<html>`에 선택값을 반영한다. system은 속성 부재로 표현한다(§4-1 규격). */
+function applyThemePreference(next: ThemePreference): void {
+  const root = document.documentElement;
+  if (next === 'system') {
+    root.removeAttribute(THEME_ATTRIBUTE_NAME);
+  } else {
+    root.setAttribute(THEME_ATTRIBUTE_NAME, next);
+  }
+  persistThemePreference(next);
+}
+
+/**
+ * 헤더 테마 토글 버튼의 화면 로컬 로직(ViewModel 대응) — `<html data-theme>` 자체가 SSOT이므로
+ * React state를 두지 않는다(§4-3). 클릭 시점에 DOM에서 현재값을 읽고 다음 값을 계산해
+ * 속성/localStorage에 쓴다. `useState`/`useEffect`가 없어 hydration 불일치 표면적이 0이다.
+ */
+export function useThemePreference(): ThemePreferenceState {
+  const cycleThemePreference = useCallback(() => {
+    const root = document.documentElement;
+    const next = getNextThemePreference(readThemePreference());
+
+    // 트랜지션 부여 → 같은 tick에 data-theme 변경 → 200ms 후 트랜지션 속성 제거.
+    root.setAttribute(THEME_TRANSITION_ATTRIBUTE_NAME, '');
+    applyThemePreference(next);
+    window.setTimeout(() => {
+      root.removeAttribute(THEME_TRANSITION_ATTRIBUTE_NAME);
+    }, THEME_TRANSITION_DURATION_MS);
+  }, []);
+
+  return { cycleThemePreference };
+}
Add a comment
List