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

107 KiB
Raw Blame History

호령 사주 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 표시.

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

어떤 방식으로 진행할까요?