Skip to content
Merged
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
28 changes: 28 additions & 0 deletions apps/blog/e2e/theme.spec.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
43 changes: 42 additions & 1 deletion apps/blog/src/app/global.css
Original file line number Diff line number Diff line change
@@ -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 변수로도 노출된다.
Expand All @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion apps/blog/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,16 @@ export const metadata = {
* 폰트 설정 및 공통 메타데이터 설정은 이곳에서 한다.
*/
export default async function RootLayout({ children }: PropsWithChildren) {
// 테마 스크립트가 하이드레이션 전에 html.class를 바꾸므로 suppressHydrationWarning으로 className 불일치 경고를 억제한다.
return (
<html lang="ko">
<html lang="ko" suppressHydrationWarning>
<head>
{/* 테마 깜빡임(FOUC) 방지: paint 전에 localStorage/시스템 설정으로 html.dark를 결정한다. */}
<script
dangerouslySetInnerHTML={{
__html: `try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&matchMedia('(prefers-color-scheme:dark)').matches)){document.documentElement.classList.add('dark')}}catch(e){}`,
}}
/>
<link rel="icon" type="image/x-icon" href="/favicon.ico"></link>
{/* <link rel="preconnect" href="https://fonts.googleapis.com" /> */}
{/* <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> */}
Expand Down
2 changes: 1 addition & 1 deletion apps/blog/src/components/article-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
<div className="grow basis-0 relative"></div>
Expand Down
4 changes: 3 additions & 1 deletion apps/blog/src/components/main-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -26,7 +27,8 @@ export function MainHeader({ seriesList, tags }: MainHeaderProps) {

<span className="grow"></span>

{/* === SEARCH BUTTON (모든 화면 크기에서 노출) === */}
{/* === 테마 토글 + 검색 (모든 화면 크기에서 노출) === */}
<ThemeToggle className="text-white" />
<SearchButton className="mr-5 text-white" />

{/* === HEADER >= MD: RIGHT SECTION === */}
Expand Down
2 changes: 1 addition & 1 deletion apps/blog/src/components/mobile-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export function MobileSidebar({ seriesList, tags }: MobileSidebarProps) {
className={classNames(
// 모바일 브라우저 주소창 높이를 반영하도록 100vh 대신 100dvh를 사용한다.
// md:hidden — 데스크톱 너비에서는 패널이 열린 채 남지 않도록 숨긴다.
'h-[100dvh] w-full bg-white z-50 fixed top-0 right-0 md:hidden',
'h-[100dvh] w-full bg-surface z-50 fixed top-0 right-0 md:hidden',
// 헤더는 고정하고 콘텐츠 영역만 스크롤시키기 위해 세로 flex 컬럼으로 구성한다.
'flex flex-col',
// 전이는 가로 슬라이드(transform)로 한정한다. transition-all이면 dvh 높이 변화까지
Expand Down
17 changes: 15 additions & 2 deletions apps/blog/src/components/post-detail/giscus-comments.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import Giscus from '@giscus/react';
import { useEffect, useState } from 'react';

/**
* GitHub Discussions 기반 댓글 위젯(giscus).
Expand All @@ -12,6 +13,18 @@ import Giscus from '@giscus/react';
* 동작에는 저장소에 giscus GitHub App 설치가 필요하다(앱 설치는 저장소 소유자만 가능).
*/
export function GiscusComments() {
// 사이트 테마(html.dark)를 따라가도록 한다. ThemeToggle이 보내는 themechange 이벤트로 갱신한다.
const [theme, setTheme] = useState<'light' | 'dark'>('light');

useEffect(() => {
setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light');
const onChange = (e: Event) => {
setTheme((e as CustomEvent<string>).detail === 'dark' ? 'dark' : 'light');
};
window.addEventListener('themechange', onChange);
return () => window.removeEventListener('themechange', onChange);
}, []);

return (
<Giscus
// 댓글이 저장될 저장소와 그 GraphQL 노드 ID
Expand All @@ -28,8 +41,8 @@ export function GiscusComments() {
emitMetadata="0"
// 입력창을 댓글 목록 아래에 배치
inputPosition="bottom"
// 블로그가 라이트 테마라 라이트로 고정
theme="light"
// 사이트 테마(라이트/다크)를 따라간다
theme={theme}
lang="ko"
// 뷰포트에 들어올 때 로드해 초기 렌더 비용을 줄인다
loading="lazy"
Expand Down
4 changes: 2 additions & 2 deletions apps/blog/src/components/search/search-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function SearchModal() {
role="dialog"
aria-modal="true"
aria-label="포스트 검색"
className="w-full max-w-[640px] overflow-hidden rounded-xl bg-white shadow-2xl"
className="w-full max-w-[640px] overflow-hidden rounded-xl bg-surface shadow-2xl"
onClick={(e) => e.stopPropagation()}
onKeyDown={onKeyDown}
>
Expand Down Expand Up @@ -166,7 +166,7 @@ export function SearchModal() {
onMouseEnter={() => setActiveIndex(idx)}
className={classNames(
'block border-b border-border-subtle px-4 py-3',
idx === activeIndex ? 'bg-gray-100' : 'bg-white',
idx === activeIndex ? 'bg-gray-100' : 'bg-surface',
)}
>
<div className="flex items-center gap-2">
Expand Down
58 changes: 58 additions & 0 deletions apps/blog/src/components/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use client';

import { IconMoon, IconSun } from '@tabler/icons-react';
import classNames from 'classnames';
import { useEffect, useState } from 'react';

interface ThemeToggleProps {
className?: string;
}

/**
* 라이트/다크 테마 전환 버튼.
*
* html.dark 클래스와 localStorage를 직접 토글한다. 초기 클래스는 layout <head>의
* 인라인 스크립트가 paint 전에 설정하므로(FOUC 방지), 이 컴포넌트는 마운트 후
* 현재 상태를 읽어 아이콘만 맞춘다.
*/
export function ThemeToggle({ className }: ThemeToggleProps) {
// aria-label(스크린리더 전용)만 상태로 관리한다. 보이는 아이콘은 아래 CSS(dark:)로 토글해
// 첫 프레임부터 html.dark와 일치시킨다(아이콘 플래시 없음).
const [dark, setDark] = useState(false);

useEffect(() => {
setDark(document.documentElement.classList.contains('dark'));
}, []);

const toggle = () => {
const next = !document.documentElement.classList.contains('dark');
setDark(next);
document.documentElement.classList.toggle('dark', next);
try {
localStorage.setItem('theme', next ? 'dark' : 'light');
} catch {
// 프라이빗 모드 등 localStorage 접근이 막힌 환경은 조용히 무시한다.
}
// giscus 등 외부 위젯이 테마를 따라오도록 전역 이벤트로 알린다.
window.dispatchEvent(new CustomEvent('themechange', { detail: next ? 'dark' : 'light' }));
};

return (
<button
type="button"
onClick={toggle}
aria-label={dark ? '라이트 모드로 전환' : '다크 모드로 전환'}
className={classNames(
// 아이콘(20px)에 패딩을 더해 탭 영역을 ~44px로 확보한다.
'flex cursor-pointer items-center p-2.5',
// 다크 헤더 위에서 키보드 포커스가 보이도록 밝은 outline + offset을 부여한다.
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2',
className,
)}
>
{/* 라이트일 때 달(다크로 전환), 다크일 때 해(라이트로 전환). html.dark 클래스로 CSS 토글. */}
<IconMoon aria-hidden className="h-5 w-5 block dark:hidden" />
<IconSun aria-hidden className="h-5 w-5 hidden dark:block" />
</button>
);
}
Loading