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

59 KiB
Raw Blame History

반응형 웹 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 추가

<!-- 기존 (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) 뒤에 추가:

  /* ── 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의 모바일 미디어쿼리를 확장:

@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: 커밋
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:

/* 기존 */
@media (max-width: 960px) {

/* 변경 */
@media (max-width: 1024px) {
  • Step 2: Lotto.css — 900px → 768px로 변경

src/pages/lotto/Lotto.css line 1077:

/* 기존 */
@media (max-width: 900px) {

/* 변경 */
@media (max-width: 768px) {

주의: 이 블록 내의 스타일이 기존 768px 블록(line 1159)과 충돌하지 않는지 확인. 충돌 시 두 블록을 병합한다.

  • Step 3: Travel.css — 900px → 768px로 변경

src/pages/travel/Travel.css line 969:

/* 기존 */
@media (max-width: 900px) {

/* 변경 */
@media (max-width: 768px) {

기존 640px 블록(line 975)과 겹치지 않는지 확인.

  • Step 4: Blog.css — 900px → 768px로 변경

src/pages/blog/Blog.css line 454:

/* 기존 */
@media (max-width: 900px) {

/* 변경 */
@media (max-width: 768px) {

기존 768px 블록(line 504)과 병합 필요 시 병합.

  • Step 5: 각 페이지를 DevTools 768px/1024px에서 확인

각 페이지가 기존과 동일하게 렌더링되는지 확인. 특히:

  • Home: 히어로 그리드 전환 시점

  • Lotto: 헤더/분석 카드 1컬럼 전환 시점

  • Travel: 헤더 레이아웃 전환 시점

  • Blog: 사이드 목록 오버레이 전환 시점

  • Step 6: 커밋

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

/* 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:

/* 기존 */
@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

각 파일에서:

/* 기존 */
@media (max-width: 640px) {

/* 변경 */
@media (max-width: 480px) {
  • Step 4: 각 페이지 480px/768px/1024px에서 확인

  • Step 5: 커밋

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

/* line 955 */
@media (max-width: 1024px) {

/* line 961 */
@media (max-width: 768px) {
  • Step 2: 커밋
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 설치

cd C:\Users\jaeoh\Desktop\workspace\web-ui
npm install react-swipeable
  • Step 2: useIsMobile 훅 작성
// 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: 커밋
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 훅 작성

// 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: 커밋
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 작성

/* 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 작성
// 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: 커밋
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 작성

/* 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 작성
// 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: 커밋
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 작성

/* 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 작성
// 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: 커밋
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 작성

/* 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 작성
// 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: 커밋
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 작성

/* 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 작성
// 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: 커밋
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를 수정:

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의 모바일 미디어쿼리를 수정:

/* 기존 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로 조건부 처리:

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 스타일은 더 이상 사용되지 않으므로 제거하거나 유지해도 무방 (컴포넌트가 렌더링되지 않으므로).

정리를 위해 제거 권장:

/* 기존 (lines 335-359) — 전체 삭제 가능 */
/* @media (max-width: 768px) { ... } */
/* @media (min-width: 769px) { ... } */

대신 데스크톱 전용 사이드바만 유지:

/* Navbar.css 말미 — 사이드바는 데스크톱 전용 */
@media (max-width: 768px) {
  .sidebar {
    display: none;
  }
}
  • Step 5: 데스크톱/모바일에서 확인

  • 데스크톱: 사이드바 정상 표시, 바텀네비 숨김

  • 모바일 (768px 이하): 사이드바 완전 숨김, 바텀네비 표시

  • 더보기 메뉴 열기/닫기 동작

  • 네비게이션 링크 클릭 시 라우팅 정상 동작

  • Step 6: 커밋

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)에 다음을 추가/수정:

@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 추가:

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로 래핑:

<PullToRefresh onRefresh={fetchBlogPosts}>
  {/* 기존 블로그 포스트 그리드 */}
</PullToRefresh>

Note: 실제 변수명/함수명은 Home.jsx를 읽어서 매칭해야 함.

  • Step 3: 모바일 375px에서 확인

  • 히어로: 1컬럼 스택

  • 네비 카드: 2컬럼

  • TODO: 스와이프 탭 동작

  • 블로그: 1컬럼 + 풀다운 리프레시

  • Step 4: 커밋

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 미디어쿼리에 추가:

@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 래핑:

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: 커밋

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 미디어쿼리에 추가:

@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 적용
import FAB from '../../components/FAB';
import PullToRefresh from '../../components/PullToRefresh';

// PullToRefresh 래핑
<PullToRefresh onRefresh={fetchNews}>
  {/* 기존 Stock 콘텐츠 */}
</PullToRefresh>

// FAB
<FAB onClick={() => { /* 종목 추가 모달 */ }} label="종목 추가" />
  • Step 3: StockTrade.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에 포트폴리오 카드 스타일 추가
/* 모바일 포트폴리오 카드 */
.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: 커밋

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 미디어쿼리에 추가:

@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 + 라이트박스 스와이프 적용
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: 커밋

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 모바일 보강

@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 적용
import FAB from '../../components/FAB';
import PullToRefresh from '../../components/PullToRefresh';

<PullToRefresh onRefresh={fetchPosts}>
  {/* 기존 블로그 콘텐츠 */}
</PullToRefresh>

<FAB onClick={() => { /* 글 쓰기 모달 */ }} label="글 쓰기" />
  • Step 3: BlogMarketing.css 모바일 보강

기존 480px 미디어쿼리(원래 640px → 표준화됨)에 추가:

@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 + 카드 뷰 적용
import FAB from '../../components/FAB';
import PullToRefresh from '../../components/PullToRefresh';
import { useIsMobile } from '../../hooks/useIsMobile';

<PullToRefresh onRefresh={fetchDashboard}>
  {/* 기존 콘텐츠 */}
</PullToRefresh>

<FAB onClick={() => { /* 키워드 분석 시작 */ }} label="키워드 분석" />
  • Step 5: 커밋
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 모바일 보강

@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 필터 적용
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: 커밋
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 모바일 보강

@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 적용
import FAB from '../../components/FAB';
import PullToRefresh from '../../components/PullToRefresh';

<PullToRefresh onRefresh={fetchLibrary}>
  {/* 기존 콘텐츠 */}
</PullToRefresh>

<FAB
  onClick={() => { /* 음악 생성 모달 */ }}
  label="음악 생성"
  className={isPlaying ? 'fab--above-player' : ''}
/>
  • Step 3: 커밋
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 모바일 보강

@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 적용
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: 커밋
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 미디어쿼리에 추가:

@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 적용
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: 커밋
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줄). 모바일 대응 추가:

@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: 커밋
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: 개발 서버 실행

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: 발견된 문제 수정 + 커밋

git add -A
git commit -m "fix: 모바일 UI 검증 후 수정"
  • Step 5: 데스크톱 회귀 확인

1024px 이상에서 모든 페이지가 기존과 동일하게 동작하는지 확인:

  • 사이드바 정상 표시
  • 바텀네비 숨김
  • FAB 숨김
  • 기존 레이아웃 유지