Files
Figma-to-HTML/RULES.md
kyeongmin beb5fd0c61 Figma-to-HTML 에이전트 초기 커밋
- 10단계 변환 프로세스 (PROCESS.md)
- 수학 공식 레퍼런스 (MATH.md, gradient_math.py)
- CSS 보정 규칙 R1~R16 (RULES.md)
- 작업 규율 7개 규칙 (PROCESS-CONTROL.md)
- 8개 Figma 프레임 1:1 HTML 변환물 (block-tests/)
- 8개 Jinja2 템플릿 staging (templates_staging/)
- 변환 완료 도서관 + 디자인 인사이트 (blocks_index.md)
- 사용법 가이드 (README.md)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:16:33 +09:00

467 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CSS 보정 규칙
Figma → HTML 변환 시 Figma와 CSS 렌더링 차이를 수학적으로 보정하는 규칙 모음.
**모든 규칙은 수학적 근거가 있어야 한다. 감으로 보정하지 않는다.**
---
## R1. Descender 보정 (padding-bottom)
**문제:** CSS `line-height: 1`이면 글리프 하강부(g, y, p, 쉼표)가 잘림.
Figma는 line-height에 관계없이 글리프를 항상 표시하지만, CSS는 line box 밖을 자른다.
**원인:** 폰트의 content area > line box일 때 half-leading이 음수가 되어 잘림 발생.
**계산:**
```
content_area_ratio = (typoAscender + |typoDescender|) / UPM
half_leading = (line_height - content_area_ratio) / 2 ← 음수이면 잘림
clipped_px = |half_leading| × font_size
padding-bottom = ceil(clipped_px)
```
**폰트별 값:**
| 폰트 | UPM | Ascender | Descender | content_area_ratio |
|------|-----|----------|-----------|-------------------|
| Noto Sans KR | 1000 | 1160 | 288 | 1.448 |
| Pretendard | 1000 | 1100 | 300 | 1.400 |
**예시 (Noto Sans KR, font-size 27.1px, line-height 1):**
```
half_leading = (1 - 1.448) / 2 = -0.224
clipped = 0.224 × 27.1 = 6.07px
→ padding-bottom: 7px
```
**적용:** `line-height < content_area_ratio`인 모든 텍스트 요소에 padding-bottom 추가.
---
## R2. 회전 감지 (bbox 비율)
**문제:** Figma MCP는 `rotation`/`transform` 속성을 출력하지 않음.
**감지 방법:** 바운딩 박스의 가로세로 비율이 해당 글자의 정상 비율과 반대이면 회전.
```
단일 문자 "(" 정상: ~18×50 (세로가 김)
Figma bbox: 60×19 (가로가 김)
→ 가로:세로 = 3.2:1 → 90° 회전 확정
```
**규칙:**
- 단일 문자 텍스트에서 `width > height × 1.5` → 90° 회전
- 일반 텍스트에서 `width < fontSize × 0.8` → 세로 배치용 좁은 박스 (writing-mode 아님, <br> 줄바꿈)
**CSS 구현:**
```css
.rotated-bracket { transform: rotate(90deg); } /* 여는 괄호 */
.rotated-bracket-close { transform: rotate(-90deg); } /* 닫는 괄호 */
```
---
## R3. 세로 텍스트 (좁은 박스)
**문제:** Figma에서 좁은 박스(width < fontSize) 안에 텍스트를 넣으면 글자가 한 줄에 하나씩 배치됨.
**감지:** `bbox.width < fontSize × 0.8` + 2글자 이상
**CSS 구현:** `writing-mode` 사용하지 않음. HTML에서 `<br>`로 글자마다 줄바꿈.
```html
<span class="vlabel"><br></span>
```
이유: `writing-mode: vertical-rl`은 Figma 원본과 다른 간격/정렬을 만듦.
---
## R4. 그라데이션 텍스트
**Figma:** 텍스트 fills에 GRADIENT_LINEAR이 있으면 그라데이션 텍스트.
**CSS:**
```css
.gradient-text {
background: linear-gradient(...);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
```
**주의:** 흰 텍스트 스트로크(`-webkit-text-stroke: white`) 사용 금지.
HTML에서 보기 불편하므로 제거한다.
---
## R5. 다중 fills 처리
**Figma:** 하나의 노드에 여러 fill이 쌓일 수 있음 (리스트 순서 = 위에서 아래).
**규칙:** 첫 번째 fill이 불투명(opacity 1)이면 나머지는 가려짐 → 첫 번째만 사용.
---
## R6. 중복 노드
**감지:** 동일 좌표 + 동일 내용 + 동일 크기 → Figma 복사 흔적.
**처리:** 1개만 렌더링, 나머지 무시. flat 목록에 [중복] 표기.
---
## R7. 미리보기 배경
**슬라이드 배경:** 항상 `#ffffff` (흰색)
**블록 배경:** 항상 `#ffffff` (미리보기용). 원본 배경색은 주석으로 기록.
이유: 다크 배경에서 요소가 안 보이는 문제 방지. 위치/크기 확인이 우선.
---
## R8. 스케일 팩터
**계산:** `Scale = 1280 / 원본_width`
**적용 대상:**
- 위치 (x, y)
- 크기 (width, height)
- 폰트 크기 (fontSize)
- 스트로크 너비 (strokeWeight)
- 간격 (gap, padding)
- 그림자 (blur, offset)
**적용하지 않는 것:**
- 색상 (그대로 유지)
- 그라데이션 방향/퍼센트 (그대로 유지)
- 폰트 굵기 (그대로 유지)
- line-height 비율 (그대로 유지)
- border-radius 비율 (스케일 적용)
**구현 권장:** 매 값 수동 곱셈 대신 `transform: scale(S)` 한 번으로 균일 축소. MATH.md §1 참조.
---
## R9. 순수 CSS 우선, SVG는 곡선/필터에만
블록 라이브러리의 동적 재구성을 위해 가능한 한 **HTML div + CSS**로 구현한다.
| 요소 | 구현 |
|------|------|
| 원/사각형 + linear-gradient | `<div>` + `border-radius` + `background: linear-gradient(...)` |
| Stroke (경계선) | `border` + `box-sizing: border-box` |
| Drop shadow blur | `box-shadow: 0 0 {2×stdDev}px {color}` |
| **곡선 (아크, 비원형 path)** | **SVG `<path>` 또는 PNG** ← CSS 불가능 |
| **복잡한 SVG filter chain** | **SVG `<filter>`** ← CSS 근사 불가 시 |
| 텍스트 | HTML `<div>` 절대 배치 |
**이유:** SVG `<img src="...svg">`는 정적 파일. 색상/개수/위치 변경 시 매번 재export 필요. CSS는 변수/Jinja로 즉시 파라미터화 가능.
---
## R10. Blend mode 호환 (plus-darker → multiply)
**문제:** Figma의 `plus darker` blend mode는 Apple CoreGraphics 전용. CSS 스펙엔 `plus-darker`가 있지만 **Safari/WebKit만 지원**, Chrome/Firefox에서는 무시되어 효과 사라짐.
**규칙:**
1. SVG/CSS에 `mix-blend-mode: plus-darker` 발견 시 → **`multiply`로 교체**
2. SVG 파일 내부의 `style="mix-blend-mode:plus-darker"`도 함께 교체
3. 시각 차이 검증: 흰 배경 위 밝은 그라데이션은 거의 동일. 어두운 영역은 multiply가 더 강함
```
plus-darker(src, dst) = max(0, src + dst - 1) [Safari only]
multiply(src, dst) = src × dst [모든 브라우저]
```
자세한 비교: MATH.md §7
---
## R11. Stroke 정렬: viewBox padding 처리
SVG는 stroke가 fill의 안/밖으로 확장될 수 있어 viewBox에 padding이 들어감. CSS 변환 시 두 케이스로 나뉨:
### 케이스 A — Stroke가 fill **외부**
예: `r=140 fill` + `r=142.5 stroke-width=5` → stroke가 r=140~145 (외부)
```css
.ring {
width: 290px; height: 290px; /* fill 280 + 외부 stroke 5×2 */
border: 5px solid white;
box-sizing: border-box; /* border 안쪽 padding-box = 280 = fill */
background: linear-gradient(...); /* default origin: padding-box 280 */
border-radius: 50%;
}
/* 위치: Figma fill 위치에서 (-5, -5) 오프셋 */
```
### 케이스 B — Stroke가 fill **내부** (overlap)
예: `r=140 fill` + `r=137.5 stroke-width=5` → stroke가 r=135~140 (fill 외곽 overlap)
```css
.ring {
width: 280px; height: 280px; /* fill 280 그대로 */
border: 5px solid white;
box-sizing: border-box; /* padding-box 270 */
background: linear-gradient(...);
background-origin: border-box; /* gradient는 280 영역에 매핑 */
background-clip: border-box;
border-radius: 50%;
}
/* 위치: Figma fill 위치 그대로 */
```
판별: SVG 안의 stroke `r` 값이 fill `r`보다 **크면** 외부 (케이스 A), **작거나 같으면** 내부 (케이스 B).
---
## R12. viewBox padding gradient remap
viewBox padding이 있는 SVG의 그라데이션 좌표는 viewBox 공간 기준이므로, CSS 박스로 매핑할 때 **각 좌표에서 padding 만큼 빼야** 한다.
```python
# SVG viewBox 310, 실제 fill 280, padding 15
css_x1 = svg_x1 - 15
css_y1 = svg_y1 - 15
css_x2 = svg_x2 - 15
css_y2 = svg_y2 - 15
# 그 다음 svg_to_css(W=280, H=280, ...)
```
또는 `scripts/gradient_math.py``svg_to_css_remap()` 사용:
```python
svg_to_css_remap(css_W=280, css_H=280, viewbox_padding=15,
x1=..., y1=..., x2=..., y2=..., stops=[...])
```
---
## R14. 한글 줄바꿈은 word-break: keep-all (전역 default)
**문제:** Chrome 기본 동작은 한글을 글자 단위로 wrap (예: "수행공정의 쉬운이해로 관리 편의성 증" / "진"). Figma는 단어 단위 wrap이라 시각이 다름.
**규칙:** 모든 변환물의 base CSS에 `word-break: keep-all` 적용.
```css
body {
font-family: 'Noto Sans KR', sans-serif;
...
word-break: keep-all; /* 한글 단어 단위 wrap (Figma matching) */
}
```
또는 텍스트 컨테이너 단위로:
```css
.bullet-text, .left-text, .right-text, .body-text {
word-break: keep-all;
}
```
**언제 빼나:**
- `white-space: nowrap` 단일 라인 텍스트 (영향 없음, 안 빼도 무방)
- 코드/숫자 등 단어 경계가 없는 콘텐츠
**예외:** 영문/기호 혼합 텍스트는 `word-break: keep-all` 만으로는 부족할 수 있음. 그 경우 `overflow-wrap: anywhere` 또는 `<br>` 명시 split.
---
## R15. 박스 vertical center align (Figma flex justify-center 모방)
**문제:** Figma React 코드에서 자주 보이는 패턴:
```jsx
<div className="-translate-y-1/2 absolute flex flex-col h-[71px] justify-center top-[243.5px]">
```
이는 **컨테이너 박스의 vertical center에 텍스트를 정렬**한다는 의미. 단순히 `top` 값만 받아서 박는 건 잘못 — 텍스트가 박스 top에 붙어 다른 요소(예: cat pill의 vertical center)와 어긋남.
**올바른 변환:**
```css
.text-box {
position: absolute;
top: <visual_top>; /* Figma top - height/2 */
height: <figma_height>;
width: <figma_width>;
display: flex;
flex-direction: column;
justify-content: center; /* vertical center */
}
```
**또는 인접 박스(예: 옆에 있는 cat pill)와 동일한 top + height를 박고 flex justify-center 적용**하면 자동으로 가운데 align. 1:1 변환에서 가장 안전.
**검증:** 인접 박스 center y 와 텍스트 박스 center y 가 같은지 측정. 차이 > 5px이면 잘못된 것.
---
## R13. Custom-Marker Bullet List 패턴 (sub-pattern)
**감지 조건 (3가지 모두 충족):**
1. 여러 텍스트 항목이 세로로 나열됨
2. 각 항목 앞에 **장식 마커**가 있음 (체크박스 아이콘, 점, 화살표, 숫자, 원, PNG 등)
3. 마커는 인터랙티브하지 않고 순수 시각 요소 (실제 `<input type="checkbox">` 가 아님)
**Figma 원본에서는** 마커와 텍스트가 별도 요소로 평면 배치돼있을 수 있다. 그래도 **시맨틱적으로는 하나의 list item**으로 봐야 한다.
### 구조 (CSS Flex Pair Pattern)
```html
<div class="bullet-list" style="--icon-gap: ...;">
<div class="bullet-row">
<span class="bullet-icon"><img src="marker.png"></span>
<span class="bullet-text">텍스트 항목</span>
</div>
<div class="bullet-row compact">
<span class="bullet-icon"><img src="marker.png"></span>
<span class="bullet-text">긴 텍스트가<br>두 줄로</span>
</div>
</div>
```
### CSS
```css
.bullet-list {
display: flex;
flex-direction: column;
/* 동일 top/bottom 정렬을 위해 컨테이너에 fixed height + space-between */
justify-content: space-between;
}
.bullet-row {
display: flex;
align-items: flex-start;
--lh: 85px; /* 기본 라인 높이 */
}
.bullet-row.compact {
--lh: 50px; /* 2-line 항목용 타이트 lh */
}
.bullet-icon {
flex: none;
width: var(--icon-w);
height: var(--icon-h);
/* 핵심: 아이콘 vertical center를 첫 줄 vertical center에 align */
margin-top: calc(var(--lh) / 2 - var(--icon-h) / 2);
/* 컬럼별 figma gap (text_left icon_left icon_w) */
margin-right: var(--icon-gap);
}
.bullet-text {
flex: 1;
line-height: var(--lh);
white-space: normal;
word-break: keep-all; /* 한글: 단어 단위 줄바꿈 */
}
```
### 핵심 수학
```
icon margin-top = lh / 2 icon_h / 2 (첫 줄 vertical center)
icon margin-right = text_left icon_left icon_w (Figma 데이터)
```
### 절대 하지 말 것
- 마커와 텍스트를 별도 요소로 절대 배치 (`<div class="checkbox" style="left:..; top:..">` × N)
- row에 fixed `height` 설정 (wrap 시 overlap)
- `white-space: nowrap` (텍스트가 컨테이너 밖으로 overflow)
- 모든 row에 동일한 top/bottom margin 강제 (텍스트 길이가 결정해야 함)
### 정렬 원칙
3개 이상의 평행한 컬럼이 있을 때:
- **모든 컬럼은 동일한 top + 동일한 height** 로 시작
- 컬럼별 자연 콘텐츠 합 중 **가장 큰 값**을 height로 사용
- `justify-content: space-between` 으로 내부 균등 분포
- 결과: 컬럼별 spacing은 다르지만 vertical extent는 동일
### 적용 사례
| 프레임 | 사용 | 비고 |
|--------|------|------|
| 1171281191 (cards-3col-persona) | 3 컬럼 × 6~7 마커-text 페어 | 첫 적용 |
| (앞으로 비슷한 패턴 발견 시 추가) | | |
### 1:1 변환 단계의 임시 보정 (템플릿화 시 제거)
다음은 1:1 시각 fidelity를 위한 **임시 보정**이며, 템플릿화 시 모두 제거해야 한다 (자연 wrap이 처리):
- `letter-spacing: -1.5px` 등 — Chrome Noto Sans KR 너비가 Figma보다 약간 넓어 wrap이 일어나는 것을 방지하기 위한 보정
- `<br>` 명시적 줄바꿈 — Figma의 의도된 split 위치 보존용. 템플릿화 시 자연 wrap이 알아서 처리
- `class="compact"` 수동 지정 — 어떤 항목이 2-line인지 1:1 단계에선 수동, 템플릿화 시 텍스트 길이 자동 판정
이 보정들은 HTML 코멘트로 `<!-- TEMP: 1:1 fidelity, 템플릿화 시 제거 -->` 표시한다.
---
## R16. 이미지 프레임 배치 — overflow:hidden으로 부분 표시
**상황:** 하나의 원본 이미지에 양쪽 끝 모두 디자인 요소(곡선, 말림, 장식 등)가 있고, Figma에서 프레임(컨테이너)보다 이미지를 크게 배치하여 **한쪽만 보이게** 하는 경우.
**Figma가 하는 것:**
- 프레임: 457.96px (표시 영역)
- 이미지: 664px (원본, 프레임보다 큼)
- 이미지를 프레임 안에서 `left`, `width`로 위치/크기 지정
- 프레임에 `overflow: hidden` → 프레임 밖으로 나간 부분 안 보임
- 결과: 이미지의 **원하는 쪽만** 프레임 안에 보임
**Figma가 주는 값의 의미:**
```
left: -45.3%; width: 145.3%
→ 이미지를 좌측으로 45.3% 밀어서 배치
→ 좌측 끝이 프레임 밖으로 나감 → 좌측 디자인 요소 안 보임
→ 우측 디자인 요소만 프레임 안에 보임
left: 0; width: 151.25%
→ 이미지를 좌측 정렬, 우측이 프레임 밖으로 넘침
→ 우측 디자인 요소 안 보임
→ 좌측 디자인 요소만 프레임 안에 보임
```
**이것은 crop이 아니다.** 이미지를 자르는 것이 아니라, 프레임 안에서 이미지의 **위치**를 조절하는 것. 이미지 원본은 그대로 유지.
**CSS 구현:**
```css
.pill-frame {
position: relative; /* 또는 absolute */
width: 457.96px; /* 프레임 크기 */
height: 95.62px;
overflow: hidden; /* 핵심: 프레임 밖 숨김 */
}
.pill-frame img {
position: absolute;
top: 0;
left: -45.3%; /* Figma 값 그대로 */
width: 145.3%; /* Figma 값 그대로 */
height: 100%;
}
```
**절대 하지 말 것:**
- `width: 100%; object-fit: fill` — 이미지가 찌그러져 양쪽 디자인 요소가 다 보임
- `scaleX(-1)` 임의 추가 — Figma에 없는 변환
- `object-fit: cover/contain` — 이미지 비율/위치가 달라짐
- "crop"이라 부르기 — 이미지를 자르는 게 아니라 위치를 조절하는 것
**rotate(180deg) + 이미지 배치 주의:**
부모에 `rotate(180deg)`가 적용된 경우 (예: 하단 pill), 이미지가 상하좌우 모두 뒤집힘. 이때 **이미지 배치(left/width)를 상단과 반대로** 적용해야 최종 결과가 올바른 방향이 됨.
```
상단 left-pill: left: -45.3%; width: 145.3% → 우측 보임
하단 left-pill: rotate(180) + left: 0; width: 151.25% → 결과적으로 우측 보임 (뒤집혀서)
상단 right-pill: left: 0; width: 151.25% → 좌측 보임
하단 right-pill: rotate(180) + left: -45.3%; width: 145.3% → 결과적으로 좌측 보임 (뒤집혀서)
```
**검증 방법:** 각 pill을 개별 screenshot으로 뽑아서 Figma 원본 pill screenshot과 **곡선/직선 위치**를 1:1 대조. 양쪽 다 곡선이 보이면 이미지 배치가 잘못된 것.
**적용 사례:**
| 프레임 | 사용 | 비고 |
|--------|------|------|
| 1171281194 (issues-paired-rows) | 두루마리 pill 8개 | 첫 적용. 상/하 배치 반전 패턴 발견. |