Files
C.E.L_Slide_test2/figma_to_html_agent/RULES.md
kyeongmin 9fbe3ac90c add: figma_to_html_agent/blocks/ + 변환 도구 docs 갱신
전체 401 files (397 추가 + 4 수정), 14304 insertions.

추가:
- figma_to_html_agent/blocks/ — Figma 변환 결과 (32 frame, ~79MB).
  각 frame folder = {analysis.md, flat.md, texts.md, index.html, assets/,
  _renders/, _render.py, RELATIONSHIPS.md / STATUS.md / classification.md
  (일부 frame)}.
  Phase Z 의 *figma source layer* — runtime 에서 직접 사용 X, contract /
  partial / builder adapter (미래 axis A) 의 source.
- figma_to_html_agent/DISCUSSION-SUMMARY-20260411.md — 변환 설계 회의 기록.
- figma_to_html_agent/HARNESS.md — 변환 검증 harness.
- figma_to_html_agent/scripts/fetch_figma_screenshots.py — Figma 스크린샷 자동 수집.

수정:
- figma_to_html_agent/PROCESS-CONTROL.md / PROCESS.md / RULES.md —
  변환 프로세스 / 룰 갱신 (R8/R9 lock 강화 등).
- figma_to_html_agent/blocks_index.md — 32 frame 인덱스 갱신.

Phase Z 영향 0 (figma_to_html_agent/blocks/ 가 V4 catalog +
templates/phase_z2/families adapter 의 source — runtime 에서 직접 import X).

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

22 KiB
Raw Blame History

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 아님,
    줄바꿈)

CSS 구현:

.rotated-bracket { transform: rotate(90deg); }   /* 여는 괄호 */
.rotated-bracket-close { transform: rotate(-90deg); }  /* 닫는 괄호 */

⚠️ 회전 bbox의 x,y 좌표를 그대로 left,top에 넣지 마라:

MCP get_metadata가 보고하는 회전 텍스트의 x, y회전 후 bbox의 top-left이다. 회전 전 글자의 시각적 중심과 다르다. 같은 세로 축에 있는 요소들인데 MCP x 값이 제각각인 경우가 이것 때문이다.

예시: 세로 라벨 "기술 ( 디지털 )" — 모두 같은 세로 축

MCP 보고값:
  "기 술"  x=17.8  width=37   → center_x = 36.3
  "("      x=72    width=60   → center_x = 102  ← bbox가 회전으로 이동
  "디지털" x=17.8  width=37   → center_x = 36.3
  "("      x=0     width=60   → center_x = 30   ← 반대쪽으로 이동

시각적 중심은 모두 x≈36.3인데, MCP bbox x는 0, 17.8, 72로 제각각.

올바른 처리:

  1. 같은 세로 축에 있는 요소들을 먼저 식별 (같은 그룹, 같은 바 안 등)
  2. 비회전 텍스트의 center_x를 기준축으로 확정: center_x = left + width / 2
  3. 회전 요소의 컨테이너도 같은 center_x에 정렬: container_left = center_x - container_width / 2
  4. y값만 MCP에서 그대로 사용 (y는 회전 여부와 관계없이 정확)
기준: center_x = 17.8 + 37/2 = 36.3
괄호 컨테이너(60px): left = 36.3 - 30 = 6.3
→ 모든 요소가 x=36.3 중심으로 정렬

절대 하지 말 것:

  • MCP의 회전 bbox x 값을 그대로 CSS left에 넣기 (72px, 0px 등)
  • "MCP 데이터니까 맞겠지" 하고 검증 없이 사용

R3. 세로 텍스트 (좁은 박스)

문제: Figma에서 좁은 박스(width < fontSize) 안에 텍스트를 넣으면 글자가 한 줄에 하나씩 배치됨.

감지: bbox.width < fontSize × 0.8 + 2글자 이상

CSS 구현: writing-mode 사용하지 않음. HTML에서 <br>로 글자마다 줄바꿈.

<span class="vlabel"><br></span>

이유: writing-mode: vertical-rl은 Figma 원본과 다른 간격/정렬을 만듦.

동일 축 정렬 원칙: 세로 텍스트 + 회전 괄호가 하나의 세로 라인을 이루면:

  • 모든 요소의 x 중심이 동일해야 한다
  • y만 변한다 (위에서 아래로 순서대로)
  • R2의 "회전 bbox 좌표 함정" 규칙 적용 필수

R4. 그라데이션 텍스트

Figma: 텍스트 fills에 GRADIENT_LINEAR이 있으면 그라데이션 텍스트.

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 (외부)

.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)

.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 만큼 빼야 한다.

