diff --git a/apps/blog/e2e/theme.spec.ts b/apps/blog/e2e/theme.spec.ts new file mode 100644 index 0000000..164b7ca --- /dev/null +++ b/apps/blog/e2e/theme.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; + +test.describe('다크모드', () => { + // 기본(시스템) 테마를 light로 고정해 토글 동작을 결정적으로 검증한다. + test.use({ colorScheme: 'light' }); + + test('테마 토글로 다크모드를 켜고 끌 수 있고, 새로고침 후에도 유지된다', async ({ page }) => { + await page.goto('/'); + + const html = page.locator('html'); + const toggle = page.getByRole('button', { name: /모드로 전환/ }); + + // 초기에는 라이트(html.dark 없음). + await expect(html).not.toHaveClass(/dark/); + + // 토글 → 다크 적용. + await toggle.click(); + await expect(html).toHaveClass(/dark/); + + // 새로고침해도 localStorage로 다크가 유지되어야 한다(FOUC 방지 스크립트). + await page.reload(); + await expect(html).toHaveClass(/dark/); + + // 다시 토글 → 라이트로 복귀. + await page.getByRole('button', { name: /모드로 전환/ }).click(); + await expect(html).not.toHaveClass(/dark/); + }); +}); diff --git a/apps/blog/src/app/global.css b/apps/blog/src/app/global.css index cd70226..20d7e23 100644 --- a/apps/blog/src/app/global.css +++ b/apps/blog/src/app/global.css @@ -1,5 +1,8 @@ @import 'tailwindcss'; +/* dark: 유틸리티가 OS 설정이 아니라 html.dark 클래스를 따르게 한다(클래스 기반 테마 토글). */ +@custom-variant dark (&:where(.dark, .dark *)); + /* === 디자인 토큰 (#94, #120) === Tailwind v4 @theme로 의미 기반 색 토큰을 정의한다. 각 토큰은 bg-brand/text-ink/ border-border 등 깔끔한 유틸리티로 생성되며, :root에 CSS 변수로도 노출된다. @@ -12,16 +15,54 @@ /* 다크 서피스 단일 토큰. 헤더·드롭다운·사이드바·뱃지의 검정 계열(black/gray-900/950)을 통일한다. */ --color-ink: #0a0a0a; /* 중립 구조색. 값은 기존 Tailwind gray와 동일해 시각 변화 없이 단일 출처로 모은다. */ - --color-surface-muted: #f9fafb; /* gray-50: 카드 배경 */ + --color-surface: #ffffff; /* 페이지·카드 기본 배경 */ + --color-surface-muted: #f9fafb; /* gray-50: 옅은 카드 배경 */ --color-border: #e5e7eb; /* gray-200: 카드/모달 경계 */ --color-border-subtle: #f3f4f6; /* gray-100: 옅은 구분선 */ } +/* === 다크 테마 (#90) === + Tailwind v4 유틸은 var(--color-*)를 참조하므로, html.dark에서 색 변수만 덮어쓰면 + 컴포넌트 변경 없이 전체가 전환된다. 텍스트 그레이(400~900)는 밝게, 배경 그레이(50~200)는 + 어둡게 뒤집는다. 항상 어두운 헤더(ink)·흰 텍스트(text-white)는 별도 토큰이라 그대로 유지된다. */ +.dark { + /* 시맨틱 surface */ + --color-surface: #0f1014; + --color-surface-muted: #17181d; + /* 배경 그레이(라이트 배경 → 다크) */ + --color-gray-50: #17181d; + --color-gray-100: #1f2026; + --color-gray-200: #2a2b32; + /* 텍스트 그레이(어두운 글자 → 밝은 글자) */ + --color-gray-400: #6e7079; + --color-gray-500: #8b8d96; + --color-gray-600: #a6a8b1; + --color-gray-700: #c4c6cd; + --color-gray-800: #d9dade; + --color-gray-900: #e9eaed; + --color-black: #e9eaed; /* text-black → 밝게 */ + /* 경계 */ + --color-border: #2a2b32; + --color-border-subtle: #232429; + /* 브랜드: 다크 배경에서 비텍스트 액센트(포커스 링)는 살짝 밝게 */ + --color-brand: #818cf8; + /* 본문 링크(blue) — 다크에서 밝게 */ + --color-blue-600: #93b4ff; + --color-blue-700: #a8c3ff; + --color-blue-800: #c2d5ff; +} + html, body { height: 100%; } +/* 페이지 전체 배경/기본 글자색을 토큰에 연결해 테마 전환 시 함께 바뀌게 한다. */ +body { + background-color: var(--color-surface); + color: var(--color-gray-900); +} + /* 동작 줄이기(prefers-reduced-motion) 설정 시 전역 트랜지션/애니메이션/부드러운 스크롤을 사실상 비활성화한다. 어지럼증(전정기관 장애) 사용자를 위한 WCAG 2.3.3 대응. */ @media (prefers-reduced-motion: reduce) { diff --git a/apps/blog/src/app/layout.tsx b/apps/blog/src/app/layout.tsx index 2c9fd88..01ec5a8 100644 --- a/apps/blog/src/app/layout.tsx +++ b/apps/blog/src/app/layout.tsx @@ -75,9 +75,16 @@ export const metadata = { * 폰트 설정 및 공통 메타데이터 설정은 이곳에서 한다. */ export default async function RootLayout({ children }: PropsWithChildren) { + // 테마 스크립트가 하이드레이션 전에 html.class를 바꾸므로 suppressHydrationWarning으로 className 불일치 경고를 억제한다. return ( - +
+ {/* 테마 깜빡임(FOUC) 방지: paint 전에 localStorage/시스템 설정으로 html.dark를 결정한다. */} + {/* */} {/* */} diff --git a/apps/blog/src/components/article-container.tsx b/apps/blog/src/components/article-container.tsx index 31f4e3f..5717486 100644 --- a/apps/blog/src/components/article-container.tsx +++ b/apps/blog/src/components/article-container.tsx @@ -12,7 +12,7 @@ export function ArticleContainer({ children, right, jsonLd }: ArticleContainerPr // skip 링크(본문 바로가기)의 이동 대상. tabIndex=-1로 프로그램적 포커스를 받을 수 있게 한다. id={Constants.a11y.mainContentId} tabIndex={-1} - className="article-container relative z-10 min-h-[100vh] bg-white flex outline-none" + className="article-container relative z-10 min-h-[100vh] bg-surface flex outline-none" > {jsonLd} diff --git a/apps/blog/src/components/main-header.tsx b/apps/blog/src/components/main-header.tsx index 4a3f6c7..d02dfa5 100644 --- a/apps/blog/src/components/main-header.tsx +++ b/apps/blog/src/components/main-header.tsx @@ -5,6 +5,7 @@ import { DropdownMenu } from './dropdown-menu'; import { SeriesModel, TagModel } from '@libs/types/commons'; import { MobileSidebar } from './mobile-sidebar'; import { SearchButton } from './search'; +import { ThemeToggle } from './theme-toggle'; export interface MainHeaderProps { seriesList: SeriesModel[]; @@ -26,7 +27,8 @@ export function MainHeader({ seriesList, tags }: MainHeaderProps) { - {/* === SEARCH BUTTON (모든 화면 크기에서 노출) === */} + {/* === 테마 토글 + 검색 (모든 화면 크기에서 노출) === */} +