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>
107 KiB
호령 사주 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.jsxpages/saju/SajuResult.jsxpages/saju/Today.jsxpages/saju/Compatibility.jsxpages/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.jspages/saju/hooks/useSajuReading.jsweb-ui/public/images/saju/horyung/7 PNGweb-ui/src/api.jssaju 헬퍼
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> 다음에 추가:
<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
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 안에 디자인 토큰 정의 (글로벌 오염 방지):
/* 호령 사주 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
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 작성
/* 호령 사주 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
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: 실패하는 테스트 작성
// 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 구현
// 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
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: 실패하는 테스트 작성
// 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 작성
// 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 작성
// 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 작성
// 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 작성
// 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
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):
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
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: 실패하는 테스트 작성
// 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 작성
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
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의 동명 함수 포팅
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
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 작성
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 작성
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 작성
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 작성
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
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 작성
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 작성
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에서 사용)
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
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 활용
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
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 작성
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
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 작성
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
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 다음 줄에:
const SajuMe = lazy(() => import('./pages/saju/Me'));
/saju children 배열의 compatibility/result 다음에:
{ 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
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 (선택)
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 + 입력 폼:
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
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 (좌 호령, 우 입력):
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
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 본문 전면 교체
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
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 (선택)
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) 구현:
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
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으로 충분). 진입 컨테이너만 다름:
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
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 전면 교체
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
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
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
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 + 폭만 확장)
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
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 전면 교체
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
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):
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 입력
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
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 전면 교체
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
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 전면 교체
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
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: 디렉토리 삭제
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
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
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
- §1 목적 → Task 1.x ~ 6.x 전체
- §2 미학 → Task 1.2 tokens, 1.3 shell, 1.6~1.11 컴포넌트
- §3 아키텍처 → Task 1.13 Me, 1.14 routes, Phase 2~5 라우트 교체
- §4 컴포넌트 명세 → Task 1.4~1.12 모두 cover
- §5 데이터 흐름 → Task 1.5 helpers, Task 2.3 useSajuForm 통합, Task 3.3 useSajuReading 통합
- §6 반응형 + 네비 → Task 1.4 useViewportMode, 1.11 BottomNav, 1.12 DesktopHeader, 페이지마다 mode 분기
- §7 Phase plan → Phase 1~6 그대로 분해
- §8 에러/빈 상태 → SajuResult/Today의 EmptyState/LoadingState/ErrorState, Compatibility 폼 검증, Me placeholder
- §9 검증 → Phase 6 + 각 task의 vitest 단위 테스트
- §10 YAGNI 명시 제외 → i18n/dark/auth/haptic 제외, plan에 등장 X
- §11 마이그레이션 → Task 6.2 일괄 삭제
- §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
어떤 방식으로 진행할까요?