Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 주요 용도 |
| -------------- | --------- | ------------------------------------------------- |
Expand All @@ -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 | 기준 |
| ----------------- | ------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------- |
Expand All @@ -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 | 역할 |
| ------------------------------- | --------------------- | ----------------------- | ---------------------------------- |
Expand All @@ -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가 `<html>`에 `.dark`와 `color-scheme`을 적용한다. `<html>`은 hydration warning을 억제해야 한다.
- `ThemeProvider`는 `system | light | dark` preference를 보유한다. 시스템 선택일 때만 `prefers-color-scheme` 변경을 구독하고, resolved theme 변경 시 `<html>` 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

Expand Down
2 changes: 1 addition & 1 deletion src/app/(custom)/whiskey-tarot/_components/IntroScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export default function IntroScreen({ onStart }: IntroScreenProps) {
{Array.from({ length: 50 }).map((_, i) => (
<div
key={i}
className="absolute w-1 h-1 bg-white rounded-full animate-pulse"
className="absolute h-1 w-1 rounded-full bg-white keep-light-surface animate-pulse"
style={{
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default function QuestioningScreen({ onReady }: QuestioningScreenProps) {
{Array.from({ length: 30 }).map((_, i) => (
<div
key={i}
className="absolute w-1 h-1 bg-white rounded-full animate-pulse"
className="absolute h-1 w-1 rounded-full bg-white keep-light-surface animate-pulse"
style={{
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ function SingleCardSlide({
className="flex-1 h-1 rounded-full overflow-hidden bg-white/20"
>
<div
className={`h-full bg-white transition-all duration-500 ${
className={`h-full bg-white keep-light-surface transition-all duration-500 ${
index < cardNumber ? 'w-full' : 'w-0'
}`}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/app/(primary)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function Layout({
}, [isMobile]);

return (
<div className="bg-white flex flex-col w-full mx-auto min-h-safe-screen">
<div className="flex min-h-safe-screen w-full mx-auto flex-col bg-bn-canvas text-bn-text">
<main>{children}</main>
{loginState.isShowLoginModal && (
<LoginModal
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ThemeProvider } from '@/lib/theme/ThemeProvider';
import { SettingsAppearanceSection } from './SettingsAppearanceSection';

jest.mock('@/components/ui/Modal/BottomSheet', () => ({
__esModule: true,
default: ({ isOpen, children }: { isOpen: boolean; children: ReactNode }) =>
isOpen ? <div role="dialog">{children}</div> : null,
}));

describe('SettingsAppearanceSection', () => {
beforeEach(() => {
window.localStorage.clear();
});

it('설정 화면에서 다크 모드를 전환한다', () => {
const { container } = render(
it('화면 모드 시트에서 다크 모드를 선택하고 선택값을 저장한다', async () => {
render(
<ThemeProvider>
<SettingsAppearanceSection />
</ThemeProvider>,
);

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();
});
});
});
117 changes: 99 additions & 18 deletions src/app/(primary)/settings/_components/SettingsAppearanceSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="py-[22px]">
<h2 className="text-subCoral dark:text-bn-accent-interactive text-13 font-bold">
화면 설정
</h2>
<div className="mt-[27px] flex items-center justify-between text-15 font-medium text-mainBlack dark:text-bn-text">
<span>다크 모드</span>
<Toggle
isActive={isDarkMode}
onToggle={toggleTheme}
onName="다크 모드 켜짐"
offName="다크 모드 꺼짐"
onColor="bg-bn-accent-interactive"
offColor="bg-mainGray dark:bg-bn-border-strong"
textSize="sr-only"
/>
</div>
<h2 className="text-subCoral text-13 font-bold">화면 설정</h2>
<button
type="button"
onClick={() => setIsSheetOpen(true)}
aria-haspopup="dialog"
aria-expanded={isSheetOpen}
className="mt-[27px] flex w-full items-center justify-between text-left text-15 font-medium text-mainBlack"
>
<span>화면 모드</span>
<span className="text-mainGray">{selectedOption?.label}</span>
</button>

<BottomSheet
isOpen={isSheetOpen}
onClose={() => setIsSheetOpen(false)}
title="화면 모드"
description="화면 모드를 선택할 수 있습니다."
height={66}
className="bg-bn-raised text-bn-text"
>
<div className="px-6 pb-safe pt-6">
<p className="text-20 font-bold">화면 모드</p>
<div
role="radiogroup"
aria-label="화면 모드 선택"
className="mt-5 space-y-2"
>
{THEME_OPTIONS.map((option) => {
const isSelected = option.value === preference;

return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => handleSelect(option.value)}
className="flex min-h-14 w-full items-center justify-between rounded-xl border border-bn-border px-4 text-left"
>
<span>
<span className="block text-15 font-bold">
{option.label}
</span>
<span className="mt-1 block text-12 text-bn-text-secondary">
{option.description}
</span>
</span>
<span
aria-hidden="true"
className={`h-5 w-5 rounded-full border-2 ${
isSelected
? 'border-bn-accent-interactive bg-bn-accent-interactive shadow-[inset_0_0_0_3px_hsl(var(--bn-raised))]'
: 'border-bn-border-strong'
}`}
/>
</button>
);
})}
</div>
<p className="mt-4 text-12 text-bn-text-secondary">
현재 적용 중: {theme === 'dark' ? '다크 모드' : '라이트 모드'}
</p>
</div>
</BottomSheet>
</section>
);
};
30 changes: 27 additions & 3 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -103,8 +124,11 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="ko" className="touch-manipulation">
<html lang="ko" className="touch-manipulation" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{ __html: themeInitializationScript }}
/>
<link
rel="preconnect"
href="https://cdn.jsdelivr.net"
Expand All @@ -118,9 +142,9 @@ export default function RootLayout({
crossOrigin="anonymous"
/>
</head>
<body suppressHydrationWarning>
<body>
<Providers>
<div className="relative min-h-safe-screen w-full bg-bgGray dark:bg-bn-section">
<div className="relative min-h-safe-screen w-full bg-bn-canvas text-bn-text">
{process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID && (
<GoogleAnalytics
gaId={process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/Form/Toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const Toggle = ({
aria-label={isActive ? onName : offName}
>
<div
className={`toggle-handle w-[0.8rem] h-[0.8rem] rounded-full bg-white transform transition-transform ${
className={`toggle-handle w-[0.8rem] h-[0.8rem] rounded-full bg-bn-raised transform transition-transform ${
isActive ? 'translate-x-2' : 'translate-x-[-0.5rem]'
}`}
/>
Expand Down
12 changes: 10 additions & 2 deletions src/components/ui/Modal/BottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ interface BottomSheetProps {
children: React.ReactNode;
height?: number; // vh 단위 (기본값: 80)
className?: string;
title?: string;
description?: string;
}

export default function BottomSheet({
Expand All @@ -16,6 +18,8 @@ export default function BottomSheet({
children,
height = 80,
className = '',
title = '바텀 시트',
description = '선택 가능한 옵션 목록입니다.',
}: BottomSheetProps) {
return (
<Drawer.Root
Expand All @@ -26,10 +30,14 @@ export default function BottomSheet({
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 z-40 bg-black/60" />
<Drawer.Content
className={`fixed bottom-0 left-0 right-0 z-50 flex flex-col rounded-t-2xl bg-white max-w-content mx-auto ${className}`}
className={`fixed bottom-0 left-0 right-0 z-50 flex flex-col rounded-t-2xl bg-bn-raised text-bn-text max-w-content mx-auto ${className}`}
style={{ height: `${height}vh` }}
>
<Drawer.Handle className="mx-auto mt-4 h-1.5 w-12 flex-shrink-0 rounded-full bg-gray-300" />
<Drawer.Title className="sr-only">{title}</Drawer.Title>
<Drawer.Description className="sr-only">
{description}
</Drawer.Description>
<Drawer.Handle className="mx-auto mt-4 h-1.5 w-12 flex-shrink-0 rounded-full bg-bn-border-strong" />
{children}
</Drawer.Content>
</Drawer.Portal>
Expand Down
Loading