# 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.pysvg_to_css_remap() 사용:

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 적용.

body {
  font-family: 'Noto Sans KR', sans-serif;
  ...
  word-break: keep-all;   /* 한글 단어 단위 wrap (Figma matching) */
}

또는 텍스트 컨테이너 단위로:

.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 코드에서 자주 보이는 패턴:

<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)와 어긋남.

올바른 변환:

.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)

<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

.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 구현:

.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개 첫 적용. 상/하 배치 반전 패턴 발견.

R17. 콘텐츠 주도형 레이아웃 (absolute 금지, flex/flow 필수)

문제: 블록 내 모든 요소를 absolute + 고정 top/left로 배치하면, 텍스트 분량이 바뀔 때 겹치거나 빈 공간이 생긴다. overflow:hidden으로 숨기는 건 해결이 아니라 더 큰 문제.

원칙:

  • CLAUDE.md: "텍스트를 자르지 않는다. 디자인이 텍스트에 맞춘다."
  • CLAUDE.md: "빈 공간 방치 안 함"
  • blocks_index.md I-1: "모든 픽셀 위치 절대 배치 금지"

구조 (2단계):

.inner (zoom: S — 뷰포트 맞춤)
  .title (flow)
  .rows (display: flex; flex-direction: column)
    section.row (flex column — pill → body → pill)
      .pill-area (flex: none)
      .body-area (flex: 1, display: flex)
        .body-left (flex: 1, 자연 flow)
        .divider (flex: none)
        .body-right (flex: 1, 자연 flow)
      .pill-area (flex: none)
    section.row ...

1단계: row 내부만 flex/flow (row 위치는 absolute top 유지) 2단계: row 간 배치도 flex column (행이 커지면 아래 행이 밀림)

절대 하지 말 것:

  • 본문 텍스트에 position: absolute + 고정 top
  • 텍스트 겹침을 overflow: hidden으로 숨기기
  • 텍스트 높이를 고정값으로 제한

적용 사례:

프레임 전환 결과
1171281194 absolute → flex 2단계 텍스트-pill 겹침 해결, 행 간 자연 flow

R18. Crop variant / Label position 분리 (pill 2축 구조)

문제: pill 이미지의 crop 방향(어떤 곡면을 보여줄지)과 라벨 위치(행에서 좌/우 어디에 있는지)를 같은 클래스로 묶으면, 하단 pill(flip 적용)에서 라벨 위치가 뒤집힌다.

원인: crop-left/right는 이미지 방향 축, pos-left/right는 행 내 위치 축. 이 둘은 독립.

규칙: 두 축을 분리한다.

/* 축1: 이미지 crop (어떤 곡면 보일지) */
.crop-left { }   /* 우측 곡선 보임 */
.crop-right { }  /* 좌측 곡선 보임 */

/* 축2: 라벨 위치 (행에서 좌/우) */
.pos-left .label { left: ...; }
.pos-right .label { right: ...; text-align: right; }

4가지 조합:

상황 crop pos
상단 좌 pill crop-left pos-left 개념 부재
상단 우 pill crop-right pos-right 잘못된 접근방식
하단 좌 pill (flip) crop-right pos-left 방향성 상실
하단 우 pill (flip) crop-left pos-right 전제조건 오류

라벨 offset:

  • 현재: designer-tuned fallback (Figma 원본 값)
  • 장기 목표: seam-based computed anchor (pill_seam_meta.md 참조)
  • 라벨 offset은 crop visible area 기준으로 별도 조정 가능

적용 사례: 1171281194 (issues-paired-rows) — 하단 pill 라벨 위치 오류 수정


R19. zoom vs transform:scale

문제: transform: scale(S)는 시각적 크기만 줄이고 레이아웃 상의 높이는 원본 그대로. flex/flow 기반 콘텐츠와 함께 쓰면 하단에 큰 여백이 생긴다.

규칙: 콘텐츠 주도형 레이아웃(R17)에서는 zoom을 사용한다.

/* ✗ 문제: layout height가 원본 크기 */
.inner {
  transform: scale(0.69);
  transform-origin: top left;
}

/* ✓ 해결: layout height도 실제로 줄어듦 */
.inner {
  zoom: 0.69;
}

차이:

속성 시각 크기 레이아웃 크기 flow 호환
transform: scale 줄어듦 원본 그대로
zoom 줄어듦 줄어듦

주의: 기존 absolute 기반 블록(1:1 reference)에서는 transform:scale이 문제없음. R17 구조로 전환한 블록에서만 zoom 적용.

브라우저 지원: Chrome, Safari, Firefox 126+ (2024~) 모두 지원.