임동욱 임동욱 08-20
feat: grid 토큰을 브레이크포인트와 같은 수로 맞추고 from() 믹스인을 연다
Figma의 grid 값은 아트보드 좌표였다. 폭은 아트보드 폭이고, 여백은 그 안에서 가운데로 맞추기
위한 offset이다 — PC의 `grid-margin-sm`이 420px인 이유가 (1920-1080)/2였다. CSS에서는 둘 다
뜻이 달라 그대로 쓸 수 없었다.

폭은 흔한 브레이크포인트로 반올림한다(사용자 확정 사항).

  PC     1552 / 1080 / 800  →  1440 / 1024 / 768
  모바일   348 /  320 / 280  →   360 /  320 / 280

같은 수를 `$breakpoints`로도 낸다. 그래야 "이 폭에서 레이아웃이 갈린다"와 "컨테이너가 여기까지
넓어진다"가 어긋나지 않는다. `xsm`(768)은 기존 `$breakpoint-pc`와 같은 값이라 그 믹스인은
그대로 쓸 수 있다.

여백은 아트보드 offset이 아니라 페이지 좌우 여백으로 바꾼다 — 모바일 16px · PC 48px. 컨테이너가
좁아진다고 여백이 넓어지지는 않으므로 세 단계가 한 값을 공유하고, `full`만 0(가장자리까지)이다.
가운데 정렬은 `margin-inline: auto`가 맡는다.

미디어 쿼리는 `var()`를 해석하지 못하므로 `fox.from(xsm|sm|default)` 믹스인을 함께 연다 —
`fox.pc`/`fox.mobile`과 같은 이유다. 모르는 이름은 컴파일 때 잡는다.

치환은 변환기(`build-tokens.py`)가 들고 있어 재export해도 유지된다.

확인: `fox.from(sm)`·`fox.from(default)`가 각각 `@media (min-width: 1024px)`·`1440px`로
컴파일되고, 모르는 이름은 "알 수 없는 브레이크포인트"로 끊긴다. 산출 CSS 변수도 새 값으로 나온다.

Co-Authored-By: Claude Opus 5 
@84858d9f6953b1a6442a5ec2bd8aeb6fbe45e334
@fox/styles/_mixins.scss
--- @fox/styles/_mixins.scss
+++ @fox/styles/_mixins.scss
@@ -1,3 +1,4 @@
+@use "sass:map";
 @use "tokens";
 
 /// PC 이상(>= 768px). 미디어 쿼리 조건부는 `var()`를 해석하지 못해 브레이크포인트만
@@ -14,3 +15,15 @@
     @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;
+  }
+}
@fox/styles/tokens/_responsive.scss
--- @fox/styles/tokens/_responsive.scss
+++ @fox/styles/tokens/_responsive.scss
@@ -6,6 +6,13 @@
 // 모바일 우선 — `$responsive`가 기본값이고 `$responsive-pc`가 브레이크포인트 이상에서 덮는다.
 $breakpoint-pc: 768px;
 
+// 컨테이너 폭(`grid-wrap-*`)과 같은 수다 — 미디어 쿼리와 폭이 어긋나지 않게 한 곳에서 낸다.
+$breakpoints: (
+  xsm: 768px,
+  sm: 1024px,
+  default: 1440px,
+);
+
 $responsive: (
   font-size-display-lg: 4.4rem,
   font-size-display-md: 3.2rem,
@@ -41,13 +48,13 @@
   form-height-md: 4rem,
   form-height-lg: 4.8rem,
   grid-wrap-full: 100%,
-  grid-wrap-default: 34.8rem,
+  grid-wrap-default: 36rem,
   grid-wrap-sm: 32rem,
   grid-wrap-xsm: 28rem,
   grid-margin-full: 0,
   grid-margin-default: 1.6rem,
-  grid-margin-sm: 3rem,
-  grid-margin-xsm: 5rem,
+  grid-margin-sm: 1.6rem,
+  grid-margin-xsm: 1.6rem,
   grid-gutter-sm: 0.8rem,
   grid-gutter-md: 1.6rem,
   grid-gutter-lg: 2rem,
@@ -119,13 +126,13 @@
   form-height-md: 4rem,
   form-height-lg: 5.6rem,
   grid-wrap-full: 100%,
-  grid-wrap-default: 155.2rem,
-  grid-wrap-sm: 108rem,
-  grid-wrap-xsm: 80rem,
+  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: 42rem,
-  grid-margin-xsm: 56rem,
+  grid-margin-sm: 4.8rem,
+  grid-margin-xsm: 4.8rem,
   grid-gutter-sm: 0.8rem,
   grid-gutter-md: 1.6rem,
   grid-gutter-lg: 2.4rem,
@fox/tools/build-tokens.py
--- @fox/tools/build-tokens.py
+++ @fox/tools/build-tokens.py
@@ -206,16 +206,45 @@
 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 값을 그대로 쓸 수 없는 토큰. 시안의 값은 **아트보드 폭**이라 CSS에서는 뜻이 달라진다.
-# 재export해도 이 치환이 유지되도록 변환기가 들고 있는다(생성 파일을 손으로 고치면 지워진다).
-DEV_OVERRIDES = {
-    # `full`은 "화면을 채운다"는 뜻인데 Figma에서는 아트보드 폭(모바일 380px / PC 1920px)으로
-    # 잡혀 있다. 그대로 쓰면 그보다 좁은 창에서 넘치고 넓은 창에서는 덜 찬다.
+# ── 개발용 치환 ───────────────────────────────────────────────────────────────
+# 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",
 }
-for _key, _value in DEV_OVERRIDES.items():
-    resp_mobile[_key] = _value
-    resp_pc[_key] = _value
+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"
 
@@ -282,6 +311,9 @@
 // 뷰포트에 따라 달라지는 크기 토큰. 키는 두 모드가 완전히 동일하다.
 // 모바일 우선 — `$responsive`가 기본값이고 `$responsive-pc`가 브레이크포인트 이상에서 덮는다.
 $breakpoint-pc: {BREAKPOINT_PC};
+
+// 컨테이너 폭(`grid-wrap-*`)과 같은 수다 — 미디어 쿼리와 폭이 어긋나지 않게 한 곳에서 낸다.
+{scss_map("breakpoints", BREAKPOINTS)}
 
 {scss_map("responsive", resp_mobile)}
 
@@ -433,6 +465,7 @@
 
 written.append(write("_mixins.scss", f"""// 믹스인 — 여러 선언이 항상 함께 가야 하는 패턴을 묶는다.
 
+@use "sass:map";
 @use "tokens";
 
 /// PC 이상(>= {BREAKPOINT_PC})에서 적용. 모바일 우선이므로 기본 스타일은 밖에 쓴다.
@@ -451,6 +484,18 @@
     @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`하는 파일.
Add a comment
List