@su-record/vibe 2.8.45 → 2.8.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,7 @@
15
15
  import fs from 'fs';
16
16
  import path from 'path';
17
17
  import os from 'os';
18
+ import { execFileSync } from 'child_process';
18
19
 
19
20
  // ─── Config ─────────────────────────────────────────────────────────
20
21
 
@@ -22,6 +23,39 @@ const FIGMA_API = 'https://api.figma.com/v1';
22
23
  const MAX_RETRIES = 3;
23
24
  const INITIAL_DELAY_MS = 2000;
24
25
 
26
+ // ─── WebP 변환 ──────────────────────────────────────────────────────
27
+
28
+ let _cwebpAvailable;
29
+ function hasCwebp() {
30
+ if (_cwebpAvailable === undefined) {
31
+ try { execFileSync('cwebp', ['-version'], { stdio: 'ignore' }); _cwebpAvailable = true; }
32
+ catch { _cwebpAvailable = false; }
33
+ }
34
+ return _cwebpAvailable;
35
+ }
36
+
37
+ /** PNG 버퍼를 webp 파일로 저장. cwebp 없으면 png 폴백. */
38
+ function writeAsWebp(pngBuf, outPath) {
39
+ if (!hasCwebp()) {
40
+ // cwebp 없으면 png 폴백
41
+ const fallback = outPath.replace(/\.webp$/, '.png');
42
+ fs.writeFileSync(fallback, pngBuf);
43
+ return fallback;
44
+ }
45
+ const tmpPng = outPath + '.tmp.png';
46
+ fs.writeFileSync(tmpPng, pngBuf);
47
+ try {
48
+ execFileSync('cwebp', ['-q', '85', tmpPng, '-o', outPath], { stdio: 'ignore' });
49
+ fs.unlinkSync(tmpPng);
50
+ return outPath;
51
+ } catch {
52
+ // 변환 실패 시 png 폴백
53
+ const fallback = outPath.replace(/\.webp$/, '.png');
54
+ fs.renameSync(tmpPng, fallback);
55
+ return fallback;
56
+ }
57
+ }
58
+
25
59
  function loadToken() {
26
60
  if (process.env.FIGMA_ACCESS_TOKEN) return process.env.FIGMA_ACCESS_TOKEN;
27
61
  const configPath = path.join(os.homedir(), '.vibe', 'config.json');
@@ -178,11 +212,10 @@ async function cmdImages(token, fk, nid, outDir, depth) {
178
212
  for (const ref of refs) {
179
213
  const url = urls[ref];
180
214
  if (!url) continue;
181
- const out = path.join(outDir, ref.slice(0,16) + '.png');
215
+ const outWebp = path.join(outDir, ref.slice(0,16) + '.webp');
182
216
  dl.push(fetch(url).then(r=>r.arrayBuffer()).then(b=>{
183
- fs.writeFileSync(out, Buffer.from(b));
184
- const sz = fs.statSync(out).size;
185
- if (sz > 0) imageMap[ref] = out;
217
+ const actual = writeAsWebp(Buffer.from(b), outWebp);
218
+ if (fs.statSync(actual).size > 0) imageMap[ref] = actual;
186
219
  }).catch(()=>{}));
187
220
  }
188
221
  await Promise.all(dl);
@@ -200,8 +233,14 @@ async function cmdScreenshot(token, fk, nid, outPath) {
200
233
  const url = data.images?.[nid];
201
234
  if (!url) continue;
202
235
  const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
203
- fs.writeFileSync(outPath, buf);
204
- console.log(JSON.stringify({ path: outPath, size: buf.length, scale }));
236
+ let finalPath = outPath;
237
+ if (outPath.endsWith('.webp')) {
238
+ finalPath = writeAsWebp(buf, outPath);
239
+ } else {
240
+ fs.writeFileSync(outPath, buf);
241
+ }
242
+ const sz = fs.statSync(finalPath).size;
243
+ console.log(JSON.stringify({ path: finalPath, size: sz, scale }));
205
244
  return;
206
245
  } catch (e) {
207
246
  if (scale === 1) fail(`Screenshot failed: ${e.message}`);
@@ -301,7 +340,7 @@ function toSCSS(node, prefix, indent = 0) {
301
340
  function buildImageNames(node, prefix, result = {}) {
302
341
  if (node.imageRef) {
303
342
  const name = nodeName(node, prefix);
304
- result[node.imageRef] = name + '.png';
343
+ result[node.imageRef] = name + '.webp';
305
344
  }
306
345
  if (node.children) {
307
346
  for (const child of node.children) {
@@ -339,12 +378,12 @@ async function cmdRender(token, fk, nid, outDir, depth, scale) {
339
378
  for (const ref of refs) {
340
379
  const url = urls[ref];
341
380
  if (!url) continue;
342
- const fileName = imageNames[ref] || ref.slice(0, 16) + '.png';
381
+ const fileName = imageNames[ref] || ref.slice(0, 16) + '.webp';
343
382
  const filePath = path.join(imgDir, fileName);
344
- const publicPath = `images/${fileName}`;
345
383
  dl.push(fetch(url).then(r => r.arrayBuffer()).then(b => {
346
- fs.writeFileSync(filePath, Buffer.from(b));
347
- if (fs.statSync(filePath).size > 0) imageMap[ref] = publicPath;
384
+ const actual = writeAsWebp(Buffer.from(b), filePath);
385
+ const actualName = path.basename(actual);
386
+ if (fs.statSync(actual).size > 0) imageMap[ref] = `images/${actualName}`;
348
387
  }).catch(() => {}));
349
388
  }
350
389
  await Promise.all(dl);
@@ -353,7 +392,7 @@ async function cmdRender(token, fk, nid, outDir, depth, scale) {
353
392
  // 4. 복합 BG 노드 → 스크린샷으로 렌더링
354
393
  for (const child of tree.children || []) {
355
394
  if (/^(BG|bg|배경)$/i.test(child.name) && child.children?.length > 3) {
356
- const bgName = `${sectionPrefix}-bg-composite.png`;
395
+ const bgName = `${sectionPrefix}-bg-composite.webp`;
357
396
  const bgPath = path.join(imgDir, bgName);
358
397
  try {
359
398
  for (const s of [2, 1]) {
@@ -361,12 +400,12 @@ async function cmdRender(token, fk, nid, outDir, depth, scale) {
361
400
  const sUrl = sData.images?.[child.nodeId];
362
401
  if (!sUrl) continue;
363
402
  const buf = Buffer.from(await (await fetch(sUrl)).arrayBuffer());
364
- fs.writeFileSync(bgPath, buf);
365
- if (buf.length > 0) {
366
- imageMap[`__bg_${child.nodeId}`] = `images/${bgName}`;
367
- // BG 노드의 imageRef를 합성 이미지로 교체
403
+ const actual = writeAsWebp(buf, bgPath);
404
+ const actualName = path.basename(actual);
405
+ if (fs.statSync(actual).size > 0) {
406
+ imageMap[`__bg_${child.nodeId}`] = `images/${actualName}`;
368
407
  child.imageRef = `__bg_${child.nodeId}`;
369
- child.children = []; // 하위 제거 (합성됨)
408
+ child.children = [];
370
409
  break;
371
410
  }
372
411
  }
@@ -375,14 +414,15 @@ async function cmdRender(token, fk, nid, outDir, depth, scale) {
375
414
  }
376
415
 
377
416
  // 5. 스크린샷
378
- const screenshotPath = path.join(outDir, `${sectionPrefix}-screenshot.png`);
417
+ const screenshotPath = path.join(outDir, `${sectionPrefix}-screenshot.webp`);
418
+ let actualScreenshot = screenshotPath;
379
419
  try {
380
420
  for (const s of [2, 1]) {
381
421
  const sData = await apiFetch(`/images/${fk}?ids=${nid}&format=png&scale=${s}`, token);
382
422
  const sUrl = sData.images?.[nid];
383
423
  if (!sUrl) continue;
384
424
  const buf = Buffer.from(await (await fetch(sUrl)).arrayBuffer());
385
- fs.writeFileSync(screenshotPath, buf);
425
+ actualScreenshot = writeAsWebp(buf, screenshotPath);
386
426
  break;
387
427
  }
388
428
  } catch { /* screenshot failed */ }
@@ -405,7 +445,7 @@ async function cmdRender(token, fk, nid, outDir, depth, scale) {
405
445
  html: `${sectionPrefix}.html`,
406
446
  scss: `${sectionPrefix}.scss`,
407
447
  json: `${sectionPrefix}.json`,
408
- screenshot: `${sectionPrefix}-screenshot.png`,
448
+ screenshot: path.basename(actualScreenshot),
409
449
  },
410
450
  images: imageMap,
411
451
  imageCount: Object.keys(imageMap).length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@su-record/vibe",
3
- "version": "2.8.45",
3
+ "version": "2.8.47",
4
4
  "description": "AI Coding Framework for Claude Code — 56 agents, 45 skills, multi-LLM orchestration",
5
5
  "type": "module",
6
6
  "main": "dist/cli/index.js",
@@ -355,7 +355,7 @@ URL 개수에 따른 처리:
355
355
  URL에서 fileKey, nodeId 추출
356
356
 
357
357
  1단계 — 전체 스크린샷 (정답 사진):
358
- node "[FIGMA_SCRIPT]" screenshot {fileKey} {nodeId} --out=/tmp/{feature}/full-screenshot.png
358
+ node "[FIGMA_SCRIPT]" screenshot {fileKey} {nodeId} --out=/tmp/{feature}/full-screenshot.webp
359
359
 
360
360
  2단계 — 전체 트리 + CSS (수치 재료):
361
361
  node "[FIGMA_SCRIPT]" tree {fileKey} {nodeId} --depth=10
@@ -377,7 +377,7 @@ URL에서 fileKey, nodeId 추출
377
377
 
378
378
  4단계 — 섹션별 스크린샷 (부분 정답):
379
379
  트리의 1depth 자식 프레임 각각:
380
- node "[FIGMA_SCRIPT]" screenshot {fileKey} {child.nodeId} --out=/tmp/{feature}/sections/{child.name}.png
380
+ node "[FIGMA_SCRIPT]" screenshot {fileKey} {child.nodeId} --out=/tmp/{feature}/sections/{child.name}.webp
381
381
  ```
382
382
 
383
383
  ### 2-1-multi. 멀티 프레임 재료 확보 (2개 이상 URL)
@@ -399,7 +399,7 @@ URL에서 fileKey, nodeId 추출
399
399
  결과:
400
400
  /tmp/{feature}/
401
401
  ├── frame-1/ ← 메인 페이지
402
- │ ├── full-screenshot.png
402
+ │ ├── full-screenshot.webp
403
403
  │ ├── tree.json
404
404
  │ ├── images/
405
405
  │ └── sections/
@@ -417,17 +417,17 @@ Phase 2 완료 시 /tmp/{feature}/ 에 다음이 준비되어야 함:
417
417
 
418
418
  단일 URL:
419
419
  /tmp/{feature}/
420
- ├── full-screenshot.png ← 전체 정답 사진
420
+ ├── full-screenshot.webp ← 전체 정답 사진
421
421
  ├── tree.json ← 노드 트리 + CSS 수치
422
422
  ├── images/ ← 모든 이미지 에셋
423
- │ ├── hero-bg.png
424
- │ ├── hero-title.png
425
- │ ├── card-item-1.png
423
+ │ ├── hero-bg.webp
424
+ │ ├── hero-title.webp
425
+ │ ├── card-item-1.webp
426
426
  │ └── ...
427
427
  └── sections/ ← 섹션별 정답 사진
428
- ├── hero.png
429
- ├── daily-checkin.png
430
- ├── playtime-mission.png
428
+ ├── hero.webp
429
+ ├── daily-checkin.webp
430
+ ├── playtime-mission.webp
431
431
  └── ...
432
432
 
433
433
  멀티 URL: 위 2-1-multi 결과 구조 참조
@@ -471,7 +471,7 @@ UI 요소 → vw 비례:
471
471
 
472
472
  ```
473
473
  1. 시각 비교 — 각 프레임의 스크린샷을 순차 Read:
474
- - frame-1/full-screenshot.png → frame-2/full-screenshot.png → ...
474
+ - frame-1/full-screenshot.webp → frame-2/full-screenshot.webp → ...
475
475
  - 시각적으로 반복되는 요소 식별:
476
476
  상단 영역 (GNB/Header), 하단 영역 (Footer),
477
477
  카드 패턴, 버튼 스타일, 섹션 레이아웃
@@ -661,14 +661,20 @@ Phase 1에서 생성한 빈 SCSS 파일에 기본 내용 Write:
661
661
  → 스크린샷은 검증용으로만 참조
662
662
 
663
663
  2. 기계적 매핑 (추정 없음):
664
- a. 이미지: 노드 렌더링된 이미지를 static/images/{feature}/에 배치
665
- b. 노드 → HTML 매핑:
664
+ a. 이미지 vs HTML 판별 (각 노드마다 — vibe.figma.convert 체크리스트 참조):
665
+ - TEXT 자식 있음 → HTML (이미지에 텍스트 포함 금지)
666
+ - INSTANCE 반복 패턴 → HTML 반복 구조 (내부 이미지 에셋만 추출)
667
+ - 인터랙티브 요소 (btn, CTA) → HTML <button>
668
+ - 동적 데이터 (가격, 기간, 수량) → HTML 텍스트
669
+ - 위 모두 아님 → 이미지 렌더링 가능
670
+ b. 이미지: 판별 통과한 노드만 static/images/{feature}/에 배치
671
+ c. 노드 → HTML 매핑:
666
672
  - BG 프레임 → CSS background-image (img 태그 아님)
667
673
  - Auto Layout 있음 → <div> + flex (direction/gap/padding 직접)
668
674
  - Auto Layout 없음 → <div> + position:relative (자식 absolute)
669
675
  - TEXT 노드 → <span> (Claude가 h2/p/button으로 승격)
670
- - 콘텐츠 이미지 → <img src="렌더링된 파일">
671
- - 반복 패턴 (동일 구조 3+) → v-for
676
+ - 순수 이미지 에셋 → <img src="렌더링된 파일">
677
+ - 반복 패턴 (동일 구조 2+) → v-for (카드 내 이미지만 <img>, 나머지 HTML)
672
678
  c. CSS 직접 매핑:
673
679
  - node.css의 모든 속성을 SCSS에 1:1 매핑
674
680
  - vw/clamp 반응형 단위 변환 (vibe.figma.convert 참조)
@@ -696,7 +702,7 @@ Phase 1에서 생성한 빈 SCSS 파일에 기본 내용 Write:
696
702
  tree.json의 Auto Layout 구조를 HTML flex 레이아웃으로 직접 매핑.
697
703
  Claude가 시맨틱 태그로 승격 (div → section/h2/p/button).
698
704
  Phase 1의 기능 요소(v-for, @click, v-if) 보존.
699
- 이미지 경로: /images/{feature}/파일명.png (실제 파일 존재 확인)
705
+ 이미지 경로: /images/{feature}/파일명.webp (실제 파일 존재 확인)
700
706
  텍스트: tree.json의 TEXT 노드 characters 값 그대로.
701
707
 
702
708
  <script setup>
@@ -870,15 +876,15 @@ import { captureScreenshot, compareScreenshots } from 'src/infra/lib/browser'
870
876
  각 섹션에 대해:
871
877
  1. 렌더링 결과 스크린샷 캡처:
872
878
  await captureScreenshot(page, {
873
- outPath: '/tmp/{feature}/rendered-{section}.png',
879
+ outPath: '/tmp/{feature}/rendered-{section}.webp',
874
880
  selector: '.{section}Section', // Phase 1에서 만든 클래스
875
881
  })
876
882
 
877
883
  2. Figma 원본과 픽셀 비교:
878
884
  const diff = await compareScreenshots(
879
- '/tmp/{feature}/sections/{section}.png', // Figma 원본
880
- '/tmp/{feature}/rendered-{section}.png', // 렌더링 결과
881
- '/tmp/{feature}/diff-{section}.png', // 차이 시각화
885
+ '/tmp/{feature}/sections/{section}.webp', // Figma 원본
886
+ '/tmp/{feature}/rendered-{section}.webp', // 렌더링 결과
887
+ '/tmp/{feature}/diff-{section}.webp', // 차이 시각화
882
888
  )
883
889
 
884
890
  3. diff 이미지를 Read로 확인:
@@ -53,7 +53,7 @@ ROOT folder: `{{MO_FOLDER}}/`, `{{PC_FOLDER}}/`
53
53
 
54
54
  | Image | Local Path | Type | Render Method |
55
55
  |-------|-----------|------|--------------|
56
- | {{NAME}} | `/images/{{FEATURE_KEY}}/{{FILE_NAME}}.png` | {{bg\|content\|vector-text}} | {{frame-render\|node-render\|group-render}} |
56
+ | {{NAME}} | `/images/{{FEATURE_KEY}}/{{FILE_NAME}}.webp` | {{bg\|content\|vector-text}} | {{frame-render\|node-render\|group-render}} |
57
57
 
58
58
  Total assets: {{ASSET_COUNT}} (BG 렌더링 {{BG_COUNT}}장 + 콘텐츠 {{CONTENT_COUNT}}장)
59
59
 
@@ -43,8 +43,8 @@ tree.json을 코드 생성에 최적화된 구조로 리매핑한다.
43
43
  "states": ["default"]
44
44
  },
45
45
  "bg": {
46
- "mo": { "nodeId": "...", "file": "mo-main/bg/hero-bg.png" },
47
- "pc": { "nodeId": "...", "file": "pc-main/bg/hero-bg.png" }
46
+ "mo": { "nodeId": "...", "file": "mo-main/bg/hero-bg.webp" },
47
+ "pc": { "nodeId": "...", "file": "pc-main/bg/hero-bg.webp" }
48
48
  },
49
49
  "size": {
50
50
  "mo": { "w": 720, "h": 1280 },
@@ -63,8 +63,8 @@ tree.json을 코드 생성에 최적화된 구조로 리매핑한다.
63
63
  "role": "title-image",
64
64
  "type": "node-render",
65
65
  "render": {
66
- "mo": { "nodeId": "...", "file": "mo-main/content/hero-title.png", "size": { "w": 620, "h": 174 } },
67
- "pc": { "nodeId": "...", "file": "pc-main/content/hero-title.png", "size": { "w": 1200, "h": 340 } }
66
+ "mo": { "nodeId": "...", "file": "mo-main/content/hero-title.webp", "size": { "w": 620, "h": 174 } },
67
+ "pc": { "nodeId": "...", "file": "pc-main/content/hero-title.webp", "size": { "w": 1200, "h": 340 } }
68
68
  },
69
69
  "alt": "추운 겨울, 따뜻한 보상이 펑펑"
70
70
  },
@@ -72,8 +72,8 @@ tree.json을 코드 생성에 최적화된 구조로 리매핑한다.
72
72
  "role": "subtitle-image",
73
73
  "type": "node-render",
74
74
  "render": {
75
- "mo": { "nodeId": "...", "file": "mo-main/content/hero-subtitle.png", "size": { "w": 586, "h": 32 } },
76
- "pc": { "nodeId": "...", "file": "pc-main/content/hero-subtitle.png", "size": { "w": 1100, "h": 60 } }
75
+ "mo": { "nodeId": "...", "file": "mo-main/content/hero-subtitle.webp", "size": { "w": 586, "h": 32 } },
76
+ "pc": { "nodeId": "...", "file": "pc-main/content/hero-subtitle.webp", "size": { "w": 1100, "h": 60 } }
77
77
  },
78
78
  "alt": "겨울을 녹일 보상, 지금 PC방에서 획득하세요!"
79
79
  }
@@ -93,8 +93,8 @@ tree.json을 코드 생성에 최적화된 구조로 리매핑한다.
93
93
  "role": "period-panel",
94
94
  "type": "content-with-bg",
95
95
  "bg": {
96
- "mo": { "nodeId": "...", "file": "mo-main/bg/period-bg.png" },
97
- "pc": { "nodeId": "...", "file": "pc-main/bg/period-bg.png" }
96
+ "mo": { "nodeId": "...", "file": "mo-main/bg/period-bg.webp" },
97
+ "pc": { "nodeId": "...", "file": "pc-main/bg/period-bg.webp" }
98
98
  },
99
99
  "size": {
100
100
  "mo": { "w": 720, "h": 500 },
@@ -68,9 +68,14 @@ component-index (/tmp/{feature}/component-index.json) 에서 매칭되는 컴포
68
68
  children 없음 → 빈 div 또는 스킵
69
69
 
70
70
  2. 배경 레이어 판별:
71
- 부모와 동일 크기(±5%) + imageRef 있음 + z-index 낮음
72
- position:absolute + inset:0 + object-fit:cover
73
- 부모에 position:relative + overflow:hidden 추가
71
+ BG 프레임 (name에 "BG"/"bg" 또는 부모와 동일 크기)
72
+ <img> 태그로 배경 처리 금지
73
+ position:absolute + inset:0 으로 이미지 배치 금지
74
+ ✅ 부모 요소에 CSS background-image로 처리:
75
+ background-image: url('/images/{feature}/{section}-bg.webp');
76
+ background-size: cover;
77
+ background-position: center top;
78
+ ✅ BG 프레임은 HTML에 아무것도 렌더링하지 않음 (CSS만)
74
79
 
75
80
  3. 반복 패턴 감지:
76
81
  같은 부모 아래 동일 타입 + 유사 구조(children 수 동일) 노드 3개 이상
@@ -192,6 +197,27 @@ tree.json의 css 객체를 SCSS로 직접 변환한다. 추정하지 않는다.
192
197
 
193
198
  ## 2. 외부 SCSS 파일 구조
194
199
 
200
+ ### _base.scss (필수 — 래퍼 컨테이너)
201
+
202
+ ```scss
203
+ // 모바일 퍼스트: vw 단위가 PC에서 거대해지는 것을 방지
204
+ // designWidth(720px)를 max-width로 제한
205
+
206
+ .{feature} {
207
+ width: 100%;
208
+ max-width: 720px; // designWidth — PC에서 모바일 레이아웃 유지
209
+ margin: 0 auto; // 중앙 정렬
210
+ overflow-x: hidden;
211
+
212
+ // PC 브레이크포인트에서 max-width 확장 (PC 디자인이 있을 때)
213
+ @media (min-width: 1025px) {
214
+ max-width: 100%; // PC 디자인이 있으면 전체 너비
215
+ }
216
+ }
217
+ ```
218
+
219
+ 이 파일이 없으면 vw 단위가 PC 뷰포트에서 비례 확대되어 레이아웃이 깨진다.
220
+
195
221
  ### layout vs components 구분
196
222
 
197
223
  ```
@@ -305,8 +331,8 @@ $bp-desktop: 1024px;
305
331
  Hero (INSTANCE 720x1280)
306
332
  ├── BG (FRAME — 배경 레이어)
307
333
  ├── Title (FRAME — flex-column, gap:24px)
308
- │ ├── Title (RECTANGLE — imageRef → title.png)
309
- │ └── Sub Title (VECTOR — imageRef → subtitle.png)
334
+ │ ├── Title (RECTANGLE — imageRef → title.webp)
335
+ │ └── Sub Title (VECTOR — imageRef → subtitle.webp)
310
336
  ├── Period (FRAME — flex-column, gap:10px, padding)
311
337
  │ └── Period (FRAME — flex-column, gap:22px)
312
338
  │ ├── Period_Left (FRAME — flex-column, gap:4px)
@@ -319,17 +345,15 @@ $bp-desktop: 1024px;
319
345
  -->
320
346
  <template>
321
347
  <section class="heroSection">
322
- <!-- BG: 부모와 동일 크기 + imageRef 배경 레이어 -->
323
- <div class="heroBg">
324
- <img src="/images/{feature}/hero-bg.png" alt="" aria-hidden="true" />
325
- </div>
348
+ <!-- BG: CSS background-image로 처리 (img 태그 아님!) -->
349
+ <!-- .heroSection { background-image: url('/images/{feature}/hero-bg.webp'); background-size: cover; } -->
326
350
 
327
351
  <!-- Title: flex-column, gap:24px → 직접 매핑 -->
328
352
  <div class="heroTitle">
329
- <img src="/images/{feature}/title.png"
353
+ <img src="/images/{feature}/title.webp"
330
354
  alt="추운 겨울, 따뜻한 보상이 펑펑"
331
355
  class="heroTitleImg" />
332
- <img src="/images/{feature}/subtitle.png"
356
+ <img src="/images/{feature}/subtitle.webp"
333
357
  alt="겨울을 녹일 보상, 지금 PC방에서 획득하세요!"
334
358
  class="heroSubtitleImg" />
335
359
  </div>
@@ -353,7 +377,7 @@ $bp-desktop: 1024px;
353
377
 
354
378
  <!-- BTN_Share: flex, borderRadius:500px → 버튼으로 승격 -->
355
379
  <button class="heroShareBtn" @click="handleShare">
356
- <img src="/images/{feature}/share-icon.png" alt="공유하기" class="heroShareIcon" />
380
+ <img src="/images/{feature}/share-icon.webp" alt="공유하기" class="heroShareIcon" />
357
381
  </button>
358
382
  </section>
359
383
  </template>
@@ -407,9 +431,10 @@ function handleShare(): void {
407
431
  트리 노드의 속성으로 이미지 유형을 판별한다:
408
432
 
409
433
  배경 이미지:
410
- 조건: imageRef 있음 + 부모와 크기 동일(±5%) + 형제 중 가장 먼저 위치
411
- 매핑: position:absolute + inset:0 + z-index:0 + object-fit:cover
412
- 태그: <img alt="" aria-hidden="true" />
434
+ 조건: BG 프레임 (name에 BG/bg 또는 부모와 크기 동일)
435
+ <img> 태그 금지
436
+ CSS background-image로만 처리:
437
+ 부모 { background-image: url('...'); background-size: cover; }
413
438
 
414
439
  콘텐츠 이미지:
415
440
  조건: imageRef 있음 + 독립적 크기 + TEXT 형제 없음
@@ -441,7 +466,7 @@ function handleShare(): void {
441
466
  같은 값 → 유지
442
467
  다른 px 값 → @include pc { width: {desktop값 × pcScaleFactor}px; }
443
468
  다른 레이아웃 → @include pc { flex-direction: row; }
444
- 다른 이미지 → @include pc { content: url(/images/{feature}/desktop-xxx.png); }
469
+ 다른 이미지 → @include pc { content: url(/images/{feature}/desktop-xxx.webp); }
445
470
 
446
471
  기존 모바일 코드 삭제 금지.
447
472
  ```
@@ -21,9 +21,39 @@
21
21
 
22
22
  ## 배경 레이어 판별
23
23
 
24
- 부모와 동일 크기(±5%) + imageRef + 형제 중 첫 위치:
25
- `position: absolute; inset: 0; z-index: 0; object-fit: cover`
26
- 부모에 `position: relative; overflow: hidden` 추가
24
+ BG 프레임 (name에 "BG"/"bg" 또는 부모와 동일 크기 ±5%):
25
+ <img> 태그로 배경 처리 금지
26
+ position:absolute + inset:0 으로 이미지 배치 금지
27
+ ✅ 부모 요소에 CSS background-image로만 처리:
28
+ background-image: url('/images/{feature}/{section}-bg.webp');
29
+ background-size: cover;
30
+ background-position: center top;
31
+ ✅ BG 프레임은 HTML에 아무것도 렌더링하지 않음 (CSS만)
32
+
33
+ ## 이미지 vs HTML 판별 (각 노드마다 확인)
34
+
35
+ ```
36
+ 노드를 이미지로 처리하기 전에 반드시 확인:
37
+
38
+ Q1. 이 노드에 TEXT 자식이 있는가?
39
+ YES → HTML로 구현 (텍스트는 이미지에 넣지 않는다)
40
+ NO → Q2로
41
+
42
+ Q2. 이 노드가 INSTANCE 반복 패턴의 일부인가?
43
+ YES → HTML 반복 구조 (v-for/.map()) — 내부 이미지 에셋만 개별 추출
44
+ NO → Q3로
45
+
46
+ Q3. 이 노드에 클릭/인터랙션이 필요한가? (btn, CTA, link, tab)
47
+ YES → HTML <button>/<a> — 이미지 금지
48
+ NO → Q4로
49
+
50
+ Q4. 이 노드의 콘텐츠가 동적 데이터인가? (가격, 수량, 기간, 상태)
51
+ YES → HTML 텍스트 — 이미지 금지
52
+ NO → 이미지 렌더링 가능
53
+
54
+ 위 체크에서 하나라도 YES → HTML로 구현
55
+ 모두 NO → 이미지(벡터 글자, BG, 래스터, 복잡 그래픽)로 렌더링
56
+ ```
27
57
 
28
58
  ## CSS 직접 매핑 규칙
29
59
 
@@ -20,10 +20,8 @@ tree.json 노드를 기반으로 컴포넌트를 생성할 때 이 템플릿을
20
20
  -->
21
21
  <template>
22
22
  <section class="{{sectionName}}">
23
- <!-- BG: 부모와 동일 크기 + imageRef 배경 레이어 -->
24
- <div class="{{sectionName}}__bg">
25
- <img src="/images/{{FEATURE_KEY}}/{{bg-file}}.png" alt="" aria-hidden="true" />
26
- </div>
23
+ <!-- BG: CSS background-image로 처리. img 태그 사용 금지! -->
24
+ <!-- SCSS: .{{sectionName}} { background-image: url('/images/{{FEATURE_KEY}}/{{bg-file}}.webp'); background-size: cover; } -->
27
25
 
28
26
  <!-- {{CHILD_1}}: tree flex-column, gap:{{GAP}}px → 직접 매핑 -->
29
27
  <div class="{{sectionName}}__{{child1Name}}">
@@ -88,7 +86,7 @@ defineProps<{
88
86
  width: 100%;
89
87
  height: {{HEIGHT_VW}}vw; // tree: {{HEIGHT}} / {{DESIGN_WIDTH}} × 100
90
88
  overflow: hidden; // tree: overflow:hidden
91
- background-image: url('/images/{{FEATURE_KEY}}/{{section}}-bg.png');
89
+ background-image: url('/images/{{FEATURE_KEY}}/{{section}}-bg.webp');
92
90
  background-size: cover;
93
91
  background-position: center top;
94
92
  }
@@ -99,32 +99,72 @@ Bash:
99
99
  ```
100
100
  각 섹션의 BG 프레임을 식별 → 합성된 배경 1장으로 렌더링:
101
101
 
102
+ ⚠️ 주의: BG 프레임만 렌더링한다 (텍스트 포함된 상위 프레임 렌더링 금지)
103
+ ❌ 섹션 전체를 렌더링 → 텍스트가 이미지에 포함 → HTML 텍스트와 중복
104
+ ❌ TEXT 자식이 있는 프레임을 렌더링 → 이미지 텍스트 + HTML 텍스트 이중 표시
105
+ ✅ BG 하위 프레임만 렌더링 → 텍스트 없는 배경만 → CSS background-image
106
+ ✅ 텍스트는 tree.json에서 추출하여 HTML로 작성
107
+
108
+ 렌더링 전 TEXT 자식 검증 (BLOCKING):
109
+ BG 프레임의 전체 자식 트리를 순회하여 TEXT 노드 존재 여부 확인
110
+ TEXT 노드 발견 시:
111
+ → TEXT 노드가 포함된 하위 프레임은 렌더링에서 제외
112
+ → BG 프레임 내 순수 시각 요소(이미지, 벡터, 장식)만 렌더링
113
+ → 또는 TEXT가 없는 가장 깊은 BG 하위 프레임을 개별 렌더링
114
+
102
115
  BG 프레임 판별 기준:
103
116
  - name에 "BG", "bg" 포함
104
117
  - 또는 부모와 크기 동일(±10%) + 자식 이미지 3개 이상
105
118
  - 또는 1depth 첫 번째 자식이면서 이미지 노드 다수 보유
106
119
 
107
120
  렌더링:
108
- node "[FIGMA_SCRIPT]" screenshot {fileKey} {bg.nodeId} --out=/tmp/{feature}/bg/{section}-bg.png
121
+ node "[FIGMA_SCRIPT]" screenshot {fileKey} {bg.nodeId} --out=/tmp/{feature}/bg/{section}-bg.webp
109
122
 
110
123
  → BG 하위 20+ 레이어가 합성된 1장
111
124
  → CSS background-image로 처리
112
125
  → 개별 레이어(눈, 나무, 파티클 등) 다운로드하지 않음
113
126
  ```
114
127
 
128
+ ### 2-1.5. 렌더링 금지 노드 (HTML로 구현할 것)
129
+
130
+ ```
131
+ 다음 조건에 해당하는 노드는 이미지로 렌더링하지 않는다:
132
+
133
+ 1. TEXT 자식 보유 프레임 → HTML로 구현
134
+ - 기간, 가격, 수량, 설명 등 텍스트 → HTML
135
+ - "1,000", "보상 교환하기", "2025.12.22" 등 → 이미지에 넣지 않음
136
+ - 텍스트가 이미지에 포함되면 수정/번역/접근성 불가
137
+
138
+ 2. INSTANCE 반복 패턴 (카드/아이템 그리드) → HTML 반복 구조
139
+ - 같은 부모 아래 동일 구조 INSTANCE 2개 이상
140
+ - ❌ 카드 그리드를 통째 이미지 1장으로 렌더링 금지
141
+ - ✅ 각 카드 내부의 이미지 에셋(아이콘, 썸네일)만 개별 추출
142
+ - ✅ 카드 레이아웃, 텍스트, 버튼은 HTML+CSS
143
+
144
+ 3. 인터랙티브 요소 → HTML <button>/<a>
145
+ - name에 "btn", "button", "CTA", "link" 포함
146
+ - 클릭 이벤트가 필요한 요소는 이미지 금지
147
+
148
+ 4. 정보 표시 영역 → HTML 텍스트
149
+ - 기간 표시 ("이벤트 기간", "교환/응모 종료일")
150
+ - 가격/수량 ("1,000", "500")
151
+ - 상태 표시 ("참여 대상", "로그인")
152
+ ```
153
+
115
154
  ### 2-2. 콘텐츠 노드 렌더링
116
155
 
117
156
  ```
118
157
  BG가 아닌 콘텐츠 이미지를 개별 노드 렌더링:
119
158
 
120
- 대상 식별 (tree.json에서):
121
- - 타이틀/서브타이틀 이미지 (name에 "title", "sub title")
122
- - 버튼 이미지 (name에 "btn", "button")
159
+ ⚠️ 렌더링 전 2-1.5 체크 필수 — 하나라도 해당하면 이미지 렌더링 금지
160
+
161
+ 대상 식별 (tree.json에서 TEXT 자식 미보유 노드만):
162
+ - 타이틀/서브타이틀 이미지 (name에 "title", "sub title") — 벡터 글자만, TEXT 노드 아님
123
163
  - 아이콘 (VECTOR/GROUP 크기 ≤ 64px)
124
- - 아이템/보상 이미지 (name에 "item", "reward", "token", "coin")
164
+ - 아이템/보상 썸네일 (name에 "item", "reward", "token", "coin") — 이미지 에셋만
125
165
 
126
166
  렌더링 (imageRef 다운로드 아님!):
127
- node "[FIGMA_SCRIPT]" screenshot {fileKey} {node.nodeId} --out=/tmp/{feature}/content/{name}.png
167
+ node "[FIGMA_SCRIPT]" screenshot {fileKey} {node.nodeId} --out=/tmp/{feature}/content/{name}.webp
128
168
 
129
169
  → 텍스처 fill이 적용된 최종 결과물이 나옴
130
170
  → 22.7MB 텍스처 대신 364KB 렌더링 이미지
@@ -142,7 +182,7 @@ BG가 아닌 콘텐츠 이미지를 개별 노드 렌더링:
142
182
 
143
183
  렌더링:
144
184
  부모 GROUP을 통째로 렌더링 (개별 글자 다운로드 금지)
145
- node "[FIGMA_SCRIPT]" screenshot {fileKey} {group.nodeId} --out=/tmp/{feature}/content/{name}.png
185
+ node "[FIGMA_SCRIPT]" screenshot {fileKey} {group.nodeId} --out=/tmp/{feature}/content/{name}.webp
146
186
 
147
187
  예시:
148
188
  "MISSION 01" GROUP (174x42, 벡터 9개) → 렌더링 1장 (58KB)
@@ -168,10 +208,10 @@ BG가 아닌 콘텐츠 이미지를 개별 노드 렌더링:
168
208
  코드 생성에는 사용하지 않는다. Phase 4 시각 검증에서만 사용.
169
209
 
170
210
  전체 스크린샷:
171
- node "[FIGMA_SCRIPT]" screenshot {fileKey} {nodeId} --out=/tmp/{feature}/full-screenshot.png
211
+ node "[FIGMA_SCRIPT]" screenshot {fileKey} {nodeId} --out=/tmp/{feature}/full-screenshot.webp
172
212
 
173
213
  섹션별 스크린샷 (1depth 자식 프레임 각각):
174
- node "[FIGMA_SCRIPT]" screenshot {fileKey} {child.nodeId} --out=/tmp/{feature}/sections/{name}.png
214
+ node "[FIGMA_SCRIPT]" screenshot {fileKey} {child.nodeId} --out=/tmp/{feature}/sections/{name}.webp
175
215
 
176
216
  용도:
177
217
  ✅ Phase 4에서 렌더링 결과와 pixelmatch 비교
@@ -189,26 +229,26 @@ BG가 아닌 콘텐츠 이미지를 개별 노드 렌더링:
189
229
  /tmp/{feature}/
190
230
  ├── tree.json ← 코드 생성의 PRIMARY 소스
191
231
  ├── bg/ ← BG 프레임 렌더링 (섹션당 1장)
192
- │ ├── hero-bg.png
193
- │ ├── daily-bg.png
232
+ │ ├── hero-bg.webp
233
+ │ ├── daily-bg.webp
194
234
  │ └── ...
195
235
  ├── content/ ← 콘텐츠 노드 렌더링
196
- │ ├── hero-title.png
197
- │ ├── hero-subtitle.png
198
- │ ├── mission-01.png ← 벡터 글자 그룹 렌더링
199
- │ ├── btn-login.png
236
+ │ ├── hero-title.webp
237
+ │ ├── hero-subtitle.webp
238
+ │ ├── mission-01.webp ← 벡터 글자 그룹 렌더링
239
+ │ ├── btn-login.webp
200
240
  │ └── ...
201
- ├── full-screenshot.png ← Phase 4 검증용
241
+ ├── full-screenshot.webp ← Phase 4 검증용
202
242
  └── sections/ ← Phase 4 섹션별 검증용
203
- ├── hero.png
243
+ ├── hero.webp
204
244
  └── ...
205
245
 
206
246
  이미지 분류 (실제 테스트 기준):
207
247
  | 분류 | 처리 | 예시 |
208
248
  |------|------|------|
209
- | BG 프레임 (89개) | 프레임 렌더링 → bg/ | hero-bg.png (4.2MB) |
210
- | 벡터 글자 (33개) | GROUP 렌더링 → content/ | mission-01.png (58KB) |
211
- | 콘텐츠 (8개) | 노드 렌더링 → content/ | hero-title.png (364KB) |
249
+ | BG 프레임 (89개) | 프레임 렌더링 → bg/ | hero-bg.webp (4.2MB) |
250
+ | 벡터 글자 (33개) | GROUP 렌더링 → content/ | mission-01.webp (58KB) |
251
+ | 콘텐츠 (8개) | 노드 렌더링 → content/ | hero-title.webp (364KB) |
212
252
  | 장식 (29개) | BG 렌더링에 포함 | — |
213
253
  → 전체 159개 → 실제 파일 약 18장
214
254
 
@@ -11,9 +11,9 @@
11
11
 
12
12
  | 이미지 유형 | 렌더링 방법 | 출력 위치 |
13
13
  |-----------|-----------|---------|
14
- | BG 프레임 (합성 배경) | `screenshot {fileKey} {bg.nodeId}` | `bg/{section}-bg.png` |
15
- | 콘텐츠 (타이틀, 버튼) | `screenshot {fileKey} {node.nodeId}` | `content/{name}.png` |
16
- | 벡터 글자 그룹 | `screenshot {fileKey} {group.nodeId}` | `content/{name}.png` |
14
+ | BG 프레임 (합성 배경) | `screenshot {fileKey} {bg.nodeId}` | `bg/{section}-bg.webp` |
15
+ | 콘텐츠 (타이틀, 버튼) | `screenshot {fileKey} {node.nodeId}` | `content/{name}.webp` |
16
+ | 벡터 글자 그룹 | `screenshot {fileKey} {group.nodeId}` | `content/{name}.webp` |
17
17
 
18
18
  ## BG 프레임 판별
19
19
 
@@ -39,18 +39,66 @@ BG 프레임 = 다음 중 하나:
39
39
  → 커스텀 폰트 텍스트 = 웹폰트 없음 → 이미지로 사용
40
40
  ```
41
41
 
42
+ ## 렌더링 금지 노드 (HTML로 구현해야 하는 것)
43
+
44
+ ```
45
+ 다음 조건에 해당하는 노드는 이미지로 렌더링하지 않는다 — HTML+CSS로 구현:
46
+
47
+ 1. TEXT 자식 보유 프레임:
48
+ 프레임 내부에 TEXT 노드가 1개 이상 있으면 → HTML로 구현
49
+ ⚠️ BG 프레임 렌더링 시에도 TEXT 포함 여부 반드시 확인:
50
+ - BG 프레임에 TEXT 자식이 있으면 → BG 하위만 렌더링 (TEXT 제외)
51
+ - 텍스트 포함된 상위 프레임을 통째 렌더링하면 → 이미지 텍스트 + HTML 텍스트 이중 표시
52
+
53
+ 2. INSTANCE 반복 패턴 (카드/아이템 그리드):
54
+ 같은 부모 아래 동일 구조 INSTANCE 2개 이상 → HTML 반복 구조 (v-for/.map())
55
+ ❌ 카드 그리드를 통째 이미지 1장으로 렌더링 금지
56
+ ✅ 각 카드 내부의 이미지 에셋만 개별 추출 (아이콘, 아이템 이미지)
57
+ ✅ 카드 레이아웃, 텍스트, 버튼은 HTML로 구현
58
+
59
+ 3. 인터랙티브 요소:
60
+ name에 "btn", "button", "CTA", "link", "tab", "toggle" 포함 → HTML <button>/<a>
61
+ ❌ 버튼을 이미지로 렌더링 금지 (클릭 이벤트 불가)
62
+
63
+ 4. 정보 텍스트 영역:
64
+ 기간, 가격, 수량, 설명 등 변경 가능 데이터 → HTML 텍스트
65
+ ❌ "1,000", "500 G-COIN", "보상 교환하기" 등을 이미지에 포함 금지
66
+ ```
67
+
68
+ ## 이미지로 렌더링하는 것 (HTML로 구현 불가)
69
+
70
+ ```
71
+ 다음만 이미지로 렌더링:
72
+
73
+ 1. 커스텀 폰트 텍스트 (벡터 글자 그룹):
74
+ - 웹폰트 없는 장식 타이틀 ("MISSION 01" 등)
75
+ - VECTOR 타입으로 분해된 글자 → GROUP 렌더링
76
+
77
+ 2. 합성 배경 (BG 프레임):
78
+ - 눈, 나무, 파티클 등 장식 레이어 합성물
79
+ - 텍스트 미포함 확인 필수
80
+
81
+ 3. 래스터 이미지 에셋:
82
+ - 게임 아이템 썸네일, 코인 아이콘 등
83
+ - imageRef가 있는 개별 RECTANGLE/노드
84
+
85
+ 4. 복잡한 벡터 그래픽:
86
+ - CSS로 재현 불가능한 일러스트/아이콘
87
+ - VECTOR/GROUP 조합의 복잡한 그래픽
88
+ ```
89
+
42
90
  ## Format
43
91
 
44
- - Output format: `.png` (Figma screenshot API 기본)
45
- - 최적화 필요빌드 단계에서 webp 변환 (추출 단계에서는 png)
92
+ - Output format: `.webp` (Figma API에서 png 수신 → cwebp로 webp 변환)
93
+ - cwebp 미설치`.png` 폴백
46
94
 
47
95
  ## Naming
48
96
 
49
97
  렌더링된 이미지의 파일명 = Figma 노드 name을 kebab-case로:
50
- - `"Hero"` BG frame → `hero-bg.png`
51
- - `"Mission 01"` vector group → `mission-01.png`
52
- - `"Title"` content → `hero-title.png` (섹션명 prefix)
53
- - `"Btn_Login"` → `btn-login.png`
98
+ - `"Hero"` BG frame → `hero-bg.webp`
99
+ - `"Mission 01"` vector group → `mission-01.webp`
100
+ - `"Title"` content → `hero-title.webp` (섹션명 prefix)
101
+ - `"Btn_Login"` → `btn-login.webp`
54
102
 
55
103
  Rules:
56
104
  - 공백 → 하이픈