chore: Figma 토큰 JSON → SCSS 변환기 추가
토큰 531개를 손으로 옮기면 오타가 섞이고 Figma 재수출 때마다 반복해야 한다. 변환 규칙(1rem = 10px, 알파 색상의 rgb() 표기, alias의 var() 보존)을 코드로 고정해 재수출 시 재실행만으로 갱신되게 한다. Co-Authored-By: Claude Opus 5
@9db7ffac6218d32e1da81c4ffe39ea452b643d7f
+++ @fox/tools/build-tokens.py
... | ... | @@ -0,0 +1,486 @@ |
| 1 | +"""Figma Design Tokens(JSON) → @fox/styles SCSS 토큰 변환기. | |
| 2 | + | |
| 3 | +입력: $FOX_TOKENS_SRC/*.tokens.json (기본 ~/Downloads/mode · 7개, Figma 컬렉션 5개/모드 7개) | |
| 4 | +출력: @fox/styles/tokens/*.scss + _root.scss + _functions.scss + _mixins.scss | |
| 5 | + | |
| 6 | +실행: python3 @fox/tools/build-tokens.py | |
| 7 | + | |
| 8 | +규칙 | |
| 9 | +- 길이 값은 1rem = 10px 기준으로 value/10 → rem. font.weight만 무단위. | |
| 10 | +- alpha < 1 색상은 rgb(R G B / A)로 출력(hex는 알파를 담지 못한다). | |
| 11 | +- Figma의 alias(참조)는 CSS에서도 var() 참조로 보존한다 — 원본 계층이 유지되어 | |
| 12 | + primitive를 바꾸면 semantic까지 전파된다. 단, 참조 대상이 제공된 파일에 없거나 | |
| 13 | + 값이 일치하지 않으면 리터럴로 떨어뜨린다(무결성 우선). | |
| 14 | +""" | |
| 15 | + | |
| 16 | +import json | |
| 17 | +import os | |
| 18 | +from collections import OrderedDict | |
| 19 | + | |
| 20 | +SRC = os.environ.get("FOX_TOKENS_SRC", os.path.expanduser("~/Downloads/mode")) | |
| 21 | +OUT = os.environ.get("FOX_OUT", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "styles")) | |
| 22 | + | |
| 23 | +FILES = { | |
| 24 | + "primitive": "primitive.tokens.json", | |
| 25 | + "theme": "theme.tokens.json", | |
| 26 | + "size": "size.tokens.json", | |
| 27 | + "light": "light.tokens.json", | |
| 28 | + "dark": "dark.tokens.json", | |
| 29 | + "pc": "pc.tokens.json", | |
| 30 | + "mobile": "mobile.tokens.json", | |
| 31 | +} | |
| 32 | + | |
| 33 | + | |
| 34 | +def leaves(node, path=""): | |
| 35 | + if isinstance(node, dict): | |
| 36 | + if "$type" in node: | |
| 37 | + yield path, node | |
| 38 | + else: | |
| 39 | + for k, v in node.items(): | |
| 40 | + if k == "$extensions": | |
| 41 | + continue | |
| 42 | + yield from leaves(v, f"{path}.{k}" if path else k) | |
| 43 | + | |
| 44 | + | |
| 45 | +raw = {} | |
| 46 | +for key, fname in FILES.items(): | |
| 47 | + doc = json.load(open(os.path.join(SRC, fname), encoding="utf-8")) | |
| 48 | + raw[key] = OrderedDict(leaves(doc)) | |
| 49 | + | |
| 50 | + | |
| 51 | +def resolve_refs(table): | |
| 52 | + """같은 파일 안의 {a.b.c} 참조를 실제 값으로 치환한다.""" | |
| 53 | + for path, node in table.items(): | |
| 54 | + val = node["$value"] | |
| 55 | + seen = set() | |
| 56 | + while isinstance(val, str) and val.startswith("{") and val.endswith("}"): | |
| 57 | + target = val[1:-1] | |
| 58 | + if target in seen or target not in table: | |
| 59 | + raise SystemExit(f"참조 해결 실패: {path} -> {val}") | |
| 60 | + seen.add(target) | |
| 61 | + val = table[target]["$value"] | |
| 62 | + node["$value"] = val | |
| 63 | + | |
| 64 | + | |
| 65 | +for table in raw.values(): | |
| 66 | + resolve_refs(table) | |
| 67 | + | |
| 68 | + | |
| 69 | +def css_color(value): | |
| 70 | + comps = value["components"] | |
| 71 | + alpha = value.get("alpha", 1) | |
| 72 | + if alpha >= 1: | |
| 73 | + return value["hex"].upper() | |
| 74 | + r, g, b = (round(c * 255) for c in comps) | |
| 75 | + a = round(alpha, 4) | |
| 76 | + return f"rgb({r} {g} {b} / {a})" | |
| 77 | + | |
| 78 | + | |
| 79 | +def css_len(value): | |
| 80 | + """px 수치를 rem으로. 1rem = 10px.""" | |
| 81 | + if value == 0: | |
| 82 | + return "0" | |
| 83 | + rem = value / 10 | |
| 84 | + text = f"{rem:.10f}".rstrip("0").rstrip(".") | |
| 85 | + return f"{text}rem" | |
| 86 | + | |
| 87 | + | |
| 88 | +# ── 변수명 매핑 ─────────────────────────────────────────────────────────────── | |
| 89 | +def name_primitive(path): | |
| 90 | + p = path.split(".") | |
| 91 | + if p[0] == "color": | |
| 92 | + return f"primitive-{p[1]}-{p[2]}" | |
| 93 | + if p[0] == "font": | |
| 94 | + return f"font-{p[1]}-{p[2]}" | |
| 95 | + if p[0] == "number": | |
| 96 | + return f"number-{p[1]}" | |
| 97 | + raise SystemExit(f"primitive 이름 매핑 없음: {path}") | |
| 98 | + | |
| 99 | + | |
| 100 | +def name_theme(path): | |
| 101 | + p = path.split(".") | |
| 102 | + assert p[0] == "color" | |
| 103 | + return f"theme-{p[1]}-{p[2]}" | |
| 104 | + | |
| 105 | + | |
| 106 | +def name_size(path): | |
| 107 | + p = path.split(".") | |
| 108 | + if p[0] == "effect": | |
| 109 | + return f"{p[1]}-{p[2]}" # effect.shadow.blur3 → shadow-blur3 | |
| 110 | + return "-".join(p) # padding.6 → padding-6 | |
| 111 | + | |
| 112 | + | |
| 113 | +CHART_ALT_GROUP = {} | |
| 114 | +for k in [str(i) for i in range(1, 11)]: | |
| 115 | + CHART_ALT_GROUP[k] = f"chart-alt-level-{k}" | |
| 116 | +for k in ("bar", "surface", "line", "disabled"): | |
| 117 | + CHART_ALT_GROUP[k] = f"chart-alt-default-{k}" | |
| 118 | +for i in range(1, 6): | |
| 119 | + for s in ("bar", "line", "surface"): | |
| 120 | + CHART_ALT_GROUP[f"{i}-{s}"] = f"chart-alt-type-{i}-{s}" | |
| 121 | + | |
| 122 | + | |
| 123 | +def name_semantic(path): | |
| 124 | + if path in CHART_ALT_GROUP: | |
| 125 | + return f"color-{CHART_ALT_GROUP[path]}" | |
| 126 | + p = path.split(".") | |
| 127 | + assert p[0] == "color", path | |
| 128 | + return "color-" + "-".join(p[1:]) | |
| 129 | + | |
| 130 | + | |
| 131 | +def name_responsive(path): | |
| 132 | + p = path.split(".") | |
| 133 | + if p[0] == "font" and p[1] == "size": | |
| 134 | + return "font-size-" + "-".join(p[2:]) # font.size.body.md → font-size-body-md | |
| 135 | + return "-".join(p) # form.height.md → form-height-md | |
| 136 | + | |
| 137 | + | |
| 138 | +# ── alias 역참조 테이블 (Figma 변수명 → 우리 CSS 변수명) ────────────────────── | |
| 139 | +alias_lookup = {} # (컬렉션명, "color/neutral/0") → (varname, 원본값) | |
| 140 | +for coll, namer in (("primitive", name_primitive), ("theme", name_theme), ("size", name_size)): | |
| 141 | + for path, node in raw[coll].items(): | |
| 142 | + alias_lookup[(coll, path.replace(".", "/"))] = (namer(path), node["$value"]) | |
| 143 | + | |
| 144 | + | |
| 145 | +def same_value(a, b): | |
| 146 | + if isinstance(a, dict) and isinstance(b, dict): | |
| 147 | + return css_color(a) == css_color(b) | |
| 148 | + return a == b | |
| 149 | + | |
| 150 | + | |
| 151 | +stats = {"var": 0, "literal": 0, "no-alias": 0} | |
| 152 | + | |
| 153 | + | |
| 154 | +def color_expr(node): | |
| 155 | + """alias가 검증되면 var() 참조, 아니면 리터럴 색상.""" | |
| 156 | + ext = node.get("$extensions", {}) | |
| 157 | + alias = ext.get("com.figma.aliasData") | |
| 158 | + if alias: | |
| 159 | + key = (alias["targetVariableSetName"], alias["targetVariableName"]) | |
| 160 | + hit = alias_lookup.get(key) | |
| 161 | + if hit and same_value(hit[1], node["$value"]): | |
| 162 | + stats["var"] += 1 | |
| 163 | + return f"var(--fox-{hit[0]})" | |
| 164 | + stats["literal"] += 1 | |
| 165 | + else: | |
| 166 | + stats["no-alias"] += 1 | |
| 167 | + return css_color(node["$value"]) | |
| 168 | + | |
| 169 | + | |
| 170 | +# ── 맵 생성 ────────────────────────────────────────────────────────────────── | |
| 171 | +def build(entries): | |
| 172 | + return OrderedDict(entries) | |
| 173 | + | |
| 174 | + | |
| 175 | +primitive_map, font_map, number_map = OrderedDict(), OrderedDict(), OrderedDict() | |
| 176 | +for path, node in raw["primitive"].items(): | |
| 177 | + name = name_primitive(path) | |
| 178 | + if name.startswith("primitive-"): | |
| 179 | + primitive_map[name[len("primitive-"):]] = css_color(node["$value"]) | |
| 180 | + elif name.startswith("font-"): | |
| 181 | + v = node["$value"] | |
| 182 | + font_map[name[len("font-"):]] = v if isinstance(v, (str, int)) else css_color(v) | |
| 183 | + else: | |
| 184 | + number_map[name[len("number-"):]] = css_len(node["$value"]) | |
| 185 | + | |
| 186 | +theme_map = OrderedDict( | |
| 187 | + (name_theme(p)[len("theme-"):], css_color(n["$value"])) for p, n in raw["theme"].items() | |
| 188 | +) | |
| 189 | + | |
| 190 | +size_map = OrderedDict((name_size(p), css_len(n["$value"])) for p, n in raw["size"].items()) | |
| 191 | + | |
| 192 | +# 시맨틱 색상: light/dark 를 light-dark() 한 줄로. | |
| 193 | +# 소속이 불분명해 chart-alt로 묶은 29개는 뒤로 보낸다 — 원본 JSON에서는 이들이 루트에 | |
| 194 | +# 먼저 오지만, 순서에 의미가 없고 카탈로그 첫 화면이 주요 색상이어야 읽기 쉽다. | |
| 195 | +color_map = OrderedDict() | |
| 196 | +ordered_light = sorted(raw["light"].items(), key=lambda kv: kv[0] in CHART_ALT_GROUP) | |
| 197 | +for path, lnode in ordered_light: | |
| 198 | + dnode = raw["dark"][path] | |
| 199 | + key = name_semantic(path)[len("color-"):] | |
| 200 | + color_map[key] = f"light-dark({color_expr(lnode)}, {color_expr(dnode)})" | |
| 201 | + | |
| 202 | +# 반응형: 모바일 기본 + pc 오버라이드 | |
| 203 | +resp_mobile = OrderedDict( | |
| 204 | + (name_responsive(p), css_len(n["$value"])) for p, n in raw["mobile"].items() | |
| 205 | +) | |
| 206 | +resp_pc = OrderedDict((name_responsive(p), css_len(n["$value"])) for p, n in raw["pc"].items()) | |
| 207 | +assert list(resp_mobile) == list(resp_pc) | |
| 208 | + | |
| 209 | +BREAKPOINT_PC = "768px" | |
| 210 | + | |
| 211 | + | |
| 212 | +# ── SCSS 출력 ──────────────────────────────────────────────────────────────── | |
| 213 | +def scss_map(name, mapping, indent=" "): | |
| 214 | + lines = [f"${name}: ("] | |
| 215 | + items = list(mapping.items()) | |
| 216 | + for i, (k, v) in enumerate(items): | |
| 217 | + comma = "," if i < len(items) - 1 else "" | |
| 218 | + key = f'"{k}"' if not k.replace("-", "").replace("_", "").isalnum() else k | |
| 219 | + lines.append(f"{indent}{key}: {v}{comma}") | |
| 220 | + lines.append(");") | |
| 221 | + return "\n".join(lines) | |
| 222 | + | |
| 223 | + | |
| 224 | +HEADER = """// ⚠️ 이 파일은 Figma Design Tokens export에서 **자동 생성**되었습니다. | |
| 225 | +// 원본: {src} | |
| 226 | +// 손으로 고치지 말고 원본 JSON을 다시 export한 뒤 변환기를 재실행하세요. | |
| 227 | +""" | |
| 228 | + | |
| 229 | + | |
| 230 | +def write(rel, body): | |
| 231 | + path = os.path.join(OUT, rel) | |
| 232 | + os.makedirs(os.path.dirname(path), exist_ok=True) | |
| 233 | + open(path, "w", encoding="utf-8").write(body) | |
| 234 | + return rel | |
| 235 | + | |
| 236 | + | |
| 237 | +written = [] | |
| 238 | + | |
| 239 | +written.append(write("tokens/_primitive.scss", HEADER.format(src="primitive.tokens.json") + f""" | |
| 240 | +// 원시 팔레트 — 화면에서 직접 쓰지 않는다. 시맨틱 토큰(`$color`)이 참조하는 바닥층이다. | |
| 241 | +{scss_map("primitive", primitive_map)} | |
| 242 | + | |
| 243 | +// 서체 — family는 문자열, weight는 무단위 수치(길이가 아니므로 rem 변환 대상이 아니다). | |
| 244 | +{scss_map("font", font_map)} | |
| 245 | + | |
| 246 | +// 원시 수치 스케일 — size 컬렉션이 참조하는 값들. 1rem = 10px 기준으로 변환됨. | |
| 247 | +{scss_map("number", number_map)} | |
| 248 | +""")) | |
| 249 | + | |
| 250 | +written.append(write("tokens/_theme.scss", HEADER.format(src="theme.tokens.json") + f""" | |
| 251 | +// 브랜드 램프 — primary / secondary / accent. | |
| 252 | +{scss_map("theme", theme_map)} | |
| 253 | +""")) | |
| 254 | + | |
| 255 | +written.append(write("tokens/_size.scss", HEADER.format(src="size.tokens.json") + f""" | |
| 256 | +// 모드와 무관한 크기 스케일. 값은 1rem = 10px 기준 rem. | |
| 257 | +{scss_map("size", size_map)} | |
| 258 | +""")) | |
| 259 | + | |
| 260 | +written.append(write("tokens/_color.scss", HEADER.format(src="light.tokens.json / dark.tokens.json") + f""" | |
| 261 | +// 시맨틱 색상 — 라이트/다크 값을 `light-dark()` 한 줄에 함께 보유한다. | |
| 262 | +// 전환은 `_root.scss`의 `color-scheme`이 담당하므로 다크 전용 블록이 없고, | |
| 263 | +// 따라서 한쪽만 고쳐서 생기는 테마 불일치가 구조적으로 불가능하다. | |
| 264 | +// | |
| 265 | +// 값이 `var(--fox-primitive-*)` / `var(--fox-theme-*)`인 항목은 Figma의 alias를 | |
| 266 | +// 그대로 옮긴 것이다 — 바닥층을 바꾸면 여기까지 전파된다. | |
| 267 | +{scss_map("color", color_map)} | |
| 268 | +""")) | |
| 269 | + | |
| 270 | +written.append(write("tokens/_responsive.scss", HEADER.format(src="mobile.tokens.json / pc.tokens.json") + f""" | |
| 271 | +// 뷰포트에 따라 달라지는 크기 토큰. 키는 두 모드가 완전히 동일하다. | |
| 272 | +// 모바일 우선 — `$responsive`가 기본값이고 `$responsive-pc`가 브레이크포인트 이상에서 덮는다. | |
| 273 | +$breakpoint-pc: {BREAKPOINT_PC}; | |
| 274 | + | |
| 275 | +{scss_map("responsive", resp_mobile)} | |
| 276 | + | |
| 277 | +{scss_map("responsive-pc", resp_pc)} | |
| 278 | +""")) | |
| 279 | + | |
| 280 | +written.append(write("tokens/_index.scss", """// 토큰 map 모음 — **CSS를 한 줄도 출력하지 않는다.** | |
| 281 | +// 값 선언(이 폴더)과 CSS 출력(`../_root.scss`)을 분리해 두었기 때문에, 컴포넌트의 | |
| 282 | +// `.module.scss`가 몇 개든 `abstracts`를 `@use`해도 `:root` 블록이 중복 출력되지 않는다 | |
| 283 | +// (각 CSS Module은 별도 컴파일 단위라 CSS를 내보내는 모듈을 공유하면 파일마다 복제된다). | |
| 284 | + | |
| 285 | +@forward "primitive"; | |
| 286 | +@forward "theme"; | |
| 287 | +@forward "size"; | |
| 288 | +@forward "color"; | |
| 289 | +@forward "responsive"; | |
| 290 | +""")) | |
| 291 | + | |
| 292 | +# _root.scss | |
| 293 | +root_lines = [] | |
| 294 | +root_lines.append("""// 토큰 CSS 출력 — `tokens/`의 SCSS map을 `--fox-*` 커스텀 프로퍼티로 선언한다. | |
| 295 | +// | |
| 296 | +// **앱 전체에서 딱 한 번만 로드되어야 한다** (`index.scss` 경유). | |
| 297 | +// 컴포넌트의 `.module.scss`에서는 절대 `@use` 하지 않는다. | |
| 298 | +// | |
| 299 | +// map을 순회해 생성하므로 토큰을 추가할 때 이 파일은 손대지 않는다. | |
| 300 | + | |
| 301 | +@use "sass:map"; | |
| 302 | +@use "tokens"; | |
| 303 | + | |
| 304 | +:root { | |
| 305 | + // 기본은 OS 설정 추종. 아래 `[data-theme]` 규칙이 이 값만 덮어써 수동 선택을 | |
| 306 | + // 처리하므로, 색상 값 자체는 `tokens/_color.scss` 한 곳에만 존재한다. | |
| 307 | + color-scheme: light dark; | |
| 308 | +""") | |
| 309 | +for var_prefix, map_name in ( | |
| 310 | + ("primitive", "primitive"), | |
| 311 | + ("font", "font"), | |
| 312 | + ("number", "number"), | |
| 313 | + ("theme", "theme"), | |
| 314 | +): | |
| 315 | + root_lines.append(f""" @each $name, $value in tokens.${map_name} {{ | |
| 316 | + --fox-{var_prefix}-#{{$name}}: #{{$value}}; | |
| 317 | + }} | |
| 318 | +""") | |
| 319 | +root_lines.append(""" // size / color / responsive 는 이름에 그룹이 이미 들어 있어 접두사가 `--fox-` 뿐이다. | |
| 320 | + @each $name, $value in tokens.$size { | |
| 321 | + --fox-#{$name}: #{$value}; | |
| 322 | + } | |
| 323 | + | |
| 324 | + @each $name, $value in tokens.$color { | |
| 325 | + --fox-color-#{$name}: #{$value}; | |
| 326 | + } | |
| 327 | + | |
| 328 | + // 모바일 우선 기본값. | |
| 329 | + @each $name, $value in tokens.$responsive { | |
| 330 | + --fox-#{$name}: #{$value}; | |
| 331 | + } | |
| 332 | +} | |
| 333 | + | |
| 334 | +// PC 모드 — 반응형 토큰만 덮어쓴다. 색상은 뷰포트와 무관하므로 여기 오지 않는다. | |
| 335 | +@media (min-width: tokens.$breakpoint-pc) { | |
| 336 | + :root { | |
| 337 | + @each $name, $value in tokens.$responsive-pc { | |
| 338 | + --fox-#{$name}: #{$value}; | |
| 339 | + } | |
| 340 | + } | |
| 341 | +} | |
| 342 | + | |
| 343 | +// 수동 테마 선택 — `color-scheme`만 고정하면 `light-dark()`가 해당 분기로 확정된다. | |
| 344 | +// 속성이 없거나 `"system"`이면 위 `:root`의 `light dark`가 유지되어 OS를 따른다. | |
| 345 | +:root[data-theme="light"] { | |
| 346 | + color-scheme: light; | |
| 347 | +} | |
| 348 | + | |
| 349 | +:root[data-theme="dark"] { | |
| 350 | + color-scheme: dark; | |
| 351 | +} | |
| 352 | + | |
| 353 | +// ⚠️ 1rem = 10px 규약의 근거. 브라우저 기본 글자 크기(16px)의 62.5%가 10px다. | |
| 354 | +// 모든 토큰의 rem 값이 이 선언을 전제로 계산돼 있다. | |
| 355 | +// 미디어 쿼리 안의 rem은 이 값의 영향을 받지 않는다(항상 브라우저 기본 크기 기준). | |
| 356 | +html { | |
| 357 | + font-size: 62.5%; | |
| 358 | +} | |
| 359 | +""") | |
| 360 | +written.append(write("_root.scss", "\n".join(root_lines))) | |
| 361 | + | |
| 362 | +# _functions.scss | |
| 363 | +GROUPS = [ | |
| 364 | + # (함수명, 대상 map, 키 접두사(없으면 None), CSS 변수 접두사) | |
| 365 | + ("color", "color", None, "color-"), | |
| 366 | + ("primitive", "primitive", None, "primitive-"), | |
| 367 | + ("theme", "theme", None, "theme-"), | |
| 368 | + ("number", "number", None, "number-"), | |
| 369 | + ("font-family", "font", "family", "font-"), | |
| 370 | + ("font-weight", "font", "weight", "font-"), | |
| 371 | + ("border", "size", "border", ""), | |
| 372 | + ("padding", "size", "padding", ""), | |
| 373 | + ("gap", "size", "gap", ""), | |
| 374 | + ("radius", "size", "radius", ""), | |
| 375 | + ("icon", "size", "icon", ""), | |
| 376 | + ("shadow", "size", "shadow", ""), | |
| 377 | + ("backdrop", "size", "backdrop", ""), | |
| 378 | + ("font-size", "responsive", "font-size", ""), | |
| 379 | + ("form", "responsive", "form", ""), | |
| 380 | + ("grid", "responsive", "grid", ""), | |
| 381 | + ("card", "responsive", "card", ""), | |
| 382 | + ("modal", "responsive", "modal", ""), | |
| 383 | + ("spacing", "responsive", "spacing", ""), | |
| 384 | + ("section", "responsive", "section", ""), | |
| 385 | +] | |
| 386 | + | |
| 387 | +fn_lines = ["""// 토큰 접근 함수 — 디자인 시스템 규율을 **컴파일러가 강제**하는 지점. | |
| 388 | +// | |
| 389 | +// 화면 코드는 `color: #256EF4`나 `padding: 13px`를 쓸 수 없고, 반드시 | |
| 390 | +// `color: fox.color(font-neutral-strong)` / `padding: fox.padding(6)`로 토큰을 | |
| 391 | +// 지목해야 한다. 존재하지 않는 이름을 쓰면 `@error`로 **빌드가 실패**하고 사용 | |
| 392 | +// 가능한 토큰 목록이 함께 출력된다. | |
| 393 | +// | |
| 394 | +// 반환값은 CSS 커스텀 프로퍼티 참조(`var(--fox-*)`)다. 값이 아니라 참조를 돌려주므로 | |
| 395 | +// (1) 라이트/다크와 pc/모바일이 런타임에 전환되고 (2) 특정 영역만 토큰을 덮어쓰는 | |
| 396 | +// 스코프 오버라이드가 가능하며 (3) devtools에서 어떤 토큰인지 그대로 보인다. | |
| 397 | + | |
| 398 | +@use "sass:map"; | |
| 399 | +@use "tokens"; | |
| 400 | + | |
| 401 | +/// 토큰 이름을 검증하고 커스텀 프로퍼티 참조를 반환한다. | |
| 402 | +@function _token($group, $key, $registry, $var-name) { | |
| 403 | + @if not map.has-key($registry, $key) { | |
| 404 | + @error "[@fox] 알 수 없는 #{$group} 토큰: `#{$key}` — 사용 가능한 값: #{map.keys($registry)}"; | |
| 405 | + } | |
| 406 | + | |
| 407 | + @return var(--fox-#{$var-name}); | |
| 408 | +} | |
| 409 | +"""] | |
| 410 | +for fn, mapname, prefix, varprefix in GROUPS: | |
| 411 | + if prefix is None: | |
| 412 | + fn_lines.append(f""" | |
| 413 | +@function {fn}($name) {{ | |
| 414 | + @return _token("{fn}", $name, tokens.${mapname}, "{varprefix}#{{$name}}"); | |
| 415 | +}}""") | |
| 416 | + else: | |
| 417 | + fn_lines.append(f""" | |
| 418 | +@function {fn}($name) {{ | |
| 419 | + @return _token("{fn}", "{prefix}-#{{$name}}", tokens.${mapname}, "{varprefix}{prefix}-#{{$name}}"); | |
| 420 | +}}""") | |
| 421 | +written.append(write("_functions.scss", "\n".join(fn_lines) + "\n")) | |
| 422 | + | |
| 423 | +written.append(write("_mixins.scss", f"""// 믹스인 — 여러 선언이 항상 함께 가야 하는 패턴을 묶는다. | |
| 424 | + | |
| 425 | +@use "tokens"; | |
| 426 | + | |
| 427 | +/// PC 이상(>= {BREAKPOINT_PC})에서 적용. 모바일 우선이므로 기본 스타일은 밖에 쓴다. | |
| 428 | +/// | |
| 429 | +/// ⚠️ 미디어 쿼리 조건부는 `var()`를 해석하지 못하므로 브레이크포인트만은 CSS 변수가 | |
| 430 | +/// 아닌 컴파일타임 값이다. 그래서 이 믹스인 경유가 강제된다. | |
| 431 | +@mixin pc {{ | |
| 432 | + @media (min-width: tokens.$breakpoint-pc) {{ | |
| 433 | + @content; | |
| 434 | + }} | |
| 435 | +}} | |
| 436 | + | |
| 437 | +/// PC 미만에서만 적용. `pc`와 경계가 겹치지 않도록 0.02px을 뺀다. | |
| 438 | +@mixin mobile {{ | |
| 439 | + @media (max-width: tokens.$breakpoint-pc - 0.02px) {{ | |
| 440 | + @content; | |
| 441 | + }} | |
| 442 | +}} | |
| 443 | +""")) | |
| 444 | + | |
| 445 | +written.append(write("abstracts.scss", """// 저작(authoring) 진입점 — 컴포넌트의 `.module.scss`가 `@use`하는 파일. | |
| 446 | +// | |
| 447 | +// @use "@fox/styles/abstracts" as fox; | |
| 448 | +// | |
| 449 | +// .button { | |
| 450 | +// padding: fox.padding(5) fox.padding(7); | |
| 451 | +// background: fox.color(button-primary-surface); | |
| 452 | +// border-radius: fox.radius(3); | |
| 453 | +// font-size: fox.font-size(label-md); | |
| 454 | +// } | |
| 455 | +// | |
| 456 | +// ⚠️ **이 파일은 CSS를 한 줄도 출력하지 않는다.** 함수·믹스인·map 정의만 있다. | |
| 457 | +// 실제 CSS 출력(토큰 선언)은 앱이 딱 한 번 import하는 `index.scss`가 전담한다. | |
| 458 | + | |
| 459 | +@forward "tokens"; | |
| 460 | +@forward "functions"; | |
| 461 | +@forward "mixins"; | |
| 462 | +""")) | |
| 463 | + | |
| 464 | +written.append(write("index.scss", """// 글로벌 스타일 진입점 — **호스트 앱이 딱 한 번 import한다.** | |
| 465 | +// | |
| 466 | +// // app/globals.scss | |
| 467 | +// @use "@fox/styles"; | |
| 468 | +// | |
| 469 | +// 여기서만 CSS가 출력된다. 컴포넌트의 `.module.scss`는 이 파일이 아니라 | |
| 470 | +// `abstracts.scss`를 `@use`해야 한다 — 그쪽은 CSS 출력이 0이라 몇 번을 참조해도 | |
| 471 | +// 중복이 생기지 않는다. | |
| 472 | + | |
| 473 | +@use "root"; | |
| 474 | +""")) | |
| 475 | + | |
| 476 | +print("생성된 파일:") | |
| 477 | +for w in written: | |
| 478 | + print(" ", w) | |
| 479 | +print() | |
| 480 | +print(f"토큰 수 — primitive {len(primitive_map)} / font {len(font_map)} / number {len(number_map)}" | |
| 481 | + f" / theme {len(theme_map)} / size {len(size_map)} / color {len(color_map)}" | |
| 482 | + f" / responsive {len(resp_mobile)}") | |
| 483 | +print(f"색상 표현 — var() 참조 {stats['var']} · 리터럴(참조 검증 실패) {stats['literal']}" | |
| 484 | + f" · alias 없음 {stats['no-alias']}") | |
| 485 | +total = len(primitive_map) + len(font_map) + len(number_map) + len(theme_map) + len(size_map) + len(color_map) + len(resp_mobile) | |
| 486 | +print(f"총 CSS 커스텀 프로퍼티: {total}") |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?