문서 정리: Phase 히스토리 md를 docs/history/로 이동 + 오래된 테스트/에셋 정리

- 루트의 IMPROVEMENT-PHASE-*.md, PHASE-*.md 등 45개 → docs/history/로 이동
- docs/block-tests/ 오래된 블록 테스트 HTML 삭제 (figma_to_html_agent로 대체)
- docs/figma-analysis/, docs/figma-assets/, docs/figma-screenshots/ 정리
- docs/test-*.html 등 초기 테스트 파일 정리
- 참고 페이지/ 스크린샷 정리

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 10:56:23 +09:00
parent d57860578f
commit c42e01f060
206 changed files with 0 additions and 13498 deletions

View File

@@ -0,0 +1,708 @@
# Phase O: 컨테이너 기반 레이아웃 시스템
> 작성일: 2026-03-27
> 상태: ✅ 코드 구현 완료 + 후속 정리 완료 (Step B 제거, 죽은 코드 정리, 미해결 3건 해결)
> 선행 완료: Phase N (catalog 개선, fallback 제거, topic_id 버그 수정)
---
## 핵심 원칙
**"비중이 컨테이너를 확정하고, 컨테이너가 블록을 제약하고, 블록이 콘텐츠를 제약한다."**
```
Kei 비중 판단 (본심 60%, 배경 20%)
컨테이너 px 확정 (본심 294px, 배경 98px)
블록 선택 시 컨테이너 크기 제약 (98px → compact 블록만)
블록 스펙 확정 (항목 수, 폰트, 패딩, 행 수)
편집자가 확정 스펙에 맞게 텍스트 작성
렌더링 (컨테이너 grid로 비중 강제 반영)
```
---
## 현재 문제 (Phase N 이후에도 남은 것)
### 문제 1: 비중이 시각에 반영 안 됨
- Kei가 본심 60%, 배경 20%로 판단했지만
- 실제 렌더링에서 배경이 73%(348px), 본심이 20%(97px)
- **원인:** 블록이 자연 높이대로 렌더링되고, 비중 기반 컨테이너가 없음
### 문제 2: 블록 선택 시 컨테이너 크기를 모름
- Kei가 블록을 고를 때 "이 블록이 컨테이너에 들어가는지" 판단 불가
- 98px 컨테이너에 height_cost=large 블록이 선택됨
### 문제 3: 블록이 컨테이너에 맞게 변형되지 않음
- 같은 `dark-bullet-list`여도 98px이면 불릿 2개, 294px이면 5개여야 하는데
- 현재는 블록이 고정 형태로 렌더링됨
### 문제 4: 텍스트 분량이 컨테이너와 무관
- sidebar 490px인데 용어 정의가 한 줄짜리
- body 98px인데 문제제기가 3단 구조
---
## 변경 대상 파일 및 역할
| 파일 | 현재 역할 | Phase O 변경 |
|------|----------|------------|
| `pipeline.py` | 5단계 오케스트레이션 | 컨테이너 계산을 Step A와 A-2 사이에 삽입 |
| `space_allocator.py` | _max_chars만 계산 | **컨테이너 스펙 생성기로 확장** (px, 블록 제약, 항목수, 폰트, 글자수) |
| `design_director.py` | Step A-2에서 블록 선택 | 컨테이너 px를 Kei에게 전달 + height_cost 제약 |
| `content_editor.py` | _max_chars로 분량 제한 | 블록 스펙(항목수, 글자수/항목)을 프롬프트에 전달 |
| `renderer.py` | flex-column으로 블록 나열 | **비중 기반 grid row로 컨테이너 생성** |
| `catalog.yaml` | when/not_for 설명 | 각 블록의 height_cost를 px 범위로 구체화 |
---
## 단계별 상세 설계
### O-1. 컨테이너 스펙 계산 (`space_allocator.py` 확장)
**현재:** `allocate_height_budget()``{topic_id: max_height_px}` 딕셔너리만 반환
**변경:** `calculate_container_specs()` → 각 컨테이너의 완전한 스펙을 반환
```python
def calculate_container_specs(
page_structure: dict, # Kei의 비중 판단: {"본심": {"topic_ids": [3], "weight": 0.6}, ...}
topics: list[dict], # 각 topic의 purpose, role, layer
preset: dict, # 프리셋 zone 정보 (budget_px, width_pct)
) -> dict[str, ContainerSpec]:
"""Kei 비중 → 컨테이너 스펙 변환.
Returns:
역할별 ContainerSpec 딕셔너리. 예:
{
"본심": ContainerSpec(
role="본심",
zone="body",
topic_ids=[3],
weight=0.6,
height_px=294, # zone_budget × weight_ratio
width_px=716, # slide_width × zone_width_pct × 0.85 (패딩 제외)
max_height_cost="xlarge", # 294px이면 xlarge까지 가능
block_constraints={
"max_items": 7, # 높이 기반 계산
"font_size_px": 15.2, # 기본값 유지 가능
"padding_px": 20, # 기본값 유지 가능
"max_chars_total": 800, # 높이×너비 기반 총 글자수
},
),
"배경": ContainerSpec(
role="배경",
zone="body",
topic_ids=[1, 2],
weight=0.2,
height_px=98,
width_px=716,
max_height_cost="compact", # 98px이면 compact만
block_constraints={
"max_items": 3,
"font_size_px": 13.0, # 줄여야 함
"padding_px": 10, # 줄여야 함
"max_chars_total": 200,
},
),
...
}
"""
```
**height_cost → px 매핑:**
현재 catalog.yaml의 height_cost는 문자열(`compact`, `medium`, `large`, `xlarge`)이다.
이것을 px 범위로 매핑해야 Kei가 블록을 고를 때 컨테이너에 맞는지 판단할 수 있다.
```python
HEIGHT_COST_PX_RANGE = {
"compact": (30, 80), # 30~80px
"medium": (80, 200), # 80~200px
"large": (200, 350), # 200~350px
"xlarge": (350, 500), # 350~500px
}
```
**컨테이너 높이 → 허용 height_cost 결정:**
```python
def max_allowed_height_cost(container_height_px: int) -> str:
"""컨테이너 높이에서 허용되는 최대 height_cost를 결정."""
if container_height_px >= 350:
return "xlarge"
elif container_height_px >= 200:
return "large"
elif container_height_px >= 80:
return "medium"
else:
return "compact"
```
**블록 내부 제약 계산:**
```python
def calculate_block_constraints(
height_px: int,
width_px: int,
topic_count: int, # 이 컨테이너에 들어가는 topic 수
font_size_px: float,
line_height: float,
padding_px: int,
) -> dict:
"""컨테이너 크기에서 블록 내부 제약을 수학적으로 계산."""
# 각 topic에 할당되는 높이
per_topic_height = (height_px - padding_px * 2) / topic_count
# 줄 수
line_height_px = font_size_px * line_height
max_lines = int(per_topic_height / line_height_px)
# 줄당 글자 수
chars_per_line = int((width_px - padding_px * 2) / (font_size_px * 0.95))
# 불릿/항목 수 (한 항목 = 약 2줄)
max_items = max(1, max_lines // 2)
# 총 글자 수
max_chars_total = max_lines * chars_per_line
return {
"max_lines": max_lines,
"max_items": max_items,
"chars_per_line": chars_per_line,
"max_chars_total": max_chars_total,
"max_chars_per_item": max(20, max_chars_total // max(1, max_items)),
}
```
**폰트/패딩 조정 기준:**
| 컨테이너 높이 | 폰트 크기 | 패딩 | line-height |
|-------------|---------|------|-----------|
| ≥300px | 15.2px (기본) | 20px (기본) | 1.7 (기본) |
| 150~299px | 14px | 14px | 1.6 |
| 80~149px | 13px | 10px | 1.5 |
| <80px | 12px | 8px | 1.4 |
---
### O-2. 블록 선택에 컨테이너 제약 전달 (`design_director.py`)
**현재:** `_opus_block_recommendation()`이 Kei에게 블록 후보 + 꼭지 목록을 보냄. 컨테이너 크기 정보 없음.
**변경:** 컨테이너 스펙을 Kei에게 함께 전달.
```python
# _opus_block_recommendation 프롬프트에 추가할 내용
container_text = "\n".join(
f"- 꼭지 {tid}: 컨테이너 {spec.height_px}px × {spec.width_px}px, "
f"허용 height_cost: {spec.max_height_cost} 이하, "
f"최대 항목 수: {spec.block_constraints['max_items']}"
for role, spec in container_specs.items()
for tid in spec.topic_ids
)
prompt += (
f"\n\n## 컨테이너 제약 (반드시 준수)\n"
f"각 꼭지의 블록은 아래 컨테이너 안에 들어가야 한다.\n"
f"height_cost가 컨테이너보다 크면 선택 금지.\n\n"
f"{container_text}\n"
)
```
**코드 레벨 검증 (Kei 응답 후):**
```python
# Kei가 선택한 블록의 height_cost가 컨테이너보다 큰지 검증
for rec in kei_recommendations:
tid = rec.get("topic_id") or rec.get("id")
block_type = rec.get("block_type", "")
# catalog에서 height_cost 조회
block_height_cost = catalog_map.get(block_type, {}).get("height_cost", "medium")
# 컨테이너의 max_height_cost 조회
container_spec = find_container_for_topic(tid, container_specs)
allowed = container_spec.max_height_cost
# 제약 위반 체크
if HEIGHT_COST_ORDER[block_height_cost] > HEIGHT_COST_ORDER[allowed]:
logger.warning(
f"[O-2 검증] 꼭지 {tid}: {block_type}({block_height_cost})이 "
f"컨테이너({container_spec.height_px}px, {allowed} 이하)에 안 맞음"
)
# 위반 시 → Kei에게 재선택 요청 (컨테이너 제약 명시)
```
---
### O-3. 블록 스펙 확정 단계 (신규)
**현재:** 없음. 블록이 선택되면 바로 편집자에게 전달.
**변경:** Step A-2 후, Step 3 전에 **블록 스펙 확정** 단계 삽입.
이 단계는 **코드(결정론적)** — AI 호출 없음.
```python
def finalize_block_specs(
blocks: list[dict], # Step A-2에서 확정된 블록 목록
container_specs: dict, # O-1에서 계산된 컨테이너 스펙
catalog: dict, # catalog.yaml 데이터
) -> list[dict]:
"""각 블록의 내부 스펙을 컨테이너 크기에 맞게 확정한다.
확정 항목:
- _container_height_px: 이 블록이 쓸 수 있는 높이
- _container_width_px: 이 블록이 쓸 수 있는 너비
- _max_items: 최대 항목/불릿/행 수
- _max_chars_per_item: 항목당 최대 글자 수
- _max_chars_total: 총 최대 글자 수
- _font_size_px: 이 컨테이너에서의 폰트 크기
- _padding_px: 이 컨테이너에서의 패딩
- _line_height: 이 컨테이너에서의 줄간격
"""
for block in blocks:
tid = block.get("topic_id")
spec = find_container_for_topic(tid, container_specs)
if not spec:
continue
block_type = block.get("type", "")
catalog_info = catalog.get(block_type, {})
# 이 블록이 쓸 수 있는 높이 (같은 컨테이너 안의 다른 블록과 분배)
siblings_in_container = [b for b in blocks if find_container_for_topic(b.get("topic_id"), container_specs) == spec]
per_block_height = spec.height_px // len(siblings_in_container)
# 폰트/패딩 결정 (컨테이너 크기 기반)
font_size, padding, line_h = determine_typography(per_block_height)
# 블록별 항목 수 계산
constraints = calculate_block_constraints(
per_block_height, spec.width_px,
topic_count=1, # 이 블록 1개
font_size_px=font_size,
line_height=line_h,
padding_px=padding,
)
# 블록 타입별 세부 조정
schema = catalog_info.get("schema", {})
if block_type in ("dark-bullet-list",):
# 불릿 블록: max_items = 불릿 수
block["_max_items"] = min(constraints["max_items"], int(schema.get("max_bullets", {}).get("body", 5)))
block["_max_chars_per_item"] = constraints["max_chars_per_item"]
elif block_type in ("card-numbered", "card-icon-desc"):
# 카드 블록: max_items = 카드 수
block["_max_items"] = constraints["max_items"]
block["_max_chars_per_item"] = constraints["max_chars_per_item"]
elif block_type in ("compare-2col-split", "compare-3col-badge", "table-simple-striped"):
# 표 블록: max_items = 행 수
block["_max_items"] = constraints["max_items"]
block["_max_chars_per_item"] = constraints["max_chars_per_item"]
elif block_type in ("comparison-2col",):
# 비교 블록: 좌우 각각의 글자 수
block["_max_chars_per_item"] = constraints["max_chars_total"] // 2
elif block_type in ("banner-gradient",):
# 배너: 한 줄
block["_max_chars_total"] = constraints["chars_per_line"]
else:
block["_max_chars_total"] = constraints["max_chars_total"]
# 공통
block["_container_height_px"] = per_block_height
block["_container_width_px"] = spec.width_px
block["_font_size_px"] = font_size
block["_padding_px"] = padding
block["_line_height"] = line_h
block["_max_chars_total"] = constraints["max_chars_total"]
return blocks
```
**typography 결정 함수:**
```python
def determine_typography(height_px: int) -> tuple[float, int, float]:
"""컨테이너 높이에 따른 폰트/패딩/줄간격 결정."""
if height_px >= 300:
return (15.2, 20, 1.7) # 기본
elif height_px >= 150:
return (14.0, 14, 1.6) # 약간 축소
elif height_px >= 80:
return (13.0, 10, 1.5) # 축소
else:
return (12.0, 8, 1.4) # 최소
```
---
### O-4. 편집자 프롬프트에 블록 스펙 전달 (`content_editor.py`)
**현재:** `_max_chars`만 전달. 항목 수, 항목당 글자 수, 폰트 크기 정보 없음.
**변경:** O-3에서 확정된 모든 스펙을 편집자에게 전달.
```python
# fill_content()에서 각 블록의 스펙을 프롬프트에 구체적으로 명시
for i, block in enumerate(blocks):
req_text = (
f"블록 {i+1} ({block_type}, 영역: {block.get('area')}):\n"
f" 목적(purpose): {block.get('purpose')}\n"
f" 필수 슬롯: {slots.get('required', [])}\n"
)
# O-4: 블록 스펙 (컨테이너 기반)
container_h = block.get("_container_height_px")
if container_h:
max_items = block.get("_max_items", "제한 없음")
max_chars_item = block.get("_max_chars_per_item", "제한 없음")
max_chars_total = block.get("_max_chars_total", "제한 없음")
font_size = block.get("_font_size_px", 15.2)
req_text += (
f"\n ★ 컨테이너 제약 (절대 준수):\n"
f" - 컨테이너 높이: {container_h}px\n"
f" - 최대 항목 수: {max_items}\n"
f" - 항목당 최대 글자 수: {max_chars_item}\n"
f" - 총 최대 글자 수: {max_chars_total}\n"
f" - 폰트 크기: {font_size}px\n"
f" 이 제약을 넘기면 컨테이너 밖으로 넘친다. 반드시 지켜라.\n"
)
```
**sidebar 용어 정의 예시:**
```
블록 5 (card-numbered, 영역: sidebar):
목적(purpose): 용어정의
★ 컨테이너 제약:
- 컨테이너 높이: 450px (sidebar 전체)
- 최대 항목 수: 3개
- 항목당 최대 글자 수: 120자 ← 출처까지 넣을 수 있는 여유
- 폰트 크기: 13px
```
**body 배경(98px) 예시:**
```
블록 2 (dark-bullet-list, 영역: body):
목적(purpose): 근거사례
★ 컨테이너 제약:
- 컨테이너 높이: 49px (배경 98px / 2 topics)
- 최대 항목 수: 2개
- 항목당 최대 글자 수: 40자 ← 간결하게
- 폰트 크기: 12px
```
---
### O-5. 렌더러에서 비중 기반 grid row 생성 (`renderer.py`)
**현재:** `_group_blocks_by_area()`가 같은 area 블록을 flex-column으로 나열. 높이 비율 없음.
**변경:** body zone 안에 역할(본심/배경/결론)별 grid row를 생성하고, 각 row의 높이를 비중 기반으로 확정.
```python
def _group_blocks_by_area_with_containers(
blocks: list[dict[str, Any]],
container_specs: dict | None = None,
) -> list[dict[str, Any]]:
"""같은 area의 블록들을 비중 기반 컨테이너로 그룹핑한다.
container_specs가 있으면:
- body zone 안에서 역할별 컨테이너 div를 생성
- 각 컨테이너의 height를 비중 기반 px로 고정
- 블록은 해당 컨테이너 안에 배치
container_specs가 없으면:
- 기존 flex-column 방식 (호환)
"""
grouped = OrderedDict()
for block in blocks:
area = block["area"]
if area not in grouped:
grouped[area] = {"area": area, "blocks": []}
grouped[area]["blocks"].append(block)
result = []
for area, data in grouped.items():
block_list = data["blocks"]
if container_specs and area == "body":
# 비중 기반 컨테이너 생성
# container_specs에서 이 area의 역할별 높이를 가져옴
container_htmls = []
# 역할 순서: 배경 → 본심 → (결론은 footer)
role_order = ["배경", "본심"]
for role in role_order:
spec = container_specs.get(role)
if not spec or spec.zone != area:
continue
# 이 역할에 해당하는 블록들
role_blocks = [
b for b in block_list
if b.get("_topic_id_role") == role or b.get("topic_id") in spec.topic_ids
]
if not role_blocks:
continue
inner_html = "\n".join(b["html"] for b in role_blocks)
# 컨테이너 div: 높이 고정 + overflow visible (측정용)
font_size = spec.block_constraints.get("font_size_px", 15.2)
padding = spec.block_constraints.get("padding_px", 20)
container_htmls.append(
f'<div class="container-{role}" style="'
f'height:{spec.height_px}px; '
f'overflow:visible; '
f'font-size:{font_size}px; '
f'--spacing-inner:{padding}px; '
f'--font-body:{font_size / 16:.3f}rem;">\n'
f'{inner_html}\n</div>'
)
html = "\n".join(container_htmls)
elif len(block_list) == 1:
html = block_list[0]["html"]
else:
inner = "\n".join(b["html"] for b in block_list)
html = (
f'<div style="display:flex; flex-direction:column; '
f'gap:var(--spacing-block); height:100%;">\n'
f'{inner}\n</div>'
)
result.append({"area": area, "html": html})
return result
```
**CSS 구조 (렌더링 결과):**
```html
<!-- body zone -->
<div class="area-body">
<!-- 배경 컨테이너: 98px 고정 -->
<div class="container-배경" style="height:98px; overflow:visible; font-size:13px;">
<!-- topic 1: comparison-2col -->
<!-- topic 2: dark-bullet-list -->
</div>
<!-- 본심 컨테이너: 294px 고정 -->
<div class="container-본심" style="height:294px; overflow:visible; font-size:15.2px;">
<!-- topic 3: compare-2col-split -->
</div>
</div>
<!-- footer: 60px -->
<div class="area-footer" style="height:60px;">
<!-- topic 5: banner-gradient -->
</div>
<!-- sidebar: 490px -->
<div class="area-sidebar">
<!-- topic 4: card-numbered (여유로운 공간) -->
</div>
```
---
### O-6. 파이프라인 흐름 변경 (`pipeline.py`)
**현재 흐름:**
```
1A(Kei 꼭지) → 1B(컨셉) → A-2(블록선택) → B(zone배치) → 공간할당 → 3(편집) → 4(CSS+렌더) → 측정 → 5(검수)
```
**변경 후:**
```
1A(Kei 꼭지 + 비중)
1B(Kei 컨셉)
★ 컨테이너 스펙 계산 (O-1, 코드/결정론적)
A-2(Kei 블록선택 — 컨테이너 제약 전달) (O-2)
B(Sonnet zone + char_guide)
★ 블록 스펙 확정 (O-3, 코드/결정론적)
3(Kei 편집 — 블록 스펙 전달) (O-4)
4(렌더링 — 컨테이너 grid) (O-5)
측정(Selenium)
5(Kei 검수)
```
**pipeline.py 변경 위치:**
```python
# 현재 코드 위치: pipeline.py 105행 부근 (2단계 시작 전)
# ★ O-1: 컨테이너 스펙 계산 (1B 완료 후, Step A-2 전)
yield {"event": "progress", "data": "1.8/5 컨테이너 스펙 계산 중..."}
from src.space_allocator import calculate_container_specs
container_specs = calculate_container_specs(
page_structure=analysis.get("page_structure", {}),
topics=analysis.get("topics", []),
preset=preset,
)
_save_step(run_dir, "step1c_containers.json", {
role: {
"height_px": spec.height_px,
"width_px": spec.width_px,
"max_height_cost": spec.max_height_cost,
"topic_ids": spec.topic_ids,
"block_constraints": spec.block_constraints,
}
for role, spec in container_specs.items()
})
# 2단계: Step A-2에 container_specs 전달
layout_concept = await create_layout_concept(content, analysis, container_specs=container_specs)
# ★ O-3: 블록 스펙 확정 (Step B 후, Step 3 전)
from src.space_allocator import finalize_block_specs
for page in layout_concept.get("pages", []):
finalize_block_specs(page.get("blocks", []), container_specs, catalog)
_save_step(run_dir, "step2c_block_specs.json", {
"blocks": [
{
"type": b.get("type"), "topic_id": b.get("topic_id"),
"_container_height_px": b.get("_container_height_px"),
"_max_items": b.get("_max_items"),
"_max_chars_per_item": b.get("_max_chars_per_item"),
"_max_chars_total": b.get("_max_chars_total"),
"_font_size_px": b.get("_font_size_px"),
}
for p in layout_concept.get("pages", [])
for b in p.get("blocks", [])
]
})
# 3단계: 편집자에게 블록 스펙이 전달됨 (O-4는 content_editor.py에서 자동 적용)
```
---
### O-7. 중간 산출물 추가 (리포트 반영)
**새로 추가되는 중간 산출물:**
| 파일 | 단계 | 내용 |
|------|------|------|
| `step1c_containers.json` | O-1 | 역할별 컨테이너 스펙 (height_px, width_px, max_height_cost, block_constraints) |
| `step2c_block_specs.json` | O-3 | 각 블록의 확정 스펙 (_container_height_px, _max_items, _font_size_px 등) |
`generate_run_report.py`에 이 2개 단계를 추가한다.
---
## 실행 순서
```
O-1: space_allocator.py 확장 (ContainerSpec + calculate_container_specs + calculate_block_constraints + determine_typography)
O-2: design_director.py 변경 (컨테이너 제약을 Kei에게 전달 + 코드 레벨 height_cost 검증)
O-3: space_allocator.py 추가 (finalize_block_specs)
O-4: content_editor.py 변경 (블록 스펙을 편집자 프롬프트에 전달)
O-5: renderer.py 변경 (비중 기반 grid row 컨테이너 생성)
O-6: pipeline.py 변경 (새 단계 삽입 + 중간 산출물 저장)
O-7: generate_run_report.py 확장 (새 중간 산출물 표시)
```
**의존 관계:**
- O-1이 먼저 (나머지 모두 O-1의 ContainerSpec에 의존)
- O-2, O-3은 O-1 완료 후
- O-4는 O-3 완료 후
- O-5는 O-1 완료 후 (O-3과 병렬 가능)
- O-6은 O-1~O-5 전부 완료 후
- O-7은 O-6 완료 후
---
## 검증 기준
이 Phase가 완료되면 아래가 반드시 성립해야 한다:
1. **비중 = 시각 비율**: Kei가 본심 60%로 판단하면, 실제 렌더링에서 body zone의 60%를 본심 블록이 차지한다
2. **컨테이너 밖으로 안 넘침**: 각 블록이 자기 컨테이너 높이 안에 들어간다 (overflow:visible이므로 넘치면 Selenium이 감지)
3. **블록 크기 적합**: 98px 컨테이너에 height_cost=large 블록이 선택되지 않는다
4. **텍스트 분량 적합**: 490px sidebar에서 용어 정의가 출처까지 포함하고, 98px 배경에서 문제제기가 간결하다
5. **중간 산출물 확인 가능**: report.html에서 컨테이너 스펙과 블록 스펙을 단계별로 확인할 수 있다
---
## 기술 조사 결과 반영
### 적용하는 것
- **fonttools** — `calculate_block_constraints()`에서 Pretendard 한글 실측 폭 사용. 하드코딩 `14.0px` 대체. 한글은 uniform-width이므로 정확.
- **CSS Grid 고정 행** — `grid-template-rows: 98px 294px` 형태로 컨테이너 높이 확정. W3C 표준, 모든 브라우저 지원.
- **`overflow: visible` + `scrollHeight`** — 컨테이너 높이 고정 + overflow visible → Selenium이 정확히 감지. CSSOM View 스펙 준수.
### 적용하지 않는 것
- **CSS Container Queries** — 38개 블록 템플릿 전부에 `@container` 규칙 추가 필요. Phase O의 핵심 목표(컨테이너 비중 반영)와 무관한 별도 작업. 필요 시 별도 Phase로.
- **Playwright** — Selenium으로 이미 작동 중. 성능 문제 체감 시 전환.
- **PPTAgent 방식 (절대 좌표)** — 우리는 콘텐츠마다 비중이 동적으로 변하므로 절대 좌표 방식 부적합.
### 조사에서 확인된 사실
- 기존 도구(Slidev, Marp, reveal.js, PPTAgent) 중 비중 기반 컨테이너 시스템을 쓰는 것은 없음. 우리가 직접 구현.
- PPTAgent의 `suggested_characters` 개념은 우리 `_max_chars`와 유사하지만, 원본 PPTX 고정값 vs 우리는 동적 계산.
---
## 기존 코드 충돌 해결 (6건)
Phase O 적용 시 기존 코드와 충돌하는 지점과 해결 방법.
### 충돌 1: `_max_height_px` vs `_container_height_px`
- **현재:** pipeline.py:188에서 `block["_max_height_px"]` 설정
- **해결:** pipeline.py 155~198행(Phase M 공간 할당) 전체를 O-1 `calculate_container_specs()`로 교체
### 충돌 2: `allocate_height_budget()` vs `calculate_container_specs()`
- **현재:** pipeline.py:179에서 `allocate_height_budget()` 호출
- **해결:** 호출부 교체. `allocate_height_budget()` 함수는 제거하지 않고 `calculate_container_specs()` 내부에서 재사용 가능.
### 충돌 3: `_max_chars` 단일값 vs `_max_items` + `_max_chars_per_item`
- **현재:** content_editor.py:121에서 `block.get("_max_chars")` 체크
- **해결:** N-3에서 추가한 `_max_chars` 프롬프트 코드를 O-4 블록 스펙으로 교체
### 충돌 4: Selenium 측정 스크립트가 container div 못 찾음
- **현재:** slide_measurer.py:36에서 `[class*="area-"]`만 검색
- **해결:** `_MEASURE_SCRIPT``.container-*` 셀렉터 추가. container div의 overflow도 감지.
### 충돌 5: Phase L 피드백 루프 필드명
- **현재:** pipeline.py:276에서 `block.get("_max_chars", 400)` 축소
- **해결:** `_max_chars_total` 또는 `_max_items` 축소로 변경
### 충돌 6: fonttools 의존성
- **현재:** pyproject.toml에 fonttools 없음, Pretendard .ttf 로컬 없음
- **해결:** `pip install fonttools` + Pretendard .ttf 다운로드 (CDN에서)
**원칙:** 모든 충돌은 "기존 코드를 Phase O 코드로 교체"하는 형태. 병존이 아닌 대체. 회귀 없음.
---
## 변경하지 않는 것
- catalog.yaml: Phase N에서 이미 개선 완료. 추가 수정 불필요.
- kei_client.py: 프롬프트 변경 없음. Kei는 이미 비중을 잘 판단하고 있다.
- slide_measurer.py: 측정 로직 기본 구조 변경 없음. container 셀렉터만 추가.
- Kei persona_agent: 수정 없음.