#!/usr/bin/env python3 """design/design-tokens.md 의 머신블록( 아래 yaml) → lib/core/theme/tokens.dart 생성기. - SoT 는 design/design-tokens.md 하나다. tokens.dart 를 손으로 고치면 시안(시안/assets/ds.css)과 어긋난다. - 재실행 가능(멱등). 실행: python3 tool/gen_tokens.py (앱 루트 app/ 에서) - 부수 기능: design-tokens.md 의 contrast_checks 쌍을 WCAG 로 재계산해서 표로 찍고, AA(4.5:1) 미달이 하나라도 있으면 exit 1. 테마가 대비를 깎았는지 여기서 걸린다. 의도적으로 생성하지 않는 토큰(= CSS 전용 표현이라 Flutter 등가물이 없다): cosmic_fx.glow-* box-shadow 문자열 (Flutter 는 BoxShadow 로 화면에서 조립) cosmic_fx.grad-* linear-gradient 문자열 cosmic_fx.nebula 다중 radial-gradient cosmic_fx.starfield radial-gradient 45개 elevation.* box-shadow 문자열 (Material elevation 과 모델이 다름) 필요해지는 주차에 이 목록에서 하나씩 내려서 추가하면 된다. """ import re import sys from pathlib import Path import yaml ROOT = Path(__file__).resolve().parent.parent # app/ TOKENS_MD = ROOT.parent / "design" / "design-tokens.md" OUT = ROOT / "lib" / "core" / "theme" / "tokens.dart" SKIP_FX = re.compile(r"^(glow-|grad-|nebula$|starfield$|foil)") def load_tokens() -> dict: text = TOKENS_MD.read_text(encoding="utf-8") marker = text.index("") block = re.search(r"```yaml\n(.*?)\n```", text[marker:], re.S) if not block: sys.exit(f"머신블록을 찾지 못했다: {TOKENS_MD}") return yaml.safe_load(block.group(1)) def camel(name: str, strip: str = "") -> str: if strip and name.startswith(strip): name = name[len(strip):] head, *rest = name.split("-") ident = head + "".join(p[:1].upper() + p[1:] for p in rest) return f"s{ident}" if ident[:1].isdigit() else ident # space-4 → s4 (Dart 식별자는 숫자로 시작 못 한다) def hex_to_rgb(v: str) -> tuple[int, int, int]: v = v.lstrip("#") return int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16) def dart_color(v: str) -> str: """#rrggbb 또는 rgba(r,g,b,a) → Color(0xAARRGGBB).""" if v.startswith("#"): r, g, b = hex_to_rgb(v) a = 255 else: m = re.match(r"rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]*)\s*)?\)", v) if not m: raise ValueError(f"색으로 못 읽는 값: {v}") r, g, b = (int(float(m.group(i))) for i in (1, 2, 3)) a = round(float(m.group(4) or 1) * 255) return f"Color(0x{a:02X}{r:02X}{g:02X}{b:02X})" def luminance(v: str) -> float: def ch(c: int) -> float: s = c / 255 return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4 r, g, b = hex_to_rgb(v) return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b) def contrast(a: str, b: str) -> float: la, lb = luminance(a), luminance(b) hi, lo = max(la, lb), min(la, lb) return (hi + 0.05) / (lo + 0.05) def num(v) -> str: return f"{float(v)}" def emit_class(name: str, doc: str, lines: list[str]) -> str: body = "\n".join(f" {ln}" for ln in lines) return f"/// {doc}\nabstract final class {name} {{\n{body}\n}}\n" def build(tok: dict) -> str: colors = tok["colors"] out = [ "// GENERATED FILE — 손으로 고치지 마라.", "// 원본: design/design-tokens.md ( 머신블록)", "// 재생성: cd app && python3 tool/gen_tokens.py", "", "import 'dart:ui' show Color;", "", ] # --- colors --- lines = [] for k, v in colors.items(): lines.append(f"static const {camel(k, 'color-')} = {dart_color(v)}; // {k} {v}") out.append(emit_class("AppColors", "컬러 토큰. app 은 cosmic* 계열이 기본 서피스다(결정 D-19).", lines)) # --- brand (소셜 버튼 전용) --- lines = [ f"static const {camel(k, 'brand-')} = {dart_color(v)}; // {k} {v}" for k, v in tok["brand"].items() ] out.append(emit_class("BrandColors", "소셜 제공자 브랜드 색. 각 사 가이드라인 고정값이라 테마와 무관하게 그대로 쓴다.", lines)) # --- cosmic_fx 중 Flutter 로 옮길 수 있는 것만 --- lines, skipped = [], [] for k, v in tok["cosmic_fx"].items(): if SKIP_FX.match(k): skipped.append(k) continue if isinstance(v, str) and v.endswith("px"): lines.append(f"static const {camel(k)} = {num(v[:-2])}; // {k} {v}") else: lines.append(f"static const {camel(k)} = {dart_color(v)}; // {k} {v}") out.append(emit_class( "AppFx", "글래스 FX. CSS 전용 표현(glow-*/grad-*/nebula/starfield)은 생성하지 않는다 — gen_tokens.py 헤더 참고.", lines, )) # --- space / radius / sizing / z --- out.append(emit_class( "AppSpace", "여백 스케일. 임의값 금지 — 여기 있는 값에서만 고른다.", [f"static const {camel(k, 'space-')} = {num(v)}; // {k}" for k, v in tok["space"].items()], )) out.append(emit_class( "AppRadius", "코너 반경.", [f"static const {camel(k, 'corner-')} = {num(v)}; // {k}" for k, v in tok["radius"].items()], )) out.append(emit_class( "AppSizing", "터치 타깃·본문 최소 크기. app 터치 타깃 48 미만으로 내리지 마라.", [f"static const {camel(k)} = {num(v)}; // {k}" for k, v in tok["sizing"].items()], )) out.append(emit_class( "AppZ", "오버레이 순서.", [f"static const {camel(k, 'z-')} = {int(v)}; // {k}" for k, v in tok["z"].items()], )) # --- motion --- lines = [] for k, v in tok["motion"].items(): if isinstance(v, str) and v.endswith("ms"): lines.append(f"static const {camel(k, 'motion-')} = Duration(milliseconds: {int(v[:-2])}); // {k}") else: lines.append(f"// {k}: {v} (cubic-bezier — Flutter 는 Cubic() 으로 화면에서 조립)") out.append(emit_class("AppMotion", "모션 지속시간. 이징 곡선은 Cubic 리터럴로 아래 주석 값을 쓴다.", lines)) # --- font families --- font = tok["font"] lines = [] for k in ("font-display", "font-body", "font-mono"): first = font[k].split(",")[0].strip().strip("'\"") lines.append(f"static const {camel(k, 'font-')} = '{first}'; // {k} = {font[k]}") lines.append(f"// font-source: {font['font-source']}") out.append(emit_class( "AppFonts", "폰트 패밀리명. 폰트 파일은 리포에 없다 — 미투입 시 시스템 폰트로 폴백된다(README 「미투입 항목」).", lines, )) # --- type scale --- lines = [] for k, v in tok["type"].items(): if "size" not in v: lines.append(f"// {k}: {v} (크기 없는 지시자 — TextStyle 로 못 옮긴다)") continue fam = camel(v["family"], "font-") parts = [f"family: AppFonts.{fam}", f"size: {num(v['size'])}", f"weight: {int(v['weight'])}"] if "leading" in v: parts.append(f"height: {num(v['leading'])}") if "tracking" in v: em = float(str(v["tracking"]).replace("em", "")) parts.append(f"letterSpacing: {round(em * float(v['size']), 3):g}") # em → 논리픽셀 lines.append(f"static const {camel(k, 'type-')} = TypeToken({', '.join(parts)}); // {k}") out.append(emit_class("AppType", "타입 스케일. 값은 design-tokens.md 의 type 블록 그대로다.", lines)) out.append( "/// 타입 토큰 1개. app_theme.dart 가 TextStyle 로 변환한다.\n" "class TypeToken {\n" " const TypeToken({\n" " required this.family,\n" " required this.size,\n" " required this.weight,\n" " this.height,\n" " this.letterSpacing,\n" " });\n\n" " final String family;\n" " final double size;\n" " final int weight;\n" " final double? height;\n" " final double? letterSpacing;\n" "}\n" ) if skipped: out.append(f"// 생성 제외된 cosmic_fx: {', '.join(skipped)}") return "\n".join(out) + "\n" def check_contrast(tok: dict) -> bool: colors = tok["colors"] print("\n대비 재계산 (design-tokens.md contrast_checks · WCAG 2.1)") print(f"{'전경':32} {'배경':32} {'비율':>8} 판정") ok = True for fg, bg in tok["contrast_checks"]: ratio = contrast(colors[fg], colors[bg]) verdict = "AA" if ratio >= 4.5 else ("AA-large" if ratio >= 3.0 else "FAIL") if ratio < 4.5: ok = False print(f"{fg:32} {bg:32} {ratio:7.2f}:1 {verdict}") return ok def main() -> int: tok = load_tokens() OUT.parent.mkdir(parents=True, exist_ok=True) OUT.write_text(build(tok), encoding="utf-8") print(f"생성: {OUT.relative_to(ROOT)} ({len(OUT.read_text(encoding='utf-8').splitlines())}줄)") if not check_contrast(tok): print("\n대비 미달 쌍이 있다. design-tokens.md 를 고쳐라 — tokens.dart 를 고치지 마라.") return 1 print("\n전 쌍 AA 통과.") return 0 if __name__ == "__main__": raise SystemExit(main())