Files
web-page-backend/docs/superpowers/plans/2026-05-26-saju-ui-v1.md
gahusb 8ef0ba81f2 docs(plan): saju-lab UI v1 — 호령 사주 페이지 구현 plan
- Phase A 백엔드 확장 (Task 1-5): fortune_scores + lucky + monthly_flow + DB 마이그레이션 + 응답 확장
- Phase B 캐릭터 자산 (Task 6): horyung.png + saju_color_sheet.png에서 6 PNG 추출 (PIL)
- Phase C 프론트 구축 (Task 7-16): CSS 격리 + 컴포넌트 11개 + 3 페이지 + e2e 검증
- TDD + 빈번한 commit + 시안 1:1 매칭 목표

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

90 KiB

saju-lab UI v1 — 호령 사주 페이지 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: 시안 기반 호령 사주 페이지 v1 (메인 + 사주풀이 + 오늘운세) 구축. 백엔드는 fortune_scores + lucky + monthly_flow 산출 추가.

Architecture: saju-lab 백엔드에 3 신규 calculator 모듈 + 응답 확장 + DB schema migration. web-ui에 호령 캐릭터 자산 + saju-page scope CSS + 3 페이지 컴포넌트 + 공통 컴포넌트. 데이터 흐름은 메인에서 1회 입력 → reading_id URL query로 다른 페이지 공유.

Tech Stack: Python 3.12 + FastAPI + SQLite (saju-lab 확장) / React 18 + Vite + React Router v6 + Pretendard + Noto Serif KR (web-ui). PIL for 캐릭터 PNG 추출.

Reference spec: docs/superpowers/specs/2026-05-26-saju-ui-design.md


Phase A — 백엔드 확장 (saju-lab)

Task 1: calculator/fortune_scores.py — 4 카테고리 점수

Files:

  • Create: saju-lab/app/calculator/fortune_scores.py

  • Create: saju-lab/tests/test_fortune_scores.py

  • Step 1: 실패 테스트 작성

saju-lab/tests/test_fortune_scores.py:

import json
from pathlib import Path
import pytest

from app.calculator.core import calculate_saju
from app.calculator.analysis import perform_full_analysis
from app.calculator.fortune_scores import calculate_fortune_scores


def _saju_for(year, month, day, hour, gender):
    saju = calculate_saju(year, month, day, hour, gender)
    analysis = perform_full_analysis(saju, 2026)
    return saju, analysis


def test_scores_in_valid_range():
    saju, analysis = _saju_for(1990, 5, 15, 14, "male")
    result = calculate_fortune_scores(saju, analysis, 2026)
    for key in ("wealth", "romance", "social", "career", "overall"):
        assert key in result, f"missing key: {key}"
        assert 0 <= result[key] <= 100, f"{key}={result[key]} out of range"


def test_overall_weighted_average():
    saju, analysis = _saju_for(1990, 5, 15, 14, "male")
    r = calculate_fortune_scores(saju, analysis, 2026)
    expected = round(
        r["wealth"] * 0.3 + r["career"] * 0.3 + r["romance"] * 0.2 + r["social"] * 0.2
    )
    assert abs(r["overall"] - expected) <= 1, f"overall mismatch: {r['overall']} vs {expected}"


def test_clamping_lower_bound():
    """매우 약한 입력 — score는 0 미만이 될 수 없음."""
    saju, analysis = _saju_for(2000, 2, 29, 12, "male")
    r = calculate_fortune_scores(saju, analysis, 2026)
    assert all(v >= 0 for v in r.values())


def test_clamping_upper_bound():
    """매우 강한 입력 — score는 100 초과 될 수 없음."""
    saju, analysis = _saju_for(1985, 1, 1, 0, "female")
    r = calculate_fortune_scores(saju, analysis, 2026)
    assert all(v <= 100 for v in r.values())


def test_different_inputs_different_scores():
    """다른 사주는 다른 점수 — degenerate to all-same-60 방지."""
    s1, a1 = _saju_for(1990, 5, 15, 14, "male")
    s2, a2 = _saju_for(1985, 1, 1, 0, "female")
    r1 = calculate_fortune_scores(s1, a1, 2026)
    r2 = calculate_fortune_scores(s2, a2, 2026)
    assert r1 != r2, "scores should differ for different sajus"


def test_handles_missing_hour():
    """시간 미상 사주도 동작."""
    saju, analysis = _saju_for(1990, 5, 15, None, "male")
    r = calculate_fortune_scores(saju, analysis, 2026)
    assert all(0 <= v <= 100 for v in r.values())
  • Step 2: Run test, verify FAIL

Run: cd saju-lab && python -m pytest tests/test_fortune_scores.py -v Expected: FAIL with ModuleNotFoundError: app.calculator.fortune_scores

  • Step 3: 구현 작성

saju-lab/app/calculator/fortune_scores.py:

"""4 카테고리 점수 — 재물/연애/인간관계/직장 + 종합점."""
from typing import Dict

from .constants import (
    FIVE_ELEMENTS, IS_YANG_STEM, SHENG_CYCLE, KE_CYCLE,
)


def _ten_god_counts(saju: dict) -> Dict[str, int]:
    """4기둥의 ten_god 카운트."""
    counts: Dict[str, int] = {}
    for p in ("year", "month", "day", "hour"):
        pillar = saju.get(p)
        if not pillar:
            continue
        tg = pillar.get("ten_god", "")
        counts[tg] = counts.get(tg, 0) + 1
    return counts


def _branch_has_chong(saju: dict) -> bool:
    """일지가 다른 기둥과 6충 관계인지."""
    LIU_CHONG = {
        frozenset(["子", "午"]), frozenset(["丑", "未"]),
        frozenset(["寅", "申"]), frozenset(["卯", "酉"]),
        frozenset(["辰", "戌"]), frozenset(["巳", "亥"]),
    }
    day_branch = saju["day"]["branch"]
    for p in ("year", "month", "hour"):
        pillar = saju.get(p)
        if not pillar:
            continue
        if frozenset([day_branch, pillar["branch"]]) in LIU_CHONG:
            return True
    return False


def _branch_has_he(saju: dict) -> bool:
    """일지가 다른 기둥과 6합 관계인지."""
    LIU_HE = {
        frozenset(["子", "丑"]), frozenset(["寅", "亥"]),
        frozenset(["卯", "戌"]), frozenset(["辰", "酉"]),
        frozenset(["巳", "申"]), frozenset(["午", "未"]),
    }
    day_branch = saju["day"]["branch"]
    for p in ("year", "month", "hour"):
        pillar = saju.get(p)
        if not pillar:
            continue
        if frozenset([day_branch, pillar["branch"]]) in LIU_HE:
            return True
    return False


def _clamp(v: int) -> int:
    return max(0, min(100, v))


def calculate_fortune_scores(saju: dict, analysis: dict, current_year: int) -> Dict[str, int]:
    """4 카테고리 + overall (가중평균)."""
    counts = _ten_god_counts(saju)
    has_chong = _branch_has_chong(saju)
    has_he = _branch_has_he(saju)

    strength = analysis.get("day_master_strength", {}).get("result", "중화")

    # 재물운: 정재/편재 강도
    wealth = 60
    wealth += counts.get("정재", 0) * 8
    wealth += counts.get("편재", 0) * 6
    wealth += counts.get("식신", 0) * 4  # 식상생재
    wealth -= counts.get("비견", 0) * 5
    wealth -= counts.get("겁재", 0) * 5
    wealth = _clamp(wealth)

    # 연애운: 일지 합/충 + 정관/정재
    romance = 60
    if has_he:
        romance += 15
    if has_chong:
        romance -= 15
    romance += counts.get("정관", 0) * 5
    romance += counts.get("정재", 0) * 5
    romance = _clamp(romance)

    # 인간관계: 인성 + 비겁 적정 + 식상
    social = 60
    social += counts.get("정인", 0) * 5
    social += counts.get("편인", 0) * 4
    social += min(counts.get("비견", 0), 2) * 5  # 적정선까지만
    social += counts.get("식신", 0) * 3
    social += counts.get("상관", 0) * 3
    social = _clamp(social)

    # 직장운: 정관 + 편관(제어) + 일간 강도
    career = 60
    career += counts.get("정관", 0) * 10
    career += counts.get("편관", 0) * 5
    if strength == "신강":
        career += 8
    elif strength == "신약":
        career -= 5
    career = _clamp(career)

    overall = round(wealth * 0.3 + career * 0.3 + romance * 0.2 + social * 0.2)
    overall = _clamp(overall)

    return {
        "wealth": wealth,
        "romance": romance,
        "social": social,
        "career": career,
        "overall": overall,
    }
  • Step 4: Run tests, verify PASS

Run: cd saju-lab && python -m pytest tests/test_fortune_scores.py -v Expected: 6 passed

  • Step 5: Commit
git add saju-lab/app/calculator/fortune_scores.py saju-lab/tests/test_fortune_scores.py
git commit -m "feat(saju-lab): fortune_scores.py — 4 카테고리 점수 + overall (6 tests)"

Task 2: calculator/lucky.py — 럭키 데이터

Files:

  • Create: saju-lab/app/calculator/lucky.py

  • Create: saju-lab/tests/test_lucky.py

  • Step 1: 실패 테스트 작성

saju-lab/tests/test_lucky.py:

from datetime import date

import pytest

from app.calculator.core import calculate_saju
from app.calculator.analysis import perform_full_analysis
from app.calculator.lucky import calculate_lucky


def _saju_for(year, month, day, hour, gender):
    saju = calculate_saju(year, month, day, hour, gender)
    analysis = perform_full_analysis(saju, 2026)
    return saju, analysis


def test_lucky_keys():
    saju, analysis = _saju_for(1990, 5, 15, 14, "male")
    r = calculate_lucky(saju, analysis, date(2026, 5, 26))
    for k in ("color", "number", "direction", "good_signs", "warnings"):
        assert k in r, f"missing {k}"


def test_lucky_number_range():
    saju, analysis = _saju_for(1990, 5, 15, 14, "male")
    r = calculate_lucky(saju, analysis, date(2026, 5, 26))
    assert 1 <= r["number"] <= 9


def test_lucky_color_from_yongshin():
    """용신 오행이 적용된 컬러."""
    saju, analysis = _saju_for(1990, 5, 15, 14, "male")
    r = calculate_lucky(saju, analysis, date(2026, 5, 26))
    assert isinstance(r["color"], list)
    assert len(r["color"]) >= 1
    yongshin = analysis["yong_shin"]["yong_shin"]
    valid_colors_by_element = {
        "木": {"청록", "녹색"},
        "火": {"빨강", "주황"},
        "土": {"황색", "베이지"},
        "金": {"흰색", "은색"},
        "水": {"파랑", "검정"},
    }
    expected_set = valid_colors_by_element[yongshin]
    assert all(c in expected_set for c in r["color"])


def test_lucky_direction_from_yongshin():
    saju, analysis = _saju_for(1990, 5, 15, 14, "male")
    r = calculate_lucky(saju, analysis, date(2026, 5, 26))
    valid_dirs = {"동쪽", "남쪽", "중앙", "서쪽", "북쪽"}
    assert r["direction"] in valid_dirs


def test_good_signs_and_warnings_are_lists():
    saju, analysis = _saju_for(1990, 5, 15, 14, "male")
    r = calculate_lucky(saju, analysis, date(2026, 5, 26))
    assert isinstance(r["good_signs"], list)
    assert isinstance(r["warnings"], list)


def test_handles_missing_hour():
    saju, analysis = _saju_for(1990, 5, 15, None, "male")
    r = calculate_lucky(saju, analysis, date(2026, 5, 26))
    assert 1 <= r["number"] <= 9
  • Step 2: Run, expect FAIL

Run: cd saju-lab && python -m pytest tests/test_lucky.py -v Expected: FAIL — ModuleNotFoundError

  • Step 3: 구현

saju-lab/app/calculator/lucky.py:

