From a568d6b2cb701cba01fbe6caba5eca758e576b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=85=B8=20=EA=B8=B0=20=ED=9B=88?= Date: Thu, 2 Jul 2026 17:49:01 +0900 Subject: [PATCH 1/7] feat(design): support system theme auto-detection and sync across web and extension --- .../entrypoints/quick-bookmark/App.tsx | 20 ++++++ .../sidepanel/providers/theme-provider.tsx | 67 ++++++++++++++----- apps/extension/entrypoints/welcome/App.tsx | 21 ++++++ apps/web/app/layout.tsx | 4 +- apps/web/playwright.config.ts | 1 + apps/web/ui/shared/theme-context.tsx | 40 +++++++---- 6 files changed, 125 insertions(+), 28 deletions(-) diff --git a/apps/extension/entrypoints/quick-bookmark/App.tsx b/apps/extension/entrypoints/quick-bookmark/App.tsx index c6630a8..a513cbc 100644 --- a/apps/extension/entrypoints/quick-bookmark/App.tsx +++ b/apps/extension/entrypoints/quick-bookmark/App.tsx @@ -48,6 +48,26 @@ async function saveMetadataWithRetry( } export function CreateApp() { + useEffect(() => { + const syncTheme = () => { + const savedTheme = localStorage.getItem('theme') || 'system'; + const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const isDark = savedTheme === 'dark' || (savedTheme === 'system' && systemPrefersDark); + document.documentElement.classList.toggle('dark', isDark); + }; + + syncTheme(); + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addEventListener('change', syncTheme); + window.addEventListener('storage', syncTheme); + + return () => { + mediaQuery.removeEventListener('change', syncTheme); + window.removeEventListener('storage', syncTheme); + }; + }, []); + const params = useMemo(() => new URLSearchParams(window.location.search), []); const [title, setTitle] = useState(params.get('title') ?? ''); diff --git a/apps/extension/entrypoints/sidepanel/providers/theme-provider.tsx b/apps/extension/entrypoints/sidepanel/providers/theme-provider.tsx index 7f271cd..b9cf6e7 100644 --- a/apps/extension/entrypoints/sidepanel/providers/theme-provider.tsx +++ b/apps/extension/entrypoints/sidepanel/providers/theme-provider.tsx @@ -1,7 +1,9 @@ +'use client'; + import type React from 'react'; import { createContext, useContext, useEffect, useState } from 'react'; -type Theme = 'light' | 'dark'; +type Theme = 'light' | 'dark' | 'system'; interface ThemeContextType { theme: Theme; @@ -12,28 +14,63 @@ interface ThemeContextType { const ThemeContext = createContext(undefined); export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setTheme] = useState('light'); + const [theme, setTheme] = useState('system'); + const [systemPrefersDark, setSystemPrefersDark] = useState(false); - // Load saved theme on mount + // Load saved theme, sync system theme and setup storage listeners on mount useEffect(() => { - const savedTheme = localStorage.getItem('theme') as Theme | null; - const initialTheme = savedTheme || 'light'; - setTheme(initialTheme); - document.documentElement.classList.toggle('dark', initialTheme === 'dark'); + const loadTheme = () => { + const savedTheme = localStorage.getItem('theme') as Theme | null; + if (savedTheme) { + setTheme(savedTheme); + } else { + localStorage.setItem('theme', 'system'); + } + }; + + loadTheme(); + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + setSystemPrefersDark(mediaQuery.matches); + + const handleSystemThemeChange = (e: MediaQueryListEvent) => { + setSystemPrefersDark(e.matches); + }; + + // 다른 익스텐션 탭/창에서 테마 변경 시 동기화 + const handleStorageChange = (e: StorageEvent) => { + if (e.key === 'theme') { + loadTheme(); + } + }; + + mediaQuery.addEventListener('change', handleSystemThemeChange); + window.addEventListener('storage', handleStorageChange); + + return () => { + mediaQuery.removeEventListener('change', handleSystemThemeChange); + window.removeEventListener('storage', handleStorageChange); + }; }, []); - // Sync class list and localStorage whenever theme state changes + const isDark = theme === 'dark' || (theme === 'system' && systemPrefersDark); + + // Declarative side-effect to sync document class and localStorage + useEffect(() => { + document.documentElement.classList.toggle('dark', isDark); + localStorage.setItem('theme', theme); + }, [theme, isDark]); + const toggleTheme = () => { - const nextTheme = theme === 'light' ? 'dark' : 'light'; - setTheme(nextTheme); - localStorage.setItem('theme', nextTheme); - document.documentElement.classList.toggle('dark', nextTheme === 'dark'); + setTheme((prev) => { + if (prev === 'system') return 'light'; + if (prev === 'light') return 'dark'; + return 'system'; + }); }; return ( - - {children} - + {children} ); } diff --git a/apps/extension/entrypoints/welcome/App.tsx b/apps/extension/entrypoints/welcome/App.tsx index d5fec55..47844aa 100644 --- a/apps/extension/entrypoints/welcome/App.tsx +++ b/apps/extension/entrypoints/welcome/App.tsx @@ -1,4 +1,5 @@ import { Bookmark, MousePointerClick, PanelRight, Search } from 'lucide-react'; +import { useEffect } from 'react'; import { browser } from 'wxt/browser'; const WEB_BASE_URL = 'http://localhost:3000'; @@ -27,6 +28,26 @@ const STEPS = [ ]; export function WelcomeApp() { + useEffect(() => { + const syncTheme = () => { + const savedTheme = localStorage.getItem('theme') || 'system'; + const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const isDark = savedTheme === 'dark' || (savedTheme === 'system' && systemPrefersDark); + document.documentElement.classList.toggle('dark', isDark); + }; + + syncTheme(); + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addEventListener('change', syncTheme); + window.addEventListener('storage', syncTheme); + + return () => { + mediaQuery.removeEventListener('change', syncTheme); + window.removeEventListener('storage', syncTheme); + }; + }, []); + const handleLogin = () => { browser.tabs.create({ url: `${WEB_BASE_URL}/auth?deviceType=EXTENSION` }); }; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index c98e3c7..dcd50f9 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -18,7 +18,9 @@ export default function RootLayout({