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