"""오늘의 럭키 컬러/숫자/방향 + 행운/위험 알림."""
from datetime import date as _date
from typing import Dict, List

import sxtwl

from .constants import HEAVENLY_STEMS, EARTHLY_BRANCHES, FIVE_ELEMENTS


LUCKY_COLOR_BY_ELEMENT: Dict[str, List[str]] = {
    "木": ["청록", "녹색"],
    "火": ["빨강", "주황"],
    "土": ["황색", "베이지"],
    "金": ["흰색", "은색"],
    "水": ["파랑", "검정"],
}

LUCKY_DIRECTION_BY_ELEMENT: Dict[str, str] = {
    "木": "동쪽",
    "火": "남쪽",
    "土": "중앙",
    "金": "서쪽",
    "水": "북쪽",
}


def _day_stem_branch_idx(target: _date) -> tuple[int, int]:
    """양력 → sxtwl 일주 천간/지지 인덱스."""
    day_obj = sxtwl.fromSolar(target.year, target.month, target.day)
    try:
        gz = day_obj.getDayGZ()
        return gz.tg, gz.dz
    except AttributeError:
        return day_obj.getDayTG(), day_obj.getDayDZ()


def calculate_lucky(saju: dict, analysis: dict, target_date: _date) -> dict:
    """오늘의 럭키 정보."""
    yongshin = analysis["yong_shin"]["yong_shin"]
    color = LUCKY_COLOR_BY_ELEMENT.get(yongshin, ["흰색"])
    direction = LUCKY_DIRECTION_BY_ELEMENT.get(yongshin, "중앙")

    # 럭키 숫자: 오늘 일진 천간+지지 인덱스 합 % 9 + 1 → 1~9
    day_stem_idx, day_branch_idx = _day_stem_branch_idx(target_date)
    number = (day_stem_idx + day_branch_idx) % 9 + 1

    # 행운/위험 신호
    good_signs: List[str] = []
    warnings: List[str] = []

    today_stem = HEAVENLY_STEMS[day_stem_idx]
    today_branch = EARTHLY_BRANCHES[day_branch_idx]
    day_master = saju["day_stem"]

    # 오늘 천간이 일간의 재성(剋받는)이면 재물 기회
    day_elem = FIVE_ELEMENTS[day_master]
    today_elem = FIVE_ELEMENTS[today_stem]
    from .constants import KE_CYCLE
    if KE_CYCLE.get(day_elem) == today_elem:
        good_signs.append("재물 기회가 다가옵니다")
    if KE_CYCLE.get(today_elem) == day_elem:
        warnings.append("강한 압박이 있을 수 있어요")

    # 오늘 지지가 일지와 6충이면 경고
    LIU_CHONG = {
        frozenset(["子", "午"]), frozenset(["丑", "未"]),
        frozenset(["寅", "申"]), frozenset(["卯", "酉"]),
        frozenset(["辰", "戌"]), frozenset(["巳", "亥"]),
    }
    day_branch = saju["day"]["branch"]
    if frozenset([day_branch, today_branch]) in LIU_CHONG:
        warnings.append("대인 갈등에 주의하세요")

    # 오늘 지지가 일지와 6합이면 행운
    LIU_HE = {
        frozenset(["子", "丑"]), frozenset(["寅", "亥"]),
        frozenset(["卯", "戌"]), frozenset(["辰", "酉"]),
        frozenset(["巳", "申"]), frozenset(["午", "未"]),
    }
    if frozenset([day_branch, today_branch]) in LIU_HE:
        good_signs.append("좋은 인연을 만날 수 있어요")

    return {
        "color": color,
        "number": number,
        "direction": direction,
        "good_signs": good_signs,
        "warnings": warnings,
    }
  • Step 4: Run tests, PASS

Run: cd saju-lab && python -m pytest tests/test_lucky.py -v Expected: 6 passed

  • Step 5: Commit
git add saju-lab/app/calculator/lucky.py saju-lab/tests/test_lucky.py
git commit -m "feat(saju-lab): lucky.py — 럭키 컬러/숫자/방향 + 행운/위험 알림 (6 tests)"

Task 3: calculator/monthly_flow.py — 12개월 운세 흐름

Files:

  • Create: saju-lab/app/calculator/monthly_flow.py

  • Create: saju-lab/tests/test_monthly_flow.py

  • Step 1: 테스트

saju-lab/tests/test_monthly_flow.py:

import pytest

from app.calculator.core import calculate_saju
from app.calculator.monthly_flow import calculate_monthly_flow


def test_returns_12_entries():
    saju = calculate_saju(1990, 5, 15, 14, "male")
    flow = calculate_monthly_flow(saju, 2026)
    assert len(flow) == 12


def test_entries_have_required_keys():
    saju = calculate_saju(1990, 5, 15, 14, "male")
    flow = calculate_monthly_flow(saju, 2026)
    for i, entry in enumerate(flow):
        assert entry["month"] == i + 1
        for k in ("stem", "branch", "score", "label"):
            assert k in entry, f"month {i+1} missing {k}"
        assert 0 <= entry["score"] <= 100


def test_labels_are_valid():
    saju = calculate_saju(1990, 5, 15, 14, "male")
    flow = calculate_monthly_flow(saju, 2026)
    valid_labels = {"변동", "성장", "안정", "도전", "정체"}
    for entry in flow:
        assert entry["label"] in valid_labels


def test_different_sajus_different_flows():
    s1 = calculate_saju(1990, 5, 15, 14, "male")
    s2 = calculate_saju(1985, 1, 1, 0, "female")
    f1 = calculate_monthly_flow(s1, 2026)
    f2 = calculate_monthly_flow(s2, 2026)
    scores_1 = [e["score"] for e in f1]
    scores_2 = [e["score"] for e in f2]
    assert scores_1 != scores_2
  • Step 2: Run, FAIL

Run: cd saju-lab && python -m pytest tests/test_monthly_flow.py -v Expected: FAIL

  • Step 3: 구현

saju-lab/app/calculator/monthly_flow.py:

"""12개월 운세 흐름 — 월운(月運) + 일간 관계."""
from typing import List

from .constants import (
    HEAVENLY_STEMS, EARTHLY_BRANCHES,
    FIVE_ELEMENTS, SHENG_CYCLE, KE_CYCLE,
)


# 寅월부터 시작하는 월지 순서 (1월=寅, 2월=卯, ..., 12월=丑)
MONTH_BRANCHES = ["寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥", "子", "丑"]

# 6충/6합
LIU_CHONG = {
    frozenset(["子", "午"]), frozenset(["丑", "未"]),
    frozenset(["寅", "申"]), frozenset(["卯", "酉"]),
    frozenset(["辰", "戌"]), frozenset(["巳", "亥"]),
}
LIU_HE = {
    frozenset(["子", "丑"]), frozenset(["寅", "亥"]),
    frozenset(["卯", "戌"]), frozenset(["辰", "酉"]),
    frozenset(["巳", "申"]), frozenset(["午", "未"]),
}


def _month_stem_for_year(year_stem: str, branch_idx: int) -> str:
    """월간(月干) — 五虎遁訣 아닌 saju-web의 단순 공식: (yearStemIdx * 2 + branchIdx) % 10."""
    year_stem_idx = HEAVENLY_STEMS.index(year_stem)
    stem_idx = (year_stem_idx * 2 + branch_idx) % 10
    return HEAVENLY_STEMS[stem_idx]


def _label_for(score: int) -> str:
    """점수 → 라벨."""
    if score >= 80:
        return "성장"
    if score >= 65:
        return "안정"
    if score >= 50:
        return "변동"
    if score >= 35:
        return "도전"
    return "정체"


def calculate_monthly_flow(saju: dict, year: int) -> List[dict]:
    """12개월 운세 흐름.

    각 월: 월간/월지 + 일간과의 관계로 score 산출.
    """
    day_stem = saju["day_stem"]
    day_element = FIVE_ELEMENTS[day_stem]
    day_branch = saju["day"]["branch"]
    year_stem = saju["year"]["stem"]

    flow: List[dict] = []
    for i, branch in enumerate(MONTH_BRANCHES):
        branch_idx = EARTHLY_BRANCHES.index(branch)
        stem = _month_stem_for_year(year_stem, branch_idx)
        stem_element = FIVE_ELEMENTS[stem]
        branch_element = FIVE_ELEMENTS[branch]

        score = 60

        # 천간 관계: 상생/상극
        if SHENG_CYCLE.get(day_element) == stem_element:
            score += 5  # 일간이 월간 생함 (식상)
        elif SHENG_CYCLE.get(stem_element) == day_element:
            score += 10  # 월간이 일간 생함 (인성)
        elif KE_CYCLE.get(day_element) == stem_element:
            score += 8   # 일간이 월간 극함 (재성)
        elif KE_CYCLE.get(stem_element) == day_element:
            score -= 8   # 월간이 일간 극함 (관성, 부정적)
        elif stem_element == day_element:
            score += 3   # 비겁

        # 지지 관계: 합/충
        if frozenset([day_branch, branch]) in LIU_HE:
            score += 10
        elif frozenset([day_branch, branch]) in LIU_CHONG:
            score -= 12

        # 지지 오행 관계
        if SHENG_CYCLE.get(branch_element) == day_element:
            score += 4
        elif KE_CYCLE.get(branch_element) == day_element:
            score -= 4

        score = max(0, min(100, score))

        flow.append({
            "month": i + 1,
            "stem": stem,
            "branch": branch,
            "score": score,
            "label": _label_for(score),
        })

    return flow
  • Step 4: Run, PASS

Run: cd saju-lab && python -m pytest tests/test_monthly_flow.py -v Expected: 4 passed

  • Step 5: Commit
git add saju-lab/app/calculator/monthly_flow.py saju-lab/tests/test_monthly_flow.py
git commit -m "feat(saju-lab): monthly_flow.py — 12개월 운세 흐름 (4 tests)"

Task 4: db.py — saju_records 3 컬럼 추가 (ALTER TABLE)

Files:

  • Modify: saju-lab/app/db.py

  • Create: saju-lab/tests/test_db_migration.py

  • Step 1: 마이그레이션 테스트

saju-lab/tests/test_db_migration.py:

import sqlite3
import pytest

from app import db as db_module


@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
    db_file = tmp_path / "test_saju.db"
    monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
    yield
    try:
        if db_file.exists():
            db_file.unlink()
    except PermissionError:
        pass


def test_new_columns_exist():
    """init_db 후 3 신규 컬럼이 존재."""
    db_module.init_db()
    conn = sqlite3.connect(db_module.DB_PATH)
    cols = [row[1] for row in conn.execute("PRAGMA table_info(saju_records)").fetchall()]
    conn.close()
    assert "fortune_scores_json" in cols
    assert "lucky_json" in cols
    assert "monthly_flow_json" in cols


def test_idempotent_init():
    """init_db를 두 번 호출해도 에러 없음."""
    db_module.init_db()
    db_module.init_db()  # 두 번째 호출 — ALTER TABLE이 OperationalError 캐치


def test_save_and_get_with_new_fields():
    """새 필드 포함 저장 + 조회."""
    db_module.init_db()
    rid = db_module.save_saju_record({
        "birth_year": 1990, "birth_month": 5, "birth_day": 15, "birth_hour": 14,
        "gender": "male", "calendar_type": "solar",
        "saju_data": {"day_stem": "辛"},
        "analysis_data": {"element_balance": {"金": 3.0}},
        "daeun_data": [{"age": 10}],
        "interpretation_json": {"items": []},
        "model": "claude-sonnet-4-6",
        "tokens_in": 100, "tokens_out": 200, "cost_usd": 0.005,
        "fortune_scores_json": {"wealth": 80, "romance": 60, "social": 70, "career": 75, "overall": 73},
        "lucky_json": {"color": ["청록"], "number": 5, "direction": "동쪽", "good_signs": ["S1"], "warnings": []},
        "monthly_flow_json": [{"month": 1, "stem": "壬", "branch": "寅", "score": 65, "label": "변동"}],
    })
    row = db_module.get_saju_record(rid)
    assert row["fortune_scores"]["wealth"] == 80
    assert row["lucky"]["number"] == 5
    assert row["monthly_flow"][0]["month"] == 1


