Files
web-page-backend/docs/superpowers/plans/2026-05-26-saju-ui-v2-redesign.md
gahusb 4ee4a1ae7d docs(plan): 호령 사주 UI v2 리디자인 — Phase 1~6 implementation plan
spec(commit fd40777)을 30+ task / 130+ step으로 분해. Phase 1: shell+토큰+공통
컴포넌트, Phase 2~5: home/saju/today/match 라우트 교체, Phase 6: v1 cleanup +
QA. 각 task는 test/run/implement/verify/commit 5-step 또는 시각 검증 step 포함.
self-review 통과 (spec 12절 모두 cover, placeholder/type inconsistency 없음).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:35:41 +09:00

2971 lines
107 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 호령 사주 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/<page>.{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 내 `<title>` 다음 줄에 추가)
- [ ] **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
어떤 방식으로 진행할까요?