diff --git a/docs/superpowers/plans/2026-04-23-responsive-web-design.md b/docs/superpowers/plans/2026-04-23-responsive-web-design.md
new file mode 100644
index 0000000..6ba395e
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-23-responsive-web-design.md
@@ -0,0 +1,2392 @@
+# 반응형 웹 UI/UX 전면 개선 구현 계획
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 14개 뷰 전체에 모바일 반응형 + 풀 모바일 UX 패턴(바텀네비, 스와이프, 풀다운 리프레시, FAB, 바텀시트) 적용
+
+**Architecture:** 공통 모바일 인프라(컴포넌트 5개 + 훅 2개)를 먼저 구축한 뒤, 주요 4개 페이지 → 나머지 페이지 순으로 적용. 기존 사이드바 네비게이션은 모바일에서 바텀 네비게이션으로 대체.
+
+**Tech Stack:** React 18, Vite, react-swipeable, 커스텀 CSS (디자인 토큰 기반)
+
+**Spec:** `docs/superpowers/specs/2026-04-23-responsive-web-design.md`
+
+**작업 대상 리포지토리:** `C:\Users\jaeoh\Desktop\workspace\web-ui` (프론트엔드 별도 Git)
+
+---
+
+## File Structure
+
+### 신규 생성 파일
+
+| 파일 | 역할 |
+|------|------|
+| `src/hooks/useIsMobile.js` | 768px 이하 감지 훅 (matchMedia) |
+| `src/hooks/useSwipe.js` | react-swipeable 래핑 훅 |
+| `src/components/BottomNav.jsx` | 모바일 하단 네비게이션 |
+| `src/components/BottomNav.css` | 바텀네비 스타일 |
+| `src/components/PullToRefresh.jsx` | 풀다운 새로고침 래퍼 |
+| `src/components/PullToRefresh.css` | 풀다운 스타일 |
+| `src/components/SwipeableView.jsx` | 좌우 스와이프 탭 전환 |
+| `src/components/SwipeableView.css` | 스와이프 뷰 스타일 |
+| `src/components/FAB.jsx` | 플로팅 액션 버튼 |
+| `src/components/FAB.css` | FAB 스타일 |
+| `src/components/MobileSheet.jsx` | 바텀시트 모달 |
+| `src/components/MobileSheet.css` | 바텀시트 스타일 |
+
+### 수정 파일
+
+| 파일 | 수정 내용 |
+|------|----------|
+| `index.html` | viewport-fit=cover 추가 |
+| `src/index.css` | breakpoint CSS 변수, safe-area 변수 |
+| `src/App.jsx` | BottomNav 조건부 렌더링 |
+| `src/App.css` | 앱 셸 모바일 레이아웃 (padding-bottom 등) |
+| `src/components/Navbar.jsx` | 모바일 사이드바/햄버거 제거 |
+| `src/components/Navbar.css` | 모바일 미디어쿼리 정리 |
+| `src/pages/home/Home.jsx` | SwipeableView, PullToRefresh 적용 |
+| `src/pages/home/Home.css` | breakpoint 통일 + 모바일 레이아웃 |
+| `src/pages/lotto/Lotto.jsx` (또는 Functions.jsx) | 탭 스와이프, FAB 적용 |
+| `src/pages/lotto/Lotto.css` | breakpoint 통일 + 모바일 레이아웃 |
+| `src/pages/stock/Stock.jsx` | 필터 칩, FAB 적용 |
+| `src/pages/stock/Stock.css` | breakpoint 통일 (예외 유지) + 모바일 |
+| `src/pages/stock/StockTrade.jsx` | 카드형 리스트, FAB 적용 |
+| `src/pages/travel/Travel.jsx` | 풀스크린 뷰어, PullToRefresh |
+| `src/pages/travel/Travel.css` | breakpoint 통일 + 모바일 |
+| `src/pages/blog/Blog.jsx` | FAB, PullToRefresh |
+| `src/pages/blog/Blog.css` | breakpoint 통일 |
+| `src/pages/blog-marketing/BlogMarketing.jsx` | FAB, PullToRefresh |
+| `src/pages/blog-marketing/BlogMarketing.css` | 모바일 개선 |
+| `src/pages/subscription/Subscription.jsx` | FAB, MobileSheet |
+| `src/pages/subscription/Subscription.css` | breakpoint 통일 |
+| `src/pages/music/MusicStudio.jsx` | FAB, PullToRefresh |
+| `src/pages/music/MusicStudio.css` | breakpoint 통일 |
+| `src/pages/todo/Todo.jsx` | SwipeableView, FAB, MobileSheet |
+| `src/pages/todo/Todo.css` | 스와이프 탭 |
+| `src/pages/agent-office/AgentOffice.jsx` | MobileSheet |
+| `src/pages/agent-office/AgentOffice.css` | 모바일 개선 |
+| `src/pages/effect-lab/EffectLab.css` | 모바일 개선 |
+| `src/pages/effect-lab/DayCalc.css` | 모바일 개선 |
+| `src/pages/effect-lab/SwordStream.css` | 모바일 미디어쿼리 추가 |
+| `package.json` | react-swipeable 의존성 추가 |
+
+---
+
+## Phase 1a: Breakpoint 정리
+
+### Task 1: viewport-fit 및 글로벌 CSS 변수 추가
+
+**Files:**
+- Modify: `index.html:6`
+- Modify: `src/index.css:15-105`
+
+- [ ] **Step 1: index.html에 viewport-fit=cover 추가**
+
+```html
+
+
+
+
+
+```
+
+- [ ] **Step 2: index.css에 breakpoint 및 safe-area CSS 변수 추가**
+
+`src/index.css`의 `:root` 블록(line 15) 안에 layout tokens 섹션(line 73-74) 뒤에 추가:
+
+```css
+ /* ── Layout ── */
+ --sidebar-w: 240px;
+ --topbar-h: 56px;
+ --bottom-nav-h: 64px;
+ --safe-area-bottom: env(safe-area-inset-bottom, 0px);
+```
+
+- [ ] **Step 3: 모바일 body 스타일에 safe-area 패딩 추가**
+
+`src/index.css` line 239-244의 모바일 미디어쿼리를 확장:
+
+```css
+@media (max-width: 768px) {
+ body {
+ overflow: auto;
+ background-attachment: scroll;
+ padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom));
+ }
+}
+```
+
+- [ ] **Step 4: 개발 서버 실행 후 데스크톱/모바일 확인**
+
+Run: `cd C:\Users\jaeoh\Desktop\workspace\web-ui && npm run dev`
+
+DevTools에서 375px, 768px, 1024px 뷰포트로 확인. 기존 레이아웃에 변화 없어야 함.
+
+- [ ] **Step 5: 커밋**
+
+```bash
+cd C:\Users\jaeoh\Desktop\workspace\web-ui
+git add index.html src/index.css
+git commit -m "feat: viewport-fit=cover 및 모바일 CSS 변수 추가"
+```
+
+---
+
+### Task 2: Breakpoint 통일 — Home, Lotto, Travel, Blog
+
+**Files:**
+- Modify: `src/pages/home/Home.css:730` (960px → 1024px)
+- Modify: `src/pages/lotto/Lotto.css:1077` (900px → 768px)
+- Modify: `src/pages/travel/Travel.css:969` (900px → 768px)
+- Modify: `src/pages/blog/Blog.css:454` (900px → 768px)
+
+- [ ] **Step 1: Home.css — 960px → 1024px로 변경**
+
+`src/pages/home/Home.css` line 730:
+
+```css
+/* 기존 */
+@media (max-width: 960px) {
+
+/* 변경 */
+@media (max-width: 1024px) {
+```
+
+- [ ] **Step 2: Lotto.css — 900px → 768px로 변경**
+
+`src/pages/lotto/Lotto.css` line 1077:
+
+```css
+/* 기존 */
+@media (max-width: 900px) {
+
+/* 변경 */
+@media (max-width: 768px) {
+```
+
+주의: 이 블록 내의 스타일이 기존 768px 블록(line 1159)과 충돌하지 않는지 확인. 충돌 시 두 블록을 병합한다.
+
+- [ ] **Step 3: Travel.css — 900px → 768px로 변경**
+
+`src/pages/travel/Travel.css` line 969:
+
+```css
+/* 기존 */
+@media (max-width: 900px) {
+
+/* 변경 */
+@media (max-width: 768px) {
+```
+
+기존 640px 블록(line 975)과 겹치지 않는지 확인.
+
+- [ ] **Step 4: Blog.css — 900px → 768px로 변경**
+
+`src/pages/blog/Blog.css` line 454:
+
+```css
+/* 기존 */
+@media (max-width: 900px) {
+
+/* 변경 */
+@media (max-width: 768px) {
+```
+
+기존 768px 블록(line 504)과 병합 필요 시 병합.
+
+- [ ] **Step 5: 각 페이지를 DevTools 768px/1024px에서 확인**
+
+각 페이지가 기존과 동일하게 렌더링되는지 확인. 특히:
+- Home: 히어로 그리드 전환 시점
+- Lotto: 헤더/분석 카드 1컬럼 전환 시점
+- Travel: 헤더 레이아웃 전환 시점
+- Blog: 사이드 목록 오버레이 전환 시점
+
+- [ ] **Step 6: 커밋**
+
+```bash
+git add src/pages/home/Home.css src/pages/lotto/Lotto.css src/pages/travel/Travel.css src/pages/blog/Blog.css
+git commit -m "refactor: Home/Lotto/Travel/Blog breakpoint 표준화 (960→1024, 900→768)"
+```
+
+---
+
+### Task 3: Breakpoint 통일 — Subscription, MusicStudio, Lotto(640px)
+
+**Files:**
+- Modify: `src/pages/subscription/Subscription.css:1142` (1100px → 1024px)
+- Modify: `src/pages/subscription/Subscription.css:1146` (900px → 768px)
+- Modify: `src/pages/music/MusicStudio.css:320` (960px → 1024px)
+- Modify: `src/pages/lotto/Lotto.css:1111,1462` (640px → 480px)
+- Modify: `src/pages/music/MusicStudio.css:490,640,1699` (640px → 480px)
+- Modify: `src/pages/travel/Travel.css:349,975` (640px → 480px)
+- Modify: `src/pages/blog-marketing/BlogMarketing.css:128` (640px → 480px)
+
+- [ ] **Step 1: Subscription.css — 1100px → 1024px, 900px → 768px**
+
+```css
+/* line 1142: 1100px → 1024px */
+@media (max-width: 1024px) {
+
+/* line 1146: 900px → 768px */
+@media (max-width: 768px) {
+```
+
+기존 768px 블록(line 1154)과 병합 필요 시 병합.
+
+- [ ] **Step 2: MusicStudio.css — 960px → 1024px**
+
+`src/pages/music/MusicStudio.css` line 320:
+
+```css
+/* 기존 */
+@media (max-width: 960px) {
+
+/* 변경 */
+@media (max-width: 1024px) {
+```
+
+- [ ] **Step 3: 640px → 480px 일괄 변경**
+
+각 파일의 640px 미디어쿼리를 480px로 변경:
+
+- `Lotto.css` line 1111, 1462
+- `MusicStudio.css` line 490, 640, 1699
+- `Travel.css` line 349, 975
+- `BlogMarketing.css` line 128
+
+각 파일에서:
+
+```css
+/* 기존 */
+@media (max-width: 640px) {
+
+/* 변경 */
+@media (max-width: 480px) {
+```
+
+- [ ] **Step 4: 각 페이지 480px/768px/1024px에서 확인**
+
+- [ ] **Step 5: 커밋**
+
+```bash
+git add src/pages/subscription/Subscription.css src/pages/music/MusicStudio.css src/pages/lotto/Lotto.css src/pages/travel/Travel.css src/pages/blog-marketing/BlogMarketing.css
+git commit -m "refactor: Subscription/Music/Lotto/Travel/BlogMarketing breakpoint 표준화"
+```
+
+---
+
+### Task 4: RealEstate.css breakpoint 통일 (routes.jsx 미등록이지만 CSS는 존재)
+
+**Files:**
+- Modify: `src/pages/realestate/RealEstate.css:955,961`
+
+- [ ] **Step 1: RealEstate.css — 1100px → 1024px, 900px → 768px**
+
+```css
+/* line 955 */
+@media (max-width: 1024px) {
+
+/* line 961 */
+@media (max-width: 768px) {
+```
+
+- [ ] **Step 2: 커밋**
+
+```bash
+git add src/pages/realestate/RealEstate.css
+git commit -m "refactor: RealEstate breakpoint 표준화 (1100→1024, 900→768)"
+```
+
+> Note: Stock.css의 420px/520px/700px은 spec에 따라 예외로 유지.
+
+---
+
+## Phase 1b: 공통 컴포넌트 & 앱 셸
+
+### Task 5: react-swipeable 설치 + useIsMobile 훅
+
+**Files:**
+- Modify: `package.json`
+- Create: `src/hooks/useIsMobile.js`
+
+- [ ] **Step 1: react-swipeable 설치**
+
+```bash
+cd C:\Users\jaeoh\Desktop\workspace\web-ui
+npm install react-swipeable
+```
+
+- [ ] **Step 2: useIsMobile 훅 작성**
+
+```jsx
+// src/hooks/useIsMobile.js
+import { useState, useEffect } from 'react';
+
+const MOBILE_BREAKPOINT = 768;
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = useState(
+ () => window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches
+ );
+
+ useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
+ const handler = (e) => setIsMobile(e.matches);
+ mql.addEventListener('change', handler);
+ return () => mql.removeEventListener('change', handler);
+ }, []);
+
+ return isMobile;
+}
+```
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add package.json package-lock.json src/hooks/useIsMobile.js
+git commit -m "feat: react-swipeable 설치 + useIsMobile 훅 추가"
+```
+
+---
+
+### Task 6: useSwipe 훅
+
+**Files:**
+- Create: `src/hooks/useSwipe.js`
+
+- [ ] **Step 1: useSwipe 훅 작성**
+
+```jsx
+// src/hooks/useSwipe.js
+import { useSwipeable } from 'react-swipeable';
+
+/**
+ * 스와이프 방향 감지 훅
+ * @param {Object} options
+ * @param {Function} options.onSwipedLeft - 왼쪽 스와이프 콜백
+ * @param {Function} options.onSwipedRight - 오른쪽 스와이프 콜백
+ * @param {number} options.threshold - 스와이프 감지 최소 거리 (기본 50px)
+ * @returns {Object} swipeHandlers - DOM 요소에 spread할 핸들러
+ */
+export function useSwipe({ onSwipedLeft, onSwipedRight, threshold = 50 } = {}) {
+ const handlers = useSwipeable({
+ onSwipedLeft,
+ onSwipedRight,
+ delta: threshold,
+ trackMouse: false,
+ preventScrollOnSwipe: true,
+ });
+
+ return handlers;
+}
+```
+
+- [ ] **Step 2: 커밋**
+
+```bash
+git add src/hooks/useSwipe.js
+git commit -m "feat: useSwipe 훅 추가 (react-swipeable 기반)"
+```
+
+---
+
+### Task 7: BottomNav 컴포넌트
+
+**Files:**
+- Create: `src/components/BottomNav.jsx`
+- Create: `src/components/BottomNav.css`
+
+- [ ] **Step 1: BottomNav.css 작성**
+
+```css
+/* src/components/BottomNav.css */
+.bottom-nav {
+ display: none;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: var(--bottom-nav-h, 64px);
+ padding-bottom: var(--safe-area-bottom, 0px);
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border-line);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ z-index: 300;
+}
+
+@media (max-width: 768px) {
+ .bottom-nav {
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ }
+}
+
+.bottom-nav__item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ min-width: 48px;
+ min-height: 48px;
+ padding: 6px 12px;
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ font-size: 10px;
+ font-family: var(--font-body);
+ cursor: pointer;
+ transition: color 0.2s var(--ease-out);
+ -webkit-tap-highlight-color: transparent;
+ text-decoration: none;
+}
+
+.bottom-nav__item:hover,
+.bottom-nav__item.is-active {
+ color: var(--neon-cyan);
+}
+
+.bottom-nav__item.is-active .bottom-nav__icon {
+ filter: drop-shadow(0 0 6px var(--neon-cyan));
+}
+
+.bottom-nav__icon {
+ width: 22px;
+ height: 22px;
+ transition: filter 0.2s var(--ease-out);
+}
+
+.bottom-nav__label {
+ font-size: 10px;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+/* 더보기 오버레이 */
+.bottom-nav__more-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ z-index: 299;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.25s var(--ease-out);
+}
+
+.bottom-nav__more-overlay.is-visible {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.bottom-nav__more-panel {
+ position: fixed;
+ bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom, 0px));
+ left: 0;
+ right: 0;
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border-line);
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
+ padding: 16px;
+ transform: translateY(100%);
+ transition: transform 0.3s var(--ease-out);
+ z-index: 301;
+}
+
+.bottom-nav__more-panel.is-visible {
+ transform: translateY(0);
+}
+
+.bottom-nav__more-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+}
+
+.bottom-nav__more-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ padding: 12px 8px;
+ background: var(--surface);
+ border: 1px solid var(--border-line);
+ border-radius: var(--radius-sm);
+ color: var(--text-default);
+ font-size: 11px;
+ text-decoration: none;
+ transition: background 0.2s, border-color 0.2s;
+}
+
+.bottom-nav__more-item:hover,
+.bottom-nav__more-item.is-active {
+ background: var(--surface-raised);
+ border-color: var(--neon-cyan-dim);
+ color: var(--neon-cyan);
+}
+
+.bottom-nav__more-icon {
+ width: 24px;
+ height: 24px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bottom-nav__more-panel,
+ .bottom-nav__more-overlay {
+ transition: none;
+ }
+}
+```
+
+- [ ] **Step 2: BottomNav.jsx 작성**
+
+```jsx
+// src/components/BottomNav.jsx
+import { useState, useCallback } from 'react';
+import { NavLink, useLocation } from 'react-router-dom';
+import { navLinks } from '../routes';
+import './BottomNav.css';
+
+const PRIMARY_PATHS = ['/', '/lotto', '/stock', '/travel'];
+
+export default function BottomNav() {
+ const [moreOpen, setMoreOpen] = useState(false);
+ const location = useLocation();
+
+ const toggleMore = useCallback(() => setMoreOpen(v => !v), []);
+ const closeMore = useCallback(() => setMoreOpen(false), []);
+
+ const primaryLinks = navLinks.filter(l => PRIMARY_PATHS.includes(l.path));
+ const moreLinks = navLinks.filter(l => !PRIMARY_PATHS.includes(l.path));
+
+ const isMoreActive = moreLinks.some(l => location.pathname.startsWith(l.path));
+
+ return (
+ <>
+
+
+
+ {moreLinks.map(link => (
+
+ `bottom-nav__more-item ${isActive ? 'is-active' : ''}`
+ }
+ onClick={closeMore}
+ >
+ {link.icon}
+ {link.label}
+
+ ))}
+
+
+
+
+ >
+ );
+}
+```
+
+- [ ] **Step 3: 데스크톱에서 바텀네비가 숨겨지는지 확인**
+
+DevTools에서 1024px → 바텀네비 `display: none`, 768px 이하 → `display: flex` 확인.
+
+- [ ] **Step 4: 커밋**
+
+```bash
+git add src/components/BottomNav.jsx src/components/BottomNav.css
+git commit -m "feat: BottomNav 모바일 하단 네비게이션 컴포넌트"
+```
+
+---
+
+### Task 8: PullToRefresh 컴포넌트
+
+**Files:**
+- Create: `src/components/PullToRefresh.jsx`
+- Create: `src/components/PullToRefresh.css`
+
+- [ ] **Step 1: PullToRefresh.css 작성**
+
+```css
+/* src/components/PullToRefresh.css */
+.pull-to-refresh {
+ position: relative;
+ overscroll-behavior-y: contain;
+}
+
+.pull-to-refresh__indicator {
+ position: absolute;
+ top: -48px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background: var(--surface-card);
+ border: 1px solid var(--border-line);
+ box-shadow: var(--shadow-md);
+ transition: transform 0.2s var(--ease-out), opacity 0.2s;
+ opacity: 0;
+ z-index: 10;
+}
+
+.pull-to-refresh__indicator.is-pulling {
+ opacity: 1;
+}
+
+.pull-to-refresh__indicator.is-refreshing {
+ opacity: 1;
+ transform: translateX(-50%) translateY(56px);
+}
+
+.pull-to-refresh__spinner {
+ width: 20px;
+ height: 20px;
+ border: 2px solid var(--border-line);
+ border-top-color: var(--neon-cyan);
+ border-radius: 50%;
+ animation: ptr-spin 0.8s linear infinite;
+}
+
+.pull-to-refresh__arrow {
+ width: 18px;
+ height: 18px;
+ color: var(--neon-cyan);
+ transition: transform 0.2s;
+}
+
+.pull-to-refresh__arrow.is-ready {
+ transform: rotate(180deg);
+}
+
+@keyframes ptr-spin {
+ to { transform: rotate(360deg); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .pull-to-refresh__spinner {
+ animation: none;
+ border-top-color: var(--neon-cyan);
+ opacity: 0.7;
+ }
+ .pull-to-refresh__indicator {
+ transition: none;
+ }
+}
+```
+
+- [ ] **Step 2: PullToRefresh.jsx 작성**
+
+```jsx
+// src/components/PullToRefresh.jsx
+import { useState, useRef, useCallback } from 'react';
+import { useIsMobile } from '../hooks/useIsMobile';
+import './PullToRefresh.css';
+
+const THRESHOLD = 60;
+const MAX_PULL = 120;
+
+export default function PullToRefresh({ onRefresh, children, className = '' }) {
+ const isMobile = useIsMobile();
+ const [pullDistance, setPullDistance] = useState(0);
+ const [refreshing, setRefreshing] = useState(false);
+ const startY = useRef(0);
+ const containerRef = useRef(null);
+
+ const handleTouchStart = useCallback((e) => {
+ if (containerRef.current?.scrollTop === 0) {
+ startY.current = e.touches[0].clientY;
+ }
+ }, []);
+
+ const handleTouchMove = useCallback((e) => {
+ if (!startY.current || refreshing) return;
+ const diff = e.touches[0].clientY - startY.current;
+ if (diff > 0 && containerRef.current?.scrollTop === 0) {
+ const distance = Math.min(diff * 0.5, MAX_PULL);
+ setPullDistance(distance);
+ }
+ }, [refreshing]);
+
+ const handleTouchEnd = useCallback(async () => {
+ if (pullDistance >= THRESHOLD && onRefresh) {
+ setRefreshing(true);
+ try {
+ await onRefresh();
+ } finally {
+ setRefreshing(false);
+ }
+ }
+ setPullDistance(0);
+ startY.current = 0;
+ }, [pullDistance, onRefresh]);
+
+ if (!isMobile) {
+ return {children}
;
+ }
+
+ const isPulling = pullDistance > 10;
+ const isReady = pullDistance >= THRESHOLD;
+
+ return (
+
+
+ {refreshing ? (
+
+ ) : (
+
+ )}
+
+
+ {children}
+
+
+ );
+}
+```
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add src/components/PullToRefresh.jsx src/components/PullToRefresh.css
+git commit -m "feat: PullToRefresh 풀다운 새로고침 컴포넌트"
+```
+
+---
+
+### Task 9: SwipeableView 컴포넌트
+
+**Files:**
+- Create: `src/components/SwipeableView.jsx`
+- Create: `src/components/SwipeableView.css`
+
+- [ ] **Step 1: SwipeableView.css 작성**
+
+```css
+/* src/components/SwipeableView.css */
+.swipeable-view {
+ overflow: hidden;
+ position: relative;
+}
+
+.swipeable-view__track {
+ display: flex;
+ transition: transform 0.3s var(--ease-out);
+ will-change: transform;
+}
+
+.swipeable-view__track.is-swiping {
+ transition: none;
+}
+
+.swipeable-view__panel {
+ flex: 0 0 100%;
+ min-width: 0;
+ overflow-y: auto;
+}
+
+.swipeable-view__tabs {
+ display: flex;
+ gap: 4px;
+ padding: 4px;
+ background: var(--surface);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border-line);
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+}
+
+.swipeable-view__tabs::-webkit-scrollbar {
+ display: none;
+}
+
+.swipeable-view__tab {
+ flex: 1;
+ min-width: fit-content;
+ padding: 8px 16px;
+ background: none;
+ border: none;
+ border-radius: var(--radius-sm);
+ color: var(--text-dim);
+ font-family: var(--font-body);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background 0.2s, color 0.2s;
+}
+
+.swipeable-view__tab.is-active {
+ background: var(--surface-raised);
+ color: var(--neon-cyan);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .swipeable-view__track {
+ transition: none;
+ }
+}
+```
+
+- [ ] **Step 2: SwipeableView.jsx 작성**
+
+```jsx
+// src/components/SwipeableView.jsx
+import { useState, useRef, useCallback } from 'react';
+import { useSwipeable } from 'react-swipeable';
+import { useIsMobile } from '../hooks/useIsMobile';
+import './SwipeableView.css';
+
+/**
+ * @param {Object} props
+ * @param {Array<{key: string, label: string, content: React.ReactNode}>} props.tabs
+ * @param {number} props.activeIndex - 외부 제어용 (optional)
+ * @param {Function} props.onTabChange - 탭 변경 콜백 (optional)
+ * @param {boolean} props.showTabs - 탭 바 표시 여부 (기본 true)
+ */
+export default function SwipeableView({ tabs, activeIndex: controlledIndex, onTabChange, showTabs = true }) {
+ const [internalIndex, setInternalIndex] = useState(0);
+ const [isSwiping, setIsSwiping] = useState(false);
+ const [swipeOffset, setSwipeOffset] = useState(0);
+ const isMobile = useIsMobile();
+
+ const activeIndex = controlledIndex ?? internalIndex;
+ const setActiveIndex = useCallback((idx) => {
+ const clamped = Math.max(0, Math.min(idx, tabs.length - 1));
+ setInternalIndex(clamped);
+ onTabChange?.(clamped);
+ }, [tabs.length, onTabChange]);
+
+ const handlers = useSwipeable({
+ onSwiping: (e) => {
+ if (!isMobile) return;
+ setIsSwiping(true);
+ setSwipeOffset(e.deltaX);
+ },
+ onSwipedLeft: () => {
+ if (!isMobile) return;
+ setIsSwiping(false);
+ setSwipeOffset(0);
+ if (activeIndex < tabs.length - 1) setActiveIndex(activeIndex + 1);
+ },
+ onSwipedRight: () => {
+ if (!isMobile) return;
+ setIsSwiping(false);
+ setSwipeOffset(0);
+ if (activeIndex > 0) setActiveIndex(activeIndex - 1);
+ },
+ onSwiped: () => {
+ setIsSwiping(false);
+ setSwipeOffset(0);
+ },
+ delta: 30,
+ trackMouse: false,
+ preventScrollOnSwipe: true,
+ });
+
+ const translateX = isSwiping
+ ? -(activeIndex * 100) + (swipeOffset / (window.innerWidth || 1)) * 100
+ : -(activeIndex * 100);
+
+ return (
+
+ {showTabs && (
+
+ {tabs.map((tab, i) => (
+
+ ))}
+
+ )}
+
+
+
+ {tabs.map((tab) => (
+
+ {tab.content}
+
+ ))}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add src/components/SwipeableView.jsx src/components/SwipeableView.css
+git commit -m "feat: SwipeableView 스와이프 탭 전환 컴포넌트"
+```
+
+---
+
+### Task 10: FAB 컴포넌트
+
+**Files:**
+- Create: `src/components/FAB.jsx`
+- Create: `src/components/FAB.css`
+
+- [ ] **Step 1: FAB.css 작성**
+
+```css
+/* src/components/FAB.css */
+.fab {
+ display: none;
+ position: fixed;
+ right: 20px;
+ bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background: var(--gradient-accent);
+ border: none;
+ color: var(--bg);
+ font-size: 24px;
+ cursor: pointer;
+ box-shadow: 0 4px 20px rgba(0, 255, 255, 0.3);
+ z-index: 250;
+ transition: transform 0.2s var(--ease-out), box-shadow 0.2s;
+ -webkit-tap-highlight-color: transparent;
+ align-items: center;
+ justify-content: center;
+}
+
+@media (max-width: 768px) {
+ .fab {
+ display: flex;
+ }
+}
+
+.fab:active {
+ transform: scale(0.92);
+}
+
+.fab:hover {
+ box-shadow: 0 6px 28px rgba(0, 255, 255, 0.45);
+}
+
+.fab__icon {
+ width: 24px;
+ height: 24px;
+}
+
+/* FAB + 미니플레이어 공존 시 (뮤직 페이지) */
+.fab--above-player {
+ bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 56px + 16px);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .fab {
+ transition: none;
+ }
+}
+```
+
+- [ ] **Step 2: FAB.jsx 작성**
+
+```jsx
+// src/components/FAB.jsx
+import { useIsMobile } from '../hooks/useIsMobile';
+import './FAB.css';
+
+/**
+ * @param {Object} props
+ * @param {Function} props.onClick
+ * @param {React.ReactNode} props.icon - 아이콘 (기본: + 아이콘)
+ * @param {string} props.label - 접근성 라벨
+ * @param {string} props.className - 추가 클래스 (e.g. 'fab--above-player')
+ */
+export default function FAB({ onClick, icon, label, className = '' }) {
+ const isMobile = useIsMobile();
+ if (!isMobile) return null;
+
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add src/components/FAB.jsx src/components/FAB.css
+git commit -m "feat: FAB 플로팅 액션 버튼 컴포넌트"
+```
+
+---
+
+### Task 11: MobileSheet 컴포넌트
+
+**Files:**
+- Create: `src/components/MobileSheet.jsx`
+- Create: `src/components/MobileSheet.css`
+
+- [ ] **Step 1: MobileSheet.css 작성**
+
+```css
+/* src/components/MobileSheet.css */
+.mobile-sheet__backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ z-index: 400;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.25s var(--ease-out);
+}
+
+.mobile-sheet__backdrop.is-visible {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.mobile-sheet {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ max-height: 90vh;
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border-line);
+ border-radius: var(--radius-xl) var(--radius-xl) 0 0;
+ z-index: 401;
+ transform: translateY(100%);
+ transition: transform 0.3s var(--ease-out);
+ display: flex;
+ flex-direction: column;
+ touch-action: none;
+}
+
+.mobile-sheet.is-visible {
+ transform: translateY(0);
+}
+
+.mobile-sheet.snap-half {
+ max-height: 50vh;
+}
+
+.mobile-sheet__handle {
+ display: flex;
+ justify-content: center;
+ padding: 12px 0 8px;
+ cursor: grab;
+ flex-shrink: 0;
+}
+
+.mobile-sheet__handle-bar {
+ width: 36px;
+ height: 4px;
+ background: var(--text-muted);
+ border-radius: 2px;
+}
+
+.mobile-sheet__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 20px 12px;
+ border-bottom: 1px solid var(--border-line);
+ flex-shrink: 0;
+}
+
+.mobile-sheet__title {
+ font-family: var(--font-display);
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-bright);
+}
+
+.mobile-sheet__close {
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ cursor: pointer;
+ padding: 8px;
+ min-width: 44px;
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.mobile-sheet__body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px 20px;
+ padding-bottom: calc(16px + var(--safe-area-bottom, 0px));
+ overscroll-behavior: contain;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .mobile-sheet,
+ .mobile-sheet__backdrop {
+ transition: none;
+ }
+}
+```
+
+- [ ] **Step 2: MobileSheet.jsx 작성**
+
+```jsx
+// src/components/MobileSheet.jsx
+import { useEffect, useCallback, useRef } from 'react';
+import './MobileSheet.css';
+
+/**
+ * @param {Object} props
+ * @param {boolean} props.open
+ * @param {Function} props.onClose
+ * @param {string} props.title
+ * @param {string} props.snap - 'full' | 'half' (기본 'full')
+ * @param {React.ReactNode} props.children
+ */
+export default function MobileSheet({ open, onClose, title, snap = 'full', children }) {
+ const sheetRef = useRef(null);
+ const startY = useRef(0);
+ const currentY = useRef(0);
+
+ useEffect(() => {
+ if (open) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = '';
+ }
+ return () => { document.body.style.overflow = ''; };
+ }, [open]);
+
+ const handleTouchStart = useCallback((e) => {
+ startY.current = e.touches[0].clientY;
+ }, []);
+
+ const handleTouchMove = useCallback((e) => {
+ currentY.current = e.touches[0].clientY;
+ const diff = currentY.current - startY.current;
+ if (diff > 0 && sheetRef.current) {
+ sheetRef.current.style.transform = `translateY(${diff}px)`;
+ sheetRef.current.style.transition = 'none';
+ }
+ }, []);
+
+ const handleTouchEnd = useCallback(() => {
+ const diff = currentY.current - startY.current;
+ if (sheetRef.current) {
+ sheetRef.current.style.transition = '';
+ sheetRef.current.style.transform = '';
+ }
+ if (diff > 100) {
+ onClose();
+ }
+ startY.current = 0;
+ currentY.current = 0;
+ }, [onClose]);
+
+ return (
+ <>
+
+
+
+ {title && (
+
+ {title}
+
+
+ )}
+
+ {children}
+
+
+ >
+ );
+}
+```
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add src/components/MobileSheet.jsx src/components/MobileSheet.css
+git commit -m "feat: MobileSheet 바텀시트 모달 컴포넌트"
+```
+
+---
+
+### Task 12: 앱 셸 수정 — BottomNav 통합 + 사이드바 모바일 제거
+
+**Files:**
+- Modify: `src/App.jsx`
+- Modify: `src/App.css:45-49,62-66,472-493`
+- Modify: `src/components/Navbar.jsx:7-16,20-40`
+- Modify: `src/components/Navbar.css:335-359`
+
+- [ ] **Step 1: App.jsx에 BottomNav 추가**
+
+`src/App.jsx`를 수정:
+
+```jsx
+import { Suspense } from 'react';
+import { Outlet, useLocation } from 'react-router-dom';
+import Navbar from './components/Navbar';
+import BottomNav from './components/BottomNav';
+import PageHeader from './components/PageHeader';
+import Loading from './components/Loading';
+import { useIsMobile } from './hooks/useIsMobile';
+import './App.css';
+
+export default function App() {
+ const isMobile = useIsMobile();
+
+ return (
+
+ );
+}
+```
+
+- [ ] **Step 2: App.css 모바일 레이아웃 수정**
+
+`src/App.css`에서 line 62-66의 모바일 미디어쿼리를 수정:
+
+```css
+/* 기존 768px 미디어쿼리에 padding-bottom 추가 */
+@media (max-width: 768px) {
+ .site-main {
+ padding: 16px;
+ padding-bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 16px);
+ }
+}
+```
+
+- [ ] **Step 3: Navbar.jsx에서 모바일 햄버거/오버레이 제거**
+
+`src/components/Navbar.jsx`를 수정 — 모바일 토글/오버레이 관련 코드를 useIsMobile로 조건부 처리:
+
+```jsx
+import { useState, useEffect } from 'react';
+import { NavLink, useLocation } from 'react-router-dom';
+import { navLinks } from '../routes';
+import { useIsMobile } from '../hooks/useIsMobile';
+import './Navbar.css';
+
+export default function Navbar() {
+ const [menuOpen, setMenuOpen] = useState(false);
+ const closeMenu = () => setMenuOpen(false);
+ const location = useLocation();
+ const isMobile = useIsMobile();
+
+ useEffect(() => {
+ if (menuOpen) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = '';
+ }
+ return () => { document.body.style.overflow = ''; };
+ }, [menuOpen]);
+
+ useEffect(() => {
+ closeMenu();
+ }, [location.pathname]);
+
+ // 모바일에서는 사이드바를 렌더링하지 않음 (BottomNav가 대체)
+ if (isMobile) return null;
+
+ return (
+
+ );
+}
+```
+
+> Note: 기존 Navbar.jsx의 정확한 JSX 구조(link.icon, link.subtitle 등)는 실제 파일을 읽어서 맞춰야 함. 위 코드는 탐색 결과 기반 근사치.
+
+- [ ] **Step 4: Navbar.css 모바일 미디어쿼리 정리**
+
+모바일 관련 미디어쿼리(line 335-359)에서 `.sidebar-toggle`, `.sidebar__overlay` 스타일은 더 이상 사용되지 않으므로 제거하거나 유지해도 무방 (컴포넌트가 렌더링되지 않으므로).
+
+정리를 위해 제거 권장:
+
+```css
+/* 기존 (lines 335-359) — 전체 삭제 가능 */
+/* @media (max-width: 768px) { ... } */
+/* @media (min-width: 769px) { ... } */
+```
+
+대신 데스크톱 전용 사이드바만 유지:
+
+```css
+/* Navbar.css 말미 — 사이드바는 데스크톱 전용 */
+@media (max-width: 768px) {
+ .sidebar {
+ display: none;
+ }
+}
+```
+
+- [ ] **Step 5: 데스크톱/모바일에서 확인**
+
+- 데스크톱: 사이드바 정상 표시, 바텀네비 숨김
+- 모바일 (768px 이하): 사이드바 완전 숨김, 바텀네비 표시
+- 더보기 메뉴 열기/닫기 동작
+- 네비게이션 링크 클릭 시 라우팅 정상 동작
+
+- [ ] **Step 6: 커밋**
+
+```bash
+git add src/App.jsx src/App.css src/components/Navbar.jsx src/components/Navbar.css
+git commit -m "feat: 앱 셸 모바일 레이아웃 — BottomNav 통합 + 사이드바 조건부 렌더링"
+```
+
+---
+
+## Phase 2: 주요 페이지 적용
+
+### Task 13: 홈 페이지 모바일 개선
+
+**Files:**
+- Modify: `src/pages/home/Home.jsx`
+- Modify: `src/pages/home/Home.css`
+
+- [ ] **Step 1: Home.css 모바일 레이아웃 보강**
+
+기존 768px 미디어쿼리(line 740)에 다음을 추가/수정:
+
+```css
+@media (max-width: 768px) {
+ /* 기존 스타일 유지하면서 추가 */
+ .home__hero-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .home__nav-grid {
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ }
+
+ .home__nav-card {
+ min-height: 80px;
+ }
+
+ .home__todo-board {
+ /* 스와이프 탭으로 대체되므로 3컬럼 그리드 숨김 */
+ display: none;
+ }
+
+ .home__todo-swipe {
+ display: block;
+ }
+
+ .home__blog-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .home__profile {
+ margin-top: 16px;
+ }
+}
+
+/* 데스크톱에서는 스와이프 뷰 숨김 */
+.home__todo-swipe {
+ display: none;
+}
+```
+
+- [ ] **Step 2: Home.jsx에 SwipeableView + PullToRefresh 적용**
+
+TODO 보드 영역에 모바일 전용 SwipeableView 추가:
+
+```jsx
+import { useIsMobile } from '../../hooks/useIsMobile';
+import SwipeableView from '../../components/SwipeableView';
+import PullToRefresh from '../../components/PullToRefresh';
+
+// 컴포넌트 내부:
+const isMobile = useIsMobile();
+
+// 기존 TODO 칸반 보드 아래에 추가:
+{isMobile && (
+
+ },
+ { key: 'progress', label: '진행중', content: },
+ { key: 'done', label: '완료', content: },
+ ]}
+ />
+
+)}
+```
+
+블로그 포스트 영역을 PullToRefresh로 래핑:
+
+```jsx
+
+ {/* 기존 블로그 포스트 그리드 */}
+
+```
+
+> Note: 실제 변수명/함수명은 Home.jsx를 읽어서 매칭해야 함.
+
+- [ ] **Step 3: 모바일 375px에서 확인**
+
+- 히어로: 1컬럼 스택
+- 네비 카드: 2컬럼
+- TODO: 스와이프 탭 동작
+- 블로그: 1컬럼 + 풀다운 리프레시
+
+- [ ] **Step 4: 커밋**
+
+```bash
+git add src/pages/home/Home.jsx src/pages/home/Home.css
+git commit -m "feat(home): 모바일 반응형 — 스와이프 TODO + 풀다운 리프레시"
+```
+
+---
+
+### Task 14: 로또 페이지 모바일 개선
+
+**Files:**
+- Modify: `src/pages/lotto/Lotto.jsx` (또는 `Functions.jsx` — 3탭 구조 파일)
+- Modify: `src/pages/lotto/Lotto.css`
+
+- [ ] **Step 1: Lotto.css 모바일 추가 스타일**
+
+기존 768px 미디어쿼리에 추가:
+
+```css
+@media (max-width: 768px) {
+ /* 기존 스타일 유지 */
+
+ /* 구매 이력 테이블 가로 스크롤 */
+ .purchase-table-wrapper {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .lotto-ball {
+ width: 32px;
+ height: 32px;
+ font-size: 13px;
+ }
+
+ /* 전략 차트 축소 */
+ .strategy-chart-wrapper {
+ overflow-x: auto;
+ }
+}
+```
+
+- [ ] **Step 2: Lotto.jsx/Functions.jsx에 SwipeableView + FAB 적용**
+
+3탭 구조에 SwipeableView 래핑:
+
+```jsx
+import SwipeableView from '../../components/SwipeableView';
+import FAB from '../../components/FAB';
+import PullToRefresh from '../../components/PullToRefresh';
+import { useIsMobile } from '../../hooks/useIsMobile';
+
+// 탭 영역:
+const isMobile = useIsMobile();
+
+// 모바일에서 기존 탭 컨텐츠를 SwipeableView로 래핑
+{isMobile ? (
+
+ },
+ { key: 'analysis', label: '분석', content: },
+ { key: 'purchase', label: '구매', content: },
+ ]}
+ activeIndex={activeTab}
+ onTabChange={setActiveTab}
+ />
+
+) : (
+ /* 기존 데스크톱 탭 구조 유지 */
+)}
+
+// FAB
+ { /* 빠른 추천 로직 */ }}
+ label="추천받기"
+ icon={}
+/>
+```
+
+- [ ] **Step 3: 모바일에서 확인**
+
+- 3탭 스와이프 전환 동작
+- 번호 볼 크기 축소
+- 구매 이력 테이블 가로 스크롤
+- FAB 위치 (바텀네비 위)
+
+- [ ] **Step 4: 커밋**
+
+```bash
+git add src/pages/lotto/ src/pages/lotto/Lotto.css
+git commit -m "feat(lotto): 모바일 반응형 — 스와이프 탭 + FAB + 볼 축소"
+```
+
+---
+
+### Task 15: 주식 페이지 모바일 개선 (Stock + StockTrade)
+
+**Files:**
+- Modify: `src/pages/stock/Stock.jsx`
+- Modify: `src/pages/stock/StockTrade.jsx`
+- Modify: `src/pages/stock/Stock.css`
+
+- [ ] **Step 1: Stock.css 모바일 보강**
+
+기존 768px 미디어쿼리에 추가:
+
+```css
+@media (max-width: 768px) {
+ /* 기존 스타일 유지 */
+
+ /* 필터 가로 스크롤 칩 */
+ .stock-filter-row {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ flex-wrap: nowrap;
+ gap: 8px;
+ padding-bottom: 4px;
+ }
+
+ .stock-filter-row > * {
+ flex-shrink: 0;
+ }
+
+ /* 지표 카드 캐러셀 */
+ .stock-indices-grid {
+ display: flex;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ gap: 12px;
+ padding-bottom: 8px;
+ scroll-snap-type: x mandatory;
+ }
+
+ .stock-indices-grid > * {
+ flex: 0 0 240px;
+ scroll-snap-align: start;
+ }
+
+ /* 뉴스 1컬럼 */
+ .stock-news-grid {
+ grid-template-columns: 1fr;
+ }
+}
+```
+
+> Note: 420px/520px/700px breakpoint는 예외로 유지 (spec 규정).
+
+- [ ] **Step 2: Stock.jsx에 FAB + PullToRefresh 적용**
+
+```jsx
+import FAB from '../../components/FAB';
+import PullToRefresh from '../../components/PullToRefresh';
+
+// PullToRefresh 래핑
+
+ {/* 기존 Stock 콘텐츠 */}
+
+
+// FAB
+ { /* 종목 추가 모달 */ }} label="종목 추가" />
+```
+
+- [ ] **Step 3: StockTrade.jsx 모바일 처리**
+
+포트폴리오 테이블을 모바일에서 카드형으로 전환:
+
+```jsx
+import { useIsMobile } from '../../hooks/useIsMobile';
+import FAB from '../../components/FAB';
+import MobileSheet from '../../components/MobileSheet';
+
+const isMobile = useIsMobile();
+
+// 포트폴리오 영역
+{isMobile ? (
+
+ {portfolio.map(item => (
+
openDetail(item)}>
+
{item.name}
+
{item.currentPrice}
+
0}>
+ {item.profitRate}%
+
+
+ ))}
+
+) : (
+ /* 기존 테이블 */
+)}
+
+// FAB
+ { /* 매도 기록 */ }} label="매도 기록" />
+
+// 상세 바텀시트
+ setDetailOpen(false)} title="종목 상세">
+ {/* 상세 내용 */}
+
+```
+
+- [ ] **Step 4: Stock.css에 포트폴리오 카드 스타일 추가**
+
+```css
+/* 모바일 포트폴리오 카드 */
+.stock-portfolio-cards {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.stock-portfolio-card {
+ display: grid;
+ grid-template-columns: 1fr auto auto;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px;
+ background: var(--surface-card);
+ border: 1px solid var(--border-line);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+}
+
+.stock-portfolio-card__name {
+ font-weight: 600;
+ color: var(--text-bright);
+}
+
+.stock-portfolio-card__profit[data-positive="true"] {
+ color: #4caf50;
+}
+
+.stock-portfolio-card__profit[data-positive="false"] {
+ color: #f44336;
+}
+```
+
+- [ ] **Step 5: Stock + StockTrade 모바일 확인 (함께 검증)**
+
+- Stock: 필터 칩 가로스크롤, 지표 카드 캐러셀, 뉴스 1컬럼
+- StockTrade: 포트폴리오 카드형, 매도이력 스크롤, FAB
+
+- [ ] **Step 6: 커밋**
+
+```bash
+git add src/pages/stock/
+git commit -m "feat(stock): 모바일 반응형 — 카드형 포트폴리오 + 캐러셀 지표 + FAB"
+```
+
+---
+
+### Task 16: 여행 페이지 모바일 개선
+
+**Files:**
+- Modify: `src/pages/travel/Travel.jsx`
+- Modify: `src/pages/travel/Travel.css`
+
+- [ ] **Step 1: Travel.css 모바일 보강**
+
+기존 768px 미디어쿼리에 추가:
+
+```css
+@media (max-width: 768px) {
+ /* 기존 스타일 유지 */
+
+ /* 지도 높이 축소 */
+ .travel-map-container {
+ height: 35vh;
+ }
+
+ /* 사진 그리드 2컬럼 */
+ .travel-photo-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ /* 라이트박스 풀스크린 */
+ .travel-lightbox {
+ border-radius: 0;
+ max-width: 100vw;
+ max-height: 100vh;
+ width: 100vw;
+ height: 100vh;
+ }
+
+ .travel-lightbox__image {
+ object-fit: contain;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@media (max-width: 480px) {
+ .travel-photo-grid {
+ grid-template-columns: 1fr;
+ }
+}
+```
+
+- [ ] **Step 2: Travel.jsx에 PullToRefresh + 라이트박스 스와이프 적용**
+
+```jsx
+import PullToRefresh from '../../components/PullToRefresh';
+import { useSwipe } from '../../hooks/useSwipe';
+import { useIsMobile } from '../../hooks/useIsMobile';
+
+// 사진 목록 PullToRefresh 래핑
+
+ {/* 기존 사진 그리드 */}
+
+
+// 라이트박스에 스와이프 네비게이션 추가
+const lightboxSwipe = useSwipe({
+ onSwipedLeft: () => nextPhoto(),
+ onSwipedRight: () => prevPhoto(),
+});
+
+// 라이트박스 내부:
+
+

+
+```
+
+- [ ] **Step 3: 모바일 확인**
+
+- 지도 높이 35vh
+- 사진 2컬럼/1컬럼
+- 라이트박스 풀스크린 + 스와이프 넘기기
+- 풀다운 리프레시
+
+- [ ] **Step 4: 커밋**
+
+```bash
+git add src/pages/travel/
+git commit -m "feat(travel): 모바일 반응형 — 풀스크린 라이트박스 + 스와이프 + 지도 축소"
+```
+
+---
+
+## Phase 3: 나머지 페이지 확장
+
+### Task 17: 블로그 + 블로그 마케팅 모바일 개선
+
+**Files:**
+- Modify: `src/pages/blog/Blog.jsx`
+- Modify: `src/pages/blog/Blog.css`
+- Modify: `src/pages/blog-marketing/BlogMarketing.jsx`
+- Modify: `src/pages/blog-marketing/BlogMarketing.css`
+
+- [ ] **Step 1: Blog.css 모바일 보강**
+
+```css
+@media (max-width: 768px) {
+ /* 기존 스타일 유지 */
+
+ /* 태그 필터 가로 스크롤 칩 */
+ .blog-tags {
+ display: flex;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ gap: 8px;
+ flex-wrap: nowrap;
+ padding-bottom: 4px;
+ }
+
+ .blog-tags > * {
+ flex-shrink: 0;
+ }
+
+ /* 에디터 풀 너비 */
+ .blog-editor {
+ width: 100%;
+ }
+}
+```
+
+- [ ] **Step 2: Blog.jsx에 FAB + PullToRefresh 적용**
+
+```jsx
+import FAB from '../../components/FAB';
+import PullToRefresh from '../../components/PullToRefresh';
+
+
+ {/* 기존 블로그 콘텐츠 */}
+
+
+ { /* 글 쓰기 모달 */ }} label="글 쓰기" />
+```
+
+- [ ] **Step 3: BlogMarketing.css 모바일 보강**
+
+기존 480px 미디어쿼리(원래 640px → 표준화됨)에 추가:
+
+```css
+@media (max-width: 768px) {
+ .blog-marketing__pipeline-table {
+ display: none;
+ }
+
+ .blog-marketing__pipeline-cards {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+}
+
+/* 데스크톱에서 카드 숨김 */
+.blog-marketing__pipeline-cards {
+ display: none;
+}
+```
+
+- [ ] **Step 4: BlogMarketing.jsx에 FAB + PullToRefresh + 카드 뷰 적용**
+
+```jsx
+import FAB from '../../components/FAB';
+import PullToRefresh from '../../components/PullToRefresh';
+import { useIsMobile } from '../../hooks/useIsMobile';
+
+
+ {/* 기존 콘텐츠 */}
+
+
+ { /* 키워드 분석 시작 */ }} label="키워드 분석" />
+```
+
+- [ ] **Step 5: 커밋**
+
+```bash
+git add src/pages/blog/ src/pages/blog-marketing/
+git commit -m "feat(blog): 모바일 반응형 — FAB + 풀다운 리프레시 + 칩 필터"
+```
+
+---
+
+### Task 18: 부동산 청약 모바일 개선
+
+**Files:**
+- Modify: `src/pages/subscription/Subscription.jsx`
+- Modify: `src/pages/subscription/Subscription.css`
+
+- [ ] **Step 1: Subscription.css 모바일 보강**
+
+```css
+@media (max-width: 768px) {
+ /* 기존 스타일 유지 */
+
+ /* 필터를 바텀시트로 대체하므로 인라인 필터 숨김 */
+ .subscription__filter-inline {
+ display: none;
+ }
+
+ .subscription__filter-trigger {
+ display: flex;
+ }
+
+ /* 공고 카드 1컬럼 */
+ .subscription__list {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* 데스크톱에서 필터 트리거 숨김 */
+.subscription__filter-trigger {
+ display: none;
+}
+```
+
+- [ ] **Step 2: Subscription.jsx에 FAB + MobileSheet 필터 적용**
+
+```jsx
+import FAB from '../../components/FAB';
+import MobileSheet from '../../components/MobileSheet';
+import PullToRefresh from '../../components/PullToRefresh';
+import { useIsMobile } from '../../hooks/useIsMobile';
+
+const [filterSheetOpen, setFilterSheetOpen] = useState(false);
+const isMobile = useIsMobile();
+
+// 모바일 필터 트리거 버튼
+{isMobile && (
+
+)}
+
+// 필터 바텀시트
+ setFilterSheetOpen(false)} title="필터">
+ {/* 기존 필터 폼 컴포넌트 */}
+
+
+
+ {/* 기존 콘텐츠 */}
+
+
+ { /* 공고 등록 */ }} label="공고 등록" />
+```
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add src/pages/subscription/
+git commit -m "feat(subscription): 모바일 반응형 — 바텀시트 필터 + FAB"
+```
+
+---
+
+### Task 19: 뮤직 스튜디오 모바일 개선
+
+**Files:**
+- Modify: `src/pages/music/MusicStudio.jsx`
+- Modify: `src/pages/music/MusicStudio.css`
+
+- [ ] **Step 1: MusicStudio.css 모바일 보강**
+
+```css
+@media (max-width: 768px) {
+ /* 라이브러리 1컬럼 */
+ .music-library-grid {
+ grid-template-columns: 1fr;
+ }
+
+ /* 레이더 위젯 중앙 */
+ .music-radar {
+ margin: 0 auto;
+ }
+
+ /* 미니 플레이어 고정 */
+ .music-mini-player {
+ position: fixed;
+ bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px));
+ left: 0;
+ right: 0;
+ height: 56px;
+ z-index: 250;
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border-line);
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+ gap: 12px;
+ }
+
+ /* 미니 플레이어 존재 시 콘텐츠 여백 */
+ .music-content--with-player {
+ padding-bottom: calc(var(--bottom-nav-h) + 56px + var(--safe-area-bottom, 0px) + 16px);
+ }
+}
+```
+
+- [ ] **Step 2: MusicStudio.jsx에 FAB + PullToRefresh 적용**
+
+```jsx
+import FAB from '../../components/FAB';
+import PullToRefresh from '../../components/PullToRefresh';
+
+
+ {/* 기존 콘텐츠 */}
+
+
+ { /* 음악 생성 모달 */ }}
+ label="음악 생성"
+ className={isPlaying ? 'fab--above-player' : ''}
+/>
+```
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add src/pages/music/
+git commit -m "feat(music): 모바일 반응형 — 미니 플레이어 + FAB + 1컬럼 라이브러리"
+```
+
+---
+
+### Task 20: TODO 모바일 개선
+
+**Files:**
+- Modify: `src/pages/todo/Todo.jsx`
+- Modify: `src/pages/todo/Todo.css`
+
+- [ ] **Step 1: Todo.css 모바일 보강**
+
+```css
+@media (max-width: 768px) {
+ /* 기존 스타일 유지 */
+
+ /* 칸반 보드 숨김 (스와이프로 대체) */
+ .todo-board {
+ display: none;
+ }
+
+ .todo-swipe-board {
+ display: block;
+ }
+}
+
+/* 데스크톱에서 스와이프 보드 숨김 */
+.todo-swipe-board {
+ display: none;
+}
+```
+
+- [ ] **Step 2: Todo.jsx에 SwipeableView + FAB + MobileSheet 적용**
+
+```jsx
+import SwipeableView from '../../components/SwipeableView';
+import FAB from '../../components/FAB';
+import MobileSheet from '../../components/MobileSheet';
+import { useIsMobile } from '../../hooks/useIsMobile';
+
+const [addSheetOpen, setAddSheetOpen] = useState(false);
+const isMobile = useIsMobile();
+
+// 모바일 스와이프 보드
+{isMobile && (
+
+ },
+ { key: 'progress', label: '진행중', content: },
+ { key: 'done', label: '완료', content: },
+ ]}
+ />
+
+)}
+
+// FAB → 바텀시트 입력
+ setAddSheetOpen(true)} label="할일 추가" />
+
+ setAddSheetOpen(false)} title="할일 추가">
+ {/* 기존 입력 폼 */}
+
+```
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add src/pages/todo/
+git commit -m "feat(todo): 모바일 반응형 — 스와이프 칸반 + FAB + 바텀시트 입력"
+```
+
+---
+
+### Task 21: 에이전트 오피스 모바일 개선
+
+**Files:**
+- Modify: `src/pages/agent-office/AgentOffice.jsx`
+- Modify: `src/pages/agent-office/AgentOffice.css`
+
+- [ ] **Step 1: AgentOffice.css 모바일 보강**
+
+기존 768px 미디어쿼리에 추가:
+
+```css
+@media (max-width: 768px) {
+ /* 기존 스타일 유지 */
+
+ /* 캔버스 풀스크린 */
+ .agent-office__canvas {
+ width: 100%;
+ height: 50vh;
+ touch-action: pan-x pan-y;
+ }
+
+ /* 명령 입력 하단 고정 */
+ .agent-office__command {
+ position: fixed;
+ bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px));
+ left: 0;
+ right: 0;
+ padding: 8px 16px;
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border-line);
+ z-index: 200;
+ }
+}
+```
+
+- [ ] **Step 2: AgentOffice.jsx에 MobileSheet 적용**
+
+```jsx
+import MobileSheet from '../../components/MobileSheet';
+import { useIsMobile } from '../../hooks/useIsMobile';
+
+const [agentSheetOpen, setAgentSheetOpen] = useState(false);
+const [selectedAgent, setSelectedAgent] = useState(null);
+const isMobile = useIsMobile();
+
+// 에이전트 클릭 시 바텀시트
+const handleAgentClick = (agent) => {
+ if (isMobile) {
+ setSelectedAgent(agent);
+ setAgentSheetOpen(true);
+ }
+ // 데스크톱은 기존 사이드 패널 동작
+};
+
+ setAgentSheetOpen(false)} title={selectedAgent?.name}>
+ {/* 에이전트 상세 + 로그 */}
+
+```
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add src/pages/agent-office/
+git commit -m "feat(agent-office): 모바일 반응형 — 바텀시트 에이전트 상세 + 하단 명령 입력"
+```
+
+---
+
+### Task 22: 이펙트 랩 모바일 개선
+
+**Files:**
+- Modify: `src/pages/effect-lab/EffectLab.css`
+- Modify: `src/pages/effect-lab/DayCalc.css`
+- Modify: `src/pages/effect-lab/SwordStream.css`
+
+- [ ] **Step 1: EffectLab.css — 기존 768px 이미 1컬럼, 추가 수정 불필요**
+
+확인만 수행. line 183의 기존 미디어쿼리가 충분한지 검증.
+
+- [ ] **Step 2: DayCalc.css — 기존 768px 이미 적용됨, 확인**
+
+line 417의 기존 미디어쿼리가 충분한지 검증.
+
+- [ ] **Step 3: SwordStream.css — 모바일 미디어쿼리 추가**
+
+SwordStream은 미디어쿼리가 없음 (83줄). 모바일 대응 추가:
+
+```css
+@media (max-width: 768px) {
+ .sword-stream {
+ touch-action: none; /* 터치 인터랙션 유지 */
+ }
+
+ .sword-stream__controls {
+ position: fixed;
+ bottom: calc(var(--bottom-nav-h, 64px) + var(--safe-area-bottom, 0px) + 8px);
+ left: 8px;
+ right: 8px;
+ padding: 8px;
+ background: rgba(0, 0, 0, 0.7);
+ border-radius: var(--radius-sm);
+ backdrop-filter: blur(8px);
+ }
+}
+```
+
+> Note: 실제 클래스명은 SwordStream.css를 읽어서 매칭해야 함.
+
+- [ ] **Step 4: 커밋**
+
+```bash
+git add src/pages/effect-lab/
+git commit -m "feat(effect-lab): SwordStream 모바일 터치 대응 + 오버레이 컨트롤"
+```
+
+---
+
+## Phase 4: 검증
+
+### Task 23: 전체 뷰 모바일 UI 검증
+
+**대상 뷰포트:**
+- 360px (Galaxy S)
+- 390px (iPhone 14)
+- 768px (iPad)
+- 1024px (데스크톱)
+
+- [ ] **Step 1: 개발 서버 실행**
+
+```bash
+cd C:\Users\jaeoh\Desktop\workspace\web-ui && npm run dev
+```
+
+- [ ] **Step 2: 각 페이지별 4개 뷰포트 확인**
+
+DevTools에서 각 뷰포트 크기로 전환하며 확인:
+
+| 페이지 | 360px | 390px | 768px | 1024px |
+|--------|-------|-------|-------|--------|
+| Home | ☐ | ☐ | ☐ | ☐ |
+| Lotto | ☐ | ☐ | ☐ | ☐ |
+| Stock | ☐ | ☐ | ☐ | ☐ |
+| StockTrade | ☐ | ☐ | ☐ | ☐ |
+| Travel | ☐ | ☐ | ☐ | ☐ |
+| Blog | ☐ | ☐ | ☐ | ☐ |
+| BlogMarketing | ☐ | ☐ | ☐ | ☐ |
+| Subscription | ☐ | ☐ | ☐ | ☐ |
+| Music | ☐ | ☐ | ☐ | ☐ |
+| Todo | ☐ | ☐ | ☐ | ☐ |
+| AgentOffice | ☐ | ☐ | ☐ | ☐ |
+| EffectLab | ☐ | ☐ | ☐ | ☐ |
+| DayCalc | ☐ | ☐ | ☐ | ☐ |
+| SwordStream | ☐ | ☐ | ☐ | ☐ |
+
+**확인 항목:**
+- UI 짤림 없음
+- 터치 타겟 44×44px 이상
+- FAB가 바텀네비 위에 올바르게 위치
+- 바텀네비 더보기 메뉴 동작
+- 스와이프 동작 (Lotto, Todo, Home TODO)
+- 풀다운 리프레시 동작
+- 바텀시트 열기/닫기/드래그 닫기
+- 가로 스크롤 테이블/캐러셀
+
+- [ ] **Step 3: prefers-reduced-motion 확인**
+
+DevTools → Rendering → Emulate CSS media feature `prefers-reduced-motion: reduce`
+
+모든 애니메이션이 비활성화되는지 확인:
+- 바텀네비 더보기 패널 전환
+- 바텀시트 슬라이드
+- 풀다운 리프레시 스피너
+- 스와이프 전환
+
+- [ ] **Step 4: 발견된 문제 수정 + 커밋**
+
+```bash
+git add -A
+git commit -m "fix: 모바일 UI 검증 후 수정"
+```
+
+- [ ] **Step 5: 데스크톱 회귀 확인**
+
+1024px 이상에서 모든 페이지가 기존과 동일하게 동작하는지 확인:
+- 사이드바 정상 표시
+- 바텀네비 숨김
+- FAB 숨김
+- 기존 레이아웃 유지