def test_save_without_new_fields_backwards_compat():
    """기존 호출 패턴 (새 필드 없이) 도 동작 (NULL 저장)."""
    db_module.init_db()
    rid = db_module.save_saju_record({
        "birth_year": 1990, "birth_month": 5, "birth_day": 15, "birth_hour": None,
        "gender": "male",
        "saju_data": {}, "analysis_data": {}, "daeun_data": [],
        "model": "x",
    })
    row = db_module.get_saju_record(rid)
    assert row["fortune_scores"] is None
    assert row["lucky"] is None
    assert row["monthly_flow"] is None
  • Step 2: Run, expect FAIL

Run: cd saju-lab && python -m pytest tests/test_db_migration.py -v Expected: FAIL — column doesn't exist

  • Step 3: db.py 수정

saju-lab/app/db.pyinit_db() 함수 끝(L70 직전)에 ALTER TABLE 추가:

def init_db() -> None:
    with _conn() as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS saju_records (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                birth_year INTEGER NOT NULL,
                birth_month INTEGER NOT NULL,
                birth_day INTEGER NOT NULL,
                birth_hour INTEGER,
                gender TEXT NOT NULL,
                calendar_type TEXT DEFAULT 'solar',
                saju_data TEXT NOT NULL,
                analysis_data TEXT NOT NULL,
                daeun_data TEXT NOT NULL,
                interpretation_json TEXT,
                model TEXT,
                tokens_in INTEGER DEFAULT 0,
                tokens_out INTEGER DEFAULT 0,
                cost_usd REAL DEFAULT 0,
                latency_ms INTEGER DEFAULT 0,
                reroll_count INTEGER DEFAULT 0,
                favorite INTEGER DEFAULT 0,
                memo TEXT,
                created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
            )
        """)
        conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_saju_created
            ON saju_records(created_at DESC)
        """)
        # 신규 컬럼 ALTER (idempotent — 이미 있으면 OperationalError로 skip)
        for col in (
            "fortune_scores_json TEXT",
            "lucky_json TEXT",
            "monthly_flow_json TEXT",
        ):
            try:
                conn.execute(f"ALTER TABLE saju_records ADD COLUMN {col}")
            except sqlite3.OperationalError:
                pass  # already exists

        conn.execute("""
            CREATE TABLE IF NOT EXISTS compat_records (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                person_a TEXT NOT NULL,
                person_b TEXT NOT NULL,
                saju_a TEXT NOT NULL,
                saju_b TEXT NOT NULL,
                score INTEGER NOT NULL,
                breakdown TEXT NOT NULL,
                interpretation_json TEXT,
                model TEXT,
                tokens_in INTEGER DEFAULT 0,
                tokens_out INTEGER DEFAULT 0,
                cost_usd REAL DEFAULT 0,
                latency_ms INTEGER DEFAULT 0,
                reroll_count INTEGER DEFAULT 0,
                favorite INTEGER DEFAULT 0,
                memo TEXT,
                created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
            )
        """)

그리고 save_saju_record를 신규 컬럼 저장하도록 수정:

