diff --git a/docs/superpowers/plans/2026-05-26-saju-ui-v2-redesign.md b/docs/superpowers/plans/2026-05-26-saju-ui-v2-redesign.md new file mode 100644 index 0000000..6404179 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-saju-ui-v2-redesign.md @@ -0,0 +1,2970 @@ +# 호령 사주 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 + +어떤 방식으로 진행할까요?