임동욱 임동욱 08-11
chore: Figma 토큰 JSON → SCSS 변환기 추가
토큰 531개를 손으로 옮기면 오타가 섞이고 Figma 재수출 때마다 반복해야 한다.
변환 규칙(1rem = 10px, 알파 색상의 rgb() 표기, alias의 var() 보존)을 코드로
고정해 재수출 시 재실행만으로 갱신되게 한다.

Co-Authored-By: Claude Opus 5 
@9db7ffac6218d32e1da81c4ffe39ea452b643d7f
 
@fox/tools/build-tokens.py (added)
+++ @fox/tools/build-tokens.py
@@ -0,0 +1,486 @@
+"""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)
+
+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};
+
+{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 "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;
+  }}
+}}
+"""))
+
+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}")
Add a comment
List