def save_saju_record(data: Dict[str, Any]) -> int:
    with _conn() as conn:
        cur = conn.execute(
            """INSERT INTO saju_records
               (birth_year, birth_month, birth_day, birth_hour, gender, calendar_type,
                saju_data, analysis_data, daeun_data, interpretation_json,
                model, tokens_in, tokens_out, cost_usd, latency_ms, reroll_count,
                fortune_scores_json, lucky_json, monthly_flow_json)
               VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
            (
                data["birth_year"], data["birth_month"], data["birth_day"],
                data.get("birth_hour"), data["gender"], data.get("calendar_type", "solar"),
                json.dumps(data["saju_data"], ensure_ascii=False),
                json.dumps(data["analysis_data"], ensure_ascii=False),
                json.dumps(data["daeun_data"], ensure_ascii=False),
                json.dumps(data.get("interpretation_json"), ensure_ascii=False) if data.get("interpretation_json") else None,
                data.get("model"),
                data.get("tokens_in", 0), data.get("tokens_out", 0),
                data.get("cost_usd", 0.0), data.get("latency_ms", 0),
                data.get("reroll_count", 0),
                json.dumps(data["fortune_scores_json"], ensure_ascii=False) if data.get("fortune_scores_json") else None,
                json.dumps(data["lucky_json"], ensure_ascii=False) if data.get("lucky_json") else None,
                json.dumps(data["monthly_flow_json"], ensure_ascii=False) if data.get("monthly_flow_json") else None,
            ),
        )
        return int(cur.lastrowid)

그리고 _saju_row_to_dict에 3 컬럼 응답 추가:

def _saju_row_to_dict(r) -> Dict[str, Any]:
    def _safe_json(val):
        if val is None:
            return None
        try:
            return json.loads(val)
        except (ValueError, TypeError):
            return None

    return {
        "id": r["id"],
        "created_at": r["created_at"],
        "birth_year": r["birth_year"], "birth_month": r["birth_month"], "birth_day": r["birth_day"],
        "birth_hour": r["birth_hour"], "gender": r["gender"], "calendar_type": r["calendar_type"],
        "saju_data": _safe_json(r["saju_data"]),
        "analysis_data": _safe_json(r["analysis_data"]),
        "daeun_data": _safe_json(r["daeun_data"]),
        "interpretation_json": _safe_json(r["interpretation_json"]),
        "model": r["model"], "tokens_in": r["tokens_in"], "tokens_out": r["tokens_out"],
        "cost_usd": r["cost_usd"], "latency_ms": r["latency_ms"], "reroll_count": r["reroll_count"],
        "favorite": int(r["favorite"]), "memo": r["memo"],
        # 신규
        "fortune_scores": _safe_json(r["fortune_scores_json"]) if "fortune_scores_json" in r.keys() else None,
        "lucky": _safe_json(r["lucky_json"]) if "lucky_json" in r.keys() else None,
        "monthly_flow": _safe_json(r["monthly_flow_json"]) if "monthly_flow_json" in r.keys() else None,
    }
  • Step 4: Run all db tests, PASS

Run: cd saju-lab && python -m pytest tests/test_db.py tests/test_db_migration.py -v Expected: 10 (기존 test_db.py) + 4 (test_db_migration.py) = 14 passed

만약 기존 test_db.py가 실패하면 (row.keys() 검사 등 행동 변화로 인해), 기존 테스트 확인 후 호환 유지.

  • Step 5: Commit
git add saju-lab/app/db.py saju-lab/tests/test_db_migration.py
git commit -m "feat(saju-lab): db.py — saju_records 3 컬럼 추가 (fortune_scores/lucky/monthly_flow) + 4 마이그레이션 테스트"

Task 5: routers/saju.py + models.py — 응답 확장

Files:

  • Modify: saju-lab/app/models.py

  • Modify: saju-lab/app/routers/saju.py

  • Modify: saju-lab/tests/test_routes.py

  • Step 1: models.py에 3 필드 추가

saju-lab/app/models.pySajuInterpretResponse 클래스 수정:

class SajuInterpretResponse(BaseModel):
    saju: dict
    analysis: dict
    daeun: List[dict]
    interpretation_json: dict
    reading_id: int
    model: str
    tokens_in: int
    tokens_out: int
    cost_usd: float
    latency_ms: int
    reroll_count: int = 0
    fortune_scores: dict
    lucky: dict
    monthly_flow: List[dict]
  • Step 2: routers/saju.py 수정

saju-lab/app/routers/saju.pyinterpret_saju_endpoint 함수에 계산 + 저장 + 응답 추가. 기존 코드를 다음으로 교체:

@router.post("/interpret", response_model=SajuInterpretResponse)
async def interpret_saju_endpoint(req: SajuInterpretRequest):
    """사주 입력 → 계산 + AI 해석 + DB 저장."""
    from datetime import date

    # 음력 입력 시 양력 변환
    if req.calendar_type == "lunar":
        sy, sm, sd = lunar_to_solar(req.year, req.month, req.day, req.is_leap_month)
    else:
        sy, sm, sd = req.year, req.month, req.day

    try:
        saju = calculate_saju(sy, sm, sd, req.hour, req.gender)
        analysis = perform_full_analysis(saju, 2026)
        daeun = calculate_daeun(sy, sm, sd, req.gender, saju["month"]["stem"], saju["month"]["branch"])
        # 신규 — 4 카테고리 + 럭키 + 월운
        from ..calculator.fortune_scores import calculate_fortune_scores
        from ..calculator.lucky import calculate_lucky
        from ..calculator.monthly_flow import calculate_monthly_flow
        fortune_scores = calculate_fortune_scores(saju, analysis, 2026)
        lucky = calculate_lucky(saju, analysis, date.today())
        monthly_flow = calculate_monthly_flow(saju, 2026)
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"계산 실패: {e}")

    try:
        interp_result = await pipeline.interpret_saju(saju, analysis, daeun, 2026)
    except pipeline.SajuError as e:
        raise HTTPException(status_code=500, detail=str(e)) from e

    rid = db_module.save_saju_record({
        "birth_year": req.year, "birth_month": req.month, "birth_day": req.day,
        "birth_hour": req.hour, "gender": req.gender,
        "calendar_type": req.calendar_type,
        "saju_data": saju,
        "analysis_data": analysis,
        "daeun_data": daeun,
        "interpretation_json": interp_result["interpretation_json"],
        "model": interp_result["model"],
        "tokens_in": interp_result["tokens_in"],
        "tokens_out": interp_result["tokens_out"],
        "cost_usd": interp_result["cost_usd"],
        "latency_ms": interp_result["latency_ms"],
        "reroll_count": interp_result["reroll_count"],
        "fortune_scores_json": fortune_scores,
        "lucky_json": lucky,
        "monthly_flow_json": monthly_flow,
    })

    return {
        "saju": saju,
        "analysis": analysis,
        "daeun": daeun,
        "interpretation_json": interp_result["interpretation_json"],
        "reading_id": rid,
        "model": interp_result["model"],
        "tokens_in": interp_result["tokens_in"],
        "tokens_out": interp_result["tokens_out"],
        "cost_usd": interp_result["cost_usd"],
        "latency_ms": interp_result["latency_ms"],
        "reroll_count": interp_result["reroll_count"],
        "fortune_scores": fortune_scores,
        "lucky": lucky,
        "monthly_flow": monthly_flow,
    }
  • Step 3: test_routes.py 수정 — 응답에 새 필드 검증 추가

기존 test_saju_interpret_endpoint 테스트의 assert 부분 강화:

def test_saju_interpret_endpoint(monkeypatch):
    """saju interpret이 pipeline mock으로 동작 + 신규 필드 검증."""
    async def fake_interpret(*args, **kwargs):
        return _interpret_result()

    from app.routers import saju as saju_router
    monkeypatch.setattr(saju_router.pipeline, "interpret_saju", fake_interpret)

    with TestClient(app) as c:
        r = c.post("/api/saju/interpret", json={
            "year": 1990, "month": 5, "day": 15, "hour": 14,
            "gender": "male", "calendar_type": "solar"
        })
        assert r.status_code == 200, r.text
        data = r.json()
        assert "saju" in data
        assert "analysis" in data
        assert "daeun" in data
        assert "reading_id" in data
        assert data["reading_id"] > 0
        # 신규 필드
        assert "fortune_scores" in data
        for k in ("wealth", "romance", "social", "career", "overall"):
            assert k in data["fortune_scores"]
            assert 0 <= data["fortune_scores"][k] <= 100
        assert "lucky" in data
        for k in ("color", "number", "direction", "good_signs", "warnings"):
            assert k in data["lucky"]
        assert "monthly_flow" in data
        assert len(data["monthly_flow"]) == 12
  • Step 4: Run all route tests

Run: cd saju-lab && python -m pytest tests/test_routes.py -v Expected: 10 passed (기존 10개, 그중 1개 강화)

  • Step 5: Commit
git add saju-lab/app/models.py saju-lab/app/routers/saju.py saju-lab/tests/test_routes.py
git commit -m "feat(saju-lab): /interpret 응답에 fortune_scores + lucky + monthly_flow 포함"

Phase B — 캐릭터 자산 추출

Task 6: 호령 6 PNG 추출 (Python PIL)

Files:

  • Create: scripts/extract_horyung.py (web-backend 또는 web-ui 어디든)

  • Create: web-ui/public/images/saju/horyung/horyung-front.png

  • Create: web-ui/public/images/saju/horyung/horyung-bust.png

  • Create: web-ui/public/images/saju/horyung/horyung-greeting.png

  • Create: web-ui/public/images/saju/horyung/horyung-thinking.png

  • Create: web-ui/public/images/saju/horyung/horyung-pointing.png

  • Create: web-ui/public/images/saju/horyung/horyung-happy.png

  • Step 1: 소스 이미지 크기 확인

Run:

python -c "from PIL import Image; im = Image.open('C:/Users/jaeoh/Desktop/workspace/source/characters/horyung.png'); print(im.size)"
python -c "from PIL import Image; im = Image.open('C:/Users/jaeoh/Desktop/workspace/source/images/saju_page/saju_color_sheet.png'); print(im.size)"

기록한 두 이미지 크기를 다음 step에 사용.

  • Step 2: 추출 스크립트 작성

C:/Users/jaeoh/Desktop/workspace/web-ui/scripts/extract_horyung.py:

"""호령 캐릭터 PNG 추출 — horyung.png(3 view) + saju_color_sheet.png(8 emotion).

Usage:
    cd web-ui
    python scripts/extract_horyung.py
"""
import os
from PIL import Image

SOURCE_ROOT = "../source"
HORYUNG_PATH = f"{SOURCE_ROOT}/characters/horyung.png"
COLORSHEET_PATH = f"{SOURCE_ROOT}/images/saju_page/saju_color_sheet.png"

OUT_DIR = "public/images/saju/horyung"
os.makedirs(OUT_DIR, exist_ok=True)


def crop_save(src_path, box, out_name):
    """src에서 box=(x1,y1,x2,y2) 영역 crop → out_name으로 저장."""
    im = Image.open(src_path).convert("RGBA")
    cropped = im.crop(box)
    cropped.save(f"{OUT_DIR}/{out_name}")
    print(f"saved {out_name} ({cropped.size})")


# horyung.png — 3 view 레이아웃 (실제 크기에 따라 좌표 조정 필요).
# Step 1에서 측정한 실제 크기로 다음 비율 계산:
# 시안 horyung.png는 대략 1200x1700 (세로형). 3 view는 세로로 분할:
# - bust shot: 상단 1/3
# - back view: 중단 1/3
# - front view: 하단 1/3 (메인 hero용)

# 다음은 1200x1700 기준 예시 좌표 — 실제 크기에 맞춰 조정:
crop_save(HORYUNG_PATH, (300, 30, 900, 570), "horyung-bust.png")     # bust shot
crop_save(HORYUNG_PATH, (250, 1130, 950, 1690), "horyung-front.png") # front view (메인 hero)

# saju_color_sheet.png — 우측 하단 캐릭터 감정 스티커 8개 (가로 4 x 세로 2)
# 시안 컬러시트는 대략 1600x1100. 8 emotion sticker는 우측 하단 영역 (대략 x=1100~1600, y=850~1100)
# 각 sticker는 약 125x125
EMOTION_BASE_X = 1100
EMOTION_BASE_Y = 850
EMOTION_W = 125
EMOTION_H = 125

# 4 emotion만 추출 (8 중 가장 유용한 4개)
crop_save(COLORSHEET_PATH,
          (EMOTION_BASE_X, EMOTION_BASE_Y, EMOTION_BASE_X + EMOTION_W, EMOTION_BASE_Y + EMOTION_H),
          "horyung-greeting.png")
crop_save(COLORSHEET_PATH,
          (EMOTION_BASE_X + EMOTION_W, EMOTION_BASE_Y, EMOTION_BASE_X + EMOTION_W*2, EMOTION_BASE_Y + EMOTION_H),
          "horyung-thinking.png")
crop_save(COLORSHEET_PATH,
          (EMOTION_BASE_X + EMOTION_W*2, EMOTION_BASE_Y, EMOTION_BASE_X + EMOTION_W*3, EMOTION_BASE_Y + EMOTION_H),
          "horyung-pointing.png")
crop_save(COLORSHEET_PATH,
          (EMOTION_BASE_X + EMOTION_W*3, EMOTION_BASE_Y, EMOTION_BASE_X + EMOTION_W*4, EMOTION_BASE_Y + EMOTION_H),
          "horyung-happy.png")

print("Done.")
  • Step 3: 실행 + 결과 확인
cd C:/Users/jaeoh/Desktop/workspace/web-ui
pip install Pillow  # 이미 있으면 skip
python scripts/extract_horyung.py
ls public/images/saju/horyung/

Expected: 6 PNG 파일 생성 (horyung-front.png, horyung-bust.png, horyung-greeting.png, horyung-thinking.png, horyung-pointing.png, horyung-happy.png)

  • Step 4: 시각 검증

각 PNG를 image viewer로 열어 확인. 좌표가 부정확하면 step 2의 box 좌표를 조정 후 step 3 재실행.

품질 기준:

  • horyung-front.png: 호령 전신이 중앙에 위치, 여백 적절
  • horyung-bust.png: 호령 얼굴 + 어깨까지 보임
  • horyung-greeting/thinking/pointing/happy.png: 각 표정 잘 보이고 잘림 없음

좌표 조정 필요 시 step 2 코드 수정 후 다시 실행.

  • Step 5: Commit (web-ui repo)
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add scripts/extract_horyung.py public/images/saju/horyung/
git commit -m "feat(saju): 호령 캐릭터 PNG 6개 추출 (horyung.png + saju_color_sheet.png)"

⚠️ Step 4에서 시각 검증 후 좌표 조정이 여러 번 필요할 수 있음. 모든 PNG가 시안 품질에 가까워야 다음 task로 진행.


Phase C — 프론트엔드 구축 (web-ui)

Phase C부터 모든 작업은 C:/Users/jaeoh/Desktop/workspace/web-ui에서. 별도 git 저장소이므로 commit도 web-ui repo에 들어감.

Task 7: Saju.css + 폰트 + 글로벌 토큰

Files:

  • Create: web-ui/src/pages/saju/Saju.css

  • Modify: web-ui/index.html (Noto Serif KR Google Fonts 추가)

  • Step 1: index.html에 Noto Serif KR 추가

web-ui/index.html<head> 안에 추가:

<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=Noto+Serif+KR:wght@500;700&display=swap" rel="stylesheet">

기존 Pretendard 폰트는 유지.

  • Step 2: Saju.css 작성

web-ui/src/pages/saju/Saju.css:

/* saju-page scope — 다른 페이지에 영향 없음 */
.saju-page {
  /* 베이스 */
  --saju-cream: #FAF6EE;
  --saju-paper: #F2EAD8;
  --saju-ink: #2E2D45;
  --saju-ink-deep: #1F1D38;

  /* 액센트 */
  --saju-gold: #D4A574;
  --saju-gold-deep: #B5874E;
  --saju-apricot: #C58F76;
  --saju-rose: #D9A2A6;
  --saju-jade: #4B7065;
  --saju-violet: #6A5285;

  /* 카테고리 (3 ActionCard) */
  --saju-today-bg: #4B7065;
  --saju-gunghab-bg: #A8736E;
  --saju-saju-bg: #4F4A78;

  /* 점수 카테고리 (4 ScoreCard) */
  --saju-wealth: #D4A574;
  --saju-romance: #D9A2A6;
  --saju-social: #4B7065;
  --saju-career: #6A5285;

  min-height: 100vh;
  background: var(--saju-cream);
  color: var(--saju-ink);
  font-family: 'Pretendard', sans-serif;
  padding: 0;
  margin: 0;
}

.saju-page * {
  box-sizing: border-box;
}

.saju-page .saju-h1,
.saju-page .saju-h2,
.saju-page .saju-h3 {
  font-family: 'Noto Serif KR', 'Pretendard', serif;
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--saju-ink);
  margin: 0;
}

.saju-page .saju-h1 { font-size: clamp(2.5rem, 4vw, 3.5rem); line-height: 1.2; }
.saju-page .saju-h2 { font-size: clamp(1.8rem, 3vw, 2.5rem); line-height: 1.3; }
.saju-page .saju-h3 { font-size: clamp(1.2rem, 2vw, 1.5rem); }

/* 호령 마스코트 */
.horyung-mascot {
  display: block;
  object-fit: contain;
}
.horyung-mascot--sm { width: 80px; height: auto; }
.horyung-mascot--md { width: 180px; height: auto; }
.horyung-mascot--lg { width: 320px; height: auto; }

/* 상단 네비게이션 */
.saju-nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1rem 2rem;
  background: var(--saju-ink);
  color: var(--saju-cream);
}
.saju-nav__logo {
  font-family: 'Noto Serif KR', serif;
  font-size: 1.25rem;
  font-weight: 700;
}
.saju-nav__links {
  display: flex;
  gap: 1.5rem;
  list-style: none;
  padding: 0;
  margin: 0;
}
.saju-nav__links a {
  color: var(--saju-cream);
  text-decoration: none;
  font-size: 0.95rem;
  opacity: 0.85;
}
.saju-nav__links a:hover { opacity: 1; }
.saju-nav__cta {
  background: var(--saju-gold);
  color: var(--saju-ink);
  border: none;
  padding: 0.5rem 1.25rem;
  border-radius: 999px;
  font-weight: 600;
  cursor: pointer;
  font-family: 'Pretendard', sans-serif;
}

/* Hero (메인) */
.saju-hero {
  display: grid;
  grid-template-columns: 1fr 1.4fr;
  gap: 3rem;
  padding: 3rem 2rem;
  max-width: 1400px;
  margin: 0 auto;
}
.saju-hero__left {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.5rem;
}
.saju-quote-box {
  background: var(--saju-paper);
  padding: 1rem 1.25rem;
  border-radius: 12px;
  border: 1px solid var(--saju-gold-deep);
  color: var(--saju-ink);
  font-size: 0.9rem;
  line-height: 1.5;
  max-width: 280px;
}
.saju-hero__right {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
  justify-content: center;
}
.saju-sub {
  color: var(--saju-ink);
  opacity: 0.7;
  margin: 0;
  line-height: 1.6;
}

/* ActionCard */
.saju-action-cards {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1rem;
  margin-top: 1rem;
}
.saju-action-card {
  background: var(--saju-saju-bg);
  color: var(--saju-cream);
  padding: 1.5rem 1rem;
  border-radius: 16px;
  text-decoration: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  transition: transform 0.2s;
  font-family: 'Pretendard', sans-serif;
}
.saju-action-card:hover { transform: translateY(-4px); }
.saju-action-card--today { background: var(--saju-today-bg); }
.saju-action-card--gunghab { background: var(--saju-gunghab-bg); }
.saju-action-card--saju { background: var(--saju-saju-bg); }
.saju-action-card[aria-disabled="true"] {
  opacity: 0.6;
  cursor: not-allowed;
}
.saju-action-card__icon { font-size: 2rem; }
.saju-action-card__title { font-size: 1.1rem; font-weight: 700; }
.saju-action-card__desc { font-size: 0.85rem; opacity: 0.85; text-align: center; }

/* Bottom (메인 — 통계 + 입력) */
.saju-bottom {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 3rem;
  padding: 3rem 2rem;
  max-width: 1400px;
  margin: 0 auto;
  background: var(--saju-ink);
  color: var(--saju-cream);
  border-radius: 24px 24px 0 0;
}
.saju-form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}
.saju-form input,
.saju-form select {
  padding: 0.75rem;
  border-radius: 8px;
  border: 1px solid var(--saju-gold-deep);
  background: var(--saju-ink-deep);
  color: var(--saju-cream);
  font-family: inherit;
  font-size: 1rem;
}
.saju-form button {
  background: var(--saju-gold);
  color: var(--saju-ink);
  border: none;
  padding: 0.875rem;
  border-radius: 999px;
  font-weight: 700;
  cursor: pointer;
  font-family: inherit;
  font-size: 1rem;
}
.saju-form button:disabled { opacity: 0.6; cursor: not-allowed; }
.saju-form__error {
  background: rgba(217, 162, 166, 0.2);
  color: var(--saju-rose);
  padding: 0.75rem;
  border-radius: 8px;
  font-size: 0.9rem;
}

/* Score / Fortune ring */
.saju-fortune-ring {
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
}
.saju-fortune-ring svg { width: 200px; height: 200px; }
.saju-fortune-ring__score {
  position: absolute;
  font-family: 'Noto Serif KR', serif;
  font-size: 2.5rem;
  font-weight: 700;
  color: var(--saju-ink);
}
.saju-fortune-ring__total { font-size: 0.9rem; color: var(--saju-ink); opacity: 0.6; }

.saju-score-card {
  background: var(--saju-cream);
  border-radius: 16px;
  padding: 1.25rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  border: 1px solid var(--saju-paper);
}
.saju-score-card__head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.saju-score-card__icon { font-size: 1.5rem; }
.saju-score-card__title { font-weight: 700; font-size: 0.95rem; }
.saju-score-card__value {
  font-family: 'Noto Serif KR', serif;
  font-size: 2rem;
  font-weight: 700;
  color: var(--saju-ink);
}
.saju-score-card__bar {
  height: 6px;
  background: var(--saju-paper);
  border-radius: 3px;
  overflow: hidden;
}
.saju-score-card__bar > div {
  height: 100%;
  background: var(--saju-gold);
  transition: width 0.5s;
}

/* Lucky box */
.saju-lucky-box {
  background: var(--saju-paper);
  border-radius: 16px;
  padding: 1.5rem;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1rem;
}
.saju-lucky-box__item { text-align: center; }
.saju-lucky-box__label {
  font-size: 0.8rem;
  color: var(--saju-ink);
  opacity: 0.7;
  margin-bottom: 0.25rem;
}
.saju-lucky-box__value {
  font-family: 'Noto Serif KR', serif;
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--saju-ink);
}

/* SajuPillars (4기둥) */
.saju-pillars {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 0.75rem;
}
.saju-pillar {
  background: var(--saju-paper);
  border-radius: 12px;
  padding: 1rem;
  text-align: center;
}
.saju-pillar__label {
  font-size: 0.8rem;
  color: var(--saju-ink);
  opacity: 0.6;
  margin-bottom: 0.5rem;
}
.saju-pillar__stem,
.saju-pillar__branch {
  font-family: 'Noto Serif KR', serif;
  font-size: 1.75rem;
  font-weight: 700;
  display: block;
}
.saju-pillar__stem-kr,
.saju-pillar__branch-kr {
  font-size: 0.85rem;
  opacity: 0.7;
}
.saju-pillar__ten-god,
.saju-pillar__fortune {
  font-size: 0.75rem;
  margin-top: 0.25rem;
  opacity: 0.7;
}

/* Element bar chart (오행) */
.saju-element-bars {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 1.5rem;
  background: var(--saju-cream);
  border-radius: 16px;
}
.saju-element-bar {
  display: grid;
  grid-template-columns: 30px 1fr 50px;
  align-items: center;
  gap: 0.75rem;
}
.saju-element-bar__label { font-size: 0.9rem; font-weight: 700; }
.saju-element-bar__track {
  height: 12px;
  background: var(--saju-paper);
  border-radius: 6px;
  overflow: hidden;
}
.saju-element-bar__fill {
  height: 100%;
  border-radius: 6px;
  transition: width 0.5s;
}
.saju-element-bar__fill--木 { background: #4B7065; }
.saju-element-bar__fill--火 { background: #C56F5C; }
.saju-element-bar__fill--土 { background: #D4A574; }
.saju-element-bar__fill--金 { background: #B8B5A8; }
.saju-element-bar__fill--水 { background: #4A5878; }
.saju-element-bar__value { text-align: right; font-size: 0.85rem; opacity: 0.7; }

/* Monthly flow */
.saju-monthly-flow {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  gap: 0.25rem;
  padding: 1rem;
  background: var(--saju-cream);
  border-radius: 16px;
}
.saju-monthly-flow__cell {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 0.5rem 0.25rem;
  border-radius: 8px;
  background: var(--saju-paper);
}
.saju-monthly-flow__month { font-size: 0.7rem; opacity: 0.7; }
.saju-monthly-flow__score {
  font-family: 'Noto Serif KR', serif;
  font-weight: 700;
  font-size: 1rem;
}
.saju-monthly-flow__label { font-size: 0.7rem; opacity: 0.8; margin-top: 0.25rem; }

/* Horyung quote */
.saju-horyung-quote {
  background: var(--saju-ink);
  color: var(--saju-cream);
  padding: 1.5rem;
  border-radius: 16px;
  display: flex;
  gap: 1rem;
  align-items: flex-start;
}
.saju-horyung-quote__text {
  font-size: 0.95rem;
  line-height: 1.6;
}

/* Interpret accordion */
.saju-interpret-accordion {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.saju-interpret-item {
  background: var(--saju-cream);
  border-radius: 12px;
  border: 1px solid var(--saju-paper);
  overflow: hidden;
}
.saju-interpret-item__header {
  padding: 1rem;
  background: var(--saju-paper);
  cursor: pointer;
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-weight: 700;
  user-select: none;
}
.saju-interpret-item__body {
  padding: 1rem;
  font-size: 0.95rem;
  line-height: 1.6;
}
.saju-interpret-item__evidence {
  background: var(--saju-paper);
  padding: 0.75rem;
  border-radius: 8px;
  margin-top: 0.75rem;
  font-size: 0.85rem;
  opacity: 0.85;
}

/* Stub (Compatibility v2) */
.saju-stub {
  max-width: 480px;
  margin: 5rem auto;
  text-align: center;
  padding: 2rem;
  background: var(--saju-paper);
  border-radius: 24px;
}
.saju-stub a {
  display: inline-block;
  margin-top: 1.5rem;
  background: var(--saju-gold);
  color: var(--saju-ink);
  padding: 0.75rem 1.5rem;
  border-radius: 999px;
  text-decoration: none;
  font-weight: 700;
}

/* 반응형 */
@media (max-width: 1280px) {
  .saju-hero { grid-template-columns: 1fr; text-align: center; }
  .saju-hero__left { order: 2; }
  .saju-hero__right { order: 1; }
  .saju-bottom { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
  .saju-nav { padding: 0.75rem 1rem; flex-wrap: wrap; gap: 0.5rem; }
  .saju-nav__links { display: none; }
  .saju-action-cards { grid-template-columns: 1fr; }
  .saju-pillars { grid-template-columns: repeat(2, 1fr); }
  .saju-monthly-flow { grid-template-columns: repeat(4, 1fr); }
  .horyung-mascot--lg { width: 220px; }
}
  • Step 3: 빌드 검증
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run build 2>&1 | tail -20

Expected: 빌드 성공 (CSS 파일 정상 처리)

  • Step 4: Commit (web-ui)
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add src/pages/saju/Saju.css index.html
git commit -m "feat(saju): Saju.css 컬러 토큰 + 폰트 + 격리 + Noto Serif KR Google Fonts"

Task 8: HoryungMascot + SajuNav 공통 컴포넌트

Files:

  • Create: web-ui/src/pages/saju/components/HoryungMascot.jsx

  • Create: web-ui/src/pages/saju/components/SajuNav.jsx

  • Step 1: HoryungMascot 작성

web-ui/src/pages/saju/components/HoryungMascot.jsx:

import React from 'react';

const POSE_TO_FILE = {
  front: '/images/saju/horyung/horyung-front.png',
  bust: '/images/saju/horyung/horyung-bust.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 HoryungMascot({ pose = 'front', size = 'lg', className = '' }) {
  const src = POSE_TO_FILE[pose] || POSE_TO_FILE.front;
  return (
    <img
      src={src}
      alt="호령"
      className={`horyung-mascot horyung-mascot--${size} ${className}`}
      onError={(e) => { e.target.style.visibility = 'hidden'; }}
    />
  );
}
  • Step 2: SajuNav 작성

web-ui/src/pages/saju/components/SajuNav.jsx:

import React from 'react';
import { Link, NavLink } from 'react-router-dom';

export default function SajuNav() {
  return (
    <nav className="saju-nav" aria-label="호령 사주">
      <Link to="/saju" className="saju-nav__logo">호령사주</Link>
      <ul className="saju-nav__links">
        <li><NavLink to="/saju/today">오늘의 운세</NavLink></li>
        <li><NavLink to="/saju/compatibility">궁합보기</NavLink></li>
        <li><NavLink to="/saju/result">사주풀이</NavLink></li>
      </ul>
      <Link to="/saju" className="saju-nav__cta">사주풀이 시작하기</Link>
    </nav>
  );
}
  • Step 3: 빌드 + 시각 확인
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run dev

브라우저: http://127.0.0.1:3007/saju (현재는 Task 28의 placeholder만 보임 — 다음 task에서 교체)

빌드 오류 없는지 콘솔 확인.

  • Step 4: Commit (web-ui)
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add src/pages/saju/components/HoryungMascot.jsx src/pages/saju/components/SajuNav.jsx
git commit -m "feat(saju): HoryungMascot + SajuNav 공통 컴포넌트"

Task 9: SajuInputForm + ActionCard + useSajuForm hook

Files:

  • Create: web-ui/src/pages/saju/hooks/useSajuForm.js

  • Create: web-ui/src/pages/saju/components/SajuInputForm.jsx

  • Create: web-ui/src/pages/saju/components/ActionCard.jsx

  • Step 1: useSajuForm hook

web-ui/src/pages/saju/hooks/useSajuForm.js:

import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { sajuInterpret } from '../../../api';

const INITIAL_FORM = {
  name: '',
  year: '',
  month: '',
  day: '',
  hour: '',
  gender: 'male',
  calendar_type: 'solar',
};

export default function useSajuForm() {
  const [form, setForm] = useState(INITIAL_FORM);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const navigate = useNavigate();

  const handleChange = useCallback((field, value) => {
    setForm((prev) => ({ ...prev, [field]: value }));
  }, []);

  const handleSubmit = useCallback(async (e) => {
    if (e?.preventDefault) e.preventDefault();
    setError(null);

    // 검증
    if (!form.year || !form.month || !form.day) {
      setError('생년월일을 모두 입력해주세요.');
      return;
    }
    const year = parseInt(form.year, 10);
    const month = parseInt(form.month, 10);
    const day = parseInt(form.day, 10);
    if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) {
      setError('올바른 생년월일을 입력해주세요.');
      return;
    }

    setLoading(true);
    try {
      const body = {
        year,
        month,
        day,
        gender: form.gender,
        calendar_type: form.calendar_type,
      };
      if (form.hour !== '') {
        body.hour = parseInt(form.hour, 10);
      }
      const result = await sajuInterpret(body);
      navigate(`/saju/result?rid=${result.reading_id}`);
    } catch (err) {
      console.error('사주 분석 실패', err);
      setError(err.message || '잠시 후 다시 시도해주세요.');
    } finally {
      setLoading(false);
    }
  }, [form, navigate]);

  return { form, handleChange, handleSubmit, loading, error };
}
  • Step 2: SajuInputForm

web-ui/src/pages/saju/components/SajuInputForm.jsx:

import React from 'react';

export default function SajuInputForm({ form, onChange, onSubmit, loading, error }) {
  return (
    <form className="saju-form" onSubmit={onSubmit}>
      <h3 className="saju-h3" style={{ color: 'var(--saju-cream)', marginBottom: '0.5rem' }}>
        사주풀이 시작하기
      </h3>
      <input
        type="text"
        placeholder="이름 (선택)"
        value={form.name}
        onChange={(e) => onChange('name', e.target.value)}
        disabled={loading}
      />
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
        <input type="number" placeholder="년 (1900-2100)" value={form.year}
               onChange={(e) => onChange('year', e.target.value)} disabled={loading} min="1900" max="2100" />
        <input type="number" placeholder="월" value={form.month}
               onChange={(e) => onChange('month', e.target.value)} disabled={loading} min="1" max="12" />
        <input type="number" placeholder="일" value={form.day}
               onChange={(e) => onChange('day', e.target.value)} disabled={loading} min="1" max="31" />
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
        <input type="number" placeholder="시 (선택, 0-23)" value={form.hour}
               onChange={(e) => onChange('hour', e.target.value)} disabled={loading} min="0" max="23" />
        <select value={form.gender} onChange={(e) => onChange('gender', e.target.value)} disabled={loading}>
          <option value="male"></option>
          <option value="female"></option>
        </select>
        <select value={form.calendar_type} onChange={(e) => onChange('calendar_type', e.target.value)} disabled={loading}>
          <option value="solar">양력</option>
          <option value="lunar">음력</option>
        </select>
      </div>
      {error && <div className="saju-form__error">{error}</div>}
      <button type="submit" disabled={loading}>
        {loading ? '호령이 풀어보는 중...' : '사주풀이 시작하기 ✦'}
      </button>
    </form>
  );
}
  • Step 3: ActionCard

web-ui/src/pages/saju/components/ActionCard.jsx:

import React from 'react';
import { Link } from 'react-router-dom';

const ICON = {
  today: '☀',
  heart: '♥',
  book: '📖',
};

export default function ActionCard({ to, icon, title, desc, variant = 'saju', disabled = false }) {
  const cls = `saju-action-card saju-action-card--${variant}`;
  if (disabled) {
    return (
      <span className={cls} aria-disabled="true">
        <span className="saju-action-card__icon">{ICON[icon] || '✦'}</span>
        <span className="saju-action-card__title">{title}</span>
        <span className="saju-action-card__desc">{desc || '준비 중'}</span>
      </span>
    );
  }
  return (
    <Link to={to} className={cls}>
      <span className="saju-action-card__icon">{ICON[icon] || '✦'}</span>
      <span className="saju-action-card__title">{title}</span>
      <span className="saju-action-card__desc">{desc}</span>
    </Link>
  );
}
  • Step 4: 빌드 확인
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run build 2>&1 | tail -10

Expected: 빌드 성공.

  • Step 5: Commit (web-ui)
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add src/pages/saju/hooks/useSajuForm.js \
        src/pages/saju/components/SajuInputForm.jsx \
        src/pages/saju/components/ActionCard.jsx
git commit -m "feat(saju): useSajuForm + SajuInputForm + ActionCard"

Task 10: Saju.jsx — 메인 페이지

Files:

  • Modify: web-ui/src/pages/saju/Saju.jsx

  • Step 1: Saju.jsx 전체 교체 (placeholder → 정식 구현)

web-ui/src/pages/saju/Saju.jsx:

import React from 'react';
import './Saju.css';
import SajuNav from './components/SajuNav';
import HoryungMascot from './components/HoryungMascot';
import SajuInputForm from './components/SajuInputForm';
import ActionCard from './components/ActionCard';
import useSajuForm from './hooks/useSajuForm';

export default function Saju() {
  const { form, handleChange, handleSubmit, loading, error } = useSajuForm();

  return (
    <div className="saju-page saju-page--main">
      <SajuNav />

      <section className="saju-hero">
        <div className="saju-hero__left">
          <HoryungMascot pose="greeting" size="lg" />
          <div className="saju-quote-box">
            <p style={{ margin: 0 }}>
              전통 사주명리학 + AI 인사이트로<br />
              당신의 오늘을 풀어드립니다
            </p>
          </div>
        </div>
        <div className="saju-hero__right">
          <h1 className="saju-h1">호령이 반갑게<br />맞이하는 오늘의 사주</h1>
          <p className="saju-sub">
            오랜 지혜와 정성으로 다듬어진 사주명리학을 호령이 풀어드립니다.<br />
            당신의 사주 8자에 담긴 운명을 만나보세요.
          </p>

          <div className="saju-action-cards">
            <ActionCard
              to="/saju/today"
              icon="today"
              title="오늘의 운세"
              desc="오늘 하루의 흐름을 확인하세요"
              variant="today"
            />
            <ActionCard
              to="/saju/compatibility"
              icon="heart"
              title="궁합보기"
              desc="두 사람의 인연을 풀어보세요"
              variant="gunghab"
              disabled
            />
            <ActionCard
              to="/saju/result"
              icon="book"
              title="사주풀이"
              desc="당신의 사주 8자를 자세히"
              variant="saju"
            />
          </div>
        </div>
      </section>

      <section className="saju-bottom">
        <div>
          <h2 className="saju-h2" style={{ color: 'var(--saju-cream)' }}>
            오늘의 운세 미리보기
          </h2>
          <p style={{ color: 'var(--saju-cream)', opacity: 0.7, lineHeight: 1.6 }}>
            사주 8자를 입력하시면 오늘의 종합점수, 4가지 카테고리 분석, 럭키 정보를  번에<br />
            확인하실  있습니다.
          </p>
        </div>
        <SajuInputForm
          form={form}
          onChange={handleChange}
          onSubmit={handleSubmit}
          loading={loading}
          error={error}
        />
      </section>
    </div>
  );
}
  • Step 2: 빌드 + 로컬 실행 확인
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run build 2>&1 | tail -10
npm run dev

브라우저: http://127.0.0.1:3007/saju 확인:

  • 상단 nav 정상 표시
  • 호령 캐릭터 좌측 정상 표시 (또는 onError로 hidden 처리)
  • 우측 h1 + 3 action card 정상
  • 하단 입력 폼 정상 (다크 네이비 배경)

⚠️ 백엔드가 로컬에 안 떠 있으면 입력 제출 시 네트워크 에러 정상. 이 task는 UI 렌더링 검증.

  • Step 3: Commit (web-ui)
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add src/pages/saju/Saju.jsx
git commit -m "feat(saju): 메인 페이지 정식 구현 (호령 hero + 3 action card + 입력 폼)"

Task 11: 사주풀이 컴포넌트 — SajuPillars + ElementBarChart + InterpretAccordion + HoryungQuote + MonthlyFlow

Files:

  • Create: web-ui/src/pages/saju/hooks/useSajuReading.js

  • Create: web-ui/src/pages/saju/components/SajuPillars.jsx

  • Create: web-ui/src/pages/saju/components/ElementBarChart.jsx

  • Create: web-ui/src/pages/saju/components/InterpretAccordion.jsx

  • Create: web-ui/src/pages/saju/components/HoryungQuote.jsx

  • Create: web-ui/src/pages/saju/components/MonthlyFlow.jsx

  • Step 1: useSajuReading hook

web-ui/src/pages/saju/hooks/useSajuReading.js:

import { useState, useEffect } from 'react';
import { sajuGetReading } from '../../../api';

export default function useSajuReading(readingId) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!readingId) {
      setLoading(false);
      return;
    }
    let cancelled = false;
    setLoading(true);
    sajuGetReading(readingId)
      .then((d) => {
        if (!cancelled) {
          setData(d);
          setLoading(false);
        }
      })
      .catch((e) => {
        if (!cancelled) {
          setError(e.message || '사주 결과를 불러올 수 없습니다.');
          setLoading(false);
        }
      });
    return () => { cancelled = true; };
  }, [readingId]);

  return { data, loading, error };
}
  • Step 2: SajuPillars

web-ui/src/pages/saju/components/SajuPillars.jsx:

import React from 'react';

const PILLAR_LABELS = { year: '년주', month: '월주', day: '일주', hour: '시주' };

export default function SajuPillars({ saju }) {
  if (!saju) return null;
  const pillars = ['year', 'month', 'day', 'hour'];
  return (
    <div className="saju-pillars">
      {pillars.map((p) => {
        const data = saju[p];
        if (!data) {
          return (
            <div key={p} className="saju-pillar">
              <div className="saju-pillar__label">{PILLAR_LABELS[p]}</div>
              <div style={{ opacity: 0.4 }}>-</div>
            </div>
          );
        }
        return (
          <div key={p} className="saju-pillar">
            <div className="saju-pillar__label">{PILLAR_LABELS[p]}</div>
            <div>
              <span className="saju-pillar__stem">{data.stem}</span>
              <span className="saju-pillar__stem-kr"> ({data.stem_kr})</span>
            </div>
            <div>
              <span className="saju-pillar__branch">{data.branch}</span>
              <span className="saju-pillar__branch-kr"> ({data.branch_kr})</span>
            </div>
            <div className="saju-pillar__ten-god">{data.ten_god}</div>
            <div className="saju-pillar__fortune">{data.fortune}</div>
          </div>
        );
      })}
    </div>
  );
}
  • Step 3: ElementBarChart

web-ui/src/pages/saju/components/ElementBarChart.jsx:

import React from 'react';

const ELEMENT_ORDER = ['木', '火', '土', '金', '水'];
const ELEMENT_KR = { '木': '목', '火': '화', '土': '토', '金': '금', '水': '수' };

export default function ElementBarChart({ scores }) {
  if (!scores) return null;
  const max = Math.max(...Object.values(scores), 1);
  return (
    <div className="saju-element-bars">
      {ELEMENT_ORDER.map((e) => {
        const value = scores[e] || 0;
        const widthPct = (value / max) * 100;
        return (
          <div key={e} className="saju-element-bar">
            <div className="saju-element-bar__label">{e} ({ELEMENT_KR[e]})</div>
            <div className="saju-element-bar__track">
              <div
                className={`saju-element-bar__fill saju-element-bar__fill--${e}`}
                style={{ width: `${widthPct}%` }}
              />
            </div>
            <div className="saju-element-bar__value">{value.toFixed(1)}%</div>
          </div>
        );
      })}
    </div>
  );
}
  • Step 4: InterpretAccordion

web-ui/src/pages/saju/components/InterpretAccordion.jsx:

import React, { useState } from 'react';

export default function InterpretAccordion({ items }) {
  const [openKey, setOpenKey] = useState(items?.[0]?.key);
  if (!items || items.length === 0) return null;
  return (
    <div className="saju-interpret-accordion">
      {items.map((it) => {
        const isOpen = openKey === it.key;
        return (
          <div key={it.key} className="saju-interpret-item">
            <div
              className="saju-interpret-item__header"
              onClick={() => setOpenKey(isOpen ? null : it.key)}
              role="button"
              tabIndex={0}
              onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setOpenKey(isOpen ? null : it.key); }}
            >
              <span>{it.title || it.key}</span>
              <span aria-hidden>{isOpen ? '▾' : '▸'}</span>
            </div>
            {isOpen && (
              <div className="saju-interpret-item__body">
                <p style={{ margin: 0 }}>{it.content}</p>
                {it.evidence && (
                  <div className="saju-interpret-item__evidence">
                    <strong>근거:</strong> {it.evidence.saju_element}<br />
                    <strong>해석 논리:</strong> {it.evidence.reasoning}
                  </div>
                )}
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}
  • Step 5: HoryungQuote

web-ui/src/pages/saju/components/HoryungQuote.jsx:

import React from 'react';
import HoryungMascot from './HoryungMascot';

export default function HoryungQuote({ pose = 'thinking', text }) {
  if (!text) return null;
  return (
    <div className="saju-horyung-quote">
      <HoryungMascot pose={pose} size="sm" />
      <div className="saju-horyung-quote__text">{text}</div>
    </div>
  );
}
  • Step 6: MonthlyFlow

web-ui/src/pages/saju/components/MonthlyFlow.jsx:

import React from 'react';

const LABEL_COLOR = {
  '성장': '#4B7065',
  '안정': '#D4A574',
  '변동': '#6A5285',
  '도전': '#C58F76',
  '정체': '#888',
};

export default function MonthlyFlow({ flow }) {
  if (!flow || flow.length === 0) return null;
  return (
    <div className="saju-monthly-flow">
      {flow.map((m) => (
        <div key={m.month} className="saju-monthly-flow__cell">
          <span className="saju-monthly-flow__month">{m.month}</span>
          <span className="saju-monthly-flow__score" style={{ color: LABEL_COLOR[m.label] }}>
            {m.score}
          </span>
          <span className="saju-monthly-flow__label">{m.label}</span>
        </div>
      ))}
    </div>
  );
}
  • Step 7: 빌드 확인
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run build 2>&1 | tail -10

Expected: 빌드 성공.

  • Step 8: Commit (web-ui)
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add src/pages/saju/hooks/useSajuReading.js \
        src/pages/saju/components/SajuPillars.jsx \
        src/pages/saju/components/ElementBarChart.jsx \
        src/pages/saju/components/InterpretAccordion.jsx \
        src/pages/saju/components/HoryungQuote.jsx \
        src/pages/saju/components/MonthlyFlow.jsx
git commit -m "feat(saju): 사주풀이 5 컴포넌트 + useSajuReading hook"

Task 12: SajuResult.jsx — 사주풀이 결과 페이지

Files:

  • Modify: web-ui/src/pages/saju/SajuResult.jsx

  • Step 1: SajuResult.jsx 정식 구현

web-ui/src/pages/saju/SajuResult.jsx:

import React from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import './Saju.css';
import SajuNav from './components/SajuNav';
import HoryungMascot from './components/HoryungMascot';
import SajuPillars from './components/SajuPillars';
import ElementBarChart from './components/ElementBarChart';
import InterpretAccordion from './components/InterpretAccordion';
import HoryungQuote from './components/HoryungQuote';
import MonthlyFlow from './components/MonthlyFlow';
import useSajuReading from './hooks/useSajuReading';

export default function SajuResult() {
  const [params] = useSearchParams();
  const rid = params.get('rid');
  const ridNum = rid ? parseInt(rid, 10) : null;
  const { data, loading, error } = useSajuReading(ridNum);

  if (!rid) {
    return (
      <div className="saju-page">
        <SajuNav />
        <div className="saju-stub">
          <HoryungMascot pose="thinking" />
          <h2 className="saju-h2">사주 정보가 없어요</h2>
          <p>먼저 메인 페이지에서 사주를 입력해주세요.</p>
          <Link to="/saju">메인으로 가기</Link>
        </div>
      </div>
    );
  }

  if (loading) {
    return (
      <div className="saju-page">
        <SajuNav />
        <div className="saju-stub">
          <HoryungMascot pose="thinking" />
          <p>호령이 사주를 풀어보는 ...</p>
        </div>
      </div>
    );
  }

  if (error || !data) {
    return (
      <div className="saju-page">
        <SajuNav />
        <div className="saju-stub">
          <h2 className="saju-h2">사주 결과를 찾을  없어요</h2>
          <p>{error || '다시 입력해주세요.'}</p>
          <Link to="/saju">메인으로 가기</Link>
        </div>
      </div>
    );
  }

  const saju = data.saju_data;
  const analysis = data.analysis_data;
  const daeun = data.daeun_data;
  const interp = data.interpretation_json;
  const monthlyFlow = data.monthly_flow;

  return (
    <div className="saju-page">
      <SajuNav />

      <section className="saju-hero">
        <div className="saju-hero__left">
          <HoryungMascot pose="thinking" size="lg" />
        </div>
        <div className="saju-hero__right">
          <h1 className="saju-h1">사주풀이</h1>
          <p className="saju-sub">
            {data.birth_year} {data.birth_month} {data.birth_day}
            {data.birth_hour !== null ? ` ${data.birth_hour}시` : ' (시간 미상)'} ·{' '}
            {data.gender === 'male' ? '남' : '여'} ·{' '}
            {data.calendar_type === 'lunar' ? '음력' : '양력'}
          </p>
          {interp?.summary && (
            <HoryungQuote pose="thinking" text={interp.summary} />
          )}
        </div>
      </section>

      <section style={{ padding: '0 2rem', maxWidth: 1400, margin: '0 auto', display: 'grid', gap: '2rem' }}>
        <div>
          <h2 className="saju-h2" style={{ marginBottom: '1rem' }}>사주 4기둥</h2>
          <SajuPillars saju={saju} />
        </div>

        <div>
          <h2 className="saju-h2" style={{ marginBottom: '1rem' }}>오행 분석</h2>
          <ElementBarChart scores={analysis?.element_scores} />
        </div>

        {analysis?.day_master_strength && (
          <div>
            <h2 className="saju-h2" style={{ marginBottom: '1rem' }}>일간 강도</h2>
            <div className="saju-quote-box" style={{ maxWidth: 'none' }}>
              <p style={{ margin: 0 }}>
                <strong>{analysis.day_master_strength.result}</strong> · 점수 {analysis.day_master_strength.score}<br />
                {(analysis.day_master_strength.reasons || []).join(' · ')}
              </p>
            </div>
          </div>
        )}

        {monthlyFlow && (
          <div>
            <h2 className="saju-h2" style={{ marginBottom: '1rem' }}>12개월 운세 흐름</h2>
            <MonthlyFlow flow={monthlyFlow} />
          </div>
        )}

        {interp?.items && (
          <div>
            <h2 className="saju-h2" style={{ marginBottom: '1rem' }}>AI 12항목 해석</h2>
            <InterpretAccordion items={interp.items} />
          </div>
        )}

        {interp?.advice && (
          <div>
            <h2 className="saju-h2" style={{ marginBottom: '1rem' }}>호령의 조언</h2>
            <HoryungQuote pose="happy" text={interp.advice} />
          </div>
        )}
      </section>

      <section style={{ padding: '3rem 2rem', display: 'flex', gap: '1rem', justifyContent: 'center' }}>
        <Link to={`/saju/today?rid=${rid}`} className="saju-action-card saju-action-card--today" style={{ maxWidth: 240 }}>
          <span className="saju-action-card__icon"></span>
          <span className="saju-action-card__title">오늘의 운세</span>
        </Link>
        <Link to="/saju" className="saju-action-card saju-action-card--saju" style={{ maxWidth: 240 }}>
          <span className="saju-action-card__icon">📖</span>
          <span className="saju-action-card__title"> 사주 보기</span>
        </Link>
      </section>
    </div>
  );
}
  • Step 2: 빌드 + 시각 확인
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run build 2>&1 | tail -10
npm run dev

브라우저: http://127.0.0.1:3007/saju → 입력 → 결과 페이지 정상 표시.

  • Step 3: Commit (web-ui)
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add src/pages/saju/SajuResult.jsx
git commit -m "feat(saju): 사주풀이 결과 페이지 (4기둥 + 오행 + 12개월 + AI 12항목)"

Task 13: 오늘운세 컴포넌트 — FortuneRing + ScoreCard + LuckyBox

Files:

  • Create: web-ui/src/pages/saju/components/FortuneRing.jsx

  • Create: web-ui/src/pages/saju/components/ScoreCard.jsx

  • Create: web-ui/src/pages/saju/components/LuckyBox.jsx

  • Step 1: FortuneRing (SVG ring chart)

web-ui/src/pages/saju/components/FortuneRing.jsx:

import React from 'react';

export default function FortuneRing({ score, max = 100 }) {
  const radius = 80;
  const circumference = 2 * Math.PI * radius;
  const safe = Math.max(0, Math.min(score || 0, max));
  const dashOffset = circumference - (safe / max) * circumference;

  return (
    <div className="saju-fortune-ring">
      <svg viewBox="0 0 200 200">
        <circle
          cx="100" cy="100" r={radius}
          stroke="var(--saju-paper)" strokeWidth="14" fill="none"
        />
        <circle
          cx="100" cy="100" r={radius}
          stroke="var(--saju-gold)" strokeWidth="14" fill="none"
          strokeDasharray={circumference}
          strokeDashoffset={dashOffset}
          strokeLinecap="round"
          transform="rotate(-90 100 100)"
          style={{ transition: 'stroke-dashoffset 0.6s ease' }}
        />
      </svg>
      <div style={{ position: 'absolute', textAlign: 'center' }}>
        <div className="saju-fortune-ring__score">{safe}</div>
        <div className="saju-fortune-ring__total">/ {max}</div>
      </div>
    </div>
  );
}
  • Step 2: ScoreCard

web-ui/src/pages/saju/components/ScoreCard.jsx:

import React from 'react';

const ICON_BY_CATEGORY = {
  wealth: '💰',
  romance: '💖',
  social: '🤝',
  career: '💼',
};

const COLOR_VAR_BY_CATEGORY = {
  wealth: 'var(--saju-wealth)',
  romance: 'var(--saju-romance)',
  social: 'var(--saju-social)',
  career: 'var(--saju-career)',
};

const TITLE_BY_CATEGORY = {
  wealth: '재물운',
  romance: '연애운',
  social: '인간관계',
  career: '직장운',
};

export default function ScoreCard({ category, score }) {
  const safe = Math.max(0, Math.min(score || 0, 100));
  return (
    <div className="saju-score-card">
      <div className="saju-score-card__head">
        <span className="saju-score-card__icon">{ICON_BY_CATEGORY[category]}</span>
        <span className="saju-score-card__title">{TITLE_BY_CATEGORY[category]}</span>
      </div>
      <div className="saju-score-card__value">{safe}<small style={{ fontSize: '1rem', opacity: 0.5 }}>/100</small></div>
      <div className="saju-score-card__bar">
        <div style={{ width: `${safe}%`, background: COLOR_VAR_BY_CATEGORY[category] }} />
      </div>
    </div>
  );
}
  • Step 3: LuckyBox

web-ui/src/pages/saju/components/LuckyBox.jsx:

import React from 'react';

export default function LuckyBox({ lucky }) {
  if (!lucky) return null;
  return (
    <div className="saju-lucky-box">
      <div className="saju-lucky-box__item">
        <div className="saju-lucky-box__label">럭키 컬러</div>
        <div className="saju-lucky-box__value">{(lucky.color || []).join(' · ')}</div>
      </div>
      <div className="saju-lucky-box__item">
        <div className="saju-lucky-box__label">럭키 숫자</div>
        <div className="saju-lucky-box__value">{lucky.number}</div>
      </div>
      <div className="saju-lucky-box__item">
        <div className="saju-lucky-box__label">럭키 방향</div>
        <div className="saju-lucky-box__value">{lucky.direction}</div>
      </div>
    </div>
  );
}
  • Step 4: 빌드 + Commit
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run build 2>&1 | tail -10
git add src/pages/saju/components/FortuneRing.jsx \
        src/pages/saju/components/ScoreCard.jsx \
        src/pages/saju/components/LuckyBox.jsx
git commit -m "feat(saju): 오늘운세 컴포넌트 3개 (FortuneRing + ScoreCard + LuckyBox)"

Task 14: Today.jsx — 오늘의 운세 페이지

Files:

  • Modify: web-ui/src/pages/saju/Today.jsx (Task 28에서 placeholder로 만든 파일 — 정식 구현으로 교체)
  • Modify: web-ui/src/routes.jsx (라우트가 SajuToday 또는 Today로 등록되어 있을 수 있음 — 확인 후 일치)

⚠️ Task 28에서는 /saju/result, /saju/compatibility만 라우트 등록했을 수 있음. Today 라우트가 없으면 추가.

  • Step 1: routes.jsx 확인 + Today 라우트 추가

web-ui/src/routes.jsx/saju/today 라우트가 없으면 추가. Task 28의 패턴 따라:

// 기존 saju 라우트 옆에 추가
const Today = lazy(() => import('./pages/saju/Today'));

// routes 배열에 추가
{ path: '/saju/today', element: <Today /> }
  • Step 2: Today.jsx 정식 구현

web-ui/src/pages/saju/Today.jsx:

import React from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import './Saju.css';
import SajuNav from './components/SajuNav';
import HoryungMascot from './components/HoryungMascot';
import FortuneRing from './components/FortuneRing';
import ScoreCard from './components/ScoreCard';
import LuckyBox from './components/LuckyBox';
import HoryungQuote from './components/HoryungQuote';
import useSajuReading from './hooks/useSajuReading';

export default function Today() {
  const [params] = useSearchParams();
  const rid = params.get('rid');
  const ridNum = rid ? parseInt(rid, 10) : null;
  const { data, loading, error } = useSajuReading(ridNum);

  if (!rid) {
    return (
      <div className="saju-page">
        <SajuNav />
        <div className="saju-stub">
          <HoryungMascot pose="thinking" />
          <h2 className="saju-h2">사주가 필요해요</h2>
          <p>오늘의 운세를 보려면 먼저 사주를 입력해주세요.</p>
          <Link to="/saju">사주 입력하러 가기</Link>
        </div>
      </div>
    );
  }

  if (loading) {
    return (
      <div className="saju-page">
        <SajuNav />
        <div className="saju-stub">
          <HoryungMascot pose="thinking" />
          <p>오늘의 운세를 풀어보는 ...</p>
        </div>
      </div>
    );
  }

  if (error || !data) {
    return (
      <div className="saju-page">
        <SajuNav />
        <div className="saju-stub">
          <h2 className="saju-h2">결과를 찾을  없어요</h2>
          <p>{error || '다시 입력해주세요.'}</p>
          <Link to="/saju">메인으로 가기</Link>
        </div>
      </div>
    );
  }

  const scores = data.fortune_scores;
  const lucky = data.lucky;

  return (
    <div className="saju-page">
      <SajuNav />

      <section className="saju-hero">
        <div className="saju-hero__left">
          <HoryungMascot pose="pointing" size="lg" />
        </div>
        <div className="saju-hero__right">
          <h1 className="saju-h1">오늘의 운세</h1>
          <p className="saju-sub">
            오늘 하루 어떤 흐름이 호령을 따라올지 확인해보세요.
          </p>
        </div>
      </section>

      <section style={{ padding: '0 2rem', maxWidth: 1400, margin: '0 auto', display: 'grid', gap: '2rem' }}>
        {scores && (
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '2rem', alignItems: 'center' }}>
            <div>
              <h2 className="saju-h2" style={{ marginBottom: '1rem' }}>오늘의 종합점</h2>
              <FortuneRing score={scores.overall} />
            </div>
            <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1rem' }}>
              <ScoreCard category="wealth" score={scores.wealth} />
              <ScoreCard category="romance" score={scores.romance} />
              <ScoreCard category="social" score={scores.social} />
              <ScoreCard category="career" score={scores.career} />
            </div>
          </div>
        )}

        {lucky && (
          <div>
            <h2 className="saju-h2" style={{ marginBottom: '1rem' }}>오늘의 럭키</h2>
            <LuckyBox lucky={lucky} />
          </div>
        )}

        {(lucky?.good_signs?.length || lucky?.warnings?.length) ? (
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
            {lucky.good_signs?.length > 0 && (
              <div className="saju-quote-box" style={{ maxWidth: 'none', background: 'rgba(75, 112, 101, 0.15)' }}>
                <strong style={{ color: 'var(--saju-jade)' }}> 행운 알림</strong>
                <ul style={{ marginTop: '0.5rem', paddingLeft: '1.2rem' }}>
                  {lucky.good_signs.map((s, i) => <li key={i}>{s}</li>)}
                </ul>
              </div>
            )}
            {lucky.warnings?.length > 0 && (
              <div className="saju-quote-box" style={{ maxWidth: 'none', background: 'rgba(197, 143, 118, 0.15)' }}>
                <strong style={{ color: 'var(--saju-apricot)' }}> 주의사항</strong>
                <ul style={{ marginTop: '0.5rem', paddingLeft: '1.2rem' }}>
                  {lucky.warnings.map((s, i) => <li key={i}>{s}</li>)}
                </ul>
              </div>
            )}
          </div>
        ) : null}

        <HoryungQuote
          pose="happy"
          text="오늘 하루도 호령과 함께 평안하시길 바라요. 작은 신호에도 귀 기울이세요."
        />
      </section>

      <section style={{ padding: '3rem 2rem', display: 'flex', gap: '1rem', justifyContent: 'center' }}>
        <Link to={`/saju/result?rid=${rid}`} className="saju-action-card saju-action-card--saju" style={{ maxWidth: 240 }}>
          <span className="saju-action-card__icon">📖</span>
          <span className="saju-action-card__title">사주풀이 보기</span>
        </Link>
        <Link to="/saju" className="saju-action-card saju-action-card--gunghab" style={{ maxWidth: 240 }} aria-disabled="true">
          <span className="saju-action-card__icon"></span>
          <span className="saju-action-card__title">궁합 (준비 )</span>
        </Link>
      </section>
    </div>
  );
}
  • Step 3: 빌드 + 시각 확인
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run build 2>&1 | tail -10
npm run dev

브라우저: http://127.0.0.1:3007/saju → 입력 → /saju/result?rid=N → "오늘의 운세 보기" 클릭 → /saju/today?rid=N 정상.

  • Step 4: Commit (web-ui)
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add src/pages/saju/Today.jsx src/routes.jsx
git commit -m "feat(saju): 오늘운세 페이지 (FortuneRing + 4 ScoreCard + LuckyBox + good_signs/warnings)"

Task 15: Compatibility.jsx — v2 placeholder

Files:

  • Modify: web-ui/src/pages/saju/Compatibility.jsx

  • Step 1: placeholder 정리

web-ui/src/pages/saju/Compatibility.jsx:

import React from 'react';
import { Link } from 'react-router-dom';
import './Saju.css';
import SajuNav from './components/SajuNav';
import HoryungMascot from './components/HoryungMascot';

export default function Compatibility() {
  return (
    <div className="saju-page">
      <SajuNav />
      <div className="saju-stub">
        <HoryungMascot pose="thinking" />
        <h2 className="saju-h2">궁합보기는  만나요</h2>
        <p> 사람의 사주를 함께 풀어보는 기능을 준비 중입니다.<br />조금만 기다려 주세요.</p>
        <Link to="/saju">메인으로 돌아가기</Link>
      </div>
    </div>
  );
}
  • Step 2: Commit
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git add src/pages/saju/Compatibility.jsx
git commit -m "feat(saju): 궁합보기 v2 placeholder + SajuNav 통합"

Task 16: 로컬 e2e + 반응형 검증 + 최종 commit

Files:

  • (수정 없음, 동작 검증만)

  • Step 1: 백엔드 로컬 실행 (선택)

cd C:/Users/jaeoh/Desktop/workspace/web-backend
docker compose up -d saju-lab nginx frontend

또는 NAS의 운영 백엔드를 가리키도록 환경변수 설정 (vite proxy 사용 시).

  • Step 2: web-ui dev 서버 + 실제 입력 → 결과 흐름 e2e
cd C:/Users/jaeoh/Desktop/workspace/web-ui
npm run dev

브라우저 검증 시나리오:

  1. http://127.0.0.1:3007/saju → 메인 페이지 정상 (호령 + 3 카드 + 입력 폼)
  2. 입력 (1990 / 5 / 15 / 14 / male / solar) → 사주풀이 시작
  3. 자동 navigate → /saju/result?rid=N
  4. 4기둥 8자, 오행 차트, 12개월 흐름, AI 12항목 아코디언, 호령 조언 정상 표시
  5. "오늘의 운세 보기" → /saju/today?rid=N
  6. FortuneRing + 4 ScoreCard + LuckyBox + good_signs/warnings 정상
  7. "궁합 (준비 중)" 클릭 → /saju/compatibility placeholder 페이지
  • Step 3: 반응형 검증 (Chrome DevTools)

DevTools → Toggle Device Toolbar로 3 크기 확인:

  • 1280x720 (데스크탑) — 시안과 같이 보임
  • 1024x768 (태블릿) — hero 컬럼 → 세로 스택, action card grid 유지
  • 375x667 (모바일) — 호령 작게, action card 1열, 4기둥 2x2

깨진 부분 발견 시 Saju.css@media 영역 조정.

  • Step 4: 콘솔 에러 0건 확인

브라우저 콘솔(F12) 열어 다음 3 페이지에서 에러/경고 없는지:

  • /saju
  • /saju/result?rid=N
  • /saju/today?rid=N

호령 PNG 누락 메시지는 onError로 silent이므로 무시. 다른 에러(404, undefined property 등) 있으면 수정.

  • Step 5: 최종 정리 commit (만약 변경된 게 있다면)
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git status
# 변경 없으면 skip. 있으면:
git add -A
git commit -m "fix(saju): e2e 검증 후 미세 조정"
  • Step 6: push (선택, 사용자가 명시 후)
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git push
cd C:/Users/jaeoh/Desktop/workspace/web-ui && git push  # 또는 npm run release:nas

전체 검증 체크리스트

백엔드 (saju-lab)

  • fortune_scores.py + 6 tests passing
  • lucky.py + 6 tests passing
  • monthly_flow.py + 4 tests passing
  • db.py migration 4 tests passing + 기존 10 db tests 영향 없음
  • /api/saju/interpret 응답에 fortune_scores, lucky, monthly_flow 포함
  • 기존 30 reference fixture 영향 없음 (응답 추가 필드만)
  • 전체 saju-lab pytest 480+ passing (기존 474 + 신규 20)

자산 (web-ui public)

  • public/images/saju/horyung/ 6 PNG 존재
  • 각 PNG가 시안 품질에 가까움 (수동 시각 검수)

프론트 (web-ui)

  • Saju.css 컬러 토큰 + 폰트 + 반응형 모두 적용
  • Saju.jsx (메인) — 호령 hero + 3 ActionCard + 입력 폼
  • SajuResult.jsx — 4기둥 + 오행 + 12개월 + AI 12항목 + 호령 조언
  • Today.jsx — FortuneRing + 4 ScoreCard + LuckyBox + good_signs/warnings
  • Compatibility.jsx — placeholder ("준비 중")
  • HoryungMascot가 6 pose 모두 렌더 + onError fallback
  • 1회 입력 → 3 페이지 자유롭게 이동 (reading_id 공유)
  • 1280/1024/375 3 사이즈 반응형 정상
  • 콘솔 에러 0건

위험 + 대응

위험 대응
horyung.png crop 좌표 부정확 Task 6 step 4에서 시각 검수 후 좌표 재조정 (반복 가능)
fortune_scores 점수가 매우 낮거나 매우 높음 5개 케이스 spot check + base 60 / 가산 폭 조정
음력 변환 시 calendar_type='lunar' 전달 누락 useSajuForm에서 명시적 전달 + 백엔드 lunar_to_solar 호출 검증
Noto Serif KR 로드 지연 → 폰트 깜빡임 display=swap 사용 (Pretendard fallback)
/api/saju/interpret 504 timeout 사용자가 DSM Reverse Proxy timeout 미리 늘려둠 (직전 작업에서 이미 다룸)
reading_id 무효(DB 삭제 등) useSajuReading에서 404 catch + "메인으로 가기" 버튼

참고

  • spec: docs/superpowers/specs/2026-05-26-saju-ui-design.md
  • 시안: source/images/saju_page/horyung_saju_{main,today,gunghab,saju}.png
  • 캐릭터: source/characters/horyung.png
  • 컬러시트: source/images/saju_page/saju_color_sheet.png
  • 백엔드 직전 commit: SHA 8123f75 (saju-lab 백엔드 474 tests)
  • 프론트 직전 commit: web-ui SHA eab52ca (Task 28 placeholder)
  • 직전 spec: docs/superpowers/specs/2026-05-25-saju-tarot-lab-migration-design.md