feat: 토큰 미리보기를 실제 적용 형태로 렌더
값만 보여주던 막대를 걷어내고 그룹별로 실제 적용 결과를 보인다 — 서체는 그 속성을 적용한 문장, 패딩·간격은 문단 두 개 사이의 공간, 반경·테두리는 실제 테두리를 가진 div, 아이콘은 브라우저 기본 체크박스, 그림자는 실제 box-shadow, 배경 흐림은 사진 위 절반에만 건 backdrop-filter다. 원시 수치는 값만 남긴다. 그림자는 Figma에 offset·blur 수치만 있고 조합과 색이 정의돼 있지 않다. 각 수치의 효과를 보이려고 location은 `0 값 0`, blur는 `0 0 값`으로 걸고 색은 실재 토큰인 alpha-default-inverse25를 쓴다 — 조합 자체는 토큰이 아니며 섹션 설명에도 적었다. 배경 흐림 예제 이미지는 next/image가 아니라다. @fox가 Next에 의존하면 폴더째 복사되는 이식성이 깨지고 외부 호스트마다 remotePatterns 설정이 필요해진다. Co-Authored-By: Claude Opus 5
@f5129c5b378bf6ef5c665f21e7e9b7494f7660d7
--- @fox/dev-test/dev-test.module.scss
+++ @fox/dev-test/dev-test.module.scss
... | ... | @@ -254,14 +254,126 @@ |
| 254 | 254 |
color: fox.color(font-neutral-subtle); |
| 255 | 255 |
} |
| 256 | 256 |
|
| 257 |
-// 크기 토큰을 눈으로 비교하기 위한 막대. 폭은 인라인 style이 토큰을 참조해 넣는다. |
|
| 258 |
-.bar {
|
|
| 259 |
- display: block; |
|
| 260 |
- block-size: fox.icon(2); |
|
| 261 |
- border-radius: fox.radius(1); |
|
| 257 |
+// 미리보기 칸은 내용만큼만 차지하게 두어 토큰/값 열이 밀리지 않게 한다. |
|
| 258 |
+.tdDemo {
|
|
| 259 |
+ inline-size: 1%; |
|
| 260 |
+ white-space: nowrap; |
|
| 261 |
+} |
|
| 262 |
+ |
|
| 263 |
+// ── 서체 미리보기 ─────────────────────────────────────────────────────────── |
|
| 264 |
+.typeList {
|
|
| 265 |
+ display: flex; |
|
| 266 |
+ flex-direction: column; |
|
| 267 |
+} |
|
| 268 |
+ |
|
| 269 |
+.typeRow {
|
|
| 270 |
+ display: flex; |
|
| 271 |
+ flex-wrap: wrap; |
|
| 272 |
+ gap: fox.gap(2) fox.gap(7); |
|
| 273 |
+ align-items: baseline; |
|
| 274 |
+ padding-block: fox.padding(5); |
|
| 275 |
+ border-block-end: fox.border(1) solid fox.color(border-neutral-subtle); |
|
| 276 |
+} |
|
| 277 |
+ |
|
| 278 |
+.typeMeta {
|
|
| 279 |
+ display: flex; |
|
| 280 |
+ flex: 0 0 22rem; |
|
| 281 |
+ flex-direction: column; |
|
| 282 |
+ gap: fox.gap(1); |
|
| 283 |
+} |
|
| 284 |
+ |
|
| 285 |
+.typeSample {
|
|
| 286 |
+ min-inline-size: 20rem; |
|
| 287 |
+ flex: 1; |
|
| 288 |
+ margin: 0; |
|
| 289 |
+ color: fox.color(font-neutral-default); |
|
| 290 |
+} |
|
| 291 |
+ |
|
| 292 |
+// ── 패딩 · 간격 미리보기 ──────────────────────────────────────────────────── |
|
| 293 |
+// 점선 테두리와 옅은 바탕이 "토큰이 만든 공간"이고, 흰 칩이 문단이다. |
|
| 294 |
+.demoPadBox, |
|
| 295 |
+.demoGapBox {
|
|
| 296 |
+ border: fox.border(1) dashed fox.color(border-theme-primary); |
|
| 262 | 297 |
background: fox.color(surface-theme-primary); |
| 263 | 298 |
} |
| 264 | 299 |
|
| 300 |
+.demoPadBox {
|
|
| 301 |
+ display: inline-block; |
|
| 302 |
+} |
|
| 303 |
+ |
|
| 304 |
+.demoGapBox {
|
|
| 305 |
+ display: inline-flex; |
|
| 306 |
+ align-items: center; |
|
| 307 |
+} |
|
| 308 |
+ |
|
| 309 |
+.demoText {
|
|
| 310 |
+ margin: 0; |
|
| 311 |
+ padding: fox.padding(1) fox.padding(3); |
|
| 312 |
+ background: fox.color(surface-neutral-default); |
|
| 313 |
+ color: fox.color(font-neutral-default); |
|
| 314 |
+ font-size: fox.font-size(label-xsm); |
|
| 315 |
+ white-space: nowrap; |
|
| 316 |
+} |
|
| 317 |
+ |
|
| 318 |
+// ── 모서리 반경 · 테두리 두께 미리보기 ────────────────────────────────────── |
|
| 319 |
+.demoRadius, |
|
| 320 |
+.demoBorder {
|
|
| 321 |
+ inline-size: fox.icon(7); |
|
| 322 |
+ block-size: fox.icon(7); |
|
| 323 |
+ border-color: fox.color(border-theme-primary); |
|
| 324 |
+ border-style: solid; |
|
| 325 |
+ background: fox.color(surface-neutral-gray); |
|
| 326 |
+} |
|
| 327 |
+ |
|
| 328 |
+.demoRadius {
|
|
| 329 |
+ border-width: fox.border(2); |
|
| 330 |
+} |
|
| 331 |
+ |
|
| 332 |
+// ── 아이콘 크기 미리보기 ──────────────────────────────────────────────────── |
|
| 333 |
+// 브라우저 기본 체크박스를 토큰 크기로 키운다 — 별도 에셋 없이 크기만 비교한다. |
|
| 334 |
+.demoIcon {
|
|
| 335 |
+ display: block; |
|
| 336 |
+ margin: 0; |
|
| 337 |
+ accent-color: fox.color(surface-theme-primary); |
|
| 338 |
+} |
|
| 339 |
+ |
|
| 340 |
+// ── 그림자 미리보기 ───────────────────────────────────────────────────────── |
|
| 341 |
+// 그림자 색은 Figma에 토큰이 없어 중립 알파 토큰을 데모용으로 쓴다. |
|
| 342 |
+// 인라인 style이 참조할 수 있도록 여기서 커스텀 프로퍼티로 내려둔다. |
|
| 343 |
+.demoShadow {
|
|
| 344 |
+ --demo-shadow-color: #{fox.color(alpha-default-inverse25)};
|
|
| 345 |
+ |
|
| 346 |
+ inline-size: fox.icon(7); |
|
| 347 |
+ block-size: fox.icon(6); |
|
| 348 |
+ border-radius: fox.radius(2); |
|
| 349 |
+ background: fox.color(surface-neutral-default); |
|
| 350 |
+} |
|
| 351 |
+ |
|
| 352 |
+// ── 배경 흐림 미리보기 ────────────────────────────────────────────────────── |
|
| 353 |
+// 캔버스 크기는 디자인 값이 아니라 데모용 지면이라 토큰을 두지 않는다. |
|
| 354 |
+.demoBackdrop {
|
|
| 355 |
+ position: relative; |
|
| 356 |
+ overflow: hidden; |
|
| 357 |
+ inline-size: 20rem; |
|
| 358 |
+ block-size: 12rem; |
|
| 359 |
+ border-radius: fox.radius(2); |
|
| 360 |
+} |
|
| 361 |
+ |
|
| 362 |
+.demoBackdropImage {
|
|
| 363 |
+ display: block; |
|
| 364 |
+ inline-size: 100%; |
|
| 365 |
+ block-size: 100%; |
|
| 366 |
+ object-fit: cover; |
|
| 367 |
+} |
|
| 368 |
+ |
|
| 369 |
+.demoBackdropOverlay {
|
|
| 370 |
+ position: absolute; |
|
| 371 |
+ inset-block: 0; |
|
| 372 |
+ inset-inline-end: 0; |
|
| 373 |
+ inline-size: 50%; |
|
| 374 |
+ border-inline-start: fox.border(1) solid fox.color(border-neutral-overlay); |
|
| 375 |
+} |
|
| 376 |
+ |
|
| 265 | 377 |
// ── 컴포넌트 예제 ─────────────────────────────────────────────────────────── |
| 266 | 378 |
.variantList {
|
| 267 | 379 |
display: flex; |
--- @fox/dev-test/token-view.tsx
+++ @fox/dev-test/token-view.tsx
... | ... | @@ -1,18 +1,22 @@ |
| 1 | 1 |
"use client"; |
| 2 | 2 |
|
| 3 |
-import type { CSSProperties } from "react";
|
|
| 3 |
+import type { CSSProperties, ReactNode } from "react";
|
|
| 4 | 4 |
import type { TokenEntry } from "./read-tokens";
|
| 5 | 5 |
import styles from "./dev-test.module.scss"; |
| 6 | 6 |
|
| 7 | 7 |
/** |
| 8 | 8 |
* 토큰 **목록**은 CSSOM에서 읽으므로 여기에 적지 않는다. 이 표는 각 그룹을 어떻게 |
| 9 |
- * 보여줄지(색은 스와치, 길이는 막대, 나머지는 값)와 한글 이름만 정한다. |
|
| 10 |
- * 목록에 없는 그룹이 새로 생겨도 값 표로 그려진다 — 빠지지 않는다. |
|
| 9 |
+ * 보여줄지와 한글 이름만 정한다. 목록에 없는 그룹이 새로 생겨도 값 표로 그려진다. |
|
| 10 |
+ * |
|
| 11 |
+ * - `swatch` 색을 칠한 카드 |
|
| 12 |
+ * - `type` 그 서체 속성을 실제로 적용한 문장 |
|
| 13 |
+ * - `demo` 그 토큰을 실제로 적용한 요소 (그룹별 렌더는 `DEMOS`) |
|
| 14 |
+ * - `value` 값만 |
|
| 11 | 15 |
*/ |
| 12 | 16 |
export const TOKEN_GROUPS: {
|
| 13 | 17 |
key: string; |
| 14 | 18 |
label: string; |
| 15 |
- kind: "swatch" | "bar" | "value"; |
|
| 19 |
+ kind: "swatch" | "type" | "demo" | "value"; |
|
| 16 | 20 |
note?: string; |
| 17 | 21 |
}[] = [ |
| 18 | 22 |
{
|
... | ... | @@ -31,17 +35,37 @@ |
| 31 | 35 |
{
|
| 32 | 36 |
key: "font", |
| 33 | 37 |
label: "서체", |
| 34 |
- kind: "value", |
|
| 35 |
- note: "size 계열은 768px 경계에서 값이 바뀝니다.", |
|
| 38 |
+ kind: "type", |
|
| 39 |
+ note: "각 토큰을 실제로 적용한 문장입니다. size 계열은 768px 경계에서 값이 바뀝니다.", |
|
| 36 | 40 |
}, |
| 37 |
- { key: "padding", label: "패딩", kind: "bar" },
|
|
| 38 |
- { key: "gap", label: "간격", kind: "bar" },
|
|
| 39 |
- { key: "radius", label: "모서리 반경", kind: "bar" },
|
|
| 40 |
- { key: "icon", label: "아이콘 크기", kind: "bar" },
|
|
| 41 |
- { key: "border", label: "테두리 두께", kind: "bar" },
|
|
| 42 |
- { key: "number", label: "원시 수치", kind: "bar" },
|
|
| 43 |
- { key: "shadow", label: "그림자 수치", kind: "value" },
|
|
| 44 |
- { key: "backdrop", label: "배경 흐림", kind: "value" },
|
|
| 41 |
+ {
|
|
| 42 |
+ key: "padding", |
|
| 43 |
+ label: "패딩", |
|
| 44 |
+ kind: "demo", |
|
| 45 |
+ note: "테두리와 문단 사이의 여백이 토큰 값입니다.", |
|
| 46 |
+ }, |
|
| 47 |
+ { key: "gap", label: "간격", kind: "demo", note: "두 문단 사이의 거리가 토큰 값입니다." },
|
|
| 48 |
+ { key: "radius", label: "모서리 반경", kind: "demo" },
|
|
| 49 |
+ {
|
|
| 50 |
+ key: "icon", |
|
| 51 |
+ label: "아이콘 크기", |
|
| 52 |
+ kind: "demo", |
|
| 53 |
+ note: "브라우저가 그리는 기본 체크박스를 토큰 크기로 키운 것입니다.", |
|
| 54 |
+ }, |
|
| 55 |
+ { key: "border", label: "테두리 두께", kind: "demo", note: "border-0은 값이 0이라 보이지 않습니다." },
|
|
| 56 |
+ { key: "number", label: "원시 수치", kind: "value" },
|
|
| 57 |
+ {
|
|
| 58 |
+ key: "shadow", |
|
| 59 |
+ label: "그림자 수치", |
|
| 60 |
+ kind: "demo", |
|
| 61 |
+ note: "Figma에는 offset·blur 수치만 있고 완성된 그림자 토큰이 없습니다. 각 수치의 효과를 보이려고 location은 `0 값 0`, blur는 `0 0 값`으로 조합했고 색은 alpha-default-inverse25를 씁니다 — 조합 자체는 토큰이 아닙니다.", |
|
| 62 |
+ }, |
|
| 63 |
+ {
|
|
| 64 |
+ key: "backdrop", |
|
| 65 |
+ label: "배경 흐림", |
|
| 66 |
+ kind: "demo", |
|
| 67 |
+ note: "오른쪽 절반에만 backdrop-filter를 걸어 원본과 대조합니다.", |
|
| 68 |
+ }, |
|
| 45 | 69 |
{ key: "form", label: "폼", kind: "value" },
|
| 46 | 70 |
{ key: "card", label: "카드", kind: "value" },
|
| 47 | 71 |
{ key: "modal", label: "모달", kind: "value" },
|
... | ... | @@ -56,16 +80,80 @@ |
| 56 | 80 |
* 넣는 값은 디자인 값이 아니라 **토큰 참조**(`var(--fox-*)`)이며 실제 값은 여전히 |
| 57 | 81 |
* 토큰이 소유한다. 일반 컴포넌트에서는 이 패턴을 쓰지 않는다. |
| 58 | 82 |
*/ |
| 59 |
-function ref(property: keyof CSSProperties, token: TokenEntry): CSSProperties {
|
|
| 60 |
- return { [property]: `var(${token.property})` } as CSSProperties;
|
|
| 83 |
+function v(token: TokenEntry): string {
|
|
| 84 |
+ return `var(${token.property})`;
|
|
| 61 | 85 |
} |
| 62 | 86 |
|
| 87 |
+// ── 그룹별 미리보기 ───────────────────────────────────────────────────────── |
|
| 88 |
+const DEMOS: Record<string, (token: TokenEntry) => ReactNode> = {
|
|
| 89 |
+ padding: (token) => ( |
|
| 90 |
+ <div className={styles.demoPadBox} style={{ padding: v(token) }}>
|
|
| 91 |
+ <p className={styles.demoText}>첫 번째 문단</p>
|
|
| 92 |
+ <p className={styles.demoText}>두 번째 문단</p>
|
|
| 93 |
+ </div> |
|
| 94 |
+ ), |
|
| 95 |
+ |
|
| 96 |
+ gap: (token) => ( |
|
| 97 |
+ <div className={styles.demoGapBox} style={{ gap: v(token) }}>
|
|
| 98 |
+ <p className={styles.demoText}>첫 번째 문단</p>
|
|
| 99 |
+ <p className={styles.demoText}>두 번째 문단</p>
|
|
| 100 |
+ </div> |
|
| 101 |
+ ), |
|
| 102 |
+ |
|
| 103 |
+ radius: (token) => <div className={styles.demoRadius} style={{ borderRadius: v(token) }} />,
|
|
| 104 |
+ |
|
| 105 |
+ // 브라우저가 자체적으로 그리는 컨트롤이라 별도 에셋 없이 크기만 확인할 수 있다. |
|
| 106 |
+ icon: (token) => ( |
|
| 107 |
+ <input |
|
| 108 |
+ type="checkbox" |
|
| 109 |
+ defaultChecked |
|
| 110 |
+ aria-label={`icon-${token.name} 크기 미리보기`}
|
|
| 111 |
+ className={styles.demoIcon}
|
|
| 112 |
+ style={{ inlineSize: v(token), blockSize: v(token) }}
|
|
| 113 |
+ /> |
|
| 114 |
+ ), |
|
| 115 |
+ |
|
| 116 |
+ border: (token) => <div className={styles.demoBorder} style={{ borderWidth: v(token) }} />,
|
|
| 117 |
+ |
|
| 118 |
+ shadow: (token) => ( |
|
| 119 |
+ <div |
|
| 120 |
+ className={styles.demoShadow}
|
|
| 121 |
+ style={{
|
|
| 122 |
+ boxShadow: token.name.startsWith("blur")
|
|
| 123 |
+ ? `0 0 ${v(token)} var(--demo-shadow-color)`
|
|
| 124 |
+ : `0 ${v(token)} 0 var(--demo-shadow-color)`,
|
|
| 125 |
+ }} |
|
| 126 |
+ /> |
|
| 127 |
+ ), |
|
| 128 |
+ |
|
| 129 |
+ backdrop: (token) => ( |
|
| 130 |
+ <div className={styles.demoBackdrop}>
|
|
| 131 |
+ {/* eslint-disable-next-line @next/next/no-img-element -- @fox는 프레임워크에 의존하지
|
|
| 132 |
+ 않아야 이식된다. next/image를 쓰면 이 폴더가 Next 전용이 되고 외부 호스트마다 |
|
| 133 |
+ remotePatterns 설정도 필요해진다. */} |
|
| 134 |
+ <img |
|
| 135 |
+ src="https://picsum.photos/seed/picsum/200/300" |
|
| 136 |
+ alt="" |
|
| 137 |
+ className={styles.demoBackdropImage}
|
|
| 138 |
+ /> |
|
| 139 |
+ <div |
|
| 140 |
+ className={styles.demoBackdropOverlay}
|
|
| 141 |
+ style={{
|
|
| 142 |
+ backdropFilter: `blur(${v(token)})`,
|
|
| 143 |
+ WebkitBackdropFilter: `blur(${v(token)})`,
|
|
| 144 |
+ }} |
|
| 145 |
+ /> |
|
| 146 |
+ </div> |
|
| 147 |
+ ), |
|
| 148 |
+}; |
|
| 149 |
+ |
|
| 150 |
+// ── 렌더러 ────────────────────────────────────────────────────────────────── |
|
| 63 | 151 |
function Swatches({ tokens, fn }: { tokens: TokenEntry[]; fn: string }) {
|
| 64 | 152 |
return ( |
| 65 | 153 |
<div className={styles.swatchGrid}>
|
| 66 | 154 |
{tokens.map((token) => (
|
| 67 | 155 |
<div key={token.property} className={styles.swatch}>
|
| 68 |
- <div className={styles.swatchChip} style={ref("background", token)} />
|
|
| 156 |
+ <div className={styles.swatchChip} style={{ background: v(token) }} />
|
|
| 69 | 157 |
<div className={styles.swatchMeta}>
|
| 70 | 158 |
<span className={styles.tokenName}>
|
| 71 | 159 |
fox.{fn}({token.name})
|
... | ... | @@ -78,14 +166,45 @@ |
| 78 | 166 |
); |
| 79 | 167 |
} |
| 80 | 168 |
|
| 81 |
-function ValueTable({
|
|
| 169 |
+/** 서체 토큰은 이름 앞머리가 곧 적용할 CSS 속성이다. */ |
|
| 170 |
+function typeStyle(token: TokenEntry): CSSProperties {
|
|
| 171 |
+ if (token.name.startsWith("family-")) {
|
|
| 172 |
+ return { fontFamily: v(token) };
|
|
| 173 |
+ } |
|
| 174 |
+ if (token.name.startsWith("weight-")) {
|
|
| 175 |
+ return { fontWeight: v(token) };
|
|
| 176 |
+ } |
|
| 177 |
+ return { fontSize: v(token) };
|
|
| 178 |
+} |
|
| 179 |
+ |
|
| 180 |
+function TypeList({ tokens, fn }: { tokens: TokenEntry[]; fn: string }) {
|
|
| 181 |
+ return ( |
|
| 182 |
+ <div className={styles.typeList}>
|
|
| 183 |
+ {tokens.map((token) => (
|
|
| 184 |
+ <div key={token.property} className={styles.typeRow}>
|
|
| 185 |
+ <div className={styles.typeMeta}>
|
|
| 186 |
+ <span className={styles.tokenName}>
|
|
| 187 |
+ fox.{fn}({token.name})
|
|
| 188 |
+ </span> |
|
| 189 |
+ <span className={styles.tokenValue}>{token.value}</span>
|
|
| 190 |
+ </div> |
|
| 191 |
+ <p className={styles.typeSample} style={typeStyle(token)}>
|
|
| 192 |
+ 다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123 |
|
| 193 |
+ </p> |
|
| 194 |
+ </div> |
|
| 195 |
+ ))} |
|
| 196 |
+ </div> |
|
| 197 |
+ ); |
|
| 198 |
+} |
|
| 199 |
+ |
|
| 200 |
+function TokenTable({
|
|
| 82 | 201 |
tokens, |
| 83 | 202 |
fn, |
| 84 |
- withBar, |
|
| 203 |
+ demo, |
|
| 85 | 204 |
}: {
|
| 86 | 205 |
tokens: TokenEntry[]; |
| 87 | 206 |
fn: string; |
| 88 |
- withBar: boolean; |
|
| 207 |
+ demo?: (token: TokenEntry) => ReactNode; |
|
| 89 | 208 |
}) {
|
| 90 | 209 |
return ( |
| 91 | 210 |
<table className={styles.table}>
|
... | ... | @@ -93,7 +212,7 @@ |
| 93 | 212 |
<tr> |
| 94 | 213 |
<th scope="col">토큰</th> |
| 95 | 214 |
<th scope="col">값</th> |
| 96 |
- {withBar ? <th scope="col">크기</th> : null}
|
|
| 215 |
+ {demo ? <th scope="col">미리보기</th> : null}
|
|
| 97 | 216 |
</tr> |
| 98 | 217 |
</thead> |
| 99 | 218 |
<tbody> |
... | ... | @@ -103,11 +222,7 @@ |
| 103 | 222 |
fox.{fn}({token.name})
|
| 104 | 223 |
</td> |
| 105 | 224 |
<td className={styles.tdValue}>{token.value}</td>
|
| 106 |
- {withBar ? (
|
|
| 107 |
- <td> |
|
| 108 |
- <span className={styles.bar} style={ref("inlineSize", token)} />
|
|
| 109 |
- </td> |
|
| 110 |
- ) : null} |
|
| 225 |
+ {demo ? <td className={styles.tdDemo}>{demo(token)}</td> : null}
|
|
| 111 | 226 |
</tr> |
| 112 | 227 |
))} |
| 113 | 228 |
</tbody> |
... | ... | @@ -125,11 +240,11 @@ |
| 125 | 240 |
{meta?.label ?? group} · {tokens.length}
|
| 126 | 241 |
</h2> |
| 127 | 242 |
{meta?.note ? <p className={styles.sectionNote}>{meta.note}</p> : null}
|
| 128 |
- {kind === "swatch" ? (
|
|
| 129 |
- <Swatches tokens={tokens} fn={group} />
|
|
| 130 |
- ) : ( |
|
| 131 |
- <ValueTable tokens={tokens} fn={group} withBar={kind === "bar"} />
|
|
| 132 |
- )} |
|
| 243 |
+ |
|
| 244 |
+ {kind === "swatch" ? <Swatches tokens={tokens} fn={group} /> : null}
|
|
| 245 |
+ {kind === "type" ? <TypeList tokens={tokens} fn={group} /> : null}
|
|
| 246 |
+ {kind === "demo" ? <TokenTable tokens={tokens} fn={group} demo={DEMOS[group]} /> : null}
|
|
| 247 |
+ {kind === "value" ? <TokenTable tokens={tokens} fn={group} /> : null}
|
|
| 133 | 248 |
</section> |
| 134 | 249 |
); |
| 135 | 250 |
} |
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?