From 486732f23e743433afc725070b3a1c53233e40f8 Mon Sep 17 00:00:00 2001 From: "bottlenote-app[bot]" <289617182+bottlenote-app[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:25:05 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A0=84=EC=97=AD=20=EB=8B=A4=ED=81=AC?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=A0=81=EC=9A=A9=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DESIGN.md | 18 +-- .../whiskey-tarot/_components/IntroScreen.tsx | 2 +- .../_components/QuestioningScreen.tsx | 2 +- .../_components/ResultSlides.tsx | 2 +- src/app/(primary)/layout.tsx | 2 +- .../SettingsAppearanceSection.test.tsx | 25 ++-- .../_components/SettingsAppearanceSection.tsx | 117 ++++++++++++++--- src/app/layout.tsx | 30 ++++- src/components/ui/Form/Toggle.tsx | 2 +- src/components/ui/Modal/BottomSheet.tsx | 12 +- src/lib/theme/ThemeProvider.test.tsx | 118 ++++++++++-------- src/lib/theme/ThemeProvider.tsx | 89 ++++++++----- src/style/globals.css | 40 +++++- tailwind.config.ts | 15 ++- 14 files changed, 341 insertions(+), 133 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 24ffb79e..f0702a6e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -14,7 +14,7 @@ ### 2.1 Color -`tailwind.config.ts`의 `tailwindColors`를 우선 사용한다. +`tailwind.config.ts`의 semantic token을 우선 사용한다. `tailwindColors` export는 기존 문서·테스트 호환을 위한 raw light palette이며, 기존 utility class는 semantic CSS 변수로 해석된다. | Token | Hex | 주요 용도 | | -------------- | --------- | ------------------------------------------------- | @@ -29,9 +29,9 @@ | `mainDarkGray` | `#252525` | input text, modal sub text 등 | | `sectionWhite` | `#F7F7F7` | 섹션/카드 배경 | -CSS variable 기반 shadcn 계열 토큰도 존재한다: `background`, `foreground`, `card`, `primary`, `secondary`, `muted`, `accent`, `destructive`, `border`, `input`, `ring`. 단, BottleNote 고유 UI는 위 `tailwindColors`를 우선한다. +CSS variable 기반 shadcn 계열 토큰도 존재한다: `background`, `foreground`, `card`, `primary`, `secondary`, `muted`, `accent`, `destructive`, `border`, `input`, `ring`. BottleNote 고유 UI는 `bn-*` semantic token을 우선한다. -Semantic color 매핑은 현재 코드 사용례를 기준으로 아래처럼 해석한다. 실제 class는 우선 기존 Tailwind token 이름을 그대로 사용하고, 새 semantic alias를 코드에 추가하기 전까지 문서 기준으로만 활용한다. +Semantic color 매핑은 현재 코드 사용례를 기준으로 아래처럼 해석한다. 새 코드는 역할이 드러나는 `bn-*` class를 사용하고, 기존 utility class는 점진적 migration을 위해 semantic CSS 변수로 매핑한다. | Semantic | Token | Hex | 기준 | | ----------------- | ------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------- | @@ -46,9 +46,9 @@ Semantic color 매핑은 현재 코드 사용례를 기준으로 아래처럼 | `border-default` | `brightGray` | `#BFBFBF` | 일반 구분선/비활성 border | | `border-brand` | `subCoral` 또는 `mainCoral` | `#E58257` / `#EF9A6E` | active/brand border는 `subCoral`, input 강조 border는 `mainCoral` 우선 | -### 2.1.1 Dark semantic tokens (Home preview) +### 2.1.1 Dark semantic tokens -홈 화면 다크 프리뷰는 `dark` scope 안에서 아래 semantic alias를 사용한다. 기존 `tailwindColors`는 라이트 화면의 raw palette로 보존하며, 다크 화면에 직접 재사용하지 않는다. +모든 프로덕트 route는 `dark` scope 안에서 아래 semantic alias를 사용한다. 기존 `tailwindColors`는 라이트 화면의 raw palette로 보존하며, 다크 화면에 직접 재사용하지 않는다. | Semantic alias | Light | Dark: Oak & Amber Night | 역할 | | ------------------------------- | --------------------- | ----------------------- | ---------------------------------- | @@ -65,10 +65,10 @@ Semantic color 매핑은 현재 코드 사용례를 기준으로 아래처럼 - Tailwind class: `bg-bn-canvas`, `bg-bn-raised`, `text-bn-text`, `border-bn-border`, `border-bn-border-strong`, `bg-bn-accent-interactive`, `text-bn-accent-rating`. - `bn-brand`, `bn-brand-tonal`, `bn-brand-foreground`은 기존 화면의 점진적 migration을 위한 compatibility alias다. 새 코드는 역할이 드러나는 `bn-accent-*` token을 사용한다. -- 다크 모드는 `html`이 아니라 `ThemeProvider`가 만드는 app shell의 `.dark` scope에 적용한다. -- 저장된 사용자 선택이 없으면 `prefers-color-scheme`의 시스템 테마를 초기값으로 사용한다. -- 설정 화면의 `화면 설정 > 다크 모드` 토글 값은 `bottle-note-theme` local storage key에 보존하며, 저장값이 있으면 시스템 테마보다 우선한다. -- 이 PR의 완전한 dark migration 대상은 홈과 설정 화면이다. 다른 route는 semantic token 전환 전까지 기존 light appearance가 일부 남을 수 있다. +- `src/app/layout.tsx`의 pre-hydration script가 ``에 `.dark`와 `color-scheme`을 적용한다. ``은 hydration warning을 억제해야 한다. +- `ThemeProvider`는 `system | light | dark` preference를 보유한다. 시스템 선택일 때만 `prefers-color-scheme` 변경을 구독하고, resolved theme 변경 시 `` class와 `color-scheme`을 갱신한다. +- 설정 화면의 `화면 설정 > 화면 모드`는 `시스템 설정 따르기`, `라이트 모드`, `다크 모드` 중 하나를 선택한다. preference는 `bottle-note-theme` local storage key에 보존한다. +- 새 페이지/공통 surface는 `bg-bn-canvas`, `bg-bn-section`, `bg-bn-raised`와 `text-bn-*`, `border-bn-*`를 사용한다. 기존 `bg-white` 등 legacy class는 다크 scope에서 compatibility rule로 보정하되, 타로 등 의도적으로 흰색이 필요한 art-directed surface에는 `keep-light-surface`를 명시한다. ### 2.2 Typography diff --git a/src/app/(custom)/whiskey-tarot/_components/IntroScreen.tsx b/src/app/(custom)/whiskey-tarot/_components/IntroScreen.tsx index 894fa82e..83a707f3 100644 --- a/src/app/(custom)/whiskey-tarot/_components/IntroScreen.tsx +++ b/src/app/(custom)/whiskey-tarot/_components/IntroScreen.tsx @@ -55,7 +55,7 @@ export default function IntroScreen({ onStart }: IntroScreenProps) { {Array.from({ length: 50 }).map((_, i) => (
(
diff --git a/src/app/(primary)/layout.tsx b/src/app/(primary)/layout.tsx index c38e0664..839a1717 100644 --- a/src/app/(primary)/layout.tsx +++ b/src/app/(primary)/layout.tsx @@ -18,7 +18,7 @@ export default function Layout({ }, [isMobile]); return ( -
+
{children}
{loginState.isShowLoginModal && ( ({ + __esModule: true, + default: ({ isOpen, children }: { isOpen: boolean; children: ReactNode }) => + isOpen ?
{children}
: null, +})); + describe('SettingsAppearanceSection', () => { beforeEach(() => { window.localStorage.clear(); }); - it('설정 화면에서 다크 모드를 전환한다', () => { - const { container } = render( + it('화면 모드 시트에서 다크 모드를 선택하고 선택값을 저장한다', async () => { + render( , ); - fireEvent.click(screen.getByRole('button', { name: '다크 모드 꺼짐' })); + fireEvent.click(screen.getByRole('button', { name: /^화면 모드/ })); + fireEvent.click(screen.getByRole('radio', { name: /다크 모드/ })); - expect( - screen.getByRole('button', { name: '다크 모드 켜짐' }), - ).toBeInTheDocument(); - expect(container.querySelector('[data-theme="dark"]')).toBeInTheDocument(); + await waitFor(() => { + expect(window.localStorage.getItem('bottle-note-theme')).toBe('dark'); + expect(document.documentElement).toHaveClass('dark'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); }); }); diff --git a/src/app/(primary)/settings/_components/SettingsAppearanceSection.tsx b/src/app/(primary)/settings/_components/SettingsAppearanceSection.tsx index 8144ad28..2e6a2731 100644 --- a/src/app/(primary)/settings/_components/SettingsAppearanceSection.tsx +++ b/src/app/(primary)/settings/_components/SettingsAppearanceSection.tsx @@ -1,28 +1,109 @@ 'use client'; -import Toggle from '@/components/ui/Form/Toggle'; -import { useTheme } from '@/lib/theme/ThemeProvider'; +import { useState } from 'react'; +import BottomSheet from '@/components/ui/Modal/BottomSheet'; +import { ThemePreference, useTheme } from '@/lib/theme/ThemeProvider'; + +const THEME_OPTIONS: Array<{ + value: ThemePreference; + label: string; + description: string; +}> = [ + { + value: 'system', + label: '시스템 설정 따르기', + description: '기기의 화면 모드를 따릅니다.', + }, + { + value: 'light', + label: '라이트 모드', + description: '밝은 화면으로 표시합니다.', + }, + { + value: 'dark', + label: '다크 모드', + description: '어두운 화면으로 표시합니다.', + }, +]; export const SettingsAppearanceSection = () => { - const { isDarkMode, toggleTheme } = useTheme(); + const [isSheetOpen, setIsSheetOpen] = useState(false); + const { preference, theme, setThemePreference } = useTheme(); + const selectedOption = THEME_OPTIONS.find( + (option) => option.value === preference, + ); + + const handleSelect = (nextPreference: ThemePreference) => { + setThemePreference(nextPreference); + setIsSheetOpen(false); + }; return (
-

- 화면 설정 -

-
- 다크 모드 - -
+

화면 설정

+ + + setIsSheetOpen(false)} + title="화면 모드" + description="화면 모드를 선택할 수 있습니다." + height={66} + className="bg-bn-raised text-bn-text" + > +
+

화면 모드

+
+ {THEME_OPTIONS.map((option) => { + const isSelected = option.value === preference; + + return ( + + ); + })} +
+

+ 현재 적용 중: {theme === 'dark' ? '다크 모드' : '라이트 모드'} +

+
+
); }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 03446b36..9d8bde4a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,27 @@ import { BASE_URL } from '@/constants/common'; const isProd = process.env.NODE_ENV === 'production'; +const themeInitializationScript = ` + (() => { + try { + const storageKey = 'bottle-note-theme'; + const preference = localStorage.getItem(storageKey); + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + const theme = preference === 'light' || preference === 'dark' + ? preference + : systemTheme; + const root = document.documentElement; + + root.classList.toggle('dark', theme === 'dark'); + root.style.colorScheme = theme; + } catch { + // Storage access can fail in restricted embedded browser contexts. + } + })(); +`; + export const metadata: Metadata = { metadataBase: new URL( isProd ? BASE_URL : 'https://development.bottle-note.com', @@ -103,8 +124,11 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + +