Files
web-page-backend/docs/superpowers/plans/2026-04-23-responsive-web-design.md
gahusb 8d92e50009 docs: 반응형 웹 UI/UX 구현 계획 23개 태스크
Phase 1a: breakpoint 통일 (Task 1-4)
Phase 1b: 공통 컴포넌트 + 앱 셸 (Task 5-12)
Phase 2: 주요 4페이지 (Task 13-16)
Phase 3: 나머지 페이지 (Task 17-22)
Phase 4: 검증 (Task 23)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:24:22 +09:00

2393 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 반응형 웹 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
<!-- 기존 (line 6) -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 변경 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
```
- [ ] **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 (
<>
<div
className={`bottom-nav__more-overlay ${moreOpen ? 'is-visible' : ''}`}
onClick={closeMore}
/>
<div className={`bottom-nav__more-panel ${moreOpen ? 'is-visible' : ''}`}>
<div className="bottom-nav__more-grid">
{moreLinks.map(link => (
<NavLink
key={link.id}
to={link.path}
className={({ isActive }) =>
`bottom-nav__more-item ${isActive ? 'is-active' : ''}`
}
onClick={closeMore}
>
<span className="bottom-nav__more-icon">{link.icon}</span>
<span>{link.label}</span>
</NavLink>
))}
</div>
</div>
<nav className="bottom-nav">
{primaryLinks.map(link => (
<NavLink
key={link.id}
to={link.path}
end={link.path === '/'}
className={({ isActive }) =>
`bottom-nav__item ${isActive ? 'is-active' : ''}`
}
>
<span className="bottom-nav__icon">{link.icon}</span>
<span className="bottom-nav__label">{link.label}</span>
</NavLink>
))}
<button
className={`bottom-nav__item ${isMoreActive || moreOpen ? 'is-active' : ''}`}
onClick={toggleMore}
aria-label="더보기 메뉴"
>
<span className="bottom-nav__icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="5" r="1.5"/>
<circle cx="12" cy="12" r="1.5"/>
<circle cx="12" cy="19" r="1.5"/>
</svg>
</span>
<span className="bottom-nav__label">더보기</span>
</button>
</nav>
</>
);
}
```
- [ ] **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 <div className={className}>{children}</div>;
}
const isPulling = pullDistance > 10;
const isReady = pullDistance >= THRESHOLD;
return (
<div
ref={containerRef}
className={`pull-to-refresh ${className}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div
className={`pull-to-refresh__indicator ${refreshing ? 'is-refreshing' : isPulling ? 'is-pulling' : ''}`}
style={isPulling && !refreshing ? { transform: `translateX(-50%) translateY(${pullDistance}px)` } : undefined}
>
{refreshing ? (
<div className="pull-to-refresh__spinner" />
) : (
<svg className={`pull-to-refresh__arrow ${isReady ? 'is-ready' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="6 9 12 15 18 9" />
</svg>
)}
</div>
<div style={isPulling && !refreshing ? { transform: `translateY(${pullDistance * 0.3}px)`, transition: 'none' } : undefined}>
{children}
</div>
</div>
);
}
```
- [ ] **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 (
<div className="swipeable-view">
{showTabs && (
<div className="swipeable-view__tabs">
{tabs.map((tab, i) => (
<button
key={tab.key}
className={`swipeable-view__tab ${i === activeIndex ? 'is-active' : ''}`}
onClick={() => setActiveIndex(i)}
>
{tab.label}
</button>
))}
</div>
)}
<div {...handlers}>
<div
className={`swipeable-view__track ${isSwiping ? 'is-swiping' : ''}`}
style={{ transform: `translateX(${translateX}%)` }}
>
{tabs.map((tab) => (
<div key={tab.key} className="swipeable-view__panel">
{tab.content}
</div>
))}
</div>
</div>
</div>
);
}
```
- [ ] **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 (
<button
className={`fab ${className}`}
onClick={onClick}
aria-label={label}
>
{icon || (
<svg className="fab__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
)}
</button>
);
}
```
- [ ] **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 (
<>
<div
className={`mobile-sheet__backdrop ${open ? 'is-visible' : ''}`}
onClick={onClose}
/>
<div
ref={sheetRef}
className={`mobile-sheet ${open ? 'is-visible' : ''} ${snap === 'half' ? 'snap-half' : ''}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="mobile-sheet__handle">
<div className="mobile-sheet__handle-bar" />
</div>
{title && (
<div className="mobile-sheet__header">
<span className="mobile-sheet__title">{title}</span>
<button className="mobile-sheet__close" onClick={onClose} aria-label="닫기">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
)}
<div className="mobile-sheet__body">
{children}
</div>
</div>
</>
);
}
```
- [ ] **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 (
<div className="app-shell">
<Navbar />
<div className="app-content">
<PageHeader />
<main className="site-main">
<Suspense fallback={<Loading className="suspend-loading" />}>
<Outlet />
</Suspense>
</main>
</div>
{isMobile && <BottomNav />}
</div>
);
}
```
- [ ] **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 (
<aside className="sidebar">
<div className="sidebar__brand">
<img src="/logo.webp" alt="" className="sidebar__logo" />
<div>
<span className="sidebar__name">gahusb</span>
<span className="sidebar__tagline">Personal Lab</span>
</div>
</div>
<div className="sidebar__divider" />
<nav className="sidebar__nav">
{navLinks.map(link => (
<NavLink
key={link.id}
to={link.path}
end={link.path === '/'}
className={({ isActive }) =>
`sidebar__item ${isActive ? 'is-active' : ''}`
}
>
<span className="sidebar__icon">{link.icon}</span>
<span className="sidebar__label">{link.label}</span>
{link.subtitle && <span className="sidebar__subtitle">{link.subtitle}</span>}
</NavLink>
))}
</nav>
<div className="sidebar__footer">
<span className="sidebar__status" />
<span className="sidebar__version">v2.0</span>
</div>
</aside>
);
}
```
> 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 && (
<div className="home__todo-swipe">
<SwipeableView
tabs={[
{ key: 'todo', label: 'TODO', content: <TodoColumn items={todoItems} /> },
{ key: 'progress', label: '진행중', content: <TodoColumn items={progressItems} /> },
{ key: 'done', label: '완료', content: <TodoColumn items={doneItems} /> },
]}
/>
</div>
)}
```
블로그 포스트 영역을 PullToRefresh로 래핑:
```jsx
<PullToRefresh onRefresh={fetchBlogPosts}>
{/* 기존 블로그 포스트 그리드 */}
</PullToRefresh>
```
> 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 ? (
<PullToRefresh onRefresh={refreshData}>
<SwipeableView
tabs={[
{ key: 'briefing', label: '브리핑', content: <BriefingTab /> },
{ key: 'analysis', label: '분석', content: <AnalysisTab /> },
{ key: 'purchase', label: '구매', content: <PurchaseTab /> },
]}
activeIndex={activeTab}
onTabChange={setActiveTab}
/>
</PullToRefresh>
) : (
/* 기존 데스크톱 탭 구조 유지 */
)}
// FAB
<FAB
onClick={() => { /* 빠른 추천 로직 */ }}
label="추천받기"
icon={<svg>...</svg>}
/>
```
- [ ] **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 래핑
<PullToRefresh onRefresh={fetchNews}>
{/* 기존 Stock 콘텐츠 */}
</PullToRefresh>
// FAB
<FAB onClick={() => { /* 종목 추가 모달 */ }} 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 ? (
<div className="stock-portfolio-cards">
{portfolio.map(item => (
<div key={item.id} className="stock-portfolio-card" onClick={() => openDetail(item)}>
<div className="stock-portfolio-card__name">{item.name}</div>
<div className="stock-portfolio-card__price">{item.currentPrice}</div>
<div className="stock-portfolio-card__profit" data-positive={item.profit > 0}>
{item.profitRate}%
</div>
</div>
))}
</div>
) : (
/* 기존 테이블 */
)}
// FAB
<FAB onClick={() => { /* 매도 기록 */ }} label="매도 기록" />
// 상세 바텀시트
<MobileSheet open={detailOpen} onClose={() => setDetailOpen(false)} title="종목 상세">
{/* 상세 내용 */}
</MobileSheet>
```
- [ ] **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 래핑
<PullToRefresh onRefresh={reloadPhotos}>
{/* 기존 사진 그리드 */}
</PullToRefresh>
// 라이트박스에 스와이프 네비게이션 추가
const lightboxSwipe = useSwipe({
onSwipedLeft: () => nextPhoto(),
onSwipedRight: () => prevPhoto(),
});
// 라이트박스 내부:
<div {...lightboxSwipe} className="travel-lightbox__stage">
<img src={currentPhoto} className="travel-lightbox__image" />
</div>
```
- [ ] **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';
<PullToRefresh onRefresh={fetchPosts}>
{/* 기존 블로그 콘텐츠 */}
</PullToRefresh>
<FAB onClick={() => { /* 글 쓰기 모달 */ }} 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';
<PullToRefresh onRefresh={fetchDashboard}>
{/* 기존 콘텐츠 */}
</PullToRefresh>
<FAB onClick={() => { /* 키워드 분석 시작 */ }} 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 && (
<button className="subscription__filter-trigger" onClick={() => setFilterSheetOpen(true)}>
필터
</button>
)}
// 필터 바텀시트
<MobileSheet open={filterSheetOpen} onClose={() => setFilterSheetOpen(false)} title="필터">
{/* 기존 필터 폼 컴포넌트 */}
</MobileSheet>
<PullToRefresh onRefresh={fetchAnnouncements}>
{/* 기존 콘텐츠 */}
</PullToRefresh>
<FAB onClick={() => { /* 공고 등록 */ }} 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';
<PullToRefresh onRefresh={fetchLibrary}>
{/* 기존 콘텐츠 */}
</PullToRefresh>
<FAB
onClick={() => { /* 음악 생성 모달 */ }}
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 && (
<div className="todo-swipe-board">
<SwipeableView
tabs={[
{ key: 'todo', label: 'TODO', content: <TodoColumn status="todo" /> },
{ key: 'progress', label: '진행중', content: <TodoColumn status="in_progress" /> },
{ key: 'done', label: '완료', content: <TodoColumn status="done" /> },
]}
/>
</div>
)}
// FAB → 바텀시트 입력
<FAB onClick={() => setAddSheetOpen(true)} label="할일 추가" />
<MobileSheet open={addSheetOpen} onClose={() => setAddSheetOpen(false)} title="할일 추가">
{/* 기존 입력 폼 */}
</MobileSheet>
```
- [ ] **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);
}
// 데스크톱은 기존 사이드 패널 동작
};
<MobileSheet open={agentSheetOpen} onClose={() => setAgentSheetOpen(false)} title={selectedAgent?.name}>
{/* 에이전트 상세 + 로그 */}
</MobileSheet>
```
- [ ] **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 숨김
- 기존 레이아웃 유지