# 반응형 웹 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 (
);
}
```
- [ ] **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 숨김
- 기존 레이아웃 유지