#!/usr/bin/env python3 """preview_ui.py — 코드 UI의 **render-health 게이트**를 강제한다. 디자인 시스템 파이프라인의 미리보기 훅(render_consult.py의 형제). Figma 불필요·rate-limit 없음. 핵심 원칙 (이 훅의 존재 이유): **스크린샷이 존재한다 ≠ 품질이다.** build rc==0 + PNG 파일 존재만으로 "성공" 처리하면, 앱이 런타임에 크래시해서 #root가 비어 있는 **빈 화면**을 렌더해도, 또는 vite가 빈 번들을 뱉어도 통과한다. 그래서 이 훅은 "build 성공 + PNG 존재"를 통과로 취급하지 **않고** 아래 게이트를 강제한다. 게이트가 하나라도(strict에서) 실패하면 "OK"를 출력하지 않고 비영점 종료한다: 1. build degraded 검출 — rc!=0은 물론, rc==0이어도 JS 번들이 없으면 위장 실패로 간주. 2. 렌더 비어있음 검출 — chrome --dump-dom으로 실제 DOM을 받아 mount 노드(#root/#app)가 비어 있으면(=React가 안 붙음) degraded. PNG는 멀쩡히 저장돼도 이건 실패다. 3. 정적 CSS health 체크 — WCAG AA 대비(contrast) + 키보드 포커스 가시성. 4. 반응형 다중 viewport 캡처(--viewports) — 모바일/태블릿/데스크톱 폭. 5. 상태(state) 라우트 캡처(--states) — loading/empty/error/overflow, 구동 가능할 때만. 이 도구는 시각적 차별성·타이포그래피·비례·spacing·imagery의 전문성을 판정하지 않는다. 해당 판단은 comparative-divergence-audit와 visual-craft review가 담당한다. 브라우저(chrome/npm/vite)가 없으면 **가짜 성공을 만들지 않는다**: 비영점 종료하거나, 검증 불가한 항목은 'unverified'로 정직하게 표시한다(통과로 위장 금지). 동작: (필요시) install → run build(또는 manifest/--build-cmd) → out-dir(자동 감지)를 임시 포트로 http.server → headless chrome로 dump-dom(렌더 검증) + screenshot(들) → 서버 종료. 패키지 매니저는 lockfile 로 감지(pnpm/yarn/bun/npm — npm 오염 방지). --pm/--build-cmd/--out-dir 로 오버라이드. sleep 금지 제약: 서버 준비는 urllib 폴링으로 대기한다. Usage: # 기본(빌드→렌더검증→스크린샷) python3 preview_ui.py [--out PNG] [--width 800] [--height 1400] [--path /] [--no-build] [--budget 8000] # 반응형 + 정적 CSS 품질 게이트 + 상태 라우트 python3 preview_ui.py --viewports 360,768,1280 --check-css \ --states "loading=/#/loading,empty=/#/empty,error=/#/error" # degraded 허용(게이트를 경고로만) — 기본은 strict(실패=비영점) python3 preview_ui.py --allow-degraded # 브라우저 없이 정적 CSS 대비/포커스만 검사(CI·테스트용) python3 preview_ui.py --contrast-only """ import argparse import glob import os import re import shutil import socket import subprocess import sys import time import urllib.request CHROME_CANDIDATES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"] # WCAG 대비 임계 — 일반 텍스트 AA=4.5. 3.0 미만은 사실상 접근 불가 → critical(strict에서 실패). CONTRAST_FAIL = 3.0 # 이 아래는 critical CONTRAST_WARN = 4.5 # 이 아래는 warning(AA 미달) # 포그라운드/백그라운드 토큰 네이밍 힌트 — CSS 변수명에서 대비 쌍을 유추한다. _FG_HINTS = ("ink", "text", "fg", "muted", "body", "heading", "title", "label", "content") _BG_HINTS = ("surface", "canvas", "bg", "background", "paper", "base", "panel") # ────────────────────────────────────────────────────────────── 인프라 헬퍼 def die(msg, code=1): """실패는 항상 stderr + 비영점. 절대 'OK'를 남기지 않는다(성공 위장 금지).""" print(f"[preview_ui] FAIL: {msg}", file=sys.stderr) sys.exit(code) def find_chrome(): for c in CHROME_CANDIDATES: p = shutil.which(c) if p: return p if os.path.exists("/usr/bin/google-chrome"): return "/usr/bin/google-chrome" return None def free_port(): s = socket.socket() s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] s.close() return port def wait_http(url, tries=100, interval=0.15): """서버 준비 대기 — Python time.sleep로 폴 간격을 준다(Bash 툴의 sleep 제약과 무관).""" for _ in range(tries): try: with urllib.request.urlopen(url, timeout=1) as r: if r.status == 200: return True except Exception: time.sleep(interval) return False def run(cmd, cwd, timeout): return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout) # ────────────────────────────────────────────────────────────── 정적 CSS 품질 체크 def _to_rgb(val): """CSS 색 리터럴(#hex / rgb()/rgba())을 (r,g,b)로. alpha<1이면 흰 배경 위 합성. 실패시 None.""" val = val.strip() m = re.match(r"#([0-9a-fA-F]{3,8})$", val) if m: h = m.group(1) if len(h) in (3, 4): h = "".join(c * 2 for c in h) if len(h) >= 6: return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) return None m = re.match(r"rgba?\(([^)]+)\)", val, re.I) if m: parts = [p.strip() for p in re.split(r"[,\s/]+", m.group(1)) if p.strip()] try: def chan(p): if p.endswith("%"): return round(float(p[:-1]) * 2.55) return round(float(p)) r, g, b = chan(parts[0]), chan(parts[1]), chan(parts[2]) a = 1.0 if len(parts) >= 4: ap = parts[3] a = float(ap[:-1]) / 100 if ap.endswith("%") else float(ap) if a < 1.0: # 반투명은 흰 배경 위로 합성해 실효 색을 본다 r = round(r * a + 255 * (1 - a)) g = round(g * a + 255 * (1 - a)) b = round(b * a + 255 * (1 - a)) return (max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b))) except Exception: return None return None def _parse_css_colors(css): """--var: ; 형태의 CSS 변수 중 색 리터럴만 {name: (r,g,b)}로.""" colors = {} for m in re.finditer(r"--([\w-]+)\s*:\s*([^;{}]+);", css): rgb = _to_rgb(m.group(2).strip()) if rgb: colors[m.group(1).strip()] = rgb return colors def _lum(rgb): def f(c): c /= 255.0 return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 r, g, b = rgb return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b) def contrast_ratio(fg, bg): """WCAG 상대명도 대비비(1..21).""" l1, l2 = _lum(fg), _lum(bg) hi, lo = max(l1, l2), min(l1, l2) return (hi + 0.05) / (lo + 0.05) def check_contrast(css): """토큰 색쌍의 WCAG 대비를 검사. returns list of dict(pair, ratio, level). level: 'critical'(<3.0) / 'warn'(<4.5) / 'ok'. 쌍 유추: - on-X ↔ X (예: on-accent ↔ accent) - fg힌트 토큰 ↔ bg힌트 토큰 (예: ink ↔ surface)""" colors = _parse_css_colors(css) findings = [] seen = set() def add(fg, bg): key = (fg, bg) if key in seen or fg == bg: return seen.add(key) r = contrast_ratio(colors[fg], colors[bg]) level = "critical" if r < CONTRAST_FAIL else ("warn" if r < CONTRAST_WARN else "ok") findings.append({"fg": fg, "bg": bg, "ratio": round(r, 2), "level": level}) for name in colors: if name.startswith("on-") and name[3:] in colors: add(name, name[3:]) fgs = [n for n in colors if any(h in n for h in _FG_HINTS) and not n.startswith("on-")] bgs = [n for n in colors if any(h in n for h in _BG_HINTS)] for fg in fgs: for bg in bgs: add(fg, bg) return findings def check_focus(css): """키보드 포커스 가시성 정적 체크. outline을 죽였는데(:focus outline:none/0) 대체 포커스 표식(box-shadow/visible outline/border 변경)이 없으면 접근성 결함. returns (ok: bool, detail: dict). 주의: 공백 백트래킹으로 'outline: none'이 'visible outline'으로 오탐되지 않게 lookahead 안에 \\s*를 넣는다.""" kills = len(re.findall(r"outline\s*:\s*(?:none|0)\b", css, re.I)) has_focus_style = False for m in re.finditer(r":focus(?:-visible)?[^{}]*\{([^}]*)\}", css, re.I | re.S): block = m.group(1) if re.search(r"box-shadow\s*:\s*(?!\s*none\b)[^;]+", block, re.I): has_focus_style = True break if re.search(r"outline\s*:\s*(?!\s*(?:none|0)\b)[^;]+", block, re.I): has_focus_style = True break if re.search(r"border(?:-color)?\s*:", block, re.I): has_focus_style = True break ok = has_focus_style or kills == 0 return ok, {"focus_style_present": has_focus_style, "outline_suppressions": kills} def gather_css(path): """path가 파일이면 그 파일, 디렉터리면 그 아래 모든 .css(node_modules 제외)를 이어붙인다.""" if os.path.isfile(path): return open(path, encoding="utf-8", errors="replace").read() chunks = [] for f in sorted(glob.glob(os.path.join(path, "**", "*.css"), recursive=True)): if "node_modules" in f or os.sep + "dist" + os.sep in f: continue chunks.append("/* %s */\n%s" % (f, open(f, encoding="utf-8", errors="replace").read())) return "\n".join(chunks) def report_css_quality(css, strict): """정적 CSS health 리포트. AA 미달 대비나 포커스 결함은 strict에서 실패.""" failed = False con = check_contrast(css) if not con: print(" [css] contrast: 검사할 색쌍을 못 찾음(토큰 CSS 변수 없음) — unverified") for f in con: mark = {"critical": "✗", "warn": "!", "ok": "✓"}[f["level"]] print(f" [css] contrast {mark} {f['fg']} on {f['bg']} = {f['ratio']}:1 ({f['level']})") if f["level"] in ("critical", "warn"): failed = True fok, fdetail = check_focus(css) print(f" [css] focus-visible: {'✓' if fok else '✗'} " f"(focus-style={fdetail['focus_style_present']}, outline-kills={fdetail['outline_suppressions']})") if not fok: failed = True return failed and strict # ────────────────────────────────────────────────────────────── 렌더 검증(빈 화면 탐지) def dom_is_empty(chrome, url, budget, timeout=90): """chrome --dump-dom으로 실제 DOM을 받아 mount 노드(#root/#app)가 비었는지 판정. React가 런타임 에러로 안 붙으면
만 남는다 → 이 경우 True(빈 화면). returns (empty: bool, tag_count: int, ok: bool). ok=False면 dump-dom 자체 실패(검증 불가).""" try: r = subprocess.run( [chrome, "--headless=new", "--disable-gpu", "--no-sandbox", f"--virtual-time-budget={budget}", "--dump-dom", url], capture_output=True, text=True, timeout=timeout) except Exception: return (False, 0, False) html = r.stdout or "" if not html.strip(): return (False, 0, False) empty_mount = bool(re.search(r'id=["\']?(?:root|app)["\']?[^>]*>\s*', html, re.I)) body = re.search(r"]*>(.*)", html, re.I | re.S) tag_count = len(re.findall(r"<[a-zA-Z]", body.group(1))) if body else len(re.findall(r"<[a-zA-Z]", html)) return (empty_mount, tag_count, True) def screenshot(chrome, url, out, width, height, budget, timeout=120): """단일 스크린샷. PNG가 저장되고 유효(PNG 시그니처)해야 성공. returns (ok, rc, stderr).""" cmd = [chrome, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", f"--window-size={width},{height}", f"--virtual-time-budget={budget}", f"--screenshot={out}", url] r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) if not os.path.exists(out) or os.path.getsize(out) == 0: return (False, r.returncode, r.stderr) with open(out, "rb") as fh: sig = fh.read(8) if sig != b"\x89PNG\r\n\x1a\n": # PNG 시그니처 아니면 chrome이 에러 파일을 남긴 것 return (False, r.returncode, "출력이 유효한 PNG가 아님") return (True, r.returncode, r.stderr) # ────────────────────────────────────────────────────────────── 빌드(스택 중립, R4) # 재리뷰 지적: 예전엔 npm+vite+dist 고정이라 pnpm/yarn·Next/Vue·다른 out-dir 에서 stack-neutral # 이 아니었고, pnpm 프로젝트에 `npm install` 로 lockfile 을 오염시킬 수 있었다. 이제 lockfile 로 # 패키지매니저를 감지하고, company-context projects[] manifest 나 CLI 로 build/out-dir 를 구동한다. _PM_INSTALL = { "pnpm": ["pnpm", "install", "--silent"], "yarn": ["yarn", "install", "--silent"], "bun": ["bun", "install"], "npm": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"], } _PM_BUILD = {"pnpm": ["pnpm", "run", "build"], "yarn": ["yarn", "build"], "bun": ["bun", "run", "build"], "npm": ["npm", "run", "build"]} def detect_pm(proj): """lockfile 로 패키지 매니저 감지(오염 방지). 없으면 npm.""" for lf, pm in (("pnpm-lock.yaml", "pnpm"), ("yarn.lock", "yarn"), ("bun.lockb", "bun")): if os.path.exists(os.path.join(proj, lf)): return pm return "npm" def detect_out_dir(proj): """빌드 산출 디렉터리 추정. Next(정적 export)=out, Nuxt=.output/public, 그 외 vite=dist.""" for cfg in ("next.config.js", "next.config.mjs", "next.config.ts"): if os.path.exists(os.path.join(proj, cfg)): return "out" if any(os.path.exists(os.path.join(proj, c)) for c in ("nuxt.config.ts", "nuxt.config.js")): return ".output/public" return "dist" def manifest_hints(proj): """company-context.yaml projects[] 에서 이 프로젝트(id==basename)의 build/out-dir 힌트.""" try: import yaml as _y root = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) doc = _y.safe_load(open(os.path.join(root, "org-os", "01-company", "company-context.yaml"), encoding="utf-8")) or {} pid = os.path.basename(proj.rstrip("/")) for pr in (doc.get("projects") or []): if isinstance(pr, dict) and pr.get("id") == pid: return {"build": (pr.get("build") or "").strip(), "out-dir": (pr.get("preview-out-dir") or "").strip()} except Exception: pass return {} def install_deps(proj, pm): if os.path.isdir(os.path.join(proj, "node_modules")): return cmd = _PM_INSTALL.get(pm, _PM_INSTALL["npm"]) if not shutil.which(cmd[0]): die(f"패키지 매니저 '{cmd[0]}' 미가용(lockfile 로 감지) — 설치하거나 --pm 로 지정하세요") r = run(cmd, proj, 600) if r.returncode != 0: die(f"{pm} install 실패:\n{r.stderr[-800:]}") def build_project(proj, pm, build_cmd, out_dir, strict): """빌드. rc!=0은 실패. rc==0이어도 index.html/JS 번들이 없으면 degraded(위장 실패).""" import shlex cmd = shlex.split(build_cmd) if build_cmd else _PM_BUILD.get(pm, _PM_BUILD["npm"]) if not shutil.which(cmd[0]): die(f"빌드 명령 '{cmd[0]}' 미가용 — --build-cmd 로 지정하세요") r = run(cmd, proj, 300) if r.returncode != 0: die(f"빌드 실패({' '.join(cmd)}, rc={r.returncode}):\n{(r.stderr or r.stdout)[-800:]}") od = os.path.join(proj, out_dir) if not os.path.exists(os.path.join(od, "index.html")): die(f"{out_dir}/index.html 없음 — 빌드 out-dir 를 확인하세요(--out-dir 로 지정 가능): {od}") js = glob.glob(os.path.join(od, "**", "*.js"), recursive=True) if not js: msg = f"빌드 rc==0이지만 JS 번들이 없음 — degraded(빈 앱): {out_dir}" if strict: die(msg) print(f" [build] WARN degraded: {msg}") warns = len(re.findall(r"warning", (r.stdout + r.stderr), re.I)) if warns: print(f" [build] {warns} warning(s) (참고, 실패 아님)") # ────────────────────────────────────────────────────────────── 메인 def parse_states(spec): """'loading=/#/loading,empty=/#/empty' → [(label, path), ...]""" out = [] for item in (spec or "").split(","): item = item.strip() if not item: continue if "=" in item: label, path = item.split("=", 1) else: label, path = item, item out.append((label.strip(), path.strip())) return out def main(): ap = argparse.ArgumentParser( prog="preview_ui.py", description="preview_ui — 코드 UI 렌더 + 최소 품질 게이트(스크린샷 존재≠품질).") ap.add_argument("project_dir", nargs="?", default=None, help="디자인 시스템/프론트 패키지 디렉터리(package.json 있는 곳)") ap.add_argument("--out", default=None, help="주 스크린샷 PNG 경로(기본 /preview.png)") ap.add_argument("--width", type=int, default=800) ap.add_argument("--height", type=int, default=1400) ap.add_argument("--path", default="/", help="스크린샷할 라우트(예: / 또는 /#/screen)") ap.add_argument("--no-build", action="store_true", help="dist가 이미 있으면 빌드 생략") ap.add_argument("--budget", type=int, default=8000, help="chrome virtual-time-budget(ms)") # ── 신규: 실질 품질 게이트 ────────────────────────────── ap.add_argument("--viewports", default=None, help="반응형 폭 목록(콤마). 예: 360,768,1280 — 폭마다 스크린샷") ap.add_argument("--states", default=None, help="상태 라우트(콤마). 예: loading=/#/loading,empty=/#/empty,error=/#/error") ap.add_argument("--check-css", action="store_true", help="정적 CSS 품질 체크(WCAG 대비 + 포커스 가시성)") ap.add_argument("--contrast-only", default=None, help="브라우저 없이 이 CSS 파일/디렉터리의 대비·포커스만 검사하고 종료(CI/테스트용)") ap.add_argument("--allow-degraded", action="store_true", help="게이트 실패를 경고로만(기본은 strict: 실패=비영점 종료)") # ── R4: 스택 중립 구동(패키지매니저/빌드/out-dir) ────────── ap.add_argument("--pm", default=None, choices=["npm", "pnpm", "yarn", "bun"], help="패키지 매니저(기본: lockfile 로 자동 감지)") ap.add_argument("--build-cmd", default=None, help="빌드 명령(기본: run build 또는 manifest.build). 예: 'pnpm run build'") ap.add_argument("--out-dir", default=None, help="빌드 산출 디렉터리(기본: 자동 감지 — vite=dist, next=out)") args = ap.parse_args() strict = not args.allow_degraded # ── 모드 A: 브라우저 없는 정적 검사(대비/포커스) — fail-loud, 빌드/크롬 불필요 ── if args.contrast_only: if not os.path.exists(args.contrast_only): die(f"--contrast-only 경로 없음: {args.contrast_only}") css = gather_css(args.contrast_only) if not css.strip(): die(f"--contrast-only: CSS를 못 찾음: {args.contrast_only}") print(f"== 정적 CSS 품질(브라우저 없음): {args.contrast_only} ==") failed = report_css_quality(css, strict=True) if failed: die("정적 CSS render-health 실패(WCAG AA 미달 대비 또는 포커스 결함)") print("OK preview_ui: 정적 CSS render-health 통과") return if not args.project_dir: die("project_dir가 필요합니다(또는 --contrast-only ). --help 참고", code=2) proj = os.path.abspath(args.project_dir) if not os.path.exists(proj): die(f"디렉터리 없음: {proj}", code=1) if not os.path.exists(os.path.join(proj, "package.json")): die(f"package.json 없음: {proj}") out = os.path.abspath(args.out or os.path.join(proj, "preview.png")) gate_failed = False # strict에서 하나라도 True면 최종 비영점 # 0) (선택) 정적 CSS 품질 — 브라우저 전에 값싸게 먼저 if args.check_css: css = gather_css(proj) if css.strip(): print("== 정적 CSS 품질(대비·포커스) ==") if report_css_quality(css, strict): gate_failed = True else: print(" [css] CSS 없음 — unverified") # 1) install + build — 스택 중립(lockfile 감지 + manifest/CLI 구동, R4). 실패시 die. hints = manifest_hints(proj) pm = args.pm or detect_pm(proj) out_dir = args.out_dir or hints.get("out-dir") or detect_out_dir(proj) build_cmd = args.build_cmd or hints.get("build") or None print(f" [build] pm={pm} · out-dir={out_dir} · build={build_cmd or (pm + ' run build')}") # ``--no-build`` is the offline/previously-built path. Installing dependencies # here made the flag unusable in restricted CI even when a valid dist already # existed, and turned a render-only check into an unexpected network mutation. if not args.no_build: install_deps(proj, pm) build_project(proj, pm, build_cmd, out_dir, strict) else: print(" [build] --no-build: dependency install/build skipped; existing output will be rendered") dist = os.path.join(proj, out_dir) if not os.path.exists(os.path.join(dist, "index.html")): die(f"{out_dir}/index.html 없음(빌드 out-dir 확인, --out-dir 로 지정 가능): {dist}") # 2) chrome — 없으면 정직하게 실패(스크린샷 없이 통과 금지) chrome = find_chrome() if not chrome: die(f"chrome 미가용 — 렌더/스크린샷 검증 불가. {out_dir}/index.html을 직접 확인하세요") # 3) serve(out-dir) + 렌더검증(dump-dom) + 스크린샷(들) port = free_port() srv = subprocess.Popen( [sys.executable, "-m", "http.server", str(port), "--directory", dist], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) shots = [] try: base = f"http://127.0.0.1:{port}/" if not wait_http(base): die("로컬 서버 준비 실패") path = args.path if args.path.startswith("/") else "/" + args.path main_url = base.rstrip("/") + path # 3a) 렌더 검증 — PNG가 나와도 mount가 비면 실패(스크린샷 존재≠품질의 핵심) empty, tags, ok = dom_is_empty(chrome, main_url, args.budget) if not ok: # R4: strict 에서 dump-dom 실패는 '통과'가 아니다 — 렌더 검증 불가는 게이트 실패. print(" [render] ✗ dump-dom 실패 — 렌더 검증 불가(strict 에서 게이트 실패)") if strict: gate_failed = True elif empty: print(f" [render] ✗ mount 노드가 비어 있음(#root/#app empty, body tags={tags}) — 빈 화면") if strict: gate_failed = True else: print(f" [render] ✓ 실제 콘텐츠 렌더됨(body tags={tags})") # 3b) 반응형 다중 viewport (없으면 단일 --width) widths = [int(w) for w in args.viewports.split(",") if w.strip()] if args.viewports else [args.width] for i, w in enumerate(widths): shot = out if (len(widths) == 1) else _suffix(out, f"w{w}") sok, rc, err = screenshot(chrome, main_url, shot, w, args.height, args.budget) if not sok: print(f" [shot] ✗ width={w} 스크린샷 실패 rc={rc}: {(err or '')[-200:]}") if strict: gate_failed = True else: shots.append(shot) print(f" [shot] ✓ width={w} → {shot} ({os.path.getsize(shot)} bytes)") # 3c) 상태 라우트(loading/empty/error/overflow) — 구동 가능할 때만 for label, spath in parse_states(args.states): spath = spath if spath.startswith("/") else "/" + spath surl = base.rstrip("/") + spath sfile = _suffix(out, f"state-{label}") sok, rc, err = screenshot(chrome, surl, sfile, args.width, args.height, args.budget) if not sok: # R4: --states 로 명시한 상태 라우트는 캡처돼야 한다 — strict 에서 실패는 게이트 실패 # (사용자가 그 상태를 요구했는데 미구현/구동불가면 '통과'가 아니다). print(f" [state:{label}] ✗ 캡처 실패 rc={rc} — 라우트 미구현/구동불가(strict 게이트 실패)") if strict: gate_failed = True else: shots.append(sfile) print(f" [state:{label}] ✓ → {sfile}") finally: srv.terminate() try: srv.wait(timeout=5) except Exception: srv.kill() if not shots: die("스크린샷을 하나도 못 만듦 — 렌더 검증 실패") if gate_failed: die(f"품질 게이트 실패(위 ✗ 항목). 스크린샷 {len(shots)}장은 남겼지만 '품질'이 아님. " f"고치고 재실행하거나 --allow-degraded로 강등하세요") print(f"OK preview_ui: {len(shots)} shot(s), primary={out} — render-health 통과(심미 품질 판정 아님)") def _suffix(path, tag): root, ext = os.path.splitext(path) return f"{root}.{tag}{ext or '.png'}" if __name__ == "__main__": main()