refactor: 토큰 분류를 원본 JSON 파일 기준으로 재편
Figma 변수 페이지와 1:1로 대조하기 위한 감사 화면으로 바꾼다. 분류를 우리 CSS 그룹이 아니라 원본 컬렉션/모드로 두고 — Mode(Light/Dark) · Responsive(PC/Mobile) · Size · Theme · Primitive — 파일에 값이 적혀 있으면 그 값을, 다른 토큰을 가리키면 참조 토큰을 표시한다. 토큰 이름 아래에 Figma 변수 경로를 함께 적는다. CSSOM 읽기로는 이 요구를 만족할 수 없어 걷어냈다. CSS는 alias를 var()로 흡수하고 light/dark를 light-dark() 한 줄로 합치므로 "원본이 값을 적었는지 토큰을 가리켰는지"를 되살릴 수 없다. 대신 변환기가 원본 구조 그대로를 담은 매니페스트를 함께 생성한다 — 여전히 자동 생성이라 목록을 손으로 관리하지 않는다. 미리보기도 CSS 변수가 아니라 매니페스트의 해석값을 쓴다. 변수를 쓰면 PC 섹션을 좁은 창에서 볼 때 모바일 값이, Light 섹션을 다크 테마에서 볼 때 다크 색이 그려진다. ⚠️ 매니페스트를 만들며 잡은 것: 이 export에는 `theme`이라는 이름의 컬렉션이 둘이고 (제공된 파랑 계열, 미제공 청록 계열) 변수 경로까지 같아, 이름만으로 매칭하면 chart-alt 18개가 엉뚱하게 해결된 것처럼 보인다. 값 대조를 넣어 "미제공 컬렉션"으로 표시한다. SCSS 생성 쪽은 원래부터 값 대조를 하고 있어 영향이 없었다. Co-Authored-By: Claude Opus 5
@f9ee35afd0ad115a7be39882654982d21b3b97e1
--- @fox/dev-test/dev-test-page.tsx
+++ @fox/dev-test/dev-test-page.tsx
... | ... | @@ -5,13 +5,13 @@ |
| 5 | 5 |
import { COMPONENT_EXAMPLES } from "./component-registry";
|
| 6 | 6 |
import { ComponentEmpty, ComponentView } from "./component-view";
|
| 7 | 7 |
import { ThemeSwitch } from "./theme-switch";
|
| 8 |
-import { TOKEN_GROUPS, TokenView } from "./token-view";
|
|
| 9 |
-import { useTokenRegistry } from "./use-token-registry";
|
|
| 8 |
+import { TOKEN_MANIFEST } from "./token-manifest";
|
|
| 9 |
+import { TokenView } from "./token-view";
|
|
| 10 | 10 |
import styles from "./dev-test.module.scss"; |
| 11 | 11 |
|
| 12 | 12 |
// 선택 상태는 URL 해시가 SSOT다 — 새로고침하거나 링크를 공유해도 보던 섹션이 유지되고, |
| 13 | 13 |
// React state를 따로 두지 않으므로 둘이 어긋날 일이 없다. |
| 14 |
-// 형식: `#token:color` / `#component:button` |
|
| 14 |
+// 형식: `#token:light` / `#component:button` |
|
| 15 | 15 |
function subscribeHash(onChange: () => void): () => void {
|
| 16 | 16 |
window.addEventListener("hashchange", onChange);
|
| 17 | 17 |
return () => window.removeEventListener("hashchange", onChange);
|
... | ... | @@ -42,27 +42,31 @@ |
| 42 | 42 |
return COMPONENT_EXAMPLES.some((example) => example.id === id); |
| 43 | 43 |
} |
| 44 | 44 |
|
| 45 |
-export function DevTestPage() {
|
|
| 46 |
- const registry = useTokenRegistry(); |
|
| 47 |
- const selection = useSelection(); |
|
| 45 |
+/** |
|
| 46 |
+ * 사이드바 분류는 원본 JSON 파일 구조를 그대로 따른다 — Mode(Light/Dark), |
|
| 47 |
+ * Responsive(PC/Mobile), 그리고 단일 모드인 Size·Theme·Primitive. 이 화면의 목적이 |
|
| 48 |
+ * Figma 변수 페이지와 1:1로 대조하는 것이라, 우리 CSS 그룹이 아니라 원본 구획이 기준이다. |
|
| 49 |
+ */ |
|
| 50 |
+function groupedSections() {
|
|
| 51 |
+ const groups: { category: string | null; sections: typeof TOKEN_MANIFEST }[] = [];
|
|
| 48 | 52 |
|
| 49 |
- if (!registry) {
|
|
| 50 |
- return ( |
|
| 51 |
- <div className={styles.shell}>
|
|
| 52 |
- <p className={styles.loading}>토큰을 읽는 중…</p>
|
|
| 53 |
- </div> |
|
| 54 |
- ); |
|
| 53 |
+ for (const section of TOKEN_MANIFEST) {
|
|
| 54 |
+ const last = groups[groups.length - 1]; |
|
| 55 |
+ if (last && last.category === section.category && section.category !== null) {
|
|
| 56 |
+ last.sections.push(section); |
|
| 57 |
+ } else {
|
|
| 58 |
+ groups.push({ category: section.category, sections: [section] });
|
|
| 59 |
+ } |
|
| 55 | 60 |
} |
| 56 | 61 |
|
| 57 |
- // 사이드바에 뜰 토큰 그룹 — 정의된 순서를 우선하고, 목록에 없는 그룹은 뒤에 붙인다. |
|
| 58 |
- const known = TOKEN_GROUPS.filter((group) => registry[group.key]?.length); |
|
| 59 |
- const extra = Object.keys(registry) |
|
| 60 |
- .filter((key) => !TOKEN_GROUPS.some((group) => group.key === key)) |
|
| 61 |
- .map((key) => ({ key, label: key }));
|
|
| 62 |
- const tokenGroups = [...known, ...extra]; |
|
| 62 |
+ return groups; |
|
| 63 |
+} |
|
| 63 | 64 |
|
| 64 |
- const current = selection ?? { kind: "token", id: tokenGroups[0]?.key ?? "" };
|
|
| 65 |
- const totalTokens = Object.values(registry).reduce((sum, list) => sum + list.length, 0); |
|
| 65 |
+export function DevTestPage() {
|
|
| 66 |
+ const selection = useSelection(); |
|
| 67 |
+ const totalTokens = TOKEN_MANIFEST.reduce((sum, section) => sum + section.tokens.length, 0); |
|
| 68 |
+ const current = selection ?? { kind: "token", id: TOKEN_MANIFEST[0]?.id ?? "" };
|
|
| 69 |
+ const activeSection = TOKEN_MANIFEST.find((section) => section.id === current.id); |
|
| 66 | 70 |
|
| 67 | 71 |
return ( |
| 68 | 72 |
<div className={styles.shell}>
|
... | ... | @@ -77,23 +81,31 @@ |
| 77 | 81 |
<nav className={styles.nav} aria-label="섹션">
|
| 78 | 82 |
<div className={styles.navGroup}>
|
| 79 | 83 |
<h2 className={styles.navGroupTitle}>토큰 · {totalTokens}</h2>
|
| 80 |
- <ul className={styles.navList}>
|
|
| 81 |
- {tokenGroups.map((group) => (
|
|
| 82 |
- <li key={group.key}>
|
|
| 83 |
- <button |
|
| 84 |
- type="button" |
|
| 85 |
- className={styles.navItem}
|
|
| 86 |
- aria-current={
|
|
| 87 |
- current.kind === "token" && current.id === group.key ? "page" : undefined |
|
| 88 |
- } |
|
| 89 |
- onClick={() => select("token", group.key)}
|
|
| 90 |
- > |
|
| 91 |
- <span>{group.label}</span>
|
|
| 92 |
- <span className={styles.navCount}>{registry[group.key].length}</span>
|
|
| 93 |
- </button> |
|
| 94 |
- </li> |
|
| 95 |
- ))} |
|
| 96 |
- </ul> |
|
| 84 |
+ |
|
| 85 |
+ {groupedSections().map((group) => (
|
|
| 86 |
+ <div key={group.category ?? group.sections[0].id} className={styles.navSubGroup}>
|
|
| 87 |
+ {group.category ? (
|
|
| 88 |
+ <h3 className={styles.navSubTitle}>{group.category}</h3>
|
|
| 89 |
+ ) : null} |
|
| 90 |
+ <ul className={styles.navList}>
|
|
| 91 |
+ {group.sections.map((section) => (
|
|
| 92 |
+ <li key={section.id}>
|
|
| 93 |
+ <button |
|
| 94 |
+ type="button" |
|
| 95 |
+ className={styles.navItem}
|
|
| 96 |
+ aria-current={
|
|
| 97 |
+ current.kind === "token" && current.id === section.id ? "page" : undefined |
|
| 98 |
+ } |
|
| 99 |
+ onClick={() => select("token", section.id)}
|
|
| 100 |
+ > |
|
| 101 |
+ <span>{section.label}</span>
|
|
| 102 |
+ <span className={styles.navCount}>{section.tokens.length}</span>
|
|
| 103 |
+ </button> |
|
| 104 |
+ </li> |
|
| 105 |
+ ))} |
|
| 106 |
+ </ul> |
|
| 107 |
+ </div> |
|
| 108 |
+ ))} |
|
| 97 | 109 |
</div> |
| 98 | 110 |
|
| 99 | 111 |
<div className={styles.navGroup}>
|
... | ... | @@ -145,10 +157,10 @@ |
| 145 | 157 |
const example = COMPONENT_EXAMPLES.find((entry) => entry.id === current.id); |
| 146 | 158 |
return example ? <ComponentView example={example} /> : <ComponentEmpty />;
|
| 147 | 159 |
})() |
| 148 |
- ) : registry[current.id]?.length ? ( |
|
| 149 |
- <TokenView group={current.id} tokens={registry[current.id]} />
|
|
| 160 |
+ ) : activeSection ? ( |
|
| 161 |
+ <TokenView section={activeSection} />
|
|
| 150 | 162 |
) : ( |
| 151 |
- <p className={styles.loading}>선택한 토큰 그룹이 없습니다.</p>
|
|
| 163 |
+ <p className={styles.loading}>선택한 토큰 섹션이 없습니다.</p>
|
|
| 152 | 164 |
)} |
| 153 | 165 |
</main> |
| 154 | 166 |
</div> |
--- @fox/dev-test/dev-test.module.scss
+++ @fox/dev-test/dev-test.module.scss
... | ... | @@ -107,6 +107,22 @@ |
| 107 | 107 |
letter-spacing: 0.04em; |
| 108 | 108 |
} |
| 109 | 109 |
|
| 110 |
+// Mode / Responsive 처럼 하위 항목을 묶는 분류. |
|
| 111 |
+.navSubGroup {
|
|
| 112 |
+ display: flex; |
|
| 113 |
+ flex-direction: column; |
|
| 114 |
+ gap: fox.gap(1); |
|
| 115 |
+ margin-block-start: fox.gap(2); |
|
| 116 |
+} |
|
| 117 |
+ |
|
| 118 |
+.navSubTitle {
|
|
| 119 |
+ padding-inline: fox.padding(3); |
|
| 120 |
+ color: fox.color(font-neutral-subtler); |
|
| 121 |
+ font-size: fox.font-size(label-xsm); |
|
| 122 |
+ font-weight: fox.font-weight(medium); |
|
| 123 |
+ opacity: 0.8; |
|
| 124 |
+} |
|
| 125 |
+ |
|
| 110 | 126 |
.navList {
|
| 111 | 127 |
display: flex; |
| 112 | 128 |
flex-direction: column; |
... | ... | @@ -215,27 +231,18 @@ |
| 215 | 231 |
font-size: fox.font-size(body-sm); |
| 216 | 232 |
} |
| 217 | 233 |
|
| 218 |
-// ── 색상 스와치 ───────────────────────────────────────────────────────────── |
|
| 219 |
-.swatchGrid {
|
|
| 220 |
- display: grid; |
|
| 221 |
- grid-template-columns: repeat(auto-fill, minmax(22rem, 1fr)); |
|
| 222 |
- gap: fox.gap(4); |
|
| 223 |
-} |
|
| 224 |
- |
|
| 225 |
-.swatch {
|
|
| 226 |
- overflow: hidden; |
|
| 227 |
- border: fox.border(1) solid fox.color(border-neutral-subtle); |
|
| 228 |
- border-radius: fox.radius(3); |
|
| 229 |
- background: fox.color(surface-neutral-default); |
|
| 230 |
-} |
|
| 231 |
- |
|
| 232 |
-.swatchChip {
|
|
| 233 |
- block-size: fox.icon(7); |
|
| 234 |
- border-block-end: fox.border(1) solid fox.color(border-neutral-overlay); |
|
| 235 |
-} |
|
| 236 |
- |
|
| 237 |
-.swatchMeta {
|
|
| 238 |
- padding: fox.padding(5); |
|
| 234 |
+// ── 색상 칩 ───────────────────────────────────────────────────────────────── |
|
| 235 |
+// 값 앞에 붙는 5px 정사각형. 토큰 스케일에 5px(0.5rem)이 없어 리터럴을 쓴다 — |
|
| 236 |
+// 카탈로그 표시용 치수이지 디자인 값이 아니다. |
|
| 237 |
+// 테두리는 box-shadow로 그린다. border를 쓰면 5px 중 2px을 테두리가 먹어 |
|
| 238 |
+// 색이 보이는 면적이 3px로 줄어든다. |
|
| 239 |
+.colorChip {
|
|
| 240 |
+ display: inline-block; |
|
| 241 |
+ inline-size: 0.5rem; |
|
| 242 |
+ block-size: 0.5rem; |
|
| 243 |
+ margin-inline-end: fox.gap(3); |
|
| 244 |
+ box-shadow: 0 0 0 fox.border(1) #{fox.color(border-neutral-overlay)};
|
|
| 245 |
+ vertical-align: middle; |
|
| 239 | 246 |
} |
| 240 | 247 |
|
| 241 | 248 |
.tokenName {
|
... | ... | @@ -246,12 +253,30 @@ |
| 246 | 253 |
word-break: break-all; |
| 247 | 254 |
} |
| 248 | 255 |
|
| 249 |
-.tokenValue {
|
|
| 256 |
+// Figma 변수 경로 — 변수 페이지와 눈으로 맞추기 위한 보조 표기. |
|
| 257 |
+.tokenFigma {
|
|
| 250 | 258 |
display: block; |
| 251 | 259 |
margin-block-start: fox.gap(1); |
| 252 | 260 |
color: fox.color(font-neutral-subtler); |
| 253 | 261 |
font-size: fox.font-size(body-xsm); |
| 254 | 262 |
word-break: break-all; |
| 263 |
+} |
|
| 264 |
+ |
|
| 265 |
+// 다른 토큰을 가리키는 값. 실제 값과 구분되어야 "이게 참조구나"가 한눈에 보인다. |
|
| 266 |
+.aliasRef {
|
|
| 267 |
+ color: fox.color(font-theme-primary); |
|
| 268 |
+} |
|
| 269 |
+ |
|
| 270 |
+// 제공되지 않은 컬렉션을 가리키는 참조 — 감사에서 걸러내야 할 항목이다. |
|
| 271 |
+.aliasForeign {
|
|
| 272 |
+ color: fox.color(font-system-warning); |
|
| 273 |
+} |
|
| 274 |
+ |
|
| 275 |
+// 서체 토큰 미리보기 문장. |
|
| 276 |
+.demoType {
|
|
| 277 |
+ margin: 0; |
|
| 278 |
+ color: fox.color(font-neutral-default); |
|
| 279 |
+ white-space: nowrap; |
|
| 255 | 280 |
} |
| 256 | 281 |
|
| 257 | 282 |
// ── 값 테이블 ─────────────────────────────────────────────────────────────── |
... | ... | @@ -286,35 +311,6 @@ |
| 286 | 311 |
.tdDemo {
|
| 287 | 312 |
inline-size: 1%; |
| 288 | 313 |
white-space: nowrap; |
| 289 |
-} |
|
| 290 |
- |
|
| 291 |
-// ── 서체 미리보기 ─────────────────────────────────────────────────────────── |
|
| 292 |
-.typeList {
|
|
| 293 |
- display: flex; |
|
| 294 |
- flex-direction: column; |
|
| 295 |
-} |
|
| 296 |
- |
|
| 297 |
-.typeRow {
|
|
| 298 |
- display: flex; |
|
| 299 |
- flex-wrap: wrap; |
|
| 300 |
- gap: fox.gap(2) fox.gap(7); |
|
| 301 |
- align-items: baseline; |
|
| 302 |
- padding-block: fox.padding(5); |
|
| 303 |
- border-block-end: fox.border(1) solid fox.color(border-neutral-subtle); |
|
| 304 |
-} |
|
| 305 |
- |
|
| 306 |
-.typeMeta {
|
|
| 307 |
- display: flex; |
|
| 308 |
- flex: 0 0 22rem; |
|
| 309 |
- flex-direction: column; |
|
| 310 |
- gap: fox.gap(1); |
|
| 311 |
-} |
|
| 312 |
- |
|
| 313 |
-.typeSample {
|
|
| 314 |
- min-inline-size: 20rem; |
|
| 315 |
- flex: 1; |
|
| 316 |
- margin: 0; |
|
| 317 |
- color: fox.color(font-neutral-default); |
|
| 318 | 314 |
} |
| 319 | 315 |
|
| 320 | 316 |
// ── 패딩 · 간격 미리보기 ──────────────────────────────────────────────────── |
--- @fox/dev-test/read-tokens.ts
... | ... | @@ -1,74 +0,0 @@ |
| 1 | -export interface TokenEntry { | |
| 2 | - /** 그룹을 뗀 토큰 이름 (`--fox-color-background-default` → `background-default`) */ | |
| 3 | - name: string; | |
| 4 | - /** | |
| 5 | - * 현재 테마·뷰포트에서 실제로 해석된 값 (`#FFFFFF`, `1.6rem`). | |
| 6 | - * | |
| 7 | - * 선언 원문이 아니라 계산값을 쓴다 — 색상은 Lightning CSS가 `light-dark()`를 | |
| 8 | - * `var(--lightningcss-light, …) var(--lightningcss-dark, …)`로 폴리필해 내보내므로 | |
| 9 | - * 원문이 읽기 어렵고, 크기는 alias가 `var(--fox-…)` 참조라 원문만으로는 값을 알 수 | |
| 10 | - * 없다. 계산값은 지금 화면에 실제로 적용되는 값과 정확히 일치한다. | |
| 11 | - */ | |
| 12 | - value: string; | |
| 13 | - /** 스타일에서 참조할 커스텀 프로퍼티 전체 이름 */ | |
| 14 | - property: string; | |
| 15 | -} | |
| 16 | - | |
| 17 | -export type TokenRegistry = Record<string, TokenEntry[]>; | |
| 18 | - | |
| 19 | -const PREFIX = "--fox-"; | |
| 20 | - | |
| 21 | -/** | |
| 22 | - * 문서의 모든 스타일시트를 훑어 `--fox-*` 커스텀 프로퍼티를 그룹별로 모은다. | |
| 23 | - * | |
| 24 | - * 테스트 페이지가 토큰 목록을 따로 들고 있지 않게 하려는 것이 목적이다 — `tokens/`에 | |
| 25 | - * 값을 추가하면 여기에 자동으로 나타나고, 목록이 두 곳에 존재해 어긋날 일이 없다. | |
| 26 | - * CSSOM은 선언 순서를 보존하므로 SCSS map에 적은 순서가 그대로 유지된다. | |
| 27 | - * | |
| 28 | - * 브라우저에서만 동작한다(문서가 있어야 한다). | |
| 29 | - */ | |
| 30 | -export function readTokenRegistry(): TokenRegistry { | |
| 31 | - const registry: TokenRegistry = {}; | |
| 32 | - const seen = new Set<string>(); | |
| 33 | - // 이름·순서는 CSSOM 규칙에서, 값은 계산 스타일에서 가져온다. | |
| 34 | - const computed = getComputedStyle(document.documentElement); | |
| 35 | - | |
| 36 | - for (const sheet of Array.from(document.styleSheets)) { | |
| 37 | - let rules: CSSRuleList; | |
| 38 | - try { | |
| 39 | - rules = sheet.cssRules; | |
| 40 | - } catch { | |
| 41 | - // 교차 출처 스타일시트는 규칙을 읽을 수 없다 — 우리 토큰은 동일 출처이므로 건너뛴다. | |
| 42 | - continue; | |
| 43 | - } | |
| 44 | - | |
| 45 | - for (const rule of Array.from(rules)) { | |
| 46 | - if (!(rule instanceof CSSStyleRule) || rule.selectorText !== ":root") { | |
| 47 | - continue; | |
| 48 | - } | |
| 49 | - | |
| 50 | - for (const property of Array.from(rule.style)) { | |
| 51 | - if (!property.startsWith(PREFIX) || seen.has(property)) { | |
| 52 | - continue; | |
| 53 | - } | |
| 54 | - seen.add(property); | |
| 55 | - | |
| 56 | - const rest = property.slice(PREFIX.length); | |
| 57 | - const separator = rest.indexOf("-"); | |
| 58 | - if (separator <= 0) { | |
| 59 | - continue; | |
| 60 | - } | |
| 61 | - | |
| 62 | - (registry[rest.slice(0, separator)] ??= []).push({ | |
| 63 | - name: rest.slice(separator + 1), | |
| 64 | - value: | |
| 65 | - computed.getPropertyValue(property).trim() || | |
| 66 | - rule.style.getPropertyValue(property).trim(), | |
| 67 | - property, | |
| 68 | - }); | |
| 69 | - } | |
| 70 | - } | |
| 71 | - } | |
| 72 | - | |
| 73 | - return registry; | |
| 74 | -} |
--- @fox/dev-test/theme-switch.tsx
+++ @fox/dev-test/theme-switch.tsx
... | ... | @@ -1,9 +1,11 @@ |
| 1 | 1 |
"use client"; |
| 2 | 2 |
|
| 3 | 3 |
import { useSyncExternalStore } from "react";
|
| 4 |
-import { THEME_ATTRIBUTE } from "./use-token-registry";
|
|
| 5 | 4 |
import styles from "./dev-test.module.scss"; |
| 6 | 5 |
|
| 6 |
+/** `@fox/styles/_root.scss`가 읽는 수동 테마 선택 속성. */ |
|
| 7 |
+const THEME_ATTRIBUTE = "data-theme"; |
|
| 8 |
+ |
|
| 7 | 9 |
type ThemeChoice = "system" | "light" | "dark"; |
| 8 | 10 |
|
| 9 | 11 |
const CHOICES: { value: ThemeChoice; label: string }[] = [
|
+++ @fox/dev-test/token-manifest.ts
| This file is too big to display. |
--- @fox/dev-test/token-view.tsx
+++ @fox/dev-test/token-view.tsx
... | ... | @@ -1,251 +1,181 @@ |
| 1 | 1 |
"use client"; |
| 2 | 2 |
|
| 3 | 3 |
import type { CSSProperties, ReactNode } from "react";
|
| 4 |
-import type { TokenEntry } from "./read-tokens";
|
|
| 4 |
+import type { ManifestSection, ManifestToken } from "./token-manifest";
|
|
| 5 | 5 |
import styles from "./dev-test.module.scss"; |
| 6 | 6 |
|
| 7 | 7 |
/** |
| 8 |
- * 토큰 **목록**은 CSSOM에서 읽으므로 여기에 적지 않는다. 이 표는 각 그룹을 어떻게 |
|
| 9 |
- * 보여줄지와 한글 이름만 정한다. 목록에 없는 그룹이 새로 생겨도 값 표로 그려진다. |
|
| 8 |
+ * 미리보기 — 토큰 이름으로 무엇을 보여줄지 고른다. |
|
| 10 | 9 |
* |
| 11 |
- * - `swatch` 색을 칠한 카드 |
|
| 12 |
- * - `type` 그 서체 속성을 실제로 적용한 문장 |
|
| 13 |
- * - `demo` 그 토큰을 실제로 적용한 요소 (그룹별 렌더는 `DEMOS`) |
|
| 14 |
- * - `value` 값만 |
|
| 10 |
+ * ⚠️ 값은 CSS 변수가 아니라 매니페스트의 **해석값**을 직접 넣는다. 변수를 쓰면 PC 섹션을 |
|
| 11 |
+ * 좁은 창에서 볼 때 모바일 값이 그려지고, Light 섹션이 다크 테마에서 다크 색으로 그려진다 — |
|
| 12 |
+ * 이 화면은 "그 파일이 정의한 값"을 보여야 하므로 현재 테마·뷰포트에 흔들리면 안 된다. |
|
| 13 |
+ * 인라인 `style`을 쓰는 예외인 이유도 같다: 렌더할 토큰을 미리 알 수 없다. |
|
| 15 | 14 |
*/ |
| 16 |
-export const TOKEN_GROUPS: {
|
|
| 17 |
- key: string; |
|
| 18 |
- label: string; |
|
| 19 |
- kind: "swatch" | "type" | "demo" | "value"; |
|
| 20 |
- note?: string; |
|
| 21 |
-}[] = [ |
|
| 15 |
+const PREVIEWS: { match: RegExp; render: (value: string) => ReactNode }[] = [
|
|
| 22 | 16 |
{
|
| 23 |
- key: "color", |
|
| 24 |
- label: "시맨틱 색상", |
|
| 25 |
- kind: "swatch", |
|
| 26 |
- note: "light/dark 값이 light-dark() 한 줄에 함께 정의돼 있습니다. 좌측 전환기로 두 테마를 대조해 보세요.", |
|
| 27 |
- }, |
|
| 28 |
- { key: "theme", label: "브랜드 램프", kind: "swatch", note: "primary · secondary · accent" },
|
|
| 29 |
- {
|
|
| 30 |
- key: "primitive", |
|
| 31 |
- label: "원시 팔레트", |
|
| 32 |
- kind: "swatch", |
|
| 33 |
- note: "화면에서 직접 쓰지 않는 바닥층입니다. 시맨틱 색상이 이 값들을 참조합니다.", |
|
| 17 |
+ match: /(^|-)padding(-|$)/, |
|
| 18 |
+ render: (value) => ( |
|
| 19 |
+ <div className={styles.demoPadBox} style={{ padding: value }}>
|
|
| 20 |
+ <p className={styles.demoText}>첫 번째 문단</p>
|
|
| 21 |
+ <p className={styles.demoText}>두 번째 문단</p>
|
|
| 22 |
+ </div> |
|
| 23 |
+ ), |
|
| 34 | 24 |
}, |
| 35 | 25 |
{
|
| 36 |
- key: "font", |
|
| 37 |
- label: "서체", |
|
| 38 |
- kind: "type", |
|
| 39 |
- note: "각 토큰을 실제로 적용한 문장입니다. size 계열은 768px 경계에서 값이 바뀝니다.", |
|
| 26 |
+ match: /(^|-)(gap|gutter)(-|$)|^spacing-/, |
|
| 27 |
+ render: (value) => ( |
|
| 28 |
+ <div className={styles.demoGapBox} style={{ gap: value }}>
|
|
| 29 |
+ <p className={styles.demoText}>첫 번째 문단</p>
|
|
| 30 |
+ <p className={styles.demoText}>두 번째 문단</p>
|
|
| 31 |
+ </div> |
|
| 32 |
+ ), |
|
| 40 | 33 |
}, |
| 41 | 34 |
{
|
| 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를 씁니다 — 조합 자체는 토큰이 아닙니다.", |
|
| 35 |
+ match: /(^|-)radius(-|$)/, |
|
| 36 |
+ render: (value) => <div className={styles.demoRadius} style={{ borderRadius: value }} />,
|
|
| 62 | 37 |
}, |
| 63 | 38 |
{
|
| 64 |
- key: "backdrop", |
|
| 65 |
- label: "배경 흐림", |
|
| 66 |
- kind: "demo", |
|
| 67 |
- note: "오른쪽 절반에만 backdrop-filter를 걸어 원본과 대조합니다.", |
|
| 39 |
+ // 브라우저가 자체적으로 그리는 컨트롤이라 별도 에셋 없이 크기만 확인할 수 있다. |
|
| 40 |
+ match: /(^|-)icon(-|$)/, |
|
| 41 |
+ render: (value) => ( |
|
| 42 |
+ <input |
|
| 43 |
+ type="checkbox" |
|
| 44 |
+ defaultChecked |
|
| 45 |
+ aria-label="크기 미리보기" |
|
| 46 |
+ className={styles.demoIcon}
|
|
| 47 |
+ style={{ inlineSize: value, blockSize: value }}
|
|
| 48 |
+ /> |
|
| 49 |
+ ), |
|
| 68 | 50 |
}, |
| 69 |
- { key: "form", label: "폼", kind: "value" },
|
|
| 70 |
- { key: "card", label: "카드", kind: "value" },
|
|
| 71 |
- { key: "modal", label: "모달", kind: "value" },
|
|
| 72 |
- { key: "grid", label: "그리드", kind: "value" },
|
|
| 73 |
- { key: "spacing", label: "여백", kind: "value" },
|
|
| 74 |
- { key: "section", label: "섹션", kind: "value" },
|
|
| 51 |
+ {
|
|
| 52 |
+ match: /^border-/, |
|
| 53 |
+ render: (value) => <div className={styles.demoBorder} style={{ borderWidth: value }} />,
|
|
| 54 |
+ }, |
|
| 55 |
+ {
|
|
| 56 |
+ match: /^shadow-/, |
|
| 57 |
+ render: (value) => ( |
|
| 58 |
+ <div |
|
| 59 |
+ className={styles.demoShadow}
|
|
| 60 |
+ style={{ boxShadow: `0 0 ${value} var(--demo-shadow-color)` }}
|
|
| 61 |
+ /> |
|
| 62 |
+ ), |
|
| 63 |
+ }, |
|
| 64 |
+ {
|
|
| 65 |
+ match: /^backdrop-/, |
|
| 66 |
+ render: (value) => ( |
|
| 67 |
+ <div className={styles.demoBackdrop}>
|
|
| 68 |
+ {/* eslint-disable-next-line @next/next/no-img-element -- @fox는 프레임워크에 의존하지
|
|
| 69 |
+ 않아야 이식된다. next/image를 쓰면 이 폴더가 Next 전용이 되고 외부 호스트마다 |
|
| 70 |
+ remotePatterns 설정도 필요해진다. */} |
|
| 71 |
+ <img |
|
| 72 |
+ src="https://picsum.photos/seed/picsum/200/300" |
|
| 73 |
+ alt="" |
|
| 74 |
+ className={styles.demoBackdropImage}
|
|
| 75 |
+ /> |
|
| 76 |
+ <div |
|
| 77 |
+ className={styles.demoBackdropOverlay}
|
|
| 78 |
+ style={{ backdropFilter: `blur(${value})`, WebkitBackdropFilter: `blur(${value})` }}
|
|
| 79 |
+ /> |
|
| 80 |
+ </div> |
|
| 81 |
+ ), |
|
| 82 |
+ }, |
|
| 83 |
+ {
|
|
| 84 |
+ match: /^font-size-/, |
|
| 85 |
+ render: (value) => ( |
|
| 86 |
+ <p className={styles.demoType} style={{ fontSize: value }}>
|
|
| 87 |
+ 다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123 |
|
| 88 |
+ </p> |
|
| 89 |
+ ), |
|
| 90 |
+ }, |
|
| 91 |
+ {
|
|
| 92 |
+ match: /^font-family-/, |
|
| 93 |
+ render: (value) => ( |
|
| 94 |
+ <p className={styles.demoType} style={{ fontFamily: value }}>
|
|
| 95 |
+ 다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123 |
|
| 96 |
+ </p> |
|
| 97 |
+ ), |
|
| 98 |
+ }, |
|
| 99 |
+ {
|
|
| 100 |
+ match: /^font-weight-/, |
|
| 101 |
+ render: (value) => ( |
|
| 102 |
+ <p className={styles.demoType} style={{ fontWeight: value }}>
|
|
| 103 |
+ 다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123 |
|
| 104 |
+ </p> |
|
| 105 |
+ ), |
|
| 106 |
+ }, |
|
| 75 | 107 |
]; |
| 76 | 108 |
|
| 77 |
-/** |
|
| 78 |
- * ⚠️ 인라인 `style`을 쓰는 **유일하게 허용되는 예외**다. 개수를 미리 알 수 없는 토큰을 |
|
| 79 |
- * 런타임에 순회해야 하는데, CSS 클래스는 알 수 없는 이름에 대해 만들 수 없다. 여기서 |
|
| 80 |
- * 넣는 값은 디자인 값이 아니라 **토큰 참조**(`var(--fox-*)`)이며 실제 값은 여전히 |
|
| 81 |
- * 토큰이 소유한다. 일반 컴포넌트에서는 이 패턴을 쓰지 않는다. |
|
| 82 |
- */ |
|
| 83 |
-function v(token: TokenEntry): string {
|
|
| 84 |
- return `var(${token.property})`;
|
|
| 85 |
-} |
|
| 86 |
- |
|
| 87 |
-/** |
|
| 88 |
- * 화면에 표시할 토큰 식별자. `--fox-` 접두사만 뗀 **실제 이름**이라 CSS 변수 |
|
| 89 |
- * (`--fox-color-background-default`)와 그대로 대응한다 — 함수 표기(`fox.color(...)`)는 |
|
| 90 |
- * 그룹명이 괄호 밖으로 빠져 목록을 훑을 때 이름이 한눈에 안 들어온다. |
|
| 91 |
- */ |
|
| 92 |
-function label(token: TokenEntry): string {
|
|
| 93 |
- return token.property.replace("--fox-", "");
|
|
| 94 |
-} |
|
| 95 |
- |
|
| 96 |
-// ── 그룹별 미리보기 ───────────────────────────────────────────────────────── |
|
| 97 |
-const DEMOS: Record<string, (token: TokenEntry) => ReactNode> = {
|
|
| 98 |
- padding: (token) => ( |
|
| 99 |
- <div className={styles.demoPadBox} style={{ padding: v(token) }}>
|
|
| 100 |
- <p className={styles.demoText}>첫 번째 문단</p>
|
|
| 101 |
- <p className={styles.demoText}>두 번째 문단</p>
|
|
| 102 |
- </div> |
|
| 103 |
- ), |
|
| 104 |
- |
|
| 105 |
- gap: (token) => ( |
|
| 106 |
- <div className={styles.demoGapBox} style={{ gap: v(token) }}>
|
|
| 107 |
- <p className={styles.demoText}>첫 번째 문단</p>
|
|
| 108 |
- <p className={styles.demoText}>두 번째 문단</p>
|
|
| 109 |
- </div> |
|
| 110 |
- ), |
|
| 111 |
- |
|
| 112 |
- radius: (token) => <div className={styles.demoRadius} style={{ borderRadius: v(token) }} />,
|
|
| 113 |
- |
|
| 114 |
- // 브라우저가 자체적으로 그리는 컨트롤이라 별도 에셋 없이 크기만 확인할 수 있다. |
|
| 115 |
- icon: (token) => ( |
|
| 116 |
- <input |
|
| 117 |
- type="checkbox" |
|
| 118 |
- defaultChecked |
|
| 119 |
- aria-label={`icon-${token.name} 크기 미리보기`}
|
|
| 120 |
- className={styles.demoIcon}
|
|
| 121 |
- style={{ inlineSize: v(token), blockSize: v(token) }}
|
|
| 122 |
- /> |
|
| 123 |
- ), |
|
| 124 |
- |
|
| 125 |
- border: (token) => <div className={styles.demoBorder} style={{ borderWidth: v(token) }} />,
|
|
| 126 |
- |
|
| 127 |
- shadow: (token) => ( |
|
| 128 |
- <div |
|
| 129 |
- className={styles.demoShadow}
|
|
| 130 |
- style={{
|
|
| 131 |
- boxShadow: token.name.startsWith("blur")
|
|
| 132 |
- ? `0 0 ${v(token)} var(--demo-shadow-color)`
|
|
| 133 |
- : `0 ${v(token)} 0 var(--demo-shadow-color)`,
|
|
| 134 |
- }} |
|
| 135 |
- /> |
|
| 136 |
- ), |
|
| 137 |
- |
|
| 138 |
- backdrop: (token) => ( |
|
| 139 |
- <div className={styles.demoBackdrop}>
|
|
| 140 |
- {/* eslint-disable-next-line @next/next/no-img-element -- @fox는 프레임워크에 의존하지
|
|
| 141 |
- 않아야 이식된다. next/image를 쓰면 이 폴더가 Next 전용이 되고 외부 호스트마다 |
|
| 142 |
- remotePatterns 설정도 필요해진다. */} |
|
| 143 |
- <img |
|
| 144 |
- src="https://picsum.photos/seed/picsum/200/300" |
|
| 145 |
- alt="" |
|
| 146 |
- className={styles.demoBackdropImage}
|
|
| 147 |
- /> |
|
| 148 |
- <div |
|
| 149 |
- className={styles.demoBackdropOverlay}
|
|
| 150 |
- style={{
|
|
| 151 |
- backdropFilter: `blur(${v(token)})`,
|
|
| 152 |
- WebkitBackdropFilter: `blur(${v(token)})`,
|
|
| 153 |
- }} |
|
| 154 |
- /> |
|
| 155 |
- </div> |
|
| 156 |
- ), |
|
| 157 |
-}; |
|
| 158 |
- |
|
| 159 |
-// ── 렌더러 ────────────────────────────────────────────────────────────────── |
|
| 160 |
-function Swatches({ tokens }: { tokens: TokenEntry[] }) {
|
|
| 161 |
- return ( |
|
| 162 |
- <div className={styles.swatchGrid}>
|
|
| 163 |
- {tokens.map((token) => (
|
|
| 164 |
- <div key={token.property} className={styles.swatch}>
|
|
| 165 |
- <div className={styles.swatchChip} style={{ background: v(token) }} />
|
|
| 166 |
- <div className={styles.swatchMeta}>
|
|
| 167 |
- <span className={styles.tokenName}>{label(token)}</span>
|
|
| 168 |
- <span className={styles.tokenValue}>{token.value}</span>
|
|
| 169 |
- </div> |
|
| 170 |
- </div> |
|
| 171 |
- ))} |
|
| 172 |
- </div> |
|
| 173 |
- ); |
|
| 174 |
-} |
|
| 175 |
- |
|
| 176 |
-/** 서체 토큰은 이름 앞머리가 곧 적용할 CSS 속성이다. */ |
|
| 177 |
-function typeStyle(token: TokenEntry): CSSProperties {
|
|
| 178 |
- if (token.name.startsWith("family-")) {
|
|
| 179 |
- return { fontFamily: v(token) };
|
|
| 109 |
+function previewFor(token: ManifestToken): ReactNode {
|
|
| 110 |
+ if (token.type === "color") {
|
|
| 111 |
+ // 색은 값 칸의 칩이 이미 보여준다. |
|
| 112 |
+ return null; |
|
| 180 | 113 |
} |
| 181 |
- if (token.name.startsWith("weight-")) {
|
|
| 182 |
- return { fontWeight: v(token) };
|
|
| 183 |
- } |
|
| 184 |
- return { fontSize: v(token) };
|
|
| 114 |
+ const hit = PREVIEWS.find((entry) => entry.match.test(token.name)); |
|
| 115 |
+ return hit ? hit.render(token.resolved) : null; |
|
| 185 | 116 |
} |
| 186 | 117 |
|
| 187 |
-function TypeList({ tokens }: { tokens: TokenEntry[] }) {
|
|
| 188 |
- return ( |
|
| 189 |
- <div className={styles.typeList}>
|
|
| 190 |
- {tokens.map((token) => (
|
|
| 191 |
- <div key={token.property} className={styles.typeRow}>
|
|
| 192 |
- <div className={styles.typeMeta}>
|
|
| 193 |
- <span className={styles.tokenName}>{label(token)}</span>
|
|
| 194 |
- <span className={styles.tokenValue}>{token.value}</span>
|
|
| 195 |
- </div> |
|
| 196 |
- <p className={styles.typeSample} style={typeStyle(token)}>
|
|
| 197 |
- 다람쥐 헌 쳇바퀴에 타고파 · Handgloves 0123 |
|
| 198 |
- </p> |
|
| 199 |
- </div> |
|
| 200 |
- ))} |
|
| 201 |
- </div> |
|
| 202 |
- ); |
|
| 118 |
+/** 참조 대상이 우리가 갖고 있지 않은 컬렉션일 때 생성기가 붙이는 표시. */ |
|
| 119 |
+function isForeign(alias: string | null): boolean {
|
|
| 120 |
+ return Boolean(alias?.includes("미제공"));
|
|
| 203 | 121 |
} |
| 204 | 122 |
|
| 205 |
-function TokenTable({
|
|
| 206 |
- tokens, |
|
| 207 |
- demo, |
|
| 208 |
-}: {
|
|
| 209 |
- tokens: TokenEntry[]; |
|
| 210 |
- demo?: (token: TokenEntry) => ReactNode; |
|
| 211 |
-}) {
|
|
| 212 |
- return ( |
|
| 213 |
- <table className={styles.table}>
|
|
| 214 |
- <thead> |
|
| 215 |
- <tr> |
|
| 216 |
- <th scope="col">토큰</th> |
|
| 217 |
- <th scope="col">값</th> |
|
| 218 |
- {demo ? <th scope="col">미리보기</th> : null}
|
|
| 219 |
- </tr> |
|
| 220 |
- </thead> |
|
| 221 |
- <tbody> |
|
| 222 |
- {tokens.map((token) => (
|
|
| 223 |
- <tr key={token.property}>
|
|
| 224 |
- <td>{label(token)}</td>
|
|
| 225 |
- <td className={styles.tdValue}>{token.value}</td>
|
|
| 226 |
- {demo ? <td className={styles.tdDemo}>{demo(token)}</td> : null}
|
|
| 227 |
- </tr> |
|
| 228 |
- ))} |
|
| 229 |
- </tbody> |
|
| 230 |
- </table> |
|
| 231 |
- ); |
|
| 123 |
+function chipStyle(token: ManifestToken): CSSProperties {
|
|
| 124 |
+ return { background: token.resolved };
|
|
| 232 | 125 |
} |
| 233 | 126 |
|
| 234 |
-export function TokenView({ group, tokens }: { group: string; tokens: TokenEntry[] }) {
|
|
| 235 |
- const meta = TOKEN_GROUPS.find((entry) => entry.key === group); |
|
| 236 |
- const kind = meta?.kind ?? "value"; |
|
| 127 |
+export function TokenView({ section }: { section: ManifestSection }) {
|
|
| 128 |
+ const hasPreview = section.tokens.some((token) => previewFor(token) !== null); |
|
| 129 |
+ const foreignCount = section.tokens.filter((token) => isForeign(token.alias)).length; |
|
| 237 | 130 |
|
| 238 | 131 |
return ( |
| 239 | 132 |
<section className={styles.section}>
|
| 240 | 133 |
<h2 className={styles.sectionTitle}>
|
| 241 |
- {meta?.label ?? group} · {tokens.length}
|
|
| 134 |
+ {section.label} · {section.tokens.length}
|
|
| 242 | 135 |
</h2> |
| 243 |
- {meta?.note ? <p className={styles.sectionNote}>{meta.note}</p> : null}
|
|
| 136 |
+ <p className={styles.sectionNote}>
|
|
| 137 |
+ {section.file} — 파일에 값이 적혀 있으면 그 값을, 다른 토큰을 가리키면 참조 토큰을
|
|
| 138 |
+ 표시합니다. |
|
| 139 |
+ {foreignCount > 0
|
|
| 140 |
+ ? ` 이 중 ${foreignCount}개는 제공되지 않은 컬렉션을 가리킵니다.`
|
|
| 141 |
+ : ""} |
|
| 142 |
+ </p> |
|
| 244 | 143 |
|
| 245 |
- {kind === "swatch" ? <Swatches tokens={tokens} /> : null}
|
|
| 246 |
- {kind === "type" ? <TypeList tokens={tokens} /> : null}
|
|
| 247 |
- {kind === "demo" ? <TokenTable tokens={tokens} demo={DEMOS[group]} /> : null}
|
|
| 248 |
- {kind === "value" ? <TokenTable tokens={tokens} /> : null}
|
|
| 144 |
+ <table className={styles.table}>
|
|
| 145 |
+ <thead> |
|
| 146 |
+ <tr> |
|
| 147 |
+ <th scope="col">토큰</th> |
|
| 148 |
+ <th scope="col">값 / 참조</th> |
|
| 149 |
+ {hasPreview ? <th scope="col">미리보기</th> : null}
|
|
| 150 |
+ </tr> |
|
| 151 |
+ </thead> |
|
| 152 |
+ <tbody> |
|
| 153 |
+ {section.tokens.map((token) => (
|
|
| 154 |
+ <tr key={token.name}>
|
|
| 155 |
+ <td> |
|
| 156 |
+ <span className={styles.tokenName}>{token.name}</span>
|
|
| 157 |
+ <span className={styles.tokenFigma}>{token.figma}</span>
|
|
| 158 |
+ </td> |
|
| 159 |
+ <td className={styles.tdValue}>
|
|
| 160 |
+ {token.type === "color" ? (
|
|
| 161 |
+ <span className={styles.colorChip} style={chipStyle(token)} />
|
|
| 162 |
+ ) : null} |
|
| 163 |
+ {token.literal !== null ? (
|
|
| 164 |
+ token.literal |
|
| 165 |
+ ) : ( |
|
| 166 |
+ <span |
|
| 167 |
+ className={isForeign(token.alias) ? styles.aliasForeign : styles.aliasRef}
|
|
| 168 |
+ title={isForeign(token.alias) ? "제공된 파일에 없는 컬렉션" : "참조 토큰"}
|
|
| 169 |
+ > |
|
| 170 |
+ → {token.alias}
|
|
| 171 |
+ </span> |
|
| 172 |
+ )} |
|
| 173 |
+ </td> |
|
| 174 |
+ {hasPreview ? <td className={styles.tdDemo}>{previewFor(token)}</td> : null}
|
|
| 175 |
+ </tr> |
|
| 176 |
+ ))} |
|
| 177 |
+ </tbody> |
|
| 178 |
+ </table> |
|
| 249 | 179 |
</section> |
| 250 | 180 |
); |
| 251 | 181 |
} |
--- @fox/dev-test/use-token-registry.ts
... | ... | @@ -1,58 +0,0 @@ |
| 1 | -import { useSyncExternalStore } from "react"; | |
| 2 | -import { readTokenRegistry, type TokenRegistry } from "./read-tokens"; | |
| 3 | - | |
| 4 | -// CSSOM은 React 바깥의 외부 시스템이라 `useSyncExternalStore`가 맞는 도구다 | |
| 5 | -// (effect에서 setState 하면 렌더가 연쇄된다). | |
| 6 | -// | |
| 7 | -// 토큰 **값**은 테마와 뷰포트에 따라 달라지므로 둘 중 하나가 바뀌면 다시 읽어야 한다. | |
| 8 | -// 반면 `useSyncExternalStore`는 매 렌더마다 getSnapshot을 호출하므로 매번 새 객체를 | |
| 9 | -// 돌려주면 무한 루프가 된다 — 그래서 (테마, 뷰포트 구간)을 키로 캐시해 같은 조건 | |
| 10 | -// 동안에는 동일한 참조를 반환한다. | |
| 11 | -let cached: { key: string; registry: TokenRegistry } | null = null; | |
| 12 | - | |
| 13 | -/** `@fox/styles/_root.scss`가 읽는 수동 테마 선택 속성. */ | |
| 14 | -export const THEME_ATTRIBUTE = "data-theme"; | |
| 15 | - | |
| 16 | -function snapshotKey(): string { | |
| 17 | - const theme = document.documentElement.getAttribute(THEME_ATTRIBUTE) ?? "system"; | |
| 18 | - // 폭 자체가 아니라 "반응형 토큰이 바뀌는 구간"만 키에 넣는다 — 창을 1px 줄일 때마다 | |
| 19 | - // 전체 토큰을 다시 읽으면 낭비다. | |
| 20 | - const wide = window.matchMedia("(min-width: 768px)").matches ? "pc" : "mobile"; | |
| 21 | - return `${theme}:${wide}`; | |
| 22 | -} | |
| 23 | - | |
| 24 | -function subscribe(onChange: () => void): () => void { | |
| 25 | - const observer = new MutationObserver(onChange); | |
| 26 | - observer.observe(document.documentElement, { | |
| 27 | - attributes: true, | |
| 28 | - attributeFilter: [THEME_ATTRIBUTE], | |
| 29 | - }); | |
| 30 | - | |
| 31 | - const query = window.matchMedia("(min-width: 768px)"); | |
| 32 | - query.addEventListener("change", onChange); | |
| 33 | - | |
| 34 | - return () => { | |
| 35 | - observer.disconnect(); | |
| 36 | - query.removeEventListener("change", onChange); | |
| 37 | - }; | |
| 38 | -} | |
| 39 | - | |
| 40 | -function getSnapshot(): TokenRegistry | null { | |
| 41 | - const key = snapshotKey(); | |
| 42 | - if (!cached || cached.key !== key) { | |
| 43 | - cached = { key, registry: readTokenRegistry() }; | |
| 44 | - } | |
| 45 | - return cached.registry; | |
| 46 | -} | |
| 47 | - | |
| 48 | -function getServerSnapshot(): TokenRegistry | null { | |
| 49 | - return null; | |
| 50 | -} | |
| 51 | - | |
| 52 | -/** | |
| 53 | - * 서버 렌더와 하이드레이션 첫 렌더에서는 `null`, 그 이후 토큰 레지스트리를 반환한다. | |
| 54 | - * 테마를 바꾸거나 768px 경계를 넘으면 그 조건에서 해석된 값으로 갱신된다. | |
| 55 | - */ | |
| 56 | -export function useTokenRegistry(): TokenRegistry | null { | |
| 57 | - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); | |
| 58 | -} |
--- @fox/tools/build-tokens.py
+++ @fox/tools/build-tokens.py
... | ... | @@ -48,6 +48,13 @@ |
| 48 | 48 |
raw[key] = OrderedDict(leaves(doc)) |
| 49 | 49 |
|
| 50 | 50 |
|
| 51 |
+# 참조 판별용 원본 값 — resolve_refs가 덮어쓰기 전에 보관한다. 감사 화면이 |
|
| 52 |
+# "파일에 실제 값이 적혀 있었는가, 다른 토큰을 가리켰는가"를 구분하려면 원본이 필요하다. |
|
| 53 |
+ORIGINAL_VALUE = {
|
|
| 54 |
+ (coll, path): node["$value"] for coll, table in raw.items() for path, node in table.items() |
|
| 55 |
+} |
|
| 56 |
+ |
|
| 57 |
+ |
|
| 51 | 58 |
def resolve_refs(table): |
| 52 | 59 |
"""같은 파일 안의 {a.b.c} 참조를 실제 값으로 치환한다."""
|
| 53 | 60 |
for path, node in table.items(): |
... | ... | @@ -473,6 +480,139 @@ |
| 473 | 480 |
@use "root"; |
| 474 | 481 |
""")) |
| 475 | 482 |
|
| 483 |
+# ── 감사용 매니페스트 ──────────────────────────────────────────────────────── |
|
| 484 |
+# Figma 변수 페이지와 1:1로 대조하기 위한 데이터다. SCSS/CSS는 alias를 var() 참조로 |
|
| 485 |
+# 흡수하고 light/dark를 light-dark() 한 줄로 합치기 때문에, 브라우저에서 읽으면 |
|
| 486 |
+# "원래 파일이 무엇을 적어 뒀는지"를 되살릴 수 없다. 그래서 원본 구조 그대로를 따로 낸다. |
|
| 487 |
+NAMERS = {
|
|
| 488 |
+ "primitive": name_primitive, |
|
| 489 |
+ "theme": name_theme, |
|
| 490 |
+ "size": name_size, |
|
| 491 |
+ "light": name_semantic, |
|
| 492 |
+ "dark": name_semantic, |
|
| 493 |
+ "pc": name_responsive, |
|
| 494 |
+ "mobile": name_responsive, |
|
| 495 |
+} |
|
| 496 |
+ |
|
| 497 |
+SECTIONS = [ |
|
| 498 |
+ ("light", "Light", "Mode"),
|
|
| 499 |
+ ("dark", "Dark", "Mode"),
|
|
| 500 |
+ ("pc", "PC", "Responsive"),
|
|
| 501 |
+ ("mobile", "Mobile", "Responsive"),
|
|
| 502 |
+ ("size", "Size", None),
|
|
| 503 |
+ ("theme", "Theme", None),
|
|
| 504 |
+ ("primitive", "Primitive", None),
|
|
| 505 |
+] |
|
| 506 |
+ |
|
| 507 |
+ |
|
| 508 |
+def literal_text(coll, path, node): |
|
| 509 |
+ """파일에 적힌 값을 Figma 표기 그대로 낸다 — 변수 페이지와 눈으로 맞추기 위함이다.""" |
|
| 510 |
+ value = node["$value"] |
|
| 511 |
+ if node["$type"] == "color": |
|
| 512 |
+ return css_color(value) |
|
| 513 |
+ if node["$type"] == "string": |
|
| 514 |
+ return str(value) |
|
| 515 |
+ return str(value) |
|
| 516 |
+ |
|
| 517 |
+ |
|
| 518 |
+def resolved_text(coll, path, node): |
|
| 519 |
+ """우리 단위로 변환된 최종 값. 길이는 rem, 색은 hex/rgb().""" |
|
| 520 |
+ value = node["$value"] |
|
| 521 |
+ if node["$type"] == "color": |
|
| 522 |
+ return css_color(value) |
|
| 523 |
+ if node["$type"] == "string": |
|
| 524 |
+ return str(value) |
|
| 525 |
+ if coll == "primitive" and path.startswith("font.weight"):
|
|
| 526 |
+ return str(value) |
|
| 527 |
+ return css_len(value) |
|
| 528 |
+ |
|
| 529 |
+ |
|
| 530 |
+def manifest_rows(coll): |
|
| 531 |
+ namer = NAMERS[coll] |
|
| 532 |
+ rows = [] |
|
| 533 |
+ # light/dark는 SCSS 출력과 같은 순서로 맞춘다 — 소속이 불분명한 chart-alt 29개를 |
|
| 534 |
+ # 뒤로 보내야 주요 색상이 먼저 보인다(원본 JSON에서는 이들이 루트에 먼저 온다). |
|
| 535 |
+ entries = raw[coll].items() |
|
| 536 |
+ if coll in ("light", "dark"):
|
|
| 537 |
+ entries = sorted(entries, key=lambda kv: kv[0] in CHART_ALT_GROUP) |
|
| 538 |
+ for path, node in entries: |
|
| 539 |
+ original = ORIGINAL_VALUE[(coll, path)] |
|
| 540 |
+ alias = None |
|
| 541 |
+ literal = None |
|
| 542 |
+ |
|
| 543 |
+ if isinstance(original, str) and original.startswith("{"):
|
|
| 544 |
+ # 같은 파일 안의 다른 토큰을 가리킨다. |
|
| 545 |
+ alias = namer(original[1:-1]) |
|
| 546 |
+ else: |
|
| 547 |
+ data = node.get("$extensions", {}).get("com.figma.aliasData")
|
|
| 548 |
+ if data: |
|
| 549 |
+ hit = alias_lookup.get((data["targetVariableSetName"], data["targetVariableName"])) |
|
| 550 |
+ # ⚠️ 이름만으로 매칭하면 안 된다 — 이 export에는 `theme`이라는 이름의 |
|
| 551 |
+ # 컬렉션이 둘 있고(제공된 파랑 계열, 미제공 청록 계열) 변수 경로까지 |
|
| 552 |
+ # 같다. 값이 일치할 때만 우리 토큰으로 인정하고, 아니면 우리가 갖고 |
|
| 553 |
+ # 있지 않은 컬렉션을 가리키는 것으로 표시한다. |
|
| 554 |
+ if hit and same_value(hit[1], node["$value"]): |
|
| 555 |
+ alias = hit[0] |
|
| 556 |
+ else: |
|
| 557 |
+ alias = f'{data["targetVariableSetName"]}/{data["targetVariableName"]} (미제공 컬렉션)'
|
|
| 558 |
+ else: |
|
| 559 |
+ literal = literal_text(coll, path, node) |
|
| 560 |
+ |
|
| 561 |
+ rows.append( |
|
| 562 |
+ {
|
|
| 563 |
+ "name": namer(path), |
|
| 564 |
+ "figma": path.replace(".", "/"),
|
|
| 565 |
+ "type": node["$type"], |
|
| 566 |
+ "literal": literal, |
|
| 567 |
+ "alias": alias, |
|
| 568 |
+ # 그 파일/모드에서 최종 해석된 값(우리 단위 기준). 미리보기가 현재 |
|
| 569 |
+ # 테마·뷰포트가 아니라 **그 섹션이 정의한 값**을 그려야 하므로, CSS |
|
| 570 |
+ # 변수가 아니라 이 값을 직접 쓴다. |
|
| 571 |
+ "resolved": resolved_text(coll, path, node), |
|
| 572 |
+ } |
|
| 573 |
+ ) |
|
| 574 |
+ return rows |
|
| 575 |
+ |
|
| 576 |
+ |
|
| 577 |
+manifest = [ |
|
| 578 |
+ {"id": coll, "label": label_, "category": category, "file": FILES[coll], "tokens": manifest_rows(coll)}
|
|
| 579 |
+ for coll, label_, category in SECTIONS |
|
| 580 |
+] |
|
| 581 |
+ |
|
| 582 |
+manifest_ts = """// ⚠️ 이 파일은 Figma Design Tokens export에서 **자동 생성**되었습니다. |
|
| 583 |
+// 손으로 고치지 말고 원본 JSON을 다시 export한 뒤 `@fox/tools/build-tokens.py`를 재실행하세요. |
|
| 584 |
+// |
|
| 585 |
+// Figma 변수 페이지와 대조하기 위한 데이터입니다. CSS는 alias를 var() 참조로 흡수하고 |
|
| 586 |
+// light/dark를 light-dark() 한 줄로 합치므로, 브라우저에서 읽어서는 "원본 파일이 무엇을 |
|
| 587 |
+// 적어 뒀는지"를 되살릴 수 없습니다 — 그래서 원본 구조를 그대로 담습니다. |
|
| 588 |
+ |
|
| 589 |
+export interface ManifestToken {
|
|
| 590 |
+ /** 우리 CSS 변수 이름에서 `--fox-`를 뗀 것 */ |
|
| 591 |
+ name: string; |
|
| 592 |
+ /** 원본 JSON에서의 경로 (= Figma 변수 이름) */ |
|
| 593 |
+ figma: string; |
|
| 594 |
+ type: "color" | "number" | "string"; |
|
| 595 |
+ /** 파일에 값이 직접 적혀 있을 때만 채워진다 */ |
|
| 596 |
+ literal: string | null; |
|
| 597 |
+ /** 다른 토큰을 가리킬 때만 채워진다 */ |
|
| 598 |
+ alias: string | null; |
|
| 599 |
+ /** 그 파일/모드에서 최종 해석된 값 — 미리보기 전용(현재 테마·뷰포트와 무관) */ |
|
| 600 |
+ resolved: string; |
|
| 601 |
+} |
|
| 602 |
+ |
|
| 603 |
+export interface ManifestSection {
|
|
| 604 |
+ id: string; |
|
| 605 |
+ label: string; |
|
| 606 |
+ /** 사이드바 상위 분류. 없으면 최상위 항목이다. */ |
|
| 607 |
+ category: string | null; |
|
| 608 |
+ file: string; |
|
| 609 |
+ tokens: ManifestToken[]; |
|
| 610 |
+} |
|
| 611 |
+ |
|
| 612 |
+export const TOKEN_MANIFEST: ManifestSection[] = """ + json.dumps(manifest, ensure_ascii=False, indent=2) + ";\n" |
|
| 613 |
+ |
|
| 614 |
+written.append(write("../dev-test/token-manifest.ts", manifest_ts))
|
|
| 615 |
+ |
|
| 476 | 616 |
print("생성된 파일:")
|
| 477 | 617 |
for w in written: |
| 478 | 618 |
print(" ", w)
|
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?