# 반응형 웹 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 숨김 - 기존 레이아웃 유지