"""Figma Design Tokens(JSON) → @fox/styles SCSS 토큰 변환기. 입력: $FOX_TOKENS_SRC/*.tokens.json (기본 ~/Downloads/mode · 7개, Figma 컬렉션 5개/모드 7개) 출력: @fox/styles/tokens/*.scss + _root.scss + _functions.scss + _mixins.scss 실행: python3 @fox/tools/build-tokens.py 규칙 - 길이 값은 1rem = 10px 기준으로 value/10 → rem. font.weight만 무단위. - alpha < 1 색상은 rgb(R G B / A)로 출력(hex는 알파를 담지 못한다). - Figma의 alias(참조)는 CSS에서도 var() 참조로 보존한다 — 원본 계층이 유지되어 primitive를 바꾸면 semantic까지 전파된다. 단, 참조 대상이 제공된 파일에 없거나 값이 일치하지 않으면 리터럴로 떨어뜨린다(무결성 우선). """ import json import os from collections import OrderedDict SRC = os.environ.get("FOX_TOKENS_SRC", os.path.expanduser("~/Downloads/mode")) OUT = os.environ.get("FOX_OUT", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "styles")) FILES = { "primitive": "primitive.tokens.json", "theme": "theme.tokens.json", "size": "size.tokens.json", "light": "light.tokens.json", "dark": "dark.tokens.json", "pc": "pc.tokens.json", "mobile": "mobile.tokens.json", } def leaves(node, path=""): if isinstance(node, dict): if "$type" in node: yield path, node else: for k, v in node.items(): if k == "$extensions": continue yield from leaves(v, f"{path}.{k}" if path else k) raw = {} for key, fname in FILES.items(): doc = json.load(open(os.path.join(SRC, fname), encoding="utf-8")) raw[key] = OrderedDict(leaves(doc)) def resolve_refs(table): """같은 파일 안의 {a.b.c} 참조를 실제 값으로 치환한다.""" for path, node in table.items(): val = node["$value"] seen = set() while isinstance(val, str) and val.startswith("{") and val.endswith("}"): target = val[1:-1] if target in seen or target not in table: raise SystemExit(f"참조 해결 실패: {path} -> {val}") seen.add(target) val = table[target]["$value"] node["$value"] = val for table in raw.values(): resolve_refs(table) def css_color(value): comps = value["components"] alpha = value.get("alpha", 1) if alpha >= 1: return value["hex"].upper() r, g, b = (round(c * 255) for c in comps) a = round(alpha, 4) return f"rgb({r} {g} {b} / {a})" def css_len(value): """px 수치를 rem으로. 1rem = 10px.""" if value == 0: return "0" rem = value / 10 text = f"{rem:.10f}".rstrip("0").rstrip(".") return f"{text}rem" # ── 변수명 매핑 ─────────────────────────────────────────────────────────────── def name_primitive(path): p = path.split(".") if p[0] == "color": return f"primitive-{p[1]}-{p[2]}" if p[0] == "font": return f"font-{p[1]}-{p[2]}" if p[0] == "number": return f"number-{p[1]}" raise SystemExit(f"primitive 이름 매핑 없음: {path}") def name_theme(path): p = path.split(".") assert p[0] == "color" return f"theme-{p[1]}-{p[2]}" def name_size(path): p = path.split(".") if p[0] == "effect": return f"{p[1]}-{p[2]}" # effect.shadow.blur3 → shadow-blur3 return "-".join(p) # padding.6 → padding-6 CHART_ALT_GROUP = {} for k in [str(i) for i in range(1, 11)]: CHART_ALT_GROUP[k] = f"chart-alt-level-{k}" for k in ("bar", "surface", "line", "disabled"): CHART_ALT_GROUP[k] = f"chart-alt-default-{k}" for i in range(1, 6): for s in ("bar", "line", "surface"): CHART_ALT_GROUP[f"{i}-{s}"] = f"chart-alt-type-{i}-{s}" def name_semantic(path): if path in CHART_ALT_GROUP: return f"color-{CHART_ALT_GROUP[path]}" p = path.split(".") assert p[0] == "color", path return "color-" + "-".join(p[1:]) def name_responsive(path): p = path.split(".") if p[0] == "font" and p[1] == "size": return "font-size-" + "-".join(p[2:]) # font.size.body.md → font-size-body-md return "-".join(p) # form.height.md → form-height-md # ── alias 역참조 테이블 (Figma 변수명 → 우리 CSS 변수명) ────────────────────── alias_lookup = {} # (컬렉션명, "color/neutral/0") → (varname, 원본값) for coll, namer in (("primitive", name_primitive), ("theme", name_theme), ("size", name_size)): for path, node in raw[coll].items(): alias_lookup[(coll, path.replace(".", "/"))] = (namer(path), node["$value"]) def same_value(a, b): if isinstance(a, dict) and isinstance(b, dict): return css_color(a) == css_color(b) return a == b stats = {"var": 0, "literal": 0, "no-alias": 0} def color_expr(node): """alias가 검증되면 var() 참조, 아니면 리터럴 색상.""" ext = node.get("$extensions", {}) alias = ext.get("com.figma.aliasData") if alias: key = (alias["targetVariableSetName"], alias["targetVariableName"]) hit = alias_lookup.get(key) if hit and same_value(hit[1], node["$value"]): stats["var"] += 1 return f"var(--fox-{hit[0]})" stats["literal"] += 1 else: stats["no-alias"] += 1 return css_color(node["$value"]) # ── 맵 생성 ────────────────────────────────────────────────────────────────── def build(entries): return OrderedDict(entries) primitive_map, font_map, number_map = OrderedDict(), OrderedDict(), OrderedDict() for path, node in raw["primitive"].items(): name = name_primitive(path) if name.startswith("primitive-"): primitive_map[name[len("primitive-"):]] = css_color(node["$value"]) elif name.startswith("font-"): v = node["$value"] font_map[name[len("font-"):]] = v if isinstance(v, (str, int)) else css_color(v) else: number_map[name[len("number-"):]] = css_len(node["$value"]) theme_map = OrderedDict( (name_theme(p)[len("theme-"):], css_color(n["$value"])) for p, n in raw["theme"].items() ) size_map = OrderedDict((name_size(p), css_len(n["$value"])) for p, n in raw["size"].items()) # 시맨틱 색상: light/dark 를 light-dark() 한 줄로. # 소속이 불분명해 chart-alt로 묶은 29개는 뒤로 보낸다 — 원본 JSON에서는 이들이 루트에 # 먼저 오지만, 순서에 의미가 없고 카탈로그 첫 화면이 주요 색상이어야 읽기 쉽다. color_map = OrderedDict() ordered_light = sorted(raw["light"].items(), key=lambda kv: kv[0] in CHART_ALT_GROUP) for path, lnode in ordered_light: dnode = raw["dark"][path] key = name_semantic(path)[len("color-"):] color_map[key] = f"light-dark({color_expr(lnode)}, {color_expr(dnode)})" # 반응형: 모바일 기본 + pc 오버라이드 resp_mobile = OrderedDict( (name_responsive(p), css_len(n["$value"])) for p, n in raw["mobile"].items() ) resp_pc = OrderedDict((name_responsive(p), css_len(n["$value"])) for p, n in raw["pc"].items()) assert list(resp_mobile) == list(resp_pc) # ── 개발용 치환 ─────────────────────────────────────────────────────────────── # Figma의 grid 값은 **아트보드 좌표**다. 폭은 아트보드 폭이고 여백은 그 안에서 가운데로 맞추기 # 위한 offset이라(예: PC sm = (1920-1080)/2 = 420px) CSS에서는 뜻이 달라진다. # 재export해도 유지되도록 변환기가 들고 있는다 — 생성 파일을 손으로 고치면 지워진다. # # 폭은 브레이크포인트와 같은 수를 쓴다. 그래야 "이 폭에서 레이아웃이 갈린다"와 "컨테이너가 # 여기까지 넓어진다"가 어긋나지 않는다(사용자 확정 사항). DEV_OVERRIDES_MOBILE = { "grid-wrap-full": "100%", "grid-wrap-default": "36rem", "grid-wrap-sm": "32rem", "grid-wrap-xsm": "28rem", # 페이지 좌우 여백. 컨테이너가 좁아진다고 여백이 넓어지지는 않으므로 한 값을 공유한다. "grid-margin-full": "0", "grid-margin-default": "1.6rem", "grid-margin-sm": "1.6rem", "grid-margin-xsm": "1.6rem", } DEV_OVERRIDES_PC = { "grid-wrap-full": "100%", "grid-wrap-default": "144rem", "grid-wrap-sm": "102.4rem", "grid-wrap-xsm": "76.8rem", "grid-margin-full": "0", "grid-margin-default": "4.8rem", "grid-margin-sm": "4.8rem", "grid-margin-xsm": "4.8rem", } resp_mobile.update(DEV_OVERRIDES_MOBILE) resp_pc.update(DEV_OVERRIDES_PC) # 미디어 쿼리는 `var()`를 해석하지 못해 컴파일타임 값이어야 한다. 위 PC 폭과 같은 수다. BREAKPOINTS = OrderedDict( ( ("xsm", "768px"), ("sm", "1024px"), ("default", "1440px"), ) ) BREAKPOINT_PC = "768px" # ── SCSS 출력 ──────────────────────────────────────────────────────────────── def scss_map(name, mapping, indent=" "): lines = [f"${name}: ("] items = list(mapping.items()) for i, (k, v) in enumerate(items): comma = "," if i < len(items) - 1 else "" key = f'"{k}"' if not k.replace("-", "").replace("_", "").isalnum() else k lines.append(f"{indent}{key}: {v}{comma}") lines.append(");") return "\n".join(lines) HEADER = """// ⚠️ 이 파일은 Figma Design Tokens export에서 **자동 생성**되었습니다. // 원본: {src} // 손으로 고치지 말고 원본 JSON을 다시 export한 뒤 변환기를 재실행하세요. """ def write(rel, body): path = os.path.join(OUT, rel) os.makedirs(os.path.dirname(path), exist_ok=True) open(path, "w", encoding="utf-8").write(body) return rel written = [] written.append(write("tokens/_primitive.scss", HEADER.format(src="primitive.tokens.json") + f""" // 원시 팔레트 — 화면에서 직접 쓰지 않는다. 시맨틱 토큰(`$color`)이 참조하는 바닥층이다. {scss_map("primitive", primitive_map)} // 서체 — family는 문자열, weight는 무단위 수치(길이가 아니므로 rem 변환 대상이 아니다). {scss_map("font", font_map)} // 원시 수치 스케일 — size 컬렉션이 참조하는 값들. 1rem = 10px 기준으로 변환됨. {scss_map("number", number_map)} """)) written.append(write("tokens/_theme.scss", HEADER.format(src="theme.tokens.json") + f""" // 브랜드 램프 — primary / secondary / accent. {scss_map("theme", theme_map)} """)) written.append(write("tokens/_size.scss", HEADER.format(src="size.tokens.json") + f""" // 모드와 무관한 크기 스케일. 값은 1rem = 10px 기준 rem. {scss_map("size", size_map)} """)) written.append(write("tokens/_color.scss", HEADER.format(src="light.tokens.json / dark.tokens.json") + f""" // 시맨틱 색상 — 라이트/다크 값을 `light-dark()` 한 줄에 함께 보유한다. // 전환은 `_root.scss`의 `color-scheme`이 담당하므로 다크 전용 블록이 없고, // 따라서 한쪽만 고쳐서 생기는 테마 불일치가 구조적으로 불가능하다. // // 값이 `var(--fox-primitive-*)` / `var(--fox-theme-*)`인 항목은 Figma의 alias를 // 그대로 옮긴 것이다 — 바닥층을 바꾸면 여기까지 전파된다. {scss_map("color", color_map)} """)) written.append(write("tokens/_responsive.scss", HEADER.format(src="mobile.tokens.json / pc.tokens.json") + f""" // 뷰포트에 따라 달라지는 크기 토큰. 키는 두 모드가 완전히 동일하다. // 모바일 우선 — `$responsive`가 기본값이고 `$responsive-pc`가 브레이크포인트 이상에서 덮는다. $breakpoint-pc: {BREAKPOINT_PC}; // 컨테이너 폭(`grid-wrap-*`)과 같은 수다 — 미디어 쿼리와 폭이 어긋나지 않게 한 곳에서 낸다. {scss_map("breakpoints", BREAKPOINTS)} {scss_map("responsive", resp_mobile)} {scss_map("responsive-pc", resp_pc)} """)) written.append(write("tokens/_index.scss", """// 토큰 map 모음 — **CSS를 한 줄도 출력하지 않는다.** // 값 선언(이 폴더)과 CSS 출력(`../_root.scss`)을 분리해 두었기 때문에, 컴포넌트의 // `.module.scss`가 몇 개든 `abstracts`를 `@use`해도 `:root` 블록이 중복 출력되지 않는다 // (각 CSS Module은 별도 컴파일 단위라 CSS를 내보내는 모듈을 공유하면 파일마다 복제된다). @forward "primitive"; @forward "theme"; @forward "size"; @forward "color"; @forward "responsive"; """)) # _root.scss root_lines = [] root_lines.append("""// 토큰 CSS 출력 — `tokens/`의 SCSS map을 `--fox-*` 커스텀 프로퍼티로 선언한다. // // **앱 전체에서 딱 한 번만 로드되어야 한다** (`index.scss` 경유). // 컴포넌트의 `.module.scss`에서는 절대 `@use` 하지 않는다. // // map을 순회해 생성하므로 토큰을 추가할 때 이 파일은 손대지 않는다. @use "sass:map"; @use "tokens"; :root { // 기본은 OS 설정 추종. 아래 `[data-theme]` 규칙이 이 값만 덮어써 수동 선택을 // 처리하므로, 색상 값 자체는 `tokens/_color.scss` 한 곳에만 존재한다. color-scheme: light dark; """) for var_prefix, map_name in ( ("primitive", "primitive"), ("font", "font"), ("number", "number"), ("theme", "theme"), ): root_lines.append(f""" @each $name, $value in tokens.${map_name} {{ --fox-{var_prefix}-#{{$name}}: #{{$value}}; }} """) root_lines.append(""" // size / color / responsive 는 이름에 그룹이 이미 들어 있어 접두사가 `--fox-` 뿐이다. @each $name, $value in tokens.$size { --fox-#{$name}: #{$value}; } @each $name, $value in tokens.$color { --fox-color-#{$name}: #{$value}; } // 모바일 우선 기본값. @each $name, $value in tokens.$responsive { --fox-#{$name}: #{$value}; } } // PC 모드 — 반응형 토큰만 덮어쓴다. 색상은 뷰포트와 무관하므로 여기 오지 않는다. @media (min-width: tokens.$breakpoint-pc) { :root { @each $name, $value in tokens.$responsive-pc { --fox-#{$name}: #{$value}; } } } // 수동 테마 선택 — `color-scheme`만 고정하면 `light-dark()`가 해당 분기로 확정된다. // 속성이 없거나 `"system"`이면 위 `:root`의 `light dark`가 유지되어 OS를 따른다. :root[data-theme="light"] { color-scheme: light; } :root[data-theme="dark"] { color-scheme: dark; } // ⚠️ 1rem = 10px 규약의 근거. 브라우저 기본 글자 크기(16px)의 62.5%가 10px다. // 모든 토큰의 rem 값이 이 선언을 전제로 계산돼 있다. // 미디어 쿼리 안의 rem은 이 값의 영향을 받지 않는다(항상 브라우저 기본 크기 기준). html { font-size: 62.5%; } """) written.append(write("_root.scss", "\n".join(root_lines))) # _functions.scss GROUPS = [ # (함수명, 대상 map, 키 접두사(없으면 None), CSS 변수 접두사) ("color", "color", None, "color-"), ("primitive", "primitive", None, "primitive-"), ("theme", "theme", None, "theme-"), ("number", "number", None, "number-"), ("font-family", "font", "family", "font-"), ("font-weight", "font", "weight", "font-"), ("border", "size", "border", ""), ("padding", "size", "padding", ""), ("gap", "size", "gap", ""), ("radius", "size", "radius", ""), ("icon", "size", "icon", ""), ("shadow", "size", "shadow", ""), ("backdrop", "size", "backdrop", ""), ("font-size", "responsive", "font-size", ""), ("form", "responsive", "form", ""), ("grid", "responsive", "grid", ""), ("card", "responsive", "card", ""), ("modal", "responsive", "modal", ""), ("spacing", "responsive", "spacing", ""), ("section", "responsive", "section", ""), ] fn_lines = ["""// 토큰 접근 함수 — 디자인 시스템 규율을 **컴파일러가 강제**하는 지점. // // 화면 코드는 `color: #256EF4`나 `padding: 13px`를 쓸 수 없고, 반드시 // `color: fox.color(font-neutral-strong)` / `padding: fox.padding(6)`로 토큰을 // 지목해야 한다. 존재하지 않는 이름을 쓰면 `@error`로 **빌드가 실패**하고 사용 // 가능한 토큰 목록이 함께 출력된다. // // 반환값은 CSS 커스텀 프로퍼티 참조(`var(--fox-*)`)다. 값이 아니라 참조를 돌려주므로 // (1) 라이트/다크와 pc/모바일이 런타임에 전환되고 (2) 특정 영역만 토큰을 덮어쓰는 // 스코프 오버라이드가 가능하며 (3) devtools에서 어떤 토큰인지 그대로 보인다. @use "sass:map"; @use "tokens"; /// 토큰 이름을 검증하고 커스텀 프로퍼티 참조를 반환한다. @function _token($group, $key, $registry, $var-name) { @if not map.has-key($registry, $key) { @error "[@fox] 알 수 없는 #{$group} 토큰: `#{$key}` — 사용 가능한 값: #{map.keys($registry)}"; } @return var(--fox-#{$var-name}); } """] for fn, mapname, prefix, varprefix in GROUPS: if prefix is None: fn_lines.append(f""" @function {fn}($name) {{ @return _token("{fn}", $name, tokens.${mapname}, "{varprefix}#{{$name}}"); }}""") else: fn_lines.append(f""" @function {fn}($name) {{ @return _token("{fn}", "{prefix}-#{{$name}}", tokens.${mapname}, "{varprefix}{prefix}-#{{$name}}"); }}""") written.append(write("_functions.scss", "\n".join(fn_lines) + "\n")) written.append(write("_mixins.scss", f"""// 믹스인 — 여러 선언이 항상 함께 가야 하는 패턴을 묶는다. @use "sass:map"; @use "tokens"; /// PC 이상(>= {BREAKPOINT_PC})에서 적용. 모바일 우선이므로 기본 스타일은 밖에 쓴다. /// /// ⚠️ 미디어 쿼리 조건부는 `var()`를 해석하지 못하므로 브레이크포인트만은 CSS 변수가 /// 아닌 컴파일타임 값이다. 그래서 이 믹스인 경유가 강제된다. @mixin pc {{ @media (min-width: tokens.$breakpoint-pc) {{ @content; }} }} /// PC 미만에서만 적용. `pc`와 경계가 겹치지 않도록 0.02px을 뺀다. @mixin mobile {{ @media (max-width: tokens.$breakpoint-pc - 0.02px) {{ @content; }} }} /// 이름 붙은 브레이크포인트 이상에서 적용 — `xsm`(768) · `sm`(1024) · `default`(1440). /// 같은 이름의 `grid-wrap-*`이 그 폭의 컨테이너다. @mixin from($name) {{ @if not map.has-key(tokens.$breakpoints, $name) {{ @error "[@fox] 알 수 없는 브레이크포인트: `#{{$name}}` — 사용 가능한 값: #{{map.keys(tokens.$breakpoints)}}"; }} @media (min-width: map.get(tokens.$breakpoints, $name)) {{ @content; }} }} """)) written.append(write("abstracts.scss", """// 저작(authoring) 진입점 — 컴포넌트의 `.module.scss`가 `@use`하는 파일. // // @use "@fox/styles/abstracts" as fox; // // .button { // padding: fox.padding(5) fox.padding(7); // background: fox.color(button-primary-surface); // border-radius: fox.radius(3); // font-size: fox.font-size(label-md); // } // // ⚠️ **이 파일은 CSS를 한 줄도 출력하지 않는다.** 함수·믹스인·map 정의만 있다. // 실제 CSS 출력(토큰 선언)은 앱이 딱 한 번 import하는 `index.scss`가 전담한다. @forward "tokens"; @forward "functions"; @forward "mixins"; """)) written.append(write("index.scss", """// 글로벌 스타일 진입점 — **호스트 앱이 딱 한 번 import한다.** // // // app/globals.scss // @use "@fox/styles"; // // 여기서만 CSS가 출력된다. 컴포넌트의 `.module.scss`는 이 파일이 아니라 // `abstracts.scss`를 `@use`해야 한다 — 그쪽은 CSS 출력이 0이라 몇 번을 참조해도 // 중복이 생기지 않는다. @use "root"; """)) print("생성된 파일:") for w in written: print(" ", w) print() print(f"토큰 수 — primitive {len(primitive_map)} / font {len(font_map)} / number {len(number_map)}" f" / theme {len(theme_map)} / size {len(size_map)} / color {len(color_map)}" f" / responsive {len(resp_mobile)}") print(f"색상 표현 — var() 참조 {stats['var']} · 리터럴(참조 검증 실패) {stats['literal']}" f" · alias 없음 {stats['no-alias']}") total = len(primitive_map) + len(font_map) + len(number_map) + len(theme_map) + len(size_map) + len(color_map) + len(resp_mobile) print(f"총 CSS 커스텀 프로퍼티: {total}")