File name
Commit message
Commit date
File name
Commit message
Commit date
08-18
08-18
File name
Commit message
Commit date
"""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}")