# 호령 사주 UI v2 리디자인 Implementation Plan > **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:** 백호 사주도사 프로토타입의 디자인 시스템을 web-ui의 `/saju` 라우트 4개에 동시 적용하고 `/saju/me` placeholder를 신설한다. 모바일/데스크탑은 1024px 경계로 컴포넌트 분리. **Architecture:** `_shell/` 디렉토리에 디자인 시스템 (토큰 CSS + 13 공통 컴포넌트 + helpers + hook)을 두고, 각 페이지는 `useViewportMode()`로 `views/.{mobile,desktop}.jsx`를 분기한다. 기존 `useSajuReading`/`useSajuForm` hook과 saju-lab API는 무변경 — 응답을 디자인 mock 필드로 매핑하는 helper만 추가. **Tech Stack:** React 18, Vite, React Router v6, vanilla CSS (CSS variables + scope), Pretendard fallback, Google Fonts (Nanum Myeongjo/Nanum Gothic/Gowun Batang). 기존 `web-ui` 컨벤션 유지. **Spec:** `docs/superpowers/specs/2026-05-26-saju-ui-v2-redesign-design.md` (commit `fd40777`) **Reference design:** `C:\Users\jaeoh\Desktop\workspace\source\images\saju_page\사주풀이\` — babel/standalone React 11 파일 + styles.css 프로토타입 --- ## File Structure ### 신규 (`web-ui/src/pages/saju/`) ``` _shell/ ├── tokens.css # CSS 변수 (--navy, --ivory, --gold, ...) ├── shell.css # paper-bg, night-bg, mt-wash, OrnateFrame, screenIn, paw-bob ├── useViewportMode.js ├── BottomNav.jsx ├── DesktopHeader.jsx ├── Mascot.jsx ├── MascotBubble.jsx ├── OrnateFrame.jsx ├── OrnamentBloom.jsx ├── TopRibbon.jsx ├── TitleBlock.jsx ├── PrimaryButton.jsx ├── GhostButton.jsx ├── InputRow.jsx ├── Icons.jsx └── helpers/ ├── daeunLabel.js ├── deriveTraits.js ├── colorMap.js └── hexA.js views/ ├── home.mobile.jsx ├── home.desktop.jsx ├── saju.mobile.jsx ├── saju.desktop.jsx ├── today.mobile.jsx ├── today.desktop.jsx ├── match.mobile.jsx └── match.desktop.jsx Me.jsx # placeholder (mobile/desktop 공통) ``` ### 교체 (기존 파일 본문 전면 교체, 파일명 유지) - `pages/saju/Saju.jsx` - `pages/saju/SajuResult.jsx` - `pages/saju/Today.jsx` - `pages/saju/Compatibility.jsx` - `pages/saju/CompatibilityResult.jsx` (라이트 리스타일만) ### 수정 - `web-ui/index.html` (Google Fonts link 추가) - `web-ui/src/routes.jsx` (Me lazy import + me 라우트 추가) ### 삭제 (Phase 6에서 일괄) - `pages/saju/components/` 전체 (12 파일) - `pages/saju/Saju.css` ### 보존 - `pages/saju/hooks/useSajuForm.js` - `pages/saju/hooks/useSajuReading.js` - `web-ui/public/images/saju/horyung/` 7 PNG - `web-ui/src/api.js` saju 헬퍼 --- # Phase 1 — Shell + 토큰 + 공통 컴포넌트 기존 4 페이지 무손상. 신규 `/saju/me` 라우트 진입 시 placeholder 표시. ## Task 1.1: Google Fonts link 추가 **Files:** - Modify: `web-ui/index.html` (head 내 `` 다음 줄에 추가) - [ ] **Step 1: index.html에 폰트 preconnect + link 추가** `<head>` 안 `<title>` 다음에 추가: ```html <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link href="https://fonts.googleapis.com/css2?family=Nanum+Myeongjo:wght@400;700;800&family=Nanum+Gothic:wght@400;700;800&family=Gowun+Batang:wght@400;700&display=swap" rel="stylesheet" /> ``` - [ ] **Step 2: dev server 띄워 폰트 로드 확인** Run: `cd web-ui && npm run dev` Open `http://localhost:3007` → Network 탭에서 `fonts.googleapis.com/css2` 200 응답, `fonts.gstatic.com/s/...` woff2 파일 4개 이상 다운로드 확인 - [ ] **Step 3: Commit** ```bash cd /c/Users/jaeoh/Desktop/workspace/web-ui git add index.html git commit -m "feat(saju-ui-v2): Google Fonts (Nanum Myeongjo/Gothic/Gowun Batang) preconnect + link" ``` --- ## Task 1.2: tokens.css 작성 **Files:** - Create: `web-ui/src/pages/saju/_shell/tokens.css` - [ ] **Step 1: tokens.css 작성** `.saju-v2` scope 안에 디자인 토큰 정의 (글로벌 오염 방지): ```css /* 호령 사주 v2 — 디자인 토큰 */ .saju-v2 { /* Brand palette */ --navy: #1F2A44; --navy-deep: #141B30; --navy-soft: #2E3B5A; --ivory: #F7F2E8; --ivory-soft: #FBF7EF; --ivory-warm: #F0E9D9; --gold: #D4AF37; --gold-soft: #E8C76B; --gold-dim: #B89530; --green: #4E6B5C; --green-soft: #6E8B7C; --green-bg: #E6EBE5; --purple: #6A4C7C; --purple-soft: #8B6C9C; --purple-bg: #ECE6F0; --pink: #F2C7CD; --pink-deep: #D89098; --pink-bg: #FBE8EB; --gray: #6B6B6B; --gray-soft: #9A968D; --gray-line: rgba(31, 42, 68, 0.10); --gray-line-strong: rgba(31, 42, 68, 0.18); /* Element colors (오행) */ --el-wood: #4E6B5C; --el-fire: #C04A4A; --el-earth: #A67B3F; --el-metal: #D4AF37; --el-water: #3A5A8C; /* Shadows */ --shadow-card: 0 2px 8px rgba(31, 42, 68, 0.04), 0 8px 24px rgba(31, 42, 68, 0.06); --shadow-pop: 0 8px 28px rgba(31, 42, 68, 0.16); --shadow-dark: 0 4px 20px rgba(0, 0, 0, 0.35); /* Fonts */ --font-title: 'Nanum Myeongjo', 'Gowun Batang', serif; --font-body: 'Nanum Gothic', system-ui, -apple-system, sans-serif; /* Layout */ --content-max-desktop: 1200px; --bottom-nav-h: 72px; --desktop-header-h: 64px; color: var(--navy); font-family: var(--font-body); -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } .saju-v2 * { box-sizing: border-box; } .saju-v2 .font-title { font-family: var(--font-title); font-weight: 800; letter-spacing: -0.01em; } .saju-v2 button { font-family: inherit; cursor: pointer; } .saju-v2 button:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; } /* hide scrollbar utility */ .saju-v2 .no-scrollbar::-webkit-scrollbar { display: none; } .saju-v2 .no-scrollbar { scrollbar-width: none; } ``` - [ ] **Step 2: Commit** ```bash git add web-ui/src/pages/saju/_shell/tokens.css git commit -m "feat(saju-ui-v2): tokens.css — 디자인 변수 + 한글 폰트 토큰 (.saju-v2 scope)" ``` --- ## Task 1.3: shell.css 작성 **Files:** - Create: `web-ui/src/pages/saju/_shell/shell.css` - [ ] **Step 1: shell.css 작성** ```css /* 호령 사주 v2 — 배경 + ornament + animation */ /* paper texture */ .saju-v2 .paper-bg { background: radial-gradient(ellipse at top, rgba(212, 175, 55, 0.06), transparent 60%), radial-gradient(ellipse at bottom, rgba(106, 76, 124, 0.04), transparent 60%), linear-gradient(180deg, var(--ivory) 0%, var(--ivory-soft) 100%); position: relative; } .saju-v2 .paper-bg::after { content: ''; position: absolute; inset: 0; pointer-events: none; background-image: radial-gradient(circle at 20% 30%, rgba(180, 140, 80, 0.04) 0, transparent 40%), radial-gradient(circle at 80% 70%, rgba(180, 140, 80, 0.04) 0, transparent 40%); } /* night sky */ .saju-v2 .night-bg { background: radial-gradient(ellipse 80% 50% at 30% 20%, rgba(232, 199, 107, 0.18), transparent 60%), radial-gradient(ellipse 60% 40% at 80% 80%, rgba(106, 76, 124, 0.3), transparent 60%), linear-gradient(180deg, var(--navy-deep) 0%, var(--navy) 55%, #1A2238 100%); position: relative; color: var(--ivory); } /* mountain wash (desktop hero) */ .saju-v2 .mt-wash { position: relative; background: radial-gradient(ellipse 70% 50% at 10% 80%, rgba(31, 42, 68, 0.06), transparent 65%), radial-gradient(ellipse 60% 40% at 90% 70%, rgba(31, 42, 68, 0.05), transparent 65%), radial-gradient(ellipse 100% 60% at 50% 100%, rgba(212, 175, 55, 0.04), transparent 70%), linear-gradient(180deg, var(--ivory-soft) 0%, #F4ECDB 100%); } .saju-v2 .mt-wash::before, .saju-v2 .mt-wash::after { content: ''; position: absolute; pointer-events: none; background-repeat: no-repeat; opacity: 0.35; background-size: contain; } .saju-v2 .mt-wash::before { left: 0; bottom: 0; width: 320px; height: 160px; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 160' fill='none' stroke='%231F2A44' stroke-width='1' opacity='0.45'><path d='M0 150 L40 90 L80 120 L130 60 L180 110 L220 80 L260 120 L310 70 L320 100 L320 160 L0 160 Z'/><path d='M30 130 L70 100 L110 130 L150 95 L200 120 L240 100 L280 120 L320 110' opacity='0.6'/></svg>"); } .saju-v2 .mt-wash::after { right: 0; bottom: 0; width: 380px; height: 180px; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 380 180' fill='none' stroke='%231F2A44' stroke-width='1' opacity='0.4'><path d='M0 160 L50 100 L100 140 L160 70 L220 130 L280 90 L330 140 L380 110 L380 180 L0 180 Z'/></svg>"); } /* screen entry */ @keyframes saju-screen-in { from { transform: translateY(6px); opacity: 0.8; } to { transform: translateY(0); opacity: 1; } } .saju-v2 .screen-in { animation: saju-screen-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; } /* paw bob */ @keyframes saju-paw-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } } .saju-v2 .paw-bob { animation: saju-paw-bob 2.4s ease-in-out infinite; display: inline-block; } /* page container */ .saju-v2 .page { min-height: 100vh; padding-bottom: var(--bottom-nav-h); } @media (min-width: 1024px) { .saju-v2 .page { padding-bottom: 0; padding-top: var(--desktop-header-h); } } ``` - [ ] **Step 2: Commit** ```bash git add web-ui/src/pages/saju/_shell/shell.css git commit -m "feat(saju-ui-v2): shell.css — paper/night/mt-wash 배경 + screenIn/paw-bob 애니메이션" ``` --- ## Task 1.4: useViewportMode hook + 단위 테스트 **Files:** - Create: `web-ui/src/pages/saju/_shell/useViewportMode.js` - Test: `web-ui/src/pages/saju/_shell/useViewportMode.test.js` - [ ] **Step 1: 실패하는 테스트 작성** ```js // useViewportMode.test.js import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import useViewportMode from './useViewportMode'; describe('useViewportMode', () => { beforeEach(() => { window.innerWidth = 800; }); it('returns mobile when width < 1024', () => { window.innerWidth = 1023; const { result } = renderHook(() => useViewportMode()); expect(result.current).toBe('mobile'); }); it('returns desktop when width >= 1024', () => { window.innerWidth = 1024; const { result } = renderHook(() => useViewportMode()); expect(result.current).toBe('desktop'); }); it('updates on resize', () => { window.innerWidth = 800; const { result } = renderHook(() => useViewportMode()); expect(result.current).toBe('mobile'); act(() => { window.innerWidth = 1200; window.dispatchEvent(new Event('resize')); }); expect(result.current).toBe('desktop'); }); }); ``` - [ ] **Step 2: 테스트 실행 — 실패 확인** Run: `cd web-ui && npx vitest run src/pages/saju/_shell/useViewportMode.test.js` Expected: FAIL — `Cannot find module './useViewportMode'` - [ ] **Step 3: hook 구현** ```js // useViewportMode.js import { useState, useEffect } from 'react'; export default function useViewportMode() { const [mode, setMode] = useState(() => typeof window !== 'undefined' && window.innerWidth >= 1024 ? 'desktop' : 'mobile' ); useEffect(() => { const onResize = () => { const next = window.innerWidth >= 1024 ? 'desktop' : 'mobile'; setMode((prev) => (prev === next ? prev : next)); }; window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); return mode; } ``` - [ ] **Step 4: 테스트 통과 확인** Run: `cd web-ui && npx vitest run src/pages/saju/_shell/useViewportMode.test.js` Expected: 3 passed - [ ] **Step 5: Commit** ```bash git add web-ui/src/pages/saju/_shell/useViewportMode.js web-ui/src/pages/saju/_shell/useViewportMode.test.js git commit -m "feat(saju-ui-v2): useViewportMode hook (1024px breakpoint) + 3 tests" ``` --- ## Task 1.5: helpers — hexA + daeunLabel + deriveTraits + colorMap + tests **Files:** - Create: `web-ui/src/pages/saju/_shell/helpers/hexA.js` - Create: `web-ui/src/pages/saju/_shell/helpers/daeunLabel.js` - Create: `web-ui/src/pages/saju/_shell/helpers/deriveTraits.js` - Create: `web-ui/src/pages/saju/_shell/helpers/colorMap.js` - Test: `web-ui/src/pages/saju/_shell/helpers/helpers.test.js` - [ ] **Step 1: 실패하는 테스트 작성** ```js // helpers.test.js import { describe, it, expect } from 'vitest'; import hexA from './hexA'; import daeunLabel from './daeunLabel'; import deriveTraits from './deriveTraits'; import { elementColor } from './colorMap'; describe('hexA', () => { it('converts hex with alpha', () => { expect(hexA('#1F2A44', 0.5)).toBe('rgba(31,42,68,0.5)'); }); it('handles 3-digit hex', () => { expect(hexA('#abc', 1)).toBe('rgba(170,187,204,1)'); }); }); describe('daeunLabel', () => { it('maps age ranges', () => { expect(daeunLabel(5)).toBe('성장기'); expect(daeunLabel(15)).toBe('학습기'); expect(daeunLabel(25)).toBe('도전기'); expect(daeunLabel(35)).toBe('성장기'); expect(daeunLabel(45)).toBe('전성기'); expect(daeunLabel(55)).toBe('안정기'); expect(daeunLabel(65)).toBe('정리기'); expect(daeunLabel(75)).toBe('여유기'); }); }); describe('deriveTraits', () => { it('derives strong-element traits (sorted by score)', () => { const traits = deriveTraits({ fire: 55, metal: 40, wood: 35, earth: 15, water: 20 }, []); expect(traits.length).toBeLessThanOrEqual(6); expect(traits[0].id).toBe('challenge'); // fire 가장 강함 expect(traits.map((t) => t.id)).toContain('lead'); // metal >= 30 }); it('always includes will trait', () => { const traits = deriveTraits({ fire: 50, metal: 30, wood: 30, earth: 30, water: 30 }, []); expect(traits.map((t) => t.id)).toContain('will'); }); }); describe('elementColor', () => { it('maps element ids to CSS vars', () => { expect(elementColor('wood')).toBe('var(--el-wood)'); expect(elementColor('fire')).toBe('var(--el-fire)'); expect(elementColor('unknown')).toBe('var(--navy)'); }); }); ``` - [ ] **Step 2: 테스트 실행 — 실패 확인** Run: `cd web-ui && npx vitest run src/pages/saju/_shell/helpers/helpers.test.js` Expected: FAIL — modules not found - [ ] **Step 3: hexA.js 작성** ```js // hexA.js export default function hexA(hex, alpha) { const h = hex.replace('#', ''); const expanded = h.length === 3 ? h.split('').map((c) => c + c).join('') : h; const n = parseInt(expanded, 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${alpha})`; } ``` - [ ] **Step 4: daeunLabel.js 작성** ```js // daeunLabel.js export default function daeunLabel(age) { if (age < 10) return '성장기'; if (age < 20) return '학습기'; if (age < 30) return '도전기'; if (age < 40) return '성장기'; if (age < 50) return '전성기'; if (age < 60) return '안정기'; if (age < 70) return '정리기'; return '여유기'; } ``` - [ ] **Step 5: colorMap.js 작성** ```js // colorMap.js const ELEMENT_TO_VAR = { wood: 'var(--el-wood)', fire: 'var(--el-fire)', earth: 'var(--el-earth)', metal: 'var(--el-metal)', water: 'var(--el-water)', }; const ELEMENT_KO = { wood: '목', fire: '화', earth: '토', metal: '금', water: '수' }; const ELEMENT_CH = { wood: '木', fire: '火', earth: '土', metal: '金', water: '水' }; export function elementColor(id) { return ELEMENT_TO_VAR[id] || 'var(--navy)'; } export function elementKo(id) { return ELEMENT_KO[id] || ''; } export function elementCh(id) { return ELEMENT_CH[id] || ''; } ``` - [ ] **Step 6: deriveTraits.js 작성** ```js // deriveTraits.js const TRAIT_DEFS = { fire: { id: 'challenge', ko: '도전정신', icon: 'challenge', color: 'var(--el-fire)' }, metal: { id: 'lead', ko: '리더십', icon: 'lead', color: 'var(--el-metal)' }, wood: { id: 'adapt', ko: '적응력', icon: 'adapt', color: 'var(--el-wood)' }, water: { id: 'wisdom', ko: '지혜', icon: 'wisdom', color: 'var(--el-water)' }, earth: { id: 'wealth', ko: '풍부함', icon: 'wealth', color: 'var(--el-earth)' }, }; const WILL_TRAIT = { id: 'will', ko: '의지', icon: 'will', color: 'var(--purple)' }; export default function deriveTraits(elements, sipsin = []) { // strong elements first (score desc) const sorted = Object.entries(elements || {}) .filter(([, v]) => typeof v === 'number') .sort((a, b) => b[1] - a[1]); const traits = []; // strong elements (>= 30%) for (const [el, score] of sorted) { if (score >= 30 && TRAIT_DEFS[el]) { traits.push(TRAIT_DEFS[el]); } } // always include will if (!traits.find((t) => t.id === 'will')) traits.push(WILL_TRAIT); // pad up to 6 with remaining elements for (const [el] of sorted) { if (traits.length >= 6) break; if (TRAIT_DEFS[el] && !traits.find((t) => t.id === TRAIT_DEFS[el].id)) { traits.push(TRAIT_DEFS[el]); } } return traits.slice(0, 6); } ``` - [ ] **Step 7: 테스트 통과 확인** Run: `cd web-ui && npx vitest run src/pages/saju/_shell/helpers/helpers.test.js` Expected: 6+ passed - [ ] **Step 8: Commit** ```bash git add web-ui/src/pages/saju/_shell/helpers/ git commit -m "feat(saju-ui-v2): _shell/helpers — hexA/daeunLabel/deriveTraits/colorMap + tests" ``` --- ## Task 1.6: Icons.jsx **Files:** - Create: `web-ui/src/pages/saju/_shell/Icons.jsx` - [ ] **Step 1: Icons.jsx 작성 — 디자인 프로토타입 `icons.jsx`의 SVG path들 그대로 포팅** 각 아이콘은 functional component (stroke/size props 지원). 5개 nav 아이콘(IconHome/IconSun/IconHeart/IconYinYang/IconUser) + 보조 아이콘(IconPaw/IconChevron/IconSparkle): ```jsx import React from 'react'; const base = (size, stroke, strokeWidth = 1.5) => ({ width: size, height: size, fill: 'none', stroke, strokeWidth, strokeLinecap: 'round', strokeLinejoin: 'round', }); export function IconHome({ size = 20, stroke = 'currentColor', strokeWidth }) { return ( <svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}> <path d="M3 11l9-7 9 7v9a2 2 0 0 1-2 2h-3v-6h-8v6H5a2 2 0 0 1-2-2z" /> </svg> ); } export function IconSun({ size = 20, stroke = 'currentColor', strokeWidth }) { return ( <svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}> <circle cx="12" cy="12" r="4" /> <path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2" /> </svg> ); } export function IconHeart({ size = 20, stroke = 'currentColor', strokeWidth }) { return ( <svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}> <path d="M12 21s-7-4.5-9.5-9A5.5 5.5 0 0 1 12 7a5.5 5.5 0 0 1 9.5 5c-2.5 4.5-9.5 9-9.5 9z" /> </svg> ); } export function IconYinYang({ size = 20, stroke = 'currentColor', strokeWidth }) { return ( <svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}> <circle cx="12" cy="12" r="9" /> <path d="M12 3a4.5 4.5 0 0 0 0 9 4.5 4.5 0 0 1 0 9" /> <circle cx="12" cy="7.5" r="1" fill={stroke} /> <circle cx="12" cy="16.5" r="1" fill={stroke} /> </svg> ); } export function IconUser({ size = 20, stroke = 'currentColor', strokeWidth }) { return ( <svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}> <circle cx="12" cy="8" r="4" /> <path d="M4 21c1-4 5-6 8-6s7 2 8 6" /> </svg> ); } export function IconPaw({ size = 12, color = 'currentColor' }) { return ( <svg viewBox="0 0 24 24" width={size} height={size} fill={color}> <ellipse cx="6" cy="10" rx="2" ry="2.5" /> <ellipse cx="10" cy="6" rx="2" ry="2.5" /> <ellipse cx="14" cy="6" rx="2" ry="2.5" /> <ellipse cx="18" cy="10" rx="2" ry="2.5" /> <path d="M8 14c0-2 2-3 4-3s4 1 4 3-2 5-4 5-4-3-4-5z" /> </svg> ); } export function IconChevron({ size = 14, color = 'currentColor', dir = 'right' }) { const rotate = { right: 0, down: 90, left: 180, up: 270 }[dir] || 0; return ( <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ transform: `rotate(${rotate}deg)` }}> <path d="M9 6l6 6-6 6" /> </svg> ); } export function IconSparkle({ size = 12, color = 'currentColor' }) { return ( <svg viewBox="0 0 24 24" width={size} height={size} fill={color}> <path d="M12 2l2 7 7 2-7 2-2 7-2-7-7-2 7-2z" /> </svg> ); } ``` - [ ] **Step 2: Commit** ```bash git add web-ui/src/pages/saju/_shell/Icons.jsx git commit -m "feat(saju-ui-v2): Icons.jsx — 5 nav + IconPaw/Chevron/Sparkle" ``` --- ## Task 1.7: Mascot.jsx + 테스트 **Files:** - Create: `web-ui/src/pages/saju/_shell/Mascot.jsx` - Test: `web-ui/src/pages/saju/_shell/Mascot.test.jsx` - [ ] **Step 1: 실패하는 테스트 작성** ```jsx // Mascot.test.jsx import React from 'react'; import { render } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import Mascot from './Mascot'; describe('Mascot', () => { const VARIANTS = ['full', 'head', 'upper', 'greeting', 'thinking', 'pointing', 'happy']; VARIANTS.forEach((v) => { it(`renders variant=${v} with correct src`, () => { const { container } = render(<Mascot variant={v} size={100} />); const img = container.querySelector('img'); expect(img).toBeTruthy(); expect(img.getAttribute('src')).toContain('/images/saju/horyung/'); }); }); }); ``` - [ ] **Step 2: 테스트 실행 — 실패 확인** Run: `cd web-ui && npx vitest run src/pages/saju/_shell/Mascot.test.jsx` Expected: FAIL — module not found - [ ] **Step 3: Mascot.jsx 작성** ```jsx import React from 'react'; const VARIANT_TO_SRC = { full: '/images/saju/horyung/horyung-main.png', head: '/images/saju/horyung/horyung-bust.png', upper: '/images/saju/horyung/horyung-front.png', greeting: '/images/saju/horyung/horyung-greeting.png', thinking: '/images/saju/horyung/horyung-thinking.png', pointing: '/images/saju/horyung/horyung-pointing.png', happy: '/images/saju/horyung/horyung-happy.png', }; export default function Mascot({ variant = 'full', size = 200, style = {}, alt = '호령' }) { const src = VARIANT_TO_SRC[variant] || VARIANT_TO_SRC.full; return ( <img src={src} alt={alt} width={size} loading="lazy" style={{ display: 'block', ...style }} /> ); } ``` - [ ] **Step 4: 테스트 통과 확인** Run: `cd web-ui && npx vitest run src/pages/saju/_shell/Mascot.test.jsx` Expected: 7 passed - [ ] **Step 5: Commit** ```bash git add web-ui/src/pages/saju/_shell/Mascot.jsx web-ui/src/pages/saju/_shell/Mascot.test.jsx git commit -m "feat(saju-ui-v2): Mascot.jsx + 7 variant 매핑 test" ``` --- ## Task 1.8: MascotBubble.jsx **Files:** - Create: `web-ui/src/pages/saju/_shell/MascotBubble.jsx` - [ ] **Step 1: MascotBubble.jsx 작성 — 디자인 프로토타입 `common.jsx`의 동명 함수 포팅** ```jsx import React from 'react'; import { IconPaw } from './Icons'; const PALETTES = { ivory: { bg: '#FBF7EF', border: 'rgba(31,42,68,0.12)', text: '#1F2A44', paw: '#B89530' }, navy: { bg: 'rgba(255,255,255,0.06)', border: 'rgba(212,175,55,0.35)', text: '#F7F2E8', paw: '#D4AF37' }, green: { bg: '#FBF7EF', border: 'rgba(78,107,92,0.30)', text: '#1F2A44', paw: '#B89530' }, purple: { bg: '#FBF7EF', border: 'rgba(106,76,124,0.30)', text: '#1F2A44', paw: '#B89530' }, }; export default function MascotBubble({ text, align = 'left', tone = 'ivory', tail = true, paw = true, style = {}, }) { const p = PALETTES[tone] || PALETTES.ivory; return ( <div style={{ position: 'relative', background: p.bg, color: p.text, border: `1px solid ${p.border}`, borderRadius: 14, padding: '12px 14px', fontSize: 13, lineHeight: 1.55, letterSpacing: '-0.01em', maxWidth: 240, boxShadow: '0 2px 6px rgba(31,42,68,0.04)', ...style, }}> <div style={{ whiteSpace: 'pre-line' }}>{text}</div> {paw && ( <div style={{ marginTop: 4, textAlign: 'right', color: p.paw, opacity: 0.8 }}> <span className="paw-bob"><IconPaw size={12} color={p.paw} /></span> </div> )} {tail && ( <div style={{ position: 'absolute', bottom: -7, [align]: 22, width: 14, height: 14, background: p.bg, borderRight: `1px solid ${p.border}`, borderBottom: `1px solid ${p.border}`, transform: 'rotate(45deg)', }} /> )} </div> ); } ``` - [ ] **Step 2: Commit** ```bash git add web-ui/src/pages/saju/_shell/MascotBubble.jsx git commit -m "feat(saju-ui-v2): MascotBubble.jsx — 4 tone (ivory/navy/green/purple) + paw-bob" ``` --- ## Task 1.9: OrnamentBloom + TopRibbon + OrnateFrame + TitleBlock **Files:** - Create: `web-ui/src/pages/saju/_shell/OrnamentBloom.jsx` - Create: `web-ui/src/pages/saju/_shell/TopRibbon.jsx` - Create: `web-ui/src/pages/saju/_shell/OrnateFrame.jsx` - Create: `web-ui/src/pages/saju/_shell/TitleBlock.jsx` - [ ] **Step 1: OrnamentBloom.jsx 작성** ```jsx import React from 'react'; export default function OrnamentBloom({ size = 18, color = '#D4AF37' }) { return ( <svg width={size} height={size} viewBox="0 0 18 18" fill="none"> <circle cx="9" cy="9" r="2.4" fill={color} /> {[0, 60, 120, 180, 240, 300].map((angle) => ( <ellipse key={angle} cx="9" cy="4" rx="1.6" ry="3" fill={color} opacity="0.7" transform={`rotate(${angle} 9 9)`} /> ))} </svg> ); } ``` - [ ] **Step 2: TopRibbon.jsx 작성** ```jsx import React from 'react'; function CloudOrnament({ width = 90, color = '#D4AF37', opacity = 0.85 }) { return ( <svg width={width} height={width / 3.5} viewBox="0 0 90 26" fill="none" opacity={opacity}> <path d="M5 18 Q12 6 24 12 Q36 4 48 14 Q60 6 72 14 Q82 8 88 18" stroke={color} strokeWidth="1" fill="none" /> <circle cx="24" cy="12" r="1.4" fill={color} /> <circle cx="48" cy="14" r="1.4" fill={color} /> <circle cx="72" cy="14" r="1.4" fill={color} /> </svg> ); } export default function TopRibbon({ color = '#D4AF37', opacity = 0.5 }) { return ( <div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 0', opacity }}> <CloudOrnament width={90} color={color} opacity={0.85} /> </div> ); } ``` - [ ] **Step 3: OrnateFrame.jsx 작성** ```jsx import React from 'react'; import hexA from './helpers/hexA'; export default function OrnateFrame({ children, color = '#D4AF37', bg = 'transparent', radius = 14, padding = '20px', style = {}, double = false, }) { return ( <div style={{ position: 'relative', borderRadius: radius, background: bg, padding, border: `1px solid ${hexA(color, 0.45)}`, boxShadow: 'var(--shadow-card)', ...style, }}> {double && ( <div style={{ position: 'absolute', inset: 4, borderRadius: radius - 4, border: `1px solid ${hexA(color, 0.3)}`, pointerEvents: 'none', }} /> )} {[[0,0,0],[0,1,90],[1,1,180],[1,0,270]].map(([x,y,r], i) => ( <svg key={i} width="12" height="12" viewBox="0 0 12 12" style={{ position: 'absolute', [x ? 'right' : 'left']: 6, [y ? 'bottom' : 'top']: 6, transform: `rotate(${r}deg)`, pointerEvents: 'none', }}> <path d="M0 4 L0 0 L4 0" stroke={color} strokeWidth="1.2" fill="none" strokeLinecap="round" /> </svg> ))} <div style={{ position: 'relative', zIndex: 1 }}>{children}</div> </div> ); } ``` - [ ] **Step 4: TitleBlock.jsx 작성** ```jsx import React from 'react'; import OrnamentBloom from './OrnamentBloom'; export default function TitleBlock({ title, subtitle, color = '#1F2A44', subColor = '#6B6B6B', center = true, withBloom = true, gold = '#D4AF37', }) { return ( <div style={{ textAlign: center ? 'center' : 'left' }}> {withBloom && center && ( <div style={{ display: 'flex', justifyContent: 'center', gap: 12, alignItems: 'center', marginBottom: 10, color: gold, }}> <svg width="40" height="6" viewBox="0 0 40 6"> <path d="M0 3 L36 3" stroke={gold} strokeWidth="1" /> <circle cx="38" cy="3" r="1.5" fill={gold} /> </svg> <OrnamentBloom size={18} color={gold} /> <svg width="40" height="6" viewBox="0 0 40 6"> <circle cx="2" cy="3" r="1.5" fill={gold} /> <path d="M4 3 L40 3" stroke={gold} strokeWidth="1" /> </svg> </div> )} <h1 className="font-title" style={{ margin: 0, fontSize: 30, color, letterSpacing: '-0.02em', }}>{title}</h1> {subtitle && ( <div style={{ marginTop: 6, fontSize: 13, color: subColor, lineHeight: 1.55, letterSpacing: '-0.01em', }}>{subtitle}</div> )} </div> ); } ``` - [ ] **Step 5: Commit** ```bash git add web-ui/src/pages/saju/_shell/OrnamentBloom.jsx web-ui/src/pages/saju/_shell/TopRibbon.jsx web-ui/src/pages/saju/_shell/OrnateFrame.jsx web-ui/src/pages/saju/_shell/TitleBlock.jsx git commit -m "feat(saju-ui-v2): OrnamentBloom + TopRibbon + OrnateFrame + TitleBlock" ``` --- ## Task 1.10: PrimaryButton + GhostButton + InputRow **Files:** - Create: `web-ui/src/pages/saju/_shell/PrimaryButton.jsx` - Create: `web-ui/src/pages/saju/_shell/GhostButton.jsx` - Create: `web-ui/src/pages/saju/_shell/InputRow.jsx` - [ ] **Step 1: PrimaryButton.jsx 작성** ```jsx import React from 'react'; import hexA from './helpers/hexA'; export default function PrimaryButton({ children, color = '#1F2A44', onClick, full = true, style = {}, gold = true, type = 'button', }) { return ( <button type={type} onClick={onClick} style={{ width: full ? '100%' : 'auto', padding: '14px 22px', background: color, color: '#F7F2E8', border: 'none', borderRadius: 12, fontSize: 15, fontWeight: 700, letterSpacing: '-0.01em', boxShadow: gold ? `0 2px 0 ${hexA(color, 0.4)}, 0 6px 18px ${hexA(color, 0.25)}, inset 0 1px 0 rgba(212,175,55,0.4)` : '0 4px 14px rgba(31,42,68,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, ...style, }}> {children} </button> ); } ``` - [ ] **Step 2: GhostButton.jsx 작성** ```jsx import React from 'react'; import hexA from './helpers/hexA'; export default function GhostButton({ children, color = '#1F2A44', onClick, full = true, style = {}, type = 'button', }) { return ( <button type={type} onClick={onClick} style={{ width: full ? '100%' : 'auto', padding: '13px 22px', background: 'transparent', color, border: `1px solid ${hexA(color, 0.4)}`, borderRadius: 12, fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, ...style, }}> {children} </button> ); } ``` - [ ] **Step 3: InputRow.jsx 작성 (Phase 2 home에서 사용)** ```jsx import React from 'react'; export default function InputRow({ label, name, type = 'text', value, onChange, placeholder, error, children, }) { return ( <div style={{ display: 'flex', alignItems: 'center', padding: '12px 14px', borderBottom: '1px solid rgba(31,42,68,0.06)', gap: 12, }}> <label htmlFor={name} style={{ width: 80, fontSize: 12, color: '#6B6B6B', fontWeight: 700, letterSpacing: '-0.01em', }}>{label}</label> <div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8 }}> {children || ( <input id={name} name={name} type={type} value={value} onChange={onChange} placeholder={placeholder} style={{ flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)', borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44', fontFamily: 'inherit', }} /> )} {error && ( <span style={{ fontSize: 11, color: '#C04A4A', fontWeight: 700 }}>{error}</span> )} </div> </div> ); } ``` - [ ] **Step 4: Commit** ```bash git add web-ui/src/pages/saju/_shell/PrimaryButton.jsx web-ui/src/pages/saju/_shell/GhostButton.jsx web-ui/src/pages/saju/_shell/InputRow.jsx git commit -m "feat(saju-ui-v2): PrimaryButton + GhostButton + InputRow" ``` --- ## Task 1.11: BottomNav.jsx **Files:** - Create: `web-ui/src/pages/saju/_shell/BottomNav.jsx` - [ ] **Step 1: BottomNav.jsx 작성 — react-router NavLink 활용** ```jsx import React from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { IconHome, IconSun, IconHeart, IconYinYang, IconUser } from './Icons'; import hexA from './helpers/hexA'; const NAV_ITEMS = [ { id: 'home', to: '/saju', label: '홈', Icon: IconHome, accent: '#1F2A44' }, { id: 'today', to: '/saju/today', label: '오늘의 운세', Icon: IconSun, accent: '#D4AF37' }, { id: 'match', to: '/saju/compatibility', label: '궁합보기', Icon: IconHeart, accent: '#4E6B5C' }, { id: 'saju', to: '/saju/result', label: '사주풀이', Icon: IconYinYang, accent: '#6A4C7C' }, { id: 'me', to: '/saju/me', label: '마이페이지', Icon: IconUser, accent: '#6B6B6B' }, ]; function pathToCurrent(pathname) { if (pathname === '/saju' || pathname === '/saju/') return 'home'; if (pathname.startsWith('/saju/today')) return 'today'; if (pathname.startsWith('/saju/compatibility')) return 'match'; if (pathname.startsWith('/saju/result')) return 'saju'; if (pathname.startsWith('/saju/me')) return 'me'; return 'home'; } export default function BottomNav({ theme = 'ivory' }) { const navigate = useNavigate(); const { pathname } = useLocation(); const current = pathToCurrent(pathname); const isDark = theme === 'navy'; const bg = isDark ? 'rgba(20,27,48,0.92)' : '#FBF7EF'; const border = isDark ? 'rgba(212,175,55,0.18)' : 'rgba(31,42,68,0.08)'; const inactive = isDark ? 'rgba(247,242,232,0.55)' : '#9A968D'; return ( <nav aria-label="사주 메뉴" style={{ position: 'fixed', left: 0, right: 0, bottom: 0, paddingBottom: 'max(16px, env(safe-area-inset-bottom))', paddingTop: 8, background: bg, borderTop: `1px solid ${border}`, backdropFilter: 'blur(14px) saturate(140%)', WebkitBackdropFilter: 'blur(14px) saturate(140%)', zIndex: 30, }}> <div style={{ display: 'flex', justifyContent: 'space-around', alignItems: 'flex-end', padding: '0 6px' }}> {NAV_ITEMS.map((item) => { const active = item.id === current; const activeColor = isDark ? (item.id === 'today' ? '#E8C76B' : item.accent === '#1F2A44' ? '#F7F2E8' : item.accent) : item.accent; const color = active ? activeColor : inactive; return ( <button key={item.id} onClick={() => navigate(item.to)} aria-label={item.label} aria-current={active ? 'page' : undefined} style={{ background: 'transparent', border: 'none', padding: '6px 4px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, color, flex: 1, minWidth: 0, position: 'relative', transition: 'color .2s', }}> <span style={{ width: 36, height: 28, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center', background: active ? hexA(item.accent, isDark ? 0.18 : 0.10) : 'transparent', transition: 'background .2s', }}> <item.Icon size={20} stroke={color} strokeWidth={active ? 1.8 : 1.5} /> </span> <span style={{ fontSize: 9.5, fontWeight: active ? 700 : 500, letterSpacing: '-0.04em', whiteSpace: 'nowrap', }}>{item.label}</span> </button> ); })} </div> </nav> ); } export { NAV_ITEMS }; ``` - [ ] **Step 2: Commit** ```bash git add web-ui/src/pages/saju/_shell/BottomNav.jsx git commit -m "feat(saju-ui-v2): BottomNav.jsx — 5 항목 + safe-area + active accent" ``` --- ## Task 1.12: DesktopHeader.jsx **Files:** - Create: `web-ui/src/pages/saju/_shell/DesktopHeader.jsx` - [ ] **Step 1: DesktopHeader.jsx 작성** ```jsx import React from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { NAV_ITEMS } from './BottomNav'; function pathToCurrent(pathname) { if (pathname === '/saju' || pathname === '/saju/') return 'home'; if (pathname.startsWith('/saju/today')) return 'today'; if (pathname.startsWith('/saju/compatibility')) return 'match'; if (pathname.startsWith('/saju/result')) return 'saju'; if (pathname.startsWith('/saju/me')) return 'me'; return 'home'; } export default function DesktopHeader() { const navigate = useNavigate(); const { pathname } = useLocation(); const current = pathToCurrent(pathname); return ( <header style={{ position: 'sticky', top: 0, zIndex: 30, height: 64, background: '#FBF7EF', borderBottom: '1px solid rgba(31,42,68,0.10)', display: 'flex', alignItems: 'center', padding: '0 32px', backdropFilter: 'blur(14px)', }}> <button onClick={() => navigate('/saju')} style={{ background: 'transparent', border: 'none', display: 'flex', alignItems: 'center', gap: 10, }}> <span className="font-title" style={{ fontSize: 26, color: '#D4AF37', lineHeight: 1, }}>壽</span> <span className="font-title" style={{ fontSize: 18, color: '#1F2A44', letterSpacing: '-0.02em', }}>호령사주</span> </button> <nav aria-label="사주 메뉴" style={{ marginLeft: 40, display: 'flex', gap: 8 }}> {NAV_ITEMS.map((item) => { const active = item.id === current; return ( <button key={item.id} onClick={() => navigate(item.to)} aria-current={active ? 'page' : undefined} style={{ background: active ? 'rgba(31,42,68,0.06)' : 'transparent', border: 'none', padding: '8px 14px', borderRadius: 8, color: active ? item.accent : '#6B6B6B', fontSize: 13, fontWeight: active ? 700 : 500, letterSpacing: '-0.02em', display: 'flex', alignItems: 'center', gap: 6, }}> <item.Icon size={16} stroke={active ? item.accent : '#9A968D'} strokeWidth={active ? 1.8 : 1.5} /> {item.label} </button> ); })} </nav> </header> ); } ``` - [ ] **Step 2: Commit** ```bash git add web-ui/src/pages/saju/_shell/DesktopHeader.jsx git commit -m "feat(saju-ui-v2): DesktopHeader.jsx — 로고 + 5 항목 horizontal nav" ``` --- ## Task 1.13: Me.jsx (placeholder) **Files:** - Create: `web-ui/src/pages/saju/Me.jsx` - [ ] **Step 1: Me.jsx 작성** ```jsx import React from 'react'; import './_shell/tokens.css'; import './_shell/shell.css'; import useViewportMode from './_shell/useViewportMode'; import BottomNav from './_shell/BottomNav'; import DesktopHeader from './_shell/DesktopHeader'; import TopRibbon from './_shell/TopRibbon'; import Mascot from './_shell/Mascot'; import MascotBubble from './_shell/MascotBubble'; import OrnateFrame from './_shell/OrnateFrame'; const DISABLED_CARDS = [ { title: '내 사주 이력', desc: '저장된 풀이를 한 번에' }, { title: '북마크', desc: '관심 가는 해석 즐겨찾기' }, { title: '설정', desc: '알림·테마·계정' }, { title: '문의', desc: '호령이 듣고 있어요' }, ]; export default function Me() { const mode = useViewportMode(); return ( <div className="saju-v2"> {mode === 'desktop' && <DesktopHeader />} <main className="page paper-bg screen-in"> <TopRibbon /> <div style={{ maxWidth: mode === 'desktop' ? 720 : 'none', margin: '0 auto', padding: '24px 20px 40px', textAlign: 'center', }}> <Mascot variant="thinking" size={140} style={{ margin: '0 auto 12px' }} /> <MascotBubble tone="purple" tail={false} text={'마이페이지는 곧 만나요.\n조금만 기다려주세요.'} style={{ margin: '0 auto 24px' }} /> <div style={{ display: 'grid', gap: 12 }}> {DISABLED_CARDS.map((card) => ( <OrnateFrame key={card.title} color="#6A4C7C" bg="#FBF7EF" padding="18px 16px" style={{ opacity: 0.55, textAlign: 'left' }}> <div className="font-title" style={{ fontSize: 16, color: '#1F2A44' }}> {card.title} </div> <div style={{ marginTop: 6, fontSize: 12, color: '#6B6B6B' }}>{card.desc}</div> </OrnateFrame> ))} </div> </div> </main> {mode === 'mobile' && <BottomNav theme="ivory" />} </div> ); } ``` - [ ] **Step 2: Commit** ```bash git add web-ui/src/pages/saju/Me.jsx git commit -m "feat(saju-ui-v2): Me.jsx — placeholder + 비활성 4 카드" ``` --- ## Task 1.14: routes.jsx 수정 **Files:** - Modify: `web-ui/src/routes.jsx` - [ ] **Step 1: routes.jsx에 Me lazy import + 라우트 추가** `pages/saju/Compatibility` import 다음 줄에: ```js const SajuMe = lazy(() => import('./pages/saju/Me')); ``` `/saju` children 배열의 `compatibility/result` 다음에: ```js { path: 'me', element: <SajuMe /> }, ``` - [ ] **Step 2: dev server에서 진입 확인** Run: `cd web-ui && npm run dev` Open `http://localhost:3007/saju/me` → Me placeholder 화면 + BottomNav 5 항목 + me 활성 표시 + 폰트 적용 확인. 1280px 브라우저로 늘려 DesktopHeader 전환 확인. - [ ] **Step 3: Commit** ```bash git add web-ui/src/routes.jsx git commit -m "feat(saju-ui-v2): /saju/me 라우트 + Me 컴포넌트 lazy import" ``` --- ## Task 1.15: Phase 1 시각 검증 + Phase commit tag - [ ] **Step 1: 기존 4 페이지 무손상 확인** `http://localhost:3007/saju` (v1 호령 메인), `/saju/result?rid=1` (v1 결과), `/saju/today?rid=1` (v1 오늘), `/saju/compatibility` (v1 placeholder) 모두 정상 렌더 — v2 변경이 영향 X. - [ ] **Step 2: /saju/me 새 페이지 골든 패스** - 모바일 (chrome devtools 390×844): paper-bg, TopRibbon, thinking Mascot, MascotBubble, 4 비활성 카드, BottomNav 하단 fixed + safe-area, me 항목 활성 표시 - 데스크탑 (1280×720): DesktopHeader 상단 sticky, 5 항목 nav, me 활성, BottomNav 없음 - 1023px ↔ 1024px 경계에서 자동 전환 - [ ] **Step 3: Phase 1 종료 tag commit (선택)** ```bash git tag saju-ui-v2-phase1 ``` --- # Phase 2 — Home (`/saju`) 기존 `Saju.jsx`를 디자인 v2로 교체. ## Task 2.1: home.mobile.jsx **Files:** - Create: `web-ui/src/pages/saju/views/home.mobile.jsx` - [ ] **Step 1: home.mobile.jsx 작성** 디자인 프로토타입의 `screen-home.jsx`(파일 미참조 — 디자인의 컬러/구조 기반 재구성) 패턴 따라 night-bg + 호령 hero + 3 ActionCard + 입력 폼: ```jsx import React from 'react'; import { useNavigate } from 'react-router-dom'; import TopRibbon from '../_shell/TopRibbon'; import TitleBlock from '../_shell/TitleBlock'; import Mascot from '../_shell/Mascot'; import MascotBubble from '../_shell/MascotBubble'; import OrnateFrame from '../_shell/OrnateFrame'; import PrimaryButton from '../_shell/PrimaryButton'; import InputRow from '../_shell/InputRow'; import { IconChevron, IconSparkle, IconSun, IconHeart, IconYinYang } from '../_shell/Icons'; import useSajuForm from '../hooks/useSajuForm'; const ACTIONS = [ { to: '/saju/today', icon: IconSun, label: '오늘의 운세', color: '#D4AF37' }, { to: '/saju/compatibility', icon: IconHeart, label: '궁합보기', color: '#4E6B5C' }, { to: '/saju/result', icon: IconYinYang, label: '사주풀이', color: '#6A4C7C' }, ]; export default function HomeMobile() { const navigate = useNavigate(); const { form, handleChange, handleSubmit, loading, error } = useSajuForm(); return ( <main className="page night-bg screen-in" style={{ paddingTop: 24 }}> <TopRibbon color="#D4AF37" opacity={0.7} /> <div style={{ padding: '8px 24px 0', textAlign: 'center', color: '#F7F2E8' }}> <TitleBlock color="#F7F2E8" subColor="rgba(247,242,232,0.7)" title="호령이 안내하는 사주" subtitle="오랜 명리학 지혜와 AI 인사이트로 당신만의 길을 비춥니다." /> </div> <div style={{ padding: '24px 20px 0', display: 'flex', gap: 12, alignItems: 'flex-end' }}> <MascotBubble tone="navy" align="left" text={'안녕하세요!\n저는 호령이에요.\n사주를 입력해 보실래요?'} style={{ flex: 1, marginBottom: 8 }} /> <Mascot variant="full" size={140} style={{ marginRight: -8 }} /> </div> <div style={{ padding: '24px 20px 0', display: 'grid', gap: 10 }}> {ACTIONS.map((a) => ( <button key={a.to} onClick={() => navigate(a.to)} style={{ display: 'flex', alignItems: 'center', gap: 12, background: 'rgba(247,242,232,0.06)', border: `1px solid ${a.color}55`, borderRadius: 12, padding: '14px 16px', color: '#F7F2E8', fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em', }}> <a.icon size={20} stroke={a.color} strokeWidth={1.8} /> <span style={{ flex: 1, textAlign: 'left' }}>{a.label}</span> <IconChevron dir="right" size={14} color="#E8C76B" /> </button> ))} </div> <div style={{ padding: '24px 20px 40px' }}> <OrnateFrame color="#D4AF37" bg="#FBF7EF" double radius={16}> <form onSubmit={handleSubmit}> <div className="font-title" style={{ fontSize: 16, color: '#1F2A44', marginBottom: 8, textAlign: 'center', }}>사주 입력</div> <InputRow label="이름" name="name" value={form.name} onChange={handleChange} placeholder="홍길동" /> <InputRow label="생년월일" name="birthDate" type="date" value={form.birthDate} onChange={handleChange} /> <InputRow label="시간" name="birthTime" type="time" value={form.birthTime} onChange={handleChange} /> <InputRow label="성별"> <select name="gender" value={form.gender} onChange={handleChange} style={{ flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)', borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44' }}> <option value="male">남</option> <option value="female">여</option> </select> </InputRow> <InputRow label="달력"> <select name="calendar" value={form.calendar} onChange={handleChange} style={{ flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)', borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44' }}> <option value="solar">양력</option> <option value="lunar">음력</option> </select> </InputRow> {error && ( <div style={{ padding: '10px 14px', color: '#C04A4A', fontSize: 12 }}>{error}</div> )} <div style={{ padding: '14px 14px 6px' }}> <PrimaryButton color="#6A4C7C" type="submit"> {loading ? '호령이 풀이 중...' : '내 사주 보기'} {!loading && <IconSparkle size={12} color="#E8C76B" />} </PrimaryButton> </div> </form> </OrnateFrame> </div> </main> ); } ``` - [ ] **Step 2: Commit** ```bash git add web-ui/src/pages/saju/views/home.mobile.jsx git commit -m "feat(saju-ui-v2): home.mobile.jsx — night hero + ActionCard×3 + 입력 폼" ``` --- ## Task 2.2: home.desktop.jsx **Files:** - Create: `web-ui/src/pages/saju/views/home.desktop.jsx` - [ ] **Step 1: home.desktop.jsx 작성** 데스크탑은 mt-wash 산수화 배경 + 2-column hero (좌 호령, 우 입력): ```jsx import React from 'react'; import { useNavigate } from 'react-router-dom'; import TitleBlock from '../_shell/TitleBlock'; import Mascot from '../_shell/Mascot'; import MascotBubble from '../_shell/MascotBubble'; import OrnateFrame from '../_shell/OrnateFrame'; import PrimaryButton from '../_shell/PrimaryButton'; import InputRow from '../_shell/InputRow'; import { IconSparkle, IconChevron, IconSun, IconHeart, IconYinYang } from '../_shell/Icons'; import useSajuForm from '../hooks/useSajuForm'; const ACTIONS = [ { to: '/saju/today', icon: IconSun, label: '오늘의 운세', desc: '오늘 한 줄로 보는 운세', color: '#D4AF37' }, { to: '/saju/compatibility', icon: IconHeart, label: '궁합보기', desc: '두 사람의 만남 풀이', color: '#4E6B5C' }, { to: '/saju/result', icon: IconYinYang, label: '사주풀이', desc: '내 사주 자세히', color: '#6A4C7C' }, ]; export default function HomeDesktop() { const navigate = useNavigate(); const { form, handleChange, handleSubmit, loading, error } = useSajuForm(); return ( <main className="page mt-wash screen-in"> <div style={{ maxWidth: 1200, margin: '0 auto', padding: '48px 32px' }}> <TitleBlock title="호령이 안내하는 사주" subtitle="오랜 명리학 지혜와 AI 인사이트로 당신만의 길을 비춥니다." /> <div style={{ marginTop: 32, display: 'grid', gridTemplateColumns: '1fr 480px', gap: 40, alignItems: 'start', }}> <div> <div style={{ display: 'flex', alignItems: 'flex-end', gap: 16 }}> <Mascot variant="full" size={260} /> <MascotBubble tone="ivory" align="left" text={'안녕하세요!\n저는 호령이에요.\n사주를 입력해 보실래요?'} style={{ marginBottom: 20 }} /> </div> <div style={{ marginTop: 32, display: 'grid', gap: 12 }}> {ACTIONS.map((a) => ( <button key={a.to} onClick={() => navigate(a.to)} style={{ display: 'flex', alignItems: 'center', gap: 16, background: '#FBF7EF', border: `1px solid ${a.color}40`, borderRadius: 12, padding: '16px 20px', color: '#1F2A44', fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em', textAlign: 'left', boxShadow: 'var(--shadow-card)', }}> <a.icon size={24} stroke={a.color} strokeWidth={1.8} /> <span style={{ flex: 1 }}> <div style={{ fontSize: 15 }}>{a.label}</div> <div style={{ fontSize: 12, color: '#6B6B6B', fontWeight: 500, marginTop: 2 }}>{a.desc}</div> </span> <IconChevron dir="right" size={16} color="#B89530" /> </button> ))} </div> </div> <OrnateFrame color="#D4AF37" bg="#FBF7EF" double radius={16} padding="24px 22px"> <form onSubmit={handleSubmit}> <div className="font-title" style={{ fontSize: 18, color: '#1F2A44', marginBottom: 12, textAlign: 'center', }}>사주 입력</div> <InputRow label="이름" name="name" value={form.name} onChange={handleChange} placeholder="홍길동" /> <InputRow label="생년월일" name="birthDate" type="date" value={form.birthDate} onChange={handleChange} /> <InputRow label="시간" name="birthTime" type="time" value={form.birthTime} onChange={handleChange} /> <InputRow label="성별"> <select name="gender" value={form.gender} onChange={handleChange} style={{ flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)', borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44' }}> <option value="male">남</option> <option value="female">여</option> </select> </InputRow> <InputRow label="달력"> <select name="calendar" value={form.calendar} onChange={handleChange} style={{ flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)', borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44' }}> <option value="solar">양력</option> <option value="lunar">음력</option> </select> </InputRow> {error && ( <div style={{ padding: '10px 14px', color: '#C04A4A', fontSize: 12 }}>{error}</div> )} <div style={{ padding: '14px 14px 6px' }}> <PrimaryButton color="#6A4C7C" type="submit"> {loading ? '호령이 풀이 중...' : '내 사주 보기'} {!loading && <IconSparkle size={12} color="#E8C76B" />} </PrimaryButton> </div> </form> </OrnateFrame> </div> </div> </main> ); } ``` - [ ] **Step 2: Commit** ```bash git add web-ui/src/pages/saju/views/home.desktop.jsx git commit -m "feat(saju-ui-v2): home.desktop.jsx — mt-wash 산수화 + 2-column hero" ``` --- ## Task 2.3: Saju.jsx (routes 진입) 교체 **Files:** - Modify: `web-ui/src/pages/saju/Saju.jsx` (전면 교체) - [ ] **Step 1: Saju.jsx 본문 전면 교체** ```jsx import React from 'react'; import './_shell/tokens.css'; import './_shell/shell.css'; import useViewportMode from './_shell/useViewportMode'; import BottomNav from './_shell/BottomNav'; import DesktopHeader from './_shell/DesktopHeader'; import HomeMobile from './views/home.mobile.jsx'; import HomeDesktop from './views/home.desktop.jsx'; export default function Saju() { const mode = useViewportMode(); return ( <div className="saju-v2"> {mode === 'desktop' ? <DesktopHeader /> : null} {mode === 'desktop' ? <HomeDesktop /> : <HomeMobile />} {mode === 'mobile' ? <BottomNav theme="navy" /> : null} </div> ); } ``` - [ ] **Step 2: useSajuForm hook 시그니처 확인** Run: `cat web-ui/src/pages/saju/hooks/useSajuForm.js` 필드명(form.name, birthDate, birthTime, gender, calendar) + handleChange + handleSubmit + loading + error 5종이 home에서 사용하는 시그니처와 일치하는지 확인. 다르면 home.mobile/desktop의 input name과 useSajuForm의 state 키를 맞춤 (Task 2.1/2.2 inline fix). - [ ] **Step 3: dev server 시각 검증** `http://localhost:3007/saju`: - 모바일 (390×844): night-bg 어두운 hero, MascotBubble navy tone, 호령 PNG, 3 ActionCard, OrnateFrame 입력 폼, BottomNav home 활성 - 데스크탑 (1280×720): mt-wash 산수화, 2-column, DesktopHeader home 활성 - 입력 후 "내 사주 보기" submit → `/saju/result?rid=N` 이동 확인 (백엔드 API 호출) - [ ] **Step 4: Commit** ```bash git add web-ui/src/pages/saju/Saju.jsx git commit -m "feat(saju-ui-v2): Saju.jsx — useViewportMode 분기 + Home v2 진입" ``` - [ ] **Step 5: Phase 2 종료 tag (선택)** ```bash git tag saju-ui-v2-phase2 ``` --- # Phase 3 — SajuResult 4탭 (`/saju/result`) ## Task 3.1: saju.mobile.jsx — 탭 컨테이너 + BasicTab **Files:** - Create: `web-ui/src/pages/saju/views/saju.mobile.jsx` - [ ] **Step 1: saju.mobile.jsx 골격 + BasicTab 작성** reading 데이터를 prop으로 받고 4탭 + 첫 탭(BasicTab) 구현: ```jsx import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import TopRibbon from '../_shell/TopRibbon'; import TitleBlock from '../_shell/TitleBlock'; import Mascot from '../_shell/Mascot'; import MascotBubble from '../_shell/MascotBubble'; import OrnateFrame from '../_shell/OrnateFrame'; import OrnamentBloom from '../_shell/OrnamentBloom'; import PrimaryButton from '../_shell/PrimaryButton'; import { IconChevron, IconSparkle } from '../_shell/Icons'; import deriveTraits from '../_shell/helpers/deriveTraits'; import daeunLabel from '../_shell/helpers/daeunLabel'; import { elementColor, elementKo, elementCh } from '../_shell/helpers/colorMap'; import hexA from '../_shell/helpers/hexA'; const TABS = [ ['basic', '기본정보'], ['chart', '사주명식'], ['flow', '운세흐름'], ['traits', '성향분석'], ]; export default function SajuMobile({ reading }) { const [tab, setTab] = useState('basic'); const navigate = useNavigate(); const traits = deriveTraits(reading?.analysis?.elements || {}, reading?.pillars || []); return ( <main className="page paper-bg screen-in"> <TopRibbon color="#6A4C7C" opacity={0.6} /> <div style={{ padding: '8px 24px 0', textAlign: 'center' }}> <TitleBlock gold="#6A4C7C" title="사주풀이" subtitle="당신의 사주를 자세히 풀이해드립니다." /> </div> <div style={{ padding: '14px 20px 0', display: 'flex', gap: 8, alignItems: 'flex-end' }}> <MascotBubble tone="purple" text={'당신이 가진 타고난\n기운과 운명의 흐름을\n알려드릴게요.'} style={{ flex: 1, marginBottom: 8 }} /> <Mascot variant="full" size={130} style={{ marginRight: -8 }} /> </div> <div style={{ padding: '14px 16px 0' }}> <div className="no-scrollbar" style={{ display: 'flex', gap: 6, overflowX: 'auto', background: 'rgba(247,242,232,0.7)', borderRadius: 999, padding: 4, border: '1px solid rgba(31,42,68,0.08)', }}> {TABS.map(([id, label]) => { const active = tab === id; return ( <button key={id} onClick={() => setTab(id)} style={{ flex: 1, padding: '10px 8px', borderRadius: 999, border: 'none', background: active ? '#1F2A44' : 'transparent', color: active ? '#F7F2E8' : '#6B6B6B', fontSize: 12, fontWeight: 700, letterSpacing: '-0.02em', whiteSpace: 'nowrap', boxShadow: active ? '0 2px 8px rgba(31,42,68,0.25), inset 0 1px 0 rgba(212,175,55,0.3)' : 'none', transition: 'all .2s', }}>{label}</button> ); })} </div> </div> <div style={{ padding: '14px 20px 0' }}> {tab === 'basic' && <BasicTab reading={reading} traits={traits} onResult={() => setTab('chart')} />} {tab === 'chart' && <ChartTab reading={reading} />} {tab === 'flow' && <FlowTab reading={reading} />} {tab === 'traits' && <TraitsTab traits={traits} onToday={() => navigate('/saju/today')} />} </div> </main> ); } function BasicTab({ reading, traits, onResult }) { const i = reading?.input || {}; const rows = [ ['이름', i.name], ['생년월일', i.birthDate], ['성별', i.gender === 'female' ? '여' : '남'], ['시간', i.birthTime], ['출생지', i.birthPlace || '-'], ['사주', reading?.label || '-'], ]; const summary = reading?.interpretation?.summary || '풀이 결과를 준비 중입니다.'; return ( <div> <div style={{ background: '#FBF7EF', borderRadius: 14, border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)', overflow: 'hidden', }}> {rows.map(([label, value], idx) => ( <div key={label} style={{ display: 'flex', alignItems: 'center', padding: '13px 16px', borderBottom: idx === rows.length - 1 ? 'none' : '1px solid rgba(31,42,68,0.06)', }}> <div style={{ width: 64, fontSize: 12, color: '#6B6B6B', fontWeight: 700 }}>{label}</div> <div style={{ flex: 1, fontSize: 13, color: '#1F2A44' }}>{value || '-'}</div> </div> ))} </div> <OrnateFrame color="#6A4C7C" bg="#FBF7EF" radius={14} padding="20px 18px 16px" style={{ marginTop: 14 }}> <div className="font-title" style={{ fontSize: 13, color: '#6A4C7C', textAlign: 'center', marginBottom: 6, }}>사주 요약</div> <div style={{ fontSize: 13, color: '#1F2A44', lineHeight: 1.75, textAlign: 'center', whiteSpace: 'pre-line', }}>{summary}</div> </OrnateFrame> <div style={{ marginTop: 14, background: '#FBF7EF', borderRadius: 14, border: '1px solid rgba(31,42,68,0.10)', padding: '16px 12px', boxShadow: 'var(--shadow-card)', }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 6 }}> {traits.slice(0, 5).map((t) => (<TraitChip key={t.id} {...t} />))} </div> </div> <div style={{ marginTop: 14 }}> <PrimaryButton color="#6A4C7C" onClick={onResult}> 상세 풀이 보러가기 <IconChevron dir="right" size={14} color="#E8C76B" /> </PrimaryButton> </div> </div> ); } function TraitChip({ ko, color }) { return ( <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, padding: '10px 4px 8px', }}> <div style={{ width: 42, height: 42, borderRadius: '50%', background: hexA(color.replace('var(--', '').replace(')', '') === 'el-fire' ? '#C04A4A' : '#1F2A44', 0.10), border: `1px solid ${color}`, display: 'flex', alignItems: 'center', justifyContent: 'center', color, fontSize: 18, fontWeight: 700, }}>•</div> <span style={{ fontSize: 11, color: '#1F2A44', fontWeight: 700, letterSpacing: '-0.02em' }}>{ko}</span> </div> ); } function ChartTab({ reading }) { const pillars = reading?.pillars || []; const elements = reading?.analysis?.elements || {}; const ohaengArr = ['wood', 'fire', 'earth', 'metal', 'water'].map((id) => ({ id, ko: elementKo(id), ch: elementCh(id), value: Math.round(elements[id] || 0), color: elementColor(id), })); const strongest = ohaengArr.reduce((a, b) => (a.value > b.value ? a : b), { value: 0 }); return ( <div> <div style={{ background: '#FBF7EF', borderRadius: 14, border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)', padding: '14px 12px 12px', }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10, padding: '0 6px' }}> <OrnamentBloom size={14} color="#6A4C7C" /> <span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>사주 명식</span> <span style={{ fontSize: 10, color: '#9A968D', marginLeft: 'auto' }}>일간 중심 해석</span> </div> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}> {pillars.map((p) => (<PillarColumn key={p.id || p.label} pillar={p} />))} </div> </div> <div style={{ marginTop: 14, background: '#FBF7EF', borderRadius: 14, border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)', padding: '16px 16px 14px', }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}> <OrnamentBloom size={14} color="#6A4C7C" /> <span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>오행 분석</span> </div> <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', height: 110, gap: 4, }}> {ohaengArr.map((o) => (<OhaengBar key={o.id} {...o} />))} </div> {strongest.value > 0 && ( <div style={{ marginTop: 12, padding: '10px 12px', background: hexA('#C04A4A', 0.06), borderRadius: 8, border: '1px solid rgba(192,74,74,0.18)', }}> <div style={{ fontSize: 12, fontWeight: 700, color: strongest.color, marginBottom: 4 }}> {strongest.ko}({strongest.ch})의 기운이 강한 사주입니다. </div> </div> )} </div> </div> ); } function PillarColumn({ pillar }) { const p = pillar; const isDay = p.id === 'day' || p.label === '일주'; return ( <div style={{ borderRadius: 10, padding: '8px 4px 10px', background: isDay ? '#FBF7EF' : 'transparent', border: isDay ? '1.5px solid #6A4C7C' : '1px solid rgba(31,42,68,0.06)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, position: 'relative', }}> {isDay && ( <div style={{ position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)', background: '#6A4C7C', color: '#F7F2E8', fontSize: 10, fontWeight: 700, padding: '2px 10px', borderRadius: 99, }}>일간</div> )} <div style={{ fontSize: 10, color: '#6B6B6B', fontWeight: 700, marginTop: 2 }}>{p.label}</div> <CharBox char={p.cheongan?.ch} mark={p.cheongan?.mark} color={elementColor(p.cheongan?.element)} /> <CharBox char={p.jiji?.ch} mark={p.jiji?.mark} color={elementColor(p.jiji?.element)} /> <div style={{ width: '100%', height: 1, background: 'rgba(31,42,68,0.08)' }} /> <div style={{ fontSize: 10, color: '#6B6B6B' }}>{p.sipsin || '-'}</div> <div style={{ fontSize: 10, color: '#9A968D', letterSpacing: '0.04em' }}>{p.jijang || ''}</div> </div> ); } function CharBox({ char, mark, color }) { return ( <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}> <div className="font-title" style={{ fontSize: 24, color, lineHeight: 1, fontWeight: 800 }}>{char || '?'}</div> <div style={{ fontSize: 8.5, color, opacity: 0.85, fontWeight: 700 }}>{mark || ''}</div> </div> ); } function OhaengBar({ ko, ch, value, color }) { return ( <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%' }}> <div style={{ flex: 1, width: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}> <div style={{ fontSize: 10, color, fontWeight: 700, textAlign: 'center', marginBottom: 2 }}>{value}%</div> <div style={{ width: '70%', margin: '0 auto', height: `${value}%`, minHeight: 4, background: color, borderRadius: '6px 6px 2px 2px', boxShadow: `0 -2px 6px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.3)`, }} /> </div> <div style={{ marginTop: 6, fontSize: 11, color: '#1F2A44', fontWeight: 700 }}> {ko}<span style={{ fontSize: 9, color: '#9A968D', marginLeft: 2 }}>({ch})</span> </div> </div> ); } function FlowTab({ reading }) { const daeun = reading?.daeun || []; const currentAge = reading?.current_age; const enriched = daeun.map((d) => { const ageNum = parseInt(String(d.age).replace(/[^0-9]/g, ''), 10); return { ...d, label: daeunLabel(ageNum), current: currentAge != null && ageNum <= currentAge && currentAge < ageNum + 10, }; }); const current = enriched.find((x) => x.current); return ( <div> <div style={{ background: '#FBF7EF', borderRadius: 14, border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)', padding: '16px 14px', }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}> <OrnamentBloom size={14} color="#6A4C7C" /> <span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>대운 흐름</span> <span style={{ marginLeft: 'auto', fontSize: 10, color: '#9A968D' }}>10년 단위</span> </div> <div style={{ fontSize: 11, color: '#6B6B6B', marginBottom: 12 }}> 10년 주기로 변화하는 운의 흐름을 확인하세요. </div> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}> {enriched.map((du, i) => (<DaeunNode key={i} {...du} />))} </div> </div> {current && ( <div style={{ marginTop: 14, background: '#1F2A44', borderRadius: 14, border: '1px solid rgba(212,175,55,0.4)', padding: '16px 16px 18px', color: '#F7F2E8', boxShadow: '0 8px 24px rgba(31,42,68,0.2)', position: 'relative', }}> <div style={{ position: 'absolute', top: -10, left: 16, background: '#6A4C7C', color: '#F7F2E8', fontSize: 10, fontWeight: 700, padding: '3px 10px', borderRadius: 99, border: '1px solid rgba(212,175,55,0.5)', }}>현재 대운 · {current.age}</div> <div className="font-title" style={{ marginTop: 8, fontSize: 18, color: '#E8C76B' }}> {current.gan} · {current.label} </div> <div style={{ marginTop: 10, fontSize: 12.5, color: '#D9D2C0', lineHeight: 1.7 }}> {reading?.interpretation?.current_daeun || '대운 풀이를 준비 중입니다.'} </div> </div> )} </div> ); } function DaeunNode({ age, gan, label, current }) { return ( <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, position: 'relative' }}> {current && ( <div style={{ position: 'absolute', top: -6, left: '50%', transform: 'translateX(-50%)', fontSize: 8, fontWeight: 700, color: '#F7F2E8', background: '#6A4C7C', padding: '1px 6px', borderRadius: 99, zIndex: 1, }}>현재</div> )} <div style={{ fontSize: 9.5, color: '#9A968D', marginTop: current ? 8 : 0, fontWeight: 700 }}>{age}</div> <div style={{ width: 42, height: 50, borderRadius: '50% 50% 40% 40%', background: current ? '#6A4C7C' : '#FBF7EF', border: current ? '1.5px solid #D4AF37' : '1px solid rgba(31,42,68,0.12)', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: current ? '0 4px 12px rgba(106,76,124,0.4)' : 'none', }}> <span className="font-title" style={{ fontSize: 20, color: current ? '#E8C76B' : '#1F2A44' }}>{gan}</span> </div> <div style={{ fontSize: 10, color: current ? '#6A4C7C' : '#6B6B6B', fontWeight: current ? 700 : 500 }}>{label}</div> </div> ); } function TraitsTab({ traits, onToday }) { return ( <div> <div style={{ background: '#FBF7EF', borderRadius: 14, border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)', padding: '16px 12px', }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12, padding: '0 6px' }}> <OrnamentBloom size={14} color="#6A4C7C" /> <span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>타고난 성향</span> </div> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}> {traits.map((t) => (<TraitChip key={t.id} {...t} />))} </div> </div> <div style={{ marginTop: 14 }}> <PrimaryButton color="#6A4C7C" onClick={onToday}> 오늘의 운세 확인하기 <IconSparkle size={12} color="#E8C76B" /> </PrimaryButton> </div> </div> ); } ``` - [ ] **Step 2: Commit** ```bash git add web-ui/src/pages/saju/views/saju.mobile.jsx git commit -m "feat(saju-ui-v2): saju.mobile.jsx — 4탭 (Basic/Chart/Flow/Traits) + 실 데이터 매핑" ``` --- ## Task 3.2: saju.desktop.jsx — 모바일 4탭 구조 유지 + 폭만 확장 **Files:** - Create: `web-ui/src/pages/saju/views/saju.desktop.jsx` - [ ] **Step 1: saju.desktop.jsx 작성 — Task 3.1의 SajuMobile를 max-width 900 컨테이너 + hero gap 확대로 wrap** 데스크탑은 4탭 그대로 유지 (Spec 12절 결정 → 단순한 1-column으로 충분). 진입 컨테이너만 다름: ```jsx import React from 'react'; import SajuMobile from './saju.mobile.jsx'; export default function SajuDesktop({ reading }) { return ( <div style={{ maxWidth: 900, margin: '0 auto' }}> <SajuMobile reading={reading} /> </div> ); } ``` - [ ] **Step 2: Commit** ```bash git add web-ui/src/pages/saju/views/saju.desktop.jsx git commit -m "feat(saju-ui-v2): saju.desktop.jsx — 4탭 유지 + max-width 900 컨테이너" ``` --- ## Task 3.3: SajuResult.jsx 라우트 진입 교체 **Files:** - Modify: `web-ui/src/pages/saju/SajuResult.jsx` (전면 교체) - [ ] **Step 1: SajuResult.jsx 전면 교체** ```jsx import React from 'react'; import { useSearchParams, Link } from 'react-router-dom'; import './_shell/tokens.css'; import './_shell/shell.css'; import useViewportMode from './_shell/useViewportMode'; import useSajuReading from './hooks/useSajuReading'; import BottomNav from './_shell/BottomNav'; import DesktopHeader from './_shell/DesktopHeader'; import Mascot from './_shell/Mascot'; import MascotBubble from './_shell/MascotBubble'; import PrimaryButton from './_shell/PrimaryButton'; import GhostButton from './_shell/GhostButton'; import SajuMobile from './views/saju.mobile.jsx'; import SajuDesktop from './views/saju.desktop.jsx'; export default function SajuResult() { const mode = useViewportMode(); const [params] = useSearchParams(); const rid = params.get('rid'); const { reading, loading, error, reload } = useSajuReading(rid); return ( <div className="saju-v2"> {mode === 'desktop' && <DesktopHeader />} {!rid && <EmptyState />} {rid && loading && <LoadingState />} {rid && error && <ErrorState onRetry={reload} />} {rid && reading && (mode === 'desktop' ? <SajuDesktop reading={reading} /> : <SajuMobile reading={reading} /> )} {mode === 'mobile' && <BottomNav theme="ivory" />} </div> ); } function EmptyState() { return ( <main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}> <Mascot variant="greeting" size={160} style={{ margin: '0 auto 16px' }} /> <MascotBubble tone="ivory" tail={false} text="사주를 먼저 입력해주세요." style={{ margin: '0 auto 24px' }} /> <Link to="/saju" style={{ display: 'inline-block' }}> <PrimaryButton color="#6A4C7C" full={false}>사주 입력하러 가기</PrimaryButton> </Link> </main> ); } function LoadingState() { return ( <main className="page paper-bg screen-in" style={{ padding: '60px 24px', textAlign: 'center' }}> <Mascot variant="thinking" size={160} style={{ margin: '0 auto 16px' }} /> <MascotBubble tone="purple" tail={false} text={'호령이 풀이 중이에요...\n(최대 1분 정도 걸려요)'} style={{ margin: '0 auto' }} /> </main> ); } function ErrorState({ onRetry }) { return ( <main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}> <Mascot variant="thinking" size={140} style={{ margin: '0 auto 16px' }} /> <MascotBubble tone="purple" tail={false} text="아이고, 풀이를 가져오지 못했어요. 다시 시도해주세요." style={{ margin: '0 auto 20px' }} /> <GhostButton color="#6A4C7C" full={false} onClick={onRetry}>다시 시도</GhostButton> </main> ); } ``` - [ ] **Step 2: useSajuReading 시그니처 확인** Run: `cat web-ui/src/pages/saju/hooks/useSajuReading.js` 반환 객체에 `reading`, `loading`, `error`, `reload`(없으면 `refetch` 등 다른 이름) 키 확인. 다르면 SajuResult.jsx의 destructure를 hook 시그니처에 맞춤 (inline fix). - [ ] **Step 3: dev server 시각 검증** - `/saju/result` (rid 없음) → EmptyState + greeting Mascot - `/saju/result?rid=<유효>` → 4탭 정상 (모바일 + 데스크탑) - 잘못된 rid → ErrorState - 4탭 전환 시 basic/chart(8자 + 오행)/flow(대운)/traits 모두 데이터 표시 - 호령 PNG 모두 로드 (Network 탭) - [ ] **Step 4: Commit** ```bash git add web-ui/src/pages/saju/SajuResult.jsx git commit -m "feat(saju-ui-v2): SajuResult.jsx v2 진입 + Empty/Loading/Error state" ``` - [ ] **Step 5: Phase 3 종료 tag** ```bash git tag saju-ui-v2-phase3 ``` --- # Phase 4 — Today (`/saju/today`) ## Task 4.1: today.mobile.jsx + today.desktop.jsx **Files:** - Create: `web-ui/src/pages/saju/views/today.mobile.jsx` - Create: `web-ui/src/pages/saju/views/today.desktop.jsx` - [ ] **Step 1: today.mobile.jsx 작성 — FortuneRing + 4 ScoreCard + LuckyBox** ```jsx import React from 'react'; import { useNavigate } from 'react-router-dom'; import TopRibbon from '../_shell/TopRibbon'; import TitleBlock from '../_shell/TitleBlock'; import Mascot from '../_shell/Mascot'; import MascotBubble from '../_shell/MascotBubble'; import OrnateFrame from '../_shell/OrnateFrame'; import OrnamentBloom from '../_shell/OrnamentBloom'; import PrimaryButton from '../_shell/PrimaryButton'; import { IconChevron, IconSparkle } from '../_shell/Icons'; const SCORE_LABELS = { wealth: { ko: '재물운', icon: '금' }, love: { ko: '연애운', icon: '연' }, health: { ko: '건강운', icon: '생' }, work: { ko: '직장운', icon: '업' }, }; export default function TodayMobile({ reading, fortune }) { const navigate = useNavigate(); const scores = fortune?.scores || {}; const lucky = fortune?.lucky || {}; const signs = fortune?.good_signs || []; const warnings = fortune?.warnings || []; const overall = Math.round(fortune?.overall_score || 0); return ( <main className="page paper-bg screen-in"> <TopRibbon color="#D4AF37" opacity={0.7} /> <div style={{ padding: '8px 24px 0', textAlign: 'center' }}> <TitleBlock title="오늘의 운세" gold="#D4AF37" subtitle={fortune?.date || '오늘의 흐름을 확인하세요.'} /> </div> <div style={{ padding: '14px 20px 0', display: 'flex', gap: 8, alignItems: 'flex-end' }}> <MascotBubble tone="ivory" text={fortune?.greeting || '오늘 하루도 좋은 흐름이 있어요.'} style={{ flex: 1, marginBottom: 8 }} /> <Mascot variant="happy" size={130} style={{ marginRight: -8 }} /> </div> <div style={{ padding: '20px', display: 'flex', justifyContent: 'center' }}> <FortuneRing value={overall} /> </div> <div style={{ padding: '0 20px', display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 10 }}> {Object.entries(SCORE_LABELS).map(([key, { ko, icon }]) => ( <ScoreCard key={key} ko={ko} icon={icon} value={Math.round(scores[key] || 0)} /> ))} </div> <div style={{ padding: '20px' }}> <OrnateFrame color="#D4AF37" bg="#FBF7EF" radius={14} padding="16px 18px" double> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}> <OrnamentBloom size={14} color="#D4AF37" /> <span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>오늘의 럭키</span> </div> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}> <LuckyItem label="색" value={lucky.color} /> <LuckyItem label="숫자" value={lucky.number} /> <LuckyItem label="방향" value={lucky.direction} /> </div> </OrnateFrame> </div> {(signs.length > 0 || warnings.length > 0) && ( <div style={{ padding: '0 20px 20px', display: 'grid', gap: 12 }}> {signs.length > 0 && ( <SignList title="좋은 징조" items={signs} color="#4E6B5C" /> )} {warnings.length > 0 && ( <SignList title="주의할 점" items={warnings} color="#C04A4A" /> )} </div> )} <div style={{ padding: '0 20px 40px' }}> <PrimaryButton color="#D4AF37" onClick={() => navigate('/saju/result?rid=' + (reading?.id || ''))}> 내 사주 자세히 보기 <IconChevron dir="right" size={14} color="#1F2A44" /> </PrimaryButton> </div> </main> ); } function FortuneRing({ value }) { const R = 60; const C = 2 * Math.PI * R; const offset = C - (C * value) / 100; return ( <svg width="160" height="160" viewBox="0 0 160 160"> <circle cx="80" cy="80" r={R} stroke="#F0E9D9" strokeWidth="14" fill="none" /> <circle cx="80" cy="80" r={R} stroke="#D4AF37" strokeWidth="14" fill="none" strokeDasharray={C} strokeDashoffset={offset} strokeLinecap="round" transform="rotate(-90 80 80)" /> <text x="80" y="86" textAnchor="middle" className="font-title" style={{ fontSize: 32, fill: '#1F2A44', fontWeight: 800 }}> {value}<tspan style={{ fontSize: 14, fill: '#9A968D' }}>점</tspan> </text> </svg> ); } function ScoreCard({ ko, icon, value }) { return ( <div style={{ background: '#FBF7EF', borderRadius: 12, border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)', padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 10, }}> <div style={{ width: 36, height: 36, borderRadius: '50%', background: 'rgba(212,175,55,0.12)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, fontWeight: 800, color: '#B89530', }}>{icon}</div> <div style={{ flex: 1 }}> <div style={{ fontSize: 11, color: '#6B6B6B', fontWeight: 700 }}>{ko}</div> <div className="font-title" style={{ fontSize: 20, color: '#1F2A44' }}> {value}<span style={{ fontSize: 12, color: '#9A968D', fontWeight: 500 }}>점</span> </div> </div> </div> ); } function LuckyItem({ label, value }) { return ( <div style={{ textAlign: 'center' }}> <div style={{ fontSize: 11, color: '#6B6B6B', fontWeight: 700 }}>{label}</div> <div className="font-title" style={{ fontSize: 18, color: '#D4AF37', marginTop: 4 }}>{value || '-'}</div> </div> ); } function SignList({ title, items, color }) { return ( <div style={{ background: '#FBF7EF', borderRadius: 12, border: `1px solid ${color}40`, padding: '14px 16px', }}> <div style={{ fontSize: 12, fontWeight: 700, color, marginBottom: 8 }}>{title}</div> <ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'grid', gap: 6 }}> {items.map((s, i) => ( <li key={i} style={{ fontSize: 12.5, color: '#1F2A44', lineHeight: 1.6 }}>• {s}</li> ))} </ul> </div> ); } ``` - [ ] **Step 2: today.desktop.jsx 작성 (모바일 wrap + 폭만 확장)** ```jsx import React from 'react'; import TodayMobile from './today.mobile.jsx'; export default function TodayDesktop({ reading, fortune }) { return ( <div style={{ maxWidth: 720, margin: '0 auto' }}> <TodayMobile reading={reading} fortune={fortune} /> </div> ); } ``` - [ ] **Step 3: Commit** ```bash git add web-ui/src/pages/saju/views/today.mobile.jsx web-ui/src/pages/saju/views/today.desktop.jsx git commit -m "feat(saju-ui-v2): today.mobile/desktop.jsx — FortuneRing + 4 ScoreCard + LuckyBox + signs" ``` --- ## Task 4.2: Today.jsx 라우트 진입 교체 **Files:** - Modify: `web-ui/src/pages/saju/Today.jsx` (전면 교체) - [ ] **Step 1: Today.jsx 전면 교체** ```jsx import React, { useEffect, useState } from 'react'; import { useSearchParams, Link } from 'react-router-dom'; import './_shell/tokens.css'; import './_shell/shell.css'; import useViewportMode from './_shell/useViewportMode'; import useSajuReading from './hooks/useSajuReading'; import BottomNav from './_shell/BottomNav'; import DesktopHeader from './_shell/DesktopHeader'; import Mascot from './_shell/Mascot'; import MascotBubble from './_shell/MascotBubble'; import PrimaryButton from './_shell/PrimaryButton'; import GhostButton from './_shell/GhostButton'; import TodayMobile from './views/today.mobile.jsx'; import TodayDesktop from './views/today.desktop.jsx'; import { sajuGetCurrentFortune } from '../../api'; export default function Today() { const mode = useViewportMode(); const [params] = useSearchParams(); const rid = params.get('rid'); const { reading, loading: readingLoading, error: readingError } = useSajuReading(rid); const [fortune, setFortune] = useState(null); const [fortuneError, setFortuneError] = useState(null); useEffect(() => { if (!rid) return; sajuGetCurrentFortune(rid).then(setFortune).catch(setFortuneError); }, [rid]); const loading = readingLoading || (rid && !fortune && !fortuneError); const error = readingError || fortuneError; return ( <div className="saju-v2"> {mode === 'desktop' && <DesktopHeader />} {!rid && <EmptyState />} {rid && loading && <LoadingState />} {rid && error && <ErrorState onRetry={() => window.location.reload()} />} {rid && reading && fortune && (mode === 'desktop' ? <TodayDesktop reading={reading} fortune={fortune} /> : <TodayMobile reading={reading} fortune={fortune} /> )} {mode === 'mobile' && <BottomNav theme="ivory" />} </div> ); } function EmptyState() { return ( <main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}> <Mascot variant="greeting" size={160} style={{ margin: '0 auto 16px' }} /> <MascotBubble tone="ivory" tail={false} text="사주를 먼저 입력해주세요." style={{ margin: '0 auto 24px' }} /> <Link to="/saju" style={{ display: 'inline-block' }}> <PrimaryButton color="#D4AF37" full={false}>사주 입력하러 가기</PrimaryButton> </Link> </main> ); } function LoadingState() { return ( <main className="page paper-bg screen-in" style={{ padding: '60px 24px', textAlign: 'center' }}> <Mascot variant="thinking" size={160} style={{ margin: '0 auto 16px' }} /> <MascotBubble tone="ivory" tail={false} text="호령이 오늘 운세를 살펴보고 있어요..." style={{ margin: '0 auto' }} /> </main> ); } function ErrorState({ onRetry }) { return ( <main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}> <Mascot variant="thinking" size={140} style={{ margin: '0 auto 16px' }} /> <MascotBubble tone="ivory" tail={false} text="오늘 운세를 가져오지 못했어요." style={{ margin: '0 auto 20px' }} /> <GhostButton color="#D4AF37" full={false} onClick={onRetry}>다시 시도</GhostButton> </main> ); } ``` - [ ] **Step 2: api.js 함수명 확인** Run: `grep -n 'sajuGetCurrentFortune\|currentFortune' web-ui/src/api.js` 실제 export 이름이 다르면 import 수정. - [ ] **Step 3: dev server 시각 검증** `/saju/today?rid=<유효>` → FortuneRing, 4 ScoreCard, LuckyBox, signs 표시 + BottomNav today 활성. 모바일 + 데스크탑. - [ ] **Step 4: Commit + tag** ```bash git add web-ui/src/pages/saju/Today.jsx git commit -m "feat(saju-ui-v2): Today.jsx v2 진입 + current-fortune fetch + state machine" git tag saju-ui-v2-phase4 ``` --- # Phase 5 — Compatibility (`/saju/compatibility`) 기존 placeholder를 본격 구현으로 교체. ## Task 5.1: match.mobile.jsx + match.desktop.jsx **Files:** - Create: `web-ui/src/pages/saju/views/match.mobile.jsx` - Create: `web-ui/src/pages/saju/views/match.desktop.jsx` - [ ] **Step 1: match.mobile.jsx 작성** 두 사람 입력 폼 (사람 A, 사람 B): ```jsx import React from 'react'; import TopRibbon from '../_shell/TopRibbon'; import TitleBlock from '../_shell/TitleBlock'; import Mascot from '../_shell/Mascot'; import MascotBubble from '../_shell/MascotBubble'; import OrnateFrame from '../_shell/OrnateFrame'; import PrimaryButton from '../_shell/PrimaryButton'; import InputRow from '../_shell/InputRow'; import { IconHeart, IconSparkle } from '../_shell/Icons'; function PersonForm({ label, person, onChange }) { return ( <OrnateFrame color="#4E6B5C" bg="#FBF7EF" radius={14} padding="14px 16px"> <div className="font-title" style={{ fontSize: 14, color: '#4E6B5C', textAlign: 'center', marginBottom: 8, }}>{label}</div> <InputRow label="이름" name={`${label}-name`} value={person.name} onChange={(e) => onChange({ ...person, name: e.target.value })} placeholder="홍길동" /> <InputRow label="생년월일" name={`${label}-birthDate`} type="date" value={person.birthDate} onChange={(e) => onChange({ ...person, birthDate: e.target.value })} /> <InputRow label="시간" name={`${label}-birthTime`} type="time" value={person.birthTime} onChange={(e) => onChange({ ...person, birthTime: e.target.value })} /> <InputRow label="성별"> <select value={person.gender} onChange={(e) => onChange({ ...person, gender: e.target.value })} style={{ flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)', borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44' }}> <option value="male">남</option> <option value="female">여</option> </select> </InputRow> </OrnateFrame> ); } export default function MatchMobile({ personA, personB, onChangeA, onChangeB, onSubmit, loading, error }) { return ( <main className="page paper-bg screen-in"> <TopRibbon color="#4E6B5C" opacity={0.6} /> <div style={{ padding: '8px 24px 0', textAlign: 'center' }}> <TitleBlock title="궁합 보기" gold="#4E6B5C" subtitle="두 사람의 사주를 입력하면 만남의 흐름을 알려드려요." /> </div> <div style={{ padding: '14px 20px 0', display: 'flex', gap: 8, alignItems: 'flex-end' }}> <MascotBubble tone="green" text={'두 사주를 비교해\n어울리는 결을\n읽어드릴게요.'} style={{ flex: 1, marginBottom: 8 }} /> <Mascot variant="upper" size={120} style={{ marginRight: -8 }} /> </div> <form onSubmit={onSubmit} style={{ padding: '24px 20px 40px', display: 'grid', gap: 14 }}> <PersonForm label="사람 A" person={personA} onChange={onChangeA} /> <div style={{ display: 'flex', justifyContent: 'center' }}> <IconHeart size={28} stroke="#4E6B5C" strokeWidth={2} /> </div> <PersonForm label="사람 B" person={personB} onChange={onChangeB} /> {error && ( <div style={{ color: '#C04A4A', fontSize: 12, textAlign: 'center' }}>{error}</div> )} <PrimaryButton color="#4E6B5C" type="submit"> {loading ? '호령이 두 사주를 비교 중...' : '궁합 보기'} {!loading && <IconSparkle size={12} color="#E8C76B" />} </PrimaryButton> </form> </main> ); } ``` - [ ] **Step 2: match.desktop.jsx 작성 — 2-column 입력** ```jsx import React from 'react'; import TitleBlock from '../_shell/TitleBlock'; import Mascot from '../_shell/Mascot'; import MascotBubble from '../_shell/MascotBubble'; import OrnateFrame from '../_shell/OrnateFrame'; import PrimaryButton from '../_shell/PrimaryButton'; import InputRow from '../_shell/InputRow'; import { IconHeart, IconSparkle } from '../_shell/Icons'; function PersonForm({ label, person, onChange }) { return ( <OrnateFrame color="#4E6B5C" bg="#FBF7EF" radius={14} padding="16px 18px"> <div className="font-title" style={{ fontSize: 16, color: '#4E6B5C', textAlign: 'center', marginBottom: 10, }}>{label}</div> <InputRow label="이름" name={`${label}-name`} value={person.name} onChange={(e) => onChange({ ...person, name: e.target.value })} placeholder="홍길동" /> <InputRow label="생년월일" name={`${label}-birthDate`} type="date" value={person.birthDate} onChange={(e) => onChange({ ...person, birthDate: e.target.value })} /> <InputRow label="시간" name={`${label}-birthTime`} type="time" value={person.birthTime} onChange={(e) => onChange({ ...person, birthTime: e.target.value })} /> <InputRow label="성별"> <select value={person.gender} onChange={(e) => onChange({ ...person, gender: e.target.value })} style={{ flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)', borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44' }}> <option value="male">남</option> <option value="female">여</option> </select> </InputRow> </OrnateFrame> ); } export default function MatchDesktop({ personA, personB, onChangeA, onChangeB, onSubmit, loading, error }) { return ( <main className="page mt-wash screen-in"> <div style={{ maxWidth: 1100, margin: '0 auto', padding: '48px 32px' }}> <TitleBlock title="궁합 보기" gold="#4E6B5C" subtitle="두 사람의 사주를 입력하면 만남의 흐름을 알려드려요." /> <div style={{ display: 'flex', alignItems: 'flex-end', gap: 16, marginTop: 24 }}> <Mascot variant="upper" size={180} /> <MascotBubble tone="green" text={'두 사주를 비교해\n어울리는 결을 읽어드릴게요.'} style={{ marginBottom: 20 }} /> </div> <form onSubmit={onSubmit} style={{ marginTop: 28, display: 'grid', gridTemplateColumns: '1fr 60px 1fr', gap: 16, alignItems: 'start', }}> <PersonForm label="사람 A" person={personA} onChange={onChangeA} /> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}> <IconHeart size={36} stroke="#4E6B5C" strokeWidth={2} /> </div> <PersonForm label="사람 B" person={personB} onChange={onChangeB} /> </form> {error && ( <div style={{ color: '#C04A4A', fontSize: 13, textAlign: 'center', marginTop: 16 }}>{error}</div> )} <div style={{ marginTop: 24, maxWidth: 320, margin: '24px auto 0' }}> <PrimaryButton color="#4E6B5C" onClick={onSubmit}> {loading ? '호령이 두 사주를 비교 중...' : '궁합 보기'} {!loading && <IconSparkle size={12} color="#E8C76B" />} </PrimaryButton> </div> </div> </main> ); } ``` - [ ] **Step 3: Commit** ```bash git add web-ui/src/pages/saju/views/match.mobile.jsx web-ui/src/pages/saju/views/match.desktop.jsx git commit -m "feat(saju-ui-v2): match.mobile/desktop.jsx — 두 사람 입력 폼 (PersonForm + IconHeart)" ``` --- ## Task 5.2: Compatibility.jsx 라우트 진입 교체 **Files:** - Modify: `web-ui/src/pages/saju/Compatibility.jsx` (전면 교체) - [ ] **Step 1: Compatibility.jsx 전면 교체** ```jsx import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import './_shell/tokens.css'; import './_shell/shell.css'; import useViewportMode from './_shell/useViewportMode'; import BottomNav from './_shell/BottomNav'; import DesktopHeader from './_shell/DesktopHeader'; import MatchMobile from './views/match.mobile.jsx'; import MatchDesktop from './views/match.desktop.jsx'; import { sajuCompatInterpret } from '../../api'; const EMPTY_PERSON = { name: '', birthDate: '', birthTime: '', gender: 'male', calendar: 'solar' }; export default function Compatibility() { const mode = useViewportMode(); const navigate = useNavigate(); const [personA, setPersonA] = useState({ ...EMPTY_PERSON }); const [personB, setPersonB] = useState({ ...EMPTY_PERSON }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const handleSubmit = async (e) => { if (e && e.preventDefault) e.preventDefault(); setError(null); if (!personA.name || !personA.birthDate || !personB.name || !personB.birthDate) { setError('두 사람의 이름과 생년월일을 입력해주세요.'); return; } setLoading(true); try { const res = await sajuCompatInterpret({ person_a: personA, person_b: personB }); navigate(`/saju/compatibility/result?cid=${res.compat_id}`); } catch (err) { setError(err?.message || '궁합 풀이에 실패했어요.'); } finally { setLoading(false); } }; const props = { personA, personB, onChangeA: setPersonA, onChangeB: setPersonB, onSubmit: handleSubmit, loading, error, }; return ( <div className="saju-v2"> {mode === 'desktop' && <DesktopHeader />} {mode === 'desktop' ? <MatchDesktop {...props} /> : <MatchMobile {...props} />} {mode === 'mobile' && <BottomNav theme="ivory" />} </div> ); } ``` - [ ] **Step 2: sajuCompatInterpret 시그니처/응답 확인** Run: `grep -n 'sajuCompatInterpret\|compat/interpret' web-ui/src/api.js` 응답 키가 `compat_id`/`id` 무엇인지 확인 후 navigate URL 맞춤. 양쪽 잘못된 키면 console 에러로 즉시 발견. - [ ] **Step 3: dev server 시각 검증** `/saju/compatibility` → 두 사람 폼 + IconHeart, submit → `/saju/compatibility/result?cid=N` 이동. 폼 검증 (이름/생년월일 빈 상태) → error 메시지. - [ ] **Step 4: Commit** ```bash git add web-ui/src/pages/saju/Compatibility.jsx git commit -m "feat(saju-ui-v2): Compatibility.jsx — placeholder → 두 사람 입력 폼 + compat API" ``` --- ## Task 5.3: CompatibilityResult.jsx 라이트 리스타일 **Files:** - Modify: `web-ui/src/pages/saju/CompatibilityResult.jsx` (전면 교체, 결과 스키마는 기존 hook/Helper 사용) - [ ] **Step 1: CompatibilityResult.jsx 전면 교체** ```jsx import React, { useEffect, useState } from 'react'; import { useSearchParams, Link } from 'react-router-dom'; import './_shell/tokens.css'; import './_shell/shell.css'; import useViewportMode from './_shell/useViewportMode'; import BottomNav from './_shell/BottomNav'; import DesktopHeader from './_shell/DesktopHeader'; import TopRibbon from './_shell/TopRibbon'; import TitleBlock from './_shell/TitleBlock'; import Mascot from './_shell/Mascot'; import MascotBubble from './_shell/MascotBubble'; import OrnateFrame from './_shell/OrnateFrame'; import PrimaryButton from './_shell/PrimaryButton'; import GhostButton from './_shell/GhostButton'; import { sajuGetCompatReading } from '../../api'; export default function CompatibilityResult() { const mode = useViewportMode(); const [params] = useSearchParams(); const cid = params.get('cid'); const [result, setResult] = useState(null); const [error, setError] = useState(null); useEffect(() => { if (!cid) return; sajuGetCompatReading(cid).then(setResult).catch(setError); }, [cid]); return ( <div className="saju-v2"> {mode === 'desktop' && <DesktopHeader />} <main className="page paper-bg screen-in"> <TopRibbon color="#4E6B5C" opacity={0.6} /> <div style={{ maxWidth: mode === 'desktop' ? 720 : 'none', margin: '0 auto', padding: '24px 20px 40px' }}> {!cid && ( <> <TitleBlock title="궁합 결과" gold="#4E6B5C" /> <div style={{ textAlign: 'center', marginTop: 24 }}> <MascotBubble tone="green" tail={false} text="궁합을 먼저 보세요." style={{ margin: '0 auto 20px' }} /> <Link to="/saju/compatibility"><PrimaryButton color="#4E6B5C" full={false}>궁합 입력하러 가기</PrimaryButton></Link> </div> </> )} {cid && !result && !error && ( <div style={{ textAlign: 'center', padding: 40 }}> <Mascot variant="thinking" size={140} style={{ margin: '0 auto 16px' }} /> <MascotBubble tone="green" tail={false} text="호령이 두 사주를 비교 중이에요..." style={{ margin: '0 auto' }} /> </div> )} {cid && error && ( <div style={{ textAlign: 'center', padding: 40 }}> <MascotBubble tone="green" tail={false} text="궁합 결과를 가져오지 못했어요." style={{ margin: '0 auto 20px' }} /> <GhostButton color="#4E6B5C" full={false} onClick={() => window.location.reload()}>다시 시도</GhostButton> </div> )} {result && ( <> <TitleBlock title="궁합 결과" gold="#4E6B5C" subtitle={`${result.person_a?.name} × ${result.person_b?.name}`} /> <div style={{ marginTop: 20, textAlign: 'center' }}> <div className="font-title" style={{ fontSize: 48, color: '#4E6B5C' }}> {result.score}<span style={{ fontSize: 18, color: '#9A968D', fontWeight: 500 }}>점</span> </div> </div> <OrnateFrame color="#4E6B5C" bg="#FBF7EF" radius={14} padding="16px 18px" style={{ marginTop: 20 }}> <div className="font-title" style={{ fontSize: 13, color: '#4E6B5C', textAlign: 'center', marginBottom: 6 }}>요약</div> <div style={{ fontSize: 13, color: '#1F2A44', lineHeight: 1.7, whiteSpace: 'pre-line' }}> {result.interpretation?.summary || '요약을 준비 중입니다.'} </div> </OrnateFrame> {result.interpretation?.strengths?.length > 0 && ( <OrnateFrame color="#4E6B5C" bg="#FBF7EF" radius={14} padding="16px 18px" style={{ marginTop: 14 }}> <div className="font-title" style={{ fontSize: 13, color: '#4E6B5C', marginBottom: 8 }}>강점</div> <ul style={{ margin: 0, paddingLeft: 18, color: '#1F2A44', fontSize: 13, lineHeight: 1.7 }}> {result.interpretation.strengths.map((s, i) => (<li key={i}>{s}</li>))} </ul> </OrnateFrame> )} {result.interpretation?.challenges?.length > 0 && ( <OrnateFrame color="#C04A4A" bg="#FBF7EF" radius={14} padding="16px 18px" style={{ marginTop: 14 }}> <div className="font-title" style={{ fontSize: 13, color: '#C04A4A', marginBottom: 8 }}>주의할 점</div> <ul style={{ margin: 0, paddingLeft: 18, color: '#1F2A44', fontSize: 13, lineHeight: 1.7 }}> {result.interpretation.challenges.map((s, i) => (<li key={i}>{s}</li>))} </ul> </OrnateFrame> )} </> )} </div> </main> {mode === 'mobile' && <BottomNav theme="ivory" />} </div> ); } ``` - [ ] **Step 2: dev server 시각 검증** `/saju/compatibility/result?cid=<유효>` → 점수 + 요약 + 강점 + 주의할 점 모두 표시. - [ ] **Step 3: Commit + tag** ```bash git add web-ui/src/pages/saju/CompatibilityResult.jsx git commit -m "feat(saju-ui-v2): CompatibilityResult.jsx v2 — 점수 + 요약 + strengths/challenges" git tag saju-ui-v2-phase5 ``` --- # Phase 6 — QA + cleanup ## Task 6.1: 골든 패스 시각 QA - [ ] **Step 1: 5 라우트 모두 진입 확인 (모바일 + 데스크탑)** `npm run dev` → chrome devtools: - 390×844 (iPhone 12): `/saju` → 입력 → `/saju/result?rid=N` → 4탭 → "오늘의 운세 확인하기" → `/saju/today?rid=N` → "내 사주 자세히 보기" → 다시 `/saju/result` → BottomNav `/saju/compatibility` → 입력 → `/saju/compatibility/result?cid=N` → BottomNav `/saju/me` - 1280×720: 동일 흐름, DesktopHeader 표시 확인 - 1024px 경계: 1023↔1024px에서 모드 전환 - [ ] **Step 2: 호령 PNG 7 variant 로드 확인** Network 탭에서 `horyung-main/bust/front/greeting/thinking/pointing/happy.png` 모두 200 응답. 일부 페이지에서 lazy 로드되어 진입 후에야 로드되어도 OK. - [ ] **Step 3: 폰트 + 토큰 적용 확인** DevTools Computed → 임의 텍스트 노드에서 `font-family: 'Nanum Gothic'` (body) / `'Nanum Myeongjo'` (h1) 적용 확인. tokens.css의 `--navy/--gold` CSS 변수가 inspector에서 보임. - [ ] **Step 4: 회귀 — saju-lab API 호출 무변경 확인** Network 탭에서 v1 시절과 동일한 엔드포인트(POST `/api/saju/interpret`, GET `/api/saju/readings/{id}`, GET `/api/saju/current-fortune`, POST `/api/saju/compat/interpret`, GET `/api/saju/compat/readings/{id}`)만 호출됨을 확인. --- ## Task 6.2: v1 cleanup — components/ 삭제 + Saju.css 삭제 + SajuNav import 제거 **Files:** - Delete: `web-ui/src/pages/saju/components/` (12 파일) - Delete: `web-ui/src/pages/saju/Saju.css` - [ ] **Step 1: dead import 사전 확인** Run: `cd web-ui && npm run build 2>&1 | head -50` 빌드가 성공한 상태인지 확인. 실패하면 import 오류 fix 후 진행. - [ ] **Step 2: 디렉토리 삭제** ```bash cd /c/Users/jaeoh/Desktop/workspace/web-ui rm -rf src/pages/saju/components rm src/pages/saju/Saju.css ``` - [ ] **Step 3: 빌드 재실행으로 dead import 없음 확인** Run: `npm run build` Expected: exit 0, 에러 없음. 에러 발생 시 grep 으로 v1 파일을 import하는 곳 찾아 제거. - [ ] **Step 4: Commit** ```bash git add -A web-ui/src/pages/saju/ git commit -m "chore(saju-ui-v2): v1 components/ + Saju.css 일괄 삭제 (Phase 6 cleanup)" ``` --- ## Task 6.3: 회귀 vitest 전체 실행 - [ ] **Step 1: vitest 전체 실행** Run: `cd web-ui && npx vitest run 2>&1 | tail -20` Expected: 모든 테스트 통과 (Phase 1~5에 추가한 useViewportMode/helpers/Mascot 테스트 포함, 기존 web-ui 다른 페이지 테스트도 무손상) - [ ] **Step 2: 빌드 최종 확인** Run: `npm run build` Expected: exit 0, dist/ 산출물 생성. bundle size 폭증 없음 (이전 대비 ±10% 이내 — _shell/이 lazy chunk로 분리됨) --- ## Task 6.4: Phase 6 종료 + push 준비 - [ ] **Step 1: git log 검토** Run: `git log --oneline saju-ui-v2-phase1..HEAD` (또는 분기 시작부터) ~30 커밋이 phase별로 묶여 있음을 확인. - [ ] **Step 2: tag** ```bash git tag saju-ui-v2-phase6 ``` - [ ] **Step 3: push 안내** 사용자에게 push 권한 안내: ``` ! cd web-ui && git push origin main ``` push 시 `web-ui`는 GitHub Actions / 로컬 빌드 후 NAS frontend/로 robocopy 필요 (workspace CLAUDE.md `scripts/deploy.bat --frontend` 참고). git push만으로 NAS 배포 안 됨 — `scripts/deploy.bat --frontend` 실행해야 NAS 반영. --- ## Self-Review (plan 작성 후) ### Spec coverage - [x] §1 목적 → Task 1.x ~ 6.x 전체 - [x] §2 미학 → Task 1.2 tokens, 1.3 shell, 1.6~1.11 컴포넌트 - [x] §3 아키텍처 → Task 1.13 Me, 1.14 routes, Phase 2~5 라우트 교체 - [x] §4 컴포넌트 명세 → Task 1.4~1.12 모두 cover - [x] §5 데이터 흐름 → Task 1.5 helpers, Task 2.3 useSajuForm 통합, Task 3.3 useSajuReading 통합 - [x] §6 반응형 + 네비 → Task 1.4 useViewportMode, 1.11 BottomNav, 1.12 DesktopHeader, 페이지마다 mode 분기 - [x] §7 Phase plan → Phase 1~6 그대로 분해 - [x] §8 에러/빈 상태 → SajuResult/Today의 EmptyState/LoadingState/ErrorState, Compatibility 폼 검증, Me placeholder - [x] §9 검증 → Phase 6 + 각 task의 vitest 단위 테스트 - [x] §10 YAGNI 명시 제외 → i18n/dark/auth/haptic 제외, plan에 등장 X - [x] §11 마이그레이션 → Task 6.2 일괄 삭제 - [x] §12 plan 위임 결정 → Task 3.2에서 데스크탑 4탭 유지 결정 명시, Task 1.10에서 InputRow 시그니처 명시 ### Placeholder scan - 모든 step에 실제 코드 또는 명확한 명령. "TBD/TODO" 없음. - "Similar to Task N" 패턴 없음 — 각 Task 코드 self-contained. - 시각 검증 step은 명확한 URL + 확인 항목 제시. ### Type consistency - `useViewportMode()` 반환 `'mobile'` / `'desktop'` 일관 - `NAV_ITEMS` 객체 키 (`id`/`to`/`label`/`Icon`/`accent`) BottomNav + DesktopHeader 동일 - Mascot variant 키 (`full|head|upper|greeting|thinking|pointing|happy`) 모든 사용처 일치 - Trait 객체 (`{id, ko, icon, color}`) deriveTraits 반환 + TraitChip props 일치 - Hook 시그니처: useSajuForm/useSajuReading/sajuGetCurrentFortune/sajuCompatInterpret/sajuGetCompatReading는 Task 2.3/3.3/4.2/5.2/5.3에서 실제 export 확인 step 포함 (mismatch 시 inline fix 가이드) ### Fixes - (없음 — self-review 통과) --- ## Execution Handoff Plan complete and saved to `docs/superpowers/plans/2026-05-26-saju-ui-v2-redesign.md` (~ Phase 6 단계, 총 30+ task, 130+ step). 두 가지 실행 옵션: **1. Subagent-Driven (추천)** — 각 task마다 fresh subagent dispatch + 매 task 후 두 단계 review (대규모 UI 작업이라 효과 큼) **2. Inline Execution** — 이 세션에서 batch 실행 + Phase 끝마다 checkpoint review 어떤 방식으로 진행할까요?